From 6725d349bd2501d64653e30a82a175609930ebc3 Mon Sep 17 00:00:00 2001 From: travisccook Date: Wed, 23 Sep 2026 09:33:29 -0400 Subject: [PATCH 1/5] feat(droidnet-forks): fork boards for RSeries and Flthy native brightness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two device-native boards, each listed right after its stock board, describe commands only the DroidNet forks run. Stock firmware ignores both, and the stock boards are unchanged. - droidnet-rseries-logic (RSeriesLogic · DroidNet fork): rseries.cfg.brightness, #LEBRI{unit}{level}. New enum rseries.unit ("" both, F front, R rear); the rseries.target enum cannot be reused, because two digit placeholders side by side would parse #LEBRI200 as target 2, level 00. - droidnet-flthy-hps (FlthyHPs · DroidNet fork): flthy.led.brightness, H{unit}B{level}. flthy.designator is copied from flthy-hps; unit default A. - Both: level int 1-255, required, no default; cosmetic, Lighting, community; no duration suffix (the stock Flthy board's |n would otherwise attach). - firmware keeps until both forks merge their branches. test/droidnet-forks.test.js checks that each example parses to its own id and round-trips, that a level-only encode uses the every-unit default, the manifest placement, that no ! command exists and that rseries.effect stops at 105. load-node pins 24 components. npm run validate exits 0; npm test 420/420. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VnqRz7vGF5Xusa3WLbo38J --- libraries/boards/droidnet-flthy-hps.json | 56 ++++++++ libraries/boards/droidnet-rseries-logic.json | 52 ++++++++ libraries/manifest.json | 12 ++ test/droidnet-forks.test.js | 132 +++++++++++++++++++ test/load-node.test.js | 2 +- 5 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 libraries/boards/droidnet-flthy-hps.json create mode 100644 libraries/boards/droidnet-rseries-logic.json create mode 100644 test/droidnet-forks.test.js diff --git a/libraries/boards/droidnet-flthy-hps.json b/libraries/boards/droidnet-flthy-hps.json new file mode 100644 index 0000000..3c4c3fe --- /dev/null +++ b/libraries/boards/droidnet-flthy-hps.json @@ -0,0 +1,56 @@ +{ + "$schema": "droidnet-command-library/library/v1", + "enums": { + "flthy.designator": { + "label": "Holoprojector", + "values": [ + { "code": "F", "label": "Front" }, + { "code": "R", "label": "Rear" }, + { "code": "T", "label": "Top" }, + { "code": "X", "label": "Front & Rear" }, + { "code": "Y", "label": "Front & Top" }, + { "code": "Z", "label": "Rear & Top" }, + { "code": "A", "label": "All" } + ] + } + }, + "components": [ + { + "id": "droidnet-flthy-hps", + "name": "FlthyHPs · DroidNet fork", + "kind": "device-native", + "confidence": "community", + "firmware": "travisccook/DroidNet-FlthyHPs @ ", + "routing": { + "class": "broadcast", + "nativeWrapper": "none", + "durationSuffix": { "supported": false } + }, + "categories": ["Lighting"], + "commands": [ + { + "id": "flthy.led.brightness", + "name": "Brightness", + "safety": "cosmetic", + "category": "Lighting", + "encoder": "template", + "template": "H{unit}B{level}", + "params": [ + { "name": "unit", "enum": "flthy.designator", "default": "A" }, + { + "name": "level", + "label": "Brightness (1–255)", + "type": "int", + "min": 1, + "max": 255, + "required": true + } + ], + "supportsDuration": false, + "examples": ["HAB128", "HFB255", "HTB1"], + "commentLabel": "Holos {unit} brightness {level}" + } + ] + } + ] +} diff --git a/libraries/boards/droidnet-rseries-logic.json b/libraries/boards/droidnet-rseries-logic.json new file mode 100644 index 0000000..edf6d66 --- /dev/null +++ b/libraries/boards/droidnet-rseries-logic.json @@ -0,0 +1,52 @@ +{ + "$schema": "droidnet-command-library/library/v1", + "enums": { + "rseries.unit": { + "label": "Display", + "values": [ + { "code": "", "label": "Both" }, + { "code": "F", "label": "Front" }, + { "code": "R", "label": "Rear" } + ] + } + }, + "components": [ + { + "id": "droidnet-rseries-logic", + "name": "RSeriesLogic · DroidNet fork", + "kind": "device-native", + "confidence": "community", + "firmware": "travisccook/DroidNet-RSeriesLogics @ ", + "routing": { + "class": "broadcast", + "nativeWrapper": "none", + "durationSuffix": { "supported": false } + }, + "categories": ["Lighting"], + "commands": [ + { + "id": "rseries.cfg.brightness", + "name": "Brightness", + "safety": "cosmetic", + "category": "Lighting", + "encoder": "template", + "template": "#LEBRI{unit}{level}", + "params": [ + { "name": "unit", "enum": "rseries.unit", "default": "" }, + { + "name": "level", + "label": "Brightness (1–255)", + "type": "int", + "min": 1, + "max": 255, + "required": true + } + ], + "supportsDuration": false, + "examples": ["#LEBRI128", "#LEBRIF255", "#LEBRIR1"], + "commentLabel": "Logics {unit} brightness {level}" + } + ] + } + ] +} diff --git a/libraries/manifest.json b/libraries/manifest.json index 2196a2d..f0125e1 100644 --- a/libraries/manifest.json +++ b/libraries/manifest.json @@ -10,6 +10,12 @@ "name": "FlthyHPs Holoprojectors", "confidence": "high" }, + { + "id": "droidnet-flthy-hps", + "file": "boards/droidnet-flthy-hps.json", + "name": "FlthyHPs · DroidNet fork", + "confidence": "community" + }, { "id": "ia-magic-panel", "file": "boards/ia-magic-panel.json", @@ -28,6 +34,12 @@ "name": "RSeriesLogic (Logic Displays)", "confidence": "high" }, + { + "id": "droidnet-rseries-logic", + "file": "boards/droidnet-rseries-logic.json", + "name": "RSeriesLogic · DroidNet fork", + "confidence": "community" + }, { "id": "wcb-hcr", "file": "boards/wcb-hcr.json", diff --git a/test/droidnet-forks.test.js b/test/droidnet-forks.test.js new file mode 100644 index 0000000..9e462a3 --- /dev/null +++ b/test/droidnet-forks.test.js @@ -0,0 +1,132 @@ +// DroidNet fork boards (spec 2026-09-22-fork-brightness-and-library-design, section 4). +// Two device-native boards describe commands that only the DroidNet forks run: +// droidnet-rseries-logic rseries.cfg.brightness #LEBRI{unit}{level} +// droidnet-flthy-hps flthy.led.brightness H{unit}B{level} +// Each sits right after its stock board in the manifest; the stock boards are unchanged. +// web.test.js only checks that an example decodes to SOME command, so this suite checks +// that each example decodes to its OWN id with the expected params. +const { readCatalog } = require('../src/load-node.js'); +function loadEngine() { jest.resetModules(); return require('../src/droidnet-command-library.js'); } + +const FORKS = [ + { + board: 'droidnet-rseries-logic', stock: 'rseries-logic', + commandId: 'rseries.cfg.brightness', template: '#LEBRI{unit}{level}', + examples: { + '#LEBRI128': { unit: '', level: '128' }, + '#LEBRIF255': { unit: 'F', level: '255' }, + '#LEBRIR1': { unit: 'R', level: '1' } + }, + levelOnly: '#LEBRI128' + }, + { + board: 'droidnet-flthy-hps', stock: 'flthy-hps', + commandId: 'flthy.led.brightness', template: 'H{unit}B{level}', + examples: { + HAB128: { unit: 'A', level: '128' }, + HFB255: { unit: 'F', level: '255' }, + HTB1: { unit: 'T', level: '1' } + }, + levelOnly: 'HAB128' + } +]; + +describe('DroidNet fork boards', () => { + let cb; + let manifest; + beforeEach(() => { + cb = loadEngine(); + const catalog = readCatalog(); + manifest = catalog.manifest; + cb.loadLibrary(catalog.boards.map(b => JSON.parse(JSON.stringify(b))), { libraryVersion: manifest.libraryVersion }); + }); + + describe.each(FORKS)('$board', (fork) => { + test('holds exactly the one brightness command, with the agreed template', () => { + expect(cb.getCommands(fork.board).map(c => c.id)).toEqual([fork.commandId]); + const cmd = cb.getCommand(fork.commandId); + expect(cmd._component.id).toBe(fork.board); + expect(cmd.template).toBe(fork.template); + expect(cmd.encoder).toBe('template'); + expect(cmd.safety).toBe('cosmetic'); + expect(cmd.category).toBe('Lighting'); + expect(cmd.supportsDuration).toBe(false); + expect(Object.keys(fork.examples)).toEqual(cmd.examples); + }); + + test('is a community, device-native board with no duration suffix', () => { + const comp = cb.getComponents().find(c => c.id === fork.board); + expect(comp.kind).toBe('device-native'); + expect(comp.confidence).toBe('community'); + expect(comp.routing.durationSuffix).toEqual({ supported: false }); + }); + + test('level is a required int from 1 to 255 with no default; unit has a default', () => { + const params = Object.fromEntries(cb.getCommand(fork.commandId).params.map(p => [p.name, p])); + expect(Object.keys(params)).toEqual(['unit', 'level']); + expect(params.level).toMatchObject({ type: 'int', min: 1, max: 255, required: true }); + expect(params.level).not.toHaveProperty('default'); + expect(params.unit).toHaveProperty('default'); + }); + + test('each example parses to its own command id with the expected params', () => { + for (const [ex, params] of Object.entries(fork.examples)) { + const steps = cb.parseWCBValue(ex); + expect([ex, steps.length]).toEqual([ex, 1]); + expect(steps[0]).toMatchObject({ type: 'command', commandId: fork.commandId, params }); + } + }); + + test('each example round-trips byte-identically', () => { + for (const ex of Object.keys(fork.examples)) { + expect(cb.buildWCBValue(cb.parseWCBValue(ex))).toBe(ex); + } + }); + + test('encoding with only a level uses the unit default (every unit)', () => { + expect(cb.encode(cb.getCommand(fork.commandId), { level: '128' }, {})).toBe(fork.levelOnly); + }); + + test('a trailing |n is not taken as a duration', () => { + expect(cb.parseWCBValue(fork.levelOnly + '|5')[0].type).toBe('raw'); + }); + + test('the stock board does not carry the fork command', () => { + expect(cb.getCommands(fork.stock).map(c => c.id)).not.toContain(fork.commandId); + }); + + test('sits directly after its stock board in the manifest', () => { + const ids = manifest.boards.map(b => b.id); + expect(ids.indexOf(fork.board)).toBe(ids.indexOf(fork.stock) + 1); + expect(manifest.boards.find(b => b.id === fork.board)).toEqual({ + id: fork.board, + file: `boards/${fork.board}.json`, + name: cb.getComponents().find(c => c.id === fork.board).name, + confidence: 'community' + }); + }); + }); + + test('comment labels name the unit and the level', () => { + const logics = cb.getCommand('rseries.cfg.brightness'); + const holos = cb.getCommand('flthy.led.brightness'); + expect(cb.renderCommentLabel(logics, { unit: '', level: '128' })).toBe('Logics Both brightness 128'); + expect(cb.renderCommentLabel(logics, { unit: 'F', level: '255' })).toBe('Logics Front brightness 255'); + expect(cb.renderCommentLabel(holos, { unit: 'A', level: '128' })).toBe('Holos All brightness 128'); + }); + + test('no lighting-engine (!) command is in the library', () => { + const bang = []; + for (const comp of cb.getComponents()) { + for (const cmd of cb.getCommands(comp.id)) { + if (typeof cmd.template === 'string' && cmd.template.startsWith('!')) bang.push(cmd.id); + } + } + expect(bang).toEqual([]); + }); + + test('the rseries.effect codes still stop at 105 (no effect 106)', () => { + const codes = cb.getEnum('rseries.effect').values.map(v => Number(v.code)); + expect(Math.max(...codes)).toBe(105); + }); +}); diff --git a/test/load-node.test.js b/test/load-node.test.js index 268bc8d..aee06a8 100644 --- a/test/load-node.test.js +++ b/test/load-node.test.js @@ -11,7 +11,7 @@ test('readCatalog returns the manifest and every board, in order', () => { test('loadCatalog({ load: false }) returns the merged catalog object', () => { const lib = loadCatalog({ load: false }); expect(lib.libraryVersion).toBe('4.3.0'); - expect(lib.components.length).toBe(22); + expect(lib.components.length).toBe(24); }); test('loadCatalog() loads the engine and resolves commands', () => { From d9b5acf90ec0fd4182c9d4204ec08590f5b9d6a7 Mon Sep 17 00:00:00 2001 From: travisccook Date: Wed, 23 Sep 2026 09:40:12 -0400 Subject: [PATCH 2/5] release: bump library to 4.4.0 (DroidNet fork brightness) - libraries/manifest.json: libraryVersion 4.4.0; generatedFrom appends "DroidNet forks (2026-09)". - releases.json: latest and libraries[0] -> 4.4.0, releasedAt today, notes for the two fork boards (the firmware each needs, RAM-only level, stock boards unchanged). Boards 22 -> 24, commands 500 -> 502. - package.json and package-lock.json (top-level and packages[""]) -> 4.4.0. - Test pins: load-node and engine libraryVersion 4.4.0, and a new load-node test that package.json, package-lock.json and releases.json carry the manifest's version (the Signal Booster's vendor-sync test needs all four). npm run validate exits 0; npm test 421/421. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VnqRz7vGF5Xusa3WLbo38J --- libraries/manifest.json | 4 ++-- package-lock.json | 4 ++-- package.json | 2 +- releases.json | 8 ++++---- test/engine.test.js | 2 +- test/load-node.test.js | 19 ++++++++++++++++--- 6 files changed, 26 insertions(+), 13 deletions(-) diff --git a/libraries/manifest.json b/libraries/manifest.json index f0125e1..32dd96a 100644 --- a/libraries/manifest.json +++ b/libraries/manifest.json @@ -1,8 +1,8 @@ { "$schema": "droidnet-command-library/catalog/v1", - "libraryVersion": "4.3.0", + "libraryVersion": "4.4.0", "schemaVersion": "v1", - "generatedFrom": "C2B5 firmware + WIPDocs (2026-06); R2UppitySpinnerV3 ALT fw 3.5.3 (2026-07); Printed Droid Magic Panel 32 Wireless KB (2026-07); WCB 6.1.5_290119RJUN2026 (2026-07); WCB 6.2.1_021242RSEP2026 + WcbCmd 0.8.0 (2026-09)", + "generatedFrom": "C2B5 firmware + WIPDocs (2026-06); R2UppitySpinnerV3 ALT fw 3.5.3 (2026-07); Printed Droid Magic Panel 32 Wireless KB (2026-07); WCB 6.1.5_290119RJUN2026 (2026-07); WCB 6.2.1_021242RSEP2026 + WcbCmd 0.8.0 (2026-09); DroidNet forks (2026-09)", "boards": [ { "id": "flthy-hps", diff --git a/package-lock.json b/package-lock.json index 8d5688f..f1295df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "droidnet-command-library", - "version": "4.3.0", + "version": "4.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "droidnet-command-library", - "version": "4.3.0", + "version": "4.4.0", "license": "MPL-2.0", "devDependencies": { "ajv": "^8.17.1", diff --git a/package.json b/package.json index 2898aa5..e05471d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "droidnet-command-library", - "version": "4.3.0", + "version": "4.4.0", "description": "Schema-driven encoder/decoder and visual composer for building serial commands to control a vibrant community of Astromech droid components. Anyone can add their board by editing a JSON library — no code changes required.", "license": "MPL-2.0", "keywords": [ diff --git a/releases.json b/releases.json index 167f3d2..5b00a62 100644 --- a/releases.json +++ b/releases.json @@ -2,17 +2,17 @@ "$schema": "droidnet-command-library/releases/v1", "_comment": "Update pointer for the 'Update Library' flow. A device fetches this file, compares latest.libraryVersion to its installed catalog, and (if newer) downloads latest.url (the catalog manifest), then fetches each board listed in manifest.boards in order, validates them, and loads them via loadLibrary(boards, { libraryVersion }).", "latest": { - "libraryVersion": "4.3.0", + "libraryVersion": "4.4.0", "schemaVersion": "v1", - "releasedAt": "2026-09-15", + "releasedAt": "2026-09-23", "url": "https://raw.githubusercontent.com/travisccook/droidnet-command-library/main/libraries/manifest.json", - "notes": "Minor: WCB firmware 6.2.1 sweep. Maestro (maestro) adds the WCB 6.2+ comma forms: Run Subroutine (with and without a parameter, number and sub-verb spellings), Stop Script, Go Home, Set Servo Target/Speed/Acceleration, and Read Position/Moving State/Errors queries. Ids widen to 0-9 (9 = this WCB's Maestros) and subroutines to 0-127. Adds WCB · DFPlayer Mini board (wcb-dfp, WCB 6.2+): 21 ;D, verbs (play/folder/MP3-folder with optional ONFIN callback, stop/pause/resume/next/prev/random, loop track/all/folder, EQ, volume/up/down, reset, status). WCB · HCR Vocalizer (wcb-hcr) adds TRIGGER, all-channel VOL and VOLUP/VOLDN steps, and limits FN to the firmware's function codes. WCB (native config) (wcb-native) adds 66 commands (mesh channel, alias, controller, status LED pin/identify, WLED/DFPlayer config, MP3/DFPlayer/HCR host routing, Maestro remote/Kyber map, ;W alias routing, ;P PWM pulse, ;V/;VP variables and IF, sequence names/get and ,L local runs, timer stop, WDP, debug/track/version) and corrects ranges (board numbers 1-20, 30-minute timer, S0 routing, HCR baud/poll/get fields, ETM miss 100, multi-destination maps). WLED presets start at 1; the MP3 Trigger and WLED boards document 6.2.1 routing. FlthyHPs Holoprojectors (flthy-hps): Solid Color, Rainbow and Short Circuit now send the codes FlthyHPs v1.6+ firmware runs (005, 006, 007; 4.2.0 sent 006, 007, 005), and the X/Y/Z designators are back. Saved steps keep their exact text, but they decode by what the firmware runs: steps saved by 4.2.0 as Solid Color or Rainbow (006, 007) now show as raw text, and 4.2.0 Short Circuit steps (005) now show as Solid Color. Text fields (keys, aliases, variable names, conditions, colors) need a host that uses engine 3.1.0 or later. Generated against WCB 6.2.1_021242RSEP2026 + WcbCmd 0.8.0; board catalog 21 -> 22, commands 397 -> 500." + "notes": "Minor: DroidNet fork boards. Adds two device-native boards, each listed right after its stock board. RSeriesLogic · DroidNet fork (droidnet-rseries-logic) has Brightness (rseries.cfg.brightness): #LEBRI{unit}{level}, where the unit is none (both displays), F (front) or R (rear) and the level is 1-255. FlthyHPs · DroidNet fork (droidnet-flthy-hps) has Brightness (flthy.led.brightness): H{unit}B{level}, where the unit is F, R, T, X, Y, Z or A (all) and the level is 1-255. Both need the DroidNet fork firmware named in the board's firmware field; stock RSeriesLogic and FlthyHPs firmware ignore them. The level is RAM-only: nothing is saved on the board, and a power cycle restores the power-on brightness. The stock boards are unchanged. Board catalog 22 -> 24, commands 500 -> 502." }, "libraries": [ { "id": "droidnet-astromech", "name": "DroidNet Astromech Boards", - "libraryVersion": "4.3.0", + "libraryVersion": "4.4.0", "schemaVersion": "v1", "url": "https://raw.githubusercontent.com/travisccook/droidnet-command-library/main/libraries/manifest.json" } diff --git a/test/engine.test.js b/test/engine.test.js index 0f9c211..847cdbb 100644 --- a/test/engine.test.js +++ b/test/engine.test.js @@ -19,7 +19,7 @@ describe('engine lookups', () => { expect(cb.getComponents().map(c => c.id)).toEqual(expect.arrayContaining(['flthy-hps', 'ia-magic-panel'])); }); test('getLibraryVersion reports the loaded version', () => { - expect(cb.getLibraryVersion()).toBe('4.3.0'); + expect(cb.getLibraryVersion()).toBe('4.4.0'); }); test('getCommand resolves and back-links its component', () => { const cmd = cb.getCommand('flthy.led.solid'); diff --git a/test/load-node.test.js b/test/load-node.test.js index aee06a8..7e3aa11 100644 --- a/test/load-node.test.js +++ b/test/load-node.test.js @@ -3,20 +3,33 @@ const { readCatalog, loadCatalog } = require('../src/load-node.js'); test('readCatalog returns the manifest and every board, in order', () => { const { manifest, boards } = readCatalog(); - expect(manifest.libraryVersion).toBe('4.3.0'); + expect(manifest.libraryVersion).toBe('4.4.0'); expect(boards).toHaveLength(manifest.boards.length); expect(boards.map(b => b.components[0].id)).toEqual(manifest.boards.map(b => b.id)); }); test('loadCatalog({ load: false }) returns the merged catalog object', () => { const lib = loadCatalog({ load: false }); - expect(lib.libraryVersion).toBe('4.3.0'); + expect(lib.libraryVersion).toBe('4.4.0'); expect(lib.components.length).toBe(24); }); test('loadCatalog() loads the engine and resolves commands', () => { const engine = loadCatalog(); - expect(engine.getLibraryVersion()).toBe('4.3.0'); + expect(engine.getLibraryVersion()).toBe('4.4.0'); expect(engine.getCommand('flthy.led.solid')).not.toBeNull(); expect(engine.encode(engine.getCommand('flthy.led.solid'), { designator: 'A', color: '5' }, {})).toBe('A0055'); }); + +test('package.json, package-lock.json and releases.json carry the manifest libraryVersion', () => { + const root = path.join(__dirname, '..'); + const read = f => JSON.parse(require('fs').readFileSync(path.join(root, f), 'utf8')); + const version = readCatalog().manifest.libraryVersion; + const lock = read('package-lock.json'); + const releases = read('releases.json'); + expect(read('package.json').version).toBe(version); + expect(lock.version).toBe(version); + expect(lock.packages[''].version).toBe(version); + expect(releases.latest.libraryVersion).toBe(version); + expect(releases.libraries.map(l => l.libraryVersion)).toEqual([version]); +}); From 66954e1703cb9f3294c07f7005139fd5a721e01e Mon Sep 17 00:00:00 2001 From: travisccook Date: Wed, 23 Sep 2026 10:27:57 -0400 Subject: [PATCH 3/5] feat(safety): new storedcommand class for board-stored WCB commands - schema/library.schema.json: safety enum adds `storedcommand` ("runs a command the user stored on the board, whose effect the library cannot see"). - wcb-native.json: config -> storedcommand for wcb.runSeq, wcb.runSeqLong, wcb.runSeqLocal, wcb.runSeqLongLocal (;C / ;SEQ run-sequence). - wcb-dfp.json / wcb-mp3.json: cosmetic -> storedcommand for the ONFIN, play+callback commands (dfp.playCb, dfp.folderCb, dfp.mp3FolderCb, mp3.playCb, mp3.playFsCb) - ONFIN runs the stored ;C after the track. Queries, save, clear, timer and the ONERR setters stay config. - assets/app.css: .dn-safety-storedcommand joins the movement/power warn rule so the reference-page badge isn't grey. - Docs: CLAUDE.md, docs/BOARD_AUTHORING_GUIDE.md, CONTRIBUTING.md, docs/INTEGRATION_GUIDE.md and .github/workflows/claude-review.yml all describe the new class; the review workflow gets a rule that flags a stored-command runner mislabeled cosmetic/config. - releases.json: latest.notes records the new class. - Tests: library.test.js allows storedcommand; wcb-621.test.js pins all 9 storedcommand ids, pins ten related ids as config, and updates the wcb-dfp non-cosmetic list. npm run validate exits 0; npm test 423/423. grep -rn '"storedcommand"' libraries/boards | wc -l -> 9. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VnqRz7vGF5Xusa3WLbo38J --- .github/workflows/claude-review.yml | 6 +++++- CLAUDE.md | 9 +++++++-- CONTRIBUTING.md | 5 +++++ assets/app.css | 2 +- docs/BOARD_AUTHORING_GUIDE.md | 10 +++++++--- docs/INTEGRATION_GUIDE.md | 4 ++++ libraries/boards/wcb-dfp.json | 6 +++--- libraries/boards/wcb-mp3.json | 4 ++-- libraries/boards/wcb-native.json | 8 ++++---- releases.json | 2 +- schema/library.schema.json | 4 ++-- test/library.test.js | 2 +- test/wcb-621.test.js | 17 +++++++++++++++-- 13 files changed, 57 insertions(+), 22 deletions(-) diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index b7e9d02..ca6dcc2 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -87,7 +87,11 @@ jobs: 3. HIGH SEVERITY — flag any safety-class mislabeling. A command that moves the droid or affects power must NOT be marked safety:"cosmetic". Per the project's Code of Conduct, mislabeling can cause real hardware to - move unexpectedly. Call this out prominently. + move unexpectedly. Call this out prominently. A command that runs a + command the user stored on the board (a WCB `;C`/`;SEQ` run, + or an `ONFIN,` callback) must be marked safety:"storedcommand", + not "cosmetic" or "config" — the library cannot see what the stored + content does, so it must not be labeled as if its effect were known. 4. For changes to src/*.js: check correctness, keep the engine DOM-free and the UI going through buildWCBValue/parseWCBValue, and preserve the diff --git a/CLAUDE.md b/CLAUDE.md index dd7955a..4cc0248 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,8 +71,13 @@ libraries/manifest.json ──fetch+merge──▶ loadLibrary ─ param names must exactly match the placeholders (validator enforces both directions). `component.kind` is `device-native` (the board's own grammar) or `wcb-verb` (interpreted by an intermediary like a WCB). `command.safety` -(`cosmetic`/`movement`/`power`/`config`) drives a confirm-before-firing warning in -the UI — anything non-`cosmetic` is treated as potentially dangerous. +(`cosmetic`/`movement`/`power`/`config`/`storedcommand`) drives a confirm-before-firing +warning in the UI — anything non-`cosmetic` is treated as potentially dangerous. +`storedcommand` is for commands that run a command the user stored on the board (a WCB +stored command, `;C`) that can be any command, so the library cannot know whether +it moves hardware, switches power or changes settings; if the device itself limits what +saved content can do (a Maestro script, a Roam-a-Dome sequence), use that limit's class +instead. A command may declare a `category` (e.g. `"Lighting"`); the component declares an ordered `categories` array that lists every category it uses and fixes the diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 302ddd9..b7d2cb7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,6 +67,11 @@ for a worked example), with tests. Libraries reference it by `encoder` name. Be kind, assume good faith, and keep board data accurate. Misrepresenting a command's `safety` class (e.g. labelling a movement command `cosmetic`) can cause real hardware to move unexpectedly — when in doubt, choose the more cautious class. +Use `storedcommand` for commands that run a command the user stored on the board (a +WCB stored command, `;C`) that can be any command, so the library cannot know +whether it moves hardware, switches power or changes settings; if the device itself +limits what saved content can do (a Maestro script, a Roam-a-Dome sequence), use that +limit's class instead. ## License diff --git a/assets/app.css b/assets/app.css index 7903171..aee26c1 100644 --- a/assets/app.css +++ b/assets/app.css @@ -36,7 +36,7 @@ body { margin: 0; background: var(--bg); color: var(--ink); font-family: var(--s .dn-badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: .72rem; background: var(--panel-2); color: var(--muted); border: 1px solid var(--edge); font-family: var(--mono); } .dn-ver { color: var(--accent); } -.dn-safety-movement, .dn-safety-power { color: var(--warn); border-color: var(--warn); } +.dn-safety-movement, .dn-safety-power, .dn-safety-storedcommand { color: var(--warn); border-color: var(--warn); } .dn-safety-config { color: var(--accent); border-color: var(--accent); } .dn-conf-community { color: var(--warn); border-color: var(--warn); } .dn-conf-low { color: var(--danger); border-color: var(--danger); } diff --git a/docs/BOARD_AUTHORING_GUIDE.md b/docs/BOARD_AUTHORING_GUIDE.md index fb68e1d..2566801 100644 --- a/docs/BOARD_AUTHORING_GUIDE.md +++ b/docs/BOARD_AUTHORING_GUIDE.md @@ -88,7 +88,7 @@ Required: `id` (unique across the whole library) and `name`. "id": "myboard.solid", // globally unique "name": "Solid Color", "category": "Lighting", // dropdown section — must be in the component's `categories` - "safety": "cosmetic", // cosmetic | movement | power | config + "safety": "cosmetic", // cosmetic | movement | power | config | storedcommand "encoder": "template", // default; can omit "template": "C{color}", "params": [ { "name": "color", "enum": "myboard.color", "default": "1" } ], @@ -104,8 +104,12 @@ one. **`safety` matters.** Anything other than `cosmetic` triggers a confirm-before-firing warning in the UI. Use `movement` for anything that moves -hardware, `power` for power switching, `config` for settings that change state. -When unsure, pick the more cautious class. +hardware, `power` for power switching, `config` for settings that change state, and +`storedcommand` for commands that run a command the user stored on the board (a WCB +stored command, `;C`) that can be any command, so the library cannot know +whether it moves hardware, switches power or changes settings; if the device itself +limits what saved content can do (a Maestro script, a Roam-a-Dome sequence), use +that limit's class instead. When unsure, pick the more cautious class. **Firmware requirements go in the name.** Neither the composer nor the hosted reference shows a component's `firmware` or `routing.notes`, so users never see diff --git a/docs/INTEGRATION_GUIDE.md b/docs/INTEGRATION_GUIDE.md index e2591d7..e0796db 100644 --- a/docs/INTEGRATION_GUIDE.md +++ b/docs/INTEGRATION_GUIDE.md @@ -143,6 +143,10 @@ engine being loaded first. The composer handles add/edit/remove, drag-reorder, inline delays and notes, a length counter, and a safety warning when any non-`cosmetic` command is present. +If you build your own UI instead of using the composer, show any `safety` value +other than `cosmetic` verbatim as a warning — including a value your integration +doesn't recognise (a future release can add a class you haven't seen yet). Don't +special-case which strings count as a warning; anything non-`cosmetic` is one. The command dropdown in the add bar renders as ordered `` sections following the selected board's `component.categories` array (uncategorized diff --git a/libraries/boards/wcb-dfp.json b/libraries/boards/wcb-dfp.json index 315c4cd..a081f06 100644 --- a/libraries/boards/wcb-dfp.json +++ b/libraries/boards/wcb-dfp.json @@ -36,7 +36,7 @@ "template": ";D,PLAY,{track}", "params": [ { "name": "track", "type": "int", "min": 1, "max": 2999, "required": true } ], "examples": [";D,PLAY,5"], "commentLabel": "DFPlayer play", "category": "Sound" }, - { "id": "dfp.playCb", "name": "Play Track + Callback", "safety": "cosmetic", "encoder": "template", + { "id": "dfp.playCb", "name": "Play Track + Callback", "safety": "storedcommand", "encoder": "template", "template": ";D,PLAY,{track},ONFIN,{key}", "params": [ { "name": "track", "type": "int", "min": 1, "max": 2999, "required": true }, { "name": "key", "pattern": "[A-Za-z0-9_]+", "required": true } ], @@ -46,7 +46,7 @@ "params": [ { "name": "folder", "type": "int", "min": 1, "max": 99, "required": true }, { "name": "track", "type": "int", "min": 1, "max": 255, "required": true } ], "examples": [";D,FOLDER,1,5"], "commentLabel": "DFPlayer folder", "category": "Sound" }, - { "id": "dfp.folderCb", "name": "Play Folder Track + Callback", "safety": "cosmetic", "encoder": "template", + { "id": "dfp.folderCb", "name": "Play Folder Track + Callback", "safety": "storedcommand", "encoder": "template", "template": ";D,FOLDER,{folder},{track},ONFIN,{key}", "params": [ { "name": "folder", "type": "int", "min": 1, "max": 99, "required": true }, { "name": "track", "type": "int", "min": 1, "max": 255, "required": true }, @@ -56,7 +56,7 @@ "template": ";D,MP3FOLDER,{track}", "params": [ { "name": "track", "type": "int", "min": 1, "max": 9999, "required": true } ], "examples": [";D,MP3FOLDER,3"], "commentLabel": "DFPlayer MP3 folder", "category": "Sound" }, - { "id": "dfp.mp3FolderCb", "name": "Play /MP3 Folder Track + Callback", "safety": "cosmetic", "encoder": "template", + { "id": "dfp.mp3FolderCb", "name": "Play /MP3 Folder Track + Callback", "safety": "storedcommand", "encoder": "template", "template": ";D,MP3FOLDER,{track},ONFIN,{key}", "params": [ { "name": "track", "type": "int", "min": 1, "max": 9999, "required": true }, { "name": "key", "pattern": "[A-Za-z0-9_]+", "required": true } ], diff --git a/libraries/boards/wcb-mp3.json b/libraries/boards/wcb-mp3.json index 1dac756..c8267d3 100644 --- a/libraries/boards/wcb-mp3.json +++ b/libraries/boards/wcb-mp3.json @@ -17,7 +17,7 @@ "template": ";A,PLAY,{track}", "params": [ { "name": "track", "type": "int", "min": 1, "max": 255, "required": true } ], "examples": [";A,PLAY,1"], "commentLabel": "MP3 play", "category": "Sound" }, - { "id": "mp3.playCb", "name": "Play Track + Callback", "safety": "cosmetic", "encoder": "template", + { "id": "mp3.playCb", "name": "Play Track + Callback", "safety": "storedcommand", "encoder": "template", "template": ";A,PLAY,{track},ONFIN,{key}", "params": [ { "name": "track", "type": "int", "min": 1, "max": 255, "required": true }, { "name": "key", "pattern": "[A-Za-z0-9_]+", "required": true } ], @@ -26,7 +26,7 @@ "template": ";A,PLAYFS,{track}", "params": [ { "name": "track", "type": "int", "min": 0, "max": 255, "required": true } ], "examples": [";A,PLAYFS,0"], "commentLabel": "MP3 play FS", "category": "Sound" }, - { "id": "mp3.playFsCb", "name": "Play Track (FS) + Callback", "safety": "cosmetic", "encoder": "template", + { "id": "mp3.playFsCb", "name": "Play Track (FS) + Callback", "safety": "storedcommand", "encoder": "template", "template": ";A,PLAYFS,{track},ONFIN,{key}", "params": [ { "name": "track", "type": "int", "min": 0, "max": 255, "required": true }, { "name": "key", "pattern": "[A-Za-z0-9_]+", "required": true } ], diff --git a/libraries/boards/wcb-native.json b/libraries/boards/wcb-native.json index b4debb7..387b2cf 100644 --- a/libraries/boards/wcb-native.json +++ b/libraries/boards/wcb-native.json @@ -1596,7 +1596,7 @@ { "id": "wcb.runSeq", "name": "Run Sequence (short)", - "safety": "config", + "safety": "storedcommand", "encoder": "template", "template": ";C{key}", "params": [ @@ -1615,7 +1615,7 @@ { "id": "wcb.runSeqLong", "name": "Run Sequence (long)", - "safety": "config", + "safety": "storedcommand", "encoder": "template", "template": ";SEQ{key}", "params": [ @@ -1634,7 +1634,7 @@ { "id": "wcb.runSeqLocal", "name": "Run Sequence on This Board Only (WCB 6.2+)", - "safety": "config", + "safety": "storedcommand", "encoder": "template", "template": ";C{key},L", "params": [ @@ -1653,7 +1653,7 @@ { "id": "wcb.runSeqLongLocal", "name": "Run Sequence on This Board Only, long (WCB 6.2+)", - "safety": "config", + "safety": "storedcommand", "encoder": "template", "template": ";SEQ{key},L", "params": [ diff --git a/releases.json b/releases.json index 5b00a62..e5aac9b 100644 --- a/releases.json +++ b/releases.json @@ -6,7 +6,7 @@ "schemaVersion": "v1", "releasedAt": "2026-09-23", "url": "https://raw.githubusercontent.com/travisccook/droidnet-command-library/main/libraries/manifest.json", - "notes": "Minor: DroidNet fork boards. Adds two device-native boards, each listed right after its stock board. RSeriesLogic · DroidNet fork (droidnet-rseries-logic) has Brightness (rseries.cfg.brightness): #LEBRI{unit}{level}, where the unit is none (both displays), F (front) or R (rear) and the level is 1-255. FlthyHPs · DroidNet fork (droidnet-flthy-hps) has Brightness (flthy.led.brightness): H{unit}B{level}, where the unit is F, R, T, X, Y, Z or A (all) and the level is 1-255. Both need the DroidNet fork firmware named in the board's firmware field; stock RSeriesLogic and FlthyHPs firmware ignore them. The level is RAM-only: nothing is saved on the board, and a power cycle restores the power-on brightness. The stock boards are unchanged. Board catalog 22 -> 24, commands 500 -> 502." + "notes": "Minor: DroidNet fork boards. Adds two device-native boards, each listed right after its stock board. RSeriesLogic · DroidNet fork (droidnet-rseries-logic) has Brightness (rseries.cfg.brightness): #LEBRI{unit}{level}, where the unit is none (both displays), F (front) or R (rear) and the level is 1-255. FlthyHPs · DroidNet fork (droidnet-flthy-hps) has Brightness (flthy.led.brightness): H{unit}B{level}, where the unit is F, R, T, X, Y, Z or A (all) and the level is 1-255. Both need the DroidNet fork firmware named in the board's firmware field; stock RSeriesLogic and FlthyHPs firmware ignore them. The level is RAM-only: nothing is saved on the board, and a power cycle restores the power-on brightness. The stock boards are unchanged. Board catalog 22 -> 24, commands 500 -> 502. Safety: new class `storedcommand` for commands that run a command the user saved on the WCB (;C/;SEQ run-sequence, and the DFPlayer/MP3 play+ONFIN callbacks, which were cosmetic). Queries, save and clear stay config." }, "libraries": [ { diff --git a/schema/library.schema.json b/schema/library.schema.json index 085fffa..fe04bce 100644 --- a/schema/library.schema.json +++ b/schema/library.schema.json @@ -123,8 +123,8 @@ }, "safety": { "type": "string", - "enum": ["cosmetic", "movement", "power", "config"], - "description": "Risk class. Anything other than 'cosmetic' raises a confirm-before-firing warning in the UI." + "enum": ["cosmetic", "movement", "power", "config", "storedcommand"], + "description": "Risk class. Anything other than 'cosmetic' raises a confirm-before-firing warning in the UI. 'storedcommand' = runs a command the user stored on the board, whose effect the library cannot see." }, "encoder": { "type": "string", diff --git a/test/library.test.js b/test/library.test.js index 35cd6f9..3e0eeb5 100644 --- a/test/library.test.js +++ b/test/library.test.js @@ -22,7 +22,7 @@ describe.each(boardFiles)('board %s', (file) => { for (const cmd of lib.components[0].commands) { expect(cmd.id && cmd.name).toBeTruthy(); if ((cmd.encoder || 'template') === 'template') expect(typeof cmd.template).toBe('string'); - if (cmd.safety) expect(['cosmetic', 'movement', 'power', 'config']).toContain(cmd.safety); + if (cmd.safety) expect(['cosmetic', 'movement', 'power', 'config', 'storedcommand']).toContain(cmd.safety); } }); diff --git a/test/wcb-621.test.js b/test/wcb-621.test.js index 168f294..5351fb8 100644 --- a/test/wcb-621.test.js +++ b/test/wcb-621.test.js @@ -127,6 +127,19 @@ describe('WCB 6.2.1 sweep guards', () => { } expect(tooLong).toEqual([]); }); + + test('storedcommand: runs a command the user stored on the board (;C/;SEQ, and the ONFIN-callback plays)', () => { + const storedIds = ['wcb.runSeq', 'wcb.runSeqLong', 'wcb.runSeqLocal', 'wcb.runSeqLongLocal', 'mp3.playCb', 'mp3.playFsCb']; + for (const id of storedIds) expect(cb.getCommand(id).safety).toBe('storedcommand'); + }); + + test('config: run-sequence queries/save/clear and the timer and ONERR setters stay config', () => { + const configIds = [ + 'wcb.seqSave', 'wcb.seqNames', 'wcb.seqGet', 'wcb.seqList', 'wcb.seqClear', 'wcb.seqClearAll', + 'wcb.timerStop', 'wcb.timer', 'wcb.mp3OnErr', 'wcb.dfpOnErr', + ]; + for (const id of configIds) expect(cb.getCommand(id).safety).toBe('config'); + }); }); // --------------------------------------------------------------------------------------------- @@ -301,9 +314,9 @@ describe('wcb-dfp: DFPlayer Mini (;D, WCB 6.2+)', () => { expect(cb.match(';D,VOLDN').commandId).toBe('dfp.volDown'); }); - test('only RESET is non-cosmetic', () => { + test('RESET is config; the ONFIN-callback plays are storedcommand', () => { const nonCosmetic = cb.getCommands('wcb-dfp').filter(c => c.safety !== 'cosmetic').map(c => `${c.id}:${c.safety}`); - expect(nonCosmetic).toEqual(['dfp.reset:config']); + expect(nonCosmetic).toEqual(['dfp.playCb:storedcommand', 'dfp.folderCb:storedcommand', 'dfp.mp3FolderCb:storedcommand', 'dfp.reset:config']); }); // ;D,DEVICE, is documented (WCB_Help.cpp:581) but WCB 6.2.1 rejects it: processDFPCommand From 1e20098b8bae7954c72a8570434d7dea90fc699e Mon Sep 17 00:00:00 2001 From: travisccook Date: Wed, 23 Sep 2026 10:35:15 -0400 Subject: [PATCH 4/5] feat(droidnet-forks): name the fork commits that carry native brightness Both forks merged native-brightness into main and pushed it. Each firmware field now names the source_commit in that fork's firmware/manifest.json: - droidnet-rseries-logic: travisccook/DroidNet-RSeriesLogics @ aa45d79 (on origin/main; contains #LEBRI under src/native/) - droidnet-flthy-hps: travisccook/DroidNet-FlthyHPs @ 75755de (on origin/main; src/main.cpp has the DroidNet native brightness markers) A new test in droidnet-forks.test.js requires the two fork components' firmware to match ^travisccook/DroidNet-(RSeriesLogics|FlthyHPs) @ $, so a placeholder cannot be merged. The stock boards' firmware strings are free text and are not checked. releases.json releasedAt is already today's date, so it has no diff to commit. npm run validate exits 0; npm test 425/425 (13 suites) - two more than the 423 the brief's Step 6 expected, accounted for by Task S's two storedcommand tests added to the suite after this brief was validated. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VnqRz7vGF5Xusa3WLbo38J --- libraries/boards/droidnet-flthy-hps.json | 2 +- libraries/boards/droidnet-rseries-logic.json | 2 +- test/droidnet-forks.test.js | 13 +++++++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/libraries/boards/droidnet-flthy-hps.json b/libraries/boards/droidnet-flthy-hps.json index 3c4c3fe..62eac0e 100644 --- a/libraries/boards/droidnet-flthy-hps.json +++ b/libraries/boards/droidnet-flthy-hps.json @@ -20,7 +20,7 @@ "name": "FlthyHPs · DroidNet fork", "kind": "device-native", "confidence": "community", - "firmware": "travisccook/DroidNet-FlthyHPs @ ", + "firmware": "travisccook/DroidNet-FlthyHPs @ 75755de", "routing": { "class": "broadcast", "nativeWrapper": "none", diff --git a/libraries/boards/droidnet-rseries-logic.json b/libraries/boards/droidnet-rseries-logic.json index edf6d66..9fca307 100644 --- a/libraries/boards/droidnet-rseries-logic.json +++ b/libraries/boards/droidnet-rseries-logic.json @@ -16,7 +16,7 @@ "name": "RSeriesLogic · DroidNet fork", "kind": "device-native", "confidence": "community", - "firmware": "travisccook/DroidNet-RSeriesLogics @ ", + "firmware": "travisccook/DroidNet-RSeriesLogics @ aa45d79", "routing": { "class": "broadcast", "nativeWrapper": "none", diff --git a/test/droidnet-forks.test.js b/test/droidnet-forks.test.js index 9e462a3..f25b374 100644 --- a/test/droidnet-forks.test.js +++ b/test/droidnet-forks.test.js @@ -130,3 +130,16 @@ describe('DroidNet fork boards', () => { expect(Math.max(...codes)).toBe(105); }); }); + +describe('DroidNet fork boards name their firmware commit', () => { + // Only the two fork components are checked. The stock boards' firmware strings are free text. + // A placeholder such as "" fails here, so it cannot be merged. + const FIRMWARE = /^travisccook\/DroidNet-(RSeriesLogics|FlthyHPs) @ [0-9a-f]{7,40}$/; + const REPO = { 'droidnet-rseries-logic': 'RSeriesLogics', 'droidnet-flthy-hps': 'FlthyHPs' }; + + test.each(Object.keys(REPO))('%s firmware is its fork repo @ a commit sha', (board) => { + const comp = readCatalog().boards.map(b => b.components[0]).find(c => c.id === board); + expect(comp.firmware).toMatch(FIRMWARE); + expect(comp.firmware.match(FIRMWARE)[1]).toBe(REPO[board]); + }); +}); From 5b2b263b483a1ced5125ebfa13fcaa56a3c596bd Mon Sep 17 00:00:00 2001 From: travisccook Date: Wed, 23 Sep 2026 10:48:51 -0400 Subject: [PATCH 5/5] docs(release): say which stock boards 4.4.0 leaves unchanged releases.json latest.notes said "The stock boards are unchanged," but this release changes the safety class (config -> storedcommand) of run-sequence and play+callback commands on three stock WCB boards (wcb-native, wcb-dfp, wcb-mp3). Narrow the sentence to the two boards this release actually leaves untouched: RSeriesLogic and FlthyHPs. npm run validate exits 0; npm test 425/425 (13 suites). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VnqRz7vGF5Xusa3WLbo38J --- releases.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/releases.json b/releases.json index e5aac9b..0bec4c8 100644 --- a/releases.json +++ b/releases.json @@ -6,7 +6,7 @@ "schemaVersion": "v1", "releasedAt": "2026-09-23", "url": "https://raw.githubusercontent.com/travisccook/droidnet-command-library/main/libraries/manifest.json", - "notes": "Minor: DroidNet fork boards. Adds two device-native boards, each listed right after its stock board. RSeriesLogic · DroidNet fork (droidnet-rseries-logic) has Brightness (rseries.cfg.brightness): #LEBRI{unit}{level}, where the unit is none (both displays), F (front) or R (rear) and the level is 1-255. FlthyHPs · DroidNet fork (droidnet-flthy-hps) has Brightness (flthy.led.brightness): H{unit}B{level}, where the unit is F, R, T, X, Y, Z or A (all) and the level is 1-255. Both need the DroidNet fork firmware named in the board's firmware field; stock RSeriesLogic and FlthyHPs firmware ignore them. The level is RAM-only: nothing is saved on the board, and a power cycle restores the power-on brightness. The stock boards are unchanged. Board catalog 22 -> 24, commands 500 -> 502. Safety: new class `storedcommand` for commands that run a command the user saved on the WCB (;C/;SEQ run-sequence, and the DFPlayer/MP3 play+ONFIN callbacks, which were cosmetic). Queries, save and clear stay config." + "notes": "Minor: DroidNet fork boards. Adds two device-native boards, each listed right after its stock board. RSeriesLogic · DroidNet fork (droidnet-rseries-logic) has Brightness (rseries.cfg.brightness): #LEBRI{unit}{level}, where the unit is none (both displays), F (front) or R (rear) and the level is 1-255. FlthyHPs · DroidNet fork (droidnet-flthy-hps) has Brightness (flthy.led.brightness): H{unit}B{level}, where the unit is F, R, T, X, Y, Z or A (all) and the level is 1-255. Both need the DroidNet fork firmware named in the board's firmware field; stock RSeriesLogic and FlthyHPs firmware ignore them. The level is RAM-only: nothing is saved on the board, and a power cycle restores the power-on brightness. The stock RSeriesLogic and FlthyHPs boards are unchanged. Board catalog 22 -> 24, commands 500 -> 502. Safety: new class `storedcommand` for commands that run a command the user saved on the WCB (;C/;SEQ run-sequence, and the DFPlayer/MP3 play+ONFIN callbacks, which were cosmetic). Queries, save and clear stay config." }, "libraries": [ {