@multiviewer/led-panel-driver
v0.1.0
Published
TypeScript driver for BLE RGB LED matrix panels (LED_BLE_*, 32x32 and 64x16). Works in Node, Electron main and renderer (Web Bluetooth).
Readme
led-panel-driver
TypeScript driver for Bluetooth LE RGB LED matrix panels that advertise as LED_BLE_* (sold as
BK Light, among other brands). It shows images, animations, native scrolling text and a clock,
and controls brightness, power and orientation.
Built for Electron: use it from the main process (Node, through noble) or from the renderer (Web Bluetooth). It also works in plain Node and in Chromium browsers.
| Panel | Device type | Size | Checked on hardware |
| ------- | ----------- | ----- | ------------------- |
| ACT1026 | 0x81 | 32x32 | yes |
| ACT1025 | 0x83 | 64x16 | yes |
Other sizes in the same family are in the device table, but nobody has tested them yet.
Install
pnpm add @multiviewer/led-panel-driverFor the main-process or Node route, also add the Bluetooth library:
pnpm add @stoprocent/nobleTypes: ./node needs @types/node, ./web needs @types/web-bluetooth, ./electron and
./electron/preload need the types that ship with the electron package.
The package has a led-panel command-line tool (see "Command line"). In a project that depends
on the package, run it with pnpm exec led-panel.
Install warnings
- On macOS,
pnpm installcan report that@stoprocent/bluetooth-hci-socketfailed to build. That is harmless: it is noble's Linux-only HCI backend, and macOS does not use it. - In this repository, the example app can warn that it cannot link the
led-panelbin. The bin file only exists after the library is built; the warning goes away afterpnpm --filter @multiviewer/led-panel-driver build.
Entry points
| Import | Runs in | What it adds |
| ------------------------------------------------ | -------------------------- | ------------------------------------------------------------------------------ |
| @multiviewer/led-panel-driver | anywhere | client, protocol, image and text helpers, mock transport |
| @multiviewer/led-panel-driver/node | Node, Electron main | NobleTransport, findPanel, scanForPanels, shutdownNoble, PNG decoding |
| @multiviewer/led-panel-driver/web | browser, Electron renderer | WebBluetoothTransport, requestPanel |
| @multiviewer/led-panel-driver/electron | Electron main | registerLedPanelIpc, enableWebBluetooth, shutdownNoble |
| @multiviewer/led-panel-driver/electron/preload | Electron preload | exposeLedPanelApi, LedPanelApi and the Bridge* types (BridgePanelInfo) |
Electron, main-process route (recommended)
The main process owns the panels. A sandboxed, context-isolated renderer drives them over IPC.
// main.ts
import { app, BrowserWindow, ipcMain } from "electron";
import { registerLedPanelIpc } from "@multiviewer/led-panel-driver/electron";
app.whenReady().then(() => {
registerLedPanelIpc(ipcMain, {
app, // releases the panels and stops noble on quit
// Only your own pages may use the panels. Without isAllowedSender, every sender is accepted.
isAllowedSender: (event) => event.senderFrame?.url.startsWith(APP_URL) ?? false,
});
new BrowserWindow({ webPreferences: { preload: PRELOAD_PATH, sandbox: true } });
});isAllowedSender runs for every IPC call and restricts which pages may use the panels. The
default accepts every sender, so set it when the app can show pages you do not control.
// preload.ts (bundle it into one CommonJS file, see "Electron notes")
import { exposeLedPanelApi } from "@multiviewer/led-panel-driver/electron/preload";
exposeLedPanelApi(); // window.ledPanel// renderer.ts
import type { LedPanelApi } from "@multiviewer/led-panel-driver/electron/preload";
declare global {
interface Window {
ledPanel: LedPanelApi;
}
}
const [first] = await window.ledPanel.scan({ timeoutMs: 5000 });
if (!first) throw new Error("no panel found");
const panel = await window.ledPanel.connect(first.id);
await window.ledPanel.showText(panel.id, "HELLO", { color: "#ffaa00", animation: "scrollLeft" });
const ctx = canvas.getContext("2d")!;
function frame() {
draw(ctx);
const { width, height, data } = ctx.getImageData(0, 0, canvas.width, canvas.height);
void window.ledPanel.drawFrame(panel.id, { width, height, data }); // latest frame wins
requestAnimationFrame(frame);
}
frame();Electron, renderer route (Web Bluetooth)
// main.ts
import { enableWebBluetooth } from "@multiviewer/led-panel-driver/electron";
enableWebBluetooth(win); // picks the panel in Electron's device chooser event// renderer.ts, inside a click handler (Web Bluetooth needs a user gesture)
import { LedPanelClient, requestPanel } from "@multiviewer/led-panel-driver/web";
const client = await LedPanelClient.connect(await requestPanel());
await client.showImage(ctx.getImageData(0, 0, 64, 16));The page must be a secure context (file://, https:// or http://localhost).
Node
import { LedPanelClient, findPanel, shutdownNoble } from "@multiviewer/led-panel-driver/node";
const client = await LedPanelClient.connect(await findPanel({ timeoutMs: 8000 }));
console.log(client.info); // { deviceType: 0x81, width: 32, height: 32, ... }
await client.setBrightness(60);
await client.showText("Hi", { color: "#00ff88" });
await client.resumeStoredLoop(); // back to the images stored on the panel
await client.disconnect();
await shutdownNoble(); // lets the process exitClient API
| Method | Does |
| ------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| showImage(image, { fit, kernel, background }) | resize to the panel, encode PNG, upload |
| drawFrame(image) | fast frame streaming for animation; a newer frame replaces a queued one |
| showText(text, { color, background, animation, speed }) | text drawn and animated by the panel itself; returns missing glyphs |
| showPng, showGif, showRgb | upload data that is already encoded |
| setBrightness(0-100), setPower(on), setOrientation(0-3) | display settings (the panel cannot report them back) |
| showClock({ style, hour24, showDate }), setTime(date) | built-in clock |
| resumeStoredLoop() | leave live content and go back to the stored images |
| on("connect" \| "disconnect" \| "reconnect" \| "notification" \| "error", fn) | events |
Text animations: static, scrollLeft, scrollRight, blink, breathe, snowflake, laser,
and scrollUp / scrollDown on 32-pixel-high panels only.
All content goes to slot 0 by default: it shows at once and does not overwrite the images
stored on the panel. Slots 1-100 are persistent and overwrite stored content, so only use them
on purpose. eraseAll() and deleteSlots() exist but are destructive.
The client reconnects by itself after a link loss (on the next operation) and runs every command through one queue.
Measured frame rates with two panels streaming at the same time: about 10 fps per panel through the main-process bridge and about 12 fps through Web Bluetooth.
Command line
led-panel scan --info
led-panel text "HELLO" --panel all --animation scrollLeft --color "#ffaa00"
led-panel image picture.png --panel LED_BLE_XXXXXXXX --fit cover
led-panel brightness 40 --panel all
led-panel --panel all resume
led-panel --helpGlobal options (--panel, --timeout, --verbose, --json, --help) work before or after
the command. --panel takes an advertised name, a Bluetooth id or all, and repeats.
Electron notes
- Quit: an Electron main process that loaded noble does not exit on macOS unless noble is
stopped.
registerLedPanelIpc(ipcMain, { app })does this on quit; otherwise callshutdownNoble()before quitting. - Preload: a sandboxed preload can only
require("electron"), so bundle it into one CommonJS file (the example uses tsdown). - Images over the bridge: pass
{ width, height, data }. AnImageDataobject arrives empty throughcontextBridge. - Errors over the bridge:
contextBridgekeeps only the message, so bridge errors look like"LedPanelNotFoundError: ...". Check the prefix, noterror.name. - macOS packaging: add
NSBluetoothAlwaysUsageDescriptionto the app's Info.plist (for electron-builder:mac.extendInfo). A Mac App Store build also needs thecom.apple.security.device.bluetoothentitlement. noble ships N-API prebuilds that load in Electron without a rebuild; unpack them from the asar archive. - Web Bluetooth: Electron has no device picker. To choose one of several panels, pass its exact
name in
requestPanel({ namePrefixes: [name] }).
Example app
examples/electron has both routes side by side, with a panel list, a streamed animation, text,
a test image, brightness, power and "resume stored images".
pnpm install
pnpm --filter led-panel-electron-example startDevelopment
pnpm install
pnpm --filter @multiviewer/led-panel-driver test
pnpm --filter @multiviewer/led-panel-driver build
pnpm lintdocs/PROTOCOL.md is the byte-level protocol reference. AGENTS.md has the contributor rules,
including the commands that must never be sent to real panels.
