diff --git a/docs/dmx-smoke-evidence/2026-06-30-sacn/README.md b/docs/dmx-smoke-evidence/2026-06-30-sacn/README.md new file mode 100644 index 0000000..984688d --- /dev/null +++ b/docs/dmx-smoke-evidence/2026-06-30-sacn/README.md @@ -0,0 +1,37 @@ +# PlotForge D4 sACN Hardware Smoke Evidence + +Date: 2026-06-30 + +Status: scaffold only; sACN hardware run pending + +## Required Files + +- [ ] `relay.log` — output from `npm run dmx:relay` +- [ ] `capture.pcap` or `capture.pcapng` — sACN UDP packet capture on port 5568 +- [ ] `node-ui.png` — screenshot of sACN node receive/output UI +- [ ] `fixture-video.mov` or `fixture-video.mp4` — optional, required for fixture verified status + +## Hardware + +- Protocol: sACN +- Node: +- Node firmware: +- Node IP: +- Network interface: +- Fixture or dimmer: +- DMX universe/address: + +## Results + +- [ ] No output before arming +- [ ] Arm succeeds +- [ ] Selected fixture intensity test works +- [ ] Blackout works +- [ ] Bad target fails visibly +- [ ] Network disconnect faults visibly +- [ ] Relay crash or tab failure faults and blacks out +- [ ] Blackout recovery works + +## Evidence Notes + +Record packet count, node UI status, fixture behavior, unicast or multicast mode, and any failures here before marking D4 verified. diff --git a/docs/dmx-smoke-evidence/2026-06-30/README.md b/docs/dmx-smoke-evidence/2026-06-30/README.md new file mode 100644 index 0000000..dd8bb24 --- /dev/null +++ b/docs/dmx-smoke-evidence/2026-06-30/README.md @@ -0,0 +1,37 @@ +# PlotForge D3 Hardware Smoke Evidence + +Date: 2026-06-30 + +Status: scaffold only; hardware run pending + +## Required Files + +- [ ] `relay.log` — output from `npm run dmx:relay` +- [ ] `capture.pcap` or `capture.pcapng` — Art-Net UDP packet capture +- [ ] `node-ui.png` — screenshot of Art-Net node receive/output UI +- [ ] `fixture-video.mov` or `fixture-video.mp4` — optional, required for fixture verified status + +## Hardware + +- Protocol: Art-Net +- Node: +- Node firmware: +- Node IP: +- Network interface: +- Fixture or dimmer: +- DMX universe/address: + +## Results + +- [ ] No output before arming +- [ ] Arm succeeds +- [ ] Selected fixture intensity test works +- [ ] Blackout works +- [ ] Bad target fails visibly +- [ ] Network disconnect faults visibly +- [ ] Relay crash or tab failure faults and blacks out +- [ ] Blackout recovery works + +## Evidence Notes + +Record packet count, node UI status, fixture behavior, and any failures here before marking D3 verified. diff --git a/docs/dmx-smoke-evidence/local/README.md b/docs/dmx-smoke-evidence/local/README.md new file mode 100644 index 0000000..6469ee0 --- /dev/null +++ b/docs/dmx-smoke-evidence/local/README.md @@ -0,0 +1,29 @@ +# PlotForge Local DMX Smoke Evidence + +Status: local pre-hardware proof only + +Run: + +```bash +PATH=/opt/homebrew/opt/node@22/bin:$PATH npm run dmx:local-smoke +``` + +The script starts a loopback DMX relay, opens an authenticated WebSocket session, sends Art-Net and sACN unicast frames to a local UDP listener, and writes `local-smoke-latest.json`. + +This proves: + +- Relay `/health`. +- Origin rejection. +- No UDP before an authenticated frame command. +- Bad target rejection without UDP. +- 512-slot ArtDmx frame emission to a UDP listener. +- Art-Net blackout burst all-zero frames. +- 512-slot sACN frame emission to a UDP listener. +- sACN blackout burst all-zero frames. +- Heartbeat watchdog fault plus blackout. +- Active client disconnect blackout. +- Relay shutdown blackout. + +Controlled sACN multicast target derivation is covered by automated relay tests, not by this loopback smoke. Real multicast behavior still belongs in D4 hardware/network smoke. + +This does **not** satisfy D3 or D4 hardware verification. D3 still requires relay log, packet capture, node UI screenshot, and a real Art-Net fixture or dimmer result in `docs/dmx-smoke-evidence/2026-06-30/`. D4 still requires the same class of proof on a sACN-capable node. diff --git a/docs/dmx-smoke-evidence/local/local-smoke-latest.json b/docs/dmx-smoke-evidence/local/local-smoke-latest.json new file mode 100644 index 0000000..5f7dff0 --- /dev/null +++ b/docs/dmx-smoke-evidence/local/local-smoke-latest.json @@ -0,0 +1,360 @@ +{ + "kind": "plotforge-dmx-local-smoke", + "generatedAt": "2026-07-12T02:32:58.976Z", + "relay": { + "url": "ws://127.0.0.1:57232", + "healthUrl": "http://127.0.0.1:57232/health", + "origin": "http://127.0.0.1:5173", + "target": "127.0.0.1:58746", + "blackoutBurst": 2, + "heartbeatTimeoutMs": 150 + }, + "checks": [ + { + "name": "health", + "status": "pass", + "body": { + "ok": true, + "service": "plotforge-dmx-relay", + "relayVersion": "0.1.0", + "boundHost": "127.0.0.1", + "maxFps": 20, + "requireToken": true + } + }, + { + "name": "bad-origin-no-output", + "status": "pass", + "packetCount": 0 + }, + { + "name": "auth-no-output-before-frame", + "status": "pass", + "packetCount": 0 + }, + { + "name": "bad-target-no-output", + "status": "pass", + "reply": { + "type": "frame.error", + "reason": "public-target", + "detail": "203.0.113.10" + } + }, + { + "name": "selected-fixture-frame", + "status": "pass", + "reply": { + "type": "frame.ok", + "protocol": "artnet", + "portAddress": { + "net": 0, + "subNet": 0, + "universe": 0 + }, + "bytesSent": 530 + } + }, + { + "name": "blackout-burst", + "status": "pass", + "reply": { + "type": "blackout.ok", + "framesSent": 2 + } + }, + { + "name": "selected-sacn-frame", + "status": "pass", + "reply": { + "type": "frame.ok", + "protocol": "sacn", + "targetMode": "unicast", + "universe": 1, + "target": { + "host": "127.0.0.1", + "port": 58746 + }, + "bytesSent": 638 + } + }, + { + "name": "sacn-blackout-burst", + "status": "pass", + "reply": { + "type": "blackout.ok", + "framesSent": 2 + } + }, + { + "name": "heartbeat-timeout-blackout", + "status": "pass", + "reply": { + "type": "fault", + "reason": "heartbeat-timeout" + } + }, + { + "name": "active-client-disconnect-blackout", + "status": "pass" + }, + { + "name": "relay-shutdown-blackout", + "status": "pass" + } + ], + "packets": [ + { + "receivedAt": "2026-07-12T02:32:58.987Z", + "byteLength": 530, + "protocol": "artnet", + "artnet": { + "id": "Art-Net\u0000", + "opcode": 20480, + "sequence": 7, + "net": 0, + "subUni": 0, + "length": 512, + "nonZeroSlots": [ + { + "address": 1, + "value": 255 + }, + { + "address": 2, + "value": 64 + } + ] + } + }, + { + "receivedAt": "2026-07-12T02:32:58.988Z", + "byteLength": 530, + "protocol": "artnet", + "artnet": { + "id": "Art-Net\u0000", + "opcode": 20480, + "sequence": 0, + "net": 0, + "subUni": 0, + "length": 512, + "nonZeroSlots": [] + } + }, + { + "receivedAt": "2026-07-12T02:32:58.988Z", + "byteLength": 530, + "protocol": "artnet", + "artnet": { + "id": "Art-Net\u0000", + "opcode": 20480, + "sequence": 0, + "net": 0, + "subUni": 0, + "length": 512, + "nonZeroSlots": [] + } + }, + { + "receivedAt": "2026-07-12T02:32:58.989Z", + "byteLength": 638, + "protocol": "sacn", + "sacn": { + "sourceName": "PlotForge Local Smoke", + "priority": 100, + "sequence": 11, + "universe": 1, + "length": 512, + "nonZeroSlots": [ + { + "address": 1, + "value": 255 + }, + { + "address": 2, + "value": 64 + } + ] + } + }, + { + "receivedAt": "2026-07-12T02:32:58.989Z", + "byteLength": 638, + "protocol": "sacn", + "sacn": { + "sourceName": "PlotForge Local Smoke", + "priority": 100, + "sequence": 0, + "universe": 1, + "length": 512, + "nonZeroSlots": [] + } + }, + { + "receivedAt": "2026-07-12T02:32:58.989Z", + "byteLength": 638, + "protocol": "sacn", + "sacn": { + "sourceName": "PlotForge Local Smoke", + "priority": 100, + "sequence": 0, + "universe": 1, + "length": 512, + "nonZeroSlots": [] + } + }, + { + "receivedAt": "2026-07-12T02:32:58.989Z", + "byteLength": 530, + "protocol": "artnet", + "artnet": { + "id": "Art-Net\u0000", + "opcode": 20480, + "sequence": 8, + "net": 0, + "subUni": 0, + "length": 512, + "nonZeroSlots": [ + { + "address": 1, + "value": 255 + }, + { + "address": 2, + "value": 64 + } + ] + } + }, + { + "receivedAt": "2026-07-12T02:32:59.142Z", + "byteLength": 530, + "protocol": "artnet", + "artnet": { + "id": "Art-Net\u0000", + "opcode": 20480, + "sequence": 0, + "net": 0, + "subUni": 0, + "length": 512, + "nonZeroSlots": [] + } + }, + { + "receivedAt": "2026-07-12T02:32:59.142Z", + "byteLength": 530, + "protocol": "artnet", + "artnet": { + "id": "Art-Net\u0000", + "opcode": 20480, + "sequence": 0, + "net": 0, + "subUni": 0, + "length": 512, + "nonZeroSlots": [] + } + }, + { + "receivedAt": "2026-07-12T02:32:59.142Z", + "byteLength": 530, + "protocol": "artnet", + "artnet": { + "id": "Art-Net\u0000", + "opcode": 20480, + "sequence": 9, + "net": 0, + "subUni": 0, + "length": 512, + "nonZeroSlots": [ + { + "address": 1, + "value": 255 + }, + { + "address": 2, + "value": 64 + } + ] + } + }, + { + "receivedAt": "2026-07-12T02:32:59.294Z", + "byteLength": 530, + "protocol": "artnet", + "artnet": { + "id": "Art-Net\u0000", + "opcode": 20480, + "sequence": 0, + "net": 0, + "subUni": 0, + "length": 512, + "nonZeroSlots": [] + } + }, + { + "receivedAt": "2026-07-12T02:32:59.294Z", + "byteLength": 530, + "protocol": "artnet", + "artnet": { + "id": "Art-Net\u0000", + "opcode": 20480, + "sequence": 0, + "net": 0, + "subUni": 0, + "length": 512, + "nonZeroSlots": [] + } + }, + { + "receivedAt": "2026-07-12T02:32:59.298Z", + "byteLength": 530, + "protocol": "artnet", + "artnet": { + "id": "Art-Net\u0000", + "opcode": 20480, + "sequence": 10, + "net": 0, + "subUni": 0, + "length": 512, + "nonZeroSlots": [ + { + "address": 1, + "value": 255 + }, + { + "address": 2, + "value": 64 + } + ] + } + }, + { + "receivedAt": "2026-07-12T02:32:59.298Z", + "byteLength": 530, + "protocol": "artnet", + "artnet": { + "id": "Art-Net\u0000", + "opcode": 20480, + "sequence": 0, + "net": 0, + "subUni": 0, + "length": 512, + "nonZeroSlots": [] + } + }, + { + "receivedAt": "2026-07-12T02:32:59.298Z", + "byteLength": 530, + "protocol": "artnet", + "artnet": { + "id": "Art-Net\u0000", + "opcode": 20480, + "sequence": 0, + "net": 0, + "subUni": 0, + "length": 512, + "nonZeroSlots": [] + } + } + ] +} diff --git a/docs/plotforge-dmx-design-decisions-2026-06-30.md b/docs/plotforge-dmx-design-decisions-2026-06-30.md new file mode 100644 index 0000000..6dd6a66 --- /dev/null +++ b/docs/plotforge-dmx-design-decisions-2026-06-30.md @@ -0,0 +1,317 @@ +# PlotForge DMX Design Decisions + +Date: 2026-06-30 + +Status: companion rationale for `plotforge-dmx-integration-plan-2026-06-30.md` + +## Purpose + +This document records the D0 decisions behind the PlotForge DMX integration plan. The integration plan is the implementation contract; this document explains why the decisions were made and what evidence should prove each choice during later phases. + +## Source Checks + +Protocol and platform checks used for these decisions: + +- ESTA published documents list ANSI E1.11 - 2024 as current for DMX512-A, and ANSI E1.31 - 2025 as current for sACN with IPv4 and IPv6 support: +- Art-Net 4 specification documents `OpDmx` / `OpOutput` as OpCode `0x5000`, identifies the Art-Net UDP port as `0x1936`, and defines the Port-Address as a 15-bit Net + Sub-Net + Universe value: +- Apple documents `NSLocalNetworkUsageDescription` as the purpose string for local network access: +- Apple documents `com.apple.developer.networking.multicast` as an entitlement requiring Apple permission: +- Apple local-network privacy guidance says local network activity triggers user permission and needs an Info.plist usage description: +- DMXKing eDMX1 MAX official page describes a single-universe Ethernet DMX512 node supporting Art-Net and sACN: +- ENTTEC ODE Mk3 official page describes a two-universe Ethernet to DMX converter supporting Art-Net and sACN, with web UI configuration: + +Prices are intentionally not locked here. Vendor pricing and availability change faster than the design decision. Verify current hardware pricing before purchase. + +## Decision 1: First Hardware Target + +Decision: use a low-cost Ethernet Art-Net/sACN node as the primary v0 hardware target, with DMXKing eDMX1 MAX and ENTTEC ODE Mk3 as candidate classes. Park USB DMX and ETC gateways. + +Rationale: + +- Ethernet nodes work from macOS, iPadOS, and browser-plus-local-relay paths without vendor USB drivers. +- A single-universe Art-Net/sACN node is enough to prove buffer compilation, packet encoding, target addressing, blackout, and evidence capture. +- ETC gateways are operationally relevant but add venue-network assumptions and higher cost to an early smoke path. +- USB DMX creates platform-specific driver and iPad attachment problems before the output model is proven. + +Implementation consequence: + +- D3 should buy or borrow one known Ethernet node, not a USB interface. +- Hardware docs must name the exact device, firmware, protocol, universe, IP, and DMX connector used. + +Verification: + +- Relay log. +- Packet capture. +- Node web UI screenshot. +- Fixture or dimmer video for fixture-verified status. + +## Decision 2: Protocol Order + +Decision: implement Art-Net unicast first; add sACN as a planned co-equal protocol after Art-Net hardware smoke. + +Rationale: + +- Art-Net `ArtDmx` packet generation is compact and fits the current no-dependency Node UDP relay pattern. +- Art-Net unicast avoids multicast entitlement and network-behavior work during the first iPad and browser relay tests. +- sACN is still important because it is the ANSI E1.31 transport path and is common in modern venues. + +Implementation consequence: + +- D2 is Art-Net only. +- D4 adds sACN against the same universe compiler and test-vector corpus. + +Verification: + +- Art-Net golden packet tests before relay hardware work. +- sACN golden packet tests before declaring D4 complete. + +## Decision 3: Fixture Schema + +Decision: use a flat, slot-indexed typed channel descriptor schema. + +Rationale: + +- PlotForge is not a console profile engine. +- A flat schema answers the needed output-test question: which slot gets which value and what is safe to write. +- Slot-indexed descriptors are easy to diff, test, serialize, and mirror into Swift. +- The schema is smaller than GDTF but still supports fine channels, ranges, unsafe flags, and raw slots. + +Implementation consequence: + +- D1 should define the schema and a minimal validator with deterministic error messages. +- UI controls should be derived from channel `type`, `ranges`, `safeMin`, `safeMax`, `pairedFine`, and `unsafe`. + +Verification: + +- JSON schema or validator tests for valid/invalid descriptors. +- Golden fixture maps for generic dimmer, RGB, RGBW, and at least one moving-light-like stub. + +## Decision 4: GDTF Role + +Decision: use GDTF as an import source only; require explicit approval before a derived mapping becomes output-ready. + +Rationale: + +- GDTF can contain rich fixture details, but inferred mappings can be wrong or incomplete. +- Multi-cell fixtures, virtual channels, naming variance, and range semantics are not safe enough for automatic live output. +- A candidate mapping workflow gives value without pretending that import equals hardware-safe control. + +Implementation consequence: + +- D5 may parse GDTF into candidate slot maps. +- The app must show a diff and require an "Approve Mapping" action before a GDTF-derived profile can emit output. + +Verification: + +- Candidate mapping tests. +- Approval-state serialization tests. +- UI smoke showing unapproved profiles remain preview-only. + +## Decision 5: Shared Profiles + +Decision: maintain one platform-neutral JSON profile store consumed by web and native. + +Rationale: + +- Output mapping bugs are too risky to duplicate separately in JavaScript and Swift. +- A shared JSON source lets web and native verify against the same fixtures and buffers. +- Versioned schema migration is easier than reconciling divergent profile literals later. + +Implementation consequence: + +- D1 should create test vectors in a form native can later consume. +- D5 should move from starter local maps to a shared profile package or repo. + +Verification: + +- Web and Swift tests read the same fixture map fixture files. +- Byte-for-byte output buffer parity for the shared corpus. + +## Decision 6: Complex Channel Types + +Decision: support complex channel types in the schema but gate unsafe/movement controls in v0. + +Rationale: + +- The schema should not need a breaking redesign for pan/tilt, wheels, shutter, strobe, or reset. +- The UI should not casually expose parameters that can move fixtures, strobe, or trigger reset. +- Paired coarse/fine channels need to be written together to avoid abrupt movement. + +Implementation consequence: + +- v0 shows safe controls by default. +- Unsafe and movement controls need a per-session "Show Unsafe Channels" gate. +- Reset needs typed confirmation. + +Verification: + +- Compiler tests prove paired fine bytes are written in the same frame. +- UI tests prove unsafe channels are hidden until explicitly enabled. + +## Decision 7: Conflicts + +Decision: block output by default when a universe has fixture overlap or invalid ranges. + +Rationale: + +- Output testing should not guess which fixture wins a slot conflict. +- Blocking the universe makes patch defects visible and protects against unexpected output. +- A future override may exclude conflicted fixtures, but it should never overwrite or suppress conflicts silently. + +Implementation consequence: + +- D1 ships block-all behavior. +- Any override is deferred and per-session only. + +Verification: + +- Overlap and out-of-range tests assert no universe buffer is emitted. +- UI tests show blocked state and the responsible fixture ranges. + +## Decision 8: Frame Rate + +Decision: use 20 Hz as the v0 hardware smoke default, raise to 30 Hz only after stable tests, and enforce protocol ceilings in code. + +Rationale: + +- Test output does not need maximum refresh. +- Lower initial rate is easier to inspect with logs and packet captures. +- Physical DMX gateways usually top out around DMX refresh realities, so UI settings must not offer impossible rates. + +Implementation consequence: + +- Persisted `maxFps` cannot exceed relay-enforced limits. +- D2 packet tests and relay tests need timing/rate-limit coverage. + +Verification: + +- Relay rejects rates above its ceiling. +- Hardware smoke records actual configured frame rate. + +## Decision 9: Blackout + +Decision: blackout is burst-then-cease by default. + +Rationale: + +- A blackout command should feel like stopping output after sending a clear zero state. +- Continuous zeros can mask whether output is still active. +- Some nodes may need zero keep-alive behavior; that should be an explicit opt-in mode. + +Implementation consequence: + +- Default blackout sends a short zero burst, then stops transmission. +- `blackoutKeepAlive` exists but defaults off and must be clearly labeled. + +Verification: + +- Relay tests prove exactly the configured number of zero frames are emitted. +- Hardware smoke proves fixture returns to zero after blackout. + +## Decision 10: Network Interface Selection + +Decision: confirm the output interface explicitly before first output, and halt output if the confirmed interface disappears. + +Rationale: + +- Lighting laptops and iPads may have Wi-Fi, Ethernet, VPN, and virtual interfaces active. +- Sending to the wrong interface can look like an app failure or, worse, hit the wrong network. +- Interface names drift; MAC or stable hardware identity is safer when available. + +Implementation consequence: + +- D2/D3 should expose interface evidence even if v0 starts with a manual target host. +- Native output must handle iPad network changes and backgrounding separately. + +Verification: + +- Manual smoke with Wi-Fi/Ethernet/VPN states. +- Fault state when the active interface disappears. + +## Decision 11: Verified Output Evidence + +Decision: require tiered evidence for hardware output. + +Rationale: + +- Relay success only proves that PlotForge tried to send a packet. +- Packet capture proves network emission. +- Node UI proves the node received/understood it. +- Fixture video proves real-world output. + +Implementation consequence: + +- D3 must ship a smoke protocol and evidence folder template. +- "Verified" and "fixture verified" should mean different things. + +Verification: + +- Minimum verified: relay log, packet capture, node screenshot. +- Fixture verified: minimum set plus fixture video. + +## Decision 12: Native iPad Path + +Decision: start native iPad with unicast Art-Net and local-network permission handling; keep sACN multicast behind entitlement readiness. + +Rationale: + +- Local network access needs a user-facing usage description. +- Multicast networking requires Apple permission for the entitlement. +- Early iPad tests should avoid blocking on entitlement and provisioning work. +- Backgrounding a live-output app is a safety event. + +Implementation consequence: + +- Native Info.plist needs `NSLocalNetworkUsageDescription` before UDP output. +- sACN multicast waits for entitlement approval and provisioning profile support. +- On background or resign-active, send the blackout burst and close output. + +Verification: + +- Signed iPad smoke covers permission denied, permission allowed, background, lock, and reopen behavior. + +## Decision 13: RDM Scope + +Decision: keep RDM out of v0 and v1 output. A future commissioning mode may read RDM data only. + +Rationale: + +- RDM discovery interrupts or changes the DMX line timing model. +- RDM SET commands can modify real fixtures. +- Output testing should avoid becoming fixture management. + +Implementation consequence: + +- D2/D3 do not implement ArtRdm or RDM SET/GET. +- D5 may store RDM-related metadata but must not send RDM. + +Verification: + +- Relay rejects unsupported RDM command types. +- Docs and UI do not imply RDM commissioning support in v0/v1. + +## Decision 14: Not A Console UI + +Decision: present output as "Output Test Mode", not a show console. + +Rationale: + +- PlotForge is a drafting and preflight tool. +- Live busking, cue stacks, fades, and show playback create a different reliability and safety bar. +- Language, color, layout, and confirmation flows should prevent scope confusion. + +Implementation consequence: + +- Output-active screens carry a persistent amber "OUTPUT TEST MODE - Not for show use" banner. +- Use test-oriented labels: Test Patch, Channel Test, Send Test Frame, Test Rig. +- Blackout is always one tap. +- First output per session confirms interface, protocol, universe, and fixture count. + +Verification: + +- UI tests assert the banner and confirmation gate exist. +- Browser and native smoke prove blackout remains available during output. + +## Open Follow-Up + +These decisions unblock D1. They do not remove the need to recheck current protocol specs, Apple platform behavior, and hardware firmware before D2/D3 implementation. Any change that affects live packet bytes, local-network permissions, or hardware proof must update both this document and the integration plan. diff --git a/docs/plotforge-dmx-hardware-smoke-2026-06-30.md b/docs/plotforge-dmx-hardware-smoke-2026-06-30.md new file mode 100644 index 0000000..df7e92d --- /dev/null +++ b/docs/plotforge-dmx-hardware-smoke-2026-06-30.md @@ -0,0 +1,213 @@ +# PlotForge D3 DMX Hardware Smoke Protocol + +Date: 2026-06-30 + +Status: protocol and evidence scaffold prepared; physical hardware smoke not yet run + +## Scope + +This protocol is the D3 gate for PlotForge DMX output. It proves the path from PlotForge web UI to local DMX relay, Art-Net UDP packet, Ethernet node, DMX output, and one known fixture or dimmer channel. + +Passing this protocol upgrades D3 from local simulator/relay proof to hardware verified. Relay logs alone do not satisfy D3. + +## Hardware Target + +Primary target class: low-cost Ethernet Art-Net/sACN node. + +Current candidate check, 2026-06-30: + +- Primary: [DMXKing eDMX1 MAX](https://shop.dmxking.com/eDMX1-MAX_p_59.html) — shop listing reviewed; one-universe Art-Net/sACN node, USD 240.00, listed in stock. +- Alternate: [ENTTEC ODE Mk3](https://www.enttec.com/product/dmx-ethernet/ode-mk3-dmx-ethernet-converter/) — manufacturer listing reviewed; two-universe Ethernet-to-DMX node with Art-Net/sACN support, web UI, PoE/DC power, and 44 FPS max refresh. [B&H listing](https://www.bhphotovideo.com/c/product/1767487-REG/enttec_70407_ode_mk3_ethernet_dmx.html) reviewed for US retail availability: USD 319.00, listed in stock. +- Purchasing note: verify final price, stock, shipping date, and return policy immediately before buying. Do not treat this protocol as purchase approval. + +## Required Equipment + +- Mac running the PlotForge checkout. +- Ethernet connection to the Art-Net node, preferably wired and isolated from production networks. +- One Art-Net/sACN Ethernet node configured for Art-Net unicast. +- One dimmer channel or fixture with a known safe intensity channel. +- DMX cable and terminator as needed. +- Screenshot tool for the node UI. +- Packet capture tool: Wireshark or `tcpdump`. +- Optional camera for fixture video. + +## Safety Preconditions + +- Test on a bench or isolated rig, not a live show network. +- Use a known safe dimmer or static fixture first. Do not start with a moving light, strobe, reset, macro, or hazardous effect. +- Patch one fixture to universe 1, address 1 where possible. +- Confirm node output is not connected to production fixtures. +- Keep a physical way to remove fixture power or DMX. +- Confirm the relay token is visible only in the local terminal and is not written into `.plot`. + +## Setup + +1. Install dependencies with Node 22 if needed. +2. Run the local pre-hardware smoke before connecting a real node: + +```bash +PATH=/opt/homebrew/opt/node@22/bin:$PATH npm run dmx:local-smoke +``` + +Expected result: `docs/dmx-smoke-evidence/local/local-smoke-latest.json` shows pass results for relay health, origin rejection, no-output-before-frame, bad target rejection, UDP-listener ArtDmx frame, Art-Net blackout burst, UDP-listener sACN frame, sACN blackout burst, heartbeat timeout blackout, client disconnect blackout, and relay shutdown blackout. This is useful setup proof, but it does **not** satisfy D3 or D4 hardware verification. + +3. Confirm the hardware evidence folder is still failing closed before the run: + +```bash +PATH=/opt/homebrew/opt/node@22/bin:$PATH npm run dmx:evidence-check -- --protocol artnet +``` + +Expected pre-run result: the command fails and lists missing hardware artifacts. After a real hardware run, the same command must pass before D3 is marked verified. +4. Start PlotForge: + +```bash +PATH=/opt/homebrew/opt/node@22/bin:$PATH npm run dev -- --host 127.0.0.1 --port 5173 +``` + +5. Start the DMX relay: + +```bash +PATH=/opt/homebrew/opt/node@22/bin:$PATH \ + PLOTFORGE_DMX_TARGET_HOST= \ + PLOTFORGE_DMX_TARGET_PORT=6454 \ + PLOTFORGE_DMX_ALLOWED_ORIGINS=http://127.0.0.1:5173,http://localhost:5173 \ + npm run dmx:relay 2>&1 | tee docs/dmx-smoke-evidence/2026-06-30/relay.log +``` + +6. Copy the relay token from the relay terminal into the Output panel. +7. Configure the node for Art-Net unicast, Net 0, Sub-Net 0, Universe 0. +8. Open the node UI and capture `node-ui.png`. +9. Start packet capture before sending any frames. + +Example `tcpdump` command: + +```bash +sudo tcpdump -i udp port 6454 -w docs/dmx-smoke-evidence/2026-06-30/capture.pcap +``` + +## Test Matrix + +### 1. No Output Without Arming + +Steps: + +- Open PlotForge Output. +- Leave output idle. +- Confirm Send Test Frame and Blackout are disabled. +- Confirm no Art-Net packets appear in capture. + +Pass: + +- No UDP packets emitted before arm. +- UI state is idle. + +### 2. Arm + +Steps: + +- Enter relay URL, token, target host, target port, and Art-Net Net/Sub-Net/Universe. +- Click Arm output. + +Pass: + +- UI state changes to armed. +- Relay logs authenticated session. +- No nonzero frame is sent just by arming. + +### 3. Selected Intensity Test + +Steps: + +- Select the safe fixture/dimmer. +- Set Dimmer to 255 and RGBW values to 0 unless the test fixture is RGBW. +- Click Send test frame. + +Pass: + +- Relay logs `frame.ok`. +- Packet capture shows ArtDmx to UDP port 6454 with `Art-Net\0`, OpDmx `0x5000`, Net 0, Sub-Net 0, Universe 0, length 512. +- Node UI reports receive activity. +- Fixture/dimmer visibly turns on. + +### 4. Blackout + +Steps: + +- Click Blackout. + +Pass: + +- Relay logs `blackout.ok`. +- Packet capture shows the configured burst of all-zero ArtDmx frames. +- Fixture/dimmer returns to zero. + +### 5. Bad Target + +Steps: + +- Change target host to a non-existent private address. +- Try Send test frame. + +Pass: + +- UI surfaces a fault or visible failure state. +- No operator could confuse the failure for verified output. + +### 6. Network Disconnect + +Steps: + +- Restore the real node target and send one selected fixture test. +- Disconnect the node network path or DMX node power. +- Try Send test frame or observe relay/node fault behavior. + +Pass: + +- UI or relay evidence shows a visible failure path. +- The node/fixture state is not recorded as verified while disconnected. + +### 7. Relay Crash Or Tab Failure + +Steps: + +- Arm and send one test frame. +- Kill the relay or stop heartbeats by closing the tab. + +Pass: + +- Relay watchdog emits blackout before fault where relay is still alive. +- UI moves to fault when the relay connection drops. +- Fixture/dimmer does not remain at a stale nonzero value after the watchdog path. + +### 8. Blackout Recovery + +Steps: + +- Restart the relay if needed. +- Re-arm output. +- Send one selected fixture test. +- Click Blackout. + +Pass: + +- Fixture/dimmer returns to zero after the recovery send. +- UI remains recoverable and can disarm cleanly. + +## Evidence Requirements + +Minimum D3 verified evidence: + +- `relay.log` +- `capture.pcap` or `capture.pcapng` +- `node-ui.png` +- Completed `docs/dmx-smoke-evidence/2026-06-30/README.md` +- Passing `PATH=/opt/homebrew/opt/node@22/bin:$PATH npm run dmx:evidence-check -- --protocol artnet` + +Fixture verified upgrade: + +- Add `fixture-video.mov` or `fixture-video.mp4` showing selected test and blackout. +- Pass `PATH=/opt/homebrew/opt/node@22/bin:$PATH npm run dmx:evidence-check -- --protocol artnet --fixture-verified` + +## Current D3 Status + +Protocol and evidence scaffold are ready. D3 is not complete until a real Art-Net node and fixture/dimmer channel are run through this protocol and the evidence files are retained. diff --git a/docs/plotforge-dmx-integration-plan-2026-06-30.md b/docs/plotforge-dmx-integration-plan-2026-06-30.md new file mode 100644 index 0000000..c41bb6e --- /dev/null +++ b/docs/plotforge-dmx-integration-plan-2026-06-30.md @@ -0,0 +1,678 @@ +# PlotForge DMX Integration Plan + +Date: 2026-06-30 + +Status: D0 resolved; D1 web simulator/buffer compiler, D2 Art-Net relay MVP, D4 sACN encoder/web relay/UI unicast path, D5 fixture personality/output-readiness slice, and the D6 native compiler/UI/Art-Net UDP slice implemented locally on 2026-06-30; D3/D4 hardware smoke protocol/evidence scaffolds prepared; D6 native physical smoke evidence checker prepared; D3/D4 physical smoke plus D6 signed iPad output smoke remain pending + +## Review Notes (2026-06-30) + +Verdict: approve the direction and proceed with D1 as written. The six-layer architecture and the safety philosophy are strong. Three items must be resolved before the phase that depends on them, and the most important live-output safety control — a relay-side dead-man's watchdog — was missing and has now been folded into the Relay Layer and Safety Rules below. + +- Blocking, before D2: relay watchdog/dead-man's switch (see Relay Layer, Safety Rules); Art-Net Port-Address encoding as 15-bit Net/Sub-Net/Universe (see Transport Encoder Layer, Data Model Sketch); HTTPS-to-`ws://localhost` mixed-content constraint (see Safety Rules). +- High: continuous-refresh keep-alive while armed (see Relay Layer, Safety Rules); ArtDmx parity/opcode/sequence pinned in golden tests (see D2 acceptance). +- Medium: full-frame vs partial-frame decision (see Transport Encoder Layer); shared compiler test-vector corpus pulled forward into D1 (see D1); conflict-override default set to block-all for v0 (see D1). +- Resolved (2026-06-30): all fourteen original Research Questions are now answered and recorded in [Decisions (D0)](#decisions-d0), sourced from the companion [DMX design decisions document](plotforge-dmx-design-decisions-2026-06-30.md). Notable consequences folded into this plan: blackout is burst-then-cease (not perpetual zeros); v0 frame rate default drops to 20 Hz with per-protocol hard ceilings; the fixture schema is the typed channel-descriptor array; and the hardware "verified" bar is tiered (relay log + packet capture + node screenshot, fixture video for "fixture verified"). + +## Intent + +PlotForge already knows enough about fixtures, patch addresses, footprints, conflicts, status, notes, OSC export, and native/web document compatibility to become useful for output verification. The right next step is not to turn PlotForge into a full lighting console. The right next step is to add a controlled DMX output layer that can preview and test what the plotted patch says. + +The product stance should stay conservative: + +- PlotForge remains a drafting, paperwork, preflight, and handoff tool. +- DMX output is opt-in and disabled by default. +- Live output must require an explicit arm action every session. +- The first implementation should drive a simulator and one test universe before it drives real rigs. +- Any real output must have visible target, protocol, universe, rate, conflict, and blackout state. + +## Current Foundation + +The current codebase gives us useful starting points: + +- The web document model stores per-fixture channel, DMX universe, DMX address, profile, mode, status, focus, circuit, dimmer, and note data. +- DMX conflicts are already footprint-aware and visible in patch/check surfaces. +- Fixture profiles already carry a DMX footprint. +- The web app includes an OSC bridge panel and `scripts/osc-relay.mjs`, which proves a browser-to-local-WebSocket-to-UDP relay pattern. +- Native code preserves blank DMX patch fields and has export support for OSC JSON and interop JSON. +- MVR import is still parked on a Vectorworks sample corpus, so DMX integration should not depend on MVR. + +The missing foundation is fixture personality detail. A footprint tells PlotForge how many slots a fixture occupies, but it does not say which slot is intensity, red, green, blue, pan, tilt, shutter, strobe, zoom, gobo, color wheel, or control reset. That means the earliest DMX feature should compile and inspect buffers safely, while real fixture parameter control should start with a limited set of known generic profiles. + +## Protocol Context + +DMX512-A is the baseline lighting control concept: a controller sends repeated values to controlled devices over up to 512 slots per universe. ESTA lists ANSI E1.11 - 2024 as the current DMX512-A standard. The older published E1.11 document describes DMX512-A as a method of digital transmission between controllers and controlled equipment, including dimmers, and notes that it is for repetitive control data for lighting devices and related non-hazardous effects. + +Art-Net and sACN are the practical network transports: + +- Art-Net carries DMX512 and RDM style data over Ethernet. The official Art-Net 4 specification identifies `OpDmx` as the packet that carries zero-start-code DMX512 data for a single universe, and Art-Net uses UDP port `0x1936`, decimal `6454`. +- sACN, ANSI E1.31, carries DMX512 style data and metadata over IP networks using an ACN subset. ESTA lists ANSI E1.31 - 2025 as current and notes IPv6 support in that revision. + +For PlotForge, Art-Net is the simpler first network output target because the packet is compact, many low-cost nodes support it, and it fits the existing no-dependency UDP relay style. sACN is important, but it should come after the buffer compiler and output safety model are proven. + +## Product Goals + +The DMX feature set should answer these operator questions: + +- Does the document patch compile into the universe and slot values I expect? +- Are there overlaps, out-of-range addresses, missing universes, or incomplete profile mappings? +- Can I safely send a selected fixture test to a local node? +- Can I blackout all output if the app, relay, or target state is unclear? +- Can I generate evidence that the output mapping matched the paperwork? + +The feature should not promise: + +- Cue stack playback. +- Timecode. +- Show-critical live busking. +- Full moving-light programming. +- RDM discovery/configuration in the first pass. +- USB DMX hardware in the first pass. +- Hazardous effects control. +- Cloud-routed live output. + +## Proposed Architecture + +Use six layers. + +### 1. Document Patch Layer + +Existing document data remains the source for fixture identity, universe, address, profile, mode, and footprint. The core invariant is that a fixture can only compile into a universe buffer when: + +- It has a positive integer universe. +- It has a positive integer address. +- Its address plus footprint stays within 512. +- Its occupied range does not overlap another output-enabled fixture in that universe. +- Its profile has a known output map, or the output mode is explicitly raw slot testing. + +Do not use `armed`, `live`, or current channel levels as persistent document state. The `.plot` file can remember output preferences, but not whether a session is armed. + +### 2. Output Intent Layer + +Add an in-memory output state that represents what the operator is trying to do: + +```text +idle +preview +armed +sending +blackout +fault +``` + +Output intent should include: + +- Selected fixture test. +- Whole patch blackout. +- Whole patch hold at zero. +- Fixture intensity test. +- RGBW color test for mapped fixtures. +- Raw slot test for a selected fixture range. + +The UI should show this state in plain language. If output is armed, it should be impossible to miss. + +### 3. Fixture Parameter Mapping Layer + +The mapping schema is a flat, slot-indexed list of typed channel descriptors — no hierarchy, no console-style attribute trees. It answers exactly one question per fixture: which DMX slot does what, and what value range is safe to write. (Decided in D0; see [Decisions (D0)](#decisions-d0) §3.) + +```json +{ + "id": "uuid-or-slug", + "schemaVersion": 1, + "label": "Robe ROBIN 600E Spot - Mode 2", + "footprint": 24, + "channels": [ + { "slot": 1, "label": "Dimmer", "type": "intensity", "default": 0, "safeMin": 0, "safeMax": 255 }, + { "slot": 2, "label": "Pan", "type": "panCoarse", "default": 128, "pairedFine": 3 }, + { "slot": 3, "label": "Pan Fine", "type": "panFine", "default": 128 }, + { "slot": 4, "label": "Shutter/Strobe", "type": "shutter", "default": 0, + "ranges": [ + { "label": "Closed", "dmxFrom": 0, "dmxTo": 3 }, + { "label": "Open", "dmxFrom": 4, "dmxTo": 7 }, + { "label": "Strobe", "dmxFrom": 64, "dmxTo": 95, "unsafe": true } + ] + }, + { "slot": 9, "label": "Fixture Reset", "type": "reset", "unsafe": true, + "triggerValue": 255, "holdMs": 3000 } + ] +} +``` + +Rules: + +- `slot` is 1-based and maps directly to the universe offset; no abstraction layer. +- `type` is an enum (`intensity`, `panCoarse`, `panFine`, `tiltCoarse`, `tiltFine`, `colorWheel`, `goboWheel`, `shutter`, `strobe`, `reset`, `raw`). The renderer uses it to choose a fader, a wheel/indexed picker, or a locked input. +- `pairedFine` links coarse/fine slots so a 16-bit value is presented as one 0-65535 control and both bytes are always written in the same frame. Never write coarse without its declared fine — partial writes cause fixture lurches. +- `unsafe: true` on a channel or range gates output behind an explicit, per-session confirmation. +- `safeMin`/`safeMax` are per-channel output clamps enforced at the serialization layer, not just the UI. +- Values are clamped to 0-255; unknown/unmapped slots default to 0. + +The schema can represent pan/tilt, reset, and other moving-light parameters, but v0 keeps those types gated/hidden in the UI (revealed only by a per-session "Show Unsafe Channels" toggle) so the schema is complete while v0 behavior stays conservative. Continuous types get type-aware fader rendering; pan/tilt carry a slew-rate guard to avoid motor slam. This map can live beside fixture profiles initially, then move to the shared canonical JSON store (see [Decisions (D0)](#decisions-d0) §5). + +### 4. Universe Compiler + +The compiler turns document fixtures plus output intent into universe buffers: + +```text +PlotShowDocument + -> selected output profile mappings + -> validation report + -> Map + -> transport frames +``` + +Compiler responsibilities: + +- Produce one 512-slot buffer per active universe. +- Track which fixture wrote each slot for explainability. +- Return blocking errors for overlaps and out-of-range ranges. +- Return warnings for incomplete mappings. +- Support blackout by emitting all-zero buffers for every active universe. +- Support selected fixture test by zeroing all other fixtures by default. +- Produce deterministic output for tests. + +The compiler should be pure domain logic with no browser, native, UDP, or file-system dependency. + +### 5. Transport Encoder Layer + +Transport encoders convert universe buffers into protocol packets. + +Art-Net v0 should support: + +- `ArtDmx` packet generation with the `Art-Net\0` ID and OpCode `0x5000` (`OpDmx`). +- Configurable target host. +- Configurable target port, default `6454`. +- Configurable universe mapping. The Art-Net Port-Address is 15-bit, split across Net (7-bit), Sub-Net (4-bit), and Universe (4-bit). Do not model it as a flat integer — a flat int silently caps output at one Net/Sub-Net and mis-routes once a universe exceeds 15. Encode the three fields explicitly. +- Even data length only (2-512). Pad odd lengths up; set LengthHi/LengthLo accordingly. v0 sends full 512-slot frames for determinism; partial frames are a later optimization. +- Sequence field policy, starting simple: `0` disables sequence checking, otherwise increment 1-255 with wraparound. +- Unicast first. + +sACN v1 should support later: + +- Source name. +- CID. +- Universe. +- Priority. +- Sequence number. +- Preview-data flag. +- Multicast or unicast target mode. +- IPv4 first, then IPv6 after the current standard revision is reviewed in detail. + +Keep transport encoders isolated and covered by golden packet tests. A packet encoder bug can put values on the wrong universe. + +### 6. Relay Layer + +Browsers cannot send raw UDP directly, so the web app needs a local relay. The existing OSC relay shape is a useful precedent: + +```text +Browser UI + -> WebSocket JSON command + -> local Node relay + -> UDP Art-Net or sACN packet + -> lighting node + -> DMX line + -> fixture +``` + +Create a separate relay rather than overloading `osc-relay.mjs`: + +```text +scripts/dmx-relay.mjs +``` + +Relay responsibilities: + +- Bind only to `127.0.0.1` by default. +- Expose `/health`. +- Accept WebSocket commands from the local app, but only after authenticating the connection (see below). A loopback bind is not access control: any process or browser tab on the machine can reach `127.0.0.1:8766`, so a malicious page the user has open could arm and drive the rig (cross-site WebSocket hijacking / DNS-rebinding class). Treat every connection as untrusted until it proves origin and token. +- Reject the WebSocket upgrade unless the `Origin` header is in an allowlist. Default allowlist is the local dev origins only (e.g. `http://localhost:5173`, `http://127.0.0.1:5173`); the hosted Vercel origin is never allowlisted, which is consistent with the mixed-content reality that an HTTPS page cannot open `ws://127.0.0.1` anyway. +- Require a per-launch bearer token. The relay generates a random token at startup, prints it to its own stdout, and rejects any command session that does not present it in the auth handshake. The token is a relay-side/session secret: never persist it in the `.plot` document and never log it from the browser. A user copies it from the relay console into the Output panel once per launch (or the dev launcher injects it). +- Validate protocol, target host, port, universe, and payload length. +- Emit Art-Net first. +- Rate-limit frame sends to a per-protocol ceiling (see [Decisions (D0)](#decisions-d0) §8). While armed and sending live values, stream a continuous keep-alive at a minimum refresh — some nodes and fixtures fail-safe or hold stale values without periodic refresh, so live output is a steady stream, not change-only. This applies only to live (non-zero) output, not to blackout. +- Blackout is burst-then-cease, not perpetual zeros: emit a short burst of all-zero frames (default 5) at the operating rate, then stop transmitting. A test tool's blackout must feel like cutting power, not dimming to zero, so output truly stops. An opt-in, default-off "keep alive" toggle sends continuous zeros for nodes whose data-loss failsafe is hold-last-look or go-to-scene rather than zero; it must be clearly labeled because it means output never fully stops. (Decided in D0; see [Decisions (D0)](#decisions-d0) §9.) +- Run a dead-man's watchdog: while armed, if no heartbeat or frame arrives from the UI within a short timeout, auto-emit blackout and drop to fault. This must not depend on a clean disconnect — a crashed tab, killed renderer, or frozen UI cannot be relied on to flush a blackout. This watchdog is the primary live-output safety control. +- Return structured success/failure messages. +- Emit blackout frames on shutdown if configured. +- Refuse public network binding unless an explicit environment variable is set. + +Potential environment variables: + +```text +PLOTFORGE_DMX_WS_PORT=8766 +PLOTFORGE_DMX_PROTOCOL=artnet +PLOTFORGE_DMX_TARGET_HOST=127.0.0.1 +PLOTFORGE_DMX_TARGET_PORT=6454 +PLOTFORGE_DMX_MAX_FPS=20 # v0 default; per-protocol hard cap enforced in code (see D0 §8) +PLOTFORGE_DMX_MIN_REFRESH_FPS=1 # live-output keep-alive only, not blackout +PLOTFORGE_DMX_BLACKOUT_BURST=5 # all-zero frames sent on blackout before transmission ceases +PLOTFORGE_DMX_BLACKOUT_KEEPALIVE=0 # opt-in: send continuous zeros instead of ceasing (default off) +PLOTFORGE_DMX_HEARTBEAT_TIMEOUT_MS=1000 +PLOTFORGE_DMX_ALLOW_PUBLIC_BIND=0 +PLOTFORGE_DMX_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 +PLOTFORGE_DMX_TOKEN= # blank = generate a random token at startup and print it +PLOTFORGE_DMX_REQUIRE_TOKEN=1 # set 0 only for headless local tests, never for hardware +``` + +#### WebSocket Command Schema And Authentication + +Every connection runs a two-step handshake before any frame is accepted. The relay rejects, with a structured error and a closed socket, any session that sends a non-`auth` message first, presents a bad token, or arrives from a non-allowlisted origin. + +Step 1 — client authenticates immediately on connect: + +```json +{ + "type": "auth", + "protocolVersion": 1, + "token": "f3a9c2…" // copied from the relay console; matched in constant time +} +``` + +Relay reply: + +```json +{ "type": "auth.ok", "relayVersion": "0.1.0", "maxFps": 20, "boundHost": "127.0.0.1" } +``` + +or + +```json +{ "type": "auth.error", "reason": "bad-token" } // also: bad-origin, unsupported-version +``` + +Step 2 — only after `auth.ok`, the client may send commands. Every command repeats the token (cheap defense against a hijacked-but-unauthed socket racing in) and carries an explicit target so the relay never infers a destination: + +```json +{ + "type": "frame", + "token": "f3a9c2…", + "protocol": "artnet", + "target": { "host": "10.0.0.51", "port": 6454 }, + "portAddress": { "net": 0, "subNet": 0, "universe": 0 }, + "length": 512, + "data": "<512 bytes, base64>" +} +``` + +```json +{ "type": "heartbeat", "token": "f3a9c2…" } // resets the dead-man's watchdog +{ "type": "blackout", "token": "f3a9c2…" } // force all-zero on every active universe +``` + +Relay → client results are always structured: + +```json +{ "type": "frame.ok", "portAddress": { "net": 0, "subNet": 0, "universe": 0 }, "bytesSent": 530 } +{ "type": "frame.error", "reason": "odd-length", "detail": "length 511 is not even" } +{ "type": "fault", "reason": "heartbeat-timeout" } // relay has already emitted blackout +``` + +Validation the relay enforces per command, independent of the UI: token match, target host/port present and not a cloud/public address unless explicitly allowed, `portAddress` fields within range (net 0-127, subNet 0-15, universe 0-15), `length` even and 2-512, decoded `data` length equal to `length`. + +Native can eventually send UDP directly, but the first native pass should share the compiler and packet rules rather than inventing a separate behavior. Native does not need the WebSocket token (it owns the send path in-process), but it should keep the same per-command validation so behavior matches the relay byte-for-byte. + +## UI Proposal + +Add an Output panel, separate from Patch and OSC. + +Core regions: + +1. **Safety header** + - Output status: idle, preview, armed, sending, blackout, fault. + - Target protocol, host, port, and universes. + - Conflict count. + - Last frame time. + - Big arm/disarm control. + - Blackout button. + +2. **Protocol settings** + - Protocol: Preview only, Art-Net, later sACN. + - Relay URL. + - Target host. + - Target port. + - Universe map. + - Max frame rate. + - Blackout on disconnect toggle. + +3. **Universe inspector** + - Universe list. + - Used slot ranges. + - Fixture labels per range. + - Conflict/warning badges. + - Raw slot value table for selected universe. + +4. **Selected fixture test** + - Fixture label, position, unit, address range. + - Intensity slider if mapped. + - RGBW controls if mapped. + - Raw slot sliders for testing. + - Reset to zero. + +5. **Evidence export** + - Export output manifest JSON. + - Export last compiled buffer summary. + - Export hardware smoke notes template. + +## Data Model Sketch + +Persist only preferences and mappings: + +```json +{ + "dmxOutput": { + "version": 1, + "protocol": "preview", + "relayUrl": "ws://127.0.0.1:8766", + "targetHost": "127.0.0.1", + "targetPort": 6454, + "maxFps": 20, + "blackoutOnDisconnect": true, + "blackoutBurst": 5, + "blackoutKeepAlive": false, + "universeMap": { + "1": { + "artnet": { "net": 0, "subNet": 0, "universe": 0 }, + "targetHost": "10.0.0.51", + "label": "Stage Node A" + } + } + } +} +``` + +The Art-Net target universe is stored as explicit Net/Sub-Net/Universe fields rather than a flat `protocolUniverse` integer, so the 15-bit Port-Address is represented faithfully and the encoder has no ambiguity to resolve at send time. `targetHost`/`targetPort` at the top level are the default; a per-universe `targetHost` overrides it so distinct universes can route to distinct nodes. `maxFps` is the v0 default rate; the per-protocol hard ceiling (see [Decisions (D0)](#decisions-d0) §8) is enforced in code and the persisted value can never exceed it. + +Do not persist: + +- Armed state. +- Current fader values. +- Last live frame. +- Temporary raw slot test values. + +Those should be session state only. + +## Safety Rules + +Output should be blocked unless all of these are true: + +- The user explicitly arms output in the current session. +- The relay health check passes. +- The target host and port are visible. +- At least one universe compiles without blocking errors. +- Conflicting fixtures are either excluded or output is blocked. +- All out-of-range DMX addresses are blocked. +- Unsupported fixture personalities are either excluded or in raw test mode. + +Additional safeguards: + +- Default to zero output. +- Blackout on disarm, using burst-then-cease (default 5 zero frames, then stop) so output truly stops rather than holding zero forever; the perpetual-zeros keep-alive is opt-in and default-off (see Relay Layer, [Decisions (D0)](#decisions-d0) §9). +- Blackout on window unload where possible. +- Blackout on relay SIGINT where configured. +- Rate-limit output to the per-protocol ceiling (see [Decisions (D0)](#decisions-d0) §8). +- Run the relay dead-man's watchdog: blackout and fault on heartbeat timeout, independent of clean disconnect (see Relay Layer). +- Stream a continuous keep-alive while sending live (non-zero) values so a stalled UI is detected rather than latching the last frame on the rig. This is distinct from blackout, which ceases transmission. +- Refuse to send to cloud deployment targets. +- Never send from the hosted Vercel app directly to arbitrary internet hosts. +- Confirm the supported-browser path for an HTTPS page opening `ws://127.0.0.1`. Chromium treats `ws://localhost` as potentially-trustworthy and allows it; Firefox historically blocks it as mixed content. Record the supported-browser matrix so deployed-app output does not silently fail after preview works in dev. +- Do not support hazardous effects output without a separate explicit safety design. + +## Implementation Phases + +Definition of done rule: a phase is not called complete unless its deliverables are implemented, automated verification passes, and any listed manual or hardware evidence is retained. Local compiler, relay, and simulator proof is "implemented locally"; hardware phases stay pending until packet capture, node UI, and fixture/dimmer evidence exists. + +| Phase | Definition of done | Current status | +| --- | --- | --- | +| D0 Planning and protocol lock | Decisions for first transport, hardware target class, fixture mapping schema, safety model, and protocol order are written down and folded into this plan. | Done. | +| D1 Simulator and buffer compiler | Web compiler produces deterministic 512-slot buffers, blocks unsafe patch states, exposes preview/inspection UI, and passes compiler/UI tests without network output. | Implemented locally. | +| D2 Art-Net relay MVP | Local relay authenticates browser commands, emits Art-Net to a UDP listener, rejects unsafe commands, blackouts on timeout/disconnect/shutdown, and writes retained local smoke evidence. | Implemented locally. | +| D3 Hardware smoke | A real Art-Net node and safe fixture/dimmer pass arm, selected test, blackout, bad-target, and failure-path checks with relay log, packet capture, node UI screenshot, completed evidence notes, and passing evidence check; optional fixture video upgrades status. | Pending hardware. | +| D4 sACN support | sACN encoder, relay send path, UI protocol selection, controlled multicast mode, local smoke, and tests share the same compiler path; physical sACN node smoke passes before marking verified. | Implemented locally; pending hardware. | +| D5 Fixture personality expansion | Output schema, generic fixture maps, unsafe-channel defaults, slot explanations, and output-readiness UI exist, with paperwork-only fixtures clearly separated. | Implemented locally. | +| D6 Native output | Native compiler matches web bytes, native UI can arm/send/blackout through UDP, permission denial is clear, and signed physical iPad smoke proves output plus save/reopen safety with passing native physical evidence check. | Implemented and locally verified; pending signed physical iPad output smoke. | + +Current verification snapshot, 2026-06-30: + +- Web CI is green with Node 22 after reinstalling local dependencies from `package-lock.json`: `npm run ci` passed lint, 34 Vitest files / 172 tests, and production build. +- Additional web/domain checks passed: `node scripts/syntax-check.mjs` parsed 92/92 files, `node scripts/smoke-domain.mjs` passed 15/15 checks, `npm audit --omit=dev` found 0 production vulnerabilities, and `npm run dmx:local-smoke` passed with retained local Art-Net and sACN loopback evidence. +- DMX-focused automated set remains green through full CI, and the relay/panel/serialization/sACN focused set passed 37/37 assertions across 4 files after multicast support was added. +- Controlled sACN multicast support is implemented locally: the web Output panel can select unicast or sACN multicast, the relay derives the E1.31 multicast address from the universe (`239.255.x.y`) instead of accepting arbitrary public hosts, and focused relay/panel/serialization/sACN tests passed 37/37 assertions across 4 files. +- Hardware evidence is now machine-checkable: `npm run dmx:evidence-check -- --protocol artnet` fails closed until the retained Art-Net folder has `relay.log`, packet capture, node UI screenshot, the expected protocol, required hardware metadata, and all required pass checkboxes. The same checker enforces `--protocol sacn` for the sACN folder. Art-Net and sACN smoke execution is consolidated in `docs/plotforge-dmx-physical-smoke-runbook-2026-06-30.md`; the sACN scaffold lives at `docs/dmx-smoke-evidence/2026-06-30-sacn/`. +- Native physical evidence is now machine-checkable: `npm run native:physical-evidence-check` fails closed until the retained signed iPad smoke folder has CoreDevice process proof, launch JSON, installed-apps JSON, display JSON, lock-state JSON, visual proof, content-valid export artifacts, required notes metadata, and all native/D6 output pass rows. The capture helper auto-fills machine-known notes metadata. The Launch process gate can be satisfied by machine evidence; visual UI, export, output, and save/reopen gates still require retained operator proof. +- Combined physical status is available with `npm run physical:evidence-status`, which checks D3 Art-Net, D4 sACN, and D6 native evidence together and exits nonzero until all three gates pass. +- Native SwiftPM verification is green after hydrating previously dataless source files: `swift test --package-path native/PlotForgeNative --scratch-path /private/tmp/PlotForgeNativeScratchHydrated` passed 79/79 tests. Native export smoke passed and wrote `.plot`, PDF, CSV, OSC JSON, and interop JSON artifacts to `/private/tmp/plotforge-native-export-smoke-current`. +- Native unsigned app builds are green: iOS Simulator and Mac Catalyst `xcodebuild ... CODE_SIGNING_ALLOWED=NO build` both passed. +- David's iPad is paired and reachable through CoreDevice. The 2026-06-30 14:35Z launch capture succeeded with `launch_exit=0`, and `/private/tmp/plotforge-native-physical-smoke-2026-06-30-dmx-output/device-process.txt` lists `PlotForgeNative.app/PlotForgeNative`. Installed-apps, display, and lock-state JSON evidence was captured for the same folder. The native physical evidence checker still fails, correctly, until an operator captures visual proof, export artifacts, metadata, and passing D6 output gate rows. +- Hardware verification remains pending for D3 Art-Net, D4 sACN, and D6 signed physical iPad output. + +### D0: Planning And Protocol Lock + +Deliverables: + +- This document. +- Protocol decision for first transport. +- Sample hardware target decision. +- Fixture profile mapping decision for the first test fixture. + +Recommended decision: + +- Start with simulator and Art-Net unicast. +- Add sACN after Art-Net v0 has tests and one hardware smoke pass. +- Keep USB DMX parked. + +### D1: DMX Simulator And Buffer Compiler + +Implementation status: implemented locally on 2026-06-30 for the web app. The current D1 slice includes `src/domain/dmxOutput.js`, golden compiler vectors under `src/__tests__/fixtures/dmx-output/`, compiler and preview-panel tests, and a no-network Output tab with compiler warnings plus universe/range/slot inspection. It does not send Art-Net, sACN, OSC, WebSocket, or UDP. + +Deliverables: + +- Pure `src/domain/dmxOutput.js`. +- Unit tests for buffer compilation. +- Shared compiler test-vector corpus (golden input documents plus expected buffers), authored here so the future native compiler targets the same fixtures from the start rather than drifting and reconciling at D6. +- Output panel preview with no network sends. +- Conflict and warning report. +- Universe slot inspector. + +Acceptance: + +- Selected fixture test produces deterministic 512-slot buffers. +- Blackout produces all-zero buffers. +- Invalid ranges block output. +- Overlaps block output. v0 default is block-all: any overlap blocks the whole universe. An advanced exclude-only-conflicted override is deferred. +- Unknown personalities show warnings and do not emit mapped parameter values. + +### D2: Art-Net Relay MVP + +Implementation status: implemented locally on 2026-06-30. The current D2 slice includes `src/domain/artnet.js` for ArtDmx packet encoding, `src/domain/dmxRelay.js` for the no-dependency local relay server, `scripts/dmx-relay.mjs`, `npm run dmx:relay`, `scripts/dmx-local-smoke.mjs`, `npm run dmx:local-smoke`, packet golden tests, relay validation tests, a local UDP-listener send test, origin/token rejection tests, heartbeat-watchdog blackout test, authenticated active-client disconnect blackout test, and relay shutdown blackout smoke. This is still pre-hardware: it proves localhost UDP emission and relay safety behavior, not a physical node or fixture. + +Deliverables: + +- `scripts/dmx-relay.mjs`. +- `scripts/dmx-local-smoke.mjs`. +- Art-Net packet encoder. +- WebSocket command schema with the two-step auth handshake (origin allowlist + per-launch token). +- `/health` endpoint. +- Tests for packet bytes and relay validation. +- `npm run dmx:relay`. +- `npm run dmx:local-smoke`. +- `npm run dmx:evidence-check`. + +Acceptance: + +- Relay can send a selected fixture test to a UDP listener in local tests. +- Golden packet tests cover ArtDmx header (`Art-Net\0` ID, OpCode `0x5000`), Net/Sub-Net/Universe Port-Address encoding, even data length with correct LengthHi/LengthLo, sequence policy, and payload bytes. +- Relay refuses invalid universe, odd/oversized payloads, and out-of-range Port-Address fields. +- Relay rejects a connection from a non-allowlisted `Origin` and a command session with a missing or wrong token, in both cases without emitting any UDP. A `frame` sent before `auth.ok` is dropped. +- Relay watchdog blackouts and faults on heartbeat timeout in a local test. +- Relay attempts a blackout burst when an authenticated active client disconnects or the relay shuts down. +- Relay returns structured send results to the UI. +- Local smoke writes retained JSON evidence for health, origin rejection, no-output-before-frame, bad target rejection, UDP-listener send, blackout burst, heartbeat timeout blackout, active-client disconnect blackout, and relay shutdown blackout. + +### D3: Hardware Smoke + +Implementation status: protocol and evidence scaffold prepared locally on 2026-06-30 in `docs/plotforge-dmx-hardware-smoke-2026-06-30.md`, `docs/dmx-smoke-evidence/2026-06-30/`, and the consolidated `docs/plotforge-dmx-physical-smoke-runbook-2026-06-30.md`. The web Output panel now has an armed relay path with token auth, explicit target fields, Send Test Frame, Blackout, Disarm, relay fault display, persisted safe target preferences, and no persisted arm/token state. `docs/dmx-smoke-evidence/local/` records repeatable pre-hardware local smoke evidence. D3 is not complete until a real Art-Net node plus fixture/dimmer channel pass the protocol and retained evidence is added. + +Deliverables: + +- Hardware smoke protocol document. +- One known Art-Net node — primary target class is a low-cost Ethernet Art-Net/sACN node such as DMXKing eDMX1 MAX or ENTTEC ODE Mk3. Verify current pricing and availability before purchase (see [Decisions (D0)](#decisions-d0) §1). +- One known fixture or dimmer channel. +- Evidence folder meeting the tiered verification bar below. + +Acceptance: + +- Blackout works (burst-then-cease; fixtures observably return to zero). +- Selected fixture intensity test works. +- Wrong target state fails visibly. +- Removing relay or node moves UI into fault state. +- Network disconnect, relay crash/tab failure, and blackout recovery are recorded as separate pass rows. +- No output happens without arming. +- Evidence bar met: **relay log + packet capture (Wireshark/tcpdump) + node web-UI screenshot** = "verified"; adding fixture video (slo-mo) upgrades to "fixture verified." Relay log alone proves only that the app *tried* (see [Decisions (D0)](#decisions-d0) §11). +- `npm run dmx:evidence-check -- --protocol artnet` passes against the retained evidence folder. `npm run dmx:evidence-check -- --protocol artnet --fixture-verified` also passes before using the "fixture verified" label. + +### D4: sACN Support + +Implementation status: sACN packet encoder, relay send path, web protocol selector, persisted preference defaults, controlled multicast target mode, local smoke path, and sACN evidence scaffold are implemented locally. `src/domain/sacn.js` has golden packet coverage for source/CID, priority, sequence, synchronization address, preview/terminate/force-sync option bits, unicast-ready packet bytes, and multicast address derivation for later hardware tests. `src/domain/dmxRelay.js`, `src/components/DmxOutputPanel.jsx`, and `scripts/dmx-local-smoke.mjs` now send sACN unicast frames and sACN blackout bursts through the same compiled 512-slot universe buffers as Art-Net. Multicast support is constrained to derived E1.31 addresses from the selected universe, so the relay does not accept arbitrary public targets as a multicast escape hatch. `docs/dmx-smoke-evidence/2026-06-30-sacn/` is ready for retained hardware proof. D4 is not complete until a hardware smoke passes on a sACN-capable node using the shared compiler path. + +Deliverables: + +- sACN packet encoder. +- Source/CID handling. +- Priority and sequence handling. +- Unicast mode first. +- Web Output panel protocol selector. +- Relay command validation and UDP emission for sACN. +- Local smoke proof for selected sACN frame and sACN blackout burst. +- Controlled multicast target mode with universe-derived `239.255.x.y` host. + +Acceptance: + +- Golden packet tests match expected E1.31 packet structure. +- Local relay smoke emits a selected sACN frame and all-zero sACN blackout burst to a UDP listener. +- Persisted sACN preferences default to UDP port 5568 and preserve 1-63999 universe routing. +- Multicast relay tests prove the derived target host and blackout path without permitting arbitrary public hosts. +- Hardware smoke passes on the same fixture path or a second sACN-capable node. +- Art-Net and sACN share the same universe compiler. + +### D5: Fixture Personality Expansion + +Implementation status: implemented locally on 2026-06-30 for the web app output path. The current D5 slice includes `src/domain/fixtureOutputProfiles.js` with the shared slot-indexed output schema, generic dimmer/RGB/RGBW/simple moving-light maps, unsafe-channel zero defaults, compiler slot explanations, OFL candidate output-map inference, and Fixture Library output-readiness UI. GDTF Share profiles remain paperwork-only unless a parsed, reliable channel map exists; PlotForge does not infer output from footprint-only GDTF metadata. + +Deliverables: + +- Shared fixture output mapping schema. +- Generic dimmer, RGB, RGBW, and simple moving-light mappings. +- UI display of mapped and unmapped parameters. +- Import path from GDTF/OFL metadata where reliable. + +Acceptance: + +- PlotForge can explain exactly which document field or test control writes each slot. +- Unsafe slots default to zero. +- The user can see which fixtures are output-ready and which are paperwork-only. + +### D6: Native Output + +Implementation status: implemented and locally verified on 2026-06-30. The current D6 slice adds `native/PlotForgeNative/Sources/PlotForgeCore/PlotDmxOutput.swift`, a pure Swift DMX compiler mirroring the web compiler's selected-test, blackout, invalid range, overlap, generic output-map, unsafe-channel, and approved custom-map behavior. `native/PlotForgeNative/Tests/PlotForgeCoreTests/PlotDmxOutputTests.swift` covers byte-level slot parity against the web golden LED vector, moving-light unsafe defaults, and native Art-Net packet bytes. The native SwiftUI shell now includes an Output sidebar tool with arm, send test, blackout, disarm, Art-Net unicast target fields, preview diagnostics, explicit Local Network failure copy, and a real UDP sender with timeout. The Xcode app target declares `NSLocalNetworkUsageDescription`. `swift test`, native export smoke, and unsigned iOS Simulator/Mac Catalyst app builds pass locally. Signed physical iPad output smoke remains pending, so the native path is not yet hardware verified. + +Deliverables: + +- Swift shared compiler or mirrored tests against the web compiler fixtures. +- Native Output panel. +- Native UDP send path or native relay control. +- iPad local-network permission handling. +- Signed physical iPad hardware smoke. +- `npm run native:physical-evidence-check`. + +Acceptance: + +- Native output matches web compiler output byte-for-byte for test documents. +- iPad denial of local network permission fails clearly. +- Physical iPad smoke proves arm, selected fixture test, blackout, and save/reopen safety. +- Native physical evidence check passes against the retained evidence folder. + +## Verification Strategy + +Automated tests: + +- Domain tests for every compiler rule. +- Packet golden tests for Art-Net. +- Packet golden tests for sACN. +- Relay validation tests for malformed commands. +- Serialization tests for `dmxOutput` preferences. +- Relay disconnect/shutdown blackout tests for active authenticated sessions. +- Local smoke script for loopback relay plus UDP-listener evidence. +- Hardware evidence checker for retained relay log, packet capture, node screenshot, metadata, and pass checkboxes. +- UI tests for arm/disarm, blackout, relay fault, conflict blocked, and selected fixture test. + +Manual smoke: + +- Preview-only browser smoke. +- UDP listener smoke with no lighting hardware. +- Hardware node smoke with one universe. +- Hardware fixture smoke with one known profile. +- Network disconnect smoke. +- Relay crash smoke. +- Blackout recovery smoke. + +CI should never require hardware. Hardware proof should live as retained evidence, similar to the native physical smoke protocol. + +## Failure Modes To Design Around + +- Wrong universe sends values to the wrong node. +- Fixture overlap causes two fixtures to fight for the same slots. +- Fixture personality mismatch turns on strobe, reset, macro, or movement unexpectedly. +- Browser tab closes while output remains nonzero. +- Relay crashes without blackout. +- Network interface changes and packets go nowhere. +- Multicast floods a production network. +- Public deploy exposes controls that look live but cannot safely send. +- Native iPad local network permission blocks UDP. +- User assumes PlotForge is now a show console. + +The UI and docs should make these failure modes hard to miss. + +## Recommendation + +Build `D1 DMX Simulator And Buffer Compiler` first. It adds real value immediately because it lets PlotForge inspect exact universe output without touching hardware. It also forces the correct abstractions before UDP, sACN, native output, or fixture personality expansion. + +After D1, build Art-Net unicast through a local relay. This matches the current OSC relay precedent, keeps the browser safe, and gives us the shortest path to one real hardware smoke. + +## References Checked + +- [ESTA TSP published documents](https://tsp.esta.org/tsp/documents/published_docs.php), checked for ANSI E1.11 - 2024 and ANSI E1.31 - 2025 current listings. +- [Art-Net overview, Artistic Licence](https://artisticlicence.com/pages/art-net-overview), checked for Art-Net ownership, purpose, and general Ethernet DMX/RDM positioning. +- [Art-Net 4 protocol specification PDF](https://art-net.org.uk/downloads/art-net.pdf), checked for ArtDmx, UDP port, universe addressing, and sACN interoperability notes. +- [ANSI E1.31 - 2016 public PDF](https://tsp.esta.org/tsp/documents/docs/E1-31-2016.pdf), checked for sACN transport intent and data classes. It is superseded, so implementation should use the current E1.31 - 2025 standard before packet-locking. +- [ANSI E1.11 - 2008 (R2018) public PDF](https://tsp.esta.org/tsp/documents/docs/ANSI-ESTA_E1-11_2008R2018.pdf), checked for DMX512-A scope and historical behavior. It is superseded, so implementation should use the current E1.11 - 2024 standard before claiming compliance. + +## Decisions (D0) + +These fourteen questions were open in the original plan and are now resolved. Full rationale and source links live in the companion [DMX design decisions document](plotforge-dmx-design-decisions-2026-06-30.md); this section is the binding summary that the rest of this plan references. + +1. **First hardware target.** Low-cost Ethernet Art-Net/sACN node as primary v0 target: DMXKing eDMX1 MAX or ENTTEC ODE Mk3 are candidate classes; verify current pricing and availability before purchase. Standard Ethernet keeps the target reachable from macOS and iPadOS with no driver stack. USB interfaces are parked (driver friction on macOS, effectively impossible on iPadOS). ETC gateways are post-v0. An Arduino node is acceptable for personal smoke tests but is never declared a "verified" target (firmware timing quirks at 44 Hz, no ArtPollReply conformance). + +2. **Protocol order.** Art-Net is the first-class v0 protocol; sACN is a planned co-equal target reached at D4, not deferred indefinitely. The path is Art-Net-proven-first because Art-Net unicast needs no special entitlement while sACN multicast does (§12); the end state ships both, selectable, defaulting to Art-Net unicast on iOS and Art-Net broadcast on macOS. + +3. **Fixture schema.** A flat, slot-indexed list of typed channel descriptors (`type` enum, `ranges`, `pairedFine`, `safeMin`/`safeMax`, per-channel/range `unsafe`) — deliberately smaller than GDTF, no attribute trees. Defined in [§3 Fixture Parameter Mapping Layer](#3-fixture-parameter-mapping-layer). + +4. **GDTF.** Import source only. Parse to a *candidate* slot map, present a diff (inferred vs. datasheet), require an explicit "Approve Mapping" action before the JSON becomes output-ready. Never write a GDTF-derived mapping to hardware without that gate (GDTF does not validate range overlaps, multi-cell reconstruction is unreliable, attribute names vary, virtual channels inflate footprints). + +5. **Shared profiles.** Yes — one canonical platform-neutral JSON profile format in a shared package/repo, consumed by both web (runtime fetch) and native (build-time snapshot + launch update check). `schemaVersion` gates breaking changes. UI layers never mutate canonical JSON; edits go through a profile editor that writes back to the shared store. + +6. **Complex channel types.** Type-aware rendering with hard guards: `intensity` scrubs freely; `panCoarse`+`panFine`/`tiltCoarse`+`tiltFine` render as one 16-bit fader with a slew-rate guard and always write both bytes in the same frame; wheels render as indexed selectors; `shutter`/`strobe`/`reset` are gated behind per-session confirmation (`reset` requires a typed confirmation); `raw` shows a no-type-safety warning. v0 hides unsafe/movement types behind a per-session "Show Unsafe Channels" toggle. + +7. **Conflicts.** Always block output by default — a conflicted universe emits no frames for any slot until resolved. A per-session advanced override may *exclude* (remove from patch), never *suppress/overwrite*, conflicted fixtures; the remaining fixtures then output normally. The override does not persist without explicit save. v0 ships block-all; the exclude override is deferred (see D1 acceptance). + +8. **Frame rate.** 20 Hz v0 smoke default, 30 Hz once stable. Per-protocol hard ceilings enforced in code and never exceedable from the UI: 44 Hz for any Art-Net/sACN-to-physical-DMX gateway, up to 60 Hz for direct IP→pixel paths (no DMX layer), 40 Hz for USB DMX. + +9. **Blackout.** Burst-then-cease: send a short burst of all-zero frames (default 5) at the operating rate, then stop transmitting — blackout should feel like cutting power, not dimming to zero. Perpetual-zeros keep-alive is an opt-in, default-off, clearly-labeled toggle for nodes whose data-loss failsafe is hold-last-look or go-to-scene. + +10. **NIC selection.** Enumerate active NICs at launch and on network change; score them (wired 2.x/10.x highest, Wi-Fi 192.168.x medium, Tailscale 100.x negative with a warning, loopback/virtual filtered out); present the top candidate in an explicit "Confirm Output Interface" dialog before first output; persist the choice by MAC (not interface name); halt output and re-prompt if the confirmed NIC disappears. + +11. **Verified-output evidence.** Tiered. Minimum to log a test as "verified": relay log + packet capture (Wireshark/tcpdump) + node web-UI screenshot. Adding fixture video (slo-mo) upgrades to "fixture verified." Oscilloscope is optional, for node characterization only. Relay log alone proves only that the app tried. Enforced at [D3 acceptance](#d3-hardware-smoke). + +12. **Native iPad.** Unicast Art-Net is the safest iOS path (only `NSLocalNetworkUsageDescription`, fires once); sACN multicast needs the `com.apple.developer.networking.multicast` entitlement (Apple approval, ~3-7 business days) and must be on the provisioning profile before TestFlight — so iOS uses unicast for both protocols during early development. On background/resign-active, send the blackout burst and close the socket; never hold output while backgrounded. Disable the idle timer while output is active. On terminate, send the burst synchronously in the termination callback. + +13. **RDM.** Out of scope for v0 and v1. A future "commissioning mode" may read RDM device data (GET only, passive, operator-initiated, never automatic); PlotForge never sends RDM SET commands. Rationale: RDM pauses DMX output during discovery (incompatible with clean output testing), is sensitive to signal-chain quality, and an accidental SET to a live fixture is exactly the danger an output tester must avoid. + +14. **"Not a console" UI.** Persistent non-dismissible amber banner "OUTPUT TEST MODE — Not for show use" on every output-active screen (distinct from red errors / green status); transport controls visually distinct with large touch targets; non-console language ("Test Patch", "Channel Test", "Send Test Frame", "Test Rig"); first output per session requires a confirmation naming interface/protocol/universe/fixture-count; blackout is always one tap with no confirmation; safe channels respond immediately after the first-session gate; the verification log is a first-class tab, not buried in settings. diff --git a/docs/plotforge-dmx-physical-smoke-runbook-2026-06-30.md b/docs/plotforge-dmx-physical-smoke-runbook-2026-06-30.md new file mode 100644 index 0000000..d3d4f79 --- /dev/null +++ b/docs/plotforge-dmx-physical-smoke-runbook-2026-06-30.md @@ -0,0 +1,231 @@ +# PlotForge DMX Physical Smoke Runbook + +Date: 2026-06-30 + +Status: operator runbook prepared; D3 Art-Net hardware, D4 sACN hardware, and D6 signed iPad output smoke remain pending until retained evidence passes the checkers below. + +## Purpose + +This runbook is the execution checklist for the remaining physical gates in the PlotForge DMX plan: + +- D3: web app to local relay to Art-Net node to one safe fixture or dimmer channel. +- D4: same hardware path using sACN through the shared compiler. +- D6: signed native iPad app output path, local network permission behavior, blackout, and save/reopen safety. + +The detailed protocols remain the source for step-by-step smoke behavior: + +- `docs/plotforge-dmx-hardware-smoke-2026-06-30.md` +- `docs/plotforge-native-physical-smoke-2026-06-26.md` +- `docs/plotforge-dmx-integration-plan-2026-06-30.md` + +## Done Rules + +Do not call a physical gate complete from relay logs alone. + +| Gate | Required retained evidence | Required checker | +| --- | --- | --- | +| D3 Art-Net hardware verified | `relay.log`, `capture.pcap` or `capture.pcapng`, `node-ui.png`, `Protocol: Art-Net`, complete hardware metadata, all required pass checkboxes | `npm run dmx:evidence-check -- --protocol artnet` | +| D3 fixture verified | D3 verified evidence plus `fixture-video.mov` or `fixture-video.mp4` | `npm run dmx:evidence-check -- --protocol artnet --fixture-verified` | +| D4 sACN hardware verified | Same evidence shape as D3, with `Protocol: sACN` and packet capture on UDP 5568 | `npm run dmx:evidence-check -- --dir --protocol sacn` | +| D6 signed iPad output verified | CoreDevice process proof, launch JSON, installed-apps JSON, display JSON, lock-state JSON, visual proof, content-valid native export artifacts, metadata, and all native/D6 gate rows passing | `npm run native:physical-evidence-check -- --dir ` | + +## Preflight + +Use Node 22 for every web-side command: + +```bash +export PATH=/opt/homebrew/opt/node@22/bin:$PATH +``` + +Confirm the local automated proof still passes before connecting physical output: + +```bash +npm run dmx:local-smoke +node scripts/smoke-domain.mjs +node scripts/syntax-check.mjs +npm audit --omit=dev +``` + +Confirm the evidence folders fail closed before the run: + +```bash +npm run dmx:evidence-check -- --protocol artnet +npm run native:physical-evidence-check -- --dir /private/tmp/plotforge-native-physical-smoke-2026-06-30-dmx-output +npm run physical:evidence-status -- --no-fail +``` + +Expected pre-run result: both evidence commands fail and list missing hardware or physical iPad proof. + +The consolidated status command checks D3 Art-Net, D4 sACN, and D6 native iPad evidence in one pass. Without `--no-fail`, it exits nonzero until every physical gate passes. + +## D3 Art-Net Hardware Smoke + +Evidence folder: + +```text +docs/dmx-smoke-evidence/2026-06-30/ +``` + +Required hardware: + +- One low-cost Ethernet Art-Net/sACN node, currently targeted at DMXKing eDMX1 MAX or ENTTEC ODE Mk3 class hardware. +- One known safe dimmer or static fixture intensity channel. +- Bench or isolated network, not a production show network. + +Command sequence: + +```bash +npm run dmx:local-smoke +npm run dev -- --host 127.0.0.1 --port 5173 +``` + +In a second terminal: + +```bash +PLOTFORGE_DMX_TARGET_HOST= \ +PLOTFORGE_DMX_TARGET_PORT=6454 \ +PLOTFORGE_DMX_ALLOWED_ORIGINS=http://127.0.0.1:5173,http://localhost:5173 \ +npm run dmx:relay 2>&1 | tee docs/dmx-smoke-evidence/2026-06-30/relay.log +``` + +In a third terminal, start packet capture before arming output: + +```bash +sudo tcpdump -i udp port 6454 -w docs/dmx-smoke-evidence/2026-06-30/capture.pcap +``` + +Operator actions: + +1. Open the web Output panel. +2. Copy the relay token from the relay terminal. +3. Confirm no output is possible before arming. +4. Arm Art-Net output to the node IP. +5. Send one selected fixture intensity test. +6. Blackout. +7. Exercise a bad target failure. +8. Exercise a network disconnect or disconnected node failure. +9. Exercise relay crash or tab failure after a nonzero send. +10. Recover by rearming and proving blackout returns output to zero. +11. Capture `node-ui.png`. +12. Fill every metadata field and checkbox in `docs/dmx-smoke-evidence/2026-06-30/README.md`. + +Verification: + +```bash +npm run dmx:evidence-check -- --protocol artnet +``` + +Optional fixture verified upgrade: + +```bash +npm run dmx:evidence-check -- --protocol artnet --fixture-verified +``` + +## D4 sACN Hardware Smoke + +Recommended evidence folder: + +```text +docs/dmx-smoke-evidence/2026-06-30-sacn/ +``` + +Create the folder by copying the D3 evidence template, then set `Protocol: sACN`. + +Command differences from D3: + +```bash +PLOTFORGE_DMX_TARGET_HOST= \ +PLOTFORGE_DMX_TARGET_PORT=5568 \ +PLOTFORGE_DMX_ALLOWED_ORIGINS=http://127.0.0.1:5173,http://localhost:5173 \ +npm run dmx:relay 2>&1 | tee docs/dmx-smoke-evidence/2026-06-30-sacn/relay.log +``` + +Packet capture: + +```bash +sudo tcpdump -i udp port 5568 -w docs/dmx-smoke-evidence/2026-06-30-sacn/capture.pcap +``` + +Operator actions: + +1. Select sACN in the web Output panel. +2. Use unicast first unless the node requires multicast. +3. If testing multicast, confirm the relay derives the E1.31 multicast target from the selected universe. +4. Run the same selected intensity, blackout, bad target, network disconnect, relay/tab failure, and blackout recovery checks as D3. +5. Capture the node UI and complete all evidence fields. + +Verification: + +```bash +npm run dmx:evidence-check -- --dir docs/dmx-smoke-evidence/2026-06-30-sacn --protocol sacn +``` + +## D6 Native Signed iPad Output Smoke + +Evidence folder: + +```text +/private/tmp/plotforge-native-physical-smoke-2026-06-30-dmx-output/ +``` + +Primary device: + +```text +David's iPad: 445A14BE-DDF1-5220-8D09-B83312A28AE6 +``` + +Current physical state: + +- CoreDevice sees David's iPad. +- The 2026-06-30 14:35Z launch capture in `/private/tmp/plotforge-native-physical-smoke-2026-06-30-dmx-output/` succeeded with `launch_exit=0` and listed `PlotForgeNative.app/PlotForgeNative` in `device-process.txt`. +- Installed-apps, display, and lock-state JSON evidence has been captured for the same folder. +- The Launch process gate is machine-verifiable from that evidence. D6 is still pending because the evidence folder does not yet contain visual proof, native export artifacts, completed metadata, or passing UI/output/save gate rows. + +Device check: + +```bash +xcrun devicectl list devices +``` + +Capture process and launch evidence: + +```bash +scripts/physical-smoke-evidence.sh \ + 445A14BE-DDF1-5220-8D09-B83312A28AE6 \ + /private/tmp/plotforge-native-physical-smoke-2026-06-30-dmx-output \ + --launch +``` + +The helper also captures installed-apps, display, and lock-state JSON evidence into the same folder. +It auto-fills blank `Date`, `Device`, `Build`, and `Bundle` values in `notes.md`. `Operator` and all behavior/result rows remain manual. + +Operator evidence: + +1. Record the full run with QuickTime or save screenshots under `smoke-screenshots/`. +2. Launch to the PlotForge start screen. +3. Open or create a plot. +4. Prove native canvas, inspector, multi-select, fixture library, labels, Wizard, patch/checks, exports, and save/reopen. +5. Open the native Output tool. +6. Exercise Local Network permission success or denial. +7. Arm output to a safe Art-Net unicast target. +8. Send one selected fixture output test. +9. Blackout. +10. Save/reopen and confirm the document remains intact. +11. Put exported `.plot`, PDF, PDF review JSON, patch CSV, gel CSV, circuit CSV, fixture paperwork CSV, OSC JSON, and interop JSON files under `exported-files/`. +12. Fill every metadata field and gate row in `notes.md` with pass/fail, evidence file, and notes. + +Verification: + +```bash +npm run native:physical-evidence-check -- --dir /private/tmp/plotforge-native-physical-smoke-2026-06-30-dmx-output +``` + +## Closeout Checklist + +Only after the relevant evidence checker passes: + +- Update `docs/plotforge-dmx-integration-plan-2026-06-30.md` current status for the completed gate. +- Keep D3, D4, and D6 status separate; one passing physical gate does not imply the others passed. +- Preserve the evidence folders in the repo for web hardware evidence and in `/private/tmp` or copied docs for native iPad evidence. +- Record whether the result is hardware verified or fixture verified. +- Run `npm run physical:evidence-status` as the final combined physical evidence gate. diff --git a/docs/plotforge-native-physical-smoke-2026-06-26.md b/docs/plotforge-native-physical-smoke-2026-06-26.md index 939eb10..a3c88cb 100644 --- a/docs/plotforge-native-physical-smoke-2026-06-26.md +++ b/docs/plotforge-native-physical-smoke-2026-06-26.md @@ -25,13 +25,26 @@ Expected files: | --- | --- | --- | | `device-process.txt` | Yes | CoreDevice process proof | | `launch-start-screen-david.json` | Yes for current run | CoreDevice launch proof for the corrected start-screen build | -| `installed-apps-david.json` | Yes for current run | CoreDevice installed app metadata showing bundle id and version | -| `display-david.json` | Yes for current run | CoreDevice display metadata showing active iPad display and orientation | +| `installed-apps.json` or `installed-apps-david.json` | Yes for current run | CoreDevice installed app metadata showing bundle id and version | +| `display.json` or `display-david.json` | Yes for current run | CoreDevice display metadata showing active iPad display and orientation | +| `lock-state.json` or `lock-state-david.json` | Yes for current run | CoreDevice lock-state metadata showing the device is usable for launch/smoke | | `smoke-recording.mov` or `smoke-screenshots/` | Yes | QuickTime iPad capture or Xcode screenshots | -| `exported-files/` | Yes for N5 | Exported PDF, CSV, OSC JSON, and interop JSON | +| `exported-files/` | Yes for N5 | Exported `.plot`, PDF, PDF review JSON, CSVs, OSC JSON, and interop JSON | | `/private/tmp/plotforge-native-export-smoke-2026-06-26/` | Reference | CLI-generated export artifact baseline from `PlotForgeExportSmoke` | | `notes.md` | Yes | Operator notes, pass/fail rows, defects | +Machine check: + +```sh +PATH=/opt/homebrew/opt/node@22/bin:$PATH npm run native:physical-evidence-check -- --dir /private/tmp/plotforge-native-physical-smoke-2026-06-26 +``` + +For the D6 DMX output smoke folder, use: + +```sh +PATH=/opt/homebrew/opt/node@22/bin:$PATH npm run native:physical-evidence-check -- --dir /private/tmp/plotforge-native-physical-smoke-2026-06-30-dmx-output +``` + ## Preflight 1. iPad is connected by USB or visible to CoreDevice. @@ -49,6 +62,9 @@ Known current proof: - Corrected start-screen build installed and launched on David's iPad on 2026-06-26. - David's iPad listed `PlotForgeNative.app/PlotForgeNative` as process `3514` in `/private/tmp/plotforge-native-physical-smoke-2026-06-26/device-process.txt`. - CoreDevice launch JSON for that run is `/private/tmp/plotforge-native-physical-smoke-2026-06-26/launch-start-screen-david.json`. +- The D6 output smoke launch capture on 2026-06-30 14:35Z succeeded in `/private/tmp/plotforge-native-physical-smoke-2026-06-30-dmx-output/`, with `launch_exit=0` and `PlotForgeNative.app/PlotForgeNative` listed in `device-process.txt`. +- The D6 output smoke folder also has CoreDevice installed-apps, display, and lock-state JSON evidence showing the PlotForge bundle, active display, and unlocked-since-boot state. +- The D6 output smoke evidence checker still fails until visual proof, export artifacts, metadata, and passing output gate rows are retained. - Ignacio did not list PlotForge as running in the last check. ## Screen Capture @@ -73,6 +89,7 @@ Still capture fallback: | Gate | Action | Pass Evidence | Result | | --- | --- | --- | --- | +| Launch process | Capture CoreDevice launch/process proof | `device-process.txt` lists `PlotForgeNative.app/PlotForgeNative` or the PlotForge bundle id; launch JSON exists when launch was attempted | Pending | | Launch choice | Open PlotForge Native from a clean app state | Recording or screenshot shows the PlotForge start screen with New Plot and Open .plot actions; the user is not stranded in the raw Files browser | Pending visual proof; CoreDevice launch proof captured | | Launch document | Create a new plot or open an existing `.plot` | Recording or screenshot shows native app frame with sidebar, canvas, and inspector | Pending | | N2 canvas | Select fixture, pan or zoom canvas, fit or reset view, drag fixture along position, nudge if keyboard is attached | Fixture selection, movement, snap behavior, and renumbered unit display are visible | Pending | @@ -82,19 +99,27 @@ Still capture fallback: | N4 labels | Change fixture unit, fixture channel, position, comment, and focus label visibility or text size | Canvas label visibility and size changes are visible | Pending | | N4 Wizard | Preview and apply Wizard starter, then undo and redo | New positions and fixtures append without clearing existing work; undo and redo recover the expected states | Pending | | N4 patch/checks | Open Patch and Reports readiness with conflicts or incomplete circuit data if present | Patch rows and check rows are readable on physical iPad | Pending | -| N5 exports | Export PDF, patch CSV, gel CSV, circuit CSV, fixture paperwork CSV, OSC JSON, and interop JSON | Files appear under `exported-files/`; filenames are deterministic; basic contents inspect cleanly | Pending | +| N5 exports | Export `.plot`, PDF, PDF review JSON, patch CSV, gel CSV, circuit CSV, fixture paperwork CSV, OSC JSON, and interop JSON | Files appear under `exported-files/`; filenames are deterministic; basic contents inspect cleanly | Pending | | N5 export baseline | Compare platform-exported files against the CLI-generated export smoke folder where applicable | `.plot`, PDF, PDF review JSON, CSV, OSC JSON, and interop JSON names and basic contents align with the baseline | Pending | +| Output arm | Open the native Output tool, enter a safe unicast Art-Net target, and arm output | Recording or screenshot shows armed state, explicit protocol/host/universe/rate, and no nonzero send before Send Test | Pending | +| Selected fixture output test | Select one safe dimmer or intensity fixture and send a test frame to the known node/fixture path | Recording plus node/fixture evidence shows only the selected fixture/channel responds; wrong target remains visibly blocked or failed | Pending | +| Blackout | Tap Blackout after the selected fixture output test | Recording plus node/fixture evidence shows output returns to zero and the app remains recoverable | Pending | +| Local network permission | Exercise the first local-network permission prompt or denial path on iPadOS | Recording or screenshot shows permission success, or denial produces clear in-app failure copy without pretending output succeeded | Pending | | Save/reopen | Save the `.plot`, close or background the app, reopen the saved document | Existing fixtures, labels, inspector data, Wizard additions, and exported state are preserved | Pending | ## Pass Rules - A gate is green only when visual proof or exported files show the behavior. - CoreDevice process proof alone proves launch, not UI correctness. +- The Launch process gate may be satisfied by machine evidence when `device-process.txt`, launch JSON, installed-apps JSON, display JSON, and lock-state JSON are all present and the process list includes `PlotForgeNative.app/PlotForgeNative`. +- `npm run native:physical-evidence-check` must pass against the retained evidence folder before this protocol is called complete. The checker validates the exported `.plot` as version 9 JSON, the PDF as `%PDF-1.4`, the four expected CSV headers, OSC/interop JSON `kind` markers, and the PDF review `physicalSignoff: pending` marker. - Any crash, signing error, missing exported file, unreadable panel, layout clipping, data loss, or failed save/reopen keeps the gate open. - If a gesture is hard to capture, write the exact operator action in `notes.md` and include before/after screenshots. ## Notes Template +`scripts/physical-smoke-evidence.sh` auto-fills blank `Date`, `Device`, `Build`, and `Bundle` fields from the CoreDevice capture. `Operator` and every behavior gate remain manual because they require human observation, visual proof, or exported files. + ```md # PlotForge Native Physical Smoke Notes @@ -113,6 +138,7 @@ Operator: | Gate | Result | Evidence File | Notes | | --- | --- | --- | --- | +| Launch process | Pending | device-process.txt, launch.json if captured | CoreDevice proof only; visual launch choice still requires screenshot or recording | | Launch choice | Pending | | | | Launch document | Pending | | | | N2 canvas | Pending | | | @@ -124,6 +150,10 @@ Operator: | N4 patch/checks | Pending | | | | N5 exports | Pending | | | | N5 export baseline | Pending | | | +| Output arm | Pending | | | +| Selected fixture output test | Pending | | | +| Blackout | Pending | | | +| Local network permission | Pending | | | | Save/reopen | Pending | | | ## Defects diff --git a/native/PlotForgeNative/Sources/PlotForgeAppShell/PlotForgeShellView.swift b/native/PlotForgeNative/Sources/PlotForgeAppShell/PlotForgeShellView.swift index 1e9807a..9050866 100644 --- a/native/PlotForgeNative/Sources/PlotForgeAppShell/PlotForgeShellView.swift +++ b/native/PlotForgeNative/Sources/PlotForgeAppShell/PlotForgeShellView.swift @@ -27,6 +27,7 @@ enum PlotTool: String, CaseIterable, Identifiable { case setup = "Setup" case fixtures = "Fixtures" case patch = "Patch" + case output = "Output" case labels = "Labels" case wizard = "Wizard" case inspector = "Inspector" @@ -40,6 +41,7 @@ enum PlotTool: String, CaseIterable, Identifiable { case .setup: "slider.horizontal.3" case .fixtures: "lightbulb" case .patch: "point.3.connected.trianglepath.dotted" + case .output: "antenna.radiowaves.left.and.right" case .labels: "textformat.size" case .wizard: "sparkles" case .inspector: "sidebar.right" @@ -64,6 +66,7 @@ struct ToolSidebar: View { } .buttonStyle(.plain) .listRowBackground(selectedTool == tool ? Color(uiToken: .control) : Color.clear) + .accessibilityIdentifier("tool-\(tool.rawValue.lowercased())") } } .safeAreaInset(edge: .top) { @@ -291,6 +294,7 @@ struct TopStatusBar: View { Spacer() MetricPill(label: "Fixtures", value: "\(document.fixtureOrder.count)") + .accessibilityIdentifier("fixture-count") MetricPill(label: "Positions", value: "\(document.positionOrder.count)") MetricPill(label: "Schema", value: "v\(document.version)") @@ -301,18 +305,21 @@ struct TopStatusBar: View { Label("Undo", systemImage: "arrow.uturn.backward") } .disabled(!canUndo) + .accessibilityIdentifier("fixture-undo") .keyboardShortcut("z", modifiers: .command) Button(action: onRedo) { Label("Redo", systemImage: "arrow.uturn.forward") } .disabled(!canRedo) + .accessibilityIdentifier("fixture-redo") .keyboardShortcut("z", modifiers: [.command, .shift]) Button(action: onDeleteSelection) { Label("Delete Selection", systemImage: "trash") } .disabled(selection.isEmpty) + .accessibilityIdentifier("fixture-delete") .keyboardShortcut(.delete, modifiers: []) } .labelStyle(.iconOnly) @@ -370,6 +377,8 @@ struct ToolPanel: View { ) case .patch: PatchToolPanel(document: document) + case .output: + DmxOutputToolPanel(document: document) case .labels: LabelToolPanel(settings: document.labelSettings, onUpdate: onUpdateLabelSettings) case .wizard: @@ -854,6 +863,7 @@ struct PlotCanvasView: View { Label("Fit View", systemImage: "arrow.up.left.and.arrow.down.right") } .keyboardShortcut("0", modifiers: .command) + .accessibilityIdentifier("fixture-fit-view") Button { viewport.zoom(by: 1.2) @@ -877,6 +887,7 @@ struct PlotCanvasView: View { } .disabled(document.fixtureOrder.isEmpty) .keyboardShortcut("[", modifiers: []) + .accessibilityIdentifier("fixture-previous") Button { onSelectAdjacentFixture(1, false) @@ -885,6 +896,7 @@ struct PlotCanvasView: View { } .disabled(document.fixtureOrder.isEmpty) .keyboardShortcut("]", modifiers: []) + .accessibilityIdentifier("fixture-next") Button { onSelectAllFixtures() @@ -893,6 +905,7 @@ struct PlotCanvasView: View { } .disabled(document.fixtureOrder.isEmpty) .keyboardShortcut("a", modifiers: .command) + .accessibilityIdentifier("fixture-select-all") Toggle(isOn: $additiveSelectionEnabled) { Label("Add Selection", systemImage: "plus.square.on.square") @@ -923,12 +936,14 @@ struct PlotCanvasView: View { } .disabled(selection.isEmpty) .keyboardShortcut(.escape, modifiers: []) + .accessibilityIdentifier("fixture-clear-selection") Button(action: onDeleteSelection) { Label("Delete Selection", systemImage: "trash") } .disabled(selection.isEmpty) .keyboardShortcut(.delete, modifiers: []) + .accessibilityIdentifier("fixture-canvas-delete") } .labelStyle(.iconOnly) .buttonStyle(.bordered) @@ -957,6 +972,7 @@ struct PlotCanvasView: View { ) .gesture(fixtureDragGesture(fixtureId: fixtureId, metrics: metrics)) .accessibilityLabel("Fixture \(fixture.unitNumber.map(String.init) ?? fixture.id)") + .accessibilityIdentifier("fixture-\(fixtureId)") } } } diff --git a/native/PlotForgeNative/Sources/PlotForgeAppShell/PlotForgeStandaloneDocumentSession.swift b/native/PlotForgeNative/Sources/PlotForgeAppShell/PlotForgeStandaloneDocumentSession.swift index 8591149..fc48368 100644 --- a/native/PlotForgeNative/Sources/PlotForgeAppShell/PlotForgeStandaloneDocumentSession.swift +++ b/native/PlotForgeNative/Sources/PlotForgeAppShell/PlotForgeStandaloneDocumentSession.swift @@ -18,6 +18,11 @@ struct PlotForgeStandaloneDocumentSession: Equatable { fileState.message } + var hasUnsavedChanges: Bool { + if case .edited = fileState { return true } + return false + } + var exportFilename: String { PlotNativeExports.plotDocumentFilename(for: document.show) } @@ -47,6 +52,57 @@ struct PlotForgeStandaloneDocumentSession: Equatable { } } +enum PlotForgePendingDocumentAction: Equatable { + case newDocument + case openDocument +} + +enum PlotForgeDirtyDocumentChoice: Equatable { + case save + case discard + case cancel +} + +enum PlotForgeDocumentTransitionEffect: Equatable { + case none + case confirmDiscard + case requestSave + case proceed(PlotForgePendingDocumentAction) +} + +struct PlotForgeDirtyDocumentTransitionCoordinator: Equatable { + private(set) var pendingAction: PlotForgePendingDocumentAction? + + mutating func request( + _ action: PlotForgePendingDocumentAction, + hasUnsavedChanges: Bool + ) -> PlotForgeDocumentTransitionEffect { + guard hasUnsavedChanges else { return .proceed(action) } + pendingAction = action + return .confirmDiscard + } + + mutating func resolve(_ choice: PlotForgeDirtyDocumentChoice) -> PlotForgeDocumentTransitionEffect { + guard let pendingAction else { return .none } + switch choice { + case .save: + return .requestSave + case .discard: + self.pendingAction = nil + return .proceed(pendingAction) + case .cancel: + self.pendingAction = nil + return .none + } + } + + mutating func finishSave(succeeded: Bool) -> PlotForgeDocumentTransitionEffect { + guard let pendingAction else { return .none } + self.pendingAction = nil + return succeeded ? .proceed(pendingAction) : .none + } +} + enum PlotForgeWorkspaceFileState: Equatable { case new case opened(filename: String) diff --git a/native/PlotForgeNative/Sources/PlotForgeAppShell/PlotForgeStandaloneWorkspaceView.swift b/native/PlotForgeNative/Sources/PlotForgeAppShell/PlotForgeStandaloneWorkspaceView.swift index 4556bf0..cb3ce2a 100644 --- a/native/PlotForgeNative/Sources/PlotForgeAppShell/PlotForgeStandaloneWorkspaceView.swift +++ b/native/PlotForgeNative/Sources/PlotForgeAppShell/PlotForgeStandaloneWorkspaceView.swift @@ -9,9 +9,16 @@ public struct PlotForgeStandaloneWorkspaceView: View { @State private var isImporterPresented = false @State private var isExporterPresented = false @State private var alert: PlotForgeWorkspaceAlert? + @State private var transitionCoordinator = PlotForgeDirtyDocumentTransitionCoordinator() + @State private var isDirtyTransitionPresented = false + private let simulatesFailedOpen: Bool - public init(document: PlotForgeFileDocument = PlotForgeFileDocument()) { + public init( + document: PlotForgeFileDocument = PlotForgeFileDocument(), + simulatesFailedOpen: Bool = ProcessInfo.processInfo.arguments.contains("-ui-testing-failed-open") + ) { _session = State(initialValue: PlotForgeStandaloneDocumentSession(document: document)) + self.simulatesFailedOpen = simulatesFailedOpen } public var body: some View { @@ -20,7 +27,7 @@ public struct PlotForgeStandaloneWorkspaceView: View { case .start: PlotForgeStartScreen( onNew: startNewDocument, - onOpen: { isImporterPresented = true } + onOpen: { requestTransition(.openDocument) } ) case .workspace: workspace @@ -49,10 +56,33 @@ public struct PlotForgeStandaloneWorkspaceView: View { dismissButton: .default(Text("OK")) ) } + .confirmationDialog( + "Save changes before continuing?", + isPresented: $isDirtyTransitionPresented, + titleVisibility: .visible + ) { + Button("Save") { + applyTransitionEffect(transitionCoordinator.resolve(.save)) + } + .accessibilityIdentifier("dirty-save") + + Button("Discard", role: .destructive) { + applyTransitionEffect(transitionCoordinator.resolve(.discard)) + } + .accessibilityIdentifier("dirty-discard") + + Button("Cancel", role: .cancel) { + applyTransitionEffect(transitionCoordinator.resolve(.cancel)) + } + .accessibilityIdentifier("dirty-cancel") + } message: { + Text("The current plot has unsaved changes. Save, discard, or cancel the pending action.") + } } private var workspace: some View { PlotForgeShellView(document: documentBinding) + .accessibilityIdentifier("plotforge-workspace") .toolbar { ToolbarItemGroup(placement: .primaryAction) { Button { @@ -60,18 +90,21 @@ public struct PlotForgeStandaloneWorkspaceView: View { } label: { Label("New", systemImage: "doc.badge.plus") } + .accessibilityIdentifier("workspace-new") Button { - isImporterPresented = true + requestTransition(.openDocument) } label: { Label("Open", systemImage: "folder") } + .accessibilityIdentifier("workspace-open") Button { isExporterPresented = true } label: { Label("Save", systemImage: "square.and.arrow.down") } + .accessibilityIdentifier("workspace-save") } ToolbarItem(placement: .status) { @@ -91,8 +124,7 @@ public struct PlotForgeStandaloneWorkspaceView: View { } private func startNewDocument() { - session.newDocument() - screen = .workspace + requestTransition(.newDocument) } private func handleImport(_ result: Result<[URL], Error>) { @@ -111,7 +143,9 @@ public struct PlotForgeStandaloneWorkspaceView: View { switch result { case .success(let url): session.noteSaved(filename: url.lastPathComponent) + applyTransitionEffect(transitionCoordinator.finishSave(succeeded: true)) case .failure(let error): + _ = transitionCoordinator.finishSave(succeeded: false) presentFileError("Save failed", error) } } @@ -125,6 +159,44 @@ public struct PlotForgeStandaloneWorkspaceView: View { } } + private func requestTransition(_ action: PlotForgePendingDocumentAction) { + applyTransitionEffect(transitionCoordinator.request( + action, + hasUnsavedChanges: session.hasUnsavedChanges + )) + } + + private func applyTransitionEffect(_ effect: PlotForgeDocumentTransitionEffect) { + switch effect { + case .none: + break + case .confirmDiscard: + isDirtyTransitionPresented = true + case .requestSave: + isExporterPresented = true + case .proceed(let action): + perform(action) + } + } + + private func perform(_ action: PlotForgePendingDocumentAction) { + switch action { + case .newDocument: + session.newDocument() + screen = .workspace + case .openDocument: + if simulatesFailedOpen { + do { + try session.openDocument(data: Data("invalid plot".utf8), filename: "invalid.plot") + } catch { + presentFileError("Open failed", error) + } + } else { + isImporterPresented = true + } + } + } + private func readFileData(at url: URL) throws -> Data { let isScoped = url.startAccessingSecurityScopedResource() defer { @@ -179,6 +251,7 @@ private struct PlotForgeStartScreen: View { ) } .buttonStyle(LaunchActionButtonStyle(prominent: true)) + .accessibilityIdentifier("start-new-plot") Button(action: onOpen) { LaunchActionContent( @@ -188,6 +261,7 @@ private struct PlotForgeStartScreen: View { ) } .buttonStyle(LaunchActionButtonStyle(prominent: false)) + .accessibilityIdentifier("start-open-plot") } HStack(spacing: 12) { @@ -200,6 +274,7 @@ private struct PlotForgeStartScreen: View { .padding(48) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } + .accessibilityIdentifier("plotforge-start-screen") } } diff --git a/native/PlotForgeNative/Sources/PlotForgeAppShell/ToolModulePanels.swift b/native/PlotForgeNative/Sources/PlotForgeAppShell/ToolModulePanels.swift index a84a81c..27c11df 100644 --- a/native/PlotForgeNative/Sources/PlotForgeAppShell/ToolModulePanels.swift +++ b/native/PlotForgeNative/Sources/PlotForgeAppShell/ToolModulePanels.swift @@ -128,6 +128,7 @@ struct FixtureLibraryEntryRow: View { .buttonStyle(.bordered) .disabled(targetPositionId == nil) .accessibilityLabel("Add \(profileName)") + .accessibilityIdentifier("fixture-add-\(entry.profile.id)") } if isExpanded { @@ -262,6 +263,335 @@ struct PlotCheckCompactRow: View { } } +struct DmxOutputToolPanel: View { + let document: PlotShowDocument + + @State private var selectedFixtureId = "" + @State private var intensity = 255 + @State private var red = 255 + @State private var green = 255 + @State private var blue = 255 + @State private var white = 0 + @State private var targetHost = "127.0.0.1" + @State private var targetPort = "6454" + @State private var artNetNet = 0 + @State private var artNetSubNet = 0 + @State private var artNetUniverse = 0 + @State private var isArmed = false + @State private var isSending = false + @State private var status = "Idle" + @State private var errorMessage = "" + + private var fixtureIds: [String] { + document.fixtureOrder.filter { document.fixtures[$0] != nil } + } + + private var activeFixtureId: String? { + if fixtureIds.contains(selectedFixtureId) { + return selectedFixtureId + } + return fixtureIds.first + } + + private var values: [String: Int] { + [ + "intensity": intensity, + "red": red, + "green": green, + "blue": blue, + "white": white, + ] + } + + private var preview: DmxOutputCompilation { + PlotDmxOutput.compile(document, options: DmxOutputCompileOptions( + selectedFixtureId: activeFixtureId, + values: values + )) + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("OUTPUT TEST MODE - Not for show use") + .font(.caption.weight(.semibold)) + .foregroundStyle(.orange) + .padding(.vertical, 6) + .padding(.horizontal, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.orange.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + + SummaryRows(rows: [ + ("State", isArmed ? "Armed" : "Idle"), + ("Universes", "\(preview.universes.count)"), + ("Blocks", "\(preview.errors.count)"), + ("Warnings", "\(preview.warnings.count)"), + ("Target", "\(targetHost):\(targetPort)"), + ]) + + Picker("Fixture", selection: Binding( + get: { activeFixtureId ?? "" }, + set: { selectedFixtureId = $0 } + )) { + ForEach(fixtureIds, id: \.self) { id in + Text(fixtureLabel(id)).tag(id) + } + } + .pickerStyle(.menu) + .disabled(fixtureIds.isEmpty) + + VStack(alignment: .leading, spacing: 8) { + ModuleSectionTitle("Safe test values") + Stepper("Dimmer \(intensity)", value: $intensity, in: 0...255, step: 5) + Stepper("Red \(red)", value: $red, in: 0...255, step: 5) + Stepper("Green \(green)", value: $green, in: 0...255, step: 5) + Stepper("Blue \(blue)", value: $blue, in: 0...255, step: 5) + Stepper("White \(white)", value: $white, in: 0...255, step: 5) + } + .font(.callout) + + VStack(alignment: .leading, spacing: 8) { + ModuleSectionTitle("Art-Net unicast") + TextField("Target host", text: $targetHost) + .textFieldStyle(.roundedBorder) + .autocorrectionDisabled() + TextField("Target port", text: $targetPort) + .textFieldStyle(.roundedBorder) + #if os(iOS) + .keyboardType(.numberPad) + #endif + Stepper("Net \(artNetNet)", value: $artNetNet, in: 0...127) + Stepper("Sub-Net \(artNetSubNet)", value: $artNetSubNet, in: 0...15) + Stepper("Universe \(artNetUniverse)", value: $artNetUniverse, in: 0...15) + } + .font(.callout) + + HStack(spacing: 8) { + Button { + armOutput() + } label: { + Label("Arm output", systemImage: "lock.open") + } + .disabled(isArmed || preview.blocked) + + Button { + Task { await sendPreviewFrame() } + } label: { + Label("Send test", systemImage: "paperplane") + } + .disabled(!isArmed || preview.blocked || isSending) + + Button { + Task { await sendBlackout() } + } label: { + Label("Blackout", systemImage: "power") + } + .disabled(!isArmed || isSending) + + Button { + disarmOutput() + } label: { + Label("Disarm", systemImage: "lock") + } + .disabled(!isArmed) + } + .buttonStyle(.bordered) + + Text("iPadOS asks for Local Network access on first UDP output. If permission is denied or the target is unreachable, this panel reports the send failure and no success is logged.") + .font(.caption) + .foregroundStyle(.secondary) + + if !status.isEmpty { + Text(status) + .font(.caption) + .foregroundStyle(.secondary) + } + if !errorMessage.isEmpty { + Text(errorMessage) + .font(.caption) + .foregroundStyle(.red) + } + + ScrollView { + LazyVStack(alignment: .leading, spacing: 10) { + ModuleSectionTitle("Preview") + ForEach(preview.errors, id: \.message) { issue in + OutputIssueRow(issue: issue) + } + ForEach(preview.warnings, id: \.message) { issue in + OutputIssueRow(issue: issue) + } + ForEach(preview.universes, id: \.universe) { universe in + DmxUniversePreviewRow(universe: universe) + } + if preview.universes.isEmpty { + EmptyModuleState(title: "No patched universes", detail: "Patch a fixture with a valid universe and address before output.") + } + } + .padding(.vertical, 2) + } + } + .onAppear { + if selectedFixtureId.isEmpty, let first = fixtureIds.first { + selectedFixtureId = first + } + } + .onChange(of: document.fixtureOrder) { + if !fixtureIds.contains(selectedFixtureId) { + selectedFixtureId = fixtureIds.first ?? "" + } + } + } + + private func fixtureLabel(_ fixtureId: String) -> String { + guard let fixture = document.fixtures[fixtureId] else { return fixtureId } + let profile = PlotToolModules.getProfile(fixture.profileId, in: document.fixtureProfiles) + let position = document.positions[fixture.positionId] + let unit = fixture.unitNumber.map { "U\($0)" } ?? fixture.id + let profileName = [profile?.manufacturer, profile?.model] + .compactMap { $0 } + .filter { !$0.isEmpty } + .joined(separator: " ") + return [position?.name, unit, profileName.isEmpty ? fixture.profileId : profileName] + .compactMap { $0 } + .joined(separator: " · ") + } + + private func armOutput() { + guard !preview.blocked else { + errorMessage = "Resolve DMX output errors before arming." + return + } + isArmed = true + status = "Output armed. Target \(targetHost):\(targetPort) is visible." + errorMessage = "" + } + + private func disarmOutput() { + isArmed = false + status = "Output disarmed. Send blackout before disconnecting if nonzero values were active." + errorMessage = "" + } + + @MainActor + private func sendPreviewFrame() async { + await send(compilation: preview, successPrefix: "Sent test frame") + } + + @MainActor + private func sendBlackout() async { + let blackout = PlotDmxOutput.compile(document, options: DmxOutputCompileOptions( + intent: .blackout, + selectedFixtureId: activeFixtureId, + values: values + )) + await send(compilation: blackout, successPrefix: "Sent blackout") + } + + @MainActor + private func send(compilation: DmxOutputCompilation, successPrefix: String) async { + guard isArmed else { + errorMessage = "Arm output before sending." + return + } + guard !compilation.blocked else { + errorMessage = "Compiler errors block all output." + return + } + guard let target = outputTarget() else { + errorMessage = "Enter a valid target host and UDP port." + return + } + + isSending = true + errorMessage = "" + defer { isSending = false } + + do { + var totalBytes = 0 + for universe in compilation.universes { + let portAddress = try mappedPortAddress(for: universe.universe) + let packet = try PlotDmxOutput.artNetDmxPacket(slots: universe.slots, portAddress: portAddress) + totalBytes += try await PlotDmxUdpSender().send(packet, to: target) + } + status = "\(successPrefix): \(compilation.universes.count) universe frame(s), \(totalBytes) UDP bytes." + } catch { + errorMessage = localNetworkFailureMessage(error) + status = "Output send failed." + } + } + + private func outputTarget() -> DmxOutputTarget? { + guard let port = UInt16(targetPort), + !targetHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return nil + } + return DmxOutputTarget(host: targetHost.trimmingCharacters(in: .whitespacesAndNewlines), port: port) + } + + private func mappedPortAddress(for documentUniverse: Int) throws -> DmxArtNetPortAddress { + let mappedUniverse = artNetUniverse + max(0, documentUniverse - 1) + guard mappedUniverse <= 15 else { + throw DmxOutputTransportError.invalidPortAddress + } + return DmxArtNetPortAddress(net: artNetNet, subNet: artNetSubNet, universe: mappedUniverse) + } + + private func localNetworkFailureMessage(_ error: Error) -> String { + let base = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + return "\(base). Check Local Network permission, target IP, Art-Net node power, and Wi-Fi/Ethernet membership." + } +} + +struct OutputIssueRow: View { + let issue: DmxOutputIssue + + var body: some View { + ModuleRowShell { + VStack(alignment: .leading, spacing: 5) { + Text(issue.code) + .font(.caption.weight(.semibold)) + .foregroundStyle(issue.severity == "error" ? .red : .orange) + Text(issue.message) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(3) + } + } + } +} + +struct DmxUniversePreviewRow: View { + let universe: DmxUniverseOutput + + var body: some View { + ModuleRowShell { + VStack(alignment: .leading, spacing: 7) { + HStack { + Text("Universe \(universe.universe)") + .font(.subheadline.weight(.semibold)) + Spacer() + Text(universe.blocked ? "Blocked" : "\(universe.nonZeroSlots.count) active") + .font(.caption) + .foregroundStyle(universe.blocked ? .red : .secondary) + } + if universe.nonZeroSlots.isEmpty { + Text("All slots zero.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(universe.nonZeroSlots.prefix(8), id: \.address) { slot in + Text("\(slot.address): \(slot.value) \(slot.type)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + } + } +} + struct LabelToolPanel: View { let settings: LabelSettings let onUpdate: (_ settings: LabelSettings) -> Void diff --git a/native/PlotForgeNative/Sources/PlotForgeCore/PlotDmxOutput.swift b/native/PlotForgeNative/Sources/PlotForgeCore/PlotDmxOutput.swift new file mode 100644 index 0000000..0e77319 --- /dev/null +++ b/native/PlotForgeNative/Sources/PlotForgeCore/PlotDmxOutput.swift @@ -0,0 +1,603 @@ +import Foundation +@preconcurrency import Network + +public enum DmxOutputIntent: String, Sendable { + case selectedFixtureTest = "selected-fixture-test" + case blackout +} + +public struct DmxOutputCompileOptions: Equatable, Sendable { + public var intent: DmxOutputIntent + public var selectedFixtureId: String? + public var values: [String: Int] + + public init( + intent: DmxOutputIntent = .selectedFixtureTest, + selectedFixtureId: String? = nil, + values: [String: Int] = PlotDmxOutput.defaultTestValues + ) { + self.intent = intent + self.selectedFixtureId = selectedFixtureId + self.values = values + } +} + +public struct DmxOutputIssue: Equatable, Sendable { + public var code: String + public var severity: String + public var fixtureId: String? + public var universe: Int? + public var message: String + + public init(code: String, severity: String, fixtureId: String? = nil, universe: Int? = nil, message: String) { + self.code = code + self.severity = severity + self.fixtureId = fixtureId + self.universe = universe + self.message = message + } +} + +public struct DmxSlotWrite: Equatable, Sendable { + public var address: Int + public var value: UInt8 + public var fixtureId: String + public var type: String + public var label: String + public var explanation: String +} + +public struct DmxUniverseOutput: Equatable, Sendable { + public var universe: Int + public var blocked: Bool + public var slots: [UInt8] + public var nonZeroSlots: [DmxSlotWrite] +} + +public struct DmxOutputCompilation: Equatable, Sendable { + public var intent: DmxOutputIntent + public var selectedFixtureId: String? + public var blocked: Bool + public var errors: [DmxOutputIssue] + public var warnings: [DmxOutputIssue] + public var universes: [DmxUniverseOutput] +} + +public struct DmxArtNetPortAddress: Equatable, Sendable { + public var net: Int + public var subNet: Int + public var universe: Int + + public init(net: Int = 0, subNet: Int = 0, universe: Int = 0) { + self.net = net + self.subNet = subNet + self.universe = universe + } +} + +public struct DmxOutputTarget: Equatable, Sendable { + public var host: String + public var port: UInt16 + + public init(host: String = "127.0.0.1", port: UInt16 = 6_454) { + self.host = host + self.port = port + } +} + +public enum DmxOutputTransportError: Error, Equatable, LocalizedError { + case invalidPortAddress + case invalidPayloadLength(Int) + case invalidTarget + case sendFailed(String) + + public var errorDescription: String? { + switch self { + case .invalidPortAddress: + "Art-Net Port-Address must use Net 0-127, Sub-Net 0-15, and Universe 0-15." + case .invalidPayloadLength(let length): + "DMX payload length must be 2-512 slots; got \(length)." + case .invalidTarget: + "DMX target host and port are required." + case .sendFailed(let message): + "Local network DMX send failed: \(message)" + } + } +} + +private struct DmxFixtureRange: Equatable { + var fixture: Fixture + var profile: FixtureProfile? + var universe: Int? + var startAddress: Int? + var endAddress: Int? + var footprint: Int + var valid: Bool + var reason: String +} + +private struct DmxOutputChannel: Equatable { + var slot: Int + var label: String + var type: String + var defaultValue: Int + var safeMin: Int + var safeMax: Int + var unsafe: Bool +} + +private struct DmxOutputProfile: Equatable { + var id: String + var profileIds: [String] + var label: String + var footprint: Int + var approved: Bool + var channels: [DmxOutputChannel] +} + +public enum PlotDmxOutput { + public static let slotCount = 512 + public static let version = 1 + public static let defaultTestValues = [ + "intensity": 255, + "red": 255, + "green": 255, + "blue": 255, + "white": 0, + "amber": 0, + "uv": 0, + ] + + public static func compile( + _ document: PlotShowDocument, + options: DmxOutputCompileOptions = DmxOutputCompileOptions() + ) -> DmxOutputCompilation { + let values = defaultTestValues.merging(options.values) { _, override in override } + var warnings: [DmxOutputIssue] = [] + var errors: [DmxOutputIssue] = [] + var ranges: [DmxFixtureRange] = [] + + for fixture in orderedFixtures(document) { + guard hasAnyPatchField(fixture) else { continue } + guard let dmx = fixture.dmx, dmx.universe != nil, dmx.address != nil else { + errors.append(DmxOutputIssue( + code: "incomplete-range", + severity: "error", + fixtureId: fixture.id, + message: "\(fixture.id) has an incomplete DMX patch." + )) + continue + } + + let range = buildRange(document, fixture: fixture) + ranges.append(range) + + if !range.valid { + errors.append(DmxOutputIssue( + code: "invalid-range", + severity: "error", + fixtureId: fixture.id, + universe: range.universe, + message: "\(fixture.id): \(range.reason)" + )) + } + + if let outputMap = outputProfile(for: fixture.profileId, in: document) { + if outputMap.footprint != range.footprint { + warnings.append(DmxOutputIssue( + code: "footprint-mismatch", + severity: "warning", + fixtureId: fixture.id, + universe: range.universe, + message: "\(fixture.id) has a \(range.footprint)ch footprint, but the output map expects \(outputMap.footprint)ch." + )) + } + } else { + warnings.append(DmxOutputIssue( + code: "unmapped-personality", + severity: "warning", + fixtureId: fixture.id, + universe: range.universe, + message: "\(fixture.id) uses \(range.profile?.model ?? fixture.profileId), which has no DMX output map. Slots stay at zero." + )) + } + } + + errors.append(contentsOf: overlapErrors(ranges)) + let blocked = !errors.isEmpty + let universes = Array(Set(ranges.compactMap(\.universe).filter { $0 >= 1 })).sorted() + var universeOutputs = universes.map { universe in + DmxUniverseOutput( + universe: universe, + blocked: blocked, + slots: Array(repeating: 0, count: slotCount), + nonZeroSlots: [] + ) + } + + if !blocked && options.intent != .blackout, let selectedFixtureId = options.selectedFixtureId { + for range in ranges where range.valid && range.fixture.id == selectedFixtureId { + guard let outputMap = outputProfile(for: range.fixture.profileId, in: document), + let universeIndex = universeOutputs.firstIndex(where: { $0.universe == range.universe }) else { + continue + } + + for channel in outputMap.channels { + guard !channel.unsafe, channel.type != "raw", channel.slot >= 1, channel.slot <= range.footprint else { + continue + } + guard let startAddress = range.startAddress else { continue } + let address = startAddress + channel.slot - 1 + guard address >= 1, address <= slotCount else { continue } + let value = UInt8(clamp(values[channel.type] ?? channel.defaultValue, min: channel.safeMin, max: channel.safeMax)) + universeOutputs[universeIndex].slots[address - 1] = value + if value > 0 { + universeOutputs[universeIndex].nonZeroSlots.append(DmxSlotWrite( + address: address, + value: value, + fixtureId: range.fixture.id, + type: channel.type, + label: channel.label, + explanation: "\(outputMap.label) slot \(channel.slot) writes \(channel.type) (\(channel.label))" + )) + } + } + } + } + + return DmxOutputCompilation( + intent: options.intent, + selectedFixtureId: options.selectedFixtureId, + blocked: blocked, + errors: errors, + warnings: warnings, + universes: universeOutputs + ) + } + + public static func artNetDmxPacket( + slots: [UInt8], + portAddress: DmxArtNetPortAddress = DmxArtNetPortAddress(), + sequence: UInt8 = 0 + ) throws -> Data { + guard portAddress.net >= 0, portAddress.net <= 127, + portAddress.subNet >= 0, portAddress.subNet <= 15, + portAddress.universe >= 0, portAddress.universe <= 15 else { + throw DmxOutputTransportError.invalidPortAddress + } + guard slots.count >= 2, slots.count <= slotCount else { + throw DmxOutputTransportError.invalidPayloadLength(slots.count) + } + + var payload = slots + if payload.count % 2 == 1 { + payload.append(0) + } + + let subUni = UInt8((portAddress.subNet << 4) | portAddress.universe) + var packet = Data() + packet.append(contentsOf: [0x41, 0x72, 0x74, 0x2D, 0x4E, 0x65, 0x74, 0x00]) + packet.append(contentsOf: [0x00, 0x50]) + packet.append(contentsOf: [0x00, 0x0E]) + packet.append(sequence) + packet.append(0x00) + packet.append(subUni) + packet.append(UInt8(portAddress.net)) + packet.append(UInt8(payload.count >> 8)) + packet.append(UInt8(payload.count & 0xFF)) + packet.append(contentsOf: payload) + return packet + } +} + +public final class PlotDmxUdpSender: @unchecked Sendable { + public init() {} + + public func send(_ packet: Data, to target: DmxOutputTarget) async throws -> Int { + guard !target.host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let port = NWEndpoint.Port(rawValue: target.port) else { + throw DmxOutputTransportError.invalidTarget + } + + let host = NWEndpoint.Host(target.host) + let connection = NWConnection(host: host, port: port, using: .udp) + let queue = DispatchQueue(label: "plotforge.dmx.udp-send", qos: .userInitiated) + + return try await withCheckedThrowingContinuation { continuation in + let completion = DmxUdpSendCompletion(continuation: continuation, connection: connection) + + connection.stateUpdateHandler = { state in + switch state { + case .ready: + connection.send(content: packet, completion: .contentProcessed { error in + if let error { + completion.finish(.failure(DmxOutputTransportError.sendFailed(error.localizedDescription))) + } else { + completion.finish(.success(packet.count)) + } + }) + case .failed(let error): + completion.finish(.failure(DmxOutputTransportError.sendFailed(error.localizedDescription))) + case .cancelled: + break + default: + break + } + } + connection.start(queue: queue) + queue.asyncAfter(deadline: .now() + 2) { + completion.finish(.failure(DmxOutputTransportError.sendFailed("UDP send timed out"))) + } + } + } +} + +private final class DmxUdpSendCompletion: @unchecked Sendable { + private let continuation: CheckedContinuation + private let connection: NWConnection + private let lock = NSLock() + private var didResume = false + + init(continuation: CheckedContinuation, connection: NWConnection) { + self.continuation = continuation + self.connection = connection + } + + func finish(_ result: Result) { + lock.lock() + defer { lock.unlock() } + guard !didResume else { return } + didResume = true + connection.cancel() + switch result { + case .success(let bytesSent): + continuation.resume(returning: bytesSent) + case .failure(let error): + continuation.resume(throwing: error) + } + } +} + +private func outputProfile(for profileId: String, in document: PlotShowDocument) -> DmxOutputProfile? { + if let profile = document.fixtureProfiles[profileId], + let custom = approvedOutputMap(from: profile.extraFields["outputMap"], profileId: profileId) { + return custom + } + return genericOutputProfile(for: profileId) +} + +private func genericOutputProfile(for profileId: String) -> DmxOutputProfile? { + if ["s4_26", "s4_19", "s4_36", "s4_50", "fresnel", "par64", "followspot_1200"].contains(profileId) { + return DmxOutputProfile( + id: "generic-dimmer-1ch", + profileIds: [profileId], + label: "Generic 1ch dimmer", + footprint: 1, + approved: true, + channels: [channel(slot: 1, label: "Dimmer", type: "intensity")] + ) + } + if profileId == "led_par_rgbw" { + return DmxOutputProfile( + id: "generic-rgbw-dimmer-8ch", + profileIds: [profileId], + label: "Generic RGBW 8ch with dimmer", + footprint: 8, + approved: true, + channels: [ + channel(slot: 1, label: "Dimmer", type: "intensity"), + channel(slot: 2, label: "Red", type: "red"), + channel(slot: 3, label: "Green", type: "green"), + channel(slot: 4, label: "Blue", type: "blue"), + channel(slot: 5, label: "White", type: "white"), + channel(slot: 6, label: "Strobe", type: "strobe", unsafe: true), + channel(slot: 7, label: "Macro", type: "raw", unsafe: true), + channel(slot: 8, label: "Control", type: "raw", unsafe: true), + ] + ) + } + if profileId == "cyc_strip" { + return DmxOutputProfile( + id: "generic-rgbw-4ch", + profileIds: ["cyc_strip"], + label: "Generic RGBW 4ch", + footprint: 4, + approved: true, + channels: [ + channel(slot: 1, label: "Red", type: "red"), + channel(slot: 2, label: "Green", type: "green"), + channel(slot: 3, label: "Blue", type: "blue"), + channel(slot: 4, label: "White", type: "white"), + ] + ) + } + if profileId == "spot_mh" { + return DmxOutputProfile( + id: "generic-moving-spot-24ch", + profileIds: [profileId], + label: "Generic simple moving spot 24ch", + footprint: 24, + approved: true, + channels: [ + channel(slot: 1, label: "Dimmer", type: "intensity"), + channel(slot: 2, label: "Pan coarse", type: "panCoarse", unsafe: true), + channel(slot: 3, label: "Pan fine", type: "panFine", unsafe: true), + channel(slot: 4, label: "Tilt coarse", type: "tiltCoarse", unsafe: true), + channel(slot: 5, label: "Tilt fine", type: "tiltFine", unsafe: true), + channel(slot: 6, label: "Color wheel", type: "colorWheel", unsafe: true), + channel(slot: 7, label: "Gobo wheel", type: "goboWheel", unsafe: true), + channel(slot: 8, label: "Shutter", type: "shutter", unsafe: true), + channel(slot: 9, label: "Strobe", type: "strobe", unsafe: true), + channel(slot: 10, label: "Reset", type: "reset", unsafe: true), + ] + ) + } + return nil +} + +private func channel( + slot: Int, + label: String, + type: String, + defaultValue: Int = 0, + safeMin: Int = 0, + safeMax: Int = 255, + unsafe: Bool = false +) -> DmxOutputChannel { + DmxOutputChannel( + slot: slot, + label: label, + type: type, + defaultValue: unsafe ? 0 : defaultValue, + safeMin: safeMin, + safeMax: unsafe ? 0 : safeMax, + unsafe: unsafe + ) +} + +private func approvedOutputMap(from value: JSONValue?, profileId: String) -> DmxOutputProfile? { + guard let object = value?.objectValue, + object["source"]?.objectValue?["approved"]?.boolValue == true, + let channels = object["channels"]?.arrayValue else { + return nil + } + let parsedChannels = channels.enumerated().compactMap { index, item -> DmxOutputChannel? in + guard let channelObject = item.objectValue else { return nil } + let slot = channelObject["slot"]?.intValue ?? index + 1 + guard slot >= 1 else { return nil } + return channel( + slot: slot, + label: channelObject["label"]?.stringValue ?? channelObject["name"]?.stringValue ?? "Slot \(slot)", + type: channelObject["type"]?.stringValue ?? "raw", + defaultValue: channelObject["default"]?.intValue ?? channelObject["defaultValue"]?.intValue ?? 0, + safeMin: channelObject["safeMin"]?.intValue ?? 0, + safeMax: channelObject["safeMax"]?.intValue ?? 255, + unsafe: channelObject["unsafe"]?.boolValue ?? false + ) + } + guard !parsedChannels.isEmpty else { return nil } + return DmxOutputProfile( + id: object["id"]?.stringValue ?? "\(profileId)-output-map", + profileIds: [profileId], + label: object["label"]?.stringValue ?? "\(profileId) output map", + footprint: object["footprint"]?.intValue ?? parsedChannels.map(\.slot).max() ?? 1, + approved: true, + channels: parsedChannels.sorted { $0.slot < $1.slot } + ) +} + +private func orderedFixtures(_ document: PlotShowDocument) -> [Fixture] { + var seen = Set() + var ordered: [Fixture] = [] + for id in document.fixtureOrder { + if let fixture = document.fixtures[id] { + ordered.append(fixture) + seen.insert(id) + } + } + let rest = document.fixtures.values + .filter { !seen.contains($0.id) } + .sorted { $0.id < $1.id } + return ordered + rest +} + +private func hasAnyPatchField(_ fixture: Fixture) -> Bool { + guard let dmx = fixture.dmx else { return false } + return dmx.universe != nil || dmx.address != nil +} + +private func buildRange(_ document: PlotShowDocument, fixture: Fixture) -> DmxFixtureRange { + let profile = fixtureProfile(fixture.profileId, in: document) + let footprint = max(1, profile?.dmxFootprint ?? 1) + let universe = fixture.dmx?.universe + let startAddress = fixture.dmx?.address + let endAddress = startAddress.map { $0 + footprint - 1 } + let valid = universe.map { $0 >= 1 } == true + && startAddress.map { $0 >= 1 } == true + && endAddress.map { $0 <= PlotDmxOutput.slotCount } == true + return DmxFixtureRange( + fixture: fixture, + profile: profile, + universe: universe, + startAddress: startAddress, + endAddress: endAddress, + footprint: footprint, + valid: valid, + reason: valid ? "" : invalidRangeReason(universe: universe, startAddress: startAddress, endAddress: endAddress) + ) +} + +private func fixtureProfile(_ profileId: String, in document: PlotShowDocument) -> FixtureProfile? { + if let profile = document.fixtureProfiles[profileId] { return profile } + return PlotToolModules.seededFixtureProfiles.first { $0.id == profileId } +} + +private func invalidRangeReason(universe: Int?, startAddress: Int?, endAddress: Int?) -> String { + guard let universe, let startAddress, let endAddress else { + return "DMX universe and address must be whole numbers." + } + if universe < 1 || startAddress < 1 { + return "DMX universe and address must be positive." + } + if endAddress > PlotDmxOutput.slotCount { + return "DMX range \(startAddress)-\(endAddress) exceeds slot \(PlotDmxOutput.slotCount)." + } + return "DMX range is invalid." +} + +private func overlapErrors(_ ranges: [DmxFixtureRange]) -> [DmxOutputIssue] { + let byUniverse = Dictionary(grouping: ranges.filter(\.valid), by: \.universe) + var errors: [DmxOutputIssue] = [] + for (universe, universeRanges) in byUniverse { + let sortedRanges = universeRanges.sorted { + ($0.startAddress ?? 0, $0.fixture.id) < ($1.startAddress ?? 0, $1.fixture.id) + } + for index in sortedRanges.indices.dropFirst() { + for previousIndex in sortedRanges.indices where previousIndex < index { + let previous = sortedRanges[previousIndex] + let current = sortedRanges[index] + if (current.startAddress ?? 0) <= (previous.endAddress ?? 0) { + errors.append(DmxOutputIssue( + code: "overlap", + severity: "error", + fixtureId: current.fixture.id, + universe: universe, + message: "DMX U\(universe ?? 0) \(previous.startAddress ?? 0)-\(previous.endAddress ?? 0) overlaps \(current.startAddress ?? 0)-\(current.endAddress ?? 0)." + )) + } + } + } + } + return errors +} + +private func clamp(_ value: Int, min minValue: Int, max maxValue: Int) -> Int { + max(minValue, min(maxValue, value)) +} + +private extension JSONValue { + var objectValue: [String: JSONValue]? { + if case .object(let value) = self { return value } + return nil + } + + var arrayValue: [JSONValue]? { + if case .array(let value) = self { return value } + return nil + } + + var stringValue: String? { + if case .string(let value) = self { return value } + return nil + } + + var boolValue: Bool? { + if case .bool(let value) = self { return value } + return nil + } + + var intValue: Int? { + if case .number(let value) = self { return Int(value) } + return nil + } +} diff --git a/native/PlotForgeNative/Tests/PlotForgeAppShellTests/DmxOutputToolPanelTests.swift b/native/PlotForgeNative/Tests/PlotForgeAppShellTests/DmxOutputToolPanelTests.swift new file mode 100644 index 0000000..7d9197f --- /dev/null +++ b/native/PlotForgeNative/Tests/PlotForgeAppShellTests/DmxOutputToolPanelTests.swift @@ -0,0 +1,10 @@ +@testable import PlotForgeAppShell +import XCTest + +final class DmxOutputToolPanelTests: XCTestCase { + func testSidebarIncludesNativeOutputTool() { + XCTAssertTrue(PlotTool.allCases.contains(.output)) + XCTAssertEqual(PlotTool.output.rawValue, "Output") + XCTAssertEqual(PlotTool.output.systemImage, "antenna.radiowaves.left.and.right") + } +} diff --git a/native/PlotForgeNative/Tests/PlotForgeAppShellTests/PlotForgeStandaloneDocumentSessionTests.swift b/native/PlotForgeNative/Tests/PlotForgeAppShellTests/PlotForgeStandaloneDocumentSessionTests.swift index 521d484..4f6a06d 100644 --- a/native/PlotForgeNative/Tests/PlotForgeAppShellTests/PlotForgeStandaloneDocumentSessionTests.swift +++ b/native/PlotForgeNative/Tests/PlotForgeAppShellTests/PlotForgeStandaloneDocumentSessionTests.swift @@ -9,6 +9,7 @@ final class PlotForgeStandaloneDocumentSessionTests: XCTestCase { XCTAssertEqual(session.fileState, .new) XCTAssertEqual(session.fileStatus, "New plot") + XCTAssertFalse(session.hasUnsavedChanges) XCTAssertEqual(session.exportFilename, "untitled-show.plot") let data = try PlotDocumentCodec.encode( @@ -26,6 +27,7 @@ final class PlotForgeStandaloneDocumentSessionTests: XCTestCase { session.updateDocument(edited) XCTAssertEqual(session.fileState, .edited(filename: "road-show.plot")) + XCTAssertTrue(session.hasUnsavedChanges) XCTAssertEqual(session.fileStatus, "Unsaved changes to road-show.plot") XCTAssertEqual(try PlotDocumentCodec.decode(session.exportData()).metadata.drawingTitle, "Updated Plot") @@ -70,4 +72,72 @@ final class PlotForgeStandaloneDocumentSessionTests: XCTestCase { XCTAssertEqual(session.fileState, .edited(filename: nil)) XCTAssertEqual(session.fileStatus, "Unsaved new plot") } + + func testCleanNewAndOpenTransitionsProceedWithoutPrompt() { + var coordinator = PlotForgeDirtyDocumentTransitionCoordinator() + + XCTAssertEqual( + coordinator.request(.newDocument, hasUnsavedChanges: false), + .proceed(.newDocument) + ) + XCTAssertEqual( + coordinator.request(.openDocument, hasUnsavedChanges: false), + .proceed(.openDocument) + ) + XCTAssertNil(coordinator.pendingAction) + } + + func testDirtyCancelPreservesPendingDocument() { + let session = dirtySession(named: "Cancel Protected") + let original = session + var coordinator = PlotForgeDirtyDocumentTransitionCoordinator() + + XCTAssertEqual( + coordinator.request(.newDocument, hasUnsavedChanges: session.hasUnsavedChanges), + .confirmDiscard + ) + XCTAssertEqual(coordinator.resolve(.cancel), .none) + XCTAssertNil(coordinator.pendingAction) + XCTAssertEqual(session, original) + } + + func testDirtyDiscardContinuesNewAndOpenTransitions() { + for action in [PlotForgePendingDocumentAction.newDocument, .openDocument] { + var coordinator = PlotForgeDirtyDocumentTransitionCoordinator() + XCTAssertEqual(coordinator.request(action, hasUnsavedChanges: true), .confirmDiscard) + XCTAssertEqual(coordinator.resolve(.discard), .proceed(action)) + XCTAssertNil(coordinator.pendingAction) + } + } + + func testDirtySaveContinuesOnlyAfterSuccessfulExport() { + for action in [PlotForgePendingDocumentAction.newDocument, .openDocument] { + var coordinator = PlotForgeDirtyDocumentTransitionCoordinator() + XCTAssertEqual(coordinator.request(action, hasUnsavedChanges: true), .confirmDiscard) + XCTAssertEqual(coordinator.resolve(.save), .requestSave) + XCTAssertEqual(coordinator.pendingAction, action) + XCTAssertEqual(coordinator.finishSave(succeeded: true), .proceed(action)) + XCTAssertNil(coordinator.pendingAction) + } + } + + func testFailedExportPreservesDirtyDocumentAndStopsPendingAction() { + let session = dirtySession(named: "Export Failure Protected") + var coordinator = PlotForgeDirtyDocumentTransitionCoordinator() + + XCTAssertEqual(coordinator.request(.openDocument, hasUnsavedChanges: true), .confirmDiscard) + XCTAssertEqual(coordinator.resolve(.save), .requestSave) + XCTAssertEqual(coordinator.finishSave(succeeded: false), .none) + XCTAssertNil(coordinator.pendingAction) + XCTAssertEqual(session.document.show.name, "Export Failure Protected") + XCTAssertTrue(session.hasUnsavedChanges) + } + + private func dirtySession(named name: String) -> PlotForgeStandaloneDocumentSession { + var session = PlotForgeStandaloneDocumentSession() + var edited = session.document + edited.show.name = name + session.updateDocument(edited) + return session + } } diff --git a/native/PlotForgeNative/Tests/PlotForgeCoreTests/PlotDmxOutputTests.swift b/native/PlotForgeNative/Tests/PlotForgeCoreTests/PlotDmxOutputTests.swift new file mode 100644 index 0000000..ab2a737 --- /dev/null +++ b/native/PlotForgeNative/Tests/PlotForgeCoreTests/PlotDmxOutputTests.swift @@ -0,0 +1,298 @@ +import PlotForgeCore +import XCTest + +final class PlotDmxOutputTests: XCTestCase { + func testSelectedLedFixtureMatchesWebGoldenSlots() throws { + let document = dmxGoldenDocument() + let result = PlotDmxOutput.compile(document, options: DmxOutputCompileOptions( + selectedFixtureId: "fx_led_one", + values: [ + "intensity": 200, + "red": 255, + "green": 128, + "blue": 64, + "white": 32, + ] + )) + let universe = try XCTUnwrap(result.universes.first) + + XCTAssertFalse(result.blocked) + XCTAssertEqual(universe.slots.count, PlotDmxOutput.slotCount) + XCTAssertEqual(universe.slots[9], 200) + XCTAssertEqual(universe.slots[10], 255) + XCTAssertEqual(universe.slots[11], 128) + XCTAssertEqual(universe.slots[12], 64) + XCTAssertEqual(universe.slots[13], 32) + XCTAssertEqual(universe.slots[0], 0) + XCTAssertEqual(universe.slots[14], 0) + XCTAssertEqual(universe.slots[15], 0) + XCTAssertEqual(universe.slots[16], 0) + XCTAssertEqual(universe.slots[39], 0) + XCTAssertEqual(universe.nonZeroSlots.map(\.address), [10, 11, 12, 13, 14]) + XCTAssertEqual(universe.nonZeroSlots.map { Int($0.value) }, [200, 255, 128, 64, 32]) + XCTAssertEqual(universe.nonZeroSlots.map(\.type), ["intensity", "red", "green", "blue", "white"]) + XCTAssertEqual( + universe.nonZeroSlots.first?.explanation, + "Generic RGBW 8ch with dimmer slot 1 writes intensity (Dimmer)" + ) + } + + func testBlackoutKeepsEveryActiveUniverseAtZero() throws { + let result = PlotDmxOutput.compile(dmxGoldenDocument(), options: DmxOutputCompileOptions(intent: .blackout)) + let universe = try XCTUnwrap(result.universes.first) + + XCTAssertFalse(result.blocked) + XCTAssertTrue(universe.slots.allSatisfy { $0 == 0 }) + XCTAssertEqual(universe.nonZeroSlots, []) + } + + func testInvalidRangeAndOverlapBlockOutput() throws { + var invalid = dmxGoldenDocument() + invalid.fixtures["fx_mover_one"]?.dmx = DmxAddress(universe: 1, address: 500) + + let invalidResult = PlotDmxOutput.compile(invalid, options: DmxOutputCompileOptions(selectedFixtureId: "fx_led_one")) + XCTAssertTrue(invalidResult.blocked) + XCTAssertTrue(invalidResult.errors.contains { $0.code == "invalid-range" && $0.fixtureId == "fx_mover_one" }) + XCTAssertTrue(try XCTUnwrap(invalidResult.universes.first).slots.allSatisfy { $0 == 0 }) + + var overlap = dmxGoldenDocument() + overlap.fixtures["fx_s4_one"]?.dmx = DmxAddress(universe: 1, address: 12) + + let overlapResult = PlotDmxOutput.compile(overlap, options: DmxOutputCompileOptions(selectedFixtureId: "fx_led_one")) + XCTAssertTrue(overlapResult.blocked) + XCTAssertTrue(overlapResult.errors.contains { $0.code == "overlap" }) + XCTAssertTrue(try XCTUnwrap(overlapResult.universes.first).slots.allSatisfy { $0 == 0 }) + } + + func testSimpleMovingFixtureMapsOnlySafeIntensity() throws { + let result = PlotDmxOutput.compile(dmxGoldenDocument(), options: DmxOutputCompileOptions( + selectedFixtureId: "fx_mover_one", + values: [ + "intensity": 255, + "panCoarse": 255, + "panFine": 255, + "tiltCoarse": 255, + "tiltFine": 255, + "reset": 255, + ] + )) + let universe = try XCTUnwrap(result.universes.first) + + XCTAssertFalse(result.blocked) + XCTAssertEqual(result.warnings, []) + XCTAssertEqual(universe.slots[39], 255) + for address in 41...49 { + XCTAssertEqual(universe.slots[address - 1], 0) + } + XCTAssertEqual(universe.nonZeroSlots.map(\.explanation), [ + "Generic simple moving spot 24ch slot 1 writes intensity (Dimmer)", + ]) + } + + func testApprovedCustomOutputMapCanDriveNativeCompiler() throws { + var document = dmxGoldenDocument() + document.fixtureProfiles["ofl_demo_tiny_wash"] = FixtureProfile( + id: "ofl_demo_tiny_wash", + manufacturer: "Demo", + model: "Tiny Wash", + symbol: "par", + category: "led-wash", + radiusMm: 235, + dmxFootprint: 3, + defaultMode: "RGB", + modes: [FixtureMode(name: "RGB", dmxFootprint: 3)], + libraryTier: "ofl-import", + extraFields: [ + "outputMap": .object([ + "id": .string("ofl_demo_tiny_wash-output-map"), + "label": .string("Tiny Wash approved map"), + "footprint": .number(3), + "source": .object(["approved": .bool(true)]), + "channels": .array([ + .object(["slot": .number(1), "label": .string("Red"), "type": .string("red")]), + .object(["slot": .number(2), "label": .string("Green"), "type": .string("green")]), + .object(["slot": .number(3), "label": .string("Blue"), "type": .string("blue")]), + ]), + ]), + ] + ) + document.fixtures["fx_ofl"] = Fixture( + id: "fx_ofl", + positionId: "pos_stage", + profileId: "ofl_demo_tiny_wash", + xMm: 2438, + unitNumber: 4, + dmx: DmxAddress(universe: 1, address: 100) + ) + document.fixtureOrder.append("fx_ofl") + + let result = PlotDmxOutput.compile(document, options: DmxOutputCompileOptions( + selectedFixtureId: "fx_ofl", + values: ["red": 11, "green": 22, "blue": 33] + )) + let universe = try XCTUnwrap(result.universes.first) + + XCTAssertFalse(result.blocked) + XCTAssertEqual(universe.slots[99], 11) + XCTAssertEqual(universe.slots[100], 22) + XCTAssertEqual(universe.slots[101], 33) + } + + func testApprovedOutputMapFootprintMismatchWarns() throws { + var document = dmxGoldenDocument() + document.fixtureProfiles["ofl_mismatch"] = FixtureProfile( + id: "ofl_mismatch", + manufacturer: "Demo", + model: "Mismatch Wash", + symbol: "par", + category: "led-wash", + radiusMm: 235, + dmxFootprint: 8, + defaultMode: "RGBAWUV", + modes: [FixtureMode(name: "RGBAWUV", dmxFootprint: 8)], + libraryTier: "ofl-import", + extraFields: [ + "outputMap": .object([ + "id": .string("ofl_mismatch-output-map"), + "label": .string("Mismatch approved map"), + "footprint": .number(4), + "source": .object(["approved": .bool(true)]), + "channels": .array([ + .object(["slot": .number(1), "label": .string("Red"), "type": .string("red")]), + .object(["slot": .number(2), "label": .string("Green"), "type": .string("green")]), + .object(["slot": .number(3), "label": .string("Blue"), "type": .string("blue")]), + .object(["slot": .number(4), "label": .string("White"), "type": .string("white")]), + ]), + ]), + ] + ) + document.fixtures["fx_mismatch"] = Fixture( + id: "fx_mismatch", + positionId: "pos_stage", + profileId: "ofl_mismatch", + xMm: 2_438, + unitNumber: 4, + dmx: DmxAddress(universe: 1, address: 100) + ) + document.fixtureOrder.append("fx_mismatch") + + let result = PlotDmxOutput.compile(document, options: DmxOutputCompileOptions(selectedFixtureId: "fx_mismatch")) + let warning = try XCTUnwrap(result.warnings.first { $0.code == "footprint-mismatch" && $0.fixtureId == "fx_mismatch" }) + + XCTAssertFalse(result.blocked) + XCTAssertEqual(warning.universe, 1) + XCTAssertTrue(warning.message.contains("8ch footprint")) + XCTAssertTrue(warning.message.contains("expects 4ch")) + } + + func testCycStripRgbwStaysPaperworkOnlyUntilSharedMapExists() throws { + var document = dmxGoldenDocument() + document.fixtures["fx_cyc"] = Fixture( + id: "fx_cyc", + positionId: "pos_stage", + profileId: "cyc_strip_rgbw", + xMm: 2_438, + unitNumber: 4, + dmx: DmxAddress(universe: 1, address: 100) + ) + document.fixtureOrder.append("fx_cyc") + + let result = PlotDmxOutput.compile(document, options: DmxOutputCompileOptions(selectedFixtureId: "fx_cyc")) + let warning = try XCTUnwrap(result.warnings.first { $0.code == "unmapped-personality" && $0.fixtureId == "fx_cyc" }) + + XCTAssertFalse(result.blocked) + XCTAssertEqual(warning.universe, 1) + XCTAssertTrue(warning.message.contains("no DMX output map")) + } + + func testNativeArtNetPacketMatchesExpectedHeaderAndPayload() throws { + let document = dmxGoldenDocument() + let result = PlotDmxOutput.compile(document, options: DmxOutputCompileOptions( + selectedFixtureId: "fx_led_one", + values: ["intensity": 200, "red": 255, "green": 128, "blue": 64, "white": 32] + )) + let universe = try XCTUnwrap(result.universes.first) + + let packet = try PlotDmxOutput.artNetDmxPacket( + slots: universe.slots, + portAddress: DmxArtNetPortAddress(net: 1, subNet: 2, universe: 3), + sequence: 7 + ) + + XCTAssertEqual(packet.count, 530) + XCTAssertEqual(Array(packet.prefix(18)), [ + 0x41, 0x72, 0x74, 0x2D, 0x4E, 0x65, 0x74, 0x00, + 0x00, 0x50, + 0x00, 0x0E, + 0x07, + 0x00, + 0x23, + 0x01, + 0x02, 0x00, + ]) + XCTAssertEqual(packet[18 + 9], 200) + XCTAssertEqual(packet[18 + 10], 255) + XCTAssertEqual(packet[18 + 11], 128) + XCTAssertEqual(packet[18 + 12], 64) + XCTAssertEqual(packet[18 + 13], 32) + } + + func testArtNetPacketValidationRejectsBadAddressAndPayload() { + XCTAssertThrowsError(try PlotDmxOutput.artNetDmxPacket( + slots: [0, 0], + portAddress: DmxArtNetPortAddress(net: 128, subNet: 0, universe: 0) + )) { error in + XCTAssertEqual(error as? DmxOutputTransportError, .invalidPortAddress) + } + + XCTAssertThrowsError(try PlotDmxOutput.artNetDmxPacket( + slots: [0], + portAddress: DmxArtNetPortAddress() + )) { error in + XCTAssertEqual(error as? DmxOutputTransportError, .invalidPayloadLength(1)) + } + } + + private func dmxGoldenDocument() -> PlotShowDocument { + let position = Position(id: "pos_stage", name: "1ST ELEC", yMm: -2_438, lengthMm: 8_534) + let profiles = Dictionary(uniqueKeysWithValues: PlotToolModules.seededFixtureProfiles + .filter { ["s4_26", "led_par_rgbw", "spot_mh"].contains($0.id) } + .map { ($0.id, $0) }) + let fixtures = [ + "fx_s4_one": Fixture( + id: "fx_s4_one", + positionId: "pos_stage", + profileId: "s4_26", + xMm: -1_829, + unitNumber: 1, + dmx: DmxAddress(universe: 1, address: 1) + ), + "fx_led_one": Fixture( + id: "fx_led_one", + positionId: "pos_stage", + profileId: "led_par_rgbw", + xMm: 0, + unitNumber: 2, + dmx: DmxAddress(universe: 1, address: 10) + ), + "fx_mover_one": Fixture( + id: "fx_mover_one", + positionId: "pos_stage", + profileId: "spot_mh", + xMm: 1_829, + unitNumber: 3, + dmx: DmxAddress(universe: 1, address: 40) + ), + ] + + return PlotShowDocument( + id: "show_dmx_golden", + name: "DMX Golden Test Plot", + positions: ["pos_stage": position], + positionOrder: ["pos_stage"], + fixtures: fixtures, + fixtureOrder: ["fx_s4_one", "fx_led_one", "fx_mover_one"], + fixtureProfiles: profiles + ) + } +} diff --git a/native/PlotForgeNative/Tests/PlotForgeCoreTests/PlotDocumentCodableTests.swift b/native/PlotForgeNative/Tests/PlotForgeCoreTests/PlotDocumentCodableTests.swift index 42b6ada..b2df124 100644 --- a/native/PlotForgeNative/Tests/PlotForgeCoreTests/PlotDocumentCodableTests.swift +++ b/native/PlotForgeNative/Tests/PlotForgeCoreTests/PlotDocumentCodableTests.swift @@ -54,6 +54,20 @@ final class PlotDocumentCodableTests: XCTestCase { "nativeShouldKeep": true, "nested": ["alpha", 3] }, + "dmxOutput": { + "version": 1, + "protocol": "artnet", + "relayUrl": "ws://127.0.0.1:8766", + "targetHost": "127.0.0.1", + "targetPort": 6454, + "universes": { + "1": { + "net": 0, + "subNet": 1, + "universe": 2 + } + } + }, "metadata": { "drawingTitle": "Future Field Test", "venueName": "Studio A" @@ -116,6 +130,9 @@ final class PlotDocumentCodableTests: XCTestCase { let encoded = try PlotDocumentCodec.encode(document) let root = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) let futureRoot = try XCTUnwrap(root["futureRoot"] as? [String: Any]) + let dmxOutput = try XCTUnwrap(root["dmxOutput"] as? [String: Any]) + let universes = try XCTUnwrap(dmxOutput["universes"] as? [String: Any]) + let universeOne = try XCTUnwrap(universes["1"] as? [String: Any]) let fixtures = try XCTUnwrap(root["fixtures"] as? [String: Any]) let fixture = try XCTUnwrap(fixtures["fx_1"] as? [String: Any]) let futureFixture = try XCTUnwrap(fixture["futureFixture"] as? [String: Any]) @@ -125,6 +142,10 @@ final class PlotDocumentCodableTests: XCTestCase { let info = try XCTUnwrap(profile["info"] as? [String: Any]) XCTAssertEqual(futureRoot["nativeShouldKeep"] as? Bool, true) + XCTAssertEqual(dmxOutput["protocol"] as? String, "artnet") + XCTAssertEqual(dmxOutput["targetPort"] as? Int, 6454) + XCTAssertEqual(universeOne["subNet"] as? Int, 1) + XCTAssertEqual(universeOne["universe"] as? Int, 2) XCTAssertEqual(futureFixture["vendorData"] as? String, "keep me") XCTAssertEqual(futureProfile["wikiUrl"] as? String, "https://example.test/profile") XCTAssertEqual(info["futureInfo"] as? String, "keep info") diff --git a/native/PlotForgeNativeApp/PlotForgeNative.xcodeproj/project.pbxproj b/native/PlotForgeNativeApp/PlotForgeNative.xcodeproj/project.pbxproj index 2473b43..326f380 100644 --- a/native/PlotForgeNativeApp/PlotForgeNative.xcodeproj/project.pbxproj +++ b/native/PlotForgeNativeApp/PlotForgeNative.xcodeproj/project.pbxproj @@ -10,11 +10,24 @@ 1F6B0D3323E94F3693958FA1 /* PlotForgeNativeApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F41FB2A86D94181BE643D4D /* PlotForgeNativeApp.swift */; }; 39CE36979A4B42A88711FC43 /* PlotForgeAppShell in Frameworks */ = {isa = PBXBuildFile; productRef = 877B77FD4EAB46C695C6AD97 /* PlotForgeAppShell */; }; 42DBE62A6D68496DB51887E0 /* PlotForgeDocumentUI in Frameworks */ = {isa = PBXBuildFile; productRef = 4A4050BC8B8A4354A21C2FE2 /* PlotForgeDocumentUI */; }; + A10100000000000000000000 /* PlotForgeNativeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000000 /* PlotForgeNativeUITests.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + A10B00000000000000000000 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = F7F58CB7996A45D5B593F290 /* Project object */; + proxyType = 1; + remoteGlobalIDString = C0D97E364552410EAF5DA810; + remoteInfo = PlotForgeNativeApp; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 32CB114D430E4439BF4D18A0 /* PlotForgeNative.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PlotForgeNative.app; sourceTree = BUILT_PRODUCTS_DIR; }; 5F41FB2A86D94181BE643D4D /* PlotForgeNativeApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlotForgeNativeApp.swift; sourceTree = ""; }; + A10000000000000000000000 /* PlotForgeNativeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlotForgeNativeUITests.swift; sourceTree = ""; }; + A10200000000000000000000 /* PlotForgeNativeUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PlotForgeNativeUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -27,6 +40,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A10500000000000000000000 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -34,6 +54,7 @@ isa = PBXGroup; children = ( ABC5F4AC23B3418692D78E42 /* PlotForgeNative */, + A10300000000000000000000 /* PlotForgeNativeUITests */, 9BB787C2E858447C832B5EBE /* Products */, ); sourceTree = ""; @@ -42,6 +63,7 @@ isa = PBXGroup; children = ( 32CB114D430E4439BF4D18A0 /* PlotForgeNative.app */, + A10200000000000000000000 /* PlotForgeNativeUITests.xctest */, ); name = Products; sourceTree = ""; @@ -54,6 +76,14 @@ path = PlotForgeNative; sourceTree = ""; }; + A10300000000000000000000 /* PlotForgeNativeUITests */ = { + isa = PBXGroup; + children = ( + A10000000000000000000000 /* PlotForgeNativeUITests.swift */, + ); + path = PlotForgeNativeUITests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -69,7 +99,7 @@ ); dependencies = ( ); - name = PlotForgeNative; + name = PlotForgeNativeApp; packageProductDependencies = ( 877B77FD4EAB46C695C6AD97 /* PlotForgeAppShell */, 4A4050BC8B8A4354A21C2FE2 /* PlotForgeDocumentUI */, @@ -78,6 +108,26 @@ productReference = 32CB114D430E4439BF4D18A0 /* PlotForgeNative.app */; productType = "com.apple.product-type.application"; }; + A10700000000000000000000 /* PlotForgeNativeUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A10800000000000000000000 /* Build configuration list for PBXNativeTarget "PlotForgeNativeUITests" */; + buildPhases = ( + A10400000000000000000000 /* Sources */, + A10500000000000000000000 /* Frameworks */, + A10600000000000000000000 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + A10C00000000000000000000 /* PBXTargetDependency */, + ); + name = PlotForgeNativeUITests; + packageProductDependencies = ( + ); + productName = PlotForgeNativeUITests; + productReference = A10200000000000000000000 /* PlotForgeNativeUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -88,6 +138,10 @@ LastSwiftUpdateCheck = 2600; LastUpgradeCheck = 2650; TargetAttributes = { + A10700000000000000000000 = { + CreatedOnToolsVersion = 26.5; + TestTargetID = C0D97E364552410EAF5DA810; + }; C0D97E364552410EAF5DA810 = { CreatedOnToolsVersion = 26.5; }; @@ -111,6 +165,7 @@ projectRoot = ""; targets = ( C0D97E364552410EAF5DA810 /* PlotForgeNative */, + A10700000000000000000000 /* PlotForgeNativeUITests */, ); }; /* End PBXProject section */ @@ -123,6 +178,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A10600000000000000000000 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -134,9 +196,49 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A10400000000000000000000 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A10100000000000000000000 /* PlotForgeNativeUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ + A10900000000000000000000 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.davehomeassist.plotforge.native.UITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 2; + TEST_TARGET_NAME = PlotForgeNativeApp; + }; + name = Debug; + }; + A10A00000000000000000000 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.davehomeassist.plotforge.native.UITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 2; + TEST_TARGET_NAME = PlotForgeNativeApp; + }; + name = Release; + }; 12127D987E394BE892EB2DEC /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -270,6 +372,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = PlotForge; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "PlotForge sends opt-in Art-Net or sACN test frames to local lighting nodes after output is armed."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -297,6 +400,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = PlotForge; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "PlotForge sends opt-in Art-Net or sACN test frames to local lighting nodes after output is armed."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -318,6 +422,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + A10800000000000000000000 /* Build configuration list for PBXNativeTarget "PlotForgeNativeUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A10900000000000000000000 /* Debug */, + A10A00000000000000000000 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 9FCB97C5351343578161F161 /* Build configuration list for PBXNativeTarget "PlotForgeNative" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -338,6 +451,14 @@ }; /* End XCConfigurationList section */ +/* Begin PBXTargetDependency section */ + A10C00000000000000000000 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = C0D97E364552410EAF5DA810 /* PlotForgeNative */; + targetProxy = A10B00000000000000000000 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCLocalSwiftPackageReference section */ 3D0F3363BEF74BD2A9D8F343 /* XCLocalSwiftPackageReference "../PlotForgeNative" */ = { isa = XCLocalSwiftPackageReference; diff --git a/native/PlotForgeNativeApp/PlotForgeNative.xcodeproj/xcshareddata/xcschemes/PlotForgeNative.xcscheme b/native/PlotForgeNativeApp/PlotForgeNative.xcodeproj/xcshareddata/xcschemes/PlotForgeNative.xcscheme index bf16713..3125dca 100644 --- a/native/PlotForgeNativeApp/PlotForgeNative.xcodeproj/xcshareddata/xcschemes/PlotForgeNative.xcscheme +++ b/native/PlotForgeNativeApp/PlotForgeNative.xcodeproj/xcshareddata/xcschemes/PlotForgeNative.xcscheme @@ -17,7 +17,21 @@ BuildableIdentifier = "primary" BlueprintIdentifier = "C0D97E364552410EAF5DA810" BuildableName = "PlotForgeNative.app" - BlueprintName = "PlotForgeNative" + BlueprintName = "PlotForgeNativeApp" + ReferencedContainer = "container:PlotForgeNative.xcodeproj"> + + + + @@ -29,7 +43,27 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> + + + + + + + + @@ -64,7 +98,7 @@ BuildableIdentifier = "primary" BlueprintIdentifier = "C0D97E364552410EAF5DA810" BuildableName = "PlotForgeNative.app" - BlueprintName = "PlotForgeNative" + BlueprintName = "PlotForgeNativeApp" ReferencedContainer = "container:PlotForgeNative.xcodeproj"> diff --git a/native/PlotForgeNativeApp/PlotForgeNativeUITests/PlotForgeNativeUITests.swift b/native/PlotForgeNativeApp/PlotForgeNativeUITests/PlotForgeNativeUITests.swift new file mode 100644 index 0000000..6cff18d --- /dev/null +++ b/native/PlotForgeNativeApp/PlotForgeNativeUITests/PlotForgeNativeUITests.swift @@ -0,0 +1,56 @@ +import XCTest + +final class PlotForgeNativeUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + func testDirtyNewCancelDiscardAndFailedOpenPreservation() throws { + let app = XCUIApplication() + app.launchArguments = ["-ui-testing-failed-open"] + app.launch() + + XCTAssertTrue(app.descendants(matching: .any)["plotforge-start-screen"].waitForExistence(timeout: 8)) + app.buttons["start-new-plot"].tap() + + XCTAssertTrue(app.descendants(matching: .any)["plotforge-workspace"].waitForExistence(timeout: 5)) + addFixture(in: app) + XCTAssertTrue(fixtureCount(in: app).label.contains("3")) + + app.buttons["workspace-new"].tap() + XCTAssertTrue(app.buttons["dirty-cancel"].waitForExistence(timeout: 3)) + app.buttons["dirty-cancel"].tap() + XCTAssertTrue(fixtureCount(in: app).label.contains("3"), "Cancel must preserve the dirty plot") + + app.buttons["workspace-new"].tap() + XCTAssertTrue(app.buttons["dirty-discard"].waitForExistence(timeout: 3)) + app.buttons["dirty-discard"].tap() + XCTAssertTrue(fixtureCount(in: app).label.contains("2"), "Discard must continue to a fresh starter plot") + + addFixture(in: app) + XCTAssertTrue(fixtureCount(in: app).label.contains("3")) + app.buttons["workspace-open"].tap() + XCTAssertTrue(app.buttons["dirty-discard"].waitForExistence(timeout: 3)) + app.buttons["dirty-discard"].tap() + + XCTAssertTrue(app.alerts["Open failed"].waitForExistence(timeout: 5)) + app.alerts["Open failed"].buttons["OK"].tap() + XCTAssertTrue(fixtureCount(in: app).label.contains("3"), "A failed open must preserve the current dirty plot") + } + + private func addFixture(in app: XCUIApplication) { + let fixturesTool = app.buttons["tool-fixtures"] + XCTAssertTrue(fixturesTool.waitForExistence(timeout: 5)) + fixturesTool.tap() + + let addFixture = app.buttons["fixture-add-s4_26"] + XCTAssertTrue(addFixture.waitForExistence(timeout: 5)) + addFixture.tap() + } + + private func fixtureCount(in app: XCUIApplication) -> XCUIElement { + let count = app.descendants(matching: .any)["fixture-count"] + XCTAssertTrue(count.waitForExistence(timeout: 5)) + return count + } +} diff --git a/package.json b/package.json index fbe7227..4ee7ac1 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,12 @@ "test": "vitest run", "test:watch": "vitest", "ci": "npm run lint && npm run test && npm run build", - "osc:relay": "node scripts/osc-relay.mjs" + "osc:relay": "node scripts/osc-relay.mjs", + "dmx:relay": "node scripts/dmx-relay.mjs", + "dmx:local-smoke": "node scripts/dmx-local-smoke.mjs", + "dmx:evidence-check": "node scripts/dmx-hardware-evidence-check.mjs", + "native:physical-evidence-check": "node scripts/native-physical-evidence-check.mjs", + "physical:evidence-status": "node scripts/physical-evidence-status.mjs" }, "dependencies": { "react": "^19.2.5", diff --git a/scripts/dmx-hardware-evidence-check.mjs b/scripts/dmx-hardware-evidence-check.mjs new file mode 100644 index 0000000..8c8473f --- /dev/null +++ b/scripts/dmx-hardware-evidence-check.mjs @@ -0,0 +1,166 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; + +const DEFAULT_DIR = "docs/dmx-smoke-evidence/2026-06-30"; +const REQUIRED_RESULTS = [ + "No output before arming", + "Arm succeeds", + "Selected fixture intensity test works", + "Blackout works", + "Bad target fails visibly", + "Network disconnect faults visibly", + "Relay crash or tab failure faults and blacks out", + "Blackout recovery works", +]; +const REQUIRED_METADATA = [ + "Protocol", + "Node", + "Node IP", + "Network interface", + "Fixture or dimmer", + "DMX universe/address", +]; + +function optionValue(name, fallback) { + const index = process.argv.indexOf(name); + if (index === -1 || index + 1 >= process.argv.length) return fallback; + return process.argv[index + 1]; +} + +function hasFlag(name) { + return process.argv.includes(name); +} + +function usage() { + return [ + "Usage: node scripts/dmx-hardware-evidence-check.mjs [--dir ] [--protocol ] [--fixture-verified] [--json]", + "", + "Checks the retained D3/D4 hardware evidence folder. Exits 0 only when", + "required artifacts, hardware metadata, and pass checkboxes are present.", + ].join("\n"); +} + +function existsNonEmpty(filePath) { + try { + return fs.statSync(filePath).isFile() && fs.statSync(filePath).size > 0; + } catch { + return false; + } +} + +function findFirstFile(dir, names) { + return names.find(name => existsNonEmpty(path.join(dir, name))) || ""; +} + +function readText(filePath) { + try { + return fs.readFileSync(filePath, "utf8"); + } catch { + return ""; + } +} + +function fieldValue(readme, label) { + const pattern = new RegExp(`^-\\s*${label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*:\\s*(.*?)\\s*$`, "i"); + for (const line of readme.split(/\r?\n/u)) { + const match = line.match(pattern); + if (match) return match[1].trim(); + } + return ""; +} + +function checkboxPassed(readme, label) { + const pattern = new RegExp(`^-\\s*\\[[xX]\\]\\s*${label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "i"); + return readme.split(/\r?\n/u).some(line => pattern.test(line)); +} + +function normalizeProtocol(value) { + const compact = String(value || "").trim().toLowerCase().replace(/[^a-z0-9]/gu, ""); + if (compact === "artnet") return "artnet"; + if (compact === "sacn" || compact === "e131") return "sacn"; + return compact; +} + +function protocolLabel(value) { + if (value === "artnet") return "Art-Net"; + if (value === "sacn") return "sACN"; + return value || "(unspecified)"; +} + +function checkEvidence(dir, { fixtureVerified = false, expectedProtocol = "" } = {}) { + const absoluteDir = path.resolve(dir); + const readmePath = path.join(absoluteDir, "README.md"); + const readme = readText(readmePath); + const protocol = fieldValue(readme, "Protocol"); + const normalizedProtocol = normalizeProtocol(protocol); + const normalizedExpectedProtocol = normalizeProtocol(expectedProtocol); + const checks = []; + + function add(name, passed, detail = "") { + checks.push({ name, passed, detail }); + } + + add("README.md exists", readme.length > 0, readmePath); + add("relay.log exists", existsNonEmpty(path.join(absoluteDir, "relay.log")), "required relay stdout/stderr capture"); + add("packet capture exists", Boolean(findFirstFile(absoluteDir, ["capture.pcap", "capture.pcapng"])), "capture.pcap or capture.pcapng"); + add("node-ui.png exists", existsNonEmpty(path.join(absoluteDir, "node-ui.png")), "node receive/output UI screenshot"); + + for (const label of REQUIRED_METADATA) { + add(`${label} recorded`, fieldValue(readme, label).length > 0, `README field: ${label}`); + } + if (normalizedExpectedProtocol) { + add( + `${protocolLabel(normalizedExpectedProtocol)} protocol recorded`, + normalizedProtocol === normalizedExpectedProtocol, + `README Protocol must be ${protocolLabel(normalizedExpectedProtocol)}; found ${protocol || "(blank)"}`, + ); + } + + for (const label of REQUIRED_RESULTS) { + add(`${label} passed`, checkboxPassed(readme, label), `README checkbox: ${label}`); + } + + if (fixtureVerified) { + add( + "fixture video exists", + Boolean(findFirstFile(absoluteDir, ["fixture-video.mov", "fixture-video.mp4"])), + "required only for fixture verified status", + ); + } + + const failed = checks.filter(check => !check.passed); + return { + status: failed.length === 0 ? "pass" : "fail", + dir: absoluteDir, + fixtureVerified, + expectedProtocol: normalizedExpectedProtocol || null, + checks, + failed, + }; +} + +if (hasFlag("--help") || hasFlag("-h")) { + process.stdout.write(`${usage()}\n`); + process.exit(0); +} + +const result = checkEvidence(optionValue("--dir", DEFAULT_DIR), { + fixtureVerified: hasFlag("--fixture-verified"), + expectedProtocol: optionValue("--protocol", ""), +}); + +if (hasFlag("--json")) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} else { + process.stdout.write(`PlotForge DMX hardware evidence: ${result.status.toUpperCase()}\n`); + process.stdout.write(`Directory: ${result.dir}\n`); + for (const check of result.checks) { + process.stdout.write(` ${check.passed ? "ok" : "FAIL"} ${check.name}`); + if (check.detail) process.stdout.write(` — ${check.detail}`); + process.stdout.write("\n"); + } +} + +process.exit(result.status === "pass" ? 0 : 1); diff --git a/scripts/dmx-local-smoke.mjs b/scripts/dmx-local-smoke.mjs new file mode 100644 index 0000000..8d24816 --- /dev/null +++ b/scripts/dmx-local-smoke.mjs @@ -0,0 +1,401 @@ +#!/usr/bin/env node + +import { randomBytes } from "node:crypto"; +import dgram from "node:dgram"; +import fs from "node:fs/promises"; +import net from "node:net"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { artDmxPacketSummary } from "../src/domain/artnet.js"; +import { createDmxRelayServer } from "../src/domain/dmxRelay.js"; +import { sacnPacketSummary } from "../src/domain/sacn.js"; + +const ALLOWED_ORIGIN = "http://127.0.0.1:5173"; +const TOKEN = "plotforge-local-smoke-token"; +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const DEFAULT_OUTPUT = path.join(ROOT, "docs/dmx-smoke-evidence/local/local-smoke-latest.json"); + +function packetStartsWith(message, signature) { + if (message.length < signature.length) return false; + for (let index = 0; index < signature.length; index += 1) { + if (message[index] !== signature.charCodeAt(index)) return false; + } + return true; +} + +function optionValue(name, fallback) { + const index = process.argv.indexOf(name); + if (index === -1 || index + 1 >= process.argv.length) return fallback; + return process.argv[index + 1]; +} + +function clientTextFrame(text) { + const payload = Buffer.from(String(text)); + const mask = randomBytes(4); + let header; + if (payload.length > 125) { + header = Buffer.alloc(4); + header[0] = 0x81; + header[1] = 0x80 | 126; + header.writeUInt16BE(payload.length, 2); + } else { + header = Buffer.from([0x81, 0x80 | payload.length]); + } + const masked = Buffer.alloc(payload.length); + for (let index = 0; index < payload.length; index += 1) { + masked[index] = payload[index] ^ mask[index % 4]; + } + return Buffer.concat([header, mask, masked]); +} + +function decodeServerFrames(buffer) { + const messages = []; + let offset = 0; + while (offset + 2 <= buffer.length) { + const frameStart = offset; + const opcode = buffer[offset] & 0x0f; + let length = buffer[offset + 1] & 0x7f; + const masked = (buffer[offset + 1] & 0x80) !== 0; + offset += 2; + if (length === 126) { + if (offset + 2 > buffer.length) return { messages, remaining: buffer.subarray(frameStart) }; + length = buffer.readUInt16BE(offset); + offset += 2; + } else if (length === 127) { + throw new Error("Large WebSocket frames are not supported by local smoke."); + } + if (masked || offset + length > buffer.length) return { messages, remaining: buffer.subarray(frameStart) }; + const payload = buffer.subarray(offset, offset + length); + offset += length; + if (opcode === 1) messages.push(JSON.parse(payload.toString("utf8"))); + } + return { messages, remaining: buffer.subarray(offset) }; +} + +function wsClient(socket, initial = Buffer.alloc(0)) { + let buffer = initial; + const queue = []; + const waiters = []; + + function drain() { + const decoded = decodeServerFrames(buffer); + buffer = decoded.remaining; + queue.push(...decoded.messages); + while (queue.length && waiters.length) { + waiters.shift().resolve(queue.shift()); + } + } + + socket.on("data", chunk => { + buffer = Buffer.concat([buffer, chunk]); + drain(); + }); + socket.on("error", error => { + while (waiters.length) waiters.shift().reject(error); + }); + drain(); + + return { + send(payload) { + socket.write(clientTextFrame(JSON.stringify(payload))); + }, + next(timeoutMs = 1000) { + if (queue.length) return Promise.resolve(queue.shift()); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("Timed out waiting for WebSocket message.")), timeoutMs); + waiters.push({ + resolve(message) { + clearTimeout(timer); + resolve(message); + }, + reject(error) { + clearTimeout(timer); + reject(error); + }, + }); + }); + }, + end() { + socket.end(); + }, + }; +} + +async function openWebSocket(port, origin = ALLOWED_ORIGIN) { + const socket = net.createConnection({ host: "127.0.0.1", port }); + await new Promise((resolve, reject) => { + socket.once("connect", resolve); + socket.once("error", reject); + }); + const key = randomBytes(16).toString("base64"); + socket.write([ + "GET / HTTP/1.1", + `Host: 127.0.0.1:${port}`, + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Key: ${key}`, + "Sec-WebSocket-Version: 13", + `Origin: ${origin}`, + "", + "", + ].join("\r\n")); + + const { header, rest } = await new Promise((resolve, reject) => { + let buffer = Buffer.alloc(0); + const timer = setTimeout(() => reject(new Error("Timed out waiting for upgrade response.")), 1000); + socket.on("data", function onData(chunk) { + buffer = Buffer.concat([buffer, chunk]); + const marker = buffer.indexOf("\r\n\r\n"); + if (marker === -1) return; + socket.off("data", onData); + clearTimeout(timer); + resolve({ + header: buffer.subarray(0, marker + 4).toString("utf8"), + rest: buffer.subarray(marker + 4), + }); + }); + socket.once("error", error => { + clearTimeout(timer); + reject(error); + }); + }); + + return { socket, header, client: wsClient(socket, rest) }; +} + +async function bindUdpCollector() { + const udp = dgram.createSocket("udp4"); + const packets = []; + udp.on("message", message => { + const receivedAt = new Date().toISOString(); + if (packetStartsWith(message, "Art-Net\0")) { + const summary = artDmxPacketSummary(message); + packets.push({ + receivedAt, + byteLength: message.length, + protocol: "artnet", + artnet: { + id: summary.id, + opcode: summary.opcode, + sequence: summary.sequence, + net: summary.net, + subUni: summary.subUni, + length: summary.length, + nonZeroSlots: Array.from(summary.payload) + .map((value, index) => value > 0 ? { address: index + 1, value } : null) + .filter(Boolean), + }, + }); + return; + } + const summary = sacnPacketSummary(message); + packets.push({ + receivedAt, + byteLength: message.length, + protocol: "sacn", + sacn: { + id: summary.id, + sourceName: summary.sourceName, + priority: summary.priority, + sequence: summary.sequence, + universe: summary.universe, + length: summary.payload.length, + nonZeroSlots: Array.from(summary.payload) + .map((value, index) => value > 0 ? { address: index + 1, value } : null) + .filter(Boolean), + }, + }); + }); + await new Promise(resolve => udp.bind(0, "127.0.0.1", resolve)); + return { + port: udp.address().port, + packets, + close() { + udp.close(); + }, + }; +} + +function packetNonZeroSlots(packet) { + return packet.artnet?.nonZeroSlots || packet.sacn?.nonZeroSlots || []; +} + +async function waitForPacketCount(packets, count, timeoutMs = 1200) { + const startedAt = Date.now(); + while (packets.length < count) { + if (Date.now() - startedAt > timeoutMs) { + throw new Error(`Timed out waiting for ${count} UDP packets; saw ${packets.length}.`); + } + await new Promise(resolve => setTimeout(resolve, 10)); + } +} + +function frameCommand(targetPort, data, overrides = {}) { + return { + type: "frame", + token: TOKEN, + protocol: "artnet", + target: { host: "127.0.0.1", port: targetPort }, + portAddress: { net: 0, subNet: 0, universe: 0 }, + length: data.length, + data: Buffer.from(data).toString("base64"), + sequence: overrides.sequence ?? 1, + ...overrides, + }; +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +async function authenticate(client) { + client.send({ type: "auth", protocolVersion: 1, token: TOKEN }); + const reply = await client.next(); + assert(reply.type === "auth.ok", `Expected auth.ok, received ${JSON.stringify(reply)}.`); + return reply; +} + +async function run() { + const outputPath = path.resolve(ROOT, optionValue("--output", DEFAULT_OUTPUT)); + const collector = await bindUdpCollector(); + let relay; + try { + relay = createDmxRelayServer({ + bindHost: "127.0.0.1", + wsPort: 0, + token: TOKEN, + requireToken: true, + allowedOrigins: [ALLOWED_ORIGIN], + heartbeatTimeoutMs: 150, + blackoutBurst: 2, + }); + await new Promise((resolve, reject) => { + relay.server.once("error", reject); + relay.server.listen(0, "127.0.0.1", () => { + relay.server.off("error", reject); + resolve(); + }); + }); + const relayPort = relay.server.address().port; + const evidence = { + kind: "plotforge-dmx-local-smoke", + generatedAt: new Date().toISOString(), + relay: { + url: `ws://127.0.0.1:${relayPort}`, + healthUrl: `http://127.0.0.1:${relayPort}/health`, + origin: ALLOWED_ORIGIN, + target: `127.0.0.1:${collector.port}`, + blackoutBurst: relay.config.blackoutBurst, + heartbeatTimeoutMs: relay.config.heartbeatTimeoutMs, + }, + checks: [], + packets: collector.packets, + }; + + const health = await fetch(evidence.relay.healthUrl); + const healthBody = await health.json(); + assert(health.status === 200 && healthBody.ok === true, "Relay /health did not return ok."); + evidence.checks.push({ name: "health", status: "pass", body: healthBody }); + + const badOrigin = await openWebSocket(relayPort, "https://example.com"); + assert(badOrigin.header.includes("403 Forbidden"), "Bad origin was not rejected."); + badOrigin.socket.destroy(); + evidence.checks.push({ name: "bad-origin-no-output", status: "pass", packetCount: collector.packets.length }); + + const { client } = await openWebSocket(relayPort); + await authenticate(client); + assert(collector.packets.length === 0, "Relay emitted UDP before a frame command."); + evidence.checks.push({ name: "auth-no-output-before-frame", status: "pass", packetCount: collector.packets.length }); + + const data = Buffer.alloc(512); + data[0] = 255; + data[1] = 64; + + client.send(frameCommand(collector.port, data, { + target: { host: "203.0.113.10", port: 6454 }, + })); + const invalidTarget = await client.next(); + assert(invalidTarget.type === "frame.error" && invalidTarget.reason === "public-target", "Public target was not rejected."); + assert(collector.packets.length === 0, "Invalid target emitted UDP."); + evidence.checks.push({ name: "bad-target-no-output", status: "pass", reply: invalidTarget }); + + client.send(frameCommand(collector.port, data, { sequence: 7 })); + const frameReply = await client.next(); + await waitForPacketCount(collector.packets, 1); + assert(frameReply.type === "frame.ok", "Selected frame did not return frame.ok."); + assert(collector.packets[0].protocol === "artnet", "Selected Art-Net frame was not decoded as Art-Net."); + assert(collector.packets[0].artnet.length === 512, "Selected frame did not send a 512-slot ArtDmx payload."); + assert(packetNonZeroSlots(collector.packets[0]).some(slot => slot.address === 1 && slot.value === 255), "Selected frame did not set slot 1."); + evidence.checks.push({ name: "selected-fixture-frame", status: "pass", reply: frameReply }); + + client.send({ type: "blackout", token: TOKEN }); + const blackoutReply = await client.next(); + await waitForPacketCount(collector.packets, 3); + assert(blackoutReply.type === "blackout.ok" && blackoutReply.framesSent === 2, "Blackout did not report the expected burst."); + assert(collector.packets.slice(1, 3).every(packet => packet.protocol === "artnet" && packetNonZeroSlots(packet).length === 0), "Blackout packets were not all-zero Art-Net."); + evidence.checks.push({ name: "blackout-burst", status: "pass", reply: blackoutReply }); + + client.send(frameCommand(collector.port, data, { + protocol: "sacn", + universe: 1, + sourceName: "PlotForge Local Smoke", + priority: 100, + sequence: 11, + })); + const sacnReply = await client.next(); + await waitForPacketCount(collector.packets, 4); + assert(sacnReply.type === "frame.ok" && sacnReply.protocol === "sacn", "sACN frame did not return frame.ok."); + assert(collector.packets[3].protocol === "sacn", "Selected sACN frame was not decoded as sACN."); + assert(collector.packets[3].sacn.universe === 1, "Selected sACN frame used the wrong universe."); + assert(collector.packets[3].sacn.length === 512, "Selected sACN frame did not send a 512-slot payload."); + assert(packetNonZeroSlots(collector.packets[3]).some(slot => slot.address === 1 && slot.value === 255), "Selected sACN frame did not set slot 1."); + evidence.checks.push({ name: "selected-sacn-frame", status: "pass", reply: sacnReply }); + + client.send({ type: "blackout", token: TOKEN }); + const sacnBlackoutReply = await client.next(); + await waitForPacketCount(collector.packets, 6); + assert(sacnBlackoutReply.type === "blackout.ok" && sacnBlackoutReply.framesSent === 2, "sACN blackout did not report the expected burst."); + assert(collector.packets.slice(4, 6).every(packet => packet.protocol === "sacn" && packetNonZeroSlots(packet).length === 0), "sACN blackout packets were not all zero."); + evidence.checks.push({ name: "sacn-blackout-burst", status: "pass", reply: sacnBlackoutReply }); + + client.send(frameCommand(collector.port, data, { sequence: 8 })); + await client.next(); + await waitForPacketCount(collector.packets, 7); + const watchdogReply = await client.next(1500); + await waitForPacketCount(collector.packets, 9); + assert(watchdogReply.type === "fault" && watchdogReply.reason === "heartbeat-timeout", "Watchdog did not fault on heartbeat timeout."); + assert(collector.packets.slice(7, 9).every(packet => packet.protocol === "artnet" && packetNonZeroSlots(packet).length === 0), "Watchdog blackout packets were not all-zero Art-Net."); + evidence.checks.push({ name: "heartbeat-timeout-blackout", status: "pass", reply: watchdogReply }); + + client.send(frameCommand(collector.port, data, { sequence: 9 })); + await client.next(); + await waitForPacketCount(collector.packets, 10); + client.end(); + await waitForPacketCount(collector.packets, 12); + assert(collector.packets.slice(10, 12).every(packet => packet.protocol === "artnet" && packetNonZeroSlots(packet).length === 0), "Disconnect blackout packets were not all-zero Art-Net."); + evidence.checks.push({ name: "active-client-disconnect-blackout", status: "pass" }); + + const shutdownClient = (await openWebSocket(relayPort)).client; + await authenticate(shutdownClient); + shutdownClient.send(frameCommand(collector.port, data, { sequence: 10 })); + await shutdownClient.next(); + await waitForPacketCount(collector.packets, 13); + await relay.close(); + relay = null; + await waitForPacketCount(collector.packets, 15); + assert(collector.packets.slice(13, 15).every(packet => packet.protocol === "artnet" && packetNonZeroSlots(packet).length === 0), "Relay shutdown blackout packets were not all-zero Art-Net."); + evidence.checks.push({ name: "relay-shutdown-blackout", status: "pass" }); + + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await fs.writeFile(outputPath, `${JSON.stringify(evidence, null, 2)}\n`); + process.stdout.write(`PlotForge local DMX smoke passed: ${outputPath}\n`); + } finally { + if (relay) await relay.close(); + collector.close(); + } +} + +run().catch(error => { + process.stderr.write(`PlotForge local DMX smoke failed: ${error.stack || error.message}\n`); + process.exitCode = 1; +}); diff --git a/scripts/dmx-relay.mjs b/scripts/dmx-relay.mjs new file mode 100644 index 0000000..0c8b358 --- /dev/null +++ b/scripts/dmx-relay.mjs @@ -0,0 +1,24 @@ +#!/usr/bin/env node + +import { dmxRelayConfigFromEnv, startDmxRelay } from "../src/domain/dmxRelay.js"; + +const config = dmxRelayConfigFromEnv(process.env); +const relay = await startDmxRelay(config); + +process.stdout.write(`PlotForge DMX relay listening on ws://${relay.config.bindHost}:${relay.config.wsPort}\n`); +process.stdout.write(`Health: http://${relay.config.bindHost}:${relay.config.wsPort}/health\n`); +if (relay.config.requireToken) { + process.stdout.write(`DMX relay token: ${relay.token}\n`); +} + +async function shutdown() { + await relay.close(); + process.exit(0); +} + +process.on("SIGINT", () => { + void shutdown(); +}); +process.on("SIGTERM", () => { + void shutdown(); +}); diff --git a/scripts/native-physical-evidence-check.mjs b/scripts/native-physical-evidence-check.mjs new file mode 100644 index 0000000..14438a6 --- /dev/null +++ b/scripts/native-physical-evidence-check.mjs @@ -0,0 +1,353 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; + +const DEFAULT_DIR = "/private/tmp/plotforge-native-physical-smoke-2026-06-26"; + +const REQUIRED_METADATA = ["Date", "Device", "Build", "Bundle", "Operator"]; +const REQUIRED_GATES = [ + { label: "Launch process" }, + { label: "Launch choice" }, + { label: "Launch document" }, + { label: "N2 canvas" }, + { label: "N3 inspector" }, + { label: "N3 multi select" }, + { label: "N4 fixtures" }, + { label: "N4 labels" }, + { label: "N4 Wizard" }, + { label: "N4 patch/checks" }, + { label: "N5 exports" }, + { label: "N5 export baseline" }, + { label: "Output arm" }, + { label: "Selected fixture output test", aliases: ["Selected fixture test"] }, + { label: "Blackout" }, + { label: "Local network permission" }, + { label: "Save/reopen" }, +]; + +const PROCESS_PATTERN = /PlotForgeNative\.app\/PlotForgeNative|com\.davehomeassist\.plotforge|PlotForgeNative/i; +const PASS_WORDS = new Set(["pass", "passed", "green", "verified", "done", "complete", "yes", "ok", "x"]); +const VISUAL_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".heic", ".mov", ".mp4"]); +const REQUIRED_CSV_HEADERS = [ + { + name: "patch CSV", + prefix: "Unit,Position,Profile,Mode,Status,Channel,Universe,Address,End Address,Footprint,Circuit,Dimmer,Color,Gobo,Color Note,Gobo Note,Focus Note,Crew Note,Conflicts", + }, + { + name: "gel rollup CSV", + prefix: "Gel,Count,Fixtures,Positions,Profiles", + }, + { + name: "circuit summary CSV", + prefix: "Circuit,Dimmer,Fixtures,Shared,Partial", + }, + { + name: "fixture paperwork CSV", + prefix: "Unit,Position,Profile,Manufacturer,Model,Mode,Channel,Universe,Address,Footprint,Status,Circuit,Dimmer,Color,Gobo,Focus X,Focus Y,Color Note,Gobo Note,Focus Note,Crew Note", + }, +]; + +function optionValue(name, fallback) { + const index = process.argv.indexOf(name); + if (index === -1 || index + 1 >= process.argv.length) return fallback; + return process.argv[index + 1]; +} + +function hasFlag(name) { + return process.argv.includes(name); +} + +function usage() { + return [ + "Usage: node scripts/native-physical-evidence-check.mjs [--dir ] [--json]", + "", + "Checks retained physical iPad smoke evidence for the native PlotForge app.", + "Exits 0 only when process proof, visual proof, export artifacts, metadata,", + "and all required pass rows are present.", + ].join("\n"); +} + +function existsNonEmpty(filePath) { + try { + const stat = fs.statSync(filePath); + return stat.isFile() && stat.size > 0; + } catch { + return false; + } +} + +function dirExists(dirPath) { + try { + return fs.statSync(dirPath).isDirectory(); + } catch { + return false; + } +} + +function readText(filePath) { + try { + return fs.readFileSync(filePath, "utf8"); + } catch { + return ""; + } +} + +function walkFiles(dirPath) { + let entries; + try { + entries = fs.readdirSync(dirPath, { withFileTypes: true }); + } catch { + return []; + } + + const files = []; + for (const entry of entries) { + const entryPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + files.push(...walkFiles(entryPath)); + } else if (entry.isFile() && existsNonEmpty(entryPath)) { + files.push(entryPath); + } + } + return files; +} + +function metadataValue(notes, label) { + const pattern = new RegExp(`^${escapeRegExp(label)}\\s*:\\s*(.*?)\\s*$`, "i"); + for (const line of notes.split(/\r?\n/u)) { + const match = line.match(pattern); + if (match) return match[1].trim(); + } + return ""; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function normalize(value) { + return value.trim().toLowerCase().replace(/\s+/g, " "); +} + +function resultPassed(value) { + const normalized = normalize(value).replace(/[^a-z0-9 ]/g, " ").trim(); + return PASS_WORDS.has(normalized) || /\bpass(ed)?\b/i.test(value) || /\bverified\b/i.test(value); +} + +function parseGateRows(notes) { + const rows = new Map(); + for (const line of notes.split(/\r?\n/u)) { + const trimmed = line.trim(); + if (!trimmed.startsWith("|") || !trimmed.endsWith("|")) continue; + const cells = trimmed.split("|").slice(1, -1).map(cell => cell.trim()); + if (cells.length < 4) continue; + const gate = normalize(cells[0]); + if (!gate || gate === "gate" || gate.startsWith("---")) continue; + rows.set(gate, { + gate: cells[0], + result: cells[1], + evidence: cells[2], + notes: cells[3], + }); + } + return rows; +} + +function processProofListed(processText) { + let inProcessSection = false; + for (const line of processText.split(/\r?\n/u)) { + if (/^##\s+PlotForge Processes\s*$/i.test(line.trim())) { + inProcessSection = true; + continue; + } + if (inProcessSection && /^##\s+/u.test(line.trim())) break; + if (inProcessSection && PROCESS_PATTERN.test(line)) return true; + } + return false; +} + +function findGateRow(rows, gate) { + const labels = [gate.label, ...(gate.aliases || [])].map(normalize); + for (const label of labels) { + if (rows.has(label)) return rows.get(label); + } + return null; +} + +function hasRequiredExports(exportFiles) { + const byExtension = new Map(); + for (const file of exportFiles) { + const ext = path.extname(file).toLowerCase(); + byExtension.set(ext, (byExtension.get(ext) || 0) + 1); + } + return { + plot: (byExtension.get(".plot") || 0) >= 1, + pdf: (byExtension.get(".pdf") || 0) >= 1, + csv: (byExtension.get(".csv") || 0) >= 4, + json: (byExtension.get(".json") || 0) >= 3, + }; +} + +function filesWithExtension(files, extension) { + return files.filter(file => path.extname(file).toLowerCase() === extension); +} + +function textStartsWith(filePath, prefix) { + return readText(filePath).startsWith(prefix); +} + +function jsonKindMatches(filePath, kind) { + const text = readText(filePath); + try { + const parsed = JSON.parse(text); + return parsed?.kind === kind; + } catch { + return new RegExp(`"kind"\\s*:\\s*"${escapeRegExp(kind)}"`, "u").test(text); + } +} + +function jsonFieldMatches(filePath, field, value) { + const text = readText(filePath); + try { + const parsed = JSON.parse(text); + return parsed?.[field] === value; + } catch { + return new RegExp(`"${escapeRegExp(field)}"\\s*:\\s*"${escapeRegExp(value)}"`, "u").test(text); + } +} + +function plotFileLooksValid(filePath) { + try { + const parsed = JSON.parse(readText(filePath)); + return Number(parsed?.version) === 9 && typeof parsed?.id === "string" && typeof parsed?.name === "string"; + } catch { + return false; + } +} + +function inspectExportArtifacts(exportFiles) { + const plotFiles = filesWithExtension(exportFiles, ".plot"); + const pdfFiles = filesWithExtension(exportFiles, ".pdf"); + const csvFiles = filesWithExtension(exportFiles, ".csv"); + const jsonFiles = filesWithExtension(exportFiles, ".json"); + const pdfReviewFiles = jsonFiles.filter(file => /pdf-review/i.test(path.basename(file))); + + return { + validPlot: plotFiles.some(plotFileLooksValid), + validPdf: pdfFiles.some(file => textStartsWith(file, "%PDF-1.4")), + csvHeaders: REQUIRED_CSV_HEADERS.map(header => ({ + ...header, + passed: csvFiles.some(file => textStartsWith(file, `${header.prefix}\n`) || textStartsWith(file, header.prefix)), + })), + oscBridgeJson: jsonFiles.some(file => jsonKindMatches(file, "plotforge-osc-bridge")), + interopManifestJson: jsonFiles.some(file => jsonKindMatches(file, "plotforge-interop-manifest")), + pdfReviewJson: pdfReviewFiles.some(file => jsonKindMatches(file, "plotforge-pdf-review")), + pdfReviewPending: pdfReviewFiles.some(file => jsonFieldMatches(file, "physicalSignoff", "pending")), + }; +} + +function checkEvidence(dir) { + const absoluteDir = path.resolve(dir); + const notesPath = path.join(absoluteDir, "notes.md"); + const processPath = path.join(absoluteDir, "device-process.txt"); + const exportDir = path.join(absoluteDir, "exported-files"); + const notes = readText(notesPath); + const processText = readText(processPath); + const files = walkFiles(absoluteDir); + const exportFiles = walkFiles(exportDir); + const rows = parseGateRows(notes); + const checks = []; + + function add(name, passed, detail = "") { + checks.push({ name, passed, detail }); + } + + const launchJsonFiles = files.filter(file => /^launch.*\.json$/i.test(path.basename(file))); + const installedAppsFiles = files.filter(file => /^installed-apps.*\.json$/i.test(path.basename(file))); + const displayFiles = files.filter(file => /^display.*\.json$/i.test(path.basename(file))); + const lockStateFiles = files.filter(file => /^lock-state.*\.json$/i.test(path.basename(file))); + const visualFiles = files.filter(file => VISUAL_EXTENSIONS.has(path.extname(file).toLowerCase())); + const exportSet = hasRequiredExports(exportFiles); + const exportArtifacts = inspectExportArtifacts(exportFiles); + const plotForgeProcessListed = processProofListed(processText); + const plotForgeAppInstalled = installedAppsFiles.some(file => /"bundleIdentifier"\s*:\s*"com\.davehomeassist\.plotforge\.native"/u.test(readText(file))); + const coreDeviceLaunchProof = + plotForgeProcessListed && + launchJsonFiles.length > 0 && + plotForgeAppInstalled && + displayFiles.length > 0 && + lockStateFiles.length > 0; + + add("notes.md exists", notes.length > 0, notesPath); + add("device-process.txt exists", existsNonEmpty(processPath), processPath); + add("PlotForge process is listed", plotForgeProcessListed, "device-process.txt must list the running app in the PlotForge Processes section"); + add("launch JSON exists", launchJsonFiles.length > 0, "launch*.json"); + add("installed apps JSON exists", installedAppsFiles.length > 0, "installed-apps*.json"); + add( + "PlotForge app is installed", + plotForgeAppInstalled, + "installed-apps*.json must include com.davehomeassist.plotforge.native", + ); + add("display JSON exists", displayFiles.length > 0, "display*.json"); + add("lock state JSON exists", lockStateFiles.length > 0, "lock-state*.json"); + add("CoreDevice launch proof complete", coreDeviceLaunchProof, "process, launch JSON, installed app, display, and lock-state evidence"); + add("visual proof exists", visualFiles.length > 0, "smoke-recording.mov/mp4 or screenshot image"); + add("exported-files exists", dirExists(exportDir), exportDir); + add("exported .plot exists", exportSet.plot, "at least one .plot under exported-files"); + add("exported .plot is valid v9 JSON", exportArtifacts.validPlot, "version 9 .plot with id and name"); + add("exported PDF exists", exportSet.pdf, "at least one .pdf under exported-files"); + add("exported PDF starts with %PDF-1.4", exportArtifacts.validPdf, "native vector PDF signature"); + add("exported CSV set exists", exportSet.csv, "at least four .csv files under exported-files"); + for (const header of exportArtifacts.csvHeaders) { + add(`${header.name} header matches`, header.passed, header.prefix); + } + add("exported JSON set exists", exportSet.json, "at least three .json files under exported-files"); + add("OSC bridge JSON is valid", exportArtifacts.oscBridgeJson, "kind: plotforge-osc-bridge"); + add("interop manifest JSON is valid", exportArtifacts.interopManifestJson, "kind: plotforge-interop-manifest"); + add("PDF review JSON is valid", exportArtifacts.pdfReviewJson, "kind: plotforge-pdf-review"); + add("PDF review physical signoff is pending", exportArtifacts.pdfReviewPending, "physicalSignoff: pending"); + + for (const label of REQUIRED_METADATA) { + add(`${label} recorded`, metadataValue(notes, label).length > 0, `notes.md field: ${label}`); + } + + for (const gate of REQUIRED_GATES) { + const row = findGateRow(rows, gate); + const autoPassed = gate.label === "Launch process" && coreDeviceLaunchProof; + const passed = Boolean(row) && (resultPassed(row.result) || autoPassed); + const evidenceReferenced = Boolean(row) && (row.evidence.trim().length > 0 || autoPassed); + add(`${gate.label} row exists`, Boolean(row), "notes.md Gate Results table"); + add(`${gate.label} passed`, passed, row ? `result: ${row.result}${autoPassed ? " (CoreDevice proof)" : ""}` : ""); + add(`${gate.label} evidence referenced`, evidenceReferenced, "Evidence File column"); + } + + const failed = checks.filter(check => !check.passed); + return { + status: failed.length === 0 ? "pass" : "fail", + dir: absoluteDir, + checks, + failed, + }; +} + +if (hasFlag("--help") || hasFlag("-h")) { + process.stdout.write(`${usage()}\n`); + process.exit(0); +} + +const result = checkEvidence(optionValue("--dir", DEFAULT_DIR)); + +if (hasFlag("--json")) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} else { + process.stdout.write(`PlotForge native physical evidence: ${result.status.toUpperCase()}\n`); + process.stdout.write(`Directory: ${result.dir}\n`); + for (const check of result.checks) { + process.stdout.write(` ${check.passed ? "ok" : "FAIL"} ${check.name}`); + if (check.detail) process.stdout.write(` - ${check.detail}`); + process.stdout.write("\n"); + } +} + +process.exit(result.status === "pass" ? 0 : 1); diff --git a/scripts/physical-evidence-status.mjs b/scripts/physical-evidence-status.mjs new file mode 100644 index 0000000..526c710 --- /dev/null +++ b/scripts/physical-evidence-status.mjs @@ -0,0 +1,129 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import path from "node:path"; + +const DEFAULT_ARTNET_DIR = "docs/dmx-smoke-evidence/2026-06-30"; +const DEFAULT_SACN_DIR = "docs/dmx-smoke-evidence/2026-06-30-sacn"; +const DEFAULT_NATIVE_DIR = "/private/tmp/plotforge-native-physical-smoke-2026-06-30-dmx-output"; + +function optionValue(name, fallback) { + const index = process.argv.indexOf(name); + if (index === -1 || index + 1 >= process.argv.length) return fallback; + return process.argv[index + 1]; +} + +function hasFlag(name) { + return process.argv.includes(name); +} + +function usage() { + return [ + "Usage: node scripts/physical-evidence-status.mjs [--artnet-dir ] [--sacn-dir ] [--native-dir ] [--json] [--no-fail]", + "", + "Runs the retained physical evidence checkers for D3 Art-Net, D4 sACN,", + "and D6 native iPad output. Exits 0 only when every gate passes unless", + "--no-fail is provided.", + ].join("\n"); +} + +function runChecker({ phase, label, script, args }) { + const result = spawnSync(process.execPath, [script, ...args, "--json"], { + cwd: process.cwd(), + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + + let parsed = null; + try { + parsed = JSON.parse(result.stdout); + } catch { + return { + phase, + label, + status: "error", + dir: "", + failed: [ + { + name: "checker output parsed", + detail: `${path.basename(script)} did not return JSON`, + }, + ], + stderr: result.stderr.trim(), + exitCode: result.status ?? 1, + }; + } + + return { + phase, + label, + status: parsed.status, + dir: parsed.dir, + failed: parsed.failed || [], + stderr: result.stderr.trim(), + exitCode: result.status ?? 0, + }; +} + +function statusLine(status) { + return status === "pass" ? "PASS" : "FAIL"; +} + +function printText(results) { + const overall = results.every(result => result.status === "pass") ? "PASS" : "FAIL"; + process.stdout.write(`PlotForge physical evidence status: ${overall}\n`); + + for (const result of results) { + const failedCount = result.failed.length; + process.stdout.write(`\n${result.phase} ${result.label}: ${statusLine(result.status)}`); + if (failedCount > 0) process.stdout.write(` (${failedCount} missing/failed checks)`); + process.stdout.write(`\nDirectory: ${result.dir || "(unknown)"}\n`); + + for (const check of result.failed.slice(0, 8)) { + process.stdout.write(` - ${check.name}`); + if (check.detail) process.stdout.write(`: ${check.detail}`); + process.stdout.write("\n"); + } + if (failedCount > 8) { + process.stdout.write(` - ... ${failedCount - 8} more\n`); + } + } + + process.stdout.write("\nNext required physical proof:\n"); + process.stdout.write("- D3/D4: relay log, packet capture, node UI screenshot, expected protocol, hardware metadata, and checked target/disconnect/recovery rows.\n"); + process.stdout.write("- D6: operator name, visual proof, content-valid native exported files, and checked UI/output/save rows.\n"); +} + +if (hasFlag("--help") || hasFlag("-h")) { + process.stdout.write(`${usage()}\n`); + process.exit(0); +} + +const results = [ + runChecker({ + phase: "D3", + label: "Art-Net hardware", + script: "scripts/dmx-hardware-evidence-check.mjs", + args: ["--dir", optionValue("--artnet-dir", DEFAULT_ARTNET_DIR), "--protocol", "artnet"], + }), + runChecker({ + phase: "D4", + label: "sACN hardware", + script: "scripts/dmx-hardware-evidence-check.mjs", + args: ["--dir", optionValue("--sacn-dir", DEFAULT_SACN_DIR), "--protocol", "sacn"], + }), + runChecker({ + phase: "D6", + label: "native iPad output", + script: "scripts/native-physical-evidence-check.mjs", + args: ["--dir", optionValue("--native-dir", DEFAULT_NATIVE_DIR)], + }), +]; + +if (hasFlag("--json")) { + process.stdout.write(`${JSON.stringify({ status: results.every(result => result.status === "pass") ? "pass" : "fail", results }, null, 2)}\n`); +} else { + printText(results); +} + +process.exit(results.every(result => result.status === "pass") || hasFlag("--no-fail") ? 0 : 1); diff --git a/scripts/physical-smoke-evidence.sh b/scripts/physical-smoke-evidence.sh index e18938c..9216da6 100755 --- a/scripts/physical-smoke-evidence.sh +++ b/scripts/physical-smoke-evidence.sh @@ -6,25 +6,62 @@ OUT_DIR="${2:-/private/tmp/plotforge-native-physical-smoke-2026-06-26}" LAUNCH_APP="${3:-}" BUNDLE_ID="${PLOTFORGE_BUNDLE_ID:-com.davehomeassist.plotforge.native}" BUNDLE_PATTERN='plotforge|com\.davehomeassist\.plotforge' +LAUNCH_EXIT=0 +CAPTURED_AT="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" mkdir -p "$OUT_DIR/smoke-screenshots" "$OUT_DIR/exported-files" if [[ "$LAUNCH_APP" == "--launch" ]]; then + set +e xcrun devicectl device process launch \ --device "$DEVICE_ID" \ --terminate-existing \ "$BUNDLE_ID" \ - --json-output "$OUT_DIR/launch.json" + --json-output "$OUT_DIR/launch.json" \ + 2> "$OUT_DIR/launch-stderr.txt" + LAUNCH_EXIT=$? + set -e fi +set +e +xcrun devicectl device info apps \ + --device "$DEVICE_ID" \ + --json-output "$OUT_DIR/installed-apps.json" \ + > "$OUT_DIR/installed-apps.txt" \ + 2> "$OUT_DIR/installed-apps-stderr.txt" +xcrun devicectl device info displays \ + --device "$DEVICE_ID" \ + --json-output "$OUT_DIR/display.json" \ + > "$OUT_DIR/display.txt" \ + 2> "$OUT_DIR/display-stderr.txt" +xcrun devicectl device info lockState \ + --device "$DEVICE_ID" \ + --json-output "$OUT_DIR/lock-state.json" \ + > "$OUT_DIR/lock-state.txt" \ + 2> "$OUT_DIR/lock-state-stderr.txt" +set -e + { echo "# PlotForge Native Physical Smoke Evidence" - echo "captured_at=$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + echo "captured_at=$CAPTURED_AT" echo "device_id=$DEVICE_ID" echo "bundle_id=$BUNDLE_ID" + if [[ "$LAUNCH_APP" == "--launch" ]]; then + echo "launch_exit=$LAUNCH_EXIT" + echo "launch_stderr=$OUT_DIR/launch-stderr.txt" + fi if [[ -f "$OUT_DIR/launch.json" ]]; then echo "launch_json=$OUT_DIR/launch.json" fi + if [[ -f "$OUT_DIR/installed-apps.json" ]]; then + echo "installed_apps_json=$OUT_DIR/installed-apps.json" + fi + if [[ -f "$OUT_DIR/display.json" ]]; then + echo "display_json=$OUT_DIR/display.json" + fi + if [[ -f "$OUT_DIR/lock-state.json" ]]; then + echo "lock_state_json=$OUT_DIR/lock-state.json" + fi echo echo "## Devices" xcrun devicectl list devices @@ -64,6 +101,10 @@ Operator: | N4 patch/checks | Pending | | | | N5 exports | Pending | | | | N5 export baseline | Pending | | | +| Output arm | Pending | | | +| Selected fixture output test | Pending | | | +| Blackout | Pending | | | +| Local network permission | Pending | | | | Save/reopen | Pending | | | ## Defects @@ -72,4 +113,49 @@ Operator: NOTES fi +set_blank_note_field() { + local label="$1" + local value="$2" + + if grep -Eq "^${label}:[[:space:]]*$" "$OUT_DIR/notes.md"; then + sed -i '' "s|^${label}:[[:space:]]*$|${label}: ${value}|" "$OUT_DIR/notes.md" + fi +} + +set_blank_note_field "Date" "$CAPTURED_AT" +set_blank_note_field "Device" "$DEVICE_ID" +set_blank_note_field "Build" "See installed-apps.json" +set_blank_note_field "Bundle" "$BUNDLE_ID" + +missing_gate_rows="" + +add_missing_gate_row() { + local gate="$1" + local evidence="$2" + local notes="$3" + + if ! grep -Fq "| $gate |" "$OUT_DIR/notes.md"; then + missing_gate_rows+="| $gate | Pending | $evidence | $notes |"$'\n' + fi +} + +add_missing_gate_row "Launch process" "device-process.txt, launch.json if captured" "CoreDevice proof only; visual launch choice still requires screenshot or recording" +add_missing_gate_row "Output arm" "" "" +add_missing_gate_row "Selected fixture output test" "" "" +add_missing_gate_row "Blackout" "" "" +add_missing_gate_row "Local network permission" "" "" + +if [[ -n "$missing_gate_rows" ]]; then + { + echo + echo "## Appended Gate Rows" + echo + echo "| Gate | Result | Evidence File | Notes |" + echo "| --- | --- | --- | --- |" + printf "%s" "$missing_gate_rows" + } >> "$OUT_DIR/notes.md" +fi + echo "$OUT_DIR" + +exit "$LAUNCH_EXIT" diff --git a/src/PlotForge.css b/src/PlotForge.css index ef82e46..8591279 100644 --- a/src/PlotForge.css +++ b/src/PlotForge.css @@ -637,7 +637,7 @@ input, textarea { font: inherit; color: inherit; } } .fixture-profile-row { display: grid; - grid-template-columns: minmax(0, 1fr) minmax(94px, auto) auto auto; + grid-template-columns: minmax(0, 1fr) minmax(94px, auto) minmax(92px, auto) auto auto; align-items: center; gap: 8px; width: 100%; @@ -689,6 +689,23 @@ input, textarea { font: inherit; color: inherit; } opacity: .45; cursor: not-allowed; } +.fixture-output-pill { + justify-self: end; + padding: 4px 7px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--surface); + color: var(--ink-mute); + white-space: nowrap; +} +.fixture-output-pill--output-ready { + border-color: color-mix(in srgb, var(--green) 48%, var(--line)); + color: var(--green); +} +.fixture-output-pill--candidate { + border-color: color-mix(in srgb, var(--amber) 48%, var(--line)); + color: var(--amber); +} .fixture-profile-detail { display: flex; flex-direction: column; @@ -805,6 +822,47 @@ input, textarea { font: inherit; color: inherit; } color: var(--blue); font-style: normal; } +.profile-output-channel-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + max-height: 150px; + overflow: auto; +} +.profile-output-channel-list span { + display: grid; + grid-template-columns: 28px minmax(0, 1fr) minmax(84px, auto); + gap: 6px; + align-items: center; + padding: 5px 7px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface); + color: var(--ink-mute); + font-size: 11px; +} +.profile-output-channel-list strong, +.profile-output-channel-list em, +.profile-output-channel-list code { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.profile-output-channel-list strong { + color: var(--blue); + font-family: var(--mono); +} +.profile-output-channel-list em { + color: var(--ink); + font-style: normal; +} +.profile-output-channel-list code { + color: var(--ink-mute); + font-family: var(--mono); + font-size: 10px; + text-align: right; +} .library-status { margin: 0; color: var(--green); @@ -885,7 +943,7 @@ input, textarea { font: inherit; color: inherit; } font-size: 13px; text-transform: none; letter-spacing: 0; } -.inspector input:focus, .inspector textarea:focus, .inspector select:focus, .project-metadata input:focus, .revisions-panel input:focus, .revisions-panel textarea:focus, .position-editor input:focus, .position-editor select:focus, .fixture-library input:focus, .fixture-library select:focus, .text-settings-panel input:focus, .print-export-panel select:focus, .osc-bridge-fields input:focus, .wizard-grid input:focus, .wizard-grid select:focus, .plot-starter-brief textarea:focus, .comment-editor textarea:focus, .position-row:focus-visible, .revision-row:focus-visible, .comment-pin-row:focus-visible, .registry-row button:focus-visible, .btn-compact:focus-visible, .segmented-control__item:focus-visible, .tool-tab:focus-visible, .mobile-dock button:focus-visible { +.inspector input:focus, .inspector textarea:focus, .inspector select:focus, .project-metadata input:focus, .revisions-panel input:focus, .revisions-panel textarea:focus, .position-editor input:focus, .position-editor select:focus, .fixture-library input:focus, .fixture-library select:focus, .text-settings-panel input:focus, .print-export-panel select:focus, .osc-bridge-fields input:focus, .dmx-output-controls input:focus, .dmx-output-controls select:focus, .dmx-output-relay input:focus, .dmx-output-relay select:focus, .dmx-output-inspector select:focus, .wizard-grid input:focus, .wizard-grid select:focus, .plot-starter-brief textarea:focus, .comment-editor textarea:focus, .position-row:focus-visible, .revision-row:focus-visible, .comment-pin-row:focus-visible, .registry-row button:focus-visible, .btn-compact:focus-visible, .segmented-control__item:focus-visible, .tool-tab:focus-visible, .mobile-dock button:focus-visible { outline: 2px solid var(--blue); outline-offset: -1px; border-color: transparent; @@ -1358,6 +1416,7 @@ input, textarea { font: inherit; color: inherit; } /* Print export */ .interop-panel, .osc-bridge-panel, +.dmx-output-panel, .show-registry-panel, .plot-starter-panel, .handoff-panel, @@ -1373,6 +1432,7 @@ input, textarea { font: inherit; color: inherit; } } .interop-panel__summary, .osc-bridge-panel__summary, +.dmx-output-panel__summary, .show-registry-panel__summary, .plot-starter-panel__summary, .handoff-panel__summary, @@ -1507,6 +1567,196 @@ input, textarea { font: inherit; color: inherit; } text-overflow: ellipsis; white-space: nowrap; } +.dmx-output-badge { + padding: 4px 8px; + border: 1px solid color-mix(in srgb, var(--amber) 45%, var(--line)); + border-radius: 999px; + background: color-mix(in srgb, var(--amber) 12%, var(--bg-2)); + color: var(--amber); + font-size: 10px; +} +.dmx-output-badge--armed, +.dmx-output-badge--sending { + border-color: color-mix(in srgb, var(--green) 52%, var(--line)); + background: color-mix(in srgb, var(--green) 12%, var(--bg-2)); + color: var(--green); +} +.dmx-output-badge--fault { + border-color: color-mix(in srgb, var(--red) 56%, var(--line)); + background: color-mix(in srgb, var(--red) 12%, var(--bg-2)); + color: var(--red); +} +.dmx-output-warning { + padding: 7px 9px; + border: 1px solid color-mix(in srgb, var(--amber) 48%, var(--line)); + border-radius: 6px; + background: color-mix(in srgb, var(--amber) 10%, var(--bg-2)); + color: var(--amber); + font-size: 11px; +} +.dmx-output-controls { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)) auto; + gap: 8px; + align-items: end; +} +.dmx-output-controls__fixture { + grid-column: span 2; +} +.dmx-output-controls label { + display: flex; + min-width: 0; + flex-direction: column; + gap: 4px; + color: var(--ink-mute); + font-size: 12px; + letter-spacing: .08em; + text-transform: uppercase; +} +.dmx-output-controls label > span { + font-size: 11px; +} +.dmx-output-controls input, +.dmx-output-controls select, +.dmx-output-inspector select { + min-width: 0; + padding: 6px 8px; + border: 1px solid var(--line); + border-radius: 4px; + background: var(--bg-2); + color: var(--ink); + font-family: var(--mono); + font-size: 12px; + letter-spacing: 0; + text-transform: none; +} +.dmx-output-controls input:disabled { + opacity: .55; +} +.dmx-output-relay { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--bg-2); +} +.dmx-output-relay__fields { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} +.dmx-output-relay__fields label { + display: flex; + min-width: 0; + flex-direction: column; + gap: 4px; + color: var(--ink-mute); + font-size: 12px; + letter-spacing: .08em; + text-transform: uppercase; +} +.dmx-output-relay__fields label:nth-child(1), +.dmx-output-relay__fields label:nth-child(2), +.dmx-output-relay__fields label:nth-child(3), +.dmx-output-relay__fields label:nth-child(4) { + grid-column: span 2; +} +.dmx-output-relay__fields label > span { + font-size: 11px; +} +.dmx-output-relay__fields input, +.dmx-output-relay__fields select { + min-width: 0; + padding: 6px 8px; + border: 1px solid var(--line); + border-radius: 4px; + background: var(--surface); + color: var(--ink); + font-family: var(--mono); + font-size: 12px; + letter-spacing: 0; + text-transform: none; +} +.dmx-output-relay__fields input:disabled, +.dmx-output-relay__fields select:disabled { + opacity: .58; +} +.dmx-output-relay__actions { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; +} +.dmx-output-blackout { + border-color: color-mix(in srgb, var(--red) 45%, var(--line)); + color: var(--red); +} +.dmx-output-message-list { + display: flex; + flex-direction: column; + gap: 5px; +} +.dmx-output-inspector { + display: flex; + flex-direction: column; + gap: 8px; +} +.dmx-output-inspector__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.dmx-output-inspector__header strong { + font-size: 13px; +} +.dmx-output-range-list, +.dmx-output-slot-list { + display: flex; + flex-direction: column; + gap: 6px; +} +.dmx-output-range-row { + display: grid; + grid-template-columns: 86px minmax(0, 1fr) 78px; + gap: 8px; + align-items: center; + min-height: 34px; + padding: 6px 8px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--bg-2); +} +.dmx-output-range-row strong, +.dmx-output-range-row em { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.dmx-output-range-row strong { + font-size: 12px; + font-weight: 600; +} +.dmx-output-range-row em { + color: var(--ink-mute); + font-size: 11px; + font-style: normal; + text-align: right; +} +.dmx-output-slot-list { + max-height: 108px; + overflow: auto; + padding: 8px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--bg-2); + color: var(--ink-mute); +} +.dmx-output-slot-list span { + display: block; +} .registry-actions, .registry-row__actions { display: flex; @@ -2211,6 +2461,28 @@ input, textarea { font: inherit; color: inherit; } font-size: 12px; font-weight: 700; } + .dmx-output-controls { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .dmx-output-relay__fields, + .dmx-output-relay__actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .dmx-output-relay__fields label:nth-child(1), + .dmx-output-relay__fields label:nth-child(2), + .dmx-output-relay__fields label:nth-child(3) { + grid-column: 1 / -1; + } + .dmx-output-controls__fixture { + grid-column: 1 / -1; + } + .dmx-output-range-row { + grid-template-columns: 72px minmax(0, 1fr); + } + .dmx-output-range-row em { + grid-column: 1 / -1; + text-align: left; + } .mobile-dock button:hover, .mobile-dock button:focus-visible { background: var(--bg-2); diff --git a/src/PlotForge.jsx b/src/PlotForge.jsx index 00fea68..f9986d9 100644 --- a/src/PlotForge.jsx +++ b/src/PlotForge.jsx @@ -11,6 +11,7 @@ import GelPalette from "./components/GelPalette.jsx"; import CircuitPanel from "./components/CircuitPanel.jsx"; import CommentPins from "./components/CommentPins.jsx"; import InteropPanel from "./components/InteropPanel.jsx"; +import DmxOutputPanel from "./components/DmxOutputPanel.jsx"; import OscBridgePanel from "./components/OscBridgePanel.jsx"; import ShowRegistryPanel from "./components/ShowRegistryPanel.jsx"; import PlotStarterPanel from "./components/PlotStarterPanel.jsx"; @@ -57,6 +58,7 @@ const TOOL_DEFINITIONS = [ { id: "fixtures", label: "Fixtures", eyebrow: "Library", description: "Fixture profiles, source lanes, and add flow." }, { id: "setup", label: "Setup", eyebrow: "Show", description: "Title block, revisions, venue, and positions." }, { id: "patch", label: "Patch", eyebrow: "Paperwork", description: "Patch table, gels, and CSV exports." }, + { id: "output", label: "Output", eyebrow: "DMX", description: "DMX preview, compiler warnings, and universe slots." }, { id: "notes", label: "Notes", eyebrow: "Plot notes", description: "Comment pins and handoff notes." }, { id: "checks", label: "Checks", eyebrow: "Review", description: "Conflicts, circuit health, and issue review." }, { id: "export", label: "Export", eyebrow: "Output", description: "Print, interop manifest, and OSC outputs." }, @@ -413,6 +415,14 @@ export default function PlotForge() { onDelete={show.onCommentPinDelete} /> ); + case "output": + return ( + + ); case "checks": return ( <> diff --git a/src/__tests__/artnet.test.js b/src/__tests__/artnet.test.js new file mode 100644 index 0000000..be0c8cc --- /dev/null +++ b/src/__tests__/artnet.test.js @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { + ARTNET_DMX_HEADER_LENGTH, + ARTNET_PROTOCOL_VERSION, + artDmxPacketSummary, + encodeArtDmx, + encodePortAddress, +} from "../domain/artnet.js"; + +describe("Art-Net encoder", () => { + it("encodes ArtDmx packet bytes with explicit net sub-net universe fields", () => { + const packet = encodeArtDmx({ + portAddress: { net: 2, subNet: 3, universe: 4 }, + data: Uint8Array.from([1, 2, 3, 4]), + sequence: 5, + physical: 6, + }); + const summary = artDmxPacketSummary(packet); + + expect(Array.from(packet.slice(0, 8))).toEqual([65, 114, 116, 45, 78, 101, 116, 0]); + expect(summary).toEqual(expect.objectContaining({ + id: "Art-Net\0", + opcode: 0x5000, + protocolVersion: ARTNET_PROTOCOL_VERSION, + sequence: 5, + physical: 6, + subUni: 0x34, + net: 2, + length: 4, + })); + expect(Array.from(summary.payload)).toEqual([1, 2, 3, 4]); + expect(packet).toHaveLength(ARTNET_DMX_HEADER_LENGTH + 4); + }); + + it("pads odd direct encoder payloads to an even ArtDmx length", () => { + const packet = encodeArtDmx({ + portAddress: { net: 0, subNet: 0, universe: 1 }, + data: Uint8Array.from([10, 20, 30]), + }); + const summary = artDmxPacketSummary(packet); + + expect(summary.length).toBe(4); + expect(Array.from(summary.payload)).toEqual([10, 20, 30, 0]); + }); + + it("rejects out-of-range Art-Net port-address fields", () => { + expect(() => encodePortAddress({ net: 128, subNet: 0, universe: 0 })).toThrow(/net/i); + expect(() => encodePortAddress({ net: 0, subNet: 16, universe: 0 })).toThrow(/sub-net/i); + expect(() => encodePortAddress({ net: 0, subNet: 0, universe: 16 })).toThrow(/universe/i); + }); +}); diff --git a/src/__tests__/dmxOutput.test.js b/src/__tests__/dmxOutput.test.js new file mode 100644 index 0000000..a3f5b69 --- /dev/null +++ b/src/__tests__/dmxOutput.test.js @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vitest"; +import goldenDoc from "./fixtures/dmx-output/golden-doc.json"; +import blackoutVector from "./fixtures/dmx-output/blackout-buffer.json"; +import selectedLedVector from "./fixtures/dmx-output/selected-led-buffer.json"; +import { + compileDmxOutput, + DMX_OUTPUT_INTENTS, + DMX_SLOT_COUNT, + summarizeDmxOutput, +} from "../domain/dmxOutput.js"; + +function expandSparseSlots(expected) { + const slots = Array(expected.slotCount).fill(0); + for (const item of expected.nonZeroSlots) { + slots[item.address - 1] = item.value; + } + return slots; +} + +function compileSelectedLed(doc = goldenDoc) { + return compileDmxOutput(doc, { + intent: selectedLedVector.intent.type, + selectedFixtureId: selectedLedVector.intent.selectedFixtureId, + values: selectedLedVector.intent.values, + }); +} + +describe("DMX output compiler", () => { + it("compiles a selected fixture test into a deterministic 512 slot buffer", () => { + const result = compileSelectedLed(); + const repeat = compileSelectedLed(); + const universe = result.universes.find(item => item.universe === selectedLedVector.expected.universe); + + expect(result.blocked).toBe(false); + expect(universe.slots).toHaveLength(DMX_SLOT_COUNT); + expect(universe.slots).toEqual(expandSparseSlots(selectedLedVector.expected)); + expect(repeat.universes[0].slots).toEqual(universe.slots); + expect(universe.nonZeroSlots.map(({ address, value, type }) => ({ address, value, type }))) + .toEqual(selectedLedVector.expected.nonZeroSlots); + for (const address of selectedLedVector.expected.zeroAddresses) { + expect(universe.slots[address - 1]).toBe(0); + } + }); + + it("compiles blackout as all zero for every active universe", () => { + const result = compileDmxOutput(goldenDoc, { intent: blackoutVector.intent.type }); + + expect(result.blocked).toBe(false); + expect(result.universes).toHaveLength(1); + expect(result.universes[0].slots).toHaveLength(blackoutVector.expected.slotCount); + expect(result.universes[0].slots.every(value => value === 0)).toBe(true); + expect(result.universes[0].nonZeroSlots).toEqual(blackoutVector.expected.nonZeroSlots); + }); + + it("blocks output when a DMX range is invalid", () => { + const doc = structuredClone(goldenDoc); + doc.fixtures.fx_mover_one.dmx.address = 500; + const result = compileSelectedLed(doc); + + expect(result.blocked).toBe(true); + expect(result.errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: "invalid-range", fixtureId: "fx_mover_one" }), + ])); + expect(result.universes[0].slots.every(value => value === 0)).toBe(true); + }); + + it("blocks output when fixture ranges overlap", () => { + const doc = structuredClone(goldenDoc); + doc.fixtures.fx_s4_one.dmx.address = 12; + const result = compileSelectedLed(doc); + + expect(result.blocked).toBe(true); + expect(result.errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: "overlap", fixtureIds: ["fx_led_one", "fx_s4_one"] }), + ])); + expect(result.universes[0].slots.every(value => value === 0)).toBe(true); + }); + + it("warns for unknown personalities and emits no mapped values for them", () => { + const doc = structuredClone(goldenDoc); + doc.fixtures.fx_mover_one.profileId = "unknown_spot_profile"; + const result = compileDmxOutput(doc, { + intent: DMX_OUTPUT_INTENTS.selectedFixtureTest, + selectedFixtureId: "fx_mover_one", + values: { intensity: 255 }, + }); + + expect(result.blocked).toBe(false); + expect(result.warnings).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: "unmapped-personality", fixtureId: "fx_mover_one" }), + ])); + expect(result.universes[0].slots.every(value => value === 0)).toBe(true); + }); + + it("maps simple moving-light intensity while keeping unsafe channels at zero", () => { + const result = compileDmxOutput(goldenDoc, { + intent: DMX_OUTPUT_INTENTS.selectedFixtureTest, + selectedFixtureId: "fx_mover_one", + values: { + intensity: 255, + panCoarse: 255, + panFine: 255, + tiltCoarse: 255, + tiltFine: 255, + reset: 255, + }, + }); + const universe = result.universes[0]; + + expect(result.blocked).toBe(false); + expect(result.warnings).toEqual([]); + expect(universe.slots[39]).toBe(255); + for (const address of [41, 42, 43, 44, 45, 46, 47, 48, 49]) { + expect(universe.slots[address - 1]).toBe(0); + } + expect(universe.nonZeroSlots).toEqual([ + expect.objectContaining({ + address: 40, + type: "intensity", + explanation: "Generic simple moving spot 24ch slot 1 writes intensity (Dimmer)", + }), + ]); + }); + + it("returns a universe inspector report with fixture ranges and summary counts", () => { + const result = compileSelectedLed(); + const summary = summarizeDmxOutput(result); + const universe = result.universes[0]; + + expect(summary).toEqual({ + blocked: false, + universeCount: 1, + patchedFixtureCount: 3, + errorCount: 0, + warningCount: 0, + nonZeroSlotCount: 5, + }); + expect(universe.usedRanges.map(range => ({ + fixtureId: range.fixtureId, + startAddress: range.startAddress, + endAddress: range.endAddress, + outputStatus: range.outputStatus, + }))).toEqual([ + { fixtureId: "fx_s4_one", startAddress: 1, endAddress: 1, outputStatus: "ready" }, + { fixtureId: "fx_led_one", startAddress: 10, endAddress: 17, outputStatus: "selected" }, + { fixtureId: "fx_mover_one", startAddress: 40, endAddress: 63, outputStatus: "ready" }, + ]); + }); +}); diff --git a/src/__tests__/dmxOutputPanel.test.jsx b/src/__tests__/dmxOutputPanel.test.jsx new file mode 100644 index 0000000..ef3b840 --- /dev/null +++ b/src/__tests__/dmxOutputPanel.test.jsx @@ -0,0 +1,353 @@ +import React from "react"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import DmxOutputPanel from "../components/DmxOutputPanel.jsx"; +import { DMX_OUTPUT_INTENTS } from "../domain/dmxOutput.js"; +import goldenDoc from "./fixtures/dmx-output/golden-doc.json"; + +class FakeWebSocket { + static CONNECTING = 0; + static OPEN = 1; + static CLOSED = 3; + static instances = []; + + constructor(url) { + this.url = url; + this.readyState = FakeWebSocket.CONNECTING; + this.listeners = new Map(); + this.sent = []; + FakeWebSocket.instances.push(this); + window.setTimeout(() => { + this.readyState = FakeWebSocket.OPEN; + this.dispatch("open", {}); + }, 0); + } + + addEventListener(type, handler, options = {}) { + const list = this.listeners.get(type) || []; + list.push({ handler, once: Boolean(options.once) }); + this.listeners.set(type, list); + } + + dispatch(type, event) { + const list = this.listeners.get(type) || []; + this.listeners.set(type, list.filter(item => !item.once)); + for (const item of list) item.handler(event); + } + + send(payload) { + this.sent.push(JSON.parse(payload)); + } + + close() { + this.readyState = FakeWebSocket.CLOSED; + this.dispatch("close", {}); + } + + serverMessage(payload) { + this.dispatch("message", { data: JSON.stringify(payload) }); + } +} + +beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + FakeWebSocket.instances = []; + window.WebSocket = FakeWebSocket; +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +async function armPanel() { + fireEvent.change(screen.getByLabelText("Relay token"), { target: { value: "test-token" } }); + fireEvent.click(screen.getByRole("button", { name: "Arm output" })); + await waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1)); + const socket = FakeWebSocket.instances[0]; + await waitFor(() => expect(socket.sent).toHaveLength(1)); + expect(socket.sent[0]).toEqual({ type: "auth", protocolVersion: 1, token: "test-token" }); + await act(async () => { + socket.serverMessage({ type: "auth.ok", relayVersion: "0.1.0", maxFps: 20, boundHost: "127.0.0.1" }); + }); + await screen.findByText("Output armed."); + return socket; +} + +describe("DmxOutputPanel", () => { + it("loads persisted relay route preferences without restoring a token", () => { + const doc = { + ...goldenDoc, + dmxOutput: { + relayUrl: "ws://127.0.0.1:8770", + token: "not-persisted", + targetHost: "192.168.1.40", + targetPort: 6455, + universes: { + "1": { net: 2, subNet: 3, universe: 4, targetHost: "192.168.1.41" }, + }, + }, + }; + + render(React.createElement(DmxOutputPanel, { + doc, + selectedFixtureId: "fx_led_one", + })); + + expect(screen.getByLabelText("Protocol")).toHaveValue("artnet"); + expect(screen.getByLabelText("Relay URL")).toHaveValue("ws://127.0.0.1:8770"); + expect(screen.getByLabelText("Relay token")).toHaveValue(""); + expect(screen.getByLabelText("Target host")).toHaveValue("192.168.1.41"); + expect(screen.getByLabelText("Target port")).toHaveValue("6455"); + expect(screen.getByLabelText("Net")).toHaveValue("2"); + expect(screen.getByLabelText("Sub-Net")).toHaveValue("3"); + expect(screen.getByLabelText("Universe")).toHaveValue("4"); + }); + + it("loads persisted sACN route preferences with protocol-specific universe mapping", () => { + const doc = { + ...goldenDoc, + dmxOutput: { + protocol: "sacn", + relayUrl: "ws://127.0.0.1:8770", + targetHost: "192.168.1.40", + universes: { + "1": { universe: 25, targetHost: "192.168.1.42" }, + }, + }, + }; + + render(React.createElement(DmxOutputPanel, { + doc, + selectedFixtureId: "fx_led_one", + })); + + expect(screen.getByLabelText("Protocol")).toHaveValue("sacn"); + expect(screen.getByLabelText("Target host")).toHaveValue("192.168.1.42"); + expect(screen.getByLabelText("Target port")).toHaveValue("5568"); + expect(screen.getByLabelText("Net")).toBeDisabled(); + expect(screen.getByLabelText("Sub-Net")).toBeDisabled(); + expect(screen.getByLabelText("sACN universe")).toHaveValue("25"); + }); + + it("persists safe relay route preferences without persisting the relay token", () => { + const onSettingsChange = vi.fn(); + + render(React.createElement(DmxOutputPanel, { + doc: goldenDoc, + selectedFixtureId: "fx_led_one", + onSettingsChange, + })); + + fireEvent.change(screen.getByLabelText("Target host"), { target: { value: "192.168.1.44" } }); + + expect(onSettingsChange).toHaveBeenLastCalledWith(expect.objectContaining({ + protocol: "artnet", + relayUrl: "ws://127.0.0.1:8766", + targetHost: "192.168.1.44", + targetPort: 6454, + universes: { + "1": { + net: 0, + subNet: 0, + universe: 0, + targetHost: "192.168.1.44", + }, + }, + })); + + fireEvent.change(screen.getByLabelText("Relay URL"), { target: { value: "ws://127.0.0.1:8770" } }); + expect(onSettingsChange).toHaveBeenLastCalledWith(expect.objectContaining({ + relayUrl: "ws://127.0.0.1:8770", + })); + + fireEvent.change(screen.getByLabelText("Universe"), { target: { value: "4" } }); + expect(onSettingsChange).toHaveBeenLastCalledWith(expect.objectContaining({ + universes: { + "1": expect.objectContaining({ universe: 4 }), + }, + })); + + const persistedCallCount = onSettingsChange.mock.calls.length; + fireEvent.change(screen.getByLabelText("Relay token"), { target: { value: "test-token" } }); + expect(onSettingsChange).toHaveBeenCalledTimes(persistedCallCount); + expect(onSettingsChange.mock.calls.flat()).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ token: "test-token" }), + ])); + }); + + it("renders preview status, compiler warnings, and universe inspector ranges", () => { + render(React.createElement(DmxOutputPanel, { + doc: goldenDoc, + selectedFixtureId: "fx_led_one", + })); + + expect(screen.getByRole("heading", { name: "DMX output" })).toBeInTheDocument(); + expect(screen.getByText("OUTPUT TEST MODE - Not for show use")).toBeInTheDocument(); + expect(screen.getByText("idle")).toBeInTheDocument(); + expect(screen.getByText("mapped")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Send test frame" })).toBeDisabled(); + + const ranges = screen.getByLabelText("DMX fixture ranges"); + expect(within(ranges).getByText("U1 10-17")).toBeInTheDocument(); + expect(within(ranges).getByText("1ST ELEC U2")).toBeInTheDocument(); + expect(within(ranges).getByText("selected")).toBeInTheDocument(); + + const slots = screen.getByLabelText("DMX non zero slots"); + expect(within(slots).getByText(/10: 255 intensity - Generic RGBW 8ch with dimmer slot 1 writes intensity/)).toBeInTheDocument(); + expect(within(slots).getByText(/13: 255 blue - Generic RGBW 8ch with dimmer slot 4 writes blue/)).toBeInTheDocument(); + }); + + it("switches to blackout preview without nonzero slots", () => { + render(React.createElement(DmxOutputPanel, { + doc: goldenDoc, + selectedFixtureId: "fx_led_one", + })); + + fireEvent.change(screen.getByLabelText("Mode"), { + target: { value: DMX_OUTPUT_INTENTS.blackout }, + }); + + expect(screen.getByText("All slots zero.")).toBeInTheDocument(); + expect(screen.getByLabelText("Dimmer")).toBeDisabled(); + }); + + it("requires arming before sending and emits a selected fixture frame after auth", async () => { + render(React.createElement(DmxOutputPanel, { + doc: goldenDoc, + selectedFixtureId: "fx_led_one", + })); + + expect(screen.getByRole("button", { name: "Arm output" })).toBeDisabled(); + const socket = await armPanel(); + expect(screen.getByRole("button", { name: "Send test frame" })).toBeEnabled(); + + fireEvent.click(screen.getByRole("button", { name: "Send test frame" })); + await waitFor(() => expect(socket.sent).toHaveLength(2)); + const frame = socket.sent[1]; + const data = Buffer.from(frame.data, "base64"); + + expect(frame).toEqual(expect.objectContaining({ + type: "frame", + token: "test-token", + protocol: "artnet", + target: { host: "127.0.0.1", port: 6454 }, + portAddress: { net: 0, subNet: 0, universe: 0 }, + length: 512, + })); + expect(data[9]).toBe(255); + expect(data[10]).toBe(255); + expect(data[12]).toBe(255); + }); + + it("can switch to sACN and emit the selected fixture frame through the relay", async () => { + render(React.createElement(DmxOutputPanel, { + doc: goldenDoc, + selectedFixtureId: "fx_led_one", + })); + + fireEvent.change(screen.getByLabelText("Protocol"), { target: { value: "sacn" } }); + + expect(screen.getByLabelText("Protocol")).toHaveValue("sacn"); + expect(screen.getByLabelText("Target port")).toHaveValue("5568"); + expect(screen.getByLabelText("Net")).toBeDisabled(); + expect(screen.getByLabelText("Sub-Net")).toBeDisabled(); + expect(screen.getByLabelText("sACN universe")).toHaveValue("1"); + + const socket = await armPanel(); + fireEvent.click(screen.getByRole("button", { name: "Send test frame" })); + await waitFor(() => expect(socket.sent).toHaveLength(2)); + const frame = socket.sent[1]; + const data = Buffer.from(frame.data, "base64"); + + expect(frame).toEqual(expect.objectContaining({ + type: "frame", + token: "test-token", + protocol: "sacn", + targetMode: "unicast", + target: { host: "127.0.0.1", port: 5568 }, + universe: 1, + sourceName: "PlotForge", + priority: 100, + length: 512, + })); + expect(frame.portAddress).toBeUndefined(); + expect(data[9]).toBe(255); + expect(data[10]).toBe(255); + expect(data[12]).toBe(255); + }); + + it("can switch sACN output to multicast with a derived target host", async () => { + render(React.createElement(DmxOutputPanel, { + doc: goldenDoc, + selectedFixtureId: "fx_led_one", + })); + + fireEvent.change(screen.getByLabelText("Protocol"), { target: { value: "sacn" } }); + fireEvent.change(screen.getByLabelText("Target mode"), { target: { value: "multicast" } }); + + expect(screen.getByLabelText("Target mode")).toHaveValue("multicast"); + expect(screen.getByLabelText("Target host")).toHaveValue("239.255.0.1"); + expect(screen.getByLabelText("Target host")).toBeDisabled(); + + const socket = await armPanel(); + fireEvent.click(screen.getByRole("button", { name: "Send test frame" })); + await waitFor(() => expect(socket.sent).toHaveLength(2)); + const frame = socket.sent[1]; + + expect(frame).toEqual(expect.objectContaining({ + type: "frame", + token: "test-token", + protocol: "sacn", + targetMode: "multicast", + target: { host: "239.255.0.1", port: 5568 }, + universe: 1, + sourceName: "PlotForge", + priority: 100, + length: 512, + })); + expect(frame.portAddress).toBeUndefined(); + }); + + it("sends blackout only after output is armed", async () => { + render(React.createElement(DmxOutputPanel, { + doc: goldenDoc, + selectedFixtureId: "fx_led_one", + })); + + expect(screen.getByRole("button", { name: "Blackout" })).toBeDisabled(); + const socket = await armPanel(); + + fireEvent.click(screen.getByRole("button", { name: "Blackout" })); + await waitFor(() => expect(socket.sent).toHaveLength(2)); + expect(socket.sent[1]).toEqual({ token: "test-token", type: "blackout" }); + }); + + it("surfaces relay fault messages", async () => { + render(React.createElement(DmxOutputPanel, { + doc: goldenDoc, + selectedFixtureId: "fx_led_one", + })); + const socket = await armPanel(); + + await act(async () => { + socket.serverMessage({ type: "fault", reason: "heartbeat-timeout" }); + }); + + expect(await screen.findByText("heartbeat-timeout")).toBeInTheDocument(); + expect(screen.getByText("fault")).toBeInTheDocument(); + }); + + it("blocks arming when compiler errors exist", () => { + const doc = structuredClone(goldenDoc); + doc.fixtures.fx_s4_one.dmx.address = 12; + + render(React.createElement(DmxOutputPanel, { + doc, + selectedFixtureId: "fx_led_one", + })); + fireEvent.change(screen.getByLabelText("Relay token"), { target: { value: "test-token" } }); + + expect(screen.getByText(/overlaps/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Arm output" })).toBeDisabled(); + }); +}); diff --git a/src/__tests__/dmxRelay.test.js b/src/__tests__/dmxRelay.test.js new file mode 100644 index 0000000..b0a58d6 --- /dev/null +++ b/src/__tests__/dmxRelay.test.js @@ -0,0 +1,480 @@ +import { randomBytes } from "node:crypto"; +import dgram from "node:dgram"; +import net from "node:net"; +import { afterEach, describe, expect, it } from "vitest"; +import { artDmxPacketSummary } from "../domain/artnet.js"; +import { sacnMulticastAddress, sacnPacketSummary } from "../domain/sacn.js"; +import { + createDmxRelayServer, + validateFrameCommand, +} from "../domain/dmxRelay.js"; + +const ALLOWED_ORIGIN = "http://127.0.0.1:5173"; +const TOKEN = "test-token"; +const SACN_CID = "12345678-1234-5678-9abc-def012345678"; + +const relays = []; + +afterEach(async () => { + while (relays.length) { + const relay = relays.pop(); + await relay.close(); + } +}); + +function clientTextFrame(text) { + const payload = Buffer.from(String(text)); + const mask = randomBytes(4); + let header; + if (payload.length > 125) { + header = Buffer.alloc(4); + header[0] = 0x81; + header[1] = 0x80 | 126; + header.writeUInt16BE(payload.length, 2); + } else { + header = Buffer.from([0x81, 0x80 | payload.length]); + } + const masked = Buffer.alloc(payload.length); + for (let index = 0; index < payload.length; index += 1) { + masked[index] = payload[index] ^ mask[index % 4]; + } + return Buffer.concat([header, mask, masked]); +} + +function decodeServerFrames(buffer) { + const messages = []; + let offset = 0; + while (offset + 2 <= buffer.length) { + const frameStart = offset; + const opcode = buffer[offset] & 0x0f; + let length = buffer[offset + 1] & 0x7f; + const masked = (buffer[offset + 1] & 0x80) !== 0; + offset += 2; + if (length === 126) { + if (offset + 2 > buffer.length) return { messages, remaining: buffer.subarray(frameStart) }; + length = buffer.readUInt16BE(offset); + offset += 2; + } + if (masked || offset + length > buffer.length) return { messages, remaining: buffer.subarray(frameStart) }; + const payload = buffer.subarray(offset, offset + length); + offset += length; + if (opcode === 1) messages.push(JSON.parse(payload.toString("utf8"))); + } + return { messages, remaining: buffer.subarray(offset) }; +} + +function createWsHarness(socket, initial = Buffer.alloc(0)) { + let buffer = initial; + const queue = []; + const waiters = []; + + function drain() { + const decoded = decodeServerFrames(buffer); + buffer = decoded.remaining; + queue.push(...decoded.messages); + while (queue.length && waiters.length) { + waiters.shift()(queue.shift()); + } + } + + socket.on("data", chunk => { + buffer = Buffer.concat([buffer, chunk]); + drain(); + }); + drain(); + + return { + send(payload) { + socket.write(clientTextFrame(JSON.stringify(payload))); + }, + next(timeoutMs = 750) { + if (queue.length) return Promise.resolve(queue.shift()); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("Timed out waiting for WebSocket message.")), timeoutMs); + waiters.push(message => { + clearTimeout(timer); + resolve(message); + }); + }); + }, + close() { + socket.destroy(); + }, + }; +} + +async function openWebSocket(port, origin = ALLOWED_ORIGIN) { + const socket = net.createConnection({ host: "127.0.0.1", port }); + await new Promise(resolve => socket.once("connect", resolve)); + const key = randomBytes(16).toString("base64"); + socket.write([ + "GET / HTTP/1.1", + `Host: 127.0.0.1:${port}`, + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Key: ${key}`, + "Sec-WebSocket-Version: 13", + `Origin: ${origin}`, + "", + "", + ].join("\r\n")); + + const { header, rest } = await new Promise((resolve, reject) => { + let buffer = Buffer.alloc(0); + const timer = setTimeout(() => reject(new Error("Timed out waiting for upgrade response.")), 750); + socket.on("data", function onData(chunk) { + buffer = Buffer.concat([buffer, chunk]); + const marker = buffer.indexOf("\r\n\r\n"); + if (marker === -1) return; + socket.off("data", onData); + clearTimeout(timer); + resolve({ + header: buffer.subarray(0, marker + 4).toString("utf8"), + rest: buffer.subarray(marker + 4), + }); + }); + }); + + return { socket, header, client: createWsHarness(socket, rest) }; +} + +async function startRelay(options = {}) { + const relay = createDmxRelayServer({ + bindHost: "127.0.0.1", + wsPort: 0, + token: TOKEN, + requireToken: true, + allowedOrigins: [ALLOWED_ORIGIN], + heartbeatTimeoutMs: 1000, + blackoutBurst: 1, + ...options, + }); + await new Promise((resolve, reject) => { + relay.server.once("error", reject); + relay.server.listen(0, "127.0.0.1", () => { + relay.server.off("error", reject); + resolve(); + }); + }); + relays.push(relay); + return { + ...relay, + port: relay.server.address().port, + }; +} + +async function bindUdpListener() { + const udp = dgram.createSocket("udp4"); + await new Promise(resolve => udp.bind(0, "127.0.0.1", resolve)); + return { + udp, + port: udp.address().port, + next(timeoutMs = 750) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("Timed out waiting for UDP packet.")), timeoutMs); + udp.once("message", message => { + clearTimeout(timer); + resolve(message); + }); + }); + }, + close() { + udp.close(); + }, + }; +} + +async function authenticate(client) { + client.send({ type: "auth", protocolVersion: 1, token: TOKEN }); + const auth = await client.next(); + expect(auth).toEqual(expect.objectContaining({ type: "auth.ok" })); +} + +function frameCommand(overrides = {}) { + const data = overrides.dataBuffer || Buffer.from([1, 2, 3, 4]); + return { + type: "frame", + token: TOKEN, + protocol: "artnet", + target: { host: "127.0.0.1", port: 6454 }, + portAddress: { net: 0, subNet: 0, universe: 0 }, + length: data.length, + data: data.toString("base64"), + sequence: 7, + ...overrides, + }; +} + +function waitForCondition(check, timeoutMs = 750) { + const startedAt = Date.now(); + return new Promise((resolve, reject) => { + function tick() { + if (check()) { + resolve(); + return; + } + if (Date.now() - startedAt > timeoutMs) { + reject(new Error("Timed out waiting for condition.")); + return; + } + setTimeout(tick, 10); + } + tick(); + }); +} + +describe("DMX relay", () => { + it("serves relay health", async () => { + const relay = await startRelay(); + const response = await fetch(`http://127.0.0.1:${relay.port}/health`); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual(expect.objectContaining({ + ok: true, + service: "plotforge-dmx-relay", + relayVersion: "0.1.0", + requireToken: true, + })); + }); + + it("sends a selected fixture Art-Net frame to a UDP listener", async () => { + const udp = await bindUdpListener(); + try { + const relay = await startRelay(); + const { client, header } = await openWebSocket(relay.port); + expect(header).toContain("101 Switching Protocols"); + await authenticate(client); + + client.send(frameCommand({ target: { host: "127.0.0.1", port: udp.port } })); + const [reply, packet] = await Promise.all([client.next(), udp.next()]); + const summary = artDmxPacketSummary(packet); + + expect(reply).toEqual(expect.objectContaining({ + type: "frame.ok", + portAddress: { net: 0, subNet: 0, universe: 0 }, + bytesSent: 22, + })); + expect(summary).toEqual(expect.objectContaining({ + id: "Art-Net\0", + opcode: 0x5000, + sequence: 7, + subUni: 0, + net: 0, + length: 4, + })); + expect(Array.from(summary.payload)).toEqual([1, 2, 3, 4]); + client.close(); + } finally { + udp.close(); + } + }); + + it("refuses malformed frame commands without sending UDP", async () => { + let sendCount = 0; + const relay = await startRelay({ + sendUdp: async () => { + sendCount += 1; + return 0; + }, + }); + const { client } = await openWebSocket(relay.port); + await authenticate(client); + + const cases = [ + { command: frameCommand({ length: 3, data: Buffer.from([1, 2, 3]).toString("base64") }), reason: "odd-length" }, + { command: frameCommand({ length: 514, data: Buffer.alloc(514).toString("base64") }), reason: "oversized-payload" }, + { command: frameCommand({ portAddress: { net: 0, subNet: 0, universe: 16 } }), reason: "invalid-universe" }, + { command: frameCommand({ protocol: "sacn", universe: 0, cid: SACN_CID, dataBuffer: Buffer.from([1, 2]) }), reason: "invalid-universe" }, + { command: frameCommand({ protocol: "sacn", target: { host: "203.0.113.10", port: 5568 }, universe: 1, cid: SACN_CID, dataBuffer: Buffer.from([1, 2]) }), reason: "public-target" }, + { command: frameCommand({ protocol: "sacn", targetMode: "multicast", target: { host: "239.255.0.2", port: 5568 }, universe: 1, cid: SACN_CID, dataBuffer: Buffer.from([1, 2]) }), reason: "invalid-target" }, + { command: frameCommand({ target: {} }), reason: "invalid-target" }, + ]; + + for (const item of cases) { + client.send(item.command); + await expect(client.next()).resolves.toEqual(expect.objectContaining({ + type: "frame.error", + reason: item.reason, + })); + } + + expect(sendCount).toBe(0); + client.close(); + }); + + it("sends a selected fixture sACN frame to a UDP listener and blackouts that output", async () => { + const udp = await bindUdpListener(); + try { + const relay = await startRelay({ sacnCid: SACN_CID, sacnSourceName: "PlotForge Relay Test" }); + const { client } = await openWebSocket(relay.port); + await authenticate(client); + + const packetPromise = udp.next(); + client.send(frameCommand({ + protocol: "sacn", + target: { host: "127.0.0.1", port: udp.port }, + universe: 4, + sourceName: "PlotForge sACN Test", + priority: 110, + sequence: 12, + })); + const [reply, packet] = await Promise.all([client.next(), packetPromise]); + const summary = sacnPacketSummary(packet); + + expect(reply).toEqual(expect.objectContaining({ + type: "frame.ok", + protocol: "sacn", + universe: 4, + bytesSent: 130, + })); + expect(summary).toEqual(expect.objectContaining({ + sourceName: "PlotForge sACN Test", + priority: 110, + sequence: 12, + universe: 4, + propertyValueCount: 5, + })); + expect(Array.from(summary.payload)).toEqual([1, 2, 3, 4]); + + const blackoutPacket = udp.next(); + client.send({ type: "blackout", token: TOKEN }); + await expect(client.next()).resolves.toEqual(expect.objectContaining({ type: "blackout.ok", framesSent: 1 })); + const blackoutSummary = sacnPacketSummary(await blackoutPacket); + expect(blackoutSummary.universe).toBe(4); + expect(Array.from(blackoutSummary.payload)).toEqual([0, 0, 0, 0]); + client.close(); + } finally { + udp.close(); + } + }); + + it("derives sACN multicast targets from the universe and tracks blackout", async () => { + const sends = []; + const relay = await startRelay({ + sacnCid: SACN_CID, + blackoutBurst: 1, + sendUdp: async (packet, port, host) => { + sends.push({ packet: Buffer.from(packet), port, host }); + return packet.length; + }, + }); + const { client } = await openWebSocket(relay.port); + await authenticate(client); + + client.send(frameCommand({ + protocol: "sacn", + targetMode: "multicast", + target: { port: 5568 }, + universe: 4, + sourceName: "PlotForge Multicast Test", + priority: 120, + sequence: 13, + })); + await expect(client.next()).resolves.toEqual(expect.objectContaining({ + type: "frame.ok", + protocol: "sacn", + targetMode: "multicast", + target: { host: sacnMulticastAddress(4), port: 5568 }, + universe: 4, + })); + + client.send({ type: "blackout", token: TOKEN }); + await expect(client.next()).resolves.toEqual(expect.objectContaining({ type: "blackout.ok", framesSent: 1 })); + + expect(sends).toHaveLength(2); + expect(sends[0]).toEqual(expect.objectContaining({ host: "239.255.0.4", port: 5568 })); + expect(sacnPacketSummary(sends[0].packet)).toEqual(expect.objectContaining({ + sourceName: "PlotForge Multicast Test", + priority: 120, + sequence: 13, + universe: 4, + })); + expect(sends[1]).toEqual(expect.objectContaining({ host: "239.255.0.4", port: 5568 })); + expect(Array.from(sacnPacketSummary(sends[1].packet).payload)).toEqual([0, 0, 0, 0]); + client.close(); + }); + + it("rejects bad origins, bad tokens, and pre-auth frames without UDP", async () => { + let sendCount = 0; + const relay = await startRelay({ + sendUdp: async () => { + sendCount += 1; + return 0; + }, + }); + + const badOrigin = await openWebSocket(relay.port, "https://example.com"); + expect(badOrigin.header).toContain("403 Forbidden"); + badOrigin.socket.destroy(); + + const badToken = await openWebSocket(relay.port); + badToken.client.send({ type: "auth", protocolVersion: 1, token: "wrong" }); + await expect(badToken.client.next()).resolves.toEqual({ type: "auth.error", reason: "bad-token" }); + badToken.client.close(); + + const preAuth = await openWebSocket(relay.port); + preAuth.client.send(frameCommand()); + await expect(preAuth.client.next()).resolves.toEqual({ type: "auth.error", reason: "auth-required" }); + preAuth.client.close(); + + expect(sendCount).toBe(0); + }); + + it("emits blackout frames and a fault when the heartbeat watchdog expires", async () => { + const packets = []; + const relay = await startRelay({ + heartbeatTimeoutMs: 30, + blackoutBurst: 2, + sendUdp: async packet => { + packets.push(Buffer.from(packet)); + return packet.length; + }, + }); + const { client } = await openWebSocket(relay.port); + await authenticate(client); + + client.send(frameCommand({ dataBuffer: Buffer.from([9, 8]) })); + await expect(client.next()).resolves.toEqual(expect.objectContaining({ type: "frame.ok" })); + await expect(client.next(1000)).resolves.toEqual({ type: "fault", reason: "heartbeat-timeout" }); + + expect(packets).toHaveLength(3); + expect(Array.from(artDmxPacketSummary(packets[0]).payload)).toEqual([9, 8]); + expect(Array.from(artDmxPacketSummary(packets[1]).payload)).toEqual([0, 0]); + expect(Array.from(artDmxPacketSummary(packets[2]).payload)).toEqual([0, 0]); + client.close(); + }); + + it("emits blackout frames when an authenticated active client disconnects", async () => { + const packets = []; + const relay = await startRelay({ + heartbeatTimeoutMs: 1000, + blackoutBurst: 1, + sendUdp: async packet => { + packets.push(Buffer.from(packet)); + return packet.length; + }, + }); + const { client, socket } = await openWebSocket(relay.port); + await authenticate(client); + + client.send(frameCommand({ dataBuffer: Buffer.from([12, 24]) })); + await expect(client.next()).resolves.toEqual(expect.objectContaining({ type: "frame.ok" })); + + socket.end(); + await waitForCondition(() => packets.length === 2, 1500); + + expect(Array.from(artDmxPacketSummary(packets[0]).payload)).toEqual([12, 24]); + expect(Array.from(artDmxPacketSummary(packets[1]).payload)).toEqual([0, 0]); + }); + + it("validates command tokens independently of the WebSocket handshake", () => { + expect(() => validateFrameCommand(frameCommand({ token: "wrong" }), { + token: TOKEN, + requireToken: true, + defaultTargetHost: "127.0.0.1", + defaultTargetPort: 6454, + allowPublicTargets: false, + })).toThrow(/Bad relay token/); + }); +}); diff --git a/src/__tests__/fixtureLibrary.test.jsx b/src/__tests__/fixtureLibrary.test.jsx index 81fc0f5..2d7ed9e 100644 --- a/src/__tests__/fixtureLibrary.test.jsx +++ b/src/__tests__/fixtureLibrary.test.jsx @@ -3,6 +3,7 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import FixtureLibrary from "../components/FixtureLibrary.jsx"; import { seedShow } from "../PlotForge.jsx"; +import { normalizeOpenFixtureLibraryProfile } from "../domain/profiles.js"; describe("FixtureLibrary", () => { it("searches expanded profiles and opens a detail page", () => { @@ -28,4 +29,47 @@ describe("FixtureLibrary", () => { expect(onAddFixture).toHaveBeenCalledWith(selectedPositionId, "blinder_2lite"); }); + + it("shows output-ready, candidate, and paperwork-only profile states", () => { + const doc = seedShow(); + doc.fixtureProfiles = { + ofl_demo_tiny_wash: normalizeOpenFixtureLibraryProfile({ + manufacturer: "Demo", + name: "Tiny Wash", + categories: ["Color Changer"], + modes: [{ name: "RGB Move", channels: ["Dimmer", "Red", "Green", "Blue", "Pan"] }], + }, { + manufacturerKey: "demo", + fixtureKey: "tiny-wash", + importedAt: 123, + }), + }; + + render(React.createElement(FixtureLibrary, { + doc, + selectedPositionId: doc.positionOrder[0], + onAddFixture: vi.fn(), + onImportOpenFixtureLibraryProfile: vi.fn(), + })); + + fireEvent.click(screen.getByRole("button", { name: "Legacy" })); + fireEvent.change(screen.getByLabelText("Search"), { target: { value: "LED PAR" } }); + + expect(screen.getAllByText("Output ready").length).toBeGreaterThan(0); + expect(screen.getByLabelText("Output channel map")).toHaveTextContent("Dimmer"); + expect(screen.getByLabelText("Output channel map")).toHaveTextContent("Strobe"); + + fireEvent.click(screen.getByRole("button", { name: "OFL" })); + fireEvent.change(screen.getByLabelText("Search"), { target: { value: "Tiny Wash" } }); + + expect(screen.getAllByText("Candidate map").length).toBeGreaterThan(0); + expect(screen.getByLabelText("Output channel map")).toHaveTextContent("Pan"); + expect(screen.getByLabelText("Output channel map")).toHaveTextContent("panCoarse locked"); + + fireEvent.click(screen.getByRole("button", { name: "GDTF" })); + fireEvent.change(screen.getByLabelText("Search"), { target: { value: "Robe" } }); + + expect(screen.getAllByText("Paperwork only").length).toBeGreaterThan(0); + expect(screen.getByText(/No approved output map/)).toBeInTheDocument(); + }); }); diff --git a/src/__tests__/fixtureOutputProfiles.test.js b/src/__tests__/fixtureOutputProfiles.test.js new file mode 100644 index 0000000..7473035 --- /dev/null +++ b/src/__tests__/fixtureOutputProfiles.test.js @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_DMX_TEST_VALUES, + FIXTURE_OUTPUT_SCHEMA_VERSION, + getFixtureOutputCandidate, + getFixtureOutputProfile, + inferOutputProfileFromOpenFixtureLibrary, + outputProfileStatus, +} from "../domain/fixtureOutputProfiles.js"; + +describe("fixture output profiles", () => { + it("maps generic dimmers, RGBW fixtures, and simple moving spots", () => { + const dimmer = getFixtureOutputProfile("s4_26"); + const led = getFixtureOutputProfile("led_par_rgbw"); + const mover = getFixtureOutputProfile("spot_mh"); + + expect(dimmer).toEqual(expect.objectContaining({ + schemaVersion: FIXTURE_OUTPUT_SCHEMA_VERSION, + label: "Generic 1ch dimmer", + footprint: 1, + })); + expect(dimmer.channels).toEqual([ + expect.objectContaining({ slot: 1, type: "intensity", unsafe: false }), + ]); + expect(led.channels.map(channel => [channel.slot, channel.type, channel.unsafe])).toEqual([ + [1, "intensity", false], + [2, "red", false], + [3, "green", false], + [4, "blue", false], + [5, "white", false], + [6, "strobe", true], + [7, "raw", true], + [8, "raw", true], + ]); + expect(mover.channels).toEqual(expect.arrayContaining([ + expect.objectContaining({ slot: 1, type: "intensity", unsafe: false }), + expect.objectContaining({ slot: 2, type: "panCoarse", unsafe: true, safeMax: 0 }), + expect.objectContaining({ slot: 10, type: "reset", unsafe: true, safeMax: 0 }), + ])); + }); + + it("keeps the default test values limited to safe channel types", () => { + expect(DEFAULT_DMX_TEST_VALUES).toEqual({ + intensity: 255, + red: 255, + green: 255, + blue: 255, + white: 0, + amber: 0, + uv: 0, + }); + }); + + it("infers unapproved OFL candidate maps from channel names", () => { + const candidate = inferOutputProfileFromOpenFixtureLibrary({ + manufacturer: "Demo", + name: "Tiny Wash", + modes: [{ + name: "RGBA Move", + channels: ["Dimmer", "Red", "Green", "Blue", "Pan", "Reset"], + }], + }, { + profileId: "ofl_demo_tiny_wash", + manufacturerKey: "demo", + fixtureKey: "tiny-wash", + }); + + expect(candidate).toEqual(expect.objectContaining({ + id: "ofl_demo_tiny_wash-candidate-output-map", + profileIds: ["ofl_demo_tiny_wash"], + footprint: 6, + source: expect.objectContaining({ + type: "open-fixture-library-candidate", + approved: false, + }), + })); + expect(candidate.channels).toEqual(expect.arrayContaining([ + expect.objectContaining({ slot: 1, type: "intensity", unsafe: false }), + expect.objectContaining({ slot: 2, type: "red", unsafe: false }), + expect.objectContaining({ slot: 5, type: "panCoarse", unsafe: true, safeMax: 0 }), + expect.objectContaining({ slot: 6, type: "reset", unsafe: true, safeMax: 0 }), + ])); + }); + + it("treats unapproved custom maps as candidates until explicitly approved", () => { + const outputMap = inferOutputProfileFromOpenFixtureLibrary({ + manufacturer: "Demo", + name: "Tiny Wash", + modes: [{ name: "RGB", channels: ["Red", "Green", "Blue"] }], + }, { profileId: "ofl_demo_tiny_wash" }); + const customProfiles = { + ofl_demo_tiny_wash: { outputMap }, + }; + + expect(outputProfileStatus("ofl_demo_tiny_wash", customProfiles).status).toBe("candidate"); + expect(getFixtureOutputCandidate("ofl_demo_tiny_wash", customProfiles)).toEqual(expect.objectContaining({ + label: expect.stringContaining("Tiny Wash"), + })); + expect(getFixtureOutputProfile("ofl_demo_tiny_wash", customProfiles)).toBeNull(); + + customProfiles.ofl_demo_tiny_wash.outputMap = { + ...outputMap, + source: { ...outputMap.source, approved: true }, + }; + + expect(outputProfileStatus("ofl_demo_tiny_wash", customProfiles).status).toBe("output-ready"); + expect(getFixtureOutputProfile("ofl_demo_tiny_wash", customProfiles)).toEqual(expect.objectContaining({ + profileIds: ["ofl_demo_tiny_wash"], + })); + }); +}); diff --git a/src/__tests__/fixtures/dmx-output/blackout-buffer.json b/src/__tests__/fixtures/dmx-output/blackout-buffer.json new file mode 100644 index 0000000..e318a29 --- /dev/null +++ b/src/__tests__/fixtures/dmx-output/blackout-buffer.json @@ -0,0 +1,10 @@ +{ + "name": "blackout buffer", + "intent": { + "type": "blackout" + }, + "expected": { + "slotCount": 512, + "nonZeroSlots": [] + } +} diff --git a/src/__tests__/fixtures/dmx-output/golden-doc.json b/src/__tests__/fixtures/dmx-output/golden-doc.json new file mode 100644 index 0000000..5ae1e64 --- /dev/null +++ b/src/__tests__/fixtures/dmx-output/golden-doc.json @@ -0,0 +1,100 @@ +{ + "version": 9, + "id": "show_dmx_golden", + "name": "DMX Golden Test Plot", + "metadata": { + "drawingTitle": "DMX Golden", + "venueName": "Test Stage", + "company": "", + "designer": "", + "draftsperson": "", + "showDate": "", + "revision": "D1", + "scaleLabel": "1/4\" = 1'-0\"" + }, + "venue": { + "stageWidthMm": 10973, + "stageDepthMm": 6706, + "proscWidthMm": 9144 + }, + "positions": { + "pos_stage": { + "id": "pos_stage", + "name": "1ST ELEC", + "kind": "pipe", + "yMm": -2438, + "lengthMm": 8534, + "trimMm": 6706 + } + }, + "positionOrder": ["pos_stage"], + "fixtures": { + "fx_s4_one": { + "id": "fx_s4_one", + "positionId": "pos_stage", + "profileId": "s4_26", + "xMm": -1829, + "rotation": 0, + "focus": null, + "unitNumber": 1, + "channel": 1, + "dmx": { "universe": 1, "address": 1 }, + "color": "R02", + "gobo": "", + "note": "", + "notes": { "color": "", "gobo": "", "focus": "", "crew": "" }, + "status": "patched", + "circuit": "A1", + "dimmer": "D1" + }, + "fx_led_one": { + "id": "fx_led_one", + "positionId": "pos_stage", + "profileId": "led_par_rgbw", + "xMm": 0, + "rotation": 0, + "focus": null, + "unitNumber": 2, + "channel": 2, + "dmx": { "universe": 1, "address": 10 }, + "color": "", + "gobo": "", + "note": "", + "notes": { "color": "", "gobo": "", "focus": "", "crew": "" }, + "status": "patched", + "circuit": "A2", + "dimmer": "D2" + }, + "fx_mover_one": { + "id": "fx_mover_one", + "positionId": "pos_stage", + "profileId": "spot_mh", + "xMm": 1829, + "rotation": 0, + "focus": null, + "unitNumber": 3, + "channel": 3, + "dmx": { "universe": 1, "address": 40 }, + "color": "", + "gobo": "", + "note": "", + "notes": { "color": "", "gobo": "", "focus": "", "crew": "" }, + "status": "patched", + "circuit": "A3", + "dimmer": "D3" + } + }, + "fixtureOrder": ["fx_s4_one", "fx_led_one", "fx_mover_one"], + "fixtureProfiles": {}, + "revisions": {}, + "revisionOrder": [], + "activeRevisionId": null, + "commentPins": {}, + "commentPinOrder": [], + "oscBridge": { + "namespace": "/plotforge", + "relayUrl": "ws://127.0.0.1:8765", + "targetHost": "127.0.0.1", + "targetPort": 8000 + } +} diff --git a/src/__tests__/fixtures/dmx-output/selected-led-buffer.json b/src/__tests__/fixtures/dmx-output/selected-led-buffer.json new file mode 100644 index 0000000..4c8a9d2 --- /dev/null +++ b/src/__tests__/fixtures/dmx-output/selected-led-buffer.json @@ -0,0 +1,26 @@ +{ + "name": "selected LED RGBW test buffer", + "intent": { + "type": "selected-fixture-test", + "selectedFixtureId": "fx_led_one", + "values": { + "intensity": 200, + "red": 255, + "green": 128, + "blue": 64, + "white": 32 + } + }, + "expected": { + "universe": 1, + "slotCount": 512, + "nonZeroSlots": [ + { "address": 10, "value": 200, "type": "intensity" }, + { "address": 11, "value": 255, "type": "red" }, + { "address": 12, "value": 128, "type": "green" }, + { "address": 13, "value": 64, "type": "blue" }, + { "address": 14, "value": 32, "type": "white" } + ], + "zeroAddresses": [1, 15, 16, 17, 40] + } +} diff --git a/src/__tests__/physicalEvidenceCheck.test.js b/src/__tests__/physicalEvidenceCheck.test.js new file mode 100644 index 0000000..854ffae --- /dev/null +++ b/src/__tests__/physicalEvidenceCheck.test.js @@ -0,0 +1,200 @@ +// @vitest-environment node + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const tempDirs = []; + +afterEach(() => { + while (tempDirs.length) { + fs.rmSync(tempDirs.pop(), { recursive: true, force: true }); + } +}); + +function makeTempDir(prefix) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function runChecker(script, args = []) { + const result = spawnSync(process.execPath, [script, ...args, "--json"], { + cwd: process.cwd(), + encoding: "utf8", + }); + return { + exitCode: result.status, + stderr: result.stderr, + json: JSON.parse(result.stdout), + }; +} + +function writeHardwareEvidence({ protocol = "sACN" } = {}) { + const dir = makeTempDir("plotforge-dmx-hardware-evidence-"); + fs.writeFileSync(path.join(dir, "relay.log"), "relay ok\n"); + fs.writeFileSync(path.join(dir, "capture.pcap"), "pcap bytes\n"); + fs.writeFileSync(path.join(dir, "node-ui.png"), "png bytes\n"); + fs.writeFileSync(path.join(dir, "README.md"), [ + "# PlotForge Hardware Evidence", + "", + "## Hardware", + "", + `- Protocol: ${protocol}`, + "- Node: Test node", + "- Node IP: 192.0.2.10", + "- Network interface: en0", + "- Fixture or dimmer: Test dimmer", + "- DMX universe/address: 1/1", + "", + "## Results", + "", + "- [x] No output before arming", + "- [x] Arm succeeds", + "- [x] Selected fixture intensity test works", + "- [x] Blackout works", + "- [x] Bad target fails visibly", + "- [x] Network disconnect faults visibly", + "- [x] Relay crash or tab failure faults and blacks out", + "- [x] Blackout recovery works", + "", + ].join("\n")); + return dir; +} + +function gateRows() { + const gates = [ + "Launch process", + "Launch choice", + "Launch document", + "N2 canvas", + "N3 inspector", + "N3 multi select", + "N4 fixtures", + "N4 labels", + "N4 Wizard", + "N4 patch/checks", + "N5 exports", + "N5 export baseline", + "Output arm", + "Selected fixture output test", + "Blackout", + "Local network permission", + "Save/reopen", + ]; + return gates.map(gate => `| ${gate} | Pass | smoke-recording.mov | ok |`).join("\n"); +} + +function writeNativeEvidence({ validArtifacts = true } = {}) { + const dir = makeTempDir("plotforge-native-evidence-"); + const exportDir = path.join(dir, "exported-files"); + fs.mkdirSync(exportDir); + + fs.writeFileSync(path.join(dir, "device-process.txt"), "## PlotForge Processes\nPlotForgeNative.app/PlotForgeNative pid 123\n"); + fs.writeFileSync(path.join(dir, "launch-start-screen.json"), "{\"launch\":\"ok\"}\n"); + fs.writeFileSync(path.join(dir, "installed-apps.json"), "{\"bundleIdentifier\":\"com.davehomeassist.plotforge.native\"}\n"); + fs.writeFileSync(path.join(dir, "display.json"), "{\"display\":\"ok\"}\n"); + fs.writeFileSync(path.join(dir, "lock-state.json"), "{\"locked\":false}\n"); + fs.writeFileSync(path.join(dir, "smoke-recording.mov"), "video proof\n"); + fs.writeFileSync(path.join(dir, "notes.md"), [ + "# PlotForge Native Physical Smoke Notes", + "", + "Date: 2026-07-01", + "Device: Test iPad", + "Build: Test build", + "Bundle: com.davehomeassist.plotforge.native", + "Operator: Test operator", + "", + "## Gate Results", + "", + "| Gate | Result | Evidence File | Notes |", + "| --- | --- | --- | --- |", + gateRows(), + "", + ].join("\n")); + + fs.writeFileSync( + path.join(exportDir, "show.plot"), + validArtifacts ? "{\"version\":9,\"id\":\"show\",\"name\":\"Show\"}\n" : "{}\n", + ); + fs.writeFileSync( + path.join(exportDir, "show-plot.pdf"), + validArtifacts ? "%PDF-1.4\n%%EOF\n" : "not a pdf\n", + ); + fs.writeFileSync( + path.join(exportDir, "show-patch.csv"), + `${validArtifacts ? "Unit,Position,Profile,Mode,Status,Channel,Universe,Address,End Address,Footprint,Circuit,Dimmer,Color,Gobo,Color Note,Gobo Note,Focus Note,Crew Note,Conflicts" : "wrong"}\n`, + ); + fs.writeFileSync( + path.join(exportDir, "show-gel-order.csv"), + `${validArtifacts ? "Gel,Count,Fixtures,Positions,Profiles" : "wrong"}\n`, + ); + fs.writeFileSync( + path.join(exportDir, "show-circuit-summary.csv"), + `${validArtifacts ? "Circuit,Dimmer,Fixtures,Shared,Partial" : "wrong"}\n`, + ); + fs.writeFileSync( + path.join(exportDir, "show-fixture-paperwork.csv"), + `${validArtifacts ? "Unit,Position,Profile,Manufacturer,Model,Mode,Channel,Universe,Address,Footprint,Status,Circuit,Dimmer,Color,Gobo,Focus X,Focus Y,Color Note,Gobo Note,Focus Note,Crew Note" : "wrong"}\n`, + ); + fs.writeFileSync( + path.join(exportDir, "show-osc-bridge.json"), + validArtifacts ? "{\"kind\":\"plotforge-osc-bridge\"}\n" : "{\"kind\":\"wrong\"}\n", + ); + fs.writeFileSync( + path.join(exportDir, "show-interop-manifest.json"), + validArtifacts ? "{\"kind\":\"plotforge-interop-manifest\"}\n" : "{\"kind\":\"wrong\"}\n", + ); + fs.writeFileSync( + path.join(exportDir, "show-pdf-review.json"), + validArtifacts + ? "{\"kind\":\"plotforge-pdf-review\",\"physicalSignoff\":\"pending\"}\n" + : "{\"kind\":\"wrong\",\"physicalSignoff\":\"done\"}\n", + ); + + return dir; +} + +describe("physical evidence checker scripts", () => { + it("requires the expected protocol for hardware smoke evidence", () => { + const dir = writeHardwareEvidence({ protocol: "sACN" }); + + const wrongProtocol = runChecker("scripts/dmx-hardware-evidence-check.mjs", ["--dir", dir, "--protocol", "artnet"]); + expect(wrongProtocol.exitCode).toBe(1); + expect(wrongProtocol.json.status).toBe("fail"); + expect(wrongProtocol.json.failed.map(check => check.name)).toContain("Art-Net protocol recorded"); + + const matchingProtocol = runChecker("scripts/dmx-hardware-evidence-check.mjs", ["--dir", dir, "--protocol", "sacn"]); + expect(matchingProtocol.stderr).toBe(""); + expect(matchingProtocol.exitCode).toBe(0); + expect(matchingProtocol.json.status).toBe("pass"); + }); + + it("validates native physical export artifact contents, not just file counts", () => { + const validDir = writeNativeEvidence({ validArtifacts: true }); + const invalidDir = writeNativeEvidence({ validArtifacts: false }); + + const valid = runChecker("scripts/native-physical-evidence-check.mjs", ["--dir", validDir]); + expect(valid.stderr).toBe(""); + expect(valid.exitCode).toBe(0); + expect(valid.json.status).toBe("pass"); + + const invalid = runChecker("scripts/native-physical-evidence-check.mjs", ["--dir", invalidDir]); + expect(invalid.exitCode).toBe(1); + expect(invalid.json.status).toBe("fail"); + expect(invalid.json.failed.map(check => check.name)).toEqual(expect.arrayContaining([ + "exported .plot is valid v9 JSON", + "exported PDF starts with %PDF-1.4", + "patch CSV header matches", + "gel rollup CSV header matches", + "circuit summary CSV header matches", + "fixture paperwork CSV header matches", + "OSC bridge JSON is valid", + "interop manifest JSON is valid", + "PDF review JSON is valid", + "PDF review physical signoff is pending", + ])); + }); +}); diff --git a/src/__tests__/profiles.test.js b/src/__tests__/profiles.test.js index d3731d7..4306715 100644 --- a/src/__tests__/profiles.test.js +++ b/src/__tests__/profiles.test.js @@ -51,6 +51,14 @@ describe("fixture profiles", () => { expect(library.find(profile => profile.id === "ofl_demo_fixture")).toEqual(expect.objectContaining({ libraryTier: "ofl-import", dmxFootprint: 4, + outputMap: expect.objectContaining({ + id: "ofl_demo_fixture-candidate-output-map", + profileIds: ["ofl_demo_fixture"], + source: expect.objectContaining({ + type: "open-fixture-library-candidate", + approved: false, + }), + }), source: expect.objectContaining({ type: "open-fixture-library" }), })); }); diff --git a/src/__tests__/sacn.test.js b/src/__tests__/sacn.test.js new file mode 100644 index 0000000..9a246cc --- /dev/null +++ b/src/__tests__/sacn.test.js @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import goldenDoc from "./fixtures/dmx-output/golden-doc.json"; +import selectedLedVector from "./fixtures/dmx-output/selected-led-buffer.json"; +import { compileDmxOutput } from "../domain/dmxOutput.js"; +import { + SACN_DMX_HEADER_LENGTH, + SACN_PORT, + encodeSacnDmx, + sacnMulticastAddress, + sacnPacketSummary, +} from "../domain/sacn.js"; + +const TEST_CID = "12345678-1234-5678-9abc-def012345678"; + +describe("sACN encoder", () => { + it("encodes an E1.31 data packet with source CID priority sequence and universe", () => { + const packet = encodeSacnDmx({ + cid: TEST_CID, + sourceName: "PlotForge Test", + universe: 1, + data: Uint8Array.from([1, 2, 3, 4]), + priority: 120, + sequence: 7, + preview: true, + }); + const summary = sacnPacketSummary(packet); + + expect(SACN_PORT).toBe(5568); + expect(packet).toHaveLength(SACN_DMX_HEADER_LENGTH + 4); + expect(summary).toEqual(expect.objectContaining({ + preambleSize: 0x0010, + postambleSize: 0, + acnPacketIdentifier: "ASC-E1.17\0\0\0", + rootFlagsLength: 0x7000 | (packet.length - 16), + rootVector: 0x00000004, + framingFlagsLength: 0x7000 | (packet.length - 38), + framingVector: 0x00000002, + sourceName: "PlotForge Test", + priority: 120, + sequence: 7, + options: 0x80, + universe: 1, + dmpFlagsLength: 0x7000 | (packet.length - 115), + dmpVector: 0x02, + addressTypeDataType: 0xa1, + firstPropertyAddress: 0, + addressIncrement: 1, + propertyValueCount: 5, + startCode: 0, + })); + expect(summary.cid).toEqual([ + 0x12, 0x34, 0x56, 0x78, 0x12, 0x34, 0x56, 0x78, + 0x9a, 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78, + ]); + expect(Array.from(summary.payload)).toEqual([1, 2, 3, 4]); + }); + + it("builds sACN packets from the same compiled DMX universe buffers as Art-Net", () => { + const compiled = compileDmxOutput(goldenDoc, { + intent: selectedLedVector.intent.type, + selectedFixtureId: selectedLedVector.intent.selectedFixtureId, + values: selectedLedVector.intent.values, + }); + const universe = compiled.universes[0]; + const packet = encodeSacnDmx({ + cid: TEST_CID, + sourceName: "PlotForge Compiler", + universe: universe.universe, + data: Uint8Array.from(universe.slots), + priority: 100, + sequence: 1, + }); + const summary = sacnPacketSummary(packet); + + expect(packet).toHaveLength(638); + expect(summary.universe).toBe(1); + expect(summary.propertyValueCount).toBe(513); + expect(summary.payload).toHaveLength(512); + expect(summary.payload[9]).toBe(200); + expect(summary.payload[10]).toBe(255); + expect(summary.payload[11]).toBe(128); + expect(summary.payload[12]).toBe(64); + expect(summary.payload[13]).toBe(32); + }); + + it("validates packet fields and exposes multicast addressing for later hardware tests", () => { + expect(sacnMulticastAddress(1)).toBe("239.255.0.1"); + expect(sacnMulticastAddress(63999)).toBe("239.255.249.255"); + expect(() => sacnMulticastAddress(0)).toThrow(/universe/i); + expect(() => encodeSacnDmx({ + cid: TEST_CID, + universe: 64000, + data: Uint8Array.from([1]), + })).toThrow(/universe/i); + expect(() => encodeSacnDmx({ + cid: "not-a-cid", + universe: 1, + data: Uint8Array.from([1]), + })).toThrow(/CID/i); + expect(() => encodeSacnDmx({ + cid: TEST_CID, + universe: 1, + priority: 201, + data: Uint8Array.from([1]), + })).toThrow(/priority/i); + }); +}); diff --git a/src/__tests__/serialization.test.js b/src/__tests__/serialization.test.js index 58936fc..29dd79c 100644 --- a/src/__tests__/serialization.test.js +++ b/src/__tests__/serialization.test.js @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { deserialize, serialize } from "../serialization.js"; import { DOC_VERSION, newRevision, newShow, addRevision, updateProjectMetadata } from "../domain/show.js"; +import { defaultDmxOutputSettings } from "../domain/dmxOutputPreferences.js"; describe("serialization", () => { it("roundtrips project metadata", () => { @@ -187,6 +188,174 @@ describe("serialization", () => { })); }); + it("migrates older documents with default DMX output preferences", () => { + const parsed = deserialize(JSON.stringify({ + version: 8, + name: "Legacy DMX Output Plot", + positions: {}, + positionOrder: [], + fixtures: {}, + fixtureOrder: [], + venue: {}, + oscBridge: {}, + })); + + expect(parsed.version).toBe(DOC_VERSION); + expect(parsed.dmxOutput).toEqual(defaultDmxOutputSettings()); + }); + + it("normalizes DMX output preferences without persisting session state", () => { + const parsed = deserialize(JSON.stringify({ + version: DOC_VERSION, + name: "DMX Preferences", + positions: {}, + positionOrder: [], + fixtures: {}, + fixtureOrder: [], + venue: {}, + dmxOutput: { + version: 99, + protocol: "sACN", + targetMode: "multicast", + relayUrl: " ws://127.0.0.1:8770 ", + token: "do-not-save", + armed: true, + state: "sending", + targetHost: "192.168.1.40", + targetPort: "5568", + maxFps: 999, + blackoutOnDisconnect: false, + blackoutBurst: 25, + blackoutKeepAlive: true, + currentValues: { intensity: 255 }, + universes: { + "2": { + net: 200, + subNet: -5, + universe: 9, + targetHost: "192.168.1.41", + }, + }, + }, + })); + + expect(parsed.dmxOutput).toEqual({ + version: 1, + protocol: "sacn", + targetMode: "multicast", + relayUrl: "ws://127.0.0.1:8770", + targetHost: "192.168.1.40", + targetPort: 5568, + maxFps: 44, + blackoutOnDisconnect: false, + blackoutBurst: 20, + blackoutKeepAlive: true, + universes: { + "2": { + net: 127, + subNet: 0, + universe: 9, + targetHost: "192.168.1.41", + }, + }, + }); + expect(parsed.dmxOutput.token).toBeUndefined(); + expect(parsed.dmxOutput.armed).toBeUndefined(); + expect(parsed.dmxOutput.currentValues).toBeUndefined(); + }); + + it("strips DMX output tokens and arm state while serializing", () => { + const doc = { + ...newShow({ name: "Unsafe DMX Save" }), + dmxOutput: { + relayUrl: "ws://127.0.0.1:8766", + token: "session-secret", + armed: true, + state: "armed", + targetHost: "127.0.0.1", + targetPort: 6454, + universes: { + "1": { net: 0, subNet: 1, universe: 2 }, + }, + }, + }; + + const root = JSON.parse(serialize(doc)); + + expect(root.dmxOutput).toEqual(expect.objectContaining({ + version: 1, + relayUrl: "ws://127.0.0.1:8766", + targetHost: "127.0.0.1", + targetPort: 6454, + universes: { + "1": { net: 0, subNet: 1, universe: 2, targetHost: "" }, + }, + })); + expect(root.dmxOutput.token).toBeUndefined(); + expect(root.dmxOutput.armed).toBeUndefined(); + expect(root.dmxOutput.state).toBeUndefined(); + }); + + it("defaults sACN output preferences to the sACN UDP port", () => { + const parsed = deserialize(JSON.stringify({ + version: DOC_VERSION, + name: "sACN Defaults", + positions: {}, + positionOrder: [], + fixtures: {}, + fixtureOrder: [], + venue: {}, + dmxOutput: { + protocol: "sacn", + }, + })); + + expect(parsed.dmxOutput.protocol).toBe("sacn"); + expect(parsed.dmxOutput.targetMode).toBe("unicast"); + expect(parsed.dmxOutput.targetPort).toBe(5568); + expect(parsed.dmxOutput.universes["1"].universe).toBe(1); + }); + + it("preserves sACN universe route values above the Art-Net universe range", () => { + const parsed = deserialize(JSON.stringify({ + version: DOC_VERSION, + name: "sACN Route", + positions: {}, + positionOrder: [], + fixtures: {}, + fixtureOrder: [], + venue: {}, + dmxOutput: { + protocol: "sacn", + universes: { + "1": { universe: 4096 }, + }, + }, + })); + + expect(parsed.dmxOutput.protocol).toBe("sacn"); + expect(parsed.dmxOutput.universes["1"].universe).toBe(4096); + }); + + it("forces Art-Net output preferences to unicast target mode", () => { + const parsed = deserialize(JSON.stringify({ + version: DOC_VERSION, + name: "Art-Net Target Mode", + positions: {}, + positionOrder: [], + fixtures: {}, + fixtureOrder: [], + venue: {}, + dmxOutput: { + protocol: "artnet", + targetMode: "multicast", + }, + })); + + expect(parsed.dmxOutput.protocol).toBe("artnet"); + expect(parsed.dmxOutput.targetMode).toBe("unicast"); + }); + it("roundtrips named revisions", () => { const revision = newRevision({ name: "Rev A", note: "Focus notes", createdAt: 1 }); const doc = addRevision(newShow({ name: "Revision Test" }), revision); diff --git a/src/components/DmxOutputPanel.jsx b/src/components/DmxOutputPanel.jsx new file mode 100644 index 0000000..4c51853 --- /dev/null +++ b/src/components/DmxOutputPanel.jsx @@ -0,0 +1,680 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { + compileDmxOutput, + DEFAULT_DMX_TEST_VALUES, + DMX_OUTPUT_INTENTS, + summarizeDmxOutput, +} from "../domain/dmxOutput.js"; +import { normalizeDmxOutputSettings } from "../domain/dmxOutputPreferences.js"; +import { getProfile } from "../domain/profiles.js"; +import { sacnMulticastAddress } from "../domain/sacn.js"; + +const VALUE_FIELDS = [ + ["intensity", "Dimmer"], + ["red", "Red"], + ["green", "Green"], + ["blue", "Blue"], + ["white", "White"], +]; + +const DEFAULT_RELAY = { + protocol: "artnet", + targetMode: "unicast", + relayUrl: "ws://127.0.0.1:8766", + token: "", + targetHost: "127.0.0.1", + targetPort: "6454", + net: "0", + subNet: "0", + universe: "0", +}; + +function defaultTargetPort(protocol) { + return protocol === "sacn" ? 5568 : 6454; +} + +function relayDefaultsFromDoc(doc) { + const settings = normalizeDmxOutputSettings(doc?.dmxOutput || {}); + const universeRoute = settings.universes?.["1"] || {}; + return { + protocol: settings.protocol, + targetMode: settings.targetMode, + relayUrl: settings.relayUrl, + token: "", + targetHost: universeRoute.targetHost || settings.targetHost, + targetPort: String(settings.targetPort), + net: String(universeRoute.net ?? 0), + subNet: String(universeRoute.subNet ?? 0), + universe: String(universeRoute.universe ?? 0), + }; +} + +function clampSlotValue(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 0; + return Math.max(0, Math.min(255, Math.round(parsed))); +} + +function fixtureLabel(doc, fixtureId) { + const fixture = doc.fixtures?.[fixtureId]; + if (!fixture) return "Select fixture"; + const position = doc.positions?.[fixture.positionId]; + const profile = getProfile(fixture.profileId, doc.fixtureProfiles); + const unit = fixture.unitNumber == null ? fixture.id : `U${fixture.unitNumber}`; + const profileName = [profile?.manufacturer, profile?.model].filter(Boolean).join(" ") || fixture.profileId; + return `${position?.name || "Unpositioned"} ${unit} · ${profileName}`; +} + +function rangeLabel(range) { + if (!Number.isInteger(range.startAddress) || !Number.isInteger(range.endAddress)) return "Invalid"; + return `U${range.universe} ${range.startAddress}-${range.endAddress}`; +} + +function outputStatusLabel(status) { + if (status === "selected") return "selected"; + if (status === "ready") return "ready"; + if (status === "unmapped") return "paperwork"; + if (status === "blocked") return "blocked"; + if (status === "invalid") return "invalid"; + return status || "unknown"; +} + +function clampInteger(value, min, max, fallback) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + return Math.max(min, Math.min(max, Math.round(parsed))); +} + +function slotsToBase64(slots) { + let binary = ""; + for (let index = 0; index < slots.length; index += 1) { + binary += String.fromCharCode(clampInteger(slots[index], 0, 255, 0)); + } + return window.btoa(binary); +} + +function relayStateLabel(state) { + if (state === "arming") return "arming"; + if (state === "armed") return "armed"; + if (state === "sending") return "sending"; + if (state === "blackout") return "blackout"; + if (state === "fault") return "fault"; + return "idle"; +} + +function relayTargetMode(protocol, targetMode) { + if (protocol !== "sacn") return "unicast"; + return targetMode === "multicast" ? "multicast" : "unicast"; +} + +function relaySettingsPatch(relay) { + const protocol = relay.protocol === "sacn" ? "sacn" : "artnet"; + const targetMode = relayTargetMode(protocol, relay.targetMode); + const targetPort = clampInteger(relay.targetPort, 1, 65535, defaultTargetPort(protocol)); + const targetHost = relay.targetHost.trim() || DEFAULT_RELAY.targetHost; + const universe = protocol === "sacn" + ? clampInteger(relay.universe, 1, 63999, 1) + : clampInteger(relay.universe, 0, 15, 0); + const route = protocol === "sacn" + ? { universe, targetHost } + : { + net: clampInteger(relay.net, 0, 127, 0), + subNet: clampInteger(relay.subNet, 0, 15, 0), + universe, + targetHost, + }; + + return { + protocol, + targetMode, + relayUrl: relay.relayUrl.trim() || DEFAULT_RELAY.relayUrl, + targetHost, + targetPort, + universes: { + "1": route, + }, + }; +} + +export default function DmxOutputPanel({ doc, selectedFixtureId, onSettingsChange }) { + const [intent, setIntent] = useState(DMX_OUTPUT_INTENTS.selectedFixtureTest); + const [manualFixtureId, setManualFixtureId] = useState(""); + const [values, setValues] = useState(DEFAULT_DMX_TEST_VALUES); + const [activeUniverse, setActiveUniverse] = useState(null); + const [relay, setRelay] = useState(() => ({ ...DEFAULT_RELAY, ...relayDefaultsFromDoc(doc) })); + const [relayState, setRelayState] = useState("idle"); + const [relayStatus, setRelayStatus] = useState(""); + const [relayError, setRelayError] = useState(""); + const socketRef = useRef(null); + const heartbeatRef = useRef(null); + const intentionalCloseRef = useRef(false); + + const fixtureIds = doc.fixtureOrder || []; + const requestedFixtureId = manualFixtureId || selectedFixtureId || fixtureIds[0] || ""; + const activeFixtureId = fixtureIds.includes(requestedFixtureId) ? requestedFixtureId : fixtureIds[0] || ""; + const compiled = useMemo(() => compileDmxOutput(doc, { + intent, + selectedFixtureId: activeFixtureId, + values, + }), [doc, intent, activeFixtureId, values]); + const summary = useMemo(() => summarizeDmxOutput(compiled), [compiled]); + const selectedUniverse = compiled.universes.find(universe => universe.universe === activeUniverse) + || compiled.universes[0] + || null; + const isArmed = relayState === "armed" || relayState === "sending" || relayState === "blackout"; + const canArm = !compiled.blocked && compiled.universes.length > 0 && relay.token.trim().length > 0; + const canSend = isArmed && !compiled.blocked && compiled.universes.length > 0; + const currentTargetMode = relayTargetMode(relay.protocol, relay.targetMode); + const displayedSacnUniverse = clampInteger(relay.universe, 1, 63999, 1); + const displayedTargetHost = currentTargetMode === "multicast" + ? sacnMulticastAddress(displayedSacnUniverse) + : relay.targetHost; + + useEffect(() => () => { + if (heartbeatRef.current) window.clearInterval(heartbeatRef.current); + socketRef.current?.close(); + }, []); + + function onValueChange(key, value) { + setValues(current => ({ ...current, [key]: clampSlotValue(value) })); + } + + function onZeroValues() { + setValues({ intensity: 0, red: 0, green: 0, blue: 0, white: 0 }); + } + + function persistRelaySettings(nextRelay) { + onSettingsChange?.(relaySettingsPatch(nextRelay)); + } + + function updateRelay(patch, options = {}) { + const nextRelay = { ...relay, ...patch }; + setRelay(nextRelay); + if (options.persist !== false) persistRelaySettings(nextRelay); + } + + function onProtocolChange(protocol) { + const nextProtocol = protocol === "sacn" ? "sacn" : "artnet"; + const currentProtocol = relay.protocol === "sacn" ? "sacn" : "artnet"; + const shouldSwitchPort = String(relay.targetPort).trim() === String(defaultTargetPort(currentProtocol)); + const nextRelay = { + ...relay, + protocol: nextProtocol, + targetMode: nextProtocol === "sacn" ? relayTargetMode(nextProtocol, relay.targetMode) : "unicast", + targetPort: shouldSwitchPort ? String(defaultTargetPort(nextProtocol)) : relay.targetPort, + universe: nextProtocol === "sacn" && String(relay.universe).trim() === "0" ? "1" : relay.universe, + }; + setRelay(nextRelay); + persistRelaySettings(nextRelay); + } + + function onTargetModeChange(targetMode) { + const nextTargetMode = targetMode === "multicast" ? "multicast" : "unicast"; + const nextRelay = { + ...relay, + targetMode: nextTargetMode, + targetPort: relay.protocol === "sacn" && String(relay.targetPort).trim() === "" + ? String(defaultTargetPort("sacn")) + : relay.targetPort, + }; + setRelay(nextRelay); + persistRelaySettings(nextRelay); + } + + function clearHeartbeat() { + if (heartbeatRef.current) window.clearInterval(heartbeatRef.current); + heartbeatRef.current = null; + } + + function sendRelayCommand(command) { + const socket = socketRef.current; + if (!socket || socket.readyState !== window.WebSocket.OPEN) { + throw new Error("DMX relay is not connected."); + } + socket.send(JSON.stringify({ token: relay.token, ...command })); + } + + function startHeartbeat() { + clearHeartbeat(); + heartbeatRef.current = window.setInterval(() => { + try { + sendRelayCommand({ type: "heartbeat" }); + } catch (error) { + setRelayState("fault"); + setRelayError(error instanceof Error ? error.message : "DMX relay heartbeat failed."); + clearHeartbeat(); + } + }, 500); + } + + function handleRelayMessage(message) { + if (message.type === "fault") { + setRelayState("fault"); + setRelayError(message.detail || message.reason || "DMX relay fault."); + clearHeartbeat(); + return; + } + if (message.type === "frame.ok") { + setRelayState("armed"); + setRelayStatus(`Frame sent (${message.bytesSent} bytes).`); + setRelayError(""); + return; + } + if (message.type === "blackout.ok") { + setRelayState("armed"); + setRelayStatus(`Blackout sent (${message.framesSent || 0} frames).`); + setRelayError(""); + return; + } + if (String(message.type || "").endsWith(".error")) { + setRelayState("fault"); + setRelayError(message.detail || message.reason || "DMX relay command failed."); + clearHeartbeat(); + return; + } + if (message.type === "heartbeat.ok") { + setRelayError(""); + } + } + + function connectRelay() { + if (socketRef.current?.readyState === window.WebSocket.OPEN) return Promise.resolve(socketRef.current); + if (!window.WebSocket) return Promise.reject(new Error("WebSocket is not available in this browser.")); + if (!relay.token.trim()) return Promise.reject(new Error("Enter the relay token before arming output.")); + + setRelayState("arming"); + setRelayError(""); + setRelayStatus("Connecting to DMX relay."); + intentionalCloseRef.current = false; + + return new Promise((resolve, reject) => { + const socket = new WebSocket(relay.relayUrl); + socketRef.current = socket; + let authenticated = false; + const timer = window.setTimeout(() => { + socket.close(); + reject(new Error("DMX relay timed out during arm.")); + }, 2500); + + socket.addEventListener("open", () => { + socket.send(JSON.stringify({ + type: "auth", + protocolVersion: 1, + token: relay.token, + })); + }, { once: true }); + + socket.addEventListener("message", event => { + let message; + try { + message = JSON.parse(event.data); + } catch { + setRelayState("fault"); + setRelayError("DMX relay returned invalid JSON."); + return; + } + if (!authenticated) { + if (message.type === "auth.ok") { + authenticated = true; + window.clearTimeout(timer); + setRelayState("armed"); + setRelayStatus("Output armed."); + setRelayError(""); + startHeartbeat(); + resolve(socket); + return; + } + window.clearTimeout(timer); + socket.close(); + reject(new Error(message.reason || "DMX relay authentication failed.")); + return; + } + handleRelayMessage(message); + }); + + socket.addEventListener("error", () => { + window.clearTimeout(timer); + reject(new Error("DMX relay connection failed.")); + }, { once: true }); + + socket.addEventListener("close", () => { + window.clearTimeout(timer); + clearHeartbeat(); + socketRef.current = null; + if (!intentionalCloseRef.current && authenticated) { + setRelayState("fault"); + setRelayError("DMX relay connection closed."); + } + }); + }); + } + + async function onArmRelay() { + try { + await connectRelay(); + } catch (error) { + setRelayState("fault"); + setRelayStatus(""); + setRelayError(error instanceof Error ? error.message : "DMX relay arm failed."); + } + } + + function mappedPortAddress(universe) { + const baseUniverse = clampInteger(relay.universe, 0, 15, 0); + const mappedUniverse = baseUniverse + Math.max(0, Number(universe.universe || 1) - 1); + if (mappedUniverse > 15) { + throw new Error("Art-Net universe mapping exceeds the current Sub-Net range."); + } + return { + net: clampInteger(relay.net, 0, 127, 0), + subNet: clampInteger(relay.subNet, 0, 15, 0), + universe: mappedUniverse, + }; + } + + function mappedSacnUniverse(universe) { + const baseUniverse = clampInteger(relay.universe, 1, 63999, 1); + const mappedUniverse = baseUniverse + Math.max(0, Number(universe.universe || 1) - 1); + if (mappedUniverse > 63999) { + throw new Error("sACN universe mapping exceeds 63999."); + } + return mappedUniverse; + } + + async function onSendTestFrame() { + if (!canSend) { + setRelayError("Arm output and clear compiler errors before sending."); + return; + } + try { + setRelayState("sending"); + for (const universe of compiled.universes) { + const protocol = relay.protocol === "sacn" ? "sacn" : "artnet"; + const sacnUniverse = protocol === "sacn" ? mappedSacnUniverse(universe) : null; + const targetMode = relayTargetMode(protocol, relay.targetMode); + const command = { + type: "frame", + protocol, + target: { + host: targetMode === "multicast" ? sacnMulticastAddress(sacnUniverse) : relay.targetHost.trim(), + port: clampInteger(relay.targetPort, 1, 65535, defaultTargetPort(protocol)), + }, + length: universe.slots.length, + data: slotsToBase64(universe.slots), + }; + if (command.protocol === "sacn") { + command.targetMode = targetMode; + command.universe = sacnUniverse; + command.sourceName = "PlotForge"; + command.priority = 100; + } else { + command.portAddress = mappedPortAddress(universe); + } + sendRelayCommand(command); + } + setRelayStatus(`Sent ${compiled.universes.length} universe frame${compiled.universes.length === 1 ? "" : "s"}.`); + setRelayError(""); + } catch (error) { + setRelayState("fault"); + setRelayError(error instanceof Error ? error.message : "DMX test frame failed."); + clearHeartbeat(); + } + } + + function onBlackout() { + if (!isArmed) { + setRelayError("Arm output before blackout."); + return; + } + try { + setRelayState("blackout"); + sendRelayCommand({ type: "blackout" }); + setRelayStatus("Blackout requested."); + setRelayError(""); + } catch (error) { + setRelayState("fault"); + setRelayError(error instanceof Error ? error.message : "DMX blackout failed."); + clearHeartbeat(); + } + } + + function onDisarm() { + try { + if (socketRef.current?.readyState === window.WebSocket.OPEN) { + sendRelayCommand({ type: "blackout" }); + } + } catch { + // Closing the relay connection is the fallback disarm path. + } + intentionalCloseRef.current = true; + clearHeartbeat(); + socketRef.current?.close(); + socketRef.current = null; + setRelayState("idle"); + setRelayStatus("Output disarmed."); + setRelayError(""); + } + + return ( +
+
+
+ D3 PREP +

DMX output

+
+ + {relayStateLabel(relayState)} + +
+ +
+ OUTPUT TEST MODE - Not for show use +
+ +
+ {summary.universeCount} universes + {summary.patchedFixtureCount} patched + + {summary.errorCount ? `${summary.errorCount} errors` : "no blocks"} + + + {summary.warningCount ? `${summary.warningCount} warnings` : "mapped"} + +
+ +
+ + + {VALUE_FIELDS.map(([key, label]) => ( + + ))} + +
+ +
+
+ + + + + + + + + +
+
+ + + + +
+ {relayStatus &&

{relayStatus}

} + {relayError &&

{relayError}

} +
+ + {(compiled.errors.length > 0 || compiled.warnings.length > 0) && ( +
+ {compiled.errors.map((error, index) => ( +

+ {error.message} +

+ ))} + {compiled.warnings.map((warning, index) => ( +

+ {warning.message} +

+ ))} +
+ )} + +
+
+ Universe inspector + {compiled.universes.length > 1 && ( + + )} +
+ + {selectedUniverse ? ( + <> +
+ {selectedUniverse.usedRanges.map(range => ( +
+ {rangeLabel(range)} + {range.fixtureLabel} + {outputStatusLabel(range.outputStatus)} +
+ ))} +
+
+ {selectedUniverse.nonZeroSlots.length === 0 ? ( + All slots zero. + ) : selectedUniverse.nonZeroSlots.map(slot => ( + + {slot.address}: {slot.value} {slot.type} - {slot.explanation} + + ))} +
+ + ) : ( +

No patched universes.

+ )} +
+
+ ); +} diff --git a/src/components/FixtureLibrary.jsx b/src/components/FixtureLibrary.jsx index 3e16a92..f81e7a6 100644 --- a/src/components/FixtureLibrary.jsx +++ b/src/components/FixtureLibrary.jsx @@ -4,6 +4,7 @@ import { getProfileDetail, getProfileSearchText, } from "../domain/profiles.js"; +import { outputProfileStatus } from "../domain/fixtureOutputProfiles.js"; const LANES = [ ["curated-gdtf", "GDTF"], @@ -27,7 +28,17 @@ function formatSource(profile) { return "Spike seed"; } -function FixtureProfileDetail({ profile, hasPosition, selectedPositionId, onAddFixture }) { +function outputStatusCopy(status) { + if (status === "output-ready") return "Output ready"; + if (status === "candidate") return "Candidate map"; + return "Paperwork only"; +} + +function channelStatusLabel(channel) { + return channel.unsafe ? `${channel.type} locked` : channel.type; +} + +function FixtureProfileDetail({ profile, customProfiles, hasPosition, selectedPositionId, onAddFixture }) { if (!profile) { return (
@@ -37,6 +48,9 @@ function FixtureProfileDetail({ profile, hasPosition, selectedPositionId, onAddF } const detail = getProfileDetail(profile); + const output = outputProfileStatus(profile.id, customProfiles); + const outputChannels = output.profile?.channels || []; + const unsafeCount = outputChannels.filter(channel => channel.unsafe).length; return (
@@ -75,8 +89,40 @@ function FixtureProfileDetail({ profile, hasPosition, selectedPositionId, onAddF
Source
{formatSource(profile)}
+
+
Output
+
{outputStatusCopy(output.status)}
+
+
+
Mapped
+
{outputChannels.length ? `${outputChannels.length} slots` : "None"}
+
+
+ OUTPUT MAP + {output.profile ? ( + <> +
+ {output.profile.label} + {outputStatusCopy(output.status)} + {unsafeCount > 0 && {unsafeCount} unsafe locked} +
+
+ {outputChannels.map(channel => ( + + {channel.slot} + {channel.label} + {channelStatusLabel(channel)} + + ))} +
+ + ) : ( +

No approved output map. This profile stays paperwork-only for live output.

+ )} +
+
BEST FOR
@@ -247,45 +293,52 @@ export default function FixtureLibrary({
- {visibleProfiles.map(profile => ( -
- - {profile.model} - {profile.manufacturer} - - - {profile.defaultMode} - {profile.dmxFootprint}ch · {profile.category} - {formatSource(profile)} - - - -
- ))} + + {profile.model} + {profile.manufacturer} + + + {profile.defaultMode} + {profile.dmxFootprint}ch · {profile.category} + {formatSource(profile)} + + + {outputStatusCopy(output.status)} + + + +
+ ); + })} {visibleProfiles.length === 0 && (

No profiles in this lane.

)} diff --git a/src/domain/artnet.js b/src/domain/artnet.js new file mode 100644 index 0000000..9ee51b1 --- /dev/null +++ b/src/domain/artnet.js @@ -0,0 +1,106 @@ +export const ARTNET_PORT = 6454; +export const ARTNET_PROTOCOL_VERSION = 14; +export const ARTNET_DMX_HEADER_LENGTH = 18; +export const ARTNET_DMX_MIN_LENGTH = 2; +export const ARTNET_DMX_MAX_LENGTH = 512; + +const ARTNET_ID = "Art-Net\0"; + +export class ArtNetError extends Error { + constructor(message, code) { + super(message); + this.name = "ArtNetError"; + this.code = code; + } +} + +function assertIntegerRange(value, min, max, label, code) { + if (!Number.isInteger(value) || value < min || value > max) { + throw new ArtNetError(`${label} must be an integer from ${min} to ${max}.`, code); + } +} + +export function validatePortAddress(portAddress = {}) { + const net = Number(portAddress.net); + const subNet = Number(portAddress.subNet); + const universe = Number(portAddress.universe); + assertIntegerRange(net, 0, 127, "Art-Net net", "invalid-net"); + assertIntegerRange(subNet, 0, 15, "Art-Net sub-net", "invalid-subnet"); + assertIntegerRange(universe, 0, 15, "Art-Net universe", "invalid-universe"); + return { net, subNet, universe }; +} + +export function encodePortAddress(portAddress = {}) { + const normalized = validatePortAddress(portAddress); + return { + ...normalized, + subUni: ((normalized.subNet & 0x0f) << 4) | (normalized.universe & 0x0f), + }; +} + +function normalizeDmxData(data) { + if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + if (data instanceof ArrayBuffer) return new Uint8Array(data); + if (Array.isArray(data)) return Uint8Array.from(data); + throw new ArtNetError("Art-Net DMX data must be a Uint8Array or byte array.", "invalid-data"); +} + +function validateDmxLength(length) { + assertIntegerRange(length, ARTNET_DMX_MIN_LENGTH, ARTNET_DMX_MAX_LENGTH, "Art-Net DMX length", "invalid-length"); +} + +function validateByte(value, label, code) { + const number = Number(value); + assertIntegerRange(number, 0, 255, label, code); + return number; +} + +export function encodeArtDmx({ + portAddress, + data, + sequence = 0, + physical = 0, + protocolVersion = ARTNET_PROTOCOL_VERSION, +} = {}) { + const encodedPortAddress = encodePortAddress(portAddress); + const dmxData = normalizeDmxData(data); + validateDmxLength(dmxData.length); + const outputLength = dmxData.length % 2 === 0 ? dmxData.length : dmxData.length + 1; + validateDmxLength(outputLength); + const packet = new Uint8Array(ARTNET_DMX_HEADER_LENGTH + outputLength); + const view = new DataView(packet.buffer); + + for (let index = 0; index < ARTNET_ID.length; index += 1) { + packet[index] = ARTNET_ID.charCodeAt(index); + } + view.setUint16(8, 0x5000, true); + view.setUint16(10, protocolVersion, false); + packet[12] = validateByte(sequence, "Art-Net sequence", "invalid-sequence"); + packet[13] = validateByte(physical, "Art-Net physical port", "invalid-physical"); + packet[14] = encodedPortAddress.subUni; + packet[15] = encodedPortAddress.net; + view.setUint16(16, outputLength, false); + packet.set(dmxData, ARTNET_DMX_HEADER_LENGTH); + + return packet; +} + +export function artDmxPacketSummary(packet) { + const data = normalizeDmxData(packet); + if (data.length < ARTNET_DMX_HEADER_LENGTH) { + throw new ArtNetError("Art-Net packet is shorter than the ArtDmx header.", "short-packet"); + } + const text = Array.from(data.slice(0, 8)).map(byte => String.fromCharCode(byte)).join(""); + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + return { + id: text, + opcode: view.getUint16(8, true), + protocolVersion: view.getUint16(10, false), + sequence: data[12], + physical: data[13], + subUni: data[14], + net: data[15], + length: view.getUint16(16, false), + payload: data.slice(ARTNET_DMX_HEADER_LENGTH), + }; +} diff --git a/src/domain/dmxOutput.js b/src/domain/dmxOutput.js new file mode 100644 index 0000000..d395d3b --- /dev/null +++ b/src/domain/dmxOutput.js @@ -0,0 +1,345 @@ +import { getProfile } from "./profiles.js"; +import { + DEFAULT_DMX_TEST_VALUES, + getFixtureOutputCandidate, + getFixtureOutputProfile, + explainOutputChannel, +} from "./fixtureOutputProfiles.js"; + +export { DEFAULT_DMX_TEST_VALUES } from "./fixtureOutputProfiles.js"; + +export const DMX_OUTPUT_VERSION = 1; +export const DMX_SLOT_COUNT = 512; + +export const DMX_OUTPUT_INTENTS = Object.freeze({ + selectedFixtureTest: "selected-fixture-test", + blackout: "blackout", +}); + +function orderedFixtures(doc) { + const fixtures = doc.fixtures || {}; + const ordered = (doc.fixtureOrder || []) + .map(id => fixtures[id]) + .filter(Boolean); + const seen = new Set(ordered.map(fixture => fixture.id)); + const rest = Object.values(fixtures) + .filter(fixture => !seen.has(fixture.id)) + .sort((a, b) => String(a.id).localeCompare(String(b.id))); + return [...ordered, ...rest]; +} + +function parseWholeNumber(value) { + if (typeof value === "number") return Number.isInteger(value) ? value : NaN; + if (typeof value === "string" && /^-?\d+$/.test(value.trim())) return Number(value.trim()); + return NaN; +} + +function profileLabel(profile, profileId) { + if (!profile) return profileId ? `Unknown profile ${profileId}` : "Unknown profile"; + return [profile.manufacturer, profile.model].filter(Boolean).join(" ") || profile.id || profileId || "Fixture profile"; +} + +function fixtureLabel(doc, fixture) { + const position = doc.positions?.[fixture.positionId]; + const unit = fixture.unitNumber == null ? fixture.id : `U${fixture.unitNumber}`; + return [position?.name, unit].filter(Boolean).join(" ") || fixture.id; +} + +function hasAnyPatchField(fixture) { + const dmx = fixture?.dmx; + if (!dmx) return false; + return dmx.universe != null || dmx.address != null; +} + +function hasCompletePatch(fixture) { + const dmx = fixture?.dmx; + return dmx?.universe != null && dmx?.address != null; +} + +function buildFixtureRange(doc, fixture) { + const profile = getProfile(fixture.profileId, doc.fixtureProfiles); + const footprint = Math.max(1, Number(profile?.dmxFootprint ?? 1) || 1); + const universe = parseWholeNumber(fixture.dmx?.universe); + const startAddress = parseWholeNumber(fixture.dmx?.address); + const endAddress = startAddress + footprint - 1; + const valid = Number.isInteger(universe) + && Number.isInteger(startAddress) + && universe >= 1 + && startAddress >= 1 + && endAddress <= DMX_SLOT_COUNT; + + return { + fixtureId: fixture.id, + fixtureLabel: fixtureLabel(doc, fixture), + profileId: fixture.profileId, + profileLabel: profileLabel(profile, fixture.profileId), + universe, + startAddress, + endAddress, + footprint, + valid, + reason: valid ? "" : invalidRangeReason({ universe, startAddress, endAddress }), + }; +} + +function invalidRangeReason(range) { + if (!Number.isInteger(range.universe) || !Number.isInteger(range.startAddress)) { + return "DMX universe and address must be whole numbers."; + } + if (range.universe < 1 || range.startAddress < 1) { + return "DMX universe and address must be positive."; + } + if (range.endAddress > DMX_SLOT_COUNT) { + return `DMX range ${range.startAddress}-${range.endAddress} exceeds slot ${DMX_SLOT_COUNT}.`; + } + return "DMX range is invalid."; +} + +function findOverlapErrors(ranges) { + const byUniverse = new Map(); + for (const range of ranges.filter(item => item.valid)) { + const list = byUniverse.get(range.universe) || []; + list.push(range); + byUniverse.set(range.universe, list); + } + + const errors = []; + for (const [universe, list] of byUniverse) { + list.sort((a, b) => a.startAddress - b.startAddress || a.fixtureId.localeCompare(b.fixtureId)); + for (let i = 1; i < list.length; i += 1) { + for (let j = 0; j < i; j += 1) { + if (list[i].startAddress <= list[j].endAddress) { + errors.push({ + code: "overlap", + severity: "error", + universe, + fixtureIds: [list[j].fixtureId, list[i].fixtureId], + message: `DMX U${universe} ${list[j].startAddress}-${list[j].endAddress} overlaps ${list[i].startAddress}-${list[i].endAddress}.`, + }); + } + } + } + } + return errors; +} + +function normalizeIntent(intent) { + if (intent === DMX_OUTPUT_INTENTS.blackout) return DMX_OUTPUT_INTENTS.blackout; + return DMX_OUTPUT_INTENTS.selectedFixtureTest; +} + +function clampChannelValue(value, channel) { + const fallback = Number(channel.default ?? 0); + const parsed = Number(value ?? fallback); + const min = Number.isFinite(channel.safeMin) ? channel.safeMin : 0; + const max = Number.isFinite(channel.safeMax) ? channel.safeMax : 255; + if (!Number.isFinite(parsed)) return Math.max(min, Math.min(max, fallback)); + return Math.max(min, Math.min(max, Math.round(parsed))); +} + +function valueForChannel(channel, values) { + if (Object.hasOwn(values, channel.type)) return values[channel.type]; + return channel.default ?? 0; +} + +function rangeStatus(range, outputProfile, isSelected, blocked) { + if (!range.valid) return "invalid"; + if (blocked) return "blocked"; + if (!outputProfile) return "unmapped"; + return isSelected ? "selected" : "ready"; +} + +function createUniverseReport(universe, ranges, blocked) { + return { + universe, + blocked, + slots: Array(DMX_SLOT_COUNT).fill(0), + usedRanges: ranges + .filter(range => range.universe === universe) + .sort((a, b) => a.startAddress - b.startAddress || a.fixtureId.localeCompare(b.fixtureId)) + .map(range => ({ ...range })), + nonZeroSlots: [], + }; +} + +export function compileDmxOutput(doc, options = {}) { + const intent = normalizeIntent(options.intent); + const selectedFixtureId = options.selectedFixtureId || null; + const values = { ...DEFAULT_DMX_TEST_VALUES, ...(options.values || {}) }; + const warnings = []; + const errors = []; + const fixtures = []; + const ranges = []; + + for (const fixture of orderedFixtures(doc)) { + if (!hasAnyPatchField(fixture)) { + fixtures.push({ + fixtureId: fixture.id, + fixtureLabel: fixtureLabel(doc, fixture), + profileId: fixture.profileId, + outputStatus: "paperwork-only", + }); + continue; + } + + if (!hasCompletePatch(fixture)) { + const message = `${fixtureLabel(doc, fixture)} has an incomplete DMX patch.`; + errors.push({ + code: "incomplete-range", + severity: "error", + fixtureId: fixture.id, + message, + }); + fixtures.push({ + fixtureId: fixture.id, + fixtureLabel: fixtureLabel(doc, fixture), + profileId: fixture.profileId, + outputStatus: "invalid", + message, + }); + continue; + } + + const range = buildFixtureRange(doc, fixture); + const outputProfile = getFixtureOutputProfile(fixture.profileId, doc.fixtureProfiles); + const outputCandidate = getFixtureOutputCandidate(fixture.profileId, doc.fixtureProfiles); + ranges.push(range); + + if (!range.valid) { + errors.push({ + code: "invalid-range", + severity: "error", + fixtureId: fixture.id, + universe: Number.isInteger(range.universe) ? range.universe : null, + message: `${range.fixtureLabel}: ${range.reason}`, + }); + } + + if (!outputProfile) { + if (outputCandidate) { + warnings.push({ + code: "candidate-personality", + severity: "warning", + fixtureId: fixture.id, + universe: Number.isInteger(range.universe) ? range.universe : null, + message: `${range.fixtureLabel} has an imported output-map candidate that needs approval before output. Slots stay at zero.`, + }); + } else { + warnings.push({ + code: "unmapped-personality", + severity: "warning", + fixtureId: fixture.id, + universe: Number.isInteger(range.universe) ? range.universe : null, + message: `${range.fixtureLabel} uses ${range.profileLabel}, which has no DMX output map. Slots stay at zero.`, + }); + } + } else if (outputProfile.footprint !== range.footprint) { + warnings.push({ + code: "footprint-mismatch", + severity: "warning", + fixtureId: fixture.id, + universe: Number.isInteger(range.universe) ? range.universe : null, + message: `${range.fixtureLabel} has a ${range.footprint}ch footprint, but the output map expects ${outputProfile.footprint}ch.`, + }); + } + + fixtures.push({ + fixtureId: fixture.id, + fixtureLabel: range.fixtureLabel, + profileId: fixture.profileId, + profileLabel: range.profileLabel, + universe: range.universe, + startAddress: range.startAddress, + endAddress: range.endAddress, + footprint: range.footprint, + outputStatus: rangeStatus(range, outputProfile, fixture.id === selectedFixtureId, false), + outputReady: Boolean(outputProfile) && range.valid, + outputMapLabel: outputProfile?.label || outputCandidate?.label || "", + outputMapStatus: outputProfile ? "output-ready" : outputCandidate ? "candidate" : "paperwork-only", + }); + } + + errors.push(...findOverlapErrors(ranges)); + const blocked = errors.length > 0; + const universes = [...new Set( + ranges + .filter(range => Number.isInteger(range.universe) && range.universe >= 1) + .map(range => range.universe), + )].sort((a, b) => a - b); + const universeReports = universes.map(universe => createUniverseReport(universe, ranges, blocked)); + + if (!blocked && intent !== DMX_OUTPUT_INTENTS.blackout) { + for (const range of ranges.filter(item => item.valid)) { + if (range.fixtureId !== selectedFixtureId) continue; + const outputProfile = getFixtureOutputProfile(range.profileId, doc.fixtureProfiles); + if (!outputProfile) continue; + const universeReport = universeReports.find(item => item.universe === range.universe); + if (!universeReport) continue; + + for (const channel of outputProfile.channels) { + if (channel.unsafe || channel.type === "raw") continue; + if (channel.slot < 1 || channel.slot > range.footprint) continue; + const address = range.startAddress + channel.slot - 1; + if (address < 1 || address > DMX_SLOT_COUNT) continue; + const value = clampChannelValue(valueForChannel(channel, values), channel); + universeReport.slots[address - 1] = value; + if (value > 0) { + universeReport.nonZeroSlots.push({ + address, + value, + fixtureId: range.fixtureId, + fixtureLabel: range.fixtureLabel, + type: channel.type, + label: channel.label, + explanation: explainOutputChannel(outputProfile, channel), + }); + } + } + } + } + + const fixturesById = new Map(fixtures.map(fixture => [fixture.fixtureId, fixture])); + for (const range of ranges) { + const outputProfile = getFixtureOutputProfile(range.profileId, doc.fixtureProfiles); + const outputCandidate = getFixtureOutputCandidate(range.profileId, doc.fixtureProfiles); + const fixture = fixturesById.get(range.fixtureId); + if (fixture) { + fixture.outputStatus = rangeStatus(range, outputProfile, range.fixtureId === selectedFixtureId, blocked); + fixture.outputMapStatus = outputProfile ? "output-ready" : outputCandidate ? "candidate" : "paperwork-only"; + } + for (const universeReport of universeReports) { + for (const usedRange of universeReport.usedRanges) { + if (usedRange.fixtureId !== range.fixtureId) continue; + usedRange.outputStatus = rangeStatus(range, outputProfile, range.fixtureId === selectedFixtureId, blocked); + usedRange.outputReady = Boolean(outputProfile) && range.valid; + usedRange.outputMapStatus = outputProfile ? "output-ready" : outputCandidate ? "candidate" : "paperwork-only"; + usedRange.outputMapLabel = outputProfile?.label || outputCandidate?.label || ""; + } + } + } + + return { + kind: "plotforge-dmx-output-preview", + version: DMX_OUTPUT_VERSION, + intent, + selectedFixtureId, + blocked, + errors, + warnings, + fixtures, + universes: universeReports, + }; +} + +export function summarizeDmxOutput(compiled) { + const nonZeroSlotCount = compiled.universes.reduce((count, universe) => count + universe.nonZeroSlots.length, 0); + const patchedFixtureCount = compiled.fixtures.filter(fixture => fixture.outputStatus !== "paperwork-only").length; + return { + blocked: compiled.blocked, + universeCount: compiled.universes.length, + patchedFixtureCount, + errorCount: compiled.errors.length, + warningCount: compiled.warnings.length, + nonZeroSlotCount, + }; +} diff --git a/src/domain/dmxOutputPreferences.js b/src/domain/dmxOutputPreferences.js new file mode 100644 index 0000000..eec3d91 --- /dev/null +++ b/src/domain/dmxOutputPreferences.js @@ -0,0 +1,95 @@ +export const DMX_OUTPUT_PREFERENCES_VERSION = 1; + +const SUPPORTED_PROTOCOLS = new Set(["artnet", "sacn"]); +const SUPPORTED_TARGET_MODES = new Set(["unicast", "multicast"]); + +function clampInteger(value, min, max, fallback) { + if (value == null || String(value).trim() === "") return fallback; + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + return Math.max(min, Math.min(max, Math.round(parsed))); +} + +function normalizeHost(value, fallback) { + const text = String(value ?? "").trim(); + return text || fallback; +} + +function normalizeProtocol(value) { + const protocol = String(value || "").trim().toLowerCase(); + return SUPPORTED_PROTOCOLS.has(protocol) ? protocol : "artnet"; +} + +function normalizeTargetMode(value) { + const mode = String(value || "").trim().toLowerCase(); + return SUPPORTED_TARGET_MODES.has(mode) ? mode : "unicast"; +} + +function normalizeBoolean(value, fallback) { + if (value == null) return fallback; + if (typeof value === "string") { + return !["0", "false", "no", "off"].includes(value.trim().toLowerCase()); + } + return Boolean(value); +} + +function normalizeUniverseRoute(route = {}, protocol = "artnet") { + const source = route && typeof route === "object" ? route : {}; + const isSacn = protocol === "sacn"; + return { + net: clampInteger(source.net, 0, 127, 0), + subNet: clampInteger(source.subNet, 0, 15, 0), + universe: clampInteger(source.universe, isSacn ? 1 : 0, isSacn ? 63999 : 15, isSacn ? 1 : 0), + targetHost: source.targetHost == null ? "" : normalizeHost(source.targetHost, ""), + }; +} + +export function defaultDmxOutputSettings() { + return { + version: DMX_OUTPUT_PREFERENCES_VERSION, + protocol: "artnet", + targetMode: "unicast", + relayUrl: "ws://127.0.0.1:8766", + targetHost: "127.0.0.1", + targetPort: 6454, + maxFps: 20, + blackoutOnDisconnect: true, + blackoutBurst: 5, + blackoutKeepAlive: false, + universes: { + "1": normalizeUniverseRoute(), + }, + }; +} + +export function normalizeDmxOutputSettings(settings = {}) { + const defaults = defaultDmxOutputSettings(); + const protocol = normalizeProtocol(settings.protocol); + const universes = {}; + const rawUniverses = settings.universes && typeof settings.universes === "object" + ? settings.universes + : {}; + + for (const [key, route] of Object.entries(rawUniverses)) { + const universeKey = String(clampInteger(key, 1, 32768, 1)); + universes[universeKey] = normalizeUniverseRoute(route, protocol); + } + + if (Object.keys(universes).length === 0) { + universes["1"] = normalizeUniverseRoute({}, protocol); + } + + return { + version: DMX_OUTPUT_PREFERENCES_VERSION, + protocol, + targetMode: protocol === "sacn" ? normalizeTargetMode(settings.targetMode) : "unicast", + relayUrl: normalizeHost(settings.relayUrl, defaults.relayUrl), + targetHost: normalizeHost(settings.targetHost, defaults.targetHost), + targetPort: clampInteger(settings.targetPort, 1, 65535, protocol === "sacn" ? 5568 : defaults.targetPort), + maxFps: clampInteger(settings.maxFps, 1, 44, defaults.maxFps), + blackoutOnDisconnect: normalizeBoolean(settings.blackoutOnDisconnect, defaults.blackoutOnDisconnect), + blackoutBurst: clampInteger(settings.blackoutBurst, 1, 20, defaults.blackoutBurst), + blackoutKeepAlive: normalizeBoolean(settings.blackoutKeepAlive, defaults.blackoutKeepAlive), + universes, + }; +} diff --git a/src/domain/dmxRelay.js b/src/domain/dmxRelay.js new file mode 100644 index 0000000..b9de8d6 --- /dev/null +++ b/src/domain/dmxRelay.js @@ -0,0 +1,595 @@ +import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; +import dgram from "node:dgram"; +import http from "node:http"; +import { + ARTNET_DMX_MAX_LENGTH, + ARTNET_DMX_MIN_LENGTH, + ARTNET_PORT, + encodeArtDmx, + validatePortAddress, +} from "./artnet.js"; +import { + SACN_DEFAULT_PRIORITY, + SACN_DEFAULT_SOURCE_NAME, + SACN_MAX_DMX_SLOTS, + SACN_PORT, + SacnError, + encodeSacnDmx, + parseSacnCid, + sacnMulticastAddress, + validateSacnUniverse, +} from "./sacn.js"; + +export const DMX_RELAY_VERSION = "0.1.0"; +export const DMX_RELAY_DEFAULT_ORIGINS = Object.freeze([ + "http://localhost:5173", + "http://127.0.0.1:5173", +]); + +export class DmxRelayError extends Error { + constructor(message, code, detail = "") { + super(message); + this.name = "DmxRelayError"; + this.code = code; + this.detail = detail; + } +} + +function parseInteger(value, fallback) { + const parsed = Number.parseInt(String(value ?? ""), 10); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function parseBooleanFlag(value, fallback = false) { + if (value == null || value === "") return fallback; + return !["0", "false", "no", "off"].includes(String(value).trim().toLowerCase()); +} + +function parseCsv(value, fallback) { + if (!value) return fallback; + const parsed = String(value).split(",").map(item => item.trim()).filter(Boolean); + return parsed.length ? parsed : fallback; +} + +function normalizeHost(host, fallback) { + const value = String(host || fallback || "").trim(); + return value || fallback; +} + +function randomToken() { + return randomBytes(18).toString("base64url"); +} + +export function dmxRelayConfigFromEnv(env = process.env) { + return { + bindHost: normalizeHost(env.PLOTFORGE_DMX_BIND_HOST, "127.0.0.1"), + wsPort: parseInteger(env.PLOTFORGE_DMX_WS_PORT, 8766), + defaultTargetHost: normalizeHost(env.PLOTFORGE_DMX_TARGET_HOST, "127.0.0.1"), + defaultTargetPort: parseInteger(env.PLOTFORGE_DMX_TARGET_PORT, ARTNET_PORT), + sacnCid: normalizeHost(env.PLOTFORGE_DMX_SACN_CID, randomUUID()), + sacnSourceName: normalizeHost(env.PLOTFORGE_DMX_SACN_SOURCE_NAME, SACN_DEFAULT_SOURCE_NAME), + maxFps: Math.min(44, Math.max(1, parseInteger(env.PLOTFORGE_DMX_MAX_FPS, 20))), + blackoutBurst: Math.max(1, parseInteger(env.PLOTFORGE_DMX_BLACKOUT_BURST, 5)), + heartbeatTimeoutMs: Math.max(100, parseInteger(env.PLOTFORGE_DMX_HEARTBEAT_TIMEOUT_MS, 1000)), + allowPublicBind: parseBooleanFlag(env.PLOTFORGE_DMX_ALLOW_PUBLIC_BIND, false), + allowPublicTargets: parseBooleanFlag(env.PLOTFORGE_DMX_ALLOW_PUBLIC_TARGETS, false), + allowedOrigins: parseCsv(env.PLOTFORGE_DMX_ALLOWED_ORIGINS, DMX_RELAY_DEFAULT_ORIGINS), + token: env.PLOTFORGE_DMX_TOKEN || randomToken(), + requireToken: parseBooleanFlag(env.PLOTFORGE_DMX_REQUIRE_TOKEN, true), + }; +} + +function protocolError(error, fallbackCode = "send-failed") { + if (error instanceof DmxRelayError) return error; + if (error instanceof SacnError) return new DmxRelayError(error.message, error.code); + return new DmxRelayError(error instanceof Error ? error.message : String(error), fallbackCode); +} + +function assertPublicBindAllowed(config) { + if (config.allowPublicBind) return; + if (config.bindHost === "127.0.0.1" || config.bindHost === "localhost") return; + throw new DmxRelayError("DMX relay refuses public binding unless PLOTFORGE_DMX_ALLOW_PUBLIC_BIND=1.", "public-bind"); +} + +export function isAllowedOrigin(origin, allowedOrigins = DMX_RELAY_DEFAULT_ORIGINS) { + if (!origin) return false; + return allowedOrigins.includes(origin); +} + +export function isPrivateTargetHost(host) { + const value = String(host || "").trim().toLowerCase(); + if (value === "localhost") return true; + const match = value.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (!match) return false; + const octets = match.slice(1).map(Number); + if (octets.some(octet => !Number.isInteger(octet) || octet < 0 || octet > 255)) return false; + const [a, b] = octets; + return a === 10 + || a === 127 + || a === 169 && b === 254 + || a === 172 && b >= 16 && b <= 31 + || a === 192 && b === 168; +} + +function safeTokenEquals(actual, expected, requireToken) { + if (!requireToken) return true; + const actualString = String(actual ?? ""); + const expectedString = String(expected ?? ""); + if (!expectedString) return false; + const expectedBuffer = Buffer.from(expectedString); + const actualBuffer = Buffer.alloc(expectedBuffer.length); + Buffer.from(actualString).copy(actualBuffer, 0, 0, expectedBuffer.length); + return timingSafeEqual(actualBuffer, expectedBuffer) && actualString.length === expectedString.length; +} + +export function webSocketTextFrame(text) { + const payload = Buffer.from(String(text)); + if (payload.length > 65535) { + throw new DmxRelayError("WebSocket payload is too large for this relay.", "large-websocket-payload"); + } + if (payload.length > 125) { + const frame = Buffer.alloc(4 + payload.length); + frame[0] = 0x81; + frame[1] = 126; + frame.writeUInt16BE(payload.length, 2); + payload.copy(frame, 4); + return frame; + } + const frame = Buffer.alloc(2 + payload.length); + frame[0] = 0x81; + frame[1] = payload.length; + payload.copy(frame, 2); + return frame; +} + +export function decodeWebSocketFrames(buffer) { + const messages = []; + let offset = 0; + while (offset + 2 <= buffer.length) { + const frameStart = offset; + const opcode = buffer[offset] & 0x0f; + let length = buffer[offset + 1] & 0x7f; + const masked = (buffer[offset + 1] & 0x80) !== 0; + offset += 2; + if (length === 126) { + if (offset + 2 > buffer.length) return { messages, remaining: buffer.subarray(frameStart) }; + length = buffer.readUInt16BE(offset); + offset += 2; + } else if (length === 127) { + throw new DmxRelayError("Large WebSocket frames are not supported.", "large-websocket-frame"); + } + if (!masked) throw new DmxRelayError("Client WebSocket frames must be masked.", "unmasked-websocket-frame"); + if (offset + 4 + length > buffer.length) return { messages, remaining: buffer.subarray(frameStart) }; + const mask = buffer.subarray(offset, offset + 4); + offset += 4; + const payload = Buffer.alloc(length); + for (let index = 0; index < length; index += 1) { + payload[index] = buffer[offset + index] ^ mask[index % 4]; + } + offset += length; + if (opcode === 1) messages.push(payload.toString("utf8")); + } + return { messages, remaining: buffer.subarray(offset) }; +} + +function decodeBase64Payload(value) { + if (typeof value !== "string" || value.length === 0) { + throw new DmxRelayError("DMX frame data must be base64.", "invalid-data"); + } + const payload = Buffer.from(value, "base64"); + if (payload.length === 0 && value !== "") { + throw new DmxRelayError("DMX frame data could not be decoded.", "invalid-data"); + } + return payload; +} + +function validateTarget(target, config) { + const host = normalizeHost(target?.host, ""); + const port = validateTargetPort(target?.port); + if (!host || !Number.isInteger(port) || port < 1 || port > 65535) { + throw new DmxRelayError("DMX target host and port are required.", "invalid-target"); + } + if (!config.allowPublicTargets && !isPrivateTargetHost(host)) { + throw new DmxRelayError("DMX target must be a local or private-network host.", "public-target", host); + } + return { host, port }; +} + +function validateTargetPort(port, fallback) { + const value = Number(port ?? fallback); + if (!Number.isInteger(value) || value < 1 || value > 65535) { + throw new DmxRelayError("DMX target host and port are required.", "invalid-target"); + } + return value; +} + +function sacnTargetMode(command) { + if (command?.targetMode === "multicast" || command?.multicast === true) return "multicast"; + return "unicast"; +} + +function validateSacnTarget(command, config, universe) { + const targetMode = sacnTargetMode(command); + if (targetMode !== "multicast") { + return { targetMode, target: validateTarget(command.target, config) }; + } + + const host = sacnMulticastAddress(universe); + const requestedHost = String(command.target?.host ?? "").trim(); + if (requestedHost && requestedHost !== host) { + throw new DmxRelayError("sACN multicast target host is derived from the universe.", "invalid-target"); + } + return { + targetMode, + target: { + host, + port: validateTargetPort(command.target?.port, SACN_PORT), + }, + }; +} + +export function validateFrameCommand(command, config = dmxRelayConfigFromEnv({})) { + const protocol = String(command?.protocol || "").trim().toLowerCase(); + if (protocol !== "artnet" && protocol !== "sacn") { + throw new DmxRelayError("Only Art-Net and sACN frames are supported.", "unsupported-protocol"); + } + if (!safeTokenEquals(command.token, config.token, config.requireToken)) { + throw new DmxRelayError("Bad relay token.", "bad-token"); + } + const length = Number(command.length); + if (!Number.isInteger(length)) { + throw new DmxRelayError("DMX frame length must be an integer.", "invalid-length"); + } + const data = decodeBase64Payload(command.data); + if (data.length !== length) { + throw new DmxRelayError(`Decoded DMX data length ${data.length} does not match ${length}.`, "data-length"); + } + const sequence = command.sequence == null ? 0 : Number(command.sequence); + if (!Number.isInteger(sequence) || sequence < 0 || sequence > 255) { + throw new DmxRelayError("DMX frame sequence must be from 0 to 255.", "invalid-sequence"); + } + + if (protocol === "artnet") { + const target = validateTarget(command.target, config); + let portAddress; + try { + portAddress = validatePortAddress(command.portAddress); + } catch (error) { + throw new DmxRelayError(error.message, error.code || "invalid-port-address"); + } + if (length % 2 !== 0) { + throw new DmxRelayError(`DMX frame length ${length} is odd.`, "odd-length"); + } + if (length < ARTNET_DMX_MIN_LENGTH || length > ARTNET_DMX_MAX_LENGTH) { + throw new DmxRelayError("DMX frame length must be from 2 to 512.", "oversized-payload"); + } + const physical = command.physical == null ? 0 : Number(command.physical); + return { protocol, target, portAddress, length, data, sequence, physical }; + } + + if (length < 1 || length > SACN_MAX_DMX_SLOTS) { + throw new DmxRelayError("sACN frame length must be from 1 to 512.", "invalid-data-length"); + } + + try { + const universe = validateSacnUniverse(command.universe); + const { targetMode, target } = validateSacnTarget(command, config, universe); + return { + protocol, + target, + targetMode, + universe, + cid: parseSacnCid(command.cid || config.sacnCid), + sourceName: normalizeHost(command.sourceName, config.sacnSourceName), + priority: command.priority == null ? SACN_DEFAULT_PRIORITY : Number(command.priority), + synchronizationAddress: command.synchronizationAddress == null ? 0 : Number(command.synchronizationAddress), + preview: Boolean(command.preview), + length, + data, + sequence, + }; + } catch (error) { + throw protocolError(error, "invalid-sacn-frame"); + } +} + +function activeOutputKey(output) { + if (output.protocol === "sacn") { + return ["sacn", output.targetMode, output.target.host, output.target.port, output.universe].join(":"); + } + return [ + "artnet", + output.target.host, + output.target.port, + output.portAddress.net, + output.portAddress.subNet, + output.portAddress.universe, + ].join(":"); +} + +function sendJson(socket, payload) { + socket.write(webSocketTextFrame(JSON.stringify(payload))); +} + +function defaultUdpSend(udp) { + return (packet, port, host) => new Promise((resolve, reject) => { + udp.send(packet, port, host, error => error ? reject(error) : resolve(packet.length)); + }); +} + +async function emitArtNetFrame(session, output, data, sequence = 0) { + const packet = Buffer.from(encodeArtDmx({ + portAddress: output.portAddress, + data, + sequence, + physical: output.physical || 0, + })); + await session.sendUdp(packet, output.target.port, output.target.host); + return packet.length; +} + +async function emitSacnFrame(session, output, data, sequence = 0) { + const packet = Buffer.from(encodeSacnDmx({ + cid: output.cid, + sourceName: output.sourceName, + universe: output.universe, + data, + priority: output.priority, + sequence, + synchronizationAddress: output.synchronizationAddress, + preview: output.preview, + })); + await session.sendUdp(packet, output.target.port || SACN_PORT, output.target.host); + return packet.length; +} + +async function emitFrame(session, output, data, sequence = 0) { + if (output.protocol === "sacn") return emitSacnFrame(session, output, data, sequence); + return emitArtNetFrame(session, output, data, sequence); +} + +async function emitBlackoutBurst(session) { + const outputs = [...session.activeOutputs.values()]; + let framesSent = 0; + for (let burst = 0; burst < session.config.blackoutBurst; burst += 1) { + for (const output of outputs) { + const bytes = await emitFrame(session, output, Buffer.alloc(output.length), 0); + framesSent += bytes > 0 ? 1 : 0; + } + } + return framesSent; +} + +function clearWatchdog(session) { + if (session.watchdog) clearTimeout(session.watchdog); + session.watchdog = null; +} + +function armWatchdog(session) { + clearWatchdog(session); + if (session.activeOutputs.size === 0) return; + session.watchdog = setTimeout(async () => { + try { + await emitBlackoutBurst(session); + session.activeOutputs.clear(); + sendJson(session.socket, { type: "fault", reason: "heartbeat-timeout" }); + } catch (error) { + sendJson(session.socket, { + type: "fault", + reason: "heartbeat-timeout-blackout-failed", + detail: error instanceof Error ? error.message : String(error), + }); + } finally { + clearWatchdog(session); + } + }, session.config.heartbeatTimeoutMs); +} + +function handleAuth(session, command) { + if (command.protocolVersion !== 1) { + sendJson(session.socket, { type: "auth.error", reason: "unsupported-version" }); + session.socket.end(); + return; + } + if (!safeTokenEquals(command.token, session.config.token, session.config.requireToken)) { + sendJson(session.socket, { type: "auth.error", reason: "bad-token" }); + session.socket.end(); + return; + } + session.authenticated = true; + sendJson(session.socket, { + type: "auth.ok", + relayVersion: DMX_RELAY_VERSION, + maxFps: session.config.maxFps, + boundHost: session.config.bindHost, + }); +} + +async function handleCommand(session, command) { + if (!session.authenticated) { + sendJson(session.socket, { type: "auth.error", reason: "auth-required" }); + session.socket.end(); + return; + } + if (!safeTokenEquals(command.token, session.config.token, session.config.requireToken)) { + sendJson(session.socket, { type: `${command.type || "command"}.error`, reason: "bad-token" }); + return; + } + if (command.type === "heartbeat") { + armWatchdog(session); + sendJson(session.socket, { type: "heartbeat.ok" }); + return; + } + if (command.type === "blackout") { + const framesSent = await emitBlackoutBurst(session); + session.activeOutputs.clear(); + clearWatchdog(session); + sendJson(session.socket, { type: "blackout.ok", framesSent }); + return; + } + if (command.type !== "frame") { + sendJson(session.socket, { type: "command.error", reason: "unsupported-command" }); + return; + } + + try { + const frame = validateFrameCommand(command, session.config); + const bytesSent = await emitFrame(session, frame, frame.data, frame.sequence); + session.activeOutputs.set(activeOutputKey(frame), frame); + armWatchdog(session); + sendJson(session.socket, frame.protocol === "sacn" + ? { type: "frame.ok", protocol: frame.protocol, targetMode: frame.targetMode, universe: frame.universe, target: frame.target, bytesSent } + : { type: "frame.ok", protocol: frame.protocol, portAddress: frame.portAddress, bytesSent }); + } catch (error) { + const relayError = protocolError(error); + sendJson(session.socket, { + type: "frame.error", + reason: relayError.code, + detail: relayError.detail || relayError.message, + }); + } +} + +function acceptWebSocket(request, socket) { + const key = request.headers["sec-websocket-key"]; + if (!key) { + socket.destroy(); + return false; + } + const accept = createHash("sha1") + .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`) + .digest("base64"); + socket.write([ + "HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Accept: ${accept}`, + "", + "", + ].join("\r\n")); + return true; +} + +export function createDmxRelayServer(options = {}) { + const config = { + ...dmxRelayConfigFromEnv({}), + ...options, + allowedOrigins: options.allowedOrigins || DMX_RELAY_DEFAULT_ORIGINS, + }; + assertPublicBindAllowed(config); + const udp = options.sendUdp ? null : dgram.createSocket("udp4"); + const sendUdp = options.sendUdp || defaultUdpSend(udp); + const sessions = new Set(); + + const server = http.createServer((request, response) => { + if (request.url === "/health") { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ + ok: true, + service: "plotforge-dmx-relay", + relayVersion: DMX_RELAY_VERSION, + boundHost: config.bindHost, + maxFps: config.maxFps, + requireToken: config.requireToken, + })); + return; + } + response.writeHead(404); + response.end(); + }); + + server.on("upgrade", (request, socket) => { + const origin = request.headers.origin || ""; + if (!isAllowedOrigin(origin, config.allowedOrigins)) { + socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n"); + socket.destroy(); + return; + } + if (!acceptWebSocket(request, socket)) return; + + const session = { + socket, + config, + sendUdp, + authenticated: false, + pending: Buffer.alloc(0), + activeOutputs: new Map(), + watchdog: null, + closed: false, + }; + sessions.add(session); + + async function closeSession({ blackout = false } = {}) { + if (session.closed) return; + session.closed = true; + clearWatchdog(session); + if (blackout && session.activeOutputs.size > 0) { + try { + await emitBlackoutBurst(session); + } catch { + // The session is already closing; the UI will fault on disconnect. + } + } + session.activeOutputs.clear(); + sessions.delete(session); + } + + socket.on("data", buffer => { + try { + const decoded = decodeWebSocketFrames(Buffer.concat([session.pending, buffer])); + session.pending = decoded.remaining; + for (const payload of decoded.messages) { + const command = JSON.parse(payload); + if (command.type === "auth" && !session.authenticated) { + handleAuth(session, command); + } else { + void handleCommand(session, command); + } + } + } catch (error) { + sendJson(session.socket, { + type: "command.error", + reason: error instanceof DmxRelayError ? error.code : "bad-json", + detail: error instanceof Error ? error.message : String(error), + }); + } + }); + + socket.on("close", () => { + void closeSession({ blackout: session.authenticated }); + }); + socket.on("error", () => { + void closeSession({ blackout: session.authenticated }); + }); + }); + + async function close() { + for (const session of sessions) { + clearWatchdog(session); + if (session.activeOutputs.size > 0) { + try { + await emitBlackoutBurst(session); + } catch { + // Shutdown cleanup cannot report to a closed client; keep closing. + } + } + session.activeOutputs.clear(); + session.socket.destroy(); + } + await new Promise(resolve => server.close(resolve)); + if (udp) udp.close(); + } + + return { server, close, config, token: config.token }; +} + +export function startDmxRelay(options = {}) { + const relay = createDmxRelayServer(options); + return new Promise((resolve, reject) => { + relay.server.once("error", reject); + relay.server.listen(relay.config.wsPort, relay.config.bindHost, () => { + relay.server.off("error", reject); + resolve(relay); + }); + }); +} diff --git a/src/domain/fixtureOutputProfiles.js b/src/domain/fixtureOutputProfiles.js new file mode 100644 index 0000000..751436e --- /dev/null +++ b/src/domain/fixtureOutputProfiles.js @@ -0,0 +1,337 @@ +export const FIXTURE_OUTPUT_SCHEMA_VERSION = 1; + +export const OUTPUT_CHANNEL_TYPES = Object.freeze([ + "intensity", + "red", + "green", + "blue", + "white", + "amber", + "uv", + "panCoarse", + "panFine", + "tiltCoarse", + "tiltFine", + "colorWheel", + "goboWheel", + "shutter", + "strobe", + "reset", + "raw", +]); + +export const DEFAULT_DMX_TEST_VALUES = Object.freeze({ + intensity: 255, + red: 255, + green: 255, + blue: 255, + white: 0, + amber: 0, + uv: 0, +}); + +const KNOWN_TYPES = new Set(OUTPUT_CHANNEL_TYPES); + +function channel({ slot, label, type, defaultValue = 0, safeMin = 0, safeMax = 255, unsafe = false, source = "generic" }) { + return { + slot, + label, + type: KNOWN_TYPES.has(type) ? type : "raw", + default: unsafe ? 0 : defaultValue, + safeMin, + safeMax: unsafe ? 0 : safeMax, + unsafe, + source, + }; +} + +function outputProfile({ + id, + profileIds, + label, + fixtureClass, + footprint, + channels, + sourceType = "generic", + approved = true, + confidence = "high", + notes = [], +}) { + return { + id, + schemaVersion: FIXTURE_OUTPUT_SCHEMA_VERSION, + profileIds, + label, + fixtureClass, + footprint, + channels, + source: { type: sourceType, approved, confidence }, + notes, + }; +} + +function dimmerMap(id, profileIds, label) { + return outputProfile({ + id, + profileIds, + label, + fixtureClass: "dimmer", + footprint: 1, + channels: [ + channel({ slot: 1, label: "Dimmer", type: "intensity" }), + ], + }); +} + +function rgbMap(id, profileIds, label) { + return outputProfile({ + id, + profileIds, + label, + fixtureClass: "rgb", + footprint: 3, + channels: [ + channel({ slot: 1, label: "Red", type: "red" }), + channel({ slot: 2, label: "Green", type: "green" }), + channel({ slot: 3, label: "Blue", type: "blue" }), + ], + }); +} + +function rgbwMap(id, profileIds, label) { + return outputProfile({ + id, + profileIds, + label, + fixtureClass: "rgbw", + footprint: 4, + channels: [ + channel({ slot: 1, label: "Red", type: "red" }), + channel({ slot: 2, label: "Green", type: "green" }), + channel({ slot: 3, label: "Blue", type: "blue" }), + channel({ slot: 4, label: "White", type: "white" }), + ], + }); +} + +function ledRgbw8Map(id, profileIds, label) { + return outputProfile({ + id, + profileIds, + label, + fixtureClass: "rgbw-dimmer", + footprint: 8, + channels: [ + channel({ slot: 1, label: "Dimmer", type: "intensity" }), + channel({ slot: 2, label: "Red", type: "red" }), + channel({ slot: 3, label: "Green", type: "green" }), + channel({ slot: 4, label: "Blue", type: "blue" }), + channel({ slot: 5, label: "White", type: "white" }), + channel({ slot: 6, label: "Strobe", type: "strobe", unsafe: true }), + channel({ slot: 7, label: "Macro", type: "raw", unsafe: true }), + channel({ slot: 8, label: "Control", type: "raw", unsafe: true }), + ], + }); +} + +function ledBarRgbw16Map(id, profileIds, label) { + return outputProfile({ + id, + profileIds, + label, + fixtureClass: "rgbw-bar", + footprint: 16, + channels: Array.from({ length: 4 }, (_item, pixel) => { + const base = pixel * 4; + const source = `pixel ${pixel + 1}`; + return [ + channel({ slot: base + 1, label: `${source} Red`, type: "red", source }), + channel({ slot: base + 2, label: `${source} Green`, type: "green", source }), + channel({ slot: base + 3, label: `${source} Blue`, type: "blue", source }), + channel({ slot: base + 4, label: `${source} White`, type: "white", source }), + ]; + }).flat(), + }); +} + +function simpleMovingSpotMap(id, profileIds, label) { + return outputProfile({ + id, + profileIds, + label, + fixtureClass: "simple-moving-spot", + footprint: 24, + channels: [ + channel({ slot: 1, label: "Dimmer", type: "intensity" }), + channel({ slot: 2, label: "Pan coarse", type: "panCoarse", unsafe: true }), + channel({ slot: 3, label: "Pan fine", type: "panFine", unsafe: true }), + channel({ slot: 4, label: "Tilt coarse", type: "tiltCoarse", unsafe: true }), + channel({ slot: 5, label: "Tilt fine", type: "tiltFine", unsafe: true }), + channel({ slot: 6, label: "Color wheel", type: "colorWheel", unsafe: true }), + channel({ slot: 7, label: "Gobo wheel", type: "goboWheel", unsafe: true }), + channel({ slot: 8, label: "Shutter", type: "shutter", unsafe: true }), + channel({ slot: 9, label: "Strobe", type: "strobe", unsafe: true }), + channel({ slot: 10, label: "Reset", type: "reset", unsafe: true }), + ], + notes: ["Movement, wheel, shutter, strobe, and reset channels default to zero until a per-session unsafe-channel gate exists."], + }); +} + +const GENERIC_OUTPUT_PROFILE_LIST = [ + dimmerMap("generic-dimmer-1ch", ["s4_26", "s4_19", "s4_36", "s4_50", "fresnel", "par64"], "Generic 1ch dimmer"), + rgbMap("generic-rgb-3ch", [], "Generic RGB 3ch"), + rgbwMap("generic-rgbw-4ch", ["cyc_strip"], "Generic RGBW 4ch"), + ledRgbw8Map("generic-rgbw-dimmer-8ch", ["led_par_rgbw"], "Generic RGBW 8ch with dimmer"), + ledBarRgbw16Map("generic-rgbw-bar-16ch", ["led_bar_rgbw"], "Generic RGBW 16ch bar"), + simpleMovingSpotMap("generic-moving-spot-24ch", ["spot_mh"], "Generic simple moving spot 24ch"), +]; + +export const GENERIC_OUTPUT_PROFILES = Object.freeze(Object.fromEntries( + GENERIC_OUTPUT_PROFILE_LIST.map(profile => [profile.id, Object.freeze(profile)]), +)); + +const OUTPUT_PROFILE_BY_PROFILE_ID = new Map(); +for (const profile of GENERIC_OUTPUT_PROFILE_LIST) { + for (const profileId of profile.profileIds) OUTPUT_PROFILE_BY_PROFILE_ID.set(profileId, profile); +} + +function normalizeChannelDescriptor(raw, index, source = "custom") { + const slot = Number(raw.slot ?? index + 1); + if (!Number.isInteger(slot) || slot < 1) return null; + return channel({ + slot, + label: raw.label || raw.name || `Slot ${slot}`, + type: raw.type || raw.attribute || "raw", + defaultValue: Number(raw.default ?? raw.defaultValue ?? 0) || 0, + safeMin: Number.isFinite(Number(raw.safeMin)) ? Number(raw.safeMin) : 0, + safeMax: Number.isFinite(Number(raw.safeMax)) ? Number(raw.safeMax) : 255, + unsafe: Boolean(raw.unsafe), + source, + }); +} + +export function normalizeOutputProfileCandidate(candidate, profileId) { + if (!candidate || typeof candidate !== "object") return null; + const rawChannels = Array.isArray(candidate.channels) ? candidate.channels : []; + const channels = rawChannels + .map((raw, index) => normalizeChannelDescriptor(raw, index, candidate.source?.type || "custom")) + .filter(Boolean) + .sort((a, b) => a.slot - b.slot); + if (channels.length === 0) return null; + return { + id: candidate.id || `${profileId}-output-map`, + schemaVersion: Number(candidate.schemaVersion) || FIXTURE_OUTPUT_SCHEMA_VERSION, + profileIds: candidate.profileIds || [profileId], + label: candidate.label || `${profileId} output map`, + fixtureClass: candidate.fixtureClass || "custom", + footprint: Math.max(...channels.map(item => item.slot), Number(candidate.footprint || 1)), + channels, + source: { + type: candidate.source?.type || "custom-candidate", + approved: Boolean(candidate.source?.approved), + confidence: candidate.source?.confidence || "candidate", + }, + notes: Array.isArray(candidate.notes) ? candidate.notes : [], + }; +} + +export function getFixtureOutputProfile(profileId, customProfiles = null) { + const customCandidate = normalizeOutputProfileCandidate(customProfiles?.[profileId]?.outputMap, profileId); + if (customCandidate?.source.approved) return customCandidate; + return OUTPUT_PROFILE_BY_PROFILE_ID.get(profileId) || null; +} + +export function getFixtureOutputCandidate(profileId, customProfiles = null) { + const customCandidate = normalizeOutputProfileCandidate(customProfiles?.[profileId]?.outputMap, profileId); + if (customCandidate && !customCandidate.source.approved) return customCandidate; + return null; +} + +function channelTypeFromName(name) { + const text = String(name || "").toLowerCase(); + if (text.includes("dimmer") || text.includes("intensity") || text === "master") return "intensity"; + if (/\bred\b|\br\b/.test(text)) return "red"; + if (/\bgreen\b|\bg\b/.test(text)) return "green"; + if (/\bblue\b|\bb\b/.test(text)) return "blue"; + if (/\bwhite\b|\bw\b/.test(text)) return "white"; + if (text.includes("amber") || /\ba\b/.test(text)) return "amber"; + if (text.includes("uv") || text.includes("ultraviolet")) return "uv"; + if (text.includes("pan fine")) return "panFine"; + if (text.includes("pan")) return "panCoarse"; + if (text.includes("tilt fine")) return "tiltFine"; + if (text.includes("tilt")) return "tiltCoarse"; + if (text.includes("gobo")) return "goboWheel"; + if (text.includes("color")) return "colorWheel"; + if (text.includes("shutter")) return "shutter"; + if (text.includes("strobe")) return "strobe"; + if (text.includes("reset")) return "reset"; + return "raw"; +} + +function slugify(value) { + return String(value ?? "fixture") + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + .slice(0, 64) || "fixture"; +} + +function isUnsafeType(type) { + return ["panCoarse", "panFine", "tiltCoarse", "tiltFine", "colorWheel", "goboWheel", "shutter", "strobe", "reset", "raw"].includes(type); +} + +function channelLabel(rawChannel, index) { + if (typeof rawChannel === "string") return rawChannel; + if (rawChannel?.name) return rawChannel.name; + if (rawChannel?.capability?.type) return rawChannel.capability.type; + if (rawChannel?.fineChannelAliases?.length) return rawChannel.fineChannelAliases[0]; + return `Slot ${index + 1}`; +} + +export function inferOutputProfileFromOpenFixtureLibrary(oflFixture, options = {}) { + const mode = Array.isArray(oflFixture?.modes) ? oflFixture.modes[0] : null; + const rawChannels = Array.isArray(mode?.channels) ? mode.channels : []; + if (!mode || rawChannels.length === 0) return null; + const channels = rawChannels.map((rawChannel, index) => { + const label = channelLabel(rawChannel, index); + const type = channelTypeFromName(label); + return channel({ + slot: index + 1, + label, + type, + unsafe: isUnsafeType(type), + source: "ofl-channel-name", + }); + }); + const useful = channels.filter(item => item.type !== "raw").length; + if (useful === 0) return null; + const manufacturerKey = options.manufacturerKey || oflFixture.manufacturerKey || "ofl"; + const fixtureKey = options.fixtureKey || oflFixture.fixtureKey || oflFixture.name || "fixture"; + const profileId = options.profileId || `ofl_${slugify(manufacturerKey)}_${slugify(fixtureKey)}`; + return outputProfile({ + id: `${profileId}-candidate-output-map`, + profileIds: [profileId], + label: `${oflFixture.manufacturer || options.manufacturerName || "OFL"} ${oflFixture.name || options.fixtureKey || "Fixture"} candidate map`, + fixtureClass: "ofl-candidate", + footprint: channels.length, + channels, + sourceType: "open-fixture-library-candidate", + approved: false, + confidence: useful === channels.length ? "medium" : "low", + notes: ["Candidate map inferred from Open Fixture Library channel names. Review against the manufacturer mode chart before approval."], + }); +} + +export function outputProfileStatus(profileId, customProfiles = null) { + const profile = getFixtureOutputProfile(profileId, customProfiles); + if (profile) return { status: "output-ready", profile }; + const candidate = getFixtureOutputCandidate(profileId, customProfiles); + if (candidate) return { status: "candidate", profile: candidate }; + return { status: "paperwork-only", profile: null }; +} + +export function explainOutputChannel(outputProfile, channelDescriptor) { + return `${outputProfile.label} slot ${channelDescriptor.slot} writes ${channelDescriptor.type} (${channelDescriptor.label})`; +} diff --git a/src/domain/profiles.js b/src/domain/profiles.js index 06a9d96..fda5645 100644 --- a/src/domain/profiles.js +++ b/src/domain/profiles.js @@ -1,3 +1,5 @@ +import { inferOutputProfileFromOpenFixtureLibrary } from "./fixtureOutputProfiles.js"; + const GDTF_SHARE_BASE = "https://gdtf-share.com"; const SOURCE_TYPES = { @@ -771,9 +773,16 @@ export function normalizeOpenFixtureLibraryProfile(oflFixture, options = {}) { const symbol = inferOflSymbol(category); const modes = normalizeOflModes(oflFixture.modes); const selectedMode = modes[0]; + const profileId = `ofl_${slugify(manufacturerKey)}_${slugify(fixtureKey)}`; + const outputMap = inferOutputProfileFromOpenFixtureLibrary(oflFixture, { + profileId, + manufacturerKey, + fixtureKey, + manufacturerName: manufacturer, + }); return { - id: `ofl_${slugify(manufacturerKey)}_${slugify(fixtureKey)}`, + id: profileId, manufacturer, model, symbol, @@ -789,6 +798,7 @@ export function normalizeOpenFixtureLibraryProfile(oflFixture, options = {}) { notes: ["Review manufacturer mode chart before final addressing."], }), libraryTier: "ofl-import", + ...(outputMap ? { outputMap } : {}), source: { type: SOURCE_TYPES.ofl, manufacturerKey, diff --git a/src/domain/sacn.js b/src/domain/sacn.js new file mode 100644 index 0000000..263b79d --- /dev/null +++ b/src/domain/sacn.js @@ -0,0 +1,185 @@ +export const SACN_PORT = 5568; +export const SACN_DMX_HEADER_LENGTH = 126; +export const SACN_MAX_DMX_SLOTS = 512; +export const SACN_MIN_UNIVERSE = 1; +export const SACN_MAX_UNIVERSE = 63999; +export const SACN_DEFAULT_PRIORITY = 100; +export const SACN_DEFAULT_SOURCE_NAME = "PlotForge"; + +const ACN_PACKET_IDENTIFIER = "ASC-E1.17\0\0\0"; +const SACN_ROOT_VECTOR_DATA = 0x00000004; +const SACN_FRAMING_VECTOR_DATA = 0x00000002; +const SACN_DMP_VECTOR_SET_PROPERTY = 0x02; +const SACN_DMP_ADDRESS_TYPE_DATA_TYPE = 0xa1; + +export class SacnError extends Error { + constructor(message, code) { + super(message); + this.name = "SacnError"; + this.code = code; + } +} + +function assertIntegerRange(value, min, max, label, code) { + if (!Number.isInteger(value) || value < min || value > max) { + throw new SacnError(`${label} must be an integer from ${min} to ${max}.`, code); + } +} + +function normalizeDmxData(data) { + if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + if (data instanceof ArrayBuffer) return new Uint8Array(data); + if (Array.isArray(data)) return Uint8Array.from(data); + throw new SacnError("sACN DMX data must be a Uint8Array or byte array.", "invalid-data"); +} + +function validateByte(value, label, code) { + const number = Number(value); + assertIntegerRange(number, 0, 255, label, code); + return number; +} + +export function validateSacnUniverse(universe) { + const value = Number(universe); + assertIntegerRange(value, SACN_MIN_UNIVERSE, SACN_MAX_UNIVERSE, "sACN universe", "invalid-universe"); + return value; +} + +function validatePriority(priority) { + const value = Number(priority); + assertIntegerRange(value, 0, 200, "sACN priority", "invalid-priority"); + return value; +} + +export function parseSacnCid(cid) { + if (ArrayBuffer.isView(cid)) { + const bytes = new Uint8Array(cid.buffer, cid.byteOffset, cid.byteLength); + if (bytes.length !== 16) throw new SacnError("sACN CID must be 16 bytes.", "invalid-cid"); + return bytes; + } + const hex = String(cid || "").replace(/-/g, "").toLowerCase(); + if (!/^[0-9a-f]{32}$/.test(hex)) { + throw new SacnError("sACN CID must be a UUID string or 16-byte array.", "invalid-cid"); + } + const bytes = new Uint8Array(16); + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16); + } + return bytes; +} + +function sourceNameBytes(sourceName) { + const encoded = new TextEncoder().encode(String(sourceName || SACN_DEFAULT_SOURCE_NAME)); + const bytes = new Uint8Array(64); + bytes.set(encoded.slice(0, 63)); + return bytes; +} + +function setFlagsAndLength(view, offset, length) { + if (length > 0x0fff) { + throw new SacnError("sACN PDU length exceeds 12-bit flags-and-length field.", "invalid-length"); + } + view.setUint16(offset, 0x7000 | length, false); +} + +function optionsByte({ preview = false, streamTerminated = false, forceSynchronization = false } = {}) { + return (preview ? 0x80 : 0) + | (streamTerminated ? 0x40 : 0) + | (forceSynchronization ? 0x20 : 0); +} + +export function sacnMulticastAddress(universe) { + const value = validateSacnUniverse(universe); + return `239.255.${(value >> 8) & 0xff}.${value & 0xff}`; +} + +export function encodeSacnDmx({ + cid, + sourceName = SACN_DEFAULT_SOURCE_NAME, + universe, + data, + priority = SACN_DEFAULT_PRIORITY, + sequence = 0, + synchronizationAddress = 0, + preview = false, + streamTerminated = false, + forceSynchronization = false, + startCode = 0, +} = {}) { + const cidBytes = parseSacnCid(cid); + const universeValue = validateSacnUniverse(universe); + const dmxData = normalizeDmxData(data); + assertIntegerRange(dmxData.length, 1, SACN_MAX_DMX_SLOTS, "sACN DMX slot count", "invalid-data-length"); + const packetLength = SACN_DMX_HEADER_LENGTH + dmxData.length; + const packet = new Uint8Array(packetLength); + const view = new DataView(packet.buffer); + + view.setUint16(0, 0x0010, false); + view.setUint16(2, 0x0000, false); + for (let index = 0; index < ACN_PACKET_IDENTIFIER.length; index += 1) { + packet[4 + index] = ACN_PACKET_IDENTIFIER.charCodeAt(index); + } + + setFlagsAndLength(view, 16, packetLength - 16); + view.setUint32(18, SACN_ROOT_VECTOR_DATA, false); + packet.set(cidBytes, 22); + + setFlagsAndLength(view, 38, packetLength - 38); + view.setUint32(40, SACN_FRAMING_VECTOR_DATA, false); + packet.set(sourceNameBytes(sourceName), 44); + packet[108] = validatePriority(priority); + view.setUint16(109, validateSacnUniverseOrZero(synchronizationAddress), false); + packet[111] = validateByte(sequence, "sACN sequence", "invalid-sequence"); + packet[112] = optionsByte({ preview, streamTerminated, forceSynchronization }); + view.setUint16(113, universeValue, false); + + setFlagsAndLength(view, 115, packetLength - 115); + packet[117] = SACN_DMP_VECTOR_SET_PROPERTY; + packet[118] = SACN_DMP_ADDRESS_TYPE_DATA_TYPE; + view.setUint16(119, 0x0000, false); + view.setUint16(121, 0x0001, false); + view.setUint16(123, dmxData.length + 1, false); + packet[125] = validateByte(startCode, "sACN start code", "invalid-start-code"); + packet.set(dmxData, SACN_DMX_HEADER_LENGTH); + + return packet; +} + +function validateSacnUniverseOrZero(value) { + const parsed = Number(value); + assertIntegerRange(parsed, 0, SACN_MAX_UNIVERSE, "sACN synchronization address", "invalid-sync-address"); + return parsed; +} + +export function sacnPacketSummary(packet) { + const data = normalizeDmxData(packet); + if (data.length < SACN_DMX_HEADER_LENGTH) { + throw new SacnError("sACN packet is shorter than the DMX data header.", "short-packet"); + } + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + const sourceName = new TextDecoder().decode(data.slice(44, 108)).replace(/\0.*$/u, ""); + return { + preambleSize: view.getUint16(0, false), + postambleSize: view.getUint16(2, false), + acnPacketIdentifier: Array.from(data.slice(4, 16)).map(byte => String.fromCharCode(byte)).join(""), + rootFlagsLength: view.getUint16(16, false), + rootVector: view.getUint32(18, false), + cid: Array.from(data.slice(22, 38)), + framingFlagsLength: view.getUint16(38, false), + framingVector: view.getUint32(40, false), + sourceName, + priority: data[108], + synchronizationAddress: view.getUint16(109, false), + sequence: data[111], + options: data[112], + universe: view.getUint16(113, false), + dmpFlagsLength: view.getUint16(115, false), + dmpVector: data[117], + addressTypeDataType: data[118], + firstPropertyAddress: view.getUint16(119, false), + addressIncrement: view.getUint16(121, false), + propertyValueCount: view.getUint16(123, false), + startCode: data[125], + payload: data.slice(SACN_DMX_HEADER_LENGTH), + }; +} diff --git a/src/domain/show.js b/src/domain/show.js index c7f44d7..e01f3fb 100644 --- a/src/domain/show.js +++ b/src/domain/show.js @@ -13,6 +13,7 @@ import { normalizeCircuitPatch, normalizeFixtureCircuit } from "./circuiting.js" import { normalizeFixtureNotes } from "./fixtureNotes.js"; import { DEFAULT_FIXTURE_STATUS, normalizeFixtureStatus } from "./fixtureStatus.js"; import { defaultOscBridgeSettings, normalizeOscBridgeSettings } from "./oscBridge.js"; +import { defaultDmxOutputSettings, normalizeDmxOutputSettings } from "./dmxOutputPreferences.js"; import { feetToMm } from "./units.js"; export const DOC_VERSION = 10; @@ -112,6 +113,7 @@ export function newShow({ name = "Untitled Show" } = {}) { revisionSnapshots: {}, // revisionId -> compact rig snapshot for diffing oscBridge: defaultOscBridgeSettings(), labelSettings: defaultLabelSettings(), + dmxOutput: defaultDmxOutputSettings(), }; } @@ -296,6 +298,14 @@ export function updateLabelSettings(doc, patch) { }; } +export function updateDmxOutputSettings(doc, patch) { + return { + ...doc, + updatedAt: Date.now(), + dmxOutput: normalizeDmxOutputSettings({ ...(doc.dmxOutput || {}), ...patch }), + }; +} + export function addRevision(doc, revision) { return { ...doc, diff --git a/src/hooks/useShowDoc.js b/src/hooks/useShowDoc.js index 8a85401..74d7ff3 100644 --- a/src/hooks/useShowDoc.js +++ b/src/hooks/useShowDoc.js @@ -19,6 +19,7 @@ import { activateRevision, updateOscBridge, updateLabelSettings, + updateDmxOutputSettings, updateShowName, updateProjectMetadata, updateFixture, @@ -255,6 +256,10 @@ export default function useShowDoc(seedShow) { commit(updateLabelSettings(doc, patch)); }, [doc, commit]); + const onDmxOutputChange = useCallback((patch) => { + commit(updateDmxOutputSettings(doc, patch)); + }, [doc, commit]); + const onAddRevision = useCallback(({ name, note }) => { const revision = newRevision({ name, note }); // Snapshot the rig as it stands at issue time so later edits can be @@ -428,6 +433,7 @@ export default function useShowDoc(seedShow) { onProjectMetadataChange, onOscBridgeChange, onLabelSettingsChange, + onDmxOutputChange, onAddRevision, onActivateRevision, onPositionChange, diff --git a/src/serialization.js b/src/serialization.js index e01e12c..97f0953 100644 --- a/src/serialization.js +++ b/src/serialization.js @@ -6,11 +6,16 @@ import { normalizeFixtureCircuit } from "./domain/circuiting.js"; import { normalizeFixtureNotes } from "./domain/fixtureNotes.js"; import { normalizeFixtureStatus } from "./domain/fixtureStatus.js"; import { normalizeOscBridgeSettings } from "./domain/oscBridge.js"; +import { normalizeDmxOutputSettings } from "./domain/dmxOutputPreferences.js"; export const PLOT_MIME = "application/x-plotforge+json"; export function serialize(doc) { - return JSON.stringify({ ...doc, version: DOC_VERSION }, null, 2); + return JSON.stringify({ + ...doc, + version: DOC_VERSION, + dmxOutput: normalizeDmxOutputSettings(doc.dmxOutput || {}), + }, null, 2); } export function deserialize(text) { @@ -98,7 +103,7 @@ export function migrate(doc) { cur = fn(cur); v = cur.version; } - return cur; + return { ...cur, dmxOutput: normalizeDmxOutputSettings(cur.dmxOutput || {}) }; } // ---------- File System Access API + download fallback ----------