From db95d9e47aa68f1e70e88bec852f2fc870de2793 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Wed, 19 Aug 2026 00:27:43 +0000 Subject: [PATCH] feat: probe the OSC target, default BEYOND to 8000, add an in-app OSC debugger --- README.md | 14 +- deploy/.env.example | 2 +- deploy/README.md | 2 +- deploy/gen-config.js | 2 +- docs/event-deployment.md | 17 +- examples/routing-two-beyond.json | 4 +- packages/cli/README.md | 7 +- packages/cli/__tests__/osc-signals.test.ts | 4 +- packages/cli/__tests__/osc.test.ts | 2 +- packages/cli/src/cli.ts | 2 +- packages/cli/src/commands/osc-signals.ts | 17 +- packages/cli/src/commands/osc.ts | 19 +- packages/desktop/__tests__/osc-debug.test.ts | 169 ++++++++ packages/desktop/__tests__/osc-target.test.ts | 3 +- packages/desktop/package.json | 3 +- packages/desktop/src/main/ipc.ts | 34 ++ packages/desktop/src/main/osc-debug.ts | 235 +++++++++++ packages/desktop/src/main/osc-target.ts | 10 +- packages/desktop/src/preload.ts | 19 +- packages/desktop/src/renderer/App.tsx | 4 + .../desktop/src/renderer/lib/navigation.ts | 4 +- .../desktop/src/renderer/routes/osc-route.tsx | 377 ++++++++++++++++++ packages/desktop/src/types/ipc.ts | 73 ++++ packages/desktop/vite.main.config.ts | 1 + packages/doctor/__tests__/beyond.test.ts | 94 +++++ packages/doctor/__tests__/checks.test.ts | 43 +- packages/doctor/__tests__/udp-probe.test.ts | 40 ++ packages/doctor/src/beyond.ts | 134 +++++++ packages/doctor/src/checks.ts | 61 ++- packages/doctor/src/collect.ts | 40 +- packages/doctor/src/index.ts | 15 +- packages/doctor/src/probe.ts | 54 +++ packages/layout/src/config.ts | 14 +- packages/layout/src/index.ts | 2 + packages/osc/README.md | 4 +- packages/receiver/README.md | 2 +- packages/receiver/src/main.ts | 4 +- pnpm-lock.yaml | 3 + 38 files changed, 1468 insertions(+), 66 deletions(-) create mode 100644 packages/desktop/__tests__/osc-debug.test.ts create mode 100644 packages/desktop/src/main/osc-debug.ts create mode 100644 packages/desktop/src/renderer/routes/osc-route.tsx create mode 100644 packages/doctor/__tests__/beyond.test.ts create mode 100644 packages/doctor/__tests__/udp-probe.test.ts create mode 100644 packages/doctor/src/beyond.ts diff --git a/README.md b/README.md index a236d3b..3cd82e3 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ When the UI/Server run on a cloud server and the laser hardware is on-site: │ Server (:3000) ◄─────────────┼──────────────┼── Receiver │ │ UI (:3003) │ │ │ │ │ │ │ ▼ OSC/UDP (localhost) │ -│ Artists connect via browser │ │ BEYOND (:7001) │ +│ Artists connect via browser │ │ BEYOND (:8000) │ └───────────────────────────────────┘ └──────────────────────────────┘ ``` @@ -184,7 +184,7 @@ PowerShell: ```powershell $env:SIMULATOR_URL = "ws://203.0.113.50:3000" $env:BEYOND_HOST = "127.0.0.1" -$env:BEYOND_PORT = "7001" +$env:BEYOND_PORT = "8000" $env:SHARD_START = "0" $env:SHARD_END = "23" $env:DEBUG_OSC = "1" @@ -195,7 +195,7 @@ Bash (Linux/macOS): ```sh SIMULATOR_URL=ws://203.0.113.50:3000 \ BEYOND_HOST=127.0.0.1 \ -BEYOND_PORT=7001 \ +BEYOND_PORT=8000 \ SHARD_START=0 \ SHARD_END=23 \ DEBUG_OSC=1 \ @@ -223,7 +223,7 @@ When a single BEYOND PC can't handle all 49 zones, split the grid across multipl ▼ ▼ ┌──────────────┐ ┌──────────────┐ │ BEYOND A │ │ BEYOND B │ - │ .1.68:7001 │ │ .1.69:7001 │ + │ .1.68:8000 │ │ .1.69:8000 │ │ zones 0–23 │ │ zones 0–24 │ └──────────────┘ └──────────────┘ ``` @@ -233,8 +233,8 @@ Create a `routing.json` file (see `examples/routing-two-beyond.json` for a full ```json { "targets": { - "beyond-a": { "type": "beyond", "host": "192.168.1.68", "port": 7001 }, - "beyond-b": { "type": "beyond", "host": "192.168.1.69", "port": 7001 } + "beyond-a": { "type": "beyond", "host": "192.168.1.68", "port": 8000 }, + "beyond-b": { "type": "beyond", "host": "192.168.1.69", "port": 8000 } }, "flushHz": 30, "cannons": [ @@ -315,7 +315,7 @@ Project config lives in the store (`wavegrid projects config`) — env vars are | `WG_RECEIVER_KEY` | store value | Receiver auth key (override to share across laptops) | | `SIMULATOR_URL` | `ws://localhost:3000` | WebSocket upstream for the receiver | | `BEYOND_HOST` | — | BEYOND PC IP (enables OSC output) | -| `BEYOND_PORT` | `7001` | BEYOND OSC receive port | +| `BEYOND_PORT` | `8000` | BEYOND OSC receive port | | `BEYOND_GRID_ORDER` | `row` | Grid-to-zone mapping: `row` or `column` | | `SHARD_START` / `SHARD_END` | — | Cannon index range for this receiver | | `DEBUG_OSC` | — | Set to `1` to log every OSC message | diff --git a/deploy/.env.example b/deploy/.env.example index f96fd2b..01060c8 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -53,7 +53,7 @@ SIM_PORT=3000 # OSC output — pick ONE: a single BEYOND target, or a routing config file. # BEYOND_HOST=127.0.0.1 -# BEYOND_PORT=7001 +# BEYOND_PORT=8000 # BEYOND_GRID_ORDER=row # "row" or "column" # Multi-target routing: generate deploy/routing.json with diff --git a/deploy/README.md b/deploy/README.md index 94b0fcc..1c00b50 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -79,7 +79,7 @@ Configure OSC output in `deploy\.env` — either a single BEYOND target | `SIMULATOR_URL` | pangolin| `ws://CLOUD_IP:SIM_PORT` | receiver → server (derived) | | `RECEIVER_ALPHA` | pangolin| `0.06` | smoothing | | `FALLBACK_DELAY` | pangolin| `3000` | ms before sine fallback | -| `BEYOND_HOST`/`BEYOND_PORT` | pangolin| — / `7001` | single BEYOND OSC target | +| `BEYOND_HOST`/`BEYOND_PORT` | pangolin| — / `8000` | single BEYOND OSC target | | `BEYOND_GRID_ORDER` | pangolin| `row` | `row` or `column` | | `ROUTING_CONFIG` | pangolin| — | JSON routing file (multi-target) | | `DEBUG_OSC` | pangolin| — | set to `1` to log all OSC | diff --git a/deploy/gen-config.js b/deploy/gen-config.js index 178167f..cc02a22 100644 --- a/deploy/gen-config.js +++ b/deploy/gen-config.js @@ -123,7 +123,7 @@ async function main() { cloudIp: args['cloud-ip'] || existing.cloudIp || env.CLOUD_IP || '', beyondA: args['beyond-a'] || (exTargets['beyond-a'] && exTargets['beyond-a'].host) || env.BEYOND_A_HOST || '192.168.1.68', beyondB: args['beyond-b'] || (exTargets['beyond-b'] && exTargets['beyond-b'].host) || env.BEYOND_B_HOST || '192.168.1.69', - port: parseInt(args.port || env.BEYOND_PORT || (exTargets['beyond-a'] && exTargets['beyond-a'].port) || '7001', 10), + port: parseInt(args.port || env.BEYOND_PORT || (exTargets['beyond-a'] && exTargets['beyond-a'].port) || '8000', 10), flushHz: parseInt(args['flush-hz'] || existing.flushHz || '30', 10), cannons: parseInt(args.cannons || env.NUM_CANNONS || '49', 10), columns: parseInt(args.columns || env.GRID_COLUMNS || '7', 10), diff --git a/docs/event-deployment.md b/docs/event-deployment.md index a97705b..2205303 100644 --- a/docs/event-deployment.md +++ b/docs/event-deployment.md @@ -14,12 +14,12 @@ graph LR subgraph PangolinPC["🖥️ Pangolin PC (Windows, on-site)"] RX["Receiver"] - BEYOND["BEYOND :7001"] + BEYOND["BEYOND :8000"] end BROWSER -- "http + ws @ ‹CLOUD_IP›:3000" --> SIM SIM -- "ws://‹CLOUD_IP›:3000" --> RX - RX -- "OSC/UDP localhost:7001" --> BEYOND + RX -- "OSC/UDP localhost:8000" --> BEYOND ``` **Three devices, three roles:** @@ -52,7 +52,7 @@ Open **http://‹CLOUD_IP›:3000** in the browser (the browser derives its WebS ```powershell $env:SIMULATOR_URL = "ws://:3000" $env:BEYOND_HOST = "127.0.0.1" -$env:BEYOND_PORT = "7001" +$env:BEYOND_PORT = "8000" $env:SHARD_START = "0" $env:SHARD_END = "23" $env:DEBUG_OSC = "1" @@ -66,8 +66,8 @@ Use a routing config instead of `BEYOND_HOST`. Save `routing.json` in the repo r ```json { "targets": { - "beyond-a": { "type": "beyond", "host": "", "port": 7001 }, - "beyond-b": { "type": "beyond", "host": "", "port": 7001 } + "beyond-a": { "type": "beyond", "host": "", "port": 8000 }, + "beyond-b": { "type": "beyond", "host": "", "port": 8000 } }, "flushHz": 30, "cannons": [ @@ -134,10 +134,15 @@ graph TD ## Troubleshooting +Advanced → OSC in the desktop app is the fastest first look: it shows the +resolved target and port, probes it, reads BEYOND.ini where BEYOND is installed +locally, sends blackout / full white / full amber (one zone or all) and tails +every message in and out. + | Symptom | Fix | |---------|-----| | UI loads but painting does nothing | The browser's WebSocket is same-origin — make sure you opened the UI at the server's real host:port (not localhost) and port 3000 is reachable | -| Receiver connects but no laser response | Verify BEYOND's OSC server is on port 7001, and "Show R-G-B-A panel" is enabled in BEYOND settings | +| Receiver connects but no laser response | Verify BEYOND's OSC server is on port 8000, and "Show R-G-B-A panel" is enabled in BEYOND settings | | Colors wrong in `rgb` mode | Confirm `alpha` is being sent (check `DEBUG_OSC=1` output for `/livecontrol/alpha 255`) | | Receiver can't connect to server | Check cloud firewall allows inbound on port 3000 | | White shows as red | Ensure BEYOND's RGBA panel is enabled: Settings → Configuration → Live Control → Extra Controls → "Show R-G-B-A panel" | diff --git a/examples/routing-two-beyond.json b/examples/routing-two-beyond.json index 00d6f84..cc77964 100644 --- a/examples/routing-two-beyond.json +++ b/examples/routing-two-beyond.json @@ -3,12 +3,12 @@ "beyond-a": { "type": "beyond", "host": "192.168.1.68", - "port": 7001 + "port": 8000 }, "beyond-b": { "type": "beyond", "host": "192.168.1.69", - "port": 7001 + "port": 8000 } }, "flushHz": 30, diff --git a/packages/cli/README.md b/packages/cli/README.md index 87d9c28..08946ca 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -106,7 +106,7 @@ hand, over the project's configured OSC target (or `--host` / `--port`): ```sh wavegrid signals send /beyond/zone/0/livecontrol/red 255 # one message wavegrid signals probe --zones 0-11 --hold 500 # light one zone at a time -wavegrid signals listen --port 7001 # print what arrives +wavegrid signals listen --port 8000 # print what arrives ``` `send` arguments are floats unless tagged (`i:3` integer, `s:text` string), since @@ -116,8 +116,9 @@ binds a port, so pointing the show at `--host 127.0.0.1 --port ` shows the exact stream the hardware would receive. For BEYOND to act on any of it, its OSC server must be enabled (BEYOND's -settings — check the port there rather than assuming; `projects osc` defaults to -7001 for BEYOND, 8000 for FB4) and the zone has to be under live control. +settings — `[OSC] PortIn` in BEYOND.ini is the value it actually binds; `projects +osc` defaults to 8000, BEYOND's factory port) and the zone has to be under live +control. This is aimed output on a configured target, unrelated to [`tools/traffic`](../../tools/traffic), which stays passive — it observes diff --git a/packages/cli/__tests__/osc-signals.test.ts b/packages/cli/__tests__/osc-signals.test.ts index b751851..6af750e 100644 --- a/packages/cli/__tests__/osc-signals.test.ts +++ b/packages/cli/__tests__/osc-signals.test.ts @@ -20,10 +20,12 @@ describe('resolveTarget', () => { it('prefers flags over config, and defaults the port per kind', () => { const config = { osc: { beyond: { host: '192.168.1.50', port: 7001 } } }; + // A --host makes the target explicit, so the port is BEYOND's default, not + // the configured project's. expect(resolveTarget({ host: '127.0.0.1' }, config)).toMatchObject({ kind: 'beyond', host: '127.0.0.1', - port: 7001, + port: 8000, origin: 'flags' }); expect(resolveTarget({ host: '127.0.0.1', kind: 'fb4' }, config)).toMatchObject({ diff --git a/packages/cli/__tests__/osc.test.ts b/packages/cli/__tests__/osc.test.ts index 7594256..81e446f 100644 --- a/packages/cli/__tests__/osc.test.ts +++ b/packages/cli/__tests__/osc.test.ts @@ -39,7 +39,7 @@ describe('runOscSetup', () => { await runOscSetup('beyond', { host: '192.168.1.50' }); expect(store.getProjectConfig('ring-demo')?.osc).toEqual({ - beyond: { host: '192.168.1.50', port: 7001, gridOrder: 'row' } + beyond: { host: '192.168.1.50', port: 8000, gridOrder: 'row' } }); }); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index a19b429..1a7c51d 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -72,7 +72,7 @@ ${c.bold('Run')} ${c.bold('Signals')} — hand-driven OSC for debugging Pangolin signals send [args] Send one OSC message to the configured target signals probe [--zones 0-11] Light one zone/fixture at a time, to find the mapping - signals listen [--port 7001] Print every OSC message arriving on a port + signals listen [--port 8000] Print every OSC message arriving on a port ${c.bold('Receiver options')} --server Brain to connect to (e.g. ws://192.168.1.42:3333) diff --git a/packages/cli/src/commands/osc-signals.ts b/packages/cli/src/commands/osc-signals.ts index 0856a87..8112a9b 100644 --- a/packages/cli/src/commands/osc-signals.ts +++ b/packages/cli/src/commands/osc-signals.ts @@ -10,7 +10,7 @@ * Aiming: the project's configured OSC target by default (so this debugs the * same path the show uses), or `--host/--port` for a one-off. */ -import { loadWavegridConfig } from '@wavegrid/layout'; +import { DEFAULT_BEYOND_PORT, DEFAULT_FB4_PORT, loadWavegridConfig } from '@wavegrid/layout'; import { BeyondOscOutput, type CannonState, @@ -31,11 +31,11 @@ export const SIGNALS_USAGE = [ ' wavegrid signals send /beyond/zone/0/livecontrol/red 255', ' wavegrid signals send /FB4-12345/color_red 100 --host 192.168.1.50 --port 8000', ' wavegrid signals probe [--zones 0-11] [--hold 500] [--hue 40]', - ' wavegrid signals listen [--port 7001]', + ` wavegrid signals listen [--port ${DEFAULT_BEYOND_PORT}]`, '', ' Options:', ' --host Override the project\'s OSC host', - ' --port Override the port (BEYOND 7001, FB4 8000)', + ` --port Override the port (BEYOND ${DEFAULT_BEYOND_PORT}, FB4 ${DEFAULT_FB4_PORT})`, ' --dry-run Print what would be sent, send nothing', '', ' Arguments are floats unless tagged: `i:3` integer, `s:text` string.', @@ -74,7 +74,12 @@ export function resolveTarget(flags: Flags, config: { osc: { beyond?: { host: st if (host) { const kind = kindFlag === 'fb4' ? 'fb4' : 'beyond'; - return { kind, host, port: port ?? (kind === 'fb4' ? 8000 : 7001), origin: 'flags' }; + return { + kind, + host, + port: port ?? (kind === 'fb4' ? DEFAULT_FB4_PORT : DEFAULT_BEYOND_PORT), + origin: 'flags' + }; } if (config.osc.beyond) { return { @@ -88,7 +93,7 @@ export function resolveTarget(flags: Flags, config: { osc: { beyond?: { host: st return { kind: 'fb4', host: config.osc.fb4.host, - port: port ?? config.osc.fb4.port ?? 8000, + port: port ?? config.osc.fb4.port ?? DEFAULT_FB4_PORT, origin: 'project config (FB4)' }; } @@ -218,7 +223,7 @@ export async function runSignalsProbe(flags: Flags): Promise { /** Sit on a port and print what arrives. Ctrl-C to stop. */ export async function runSignalsListen(flags: Flags): Promise { - const port = num(flags, 'port') ?? 7001; + const port = num(flags, 'port') ?? DEFAULT_BEYOND_PORT; const host = str(flags, 'host') ?? '0.0.0.0'; let seen = 0; diff --git a/packages/cli/src/commands/osc.ts b/packages/cli/src/commands/osc.ts index 490ec43..01b6c0a 100644 --- a/packages/cli/src/commands/osc.ts +++ b/packages/cli/src/commands/osc.ts @@ -1,4 +1,11 @@ -import { LOOPBACK_HOST, normalizeOscHost, type OscConfig, type WavegridConfig } from '@wavegrid/layout'; +import { + DEFAULT_BEYOND_PORT, + DEFAULT_FB4_PORT, + LOOPBACK_HOST, + normalizeOscHost, + type OscConfig, + type WavegridConfig +} from '@wavegrid/layout'; import type { Inquirerer, Question } from 'inquirerer'; import c from 'yanse'; @@ -31,8 +38,8 @@ function confirm(project: string, osc: OscConfig | undefined): void { const USAGE = [ ' Usage:', ' wavegrid projects osc (interactive wizard)', - ' wavegrid projects osc beyond --host [--port 7001] [--grid-order row|column]', - ' wavegrid projects osc fb4 --host [--port 8000]', + ` wavegrid projects osc beyond --host [--port ${DEFAULT_BEYOND_PORT}] [--grid-order row|column]`, + ` wavegrid projects osc fb4 --host [--port ${DEFAULT_FB4_PORT}]`, ' wavegrid projects osc routing --file ', ' wavegrid projects osc show', ' wavegrid projects osc clear' @@ -55,7 +62,7 @@ function applyFromFlags(flags: Flags, kind: string): boolean { if (kind === 'beyond') { const host = normalizeOscHost(str(flags, 'host') ?? ''); if (!host) return false; - const port = num(flags, 'port') ?? 7001; + const port = num(flags, 'port') ?? DEFAULT_BEYOND_PORT; const gridOrder = str(flags, 'grid-order') === 'column' ? 'column' : 'row'; const project = save(flags, (config) => { config.osc = { beyond: { host, port, gridOrder } }; @@ -66,7 +73,7 @@ function applyFromFlags(flags: Flags, kind: string): boolean { if (kind === 'fb4') { const host = normalizeOscHost(str(flags, 'host') ?? ''); if (!host) return false; - const port = num(flags, 'port') ?? 8000; + const port = num(flags, 'port') ?? DEFAULT_FB4_PORT; const project = save(flags, (config) => { config.osc = { fb4: { host, port } }; }); @@ -98,7 +105,7 @@ async function wizardBeyond(prompter: Inquirerer, current?: OscConfig): Promise< type: 'number', name: 'port', message: 'BEYOND OSC port', - default: current?.beyond?.port ?? 7001, + default: current?.beyond?.port ?? DEFAULT_BEYOND_PORT, required: true } as Question, { diff --git a/packages/desktop/__tests__/osc-debug.test.ts b/packages/desktop/__tests__/osc-debug.test.ts new file mode 100644 index 0000000..6a17559 --- /dev/null +++ b/packages/desktop/__tests__/osc-debug.test.ts @@ -0,0 +1,169 @@ +/** + * The OSC debugger sends at live hardware, so what it puts on the wire has to be + * exactly what the operator asked for: the project's own target, the addresses + * the show uses, and nothing when there is no target to send to. + */ +interface Sent { + host: string; + port: number; + address: string; + args: { type: string; value: number | string }[]; +} + +const sent: Sent[] = []; +const probe = jest.fn, [string, number, number?]>(); + +const config: { osc?: Record; layout?: { preset?: string } } = {}; + +jest.mock('@wavegrid/settings', () => ({ + openStore: () => ({ + hasProject: (name: string) => name === 'show', + getProjectConfig: () => config + }) +})); + +jest.mock('@wavegrid/osc', () => { + const actual = jest.requireActual('@wavegrid/osc'); + return { + ...actual, + sendOscMessage: (host: string, port: number, address: string, args: Sent['args']) => { + sent.push({ host, port, address, args }); + return Promise.resolve(); + } + }; +}); + +jest.mock('@wavegrid/doctor', () => { + const actual = jest.requireActual('@wavegrid/doctor'); + return { + ...actual, + udpProbe: (host: string, port: number, timeout?: number) => probe(host, port, timeout), + // BEYOND is a Windows install; the tests must not depend on this box having one. + findBeyondIni: () => null + }; +}); + +import { + clearOscLog, + oscDebugState, + oscDebugTarget, + probeOscTarget, + sendOscPreset, + sendOscSignal +} from '@/main/osc-debug'; + +beforeEach(() => { + sent.length = 0; + clearOscLog(); + probe.mockReset(); + probe.mockResolvedValue('no-rejection'); + config.osc = { beyond: { host: '127.0.0.1', port: 8000 } }; + config.layout = { preset: 'ring-6' }; +}); + +describe('oscDebugTarget', () => { + it('reports the project’s configured BEYOND target', () => { + expect(oscDebugTarget('show')).toEqual({ kind: 'beyond', host: '127.0.0.1', port: 8000 }); + }); + + it('is null for a project with no OSC output, and for an unknown project', () => { + config.osc = {}; + expect(oscDebugTarget('show')).toBeNull(); + config.osc = { beyond: { host: '127.0.0.1', port: 8000 } }; + expect(oscDebugTarget('nope')).toBeNull(); + }); +}); + +describe('probeOscTarget', () => { + it('probes the configured host and port and keeps the verdict', async () => { + probe.mockResolvedValue('refused'); + const state = await probeOscTarget('show'); + expect(probe).toHaveBeenCalledWith('127.0.0.1', 8000, expect.any(Number)); + expect(state.probe).toBe('refused'); + }); + + it('does not probe when there is nothing configured to probe', async () => { + config.osc = {}; + const state = await probeOscTarget('show'); + expect(probe).not.toHaveBeenCalled(); + expect(state.probe).toBeNull(); + }); +}); + +describe('sendOscPreset', () => { + it('addresses one zone with the show’s own livecontrol addresses', async () => { + const result = await sendOscPreset('show', 'amber', 2); + expect(result.ok).toBe(true); + const addresses = sent.map((s) => s.address); + expect(addresses).toContain('/beyond/zone/2/livecontrol/red'); + expect(addresses.every((a) => a.startsWith('/beyond/zone/2/'))).toBe(true); + expect(sent.every((s) => s.host === '127.0.0.1' && s.port === 8000)).toBe(true); + }); + + it('covers every fixture in the layout when no zone is named', async () => { + await sendOscPreset('show', 'white', null); + const zones = new Set(sent.map((s) => s.address.split('/')[3])); + expect([...zones].sort()).toEqual(['0', '1', '2', '3', '4', '5']); + }); + + it('sends blackout as zero brightness, not as an absence of messages', async () => { + await sendOscPreset('show', 'blackout', 0); + const brightness = sent.find((s) => s.address.endsWith('/Brightness')); + expect(brightness?.args[0].value).toBe(0); + }); + + it('sends floats, as the show does', async () => { + await sendOscPreset('show', 'white', 0); + expect(sent.every((s) => s.args.every((a) => a.type === 'float'))).toBe(true); + }); + + it('refuses when the project has no target, and puts nothing on the wire', async () => { + config.osc = {}; + const result = await sendOscPreset('show', 'white', 0); + expect(result.ok).toBe(false); + expect(sent).toHaveLength(0); + }); + + it('needs a serial before it can address an FB4', async () => { + config.osc = { fb4: { host: '10.0.0.9', port: 8000 } }; + expect((await sendOscPreset('show', 'white', 0)).ok).toBe(false); + expect(sent).toHaveLength(0); + const result = await sendOscPreset('show', 'white', 0, '12345'); + expect(result.ok).toBe(true); + expect(sent.map((s) => s.address)).toContain('/FB4-12345/color_red'); + }); +}); + +describe('sendOscSignal', () => { + it('sends a hand-typed address verbatim and logs exactly what went out', async () => { + const result = await sendOscSignal('show', '/beyond/zone/3/livecontrol/red', ['255']); + expect(result.ok).toBe(true); + expect(sent[0]).toMatchObject({ + host: '127.0.0.1', + port: 8000, + address: '/beyond/zone/3/livecontrol/red' + }); + const [entry] = oscDebugState('show').log; + expect(entry).toMatchObject({ + dir: 'out', + address: '/beyond/zone/3/livecontrol/red', + peer: '127.0.0.1:8000' + }); + }); + + it('honours explicit argument types, so integer-only addresses can be tried', async () => { + await sendOscSignal('show', '/beyond/zone/0/livecontrol/red', ['i:3']); + expect(sent[0].args).toEqual([{ type: 'integer', value: 3 }]); + }); + + it('rejects an address that is not an OSC path', async () => { + const result = await sendOscSignal('show', 'beyond/zone/0', ['1']); + expect(result.ok).toBe(false); + expect(sent).toHaveLength(0); + }); + + it('sends a bare address when no arguments are given', async () => { + await sendOscSignal('show', '/beyond/zone/0/livecontrol/red', ['']); + expect(sent[0].args).toEqual([]); + }); +}); diff --git a/packages/desktop/__tests__/osc-target.test.ts b/packages/desktop/__tests__/osc-target.test.ts index f5b9e97..9b50dd8 100644 --- a/packages/desktop/__tests__/osc-target.test.ts +++ b/packages/desktop/__tests__/osc-target.test.ts @@ -72,7 +72,8 @@ describe('applyOscTarget', () => { }); it('falls back to the default port for an unusable one', () => { - expect(applyOscTarget(null, { ...NONE, kind: 'beyond', host: 'h', port: NaN }).osc?.beyond?.port).toBe(7001); + // BEYOND's factory OSC receive port, shared with the CLI and the receiver. + expect(applyOscTarget(null, { ...NONE, kind: 'beyond', host: 'h', port: NaN }).osc?.beyond?.port).toBe(8000); expect(applyOscTarget(null, { ...NONE, kind: 'fb4', host: 'h', port: 99999 }).osc?.fb4?.port).toBe(8000); }); diff --git a/packages/desktop/package.json b/packages/desktop/package.json index a75d7c8..d16179d 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -2,7 +2,7 @@ "name": "@wavegrid/desktop", "version": "0.1.0", "author": "Dan Lynch ", - "description": "Wavegrid Desktop — a thin Electron shell around the one-brain runtime, with a Constructive Blocks admin UI wrapping the untouched laser UI", + "description": "Wavegrid Desktop \u2014 a thin Electron shell around the one-brain runtime, with a Constructive Blocks admin UI wrapping the untouched laser UI", "private": true, "license": "SEE LICENSE IN LICENSE", "main": ".vite/build/main.js", @@ -31,6 +31,7 @@ "@wavegrid/discovery": "workspace:*", "@wavegrid/doctor": "workspace:*", "@wavegrid/layout": "workspace:*", + "@wavegrid/osc": "workspace:*", "@wavegrid/receiver": "workspace:*", "@wavegrid/server": "workspace:*", "@wavegrid/settings": "workspace:*", diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 84abe14..64fdfe2 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -16,6 +16,15 @@ import { invalidateLaserView, type LaserSyncState, syncLaser } from '@/main/lase import { buildLightMapView } from '@/main/light-map'; import { buildNetworkReport } from '@/main/network'; import { applyNovaLook, novaBlackout, setNovaSpeed } from '@/main/nova'; +import { + clearOscLog, + oscDebugState, + probeOscTarget, + sendOscPreset, + sendOscSignal, + startOscListen, + stopOscListen +} from '@/main/osc-debug'; import { applyOscTarget, toOscTarget } from '@/main/osc-target'; import { applyEditable, @@ -44,6 +53,7 @@ import type { ImportRequest, LightMapView, NewProjectInput, + OscDebugPreset, OscTarget, ProjectSummary, RequiredSecretInfo, @@ -393,5 +403,29 @@ export function registerAllIpc(): void { return writeSettings(filePaths[0]); }); + // OSC debugger (Advanced → OSC). Local UDP only, single messages, at the + // project's own configured target — never a running show. + ipcMain.handle('oscDebug:state', (_e, project: string) => oscDebugState(project)); + ipcMain.handle('oscDebug:probe', (_e, project: string) => probeOscTarget(project)); + ipcMain.handle( + 'oscDebug:preset', + (_e, project: string, preset: OscDebugPreset, zone: number | null, serial?: string) => + sendOscPreset(project, preset, zone, serial) + ); + ipcMain.handle('oscDebug:send', (_e, project: string, address: string, args: string[]) => + sendOscSignal(project, address, args) + ); + ipcMain.handle('oscDebug:listen', (_e, project: string, port: number) => + startOscListen(project, port) + ); + ipcMain.handle('oscDebug:stopListen', async (_e, project: string) => { + await stopOscListen(); + return oscDebugState(project); + }); + ipcMain.handle('oscDebug:clear', (_e, project: string) => { + clearOscLog(); + return oscDebugState(project); + }); + ipcMain.on('laser:sync', (_e, state: LaserSyncState) => syncLaser(state)); } diff --git a/packages/desktop/src/main/osc-debug.ts b/packages/desktop/src/main/osc-debug.ts new file mode 100644 index 0000000..2c9eb85 --- /dev/null +++ b/packages/desktop/src/main/osc-debug.ts @@ -0,0 +1,235 @@ +/** + * The OSC debugger's back end (Advanced → OSC). + * + * Debugging "the lasers aren't responding" needs three things a show cannot + * give you: is anything listening where we send, what exactly goes on the wire, + * and does one hand-made message move a laser. OSC is UDP, so none of that is + * observable from a running show — every frame aimed at a wrong port is dropped + * in silence. + * + * What it sends is deliberately small: single messages, built by the same + * encoders the show uses, at the project's own configured target. Nothing here + * starts an animation or invents a hardware command. + */ +import { existsSync, readFileSync } from 'node:fs'; + +import { checkBeyond, findBeyondIni, readBeyondSettings, udpProbe, type UdpState } from '@wavegrid/doctor'; +import { resolveLayout } from '@wavegrid/layout'; +import { + type CannonState, + encodeBeyondMessages, + encodeFB4Messages, + listenForOsc, + type OscListener, + type OscMessage, + parseOscArg, + sendOscMessage +} from '@wavegrid/osc'; +import { openStore } from '@wavegrid/settings'; + +import type { + OscDebugPreset, + OscDebugState, + OscDebugTarget, + OscSignalEntry, + OscSignalResult +} from '@/types/ipc'; + +/** Colours the preset buttons send, as the artist UI would describe them. */ +const PRESET_COLORS: Record, CannonState> = { + white: { h: 0, s: 0, b: 100 }, + amber: { h: 40, s: 100, b: 100 } +}; + +/** Enough log to see a pattern, little enough to render every poll. */ +const LOG_LIMIT = 200; + +/** How long a probe waits for an ICMP rejection before calling it quiet. */ +const PROBE_MS = 700; + +const log: OscSignalEntry[] = []; +let probe: UdpState | null = null; +let listener: OscListener | null = null; +let listenPort: number | null = null; + +function record(entry: OscSignalEntry): void { + log.push(entry); + if (log.length > LOG_LIMIT) log.splice(0, log.length - LOG_LIMIT); +} + +/** The project's own OSC target, or null when it has none to debug. */ +export function oscDebugTarget(project: string): OscDebugTarget | null { + const store = openStore(); + if (!store.hasProject(project)) return null; + const osc = store.getProjectConfig(project)?.osc; + if (osc?.beyond) return { kind: 'beyond', host: osc.beyond.host, port: osc.beyond.port }; + if (osc?.fb4) return { kind: 'fb4', host: osc.fb4.host, port: osc.fb4.port }; + return null; +} + +/** + * BEYOND's own settings, when it is installed here — the two values that mute + * OSC without any error: its receive port, and the R-G-B-A panel that gates the + * `livecontrol` colour addresses. + */ +function beyondAdvice(target: OscDebugTarget | null): OscDebugState['beyond'] { + const path = findBeyondIni(process.env, existsSync); + if (!path) return null; + try { + const settings = readBeyondSettings(readFileSync(path, 'utf8')); + return { + path, + oscPort: settings.oscPort ?? null, + showRgbaPanel: settings.showRgbaPanel ?? null, + checks: checkBeyond(settings, target?.kind === 'beyond' ? target.port : undefined) + }; + } catch { + return null; + } +} + +/** How many fixtures "all" means for this project. */ +function cannonCount(project: string): number { + const preset = openStore().getProjectConfig(project)?.layout?.preset; + try { + return resolveLayout({ preset: preset ?? 'ring-6' }).count; + } catch { + return 6; + } +} + +export function oscDebugState(project: string): OscDebugState { + const target = oscDebugTarget(project); + return { + target, + probe, + listening: listenPort, + log: [...log], + beyond: beyondAdvice(target) + }; +} + +/** Is anything bound where we send? A rejection is proof it is not. */ +export async function probeOscTarget(project: string): Promise { + const target = oscDebugTarget(project); + probe = target ? await udpProbe(target.host, target.port, PROBE_MS) : null; + return oscDebugState(project); +} + +async function deliver(target: OscDebugTarget, messages: OscMessage[]): Promise { + try { + for (const msg of messages) { + // Floats, as the show's own output stage sends them — the point of the + // panel is that these bytes are indistinguishable from a real frame's. + const values = Array.isArray(msg.value) ? msg.value : [msg.value]; + await sendOscMessage( + target.host, + target.port, + msg.address, + values.map((value) => ({ type: 'float' as const, value })) + ); + record({ + at: Date.now(), + dir: 'out', + address: msg.address, + args: values.map((v) => `f:${v}`).join(' '), + peer: `${target.host}:${target.port}` + }); + } + return { ok: true, sent: messages.length }; + } catch (err) { + return { ok: false, sent: 0, error: err instanceof Error ? err.message : String(err) }; + } +} + +/** Send one hand-typed message, so an address from BEYOND's docs can be tried. */ +export async function sendOscSignal( + project: string, + address: string, + args: string[] +): Promise { + const target = oscDebugTarget(project); + if (!target) return { ok: false, sent: 0, error: 'This project has no OSC target — set one under Output.' }; + if (!address.startsWith('/')) return { ok: false, sent: 0, error: 'An OSC address starts with "/".' }; + try { + const parsed = args.filter((a) => a.trim() !== '').map(parseOscArg); + await sendOscMessage(target.host, target.port, address, parsed); + record({ + at: Date.now(), + dir: 'out', + address, + args: parsed.map((a) => `${a.type[0]}:${a.value}`).join(' '), + peer: `${target.host}:${target.port}` + }); + return { ok: true, sent: 1 }; + } catch (err) { + return { ok: false, sent: 0, error: err instanceof Error ? err.message : String(err) }; + } +} + +/** + * A known-good frame for one fixture or all of them, encoded by the show's own + * adapters — so if this lights a laser and the show does not, the difference is + * upstream of the wire. + */ +export async function sendOscPreset( + project: string, + preset: OscDebugPreset, + zone: number | null, + serial?: string +): Promise { + const target = oscDebugTarget(project); + if (!target) return { ok: false, sent: 0, error: 'This project has no OSC target — set one under Output.' }; + + const count = cannonCount(project); + const indices = zone == null ? Array.from({ length: count }, (_, i) => i) : [zone]; + const color = preset === 'blackout' ? { h: 0, s: 0, b: 0 } : PRESET_COLORS[preset]; + const grid: CannonState[] = []; + for (const i of indices) grid[i] = { ...color }; + for (let i = 0; i < grid.length; i++) grid[i] ??= { h: 0, s: 0, b: 0 }; + + if (target.kind === 'fb4') { + if (!serial) return { ok: false, sent: 0, error: 'FB4 addressing needs the projector serial.' }; + const serialMap = Object.fromEntries(indices.map((i) => [i, serial])); + return deliver(target, encodeFB4Messages(grid, serialMap)); + } + const projectorMap = Object.fromEntries(indices.map((i) => [i, i])); + return deliver(target, encodeBeyondMessages(grid, projectorMap)); +} + +/** + * Bind a port and log what arrives. This is how you prove a send left the + * machine, and how you read what another controller is emitting. + */ +export async function startOscListen(project: string, port: number): Promise { + await stopOscListen(); + try { + listener = await listenForOsc(port, '0.0.0.0', ({ address, args, from }) => { + record({ + at: Date.now(), + dir: 'in', + address, + args: args.map((a) => String(a)).join(' '), + peer: from + }); + }); + listenPort = port; + return oscDebugState(project); + } catch (err) { + listener = null; + listenPort = null; + // Almost always EADDRINUSE: BEYOND (or the show) already holds the port. + return { ...oscDebugState(project), error: err instanceof Error ? err.message : String(err) }; + } +} + +export async function stopOscListen(): Promise { + const current = listener; + listener = null; + listenPort = null; + if (current) await current.close().catch(() => undefined); +} + +export function clearOscLog(): void { + log.length = 0; +} diff --git a/packages/desktop/src/main/osc-target.ts b/packages/desktop/src/main/osc-target.ts index 0bc5aaa..9ed2b48 100644 --- a/packages/desktop/src/main/osc-target.ts +++ b/packages/desktop/src/main/osc-target.ts @@ -1,14 +1,16 @@ // Pure helpers translating between the stored OscConfig and the flat // OscTarget the renderer binds to. Same four choices as the CLI's // `wavegrid projects osc` wizard: BEYOND, FB4, a routing file, or none. -import { normalizeOscHost, type OscConfig } from '@wavegrid/layout'; +import { + DEFAULT_BEYOND_PORT, + DEFAULT_FB4_PORT, + normalizeOscHost, + type OscConfig +} from '@wavegrid/layout'; import type { ProjectConfig } from '@wavegrid/settings'; import type { OscTarget } from '@/types/ipc'; -const DEFAULT_BEYOND_PORT = 7001; -const DEFAULT_FB4_PORT = 8000; - /** Read the stored config as the flat target the editor shows. */ export function toOscTarget(stored: ProjectConfig | null): OscTarget { const osc: OscConfig = stored?.osc ?? {}; diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index b97eb57..12f1778 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -1,6 +1,6 @@ import { contextBridge, ipcRenderer } from 'electron'; -import type { AccessKeyInfo, BrainStatus, DeviceInfo, DiscoveredBrainInfo, DoctorReport, EditableConfig, ExportResult, ImportRequest, ImportSummary, LaserSyncState, LightMapView, NetworkReport, NewProjectInput, OscTarget, ProjectSummary, RequiredSecretInfo, SessionInfo, ShardRange, StoreClearResult, StoreInfo, TrafficCaptureFile, TrafficCaptureRequest, TrafficCaptureState, TrafficDiscovery, TrafficDoctorReport, TrafficInterfaceInfo, TrafficResult, TrafficSettings, UserAccount, UserRole, WavegridApi, WavegridLaser } from '@/types/ipc'; +import type { AccessKeyInfo, BrainStatus, DeviceInfo, DiscoveredBrainInfo, DoctorReport, EditableConfig, ExportResult, ImportRequest, ImportSummary, LaserSyncState, LightMapView, NetworkReport, NewProjectInput, OscDebugState, OscSignalResult, OscTarget, ProjectSummary, RequiredSecretInfo, SessionInfo, ShardRange, StoreClearResult, StoreInfo, TrafficCaptureFile, TrafficCaptureRequest, TrafficCaptureState, TrafficDiscovery, TrafficDoctorReport, TrafficInterfaceInfo, TrafficResult, TrafficSettings, UserAccount, UserRole, WavegridApi, WavegridLaser } from '@/types/ipc'; // The single, narrow bridge exposed to the renderer. The renderer never imports // @wavegrid/settings or `fs`; everything goes through these typed calls. @@ -128,6 +128,23 @@ const api: WavegridApi = { chooseCaptureDir: () => ipcRenderer.invoke('traffic:chooseCaptureDir') as Promise }, + // OSC debugger. All local: the main process sends UDP to the project's own + // target, so there is no service to reach and no key to hold. + oscDebug: { + state: (project) => ipcRenderer.invoke('oscDebug:state', project) as Promise, + probe: (project) => ipcRenderer.invoke('oscDebug:probe', project) as Promise, + preset: (project, preset, zone, serial) => + ipcRenderer.invoke('oscDebug:preset', project, preset, zone, serial) as Promise, + send: (project, address, args) => + ipcRenderer.invoke('oscDebug:send', project, address, args) as Promise, + listen: (project, port) => + ipcRenderer.invoke('oscDebug:listen', project, port) as Promise< + OscDebugState & { error?: string } + >, + stopListen: (project) => + ipcRenderer.invoke('oscDebug:stopListen', project) as Promise, + clear: (project) => ipcRenderer.invoke('oscDebug:clear', project) as Promise + }, store: { info: () => ipcRenderer.invoke('store:info') as Promise, clear: (keepDevice) => diff --git a/packages/desktop/src/renderer/App.tsx b/packages/desktop/src/renderer/App.tsx index 21b12d9..45da6ad 100644 --- a/packages/desktop/src/renderer/App.tsx +++ b/packages/desktop/src/renderer/App.tsx @@ -29,6 +29,7 @@ import { ConfigRoute } from '@/renderer/routes/config-route'; import { DevicesRoute } from '@/renderer/routes/devices-route'; import { LightsRoute } from '@/renderer/routes/lights-route'; import { NovaRoute } from '@/renderer/routes/nova-route'; +import { OscRoute } from '@/renderer/routes/osc-route'; import { OutputRoute } from '@/renderer/routes/output-route'; import { ProjectSwitcher } from '@/renderer/routes/project-switcher'; import { ProjectsRoute } from '@/renderer/routes/projects-route'; @@ -50,6 +51,7 @@ const ROUTE_ICON: Record = { lights: Lightbulb, output: Radio, devices: Cpu, + osc: Radio, traffic: Waves, settings: Cog }; @@ -523,6 +525,8 @@ export function App() { {/* Traffic loads itself: it is the only screen that touches Wireshark, and nothing may be looked for until you open it. */} {route === 'traffic' && } + {/* OSC debugger: probes and single sends only, and only while open. */} + {route === 'osc' && } {route === 'settings' && ( = { lights: 'Lights', output: 'Output', devices: 'Devices', + osc: 'OSC', traffic: 'Traffic', settings: 'Settings' }; @@ -44,7 +46,7 @@ export interface NavGroup { export const NAV_GROUPS: NavGroup[] = [ { id: 'run', label: 'Run', routes: ['show', 'nova', 'status'] }, { id: 'setup', label: 'Set up', routes: ['config', 'lights', 'output'] }, - { id: 'advanced', label: 'Advanced', routes: ['devices', 'access', 'traffic', 'settings'] } + { id: 'advanced', label: 'Advanced', routes: ['devices', 'access', 'osc', 'traffic', 'settings'] } ]; /** Routes reachable other than from a sidebar group. */ diff --git a/packages/desktop/src/renderer/routes/osc-route.tsx b/packages/desktop/src/renderer/routes/osc-route.tsx new file mode 100644 index 0000000..b1330a9 --- /dev/null +++ b/packages/desktop/src/renderer/routes/osc-route.tsx @@ -0,0 +1,377 @@ +/** + * Advanced → OSC. The screen to open when the lasers do not respond. + * + * OSC rides UDP, so a show aimed at the wrong port looks exactly like a working + * one: nothing errors, nothing arrives. This panel replaces that silence with + * four answers — where we send, whether anything is bound there, what BEYOND's + * own settings say, and what a single hand-made message does. + * + * Sends are deliberately minimal: one message per click, at the project's own + * configured target, encoded by the same code the show uses. Nothing here starts + * an animation, and nothing needs a service or a key — it is this machine's UDP + * socket and nothing else. + */ +import { Eraser, Radio, RefreshCw, Send, Square } from 'lucide-react'; +import * as React from 'react'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle +} from '@/components/ui/empty'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Separator } from '@/components/ui/separator'; +import type { OscDebugPreset, OscDebugState, OscProbeState } from '@/types/ipc'; + +interface OscRouteProps { + activeProject: string | null; +} + +/** What each probe verdict actually means, in the operator's terms. UDP has no + * handshake, so "quiet" is the honest word for the good case. */ +const PROBE_TEXT: Record = { + refused: { + label: 'nothing listening', + tone: 'text-destructive', + detail: + 'The target rejected the packet — no program is bound to that port. Enable BEYOND’s OSC server, or point Output at the port BEYOND actually listens on.' + }, + unreachable: { + label: 'unreachable', + tone: 'text-destructive', + detail: + 'The host or network could not be reached. Check the address under Output, that the machine is on, and that a firewall is not dropping UDP.' + }, + 'no-rejection': { + label: 'no rejection', + tone: 'text-emerald-600 dark:text-emerald-400', + detail: + 'Nothing refused the packet, which is as much as UDP can prove — it is not a delivery receipt. Send a preset below and watch the rig.' + } +}; + +const PRESETS: { id: OscDebugPreset; label: string }[] = [ + { id: 'blackout', label: 'Blackout' }, + { id: 'white', label: 'Full white' }, + { id: 'amber', label: 'Full amber' } +]; + +function clock(at: number): string { + return new Date(at).toLocaleTimeString(); +} + +export function OscRoute({ activeProject }: OscRouteProps) { + const api = window.wavegrid.oscDebug; + const [state, setState] = React.useState(null); + const [zone, setZone] = React.useState(''); + const [serial, setSerial] = React.useState(''); + const [address, setAddress] = React.useState('/beyond/zone/0/livecontrol/red'); + const [args, setArgs] = React.useState('255'); + const [port, setPort] = React.useState('8000'); + const [busy, setBusy] = React.useState(''); + const [note, setNote] = React.useState(null); + + const refresh = React.useCallback(async () => { + if (!activeProject) return; + setState(await api.state(activeProject)); + }, [api, activeProject]); + + React.useEffect(() => { + void refresh(); + }, [refresh]); + + // While listening, the tail is the whole point — poll it. Cheap: a bounded + // in-memory log, and only while this screen is open. + React.useEffect(() => { + if (!activeProject || state?.listening == null) return; + const timer = setInterval(() => void refresh(), 1000); + return () => clearInterval(timer); + }, [activeProject, state?.listening, refresh]); + + if (!activeProject) { + return ( +
+ + + + + + No active project + Select a project to debug its OSC output. + + +
+ ); + } + + const target = state?.target ?? null; + const zoneIndex = zone.trim() === '' ? null : Number(zone); + + const probe = async () => { + setBusy('probe'); + setState(await api.probe(activeProject)); + setBusy(''); + }; + + const preset = async (id: OscDebugPreset) => { + setBusy(id); + setNote(null); + const result = await api.preset( + activeProject, + id, + Number.isInteger(zoneIndex) ? zoneIndex : null, + serial.trim() || undefined + ); + setNote(result.ok ? `sent ${result.sent} message(s)` : result.error ?? 'send failed'); + await refresh(); + setBusy(''); + }; + + const send = async () => { + setBusy('send'); + setNote(null); + const result = await api.send(activeProject, address.trim(), args.trim().split(/\s+/)); + setNote(result.ok ? 'sent' : result.error ?? 'send failed'); + await refresh(); + setBusy(''); + }; + + const listen = async () => { + setBusy('listen'); + setNote(null); + const next = await api.listen(activeProject, Number(port)); + setState(next); + if (next.error) setNote(next.error); + setBusy(''); + }; + + const stopListen = async () => { + setBusy('listen'); + setState(await api.stopListen(activeProject)); + setBusy(''); + }; + + return ( +
+
+
+ + + Where this project sends + + +
+ + {target ? ( +
+ {target.kind === 'beyond' ? 'BEYOND' : 'FB4'} + + {target.host}:{target.port} + +
+ ) : ( +

+ This project sends nowhere — choose BEYOND or FB4 under Set up → Output first. +

+ )} + {state?.probe && ( +
+ + {PROBE_TEXT[state.probe].label} + +

{PROBE_TEXT[state.probe].detail}

+
+ )} +
+ + {state?.beyond && ( +
+ BEYOND on this machine + + {state.beyond.path} +
+ + OSC port {state.beyond.oscPort ?? 'unknown'} + + + R-G-B-A panel{' '} + {state.beyond.showRgbaPanel == null + ? 'unknown' + : state.beyond.showRgbaPanel + ? 'shown' + : 'hidden'} + +
+ {state.beyond.checks.map((check) => ( +
+ + {check.name} — {check.detail} + + {check.remedy && ( + {check.remedy} + )} +
+ ))} +
+ )} + +
+ Send a known signal + +

+ One message per click, encoded exactly as the show encodes it. Leave the fixture blank to + address every fixture in this project’s layout. +

+
+
+ + setZone(e.target.value)} + /> +
+ {target?.kind === 'fb4' && ( +
+ + setSerial(e.target.value)} + /> +
+ )} + {PRESETS.map((p) => ( + + ))} +
+ +
+
+ + setAddress(e.target.value)} + /> +
+
+ + setArgs(e.target.value)} + /> +
+ +
+ {note &&

{note}

} +
+ +
+
+ Messages +
+ setPort(e.target.value)} + /> + {state?.listening == null ? ( + + ) : ( + + )} + +
+
+ +

+ Listening binds a port on this machine, so it cannot be the port BEYOND is using — point a + spare receiver here, or run wavegrid signals listen on the other machine. +

+ {(state?.log.length ?? 0) === 0 ? ( +

Nothing sent or received yet.

+ ) : ( +
+ {[...(state?.log ?? [])].reverse().map((entry, i) => ( +
+ {clock(entry.at)} + + {entry.dir === 'out' ? '→' : '←'} + + {entry.address} + {entry.args} + {entry.peer} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/packages/desktop/src/types/ipc.ts b/packages/desktop/src/types/ipc.ts index a4f8f82..8ad9686 100644 --- a/packages/desktop/src/types/ipc.ts +++ b/packages/desktop/src/types/ipc.ts @@ -515,6 +515,29 @@ export interface WavegridApi { /** Ask for a directory with the native picker. Null when cancelled. */ chooseCaptureDir(): Promise; }; + /** + * OSC debugger (Advanced → OSC). Local only — the desktop process talks UDP + * to the project's configured target; there is no service and no key. Sends + * are single messages, never a running show. + */ + oscDebug: { + state(project: string): Promise; + /** Probe the configured host:port for a refusal. Short, non-fatal. */ + probe(project: string): Promise; + /** A known-good frame for one fixture (`zone`) or all of them (null). */ + preset( + project: string, + preset: OscDebugPreset, + zone: number | null, + serial?: string + ): Promise; + /** One hand-typed address and its arguments, exactly as given. */ + send(project: string, address: string, args: string[]): Promise; + /** Bind a port and log what arrives — proof a send left the machine. */ + listen(project: string, port: number): Promise; + stopListen(project: string): Promise; + clear(project: string): Promise; + }; } // ── Traffic (Advanced → Traffic) ─────────────────────────────────────────── @@ -610,6 +633,56 @@ export interface TrafficResult { stderr: string; } +// ── OSC debugger (Advanced → OSC) ────────────────────────────────────────── +// +// OSC is UDP: a frame aimed at a closed port is dropped with no error, so a +// silent rig looks identical to a working one. The panel makes that visible — +// where we send, whether anything is bound there, what BEYOND's own settings +// say, and the exact bytes of a hand-sent message. + +export interface OscDebugTarget { + kind: 'beyond' | 'fb4'; + host: string; + port: number; +} + +/** What one UDP liveness probe can honestly say. `no-rejection` is not proof of + * delivery — UDP has no handshake — only the absence of a refusal. */ +export type OscProbeState = 'refused' | 'unreachable' | 'no-rejection'; + +/** One line of the panel's message tail. */ +export interface OscSignalEntry { + at: number; + dir: 'out' | 'in'; + address: string; + /** Arguments as rendered for the operator, not as encoded on the wire. */ + args: string; + peer: string; +} + +export interface OscSignalResult { + ok: boolean; + sent: number; + error?: string; +} + +export type OscDebugPreset = 'blackout' | 'white' | 'amber'; + +export interface OscDebugState { + target: OscDebugTarget | null; + probe: OscProbeState | null; + /** Port the panel is currently listening on, or null. */ + listening: number | null; + log: OscSignalEntry[]; + /** BEYOND's local settings, when BEYOND is installed on this machine. */ + beyond: { + path: string; + oscPort: number | null; + showRgbaPanel: boolean | null; + checks: DoctorCheck[]; + } | null; +} + export interface WavegridLaser { sync(state: LaserSyncState): void; } diff --git a/packages/desktop/vite.main.config.ts b/packages/desktop/vite.main.config.ts index bb959a4..c5b878f 100644 --- a/packages/desktop/vite.main.config.ts +++ b/packages/desktop/vite.main.config.ts @@ -24,6 +24,7 @@ export default defineConfig({ '@wavegrid/layout', '@wavegrid/discovery', '@wavegrid/doctor', + '@wavegrid/osc', 'ws', 'bonjour-service' ] diff --git a/packages/doctor/__tests__/beyond.test.ts b/packages/doctor/__tests__/beyond.test.ts new file mode 100644 index 0000000..d4c2050 --- /dev/null +++ b/packages/doctor/__tests__/beyond.test.ts @@ -0,0 +1,94 @@ +import { beyondIniCandidates, checkBeyond, findBeyondIni, parseIni, readBeyondSettings } from '../src/beyond'; + +const INI = [ + '; BEYOND configuration', + '[General]', + 'ShowRGBAPanel=0', + 'Language=English', + '', + '[OSC]', + 'Enable=1', + 'PortIn=8000', + 'PortOut=8001' +].join('\r\n'); + +describe('parseIni', () => { + it('reads sections and keys case-insensitively, ignoring comments', () => { + const ini = parseIni(INI); + expect(ini.general.showrgbapanel).toBe('0'); + expect(ini.osc.portin).toBe('8000'); + expect(ini.general.language).toBe('English'); + }); + + it('ignores lines that are not key=value', () => { + expect(parseIni('[OSC]\nnonsense\n=5\nPortIn=9000').osc).toEqual({ portin: '9000' }); + }); +}); + +describe('readBeyondSettings', () => { + it('extracts the OSC port, enable flag and RGBA panel', () => { + expect(readBeyondSettings(INI)).toEqual({ oscEnabled: true, oscPort: 8000, showRgbaPanel: false }); + }); + + it('leaves unknown settings undefined rather than guessing', () => { + expect(readBeyondSettings('[General]\n')).toEqual({ + oscEnabled: undefined, + oscPort: undefined, + showRgbaPanel: undefined + }); + }); +}); + +describe('beyondIniCandidates', () => { + it('honours an explicit override', () => { + expect(beyondIniCandidates({ WAVEGRID_BEYOND_INI: 'C:\\tmp\\BEYOND.ini' })).toEqual(['C:\\tmp\\BEYOND.ini']); + }); + + it('looks under the Windows data dirs otherwise', () => { + const found = beyondIniCandidates({ PROGRAMDATA: 'C:\\ProgramData' }); + expect(found).toEqual(['C:\\ProgramData\\Pangolin\\BEYOND\\BEYOND.ini']); + }); + + it('produces nothing off Windows, so the check is skipped', () => { + expect(beyondIniCandidates({})).toEqual([]); + }); +}); + +describe('findBeyondIni', () => { + it('returns the first existing candidate', () => { + const env = { PROGRAMDATA: 'C:\\ProgramData', APPDATA: 'C:\\Users\\x\\AppData\\Roaming' }; + const appdata = 'C:\\Users\\x\\AppData\\Roaming\\Pangolin\\BEYOND\\BEYOND.ini'; + expect(findBeyondIni(env, (p) => p === appdata)).toBe(appdata); + expect(findBeyondIni(env, () => false)).toBeNull(); + }); +}); + +describe('checkBeyond', () => { + it('fails when wavegrid sends to a port BEYOND is not receiving on', () => { + const [check] = checkBeyond({ oscEnabled: true, oscPort: 8000 }, 7001); + expect(check.status).toBe('fail'); + expect(check.detail).toContain('sends to :7001'); + expect(check.remedy).toContain('--port 8000'); + }); + + it('passes when the ports agree', () => { + expect(checkBeyond({ oscEnabled: true, oscPort: 8000 }, 8000)[0].status).toBe('pass'); + }); + + it('fails when BEYOND has OSC switched off', () => { + const checks = checkBeyond({ oscEnabled: false, oscPort: 8000 }, 8000); + expect(checks[0]).toMatchObject({ name: 'BEYOND OSC', status: 'fail' }); + expect(checks[0].detail).toContain('disabled'); + }); + + it('flags ShowRGBAPanel=0, which mutes the livecontrol colour addresses', () => { + const checks = checkBeyond({ oscEnabled: true, oscPort: 8000, showRgbaPanel: false }, 8000); + const rgba = checks.find((c) => c.name === 'BEYOND RGBA panel'); + expect(rgba?.status).toBe('fail'); + expect(rgba?.remedy).toContain('ShowRGBAPanel=1'); + }); + + it('says nothing when the settings are unknown', () => { + expect(checkBeyond({}, 8000)).toEqual([]); + }); +}); diff --git a/packages/doctor/__tests__/checks.test.ts b/packages/doctor/__tests__/checks.test.ts index 9ac5e18..5db31b9 100644 --- a/packages/doctor/__tests__/checks.test.ts +++ b/packages/doctor/__tests__/checks.test.ts @@ -9,6 +9,7 @@ import { checkOsc, checkShard, isSecureMode, + oscEndpoint, overallStatus } from '../src/checks'; import { dirWritable } from '../src/collect'; @@ -27,6 +28,8 @@ function config(osc: WavegridConfig['osc']): WavegridConfig { }; } +const beyondless = config({}); + describe('checkEnvHijack', () => { it('passes when no generic port/host vars are set', () => { const check = checkEnvHijack({}); @@ -71,12 +74,46 @@ describe('isSecureMode', () => { }); describe('checkOsc', () => { + const beyond = config({ beyond: { host: '10.0.0.5', port: 8000, gridOrder: 'row' } }); + it('warns when no target is configured', () => { - expect(checkOsc(config({})).status).toBe('warn'); + expect(checkOsc(beyondless).status).toBe('warn'); + }); + + it('fails when the port rejected the probe — the silent-drop case', () => { + const check = checkOsc(beyond, 'refused'); + expect(check.status).toBe('fail'); + expect(check.detail).toContain('nothing listening'); + expect(check.remedy).toContain('--port'); + }); + + it('warns when the host is unreachable rather than failing the show', () => { + expect(checkOsc(beyond, 'unreachable').status).toBe('warn'); + }); + + it('passes when nothing rejected the probe, without claiming delivery', () => { + const check = checkOsc(beyond, 'no-rejection'); + expect(check.status).toBe('pass'); + expect(check.detail).toContain('no delivery proof'); + }); + + it('says so when the target was not probed', () => { + expect(checkOsc(beyond).detail).toContain('not probed'); }); - it('passes with a BEYOND target', () => { - expect(checkOsc(config({ beyond: { host: '10.0.0.5', port: 7001, gridOrder: 'row' } })).status).toBe('pass'); + it('reads the FB4 target when there is no BEYOND one', () => { + const check = checkOsc(config({ fb4: { host: '10.0.0.9', port: 8000 } }), 'refused'); + expect(check.status).toBe('fail'); + expect(check.detail).toContain('FB4 → 10.0.0.9:8000'); + }); +}); + +describe('oscEndpoint', () => { + it('prefers BEYOND, then FB4, and gives up on a routing file', () => { + expect(oscEndpoint(config({ beyond: { host: 'b', port: 8000, gridOrder: 'row' }, fb4: { host: 'f', port: 8000 } }))) + .toMatchObject({ kind: 'BEYOND', host: 'b' }); + expect(oscEndpoint(config({ fb4: { host: 'f', port: 8000 } }))).toMatchObject({ kind: 'FB4' }); + expect(oscEndpoint(config({ routingConfig: '/tmp/routing.json' }))).toBeNull(); }); }); diff --git a/packages/doctor/__tests__/udp-probe.test.ts b/packages/doctor/__tests__/udp-probe.test.ts new file mode 100644 index 0000000..6964d6f --- /dev/null +++ b/packages/doctor/__tests__/udp-probe.test.ts @@ -0,0 +1,40 @@ +import dgram from 'dgram'; + +import { udpProbe } from '../src/probe'; + +/** Bind a UDP socket on an ephemeral port and report which one. */ +function listener(): Promise<{ port: number; close: () => void }> { + return new Promise((resolve) => { + const socket = dgram.createSocket('udp4'); + socket.bind(0, '127.0.0.1', () => { + resolve({ port: socket.address().port, close: () => socket.close() }); + }); + }); +} + +describe('udpProbe', () => { + it('reports no rejection when something is bound', async () => { + const server = await listener(); + try { + await expect(udpProbe('127.0.0.1', server.port, 300)).resolves.toBe('no-rejection'); + } finally { + server.close(); + } + }); + + it('reports refused when nothing is bound — the wrong-port case', async () => { + // Bind then release, so the port is known-free rather than merely unlikely. + const server = await listener(); + const port = server.port; + server.close(); + await expect(udpProbe('127.0.0.1', port, 1000)).resolves.toBe('refused'); + }); + + it('resolves within its timeout for an unroutable host instead of hanging', async () => { + const started = Date.now(); + // TEST-NET-1 (RFC 5737): guaranteed not to be a real host. + const state = await udpProbe('192.0.2.1', 8000, 250); + expect(['no-rejection', 'unreachable']).toContain(state); + expect(Date.now() - started).toBeLessThan(3000); + }); +}); diff --git a/packages/doctor/src/beyond.ts b/packages/doctor/src/beyond.ts new file mode 100644 index 0000000..a1d72d3 --- /dev/null +++ b/packages/doctor/src/beyond.ts @@ -0,0 +1,134 @@ +/** + * Read BEYOND's own configuration, when wavegrid is running on the same + * Windows box, and check it against what wavegrid is sending. + * + * Two settings there decide whether OSC has any effect at all, and neither is + * observable from wavegrid's side — OSC is UDP, so a mismatch looks exactly + * like a healthy show that produces no light: + * - `[OSC]` receive port, which must equal the port wavegrid sends to; + * - `[General] ShowRGBAPanel`, which gates the `livecontrol` colour + * addresses the BEYOND adapter drives. + * + * Key names have varied across BEYOND versions, so lookups are + * case-insensitive and tolerate several spellings; anything not found is + * reported as unknown rather than guessed. + */ +import type { Check } from './checks'; + +export type Ini = Record>; + +/** Parse INI text into `section → key → value`, all keys lowercased. */ +export function parseIni(text: string): Ini { + const out: Ini = {}; + let section = ''; + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (line === '' || line.startsWith(';') || line.startsWith('#')) continue; + const header = /^\[(.+)]$/.exec(line); + if (header) { + section = header[1].trim().toLowerCase(); + out[section] ??= {}; + continue; + } + const eq = line.indexOf('='); + if (eq <= 0) continue; + out[section] ??= {}; + out[section][line.slice(0, eq).trim().toLowerCase()] = line.slice(eq + 1).trim(); + } + return out; +} + +function lookup(ini: Ini, section: string, keys: string[]): string | undefined { + const values = ini[section.toLowerCase()]; + if (!values) return undefined; + for (const key of keys) { + const v = values[key.toLowerCase()]; + if (v != null && v !== '') return v; + } + return undefined; +} + +function toBool(value: string | undefined): boolean | undefined { + if (value == null) return undefined; + const v = value.trim().toLowerCase(); + if (v === '1' || v === 'true' || v === 'yes' || v === 'on') return true; + if (v === '0' || v === 'false' || v === 'no' || v === 'off') return false; + return undefined; +} + +export interface BeyondSettings { + /** OSC server on/off, when the key exists. */ + oscEnabled?: boolean; + /** The port BEYOND receives OSC on. */ + oscPort?: number; + /** R-G-B-A panel, required for the `livecontrol` colour addresses. */ + showRgbaPanel?: boolean; +} + +export function readBeyondSettings(iniText: string): BeyondSettings { + const ini = parseIni(iniText); + const port = lookup(ini, 'osc', ['portin', 'port', 'inport', 'oscport', 'port in']); + const parsedPort = port != null ? parseInt(port, 10) : NaN; + return { + oscEnabled: toBool(lookup(ini, 'osc', ['enable', 'enabled', 'oscenable', 'active'])), + oscPort: Number.isFinite(parsedPort) ? parsedPort : undefined, + showRgbaPanel: toBool(lookup(ini, 'general', ['showrgbapanel', 'showrgbpanel'])) + }; +} + +/** + * Where BEYOND.ini is looked for. `WAVEGRID_BEYOND_INI` wins, so an operator + * can point at a non-standard install instead of waiting on a code change. + */ +export function beyondIniCandidates(env: NodeJS.ProcessEnv): string[] { + const override = env.WAVEGRID_BEYOND_INI?.trim(); + if (override) return [override]; + const dirs = [env.PROGRAMDATA, env.APPDATA, env['ProgramFiles(x86)'], env.ProgramFiles] + .filter((d): d is string => typeof d === 'string' && d !== ''); + return dirs.map((dir) => `${dir}\\Pangolin\\BEYOND\\BEYOND.ini`); +} + +/** First candidate that exists, or null when BEYOND is not on this machine. */ +export function findBeyondIni(env: NodeJS.ProcessEnv, exists: (path: string) => boolean): string | null { + return beyondIniCandidates(env).find(exists) ?? null; +} + +/** + * Compare BEYOND's settings with what wavegrid sends. `sentPort` is the port + * the project's BEYOND target is aimed at, when it has one. + */ +export function checkBeyond(settings: BeyondSettings, sentPort?: number): Check[] { + const checks: Check[] = []; + + if (settings.oscEnabled === false) { + checks.push({ + name: 'BEYOND OSC', + status: 'fail', + detail: 'BEYOND.ini has its OSC server disabled — nothing wavegrid sends is read', + remedy: 'enable OSC in BEYOND (Settings → OSC), then restart BEYOND' + }); + } else if (settings.oscPort != null) { + const matches = sentPort == null || sentPort === settings.oscPort; + checks.push( + matches + ? { name: 'BEYOND OSC', status: 'pass', detail: `BEYOND receives OSC on :${settings.oscPort}` } + : { + name: 'BEYOND OSC', + status: 'fail', + detail: `wavegrid sends to :${sentPort} but BEYOND receives on :${settings.oscPort} — every frame is dropped`, + remedy: `wavegrid projects osc beyond --host --port ${settings.oscPort}` + } + ); + } + + if (settings.showRgbaPanel === false) { + checks.push({ + name: 'BEYOND RGBA panel', + status: 'fail', + detail: 'ShowRGBAPanel=0 — BEYOND ignores the livecontrol colour addresses wavegrid drives', + remedy: 'enable "Show R-G-B-A panel" in BEYOND settings (ShowRGBAPanel=1), then restart BEYOND' + }); + } + + return checks; +} diff --git a/packages/doctor/src/checks.ts b/packages/doctor/src/checks.ts index db10d78..0718d93 100644 --- a/packages/doctor/src/checks.ts +++ b/packages/doctor/src/checks.ts @@ -6,6 +6,8 @@ import type { WavegridConfig } from '@wavegrid/layout'; +import type { UdpState } from './probe'; + export type CheckStatus = 'pass' | 'warn' | 'fail'; export interface Check { @@ -66,16 +68,65 @@ export function isSecureMode(mode: number): boolean { return (mode & 0o077) === 0; } -/** Summarize whether an OSC output target is configured (informational). */ -export function checkOsc(config: WavegridConfig): Check { +/** The single OSC endpoint a probe can be aimed at, when there is one. */ +export interface OscEndpoint { + kind: 'BEYOND' | 'FB4'; + host: string; + port: number; +} + +/** + * The endpoint to probe. A routing file can name many targets, so it is left to + * `wavegrid signals` rather than guessed at here. + */ +export function oscEndpoint(config: WavegridConfig): OscEndpoint | null { if (config.osc.beyond) { - return { name: 'OSC target', status: 'pass', detail: `BEYOND → ${config.osc.beyond.host}:${config.osc.beyond.port}` }; + return { kind: 'BEYOND', host: config.osc.beyond.host, port: config.osc.beyond.port }; } if (config.osc.fb4) { - return { name: 'OSC target', status: 'pass', detail: `FB4 → ${config.osc.fb4.host}:${config.osc.fb4.port}` }; + return { kind: 'FB4', host: config.osc.fb4.host, port: config.osc.fb4.port }; + } + return null; +} + +/** + * Report the OSC output target *and whether anything is listening on it*. + * + * `probe` comes from `udpProbe`. Without it this only says what is configured, + * which is how a wrong port stayed green through a whole show: OSC is UDP, so + * every frame aimed at an unbound port is dropped in silence. + */ +export function checkOsc(config: WavegridConfig, probe?: UdpState): Check { + const endpoint = oscEndpoint(config); + if (endpoint) { + const where = `${endpoint.kind} → ${endpoint.host}:${endpoint.port}`; + if (probe === 'refused') { + return { + name: 'OSC target', + status: 'fail', + detail: `${where} — nothing listening (port unreachable)`, + remedy: + endpoint.kind === 'BEYOND' + ? `enable BEYOND's OSC server and match its receive port ([OSC] port in BEYOND.ini), then \`wavegrid projects osc beyond --host ${endpoint.host} --port \`` + : `check the FB4's OSC port, then \`wavegrid projects osc fb4 --host ${endpoint.host} --port \`` + }; + } + if (probe === 'unreachable') { + return { + name: 'OSC target', + status: 'warn', + detail: `${where} — host unreachable`, + remedy: `check the network route to ${endpoint.host} (\`ping ${endpoint.host}\`)` + }; + } + if (probe === 'no-rejection') { + // UDP gives no delivery confirmation; say so rather than imply proof. + return { name: 'OSC target', status: 'pass', detail: `${where} — port not rejecting (UDP: no delivery proof)` }; + } + return { name: 'OSC target', status: 'pass', detail: `${where} (not probed)` }; } if (config.osc.routingConfig) { - return { name: 'OSC target', status: 'pass', detail: `routing file ${config.osc.routingConfig}` }; + return { name: 'OSC target', status: 'pass', detail: `routing file ${config.osc.routingConfig} (not probed)` }; } return { name: 'OSC target', diff --git a/packages/doctor/src/collect.ts b/packages/doctor/src/collect.ts index 7ac9baf..ceec462 100644 --- a/packages/doctor/src/collect.ts +++ b/packages/doctor/src/collect.ts @@ -11,10 +11,11 @@ import { projectSecretsFile, type SettingsStore } from '@wavegrid/settings'; -import { accessSync, constants, existsSync, statSync } from 'fs'; +import { accessSync, constants, existsSync, readFileSync, statSync } from 'fs'; import { dirname } from 'path'; import { URL } from 'url'; +import { type BeyondSettings, checkBeyond, findBeyondIni, readBeyondSettings } from './beyond'; import { type Check, checkEnvHijack, @@ -22,9 +23,10 @@ import { checkShard, type CheckStatus, isSecureMode, + oscEndpoint, overallStatus } from './checks'; -import { type ProbeError, querySystemStatus, tcpProbe } from './probe'; +import { type ProbeError, querySystemStatus, tcpProbe, udpProbe, type UdpState } from './probe'; const NODE_MIN_MAJOR = 18; @@ -80,6 +82,22 @@ export interface LocalChecksInput { project: string; resolved: ResolvedConfig; env?: NodeJS.ProcessEnv; + /** Result of probing the configured OSC target, when one was probed. */ + oscProbe?: UdpState; +} + +/** + * BEYOND's own settings, when it is installed on this machine. Unreadable or + * absent means "no BEYOND here", which is the normal case off the show PC. + */ +function beyondSettings(env: NodeJS.ProcessEnv): BeyondSettings | null { + const path = findBeyondIni(env, existsSync); + if (!path) return null; + try { + return readBeyondSettings(readFileSync(path, 'utf8')); + } catch { + return null; + } } /** @@ -87,7 +105,7 @@ export interface LocalChecksInput { * resolution, because "which project" is answered differently by the CLI * (flags/env/active) and the desktop app (the selected one). */ -export function localChecks({ store, project, resolved, env = process.env }: LocalChecksInput): Check[] { +export function localChecks({ store, project, resolved, env = process.env, oscProbe }: LocalChecksInput): Check[] { const checks: Check[] = []; const { config, layout, runMode } = resolved; @@ -146,7 +164,9 @@ export function localChecks({ store, project, resolved, env = process.env }: Loc ); } - checks.push(checkOsc(config)); + checks.push(checkOsc(config, oscProbe)); + const beyond = beyondSettings(env); + if (beyond) checks.push(...checkBeyond(beyond, config.osc.beyond?.port)); checks.push(checkEnvHijack(env)); return checks; } @@ -155,12 +175,20 @@ export interface CollectInput extends LocalChecksInput { /** Override the probed brain URL (defaults to the project's server port). */ serverUrl?: string; timeoutMs?: number; + /** Budget for the OSC liveness probe. Kept short: the target is often a + * remote show PC, and a diagnostic must never hang on it. */ + oscTimeoutMs?: number; } /** Run the local checks, then read the brain's own view if it is reachable. */ export async function collectDiagnostics(input: CollectInput): Promise { - const { store, project, resolved, serverUrl, timeoutMs } = input; - const checks = localChecks(input); + const { store, project, resolved, serverUrl, timeoutMs, oscTimeoutMs } = input; + + const endpoint = oscEndpoint(resolved.config); + const oscProbe = + input.oscProbe ?? + (endpoint ? await udpProbe(endpoint.host, endpoint.port, oscTimeoutMs) : undefined); + const checks = localChecks({ ...input, oscProbe }); const url = serverUrl ?? `ws://localhost:${resolved.config.server.port}`; const parsed = new URL(url); diff --git a/packages/doctor/src/index.ts b/packages/doctor/src/index.ts index 7518a91..961c8ff 100644 --- a/packages/doctor/src/index.ts +++ b/packages/doctor/src/index.ts @@ -1,3 +1,12 @@ +export { + beyondIniCandidates, + type BeyondSettings, + checkBeyond, + findBeyondIni, + type Ini, + parseIni, + readBeyondSettings +} from './beyond'; export { type Check, checkEnvHijack, @@ -6,6 +15,8 @@ export { type CheckStatus, IGNORED_ENV_VARS, isSecureMode, + type OscEndpoint, + oscEndpoint, overallStatus } from './checks'; export { @@ -34,5 +45,7 @@ export { type ProbeError, querySystemStatus, type StatusProbe, - tcpProbe + tcpProbe, + udpProbe, + type UdpState } from './probe'; diff --git a/packages/doctor/src/probe.ts b/packages/doctor/src/probe.ts index ab7558b..05fafbb 100644 --- a/packages/doctor/src/probe.ts +++ b/packages/doctor/src/probe.ts @@ -3,6 +3,7 @@ * a diagnostic must report "not running" as a fact, never crash the caller. */ import type { SystemStatus } from '@wavegrid/server'; +import dgram from 'dgram'; import net from 'net'; import { URL } from 'url'; import { WebSocket } from 'ws'; @@ -34,6 +35,59 @@ export function tcpProbe(host: string, port: number, timeoutMs = 1500): Promise< }); } +/** + * What a UDP probe could establish. UDP has no handshake, so silence is not + * proof of a listener — but an ICMP rejection *is* proof there is none, and + * that is the failure this catches (wavegrid aimed at the wrong BEYOND port). + */ +export type UdpState = + /** Nothing rejected the datagram — a listener is plausible, not proven. */ + | 'no-rejection' + /** ICMP port-unreachable came back: nothing is bound to that port. */ + | 'refused' + /** Host/network unreachable, or the name did not resolve. */ + | 'unreachable'; + +/** Errno values the OS surfaces when ICMP says the port is not bound. */ +const REFUSED = new Set(['ECONNREFUSED', 'ECONNRESET']); + +/** + * Probe a UDP port without transmitting show data: a zero-length datagram, + * which any OSC receiver ignores. Resolves 'refused' when the kernel reports + * an ICMP rejection within `timeoutMs`, so a wrong port is caught rather than + * silently dropped for the whole event. + */ +export function udpProbe(host: string, port: number, timeoutMs = 700): Promise { + const target = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host; + return new Promise((resolve) => { + const socket = dgram.createSocket('udp4'); + let settled = false; + const done = (state: UdpState) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { socket.close(); } catch { /* already closed */ } + resolve(state); + }; + // No rejection inside the window is the best UDP can offer. + const timer = setTimeout(() => done('no-rejection'), timeoutMs); + + socket.on('error', (err: NodeJS.ErrnoException) => { + done(REFUSED.has(err.code ?? '') ? 'refused' : 'unreachable'); + }); + try { + socket.connect(port, target, () => { + // A connected socket is what makes the ICMP reply visible to us. + socket.send(Buffer.alloc(0), (err: NodeJS.ErrnoException | null) => { + if (err) done(REFUSED.has(err.code ?? '') ? 'refused' : 'unreachable'); + }); + }); + } catch { + done('unreachable'); + } + }); +} + /** Connect to a running server and request a system_status snapshot. */ export function querySystemStatus(url: string, key: string, timeoutMs = 3000): Promise { return new Promise((resolve) => { diff --git a/packages/layout/src/config.ts b/packages/layout/src/config.ts index 5b740fb..3638d07 100644 --- a/packages/layout/src/config.ts +++ b/packages/layout/src/config.ts @@ -3,6 +3,16 @@ import { createConfigLoader } from 'confstash'; import { resolveLayout } from './presets'; import { Layout, RunMode, WavegridConfig } from './types'; +/** + * BEYOND's factory OSC receive port (`[OSC] PortIn` in BEYOND.ini). Every + * default in the repo comes from here: a wrong default is invisible, because + * OSC over UDP is silently dropped when nothing is bound. + */ +export const DEFAULT_BEYOND_PORT = 8000; + +/** FB4's OSC port. */ +export const DEFAULT_FB4_PORT = 8000; + export const DEFAULT_CONFIG: WavegridConfig = { layout: { preset: 'grid-7x7' }, mode: 'auto', @@ -82,12 +92,12 @@ function envLayer(env: NodeJS.ProcessEnv): Partial { if (env.BEYOND_HOST) { osc.beyond = { host: env.BEYOND_HOST, - port: toInt(env.BEYOND_PORT) ?? 7001, + port: toInt(env.BEYOND_PORT) ?? DEFAULT_BEYOND_PORT, gridOrder: env.BEYOND_GRID_ORDER === 'column' ? 'column' : 'row' }; } if (env.FB4_HOST) { - osc.fb4 = { host: env.FB4_HOST, port: toInt(env.FB4_PORT) ?? 8000 }; + osc.fb4 = { host: env.FB4_HOST, port: toInt(env.FB4_PORT) ?? DEFAULT_FB4_PORT }; } if (env.ROUTING_CONFIG) osc.routingConfig = env.ROUTING_CONFIG; if (Object.keys(osc).length > 0) out.osc = osc; diff --git a/packages/layout/src/index.ts b/packages/layout/src/index.ts index ab2cbd5..d3f007c 100644 --- a/packages/layout/src/index.ts +++ b/packages/layout/src/index.ts @@ -76,7 +76,9 @@ export { // Config loading (confstash) + run-mode derivation export { createWavegridLoader, + DEFAULT_BEYOND_PORT, DEFAULT_CONFIG, + DEFAULT_FB4_PORT, type LoadOptions, loadWavegridConfig, type ResolvedConfig, diff --git a/packages/osc/README.md b/packages/osc/README.md index 9d14514..56bedae 100644 --- a/packages/osc/README.md +++ b/packages/osc/README.md @@ -30,7 +30,7 @@ const receiver = new Receiver({ input: new WebSocketInput({ url: 'ws://192.168.1.50:3000' }), output: new BeyondOscOutput({ host: '192.168.50.10', - port: 7001, + port: 8000, projectorMap: { 0: 0, 1: 1, 2: 2 } }) }); @@ -59,7 +59,7 @@ receiver.start(); ```json { "targets": { - "beyond-a": { "type": "beyond", "host": "192.168.50.10", "port": 7001 }, + "beyond-a": { "type": "beyond", "host": "192.168.50.10", "port": 8000 }, "fb4-b": { "type": "fb4", "host": "192.168.50.20", "port": 8000 } }, "flushHz": 30, diff --git a/packages/receiver/README.md b/packages/receiver/README.md index d782ecd..04a4234 100644 --- a/packages/receiver/README.md +++ b/packages/receiver/README.md @@ -70,7 +70,7 @@ receiver.start(); | `SHARD_END` | — | Last cannon index (inclusive) | | `ROUTING_CONFIG` | — | Path to JSON routing config (enables OSC) | | `BEYOND_HOST` | — | Quick single-target BEYOND OSC host | -| `BEYOND_PORT` | `7001` | BEYOND OSC port | +| `BEYOND_PORT` | `8000` | BEYOND OSC port | | `DEBUG_OSC` | — | Set to `1` to log all OSC messages | | `FB4_HOST` | — | Quick single-target FB4 OSC host | | `FB4_PORT` | `8000` | FB4 OSC port | diff --git a/packages/receiver/src/main.ts b/packages/receiver/src/main.ts index 25386fb..7288e75 100644 --- a/packages/receiver/src/main.ts +++ b/packages/receiver/src/main.ts @@ -15,7 +15,7 @@ * FB4_HOST/PORT Quick single-target FB4 OSC (alternative to routing file) */ -import { loadWavegridConfig, type ResolvedConfig } from '@wavegrid/layout'; +import { DEFAULT_BEYOND_PORT, loadWavegridConfig, type ResolvedConfig } from '@wavegrid/layout'; import { BeyondOscOutput, createRoutedOutput, FB4OscOutput } from '@wavegrid/osc'; import * as fs from 'fs'; import * as os from 'os'; @@ -116,7 +116,7 @@ export function startReceiver(resolved: ResolvedConfig = loadWavegridConfig()): if (process.env.BEYOND_HOST) { const host = process.env.BEYOND_HOST; - const port = parseInt(process.env.BEYOND_PORT || '7001', 10); + const port = parseInt(process.env.BEYOND_PORT || String(DEFAULT_BEYOND_PORT), 10); const gridOrder = (process.env.BEYOND_GRID_ORDER || 'row').toLowerCase(); const projectorMap: Record = {}; // Column-major reordering is only meaningful for grid layouts. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40f464a..0e75e02 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,6 +146,9 @@ importers: '@wavegrid/layout': specifier: workspace:* version: link:../layout/dist + '@wavegrid/osc': + specifier: workspace:* + version: link:../osc/dist '@wavegrid/receiver': specifier: workspace:* version: link:../receiver/dist