Web-based controller for iPixel LED Displays Based on: https://play.google.com/store/apps/details?id=com.mobilesg.pixelcolor&hl=en_US
  • JavaScript 56.5%
  • Python 43.3%
  • Shell 0.2%
Find a file
2026-10-26 17:58:09 -04:00
.openai Update Site source 2026-10-25 03:05:55 -04:00
dist Add rainbow text colors and Creative Text animation styles 2026-10-26 14:57:00 -04:00
docs Add native and rendered text to the Python automation CLI 2026-10-26 17:25:43 -04:00
fixtures Add rainbow text colors and Creative Text animation styles 2026-10-26 14:57:00 -04:00
python Record Fedora acceptance and explain CLI startup latency 2026-10-26 17:58:09 -04:00
tests Clamp BlueZ write allowances instead of rejecting connection 2026-10-26 17:48:01 -04:00
tools Add native and rendered text to the Python automation CLI 2026-10-26 17:25:43 -04:00
.gitattributes Add checksum-based rsync deployment for Git Bash 2026-10-25 04:46:08 -04:00
.gitignore Update Site source 2026-10-25 03:05:55 -04:00
deploy.sh Add checksum-based rsync deployment for Git Bash 2026-10-25 04:46:08 -04:00
ipxel-protocol.md Add confirmed single and selected-slot deletion from APK protocol 2026-10-26 01:36:58 -04:00
plan.md Record Fedora acceptance and explain CLI startup latency 2026-10-26 17:58:09 -04:00
README.md Add native and rendered text to the Python automation CLI 2026-10-26 17:25:43 -04:00
serve.mjs Add stored-screen controls, size-aware previews and APK clock references 2026-10-25 08:30:38 -04:00

iPixel Browser Controller

Protocol correction: 0x8003 is the APK's erase-all command, not “default display.” Ordinary command batches block it, and playback/media uploads never send it. It is available only through the separate confirmed Erase all action described below. Earlier releases could erase stored media while preparing normal operations. The user confirms GIF upload and restart persistence on the owned 32×32 panel, and reports corrected slot playback appears to work.

A standalone HTML, CSS, and JavaScript controller for Bluetooth LED panels using the iPixel Color protocol. The current prototype was developed from an Android Bluetooth capture of a VAETTLO 32×32 panel and existing open-source protocol references.

The browser connects directly to the nearby panel using Web Bluetooth. There is no application backend, Python helper, vendor account, or runtime dependency.

Try the prototype

Open the hosted controller.

  1. Turn on your panel and enable Bluetooth on your phone or computer.
  2. Close the iPixel Color app so it releases its connection.
  3. Open the page directly in a compatible browser, rather than an embedded preview.
  4. Click Connect panel and select the LED panel in the browser's device chooser.

Connection synchronizes the panel's time and queries device information. It does not change brightness, power, or display mode until you use those controls. Pairing through the operating system's Bluetooth settings is not normally needed for this BLE connection.

Choose 32×32 demo or 64×64 unverified, then use Try a demo to explore the controls without hardware. Demo replies are simulated; they do not establish physical-device compatibility.

The power switch starts On, so its first toggle requests Off. Connecting does not send an implicit power command. Clock previews use the profile-specific artwork from the supplied APK and are labeled as static references with sample times. Unknown faces keep an explicit unavailable notice.

Reconnecting after reload

After a successful connection, the app remembers the selected device ID locally. Reconnect last panel when this page opens enables one automatic attempt on the next page load, using the existing browser permission; only time/info initialization runs. Disable the checkbox to keep a one-click Reconnect panel shortcut instead. Choose another panel opens the browser chooser explicitly. No upload, erase or previous display action is replayed, and dropped/uncertain writes never trigger automatic reconnection.

Reload restoration requires navigator.bluetooth.getDevices() and retained site permission. Support varies by browser/platform and may require Chrome's Experimental Web Platform features flag; persistent permissions also depend on the browser's permissions backend. The UI detects the actual API and explains the fallback. See the Web Bluetooth implementation status. Without restoration support, the normal chooser still works; disconnect/reconnect within the same page can reuse the device object. This reconnects the GATT session, not an OS Bluetooth bond.

Only the saved permitted device is selected automatically. If there is one previously permitted device but no saved selection, the app offers a manual shortcut first. Missing/revoked permissions or multiple ambiguous devices require the chooser. A remembered connection attempt times out after 12 seconds; no background retry loop runs. The user confirms remembered reconnect works in their browser; other browser/platform combinations remain unverified.

Use Forget panel for connection recovery. It immediately disconnects/cancels the current session, clears the remembered identity and forces the next connection through the chooser. When available, it calls the browser's BluetoothDevice.forget() to remove this site's device permission. Unsupported/failed permission removal gets an explicit browser-settings fallback. It preserves auto-reconnect preference, local drawings/slot lists and all panel content; it never sends a panel erase. An explicit reset marker prevents a surviving sole browser grant from restoring the shortcut after reload. Pending connection work no longer holds the UI busy until its timeout; late completion cannot replace a newer device session.

This resets this site's browser connection/permission, not the phone's connection or an OS Bluetooth bond. Close iPixel Color or disconnect it on the phone first. If the panel stays busy, power-cycle it before using the fresh chooser.

Device dimensions and future 64×64 support

The framebuffer, addressing, rotation and preview renderer take explicit width and height. Tests cover 32×32, 64×64 and a rectangular 96×16 buffer; real content preparation must reject unknown dimensions instead of using the initial illustrative grid as a default upload size.

Model byte Dimensions Evidence
0x81 32×32 Target capture and original protocol references.
0x80 64×64 Documented mapping and APK-specific artwork; hardware behavior is unverified because we do not own this version.

Storage limits, fonts, firmware behavior and native clock capabilities are not inferred from dimensions. The two square profiles have separate nine-face reference sets from iPixel Color 3.7.9. These images show sample times and do not animate or read back the panel's clock. See APK notes.

Implementation roadmap

Core remaining work is physical Python CLI acceptance, browser firmware-native layout/motion previews and offline/release polish. Python core controls, native/custom static/scroll text, still-image/GIF preparation/upload and in-memory render/transfer functions are implemented; physical Python BLE and selected-slot deletion verification are pending. Python is an automation CLI and does not need browser drawing/editor/library/composer parity. Browser text includes eight rainbow/gradient choices, twenty generated animation styles and six-item Picture Text sequences; Saved library keeps reusable text/media/sequence copies across reloads with portable backups. Built-in video conversion and Rhythm/audio visualization are skipped for both clients; see scope decisions. Live camera/screen streaming and custom firmware remain separate optional projects.

Built-in clock faces run in the panel firmware; the app sends a style selection. The APK has vendor OTA paths, but custom firmware acceptance for our board is unverified. An early iPixel-CFW effort is a research reference rather than a ready firmware for this VAETTLO panel. Custom host-rendered clocks could use existing pixel/image transfers without flashing, with the Python/ESP32 host remaining active. Full square-panel rotation mapping also needs follow-up: the APK has quarter-turn values while the current control follows an older boolean reference.

See plan.md for the full protocol coverage, discrepancies to verify, feature phases and hardware acceptance checks. The standalone Python client provides core controls, text --mode native|static|scroll, PNG/JPEG/still WebP and GIF preparation/export/upload, using identified dimensions, raw RGB, explicit slot replacement and CRC-verified transfer windows/cached GIF completion. Offline --prepare-only --size WIDTHxHEIGHT needs no Bluetooth. In-memory render/transfer functions support later automation without temporary files. Python is a separate CLI/client, not a required browser backend or a port of the visual editors. Fedora-first/Windows physical BLE acceptance is pending. The upstream review explains pypixelcolor compatibility and native Rhythm inputs.

Browser support

Environment Requirements
Android Chrome with Bluetooth enabled.
Windows Chrome or Edge, with a working BLE adapter.
Linux Chrome/Chromium with a working system Bluetooth stack. Web Bluetooth may require enabling Experimental Web Platform features at chrome://flags/#enable-experimental-web-platform-features and relaunching the browser. Availability depends on the browser build and system setup.
macOS Chrome provides Web Bluetooth; this prototype has not been tested on macOS.
Firefox / standard Safari Direct Web Bluetooth is unavailable. The demo remains usable.

The app checks whether Web Bluetooth is exposed; it does not restrict access by operating system. Linux setup follows Chrome's Web Bluetooth documentation.

Bluetooth requires a secure context: use the hosted HTTPS page or localhost. Opening an ordinary HTTP address on your LAN is not equivalent to localhost on the browsing device.

Run locally

With Node.js installed, from this repository's root:

node serve.mjs

Open http://localhost:8080 on the same computer. The included development server only serves files from dist/; Bluetooth access and command processing happen in the browser. Any suitable static HTTPS host can serve dist/ in production.

For Android testing, use the hosted HTTPS page. A phone's localhost refers to the phone, not the computer running the development server.

Current controls

  • Connect and disconnect, with panel notifications.
  • Display power and brightness.
  • Normal/180° orientation.
  • Local time synchronization, custom date/time entry and optional resynchronization every minute while the page is active.
  • Nine APK-derived clock-style choices with profile-specific static layout references.
  • Device-information queries.
  • A connection journal with raw command/reply bytes and a local log download.
  • Model/firmware-byte reporting, atomic multi-command operations, byte progress, and cancellation.
  • Existing-slot playback, an ordered/enabled playlist editor, and per-panel drafts saved in browser storage.
  • 32×32/64×64 demo modes and a dimension-aware canvas preview.
  • Pixel editor with pencil, eraser, flood fill, color picker, clear, undo/redo and separate 32×32/64×64 drafts.
  • Explicit temporary DIY drawing, opt-in live drawing, and local exact-size PNG import/export.
  • Still-image fitting, rotation/mirroring, background compositing and raw RGB transfer from PNG/JPEG/still WebP inputs or the editor drawing.
  • GIF validation, animated preview, automatic fitting to panel dimensions, prepared-file download and temporary/program-address upload.

The preview is a local illustration, not telemetry or a screenshot from the panel. A completed Bluetooth write does not prove that the display changed. The journal distinguishes writes, replies, and missing replies; check the physical panel when testing.

Static text uses the raw RGB image path; scrolling text uses generated GIFs; native text sends glyph records and effect settings for the panel to animate. Background automation remains on the plan. The user confirmed temporary raw RGB display, GIF playback/restart persistence, static text, explicit erase and remembered reconnect on the owned 32×32 panel. Native text/effects, adjusted GIF timing, fractional scales and scrolling direction/speed still need physical checks.

Known rejection statuses fail the operation. Missing required replies, ambiguous replies, partial write failures, and cancellation after transmission begins end the connection so a stale reply or partial frame cannot corrupt the next operation. The controller does not automatically retry an action that may already have happened on the panel. Explicitly rejected commands leave a usable connection open.

Slots and playback order

Play slot requests a one-entry 0x8008 playlist using a byte-sized slot address (1–255). Both single-slot and playlist playback exit temporary DIY with 0x0104/0, then send the playlist. Both commands require classified replies. The erase-all command 0x8003 is blocked; its previous use here was a bug. The user reports the corrected playback works on the owned 32×32 panel. The reference-only 0x8007 screen builder remains available for protocol research but is no longer used by the UI after target-panel playback was reported ineffective.

Playback order is one current list of panel slot addresses, not multiple saved playlists. Each address appears once. Add slot adds an address; for an existing address the button becomes Rename slot and changes its local name. Check the slots to include, move them with the arrows, then press Play selected slots to send their addresses in order using 0x8008. Forget removes only the browser row. These edits do not upload or erase panel content. Upload images/GIFs at a program address using the upload sections below; completion adds or updates that slot’s row. Names/order stay local, and populated slots cannot be read back.

Actual populated slots and capacity remain unknown. Slot 1 appears in the target capture; an accepted playback reply does not establish whether that slot still has content. Address 101 is used for temporary DIY content in the capture; 111–119 are described as remote-control destinations. No verified inventory/readback query has been added. Drafts are stored per selected panel and browser origin, with an in-memory fallback if storage is unavailable. They are explicitly local metadata, not a device inventory. The user reports corrected playback works; exact multi-slot order and capacity remain separate checks.

Custom clock inputs use local wall time in the browser's time zone. Manual date/time disables periodic sync. Automatic sync pauses when the page is hidden and stops on disconnect; it does not provide background automation after the page closes.

Pixel editor

Edits stay local until Send drawing or Live drawing is selected. While disconnected, choose a 32×32 or 64×64 draft; connecting selects the identified panel's dimensions. Switching sizes retains separate drafts and undo histories for this page session. Export a PNG to keep a drawing after closing the page.

Use Pencil for one pixel or Brush for a square footprint. Brush/eraser sizes are 1, 2, 3, 5, 8 or 16 pixels; strokes interpolate and clip at panel edges, with one undo step per stroke. Flood fill remains separate. Choose color opens an in-app dialog containing the native color input, a hex field and Pick from drawing. Sampling a pixel changes only the chosen color; Cancel/Escape stops sampling. The native browser color dialog itself is not modified.

Flood fill replaces a connected four-neighbor region using the original clicked color. Tolerance defaults to 12 per RGB channel to include small imported-image differences; choose 0 for exact RGB matching. Transparent/erased pixels match black, as displayed by the panel. Every candidate compares to the original seed, so a gradient cannot gradually carry the fill into unrelated colors. The UI reports matched/changed pixels, and Undo restores the original RGBA data as one action. This changes the prior exact-RGBA rule, which split opaque black from erased pixels and separated nearly identical image colors.

Editor import accepts PNGs at the exact draft dimensions, with a 5 MiB browser resource guard; use the still-image uploader for arbitrary image fitting. Edit prepared image copies the current fitted/transformed still image into the matching editor draft, stops live drawing and adds an Undo step. It works before or after sending that image. Use editor drawing copies back in the other direction. Both transfers remain local until explicit Send/live drawing. Export preserves the canonical RGBA pixels; transparent colors composite onto black for panel drawing.

Send drawing enters DIY mode and clears the current display, then sends the requested colors and coordinates. It does not save a storage slot. Exit DIY requests a return to the previous content. The mode acknowledgements are captured; drawing layout is derived from the supplied APK and still needs a physical check. Start with Corner test pattern: red top left, green top right, blue bottom left, white bottom right and an asymmetric orange marker. Check color order and orientation on the panel; 64×64 hardware remains unverified.

Live drawing is off by default. It sends an initial snapshot, then groups changed coordinates by color and keeps only the latest pending snapshot while a batch is in flight. Turning it off discards pending edits; an in-flight batch can finish. Cancel interrupts a running transfer and disconnects so a partial frame cannot affect the next session. Other panel controls stop live drawing. Automatic clock resync pauses during live drawing, and disconnecting or closing the page stops it.

Pixel packets do not expect a reply. A completed write is a request, not proof of the physical result. The editor works with the panel's logical coordinates; the main preview applies the requested display flip separately. Unknown panel dimensions block transmission. See APK drawing findings and the language-neutral drawing fixtures.

Still-image upload

Choose a local PNG, JPEG or still WebP, or press Use editor drawing. Fitting and conversion stay in the browser. Choose contain, cover/crop or stretch; rotate clockwise, mirror either axis, choose the background and optionally smooth scaling. The pixel preview and exported prepared PNG use the same opaque framebuffer. The default nearest-neighbor option preserves pixel art; smoothing blends premultiplied alpha before compositing on the background.

Inputs are limited to 5 MiB and 16 megapixels before bitmap decoding. Animated PNG/WebP and unsupported formats are rejected rather than silently reduced to a first frame. JPEG EXIF orientation is applied by the browser. Unknown panel dimensions block upload, while offline previews can use either square size. Connecting or changing the identified model rebuilds the output at its dimensions.

Temporary display uploads to address 101. Program slot accepts an explicitly chosen address from 1–100, following reference program ranges without claiming the target's actual capacity. It requires the Replace this slot and make it the active one-slot program checkbox. This can replace content at that address and changes the active playlist; it does not merge the existing active list. Consent resets after upload, input/transform/address changes, or a changed panel session. A local bookmark is added only after file-complete acknowledgement.

The upload exits DIY, declares the one-slot playlist for a program destination, then sends row-major RGB triplets using 0x0002, total raw-pixel length/CRC32, mode 0 and the selected address. A 32×32 frame is 3,072 bytes; 64×64 is exactly 12,288 bytes (one complete window). PNG headers, scanline filters and zlib data are not transmitted. First marker is 0, continuations are 2; replies must be 1 for continuation and 3 for completion. Raw payload order follows the APK's BGRUtils.bitmap2RGB and the ESPHome transmitter. The initial PNG-file payload was acknowledged by this panel but did not render; see the format correction. The eight-second reply timeout follows the APK's image callback timeout. The existing transport handles BLE chunks, byte progress and cancellation.

The default raw-payload limit is 64 KiB, configurable from 1–1024 KiB as a client resource guard rather than a firmware specification. Cancel interrupts and disconnects; reconnect and explicitly retry the whole image if necessary. An interrupted transfer creates no new bookmark. No automatic retries are performed.

Physical acceptance remains open: retest corrected raw RGB display first, then an explicit program address and playback after reconnect/power cycle. The user has not uploaded saved app content, so previous slot playback has no populated-slot baseline yet. Neither simulated replies nor a file-complete acknowledgement alone establishes correct physical pixels or persistence. The 64×64 version remains hardware-unverified.

Manual notification mockup

Open the notification pixel mockup to compare D/S badges, small logo sketches and full app names at 32×32 or 64×64. Change the two counts and download a panel-sized PNG for the still-image uploader. The user prefers the pixel-icon layout; the alternatives remain available for comparison. The page has no Bluetooth or notification access; actual notification integration remains deferred until the web/Python clients are finished, Fedora first and Windows later.

GIF upload

For video, convert the desired clip to GIF in another application first, then import it here. Choose trimming, framing and frame rate in that converter. Built-in video conversion and Rhythm/music visualization are intentionally outside the browser/Python client scope; see scope decisions.

Choose a local GIF87a/GIF89a file. Matching dimensions at 1× speed preserve the original bytes, including palettes, transparency, disposal, frame delays and loop metadata. Other dimensions are resized locally with contain, cover or stretch, composited onto black, and re-encoded. The animated preview decodes the prepared file, so it shows the transmitted palette. Frames with more than 256 colors use a deterministic reduced palette, reported before upload. Download the prepared GIF to keep it. There is no GIF editor.

Preparation runs in a cancellable local worker using the bundled MIT-licensed omggif codec, without a CDN or vendor service. Preview playback pauses when the page is hidden. Delays below 20 ms use 100 ms in the preview; original delay values remain in the file and panel timing may differ. Finite loop counts stop after their declared repeats; Restart replays them.

Temporary display uses address 101. Program uploads use addresses 1–100 and the same explicit replacement/one-slot-program choice as still images. Upload progress covers all windows; cancellation during transmission disconnects. No automatic retry is performed. A local slot bookmark is created only after file-complete acknowledgement.

The panel's maximum GIF file size is unknown. APK 3.7.9's inspected GIF sender splits the whole file into model-dependent windows and handles a device “insufficient space” reply; no numeric maximum was found in the inspected import/encode/send paths. Its 12 KiB window for the square models is not a total-file limit. The original capture contains a 200,183-byte, 250-frame GIF; this is an observed size, not maximum capacity or a new browser hardware test. See APK size-limit findings.

The adjustable prepared-file guard defaults to 512 KiB (1–2048 KiB). Other client guards are 5 MiB input, a 2 megapixel source canvas, 500 frames, 64 megapixels of cumulative decode work and 32 MiB of preview pixels. These protect browser resources and upload duration; they do not claim panel capacity. Invalid/truncated GIF blocks, palettes, frame rectangles and LZW data are rejected before any BLE writes. The 64×64 workflow is covered by synthetic checks and remains hardware-unverified.

Choose Playback speed (0.25×, 0.5×, 1×, 2× or 4×) before uploading. This changes frame delays in the prepared file, preview and download; re-upload to change the panel speed. Matching-size files retain their compressed pixels and palettes. The GIF89a specification stores delays in hundredths of a second. Adjusted delays round to 10 ms units and are bounded to 20–655,350 ms, with any clamping reported. Zero/unspecified delays use a 100 ms baseline when retiming; 1× leaves them untouched. GIF87a headers are upgraded to GIF89a when control extensions are added. Firmware speed limits may differ; adjusted-speed hardware checks remain open.

The second Android capture confirms a cached GIF can return file-complete after its first window. The controller skips remaining windows only for a contiguous GIF group with matching destination/metadata, exact total length and verified CRC32. Other premature completions still fail. This does not prove independent slot recall without resending media.

Text and scrolling

The text section renders locally with an original MIT-licensed 3×5 bitmap font, including versioned glyph/row metadata for the future Python client. It supports uppercase A–Z, digits and basic punctuation; ASCII lowercase becomes uppercase. Unsupported characters are reported rather than substituted. No OS font, remote font service or vendor server is used.

Choose scale, colors, alignment/placement, letter/line gaps and optional character wrapping. Static overflow is clipped to the panel area and reported. Static preview, PNG export and raw RGB upload share the same pixels. Offline preparation supports 32×32 and 64×64; transmission requires identified dimensions. Temporary address 101 and explicitly consented program addresses 1–100 follow the existing media workflow. Mixed composition remains on the plan; 64×64 hardware is unverified.

Choose Scrolling text · GIF, then left/right/up/down and 1–50 pixels/second. The browser generates a looping GIF in a cancellable local worker; preview, GIF download and upload use its decoded frames. Playback continues on the panel without the page after a successful upload. Horizontal scrolling keeps explicit line breaks and disables wrapping; vertical scrolling honors wrapping. The preview reports effective speed after rounding to GIF centiseconds and warns about clipping across the non-scrolling axis. Pause/restart affects only the preview; change speed and upload again to change the panel. The main panel preview is labeled as a sample frame.

Generated text is limited to 500 frames, a 512 KiB file and 32 MiB of preview pixels. These are client guards, not measured firmware capacities; shorten the text or reduce scale/spacing if it exceeds them. All 177 JavaScript tests pass. Real Chrome worker/export checks compare all 82 sample frames with native GIF decoding and confirm desktop/mobile layouts without overflow or runtime errors. Physical scrolling direction and speed remain an acceptance check.

Text scale includes 1.5× and Custom (0.5–8×, including values such as 1.3). Glyph dimensions round to whole pixels and use nearest source-pixel centers, so 1.3× becomes 4×7 and 1.5× becomes 5×8. The preview reports the effective size; it does not add antialiasing or promise subpixel LEDs. The user has confirmed the original static text workflow on the owned 32×32 panel; new fractional sizing remains a physical acceptance check.

Choose Native text · panel effects for Fixed, Move left/right, Up/Down, Blink, Breathe, Snowflake or Laser. The controller sends the bundled font as 1-bit glyph records through opcode 0x0100; the panel animates it. It does not send UTF-8 or generate a GIF for this mode. Choose 16- or 32-pixel cell height on the 32×32 profile; the future 64×64 profile also exposes 48/64. These are protocol cell sizes; our 3×5 glyphs scale and center inside half-width cells, rather than reproducing the app's Simsun font. Image/GIF text retains custom scaling.

Native speed is the app's 0–100 value (default 80), with no claimed pixels/second conversion. Solid foreground/background and alignment are supported. Custom letter/line gaps, wrapping controls and explicit newlines belong to image/GIF text; native newline encoding is not verified. Native previews and exported PNGs are labeled static glyph samples, not firmware effect/layout previews. Transmission is enabled only for the identified 0x81/0x80 square families; dimensions alone do not authorize native text for another model.

The normal Text vertical-alignment encoding is corrected to 0/1/2: the app subtracts 3 from its internal UI IDs before sending. The earlier controller sent the UI IDs directly. All 206 JavaScript tests pass, and Chrome verifies uploads for all nine alignment combinations against independent Python packet/CRC goldens. For physical verification, send F at native cell height 16, effect Fixed, and compare Top/Middle/Bottom and Left/Center/Right. A 32-pixel cell fills a 32×32 panel vertically, so its vertical settings cannot visibly shift that cell. Effect-specific placement still needs a physical check.

Text color style offers Solid plus eight rainbow/gradient choices. Native text sends the app's color-style ID for the firmware to render; its local color sample is approximate and static. Image/GIF text bakes the displayed gradient into its pixels. The solid-color picker becomes available again when Solid is selected. New settings are retained in saved presets and portable backups. Native rainbow rendering still needs physical verification; see color mapping and evidence.

Normal Text's nine native modes are separate from generated animation presets. Choose Animated text · GIF presets for the original eight Picture Text styles (Zoom in/out, Spin, Reveal from left, Drop from top, Stretch from top, Reveal from top and Type on) or twelve Creative Text styles (Bounce, Printer, 3D turn, Wave in, Rotate and scale, Clock flip, Rotate in from right/left, Particles, Middle split, Expand and shrink and 3D flip). Writing direction switches between horizontal and upright vertical lettering. Frame delay controls speed, and each completed text page holds for 1/2/3/5/10 seconds. Styles use our bitmap font and approximate Android font rasterization/projection; they are not exact app animation replicas. The native preview stays a static glyph/color sample; generated GIF previews play the actual encoded frames. Exact firmware-native animation preview and timing remain unfinished.

If a text send button is disabled, its blocking reason is shown immediately beside it: invalid content, preparation, connection/identification, model support or program replacement consent. The default sample has two lines, which native text currently rejects; Join lines for native text explicitly turns newlines into spaces without sending anything. “Current settings have not been uploaded” is informational, not a disable condition. The user's exact reported native-button case still needs confirmation; the default-line-break case is reproduced and recoverable in browser tests.

Latest recovery/readiness checks: 197 JavaScript tests pass. Chrome checks the native default-sample reason/join/send flow, a mocked device-permission reset, preservation of unrelated storage, fresh chooser routing and reload suppression. Desktop/mobile screenshots are reviewed with no overflow or runtime errors. Actual Bluetooth handover from the phone remains a physical acceptance check.

All 190 JavaScript tests pass, including captured native packets, independently calculated glyph bits/CRC, strict upload stages, cancellation and missing replies. The new encoder reproduces all 82 text commands from the two effect captures byte-for-byte. Chrome verifies the actual demo TX packet against an independent Python golden and decodes the sample PNG with matching pixels; desktop/mobile layouts have no overflow or runtime errors. Native physical appearance, alignment, speed, slot retention and 64×64 hardware remain acceptance checks. Native text uses an APK-derived 100 KiB client payload guard, not a measured storage capacity; no uncertain write is retried automatically.

Picture Text sequences

Prepare content in Text, Image upload or GIF upload, then use Make a sequence → Add prepared content from → Add item. Up to six snapshots can mix native text, RGB still images and GIFs at matching dimensions. Select a row to preview, use arrows to reorder, and give the sequence a title. Source edits do not change added snapshots.

Choose the first program address and allow replacing the complete consecutive slot range before uploading. The controller declares one ordered program and uploads each item to its own address; it does not flatten the complete sequence into a single GIF or send erase-all. Six items is the app editor limit, not a measured firmware maximum. The panel controls sequence timing; local preview plays the selected GIF or shows a static/native glyph sample. Actual mixed playback/order/timing/retention still need a physical check.

Download project preserves the title and prepared media in a portable .ipixel.json file; Open project JSON validates/restores that local draft without transmitting. Drafts are not saved automatically, and destination/overwrite authorization is not restored from files. Client guards: 8 MiB project input, 512 KiB per media item, plus existing GIF/native/RGB limits. A different-size connected panel blocks upload while keeping the draft available for download. See implementation and usage details.

Validation: 228 JavaScript tests pass. Chrome runs all eight real workers/exports, compares every sample frame with native GIF decoding and verifies a six-item project round trip and actual demo upload bytes, slots 1–6, one program declaration and no erase. Independent Pillow checks match 384 frames, timing and loop metadata across all presets at 32×32/64×64. Desktop/mobile layouts are reviewed without overflow or runtime errors. Mixed physical playback/order/timing and 64×64 hardware acceptance remain open.

Saved browser library

In Saved library → Keep your favorites, choose current Text, Image upload, GIF upload or the complete Picture Text sequence, enter a name, then Save new. Select a saved row to preview it. Load into editor restores an editable draft; review and upload using that section's existing button. Add output to sequence adds a saved text/media snapshot directly to the current sequence draft.

Text copies retain the bundled-font settings and prepared output. Media copies retain prepared pixels/GIF bytes rather than the original full-resolution source. Sequences retain all item bytes and order. Text and individual media can be rebuilt/resized for the current panel; saved sequences keep their dimensions. Loading resets destination/slot overwrite consent and sends no Bluetooth commands. Update selected replaces only the chosen browser copy; Rename selected keeps its content. Delete local copy affects neither panel storage nor existing editor drafts.

IndexedDB stores the library in this browser for this site's origin. There is no account or backend. Clearing site data removes it; changing browser, hostname or HTTP/HTTPS origin uses a different library. Download current, Download selected and Export library create versioned .ipixel-library.json backups. Import library backup validates everything before adding new copies, preserving existing items. Backups exclude device IDs, panel slot assignments and overwrite authorization. Original Picture Text .ipixel.json files still open in the sequence editor.

Client guards: 100 saved items, 2 MiB per individual prepared media file, six items/512 KiB per item in sequences, and 32 MiB per backup. Browser quota can be lower. Failed writes/imports roll back entirely, and stale edits from another tab require a refresh. If browser storage is unavailable, Download current still provides a file backup. Drafts are saved only when requested; editing after a save does not update that copy automatically. These storage/file guards do not establish firmware capacity.

Validation: 233 JavaScript tests plus isolated Chrome checks cover byte/recipe backups at 32×32 and 64×64, real IndexedDB save/update/rename/delete/reload, editor restoration, mixed sequences, duplicate-safe import, quota rollback, atomic batch import, concurrent-tab revision checks and the 100-item cap. Local library actions emit no panel commands. Desktop/mobile layouts were reviewed with no overflow or runtime errors.

Delete selected panel slots

Under Play content already on the panel → Delete stored slots, use Delete slot N… for the address above, or check Playback order rows and choose Delete selected slots…. Review the connected panel name and exact addresses, then type DELETE SLOTS to confirm. Changing the address/list or session cancels confirmation. Only distinct program addresses 1–100 are exposed; empty requests, temporary/reserved addresses and duplicates are rejected.

This sends one APK-backed 0x0102 request and never substitutes Erase All. Only an accepted reply removes those addresses from the current panel's local labels/order. Other slots, browser library copies and editor/sequence drafts are kept. Missing, rejected or unexpected replies retain local labels and never retry automatically; check the panel before an explicit retry. Forget remains a browser-label-only action.

The original RE document mentioned this command but had a header-length error, now corrected. None of the four supplied recordings contains selected-slot deletion; the original recording does contain Erase All. The one-byte 0x01 success convention and physical deletion behavior still need a hardware check. Before relying on it, test deleting one of two replaceable known slots and verify the other still plays at its original address. See packet/evidence and validation details.

Erase all panel content

The separate storage section names the selected panel and requires typing ERASE ALL before enabling confirmation. The action sends only 04 00 03 80, waits up to eight seconds for its classified reply, and never retries automatically. Confirmation expires when the connection/session changes or another action starts. Normal send/sendBatch calls continue rejecting this opcode; the explicit transport action alone authorizes one isolated erase packet.

After an accepted reply, the current panel's local slot names/order are cleared and the preview reports the accepted erase. Missing/rejected replies or interruption retain those local records; the panel may already have acted, so check it before an explicit retry. Demo mode simulates the action. The user confirmed erase-all works on the owned 32×32 panel; other firmware remains unverified. Forget remains a local-only row removal.

Deploy with Git Bash

Run from the repository root:

./deploy.sh --dry-run  # Preview the changes
./deploy.sh            # Upload changed files

The script uses rsync over SSH to upload the contents of dist/ to root@sea.sonicboxes.com:/var/www/html/led32/. It also works when called from another directory. Checksums skip unchanged file contents even if local timestamps changed. Remote files are not deleted. SSH uses your existing keys and configuration; rsync must be installed in Git Bash.

Only the static application is uploaded. Source notes, tests, Git metadata, and Bluetooth captures are outside the deployment directory.

Validate

Run the capture-backed protocol tests and simulated Bluetooth transport tests:

node --test

The suite covers shared golden packets, CRC32 and GIF framing, accepted/rejected/malformed replies, atomic batches, cancellation and recovery, frame reassembly, discovery, demo isolation, editor history/coordinates, PNG CRCs and decoded RGBA, bounded live drawing, and UI interactions. UI tests use a minimal DOM boundary and do not replace visual browser QA. New physical display behavior still requires target-panel validation.

An optional development tool summarizes the original capture without dumping payloads or device addresses. It requires Python and Wireshark's tshark; neither is required by the browser app:

python -I tests/test_inspect_capture.py
python tools/inspect_capture.py /path/to/btsnoop_hci.log

The original trace currently reassembles into 272 commands with no trailing bytes. All five GIFs and all three text uploads pass whole-file length and CRC checks. The fixtures/ manifest, packet JSON and tiny media assets are language-neutral so the future Python client can run the same parity checks.

If connection or a command fails, use Save log in the UI and retain the output for diagnosis. Logs are downloaded locally; the app does not upload them.

Source layout

dist/
  index.html       Controller interface
  style.css        Responsive styling
  app.mjs          UI behavior and illustrative preview
  protocol.mjs     Packet builders and notification framing
  bluetooth.mjs    Web Bluetooth connection and serialized transport
  profiles.mjs     Model dimensions, evidence and unknown capability metadata
  framebuffer.mjs  Dimension-independent RGBA pixels and rotation
  preview.mjs      Size-aware canvas rendering
  library.mjs      Local playlist schema and per-device storage
  library-ui.mjs   Screen/playlist controls
  time.mjs         Strict local date/time parsing
  editor.mjs       Local RGBA editing, strokes, fill and undo/redo
  editor-ui.mjs    Pointer/touch controls and temporary DIY actions
  drawing.mjs      Grouped pixel packets and bounded live drawing
  png.mjs          Local PNG encoding and exact-size browser import
  image.mjs        Framebuffer fitting, alpha compositing and upload preparation
  image-source.mjs Input header/size validation and native local image decoding
  image-ui.mjs     Still-image workflow, explicit destination and completion UI
  gif.mjs          GIF validation, composition, resizing and upload preparation
  gif-worker.mjs   Cancellable local preparation worker
  gif-player.mjs   Preview timing and finite/infinite loop playback
  gif-ui.mjs       GIF controls and upload workflow
  text.mjs / text-ui.mjs  Deterministic static text layout and RGB upload
  fonts/           Original MIT bitmap glyph JSON, metadata and license
  maintenance-ui.mjs  Session-bound typed erase confirmation
  reconnect-ui.mjs Remembered permissions and one-attempt reload reconnect
  vendor/          Pinned MIT GIF codec, license and provenance
  clock-references.mjs / .json  Validated APK layout references
  checksum.mjs     Whole-file CRC32
  media.mjs        Captured GIF application-window framing
  sources.html     Protocol references and engineering notes
tests/             Node.js protocol and transport tests
fixtures/          Shared protocol and media parity data
tools/             Optional development-only capture inspection
serve.mjs          Optional static development host
ipxel-protocol.md   User-provided protocol reference
.openai/hosting.json  Optional Sites preview configuration

The original Bluetooth capture, complete APK bundle and generated decompiled Java are not included in this repository. Selected user-provided clock reference artwork is embedded in dist/clock-references.json with its APK provenance; all other analysis remains outside the repository.

Protocol and references

The supplied protocol notes are retained as reference material. Captured device traffic and verified tests take precedence where documentation differs from this panel's behavior.

This is the iPixel Color protocol, which should not be confused with the iDotMatrix protocol used by other LED panels.

The captured panel exposes service 0x00FA, write characteristic FA02, and notification characteristic FA03. The controller also requests permission for service 0xFA01 to accommodate related firmware. Characteristics are discovered; handles are not hardcoded.

Commands contain a two-byte little-endian total length, a two-byte little-endian command ID, and payload bytes. The current control packets are checked against protocol references and captured clock/time packets. Transport uses conservative 20-byte write chunks and waits for matching replies.

Independent prototype; not affiliated with VAETTLO, iPixel Color, or their vendors.