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/droidnet-flthy-hps.json b/libraries/boards/droidnet-flthy-hps.json new file mode 100644 index 0000000..62eac0e --- /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 @ 75755de", + "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..9fca307 --- /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 @ aa45d79", + "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/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/libraries/manifest.json b/libraries/manifest.json index 2196a2d..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", @@ -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/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..0bec4c8 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 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": [ { "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/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/droidnet-forks.test.js b/test/droidnet-forks.test.js new file mode 100644 index 0000000..f25b374 --- /dev/null +++ b/test/droidnet-forks.test.js @@ -0,0 +1,145 @@ +// 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); + }); +}); + +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]); + }); +}); 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/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/load-node.test.js b/test/load-node.test.js index 268bc8d..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.components.length).toBe(22); + 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]); +}); 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