From da89e5279be9690bc18f65e605b955170885c654 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Wed, 19 Aug 2026 00:43:26 +0000 Subject: [PATCH] fix(desktop): give the show's receiver the selected project's OSC target The receiver reads BEYOND_HOST/FB4_HOST/ROUTING_CONFIG from process.env only, which the desktop app never set: paint reached the brain over the WebSocket and nothing reached BEYOND, while the OSC debugger (which reads the store directly) drove the lasers fine. Share the CLI's config-to-env projection from @wavegrid/layout, apply it before starting the in-process receiver, and reset config-derived keys per start so a project switch cannot keep firing at the previous project's target. Surface the receiver's outputs so a console-only show says so instead of looking healthy. --- packages/cli/src/commands/env.ts | 55 +------ .../desktop/__tests__/receiver-env.test.ts | 145 ++++++++++++++++++ .../desktop/__tests__/show-output.test.ts | 30 ++++ packages/desktop/src/main/brain.ts | 25 ++- packages/desktop/src/main/doctor.ts | 6 +- packages/desktop/src/main/receiver-env.ts | 111 ++++++++++++++ packages/desktop/src/main/runtime.ts | 1 + packages/desktop/src/renderer/App.tsx | 2 +- .../desktop/src/renderer/lib/show-output.ts | 17 ++ .../desktop/src/renderer/lib/use-wavegrid.ts | 1 + .../desktop/src/renderer/routes/osc-route.tsx | 42 ++++- .../src/renderer/routes/show-route.tsx | 13 ++ packages/desktop/src/types/ipc.ts | 4 + packages/layout/__tests__/config-env.test.ts | 103 +++++++++++++ packages/layout/src/config-env.ts | 110 +++++++++++++ packages/layout/src/index.ts | 3 + packages/receiver/src/main.ts | 9 +- 17 files changed, 605 insertions(+), 72 deletions(-) create mode 100644 packages/desktop/__tests__/receiver-env.test.ts create mode 100644 packages/desktop/__tests__/show-output.test.ts create mode 100644 packages/desktop/src/main/receiver-env.ts create mode 100644 packages/desktop/src/renderer/lib/show-output.ts create mode 100644 packages/layout/__tests__/config-env.test.ts create mode 100644 packages/layout/src/config-env.ts diff --git a/packages/cli/src/commands/env.ts b/packages/cli/src/commands/env.ts index 94bd1d3..89df562 100644 --- a/packages/cli/src/commands/env.ts +++ b/packages/cli/src/commands/env.ts @@ -1,60 +1,13 @@ -import { loadWavegridConfig, type WavegridConfig } from '@wavegrid/layout'; +import { configEnvMap, loadWavegridConfig } from '@wavegrid/layout'; import { writeFileSync } from 'fs'; import { join } from 'path'; import c from 'yanse'; import { type Flags, getStore, resolveProjectName } from '../project'; -/** - * Project the non-secret resolved config into the env-var names the server and - * receiver read directly. This is how config authored in the store or a local - * wavegrid.json (not env) reaches the runtime. - */ -export function configEnvMap(config: WavegridConfig): Record { - const env: Record = {}; - const set = (k: string, v: string | number | undefined) => { - if (v !== undefined && v !== '') env[k] = String(v); - }; - - if (config.layout.preset) set('WAVEGRID_LAYOUT', config.layout.preset); - set('WAVEGRID_MODE', config.mode); - set('WAVEGRID_HOST', config.server.host); - set('WAVEGRID_PORT', config.server.port); - set('WAVEGRID_UI_PORT', config.ui.port); - set('SIMULATOR_URL', `ws://localhost:${config.server.port}`); - - set('RECEIVER_ALPHA', config.receiver.alpha); - set('FALLBACK_DELAY', config.receiver.fallbackDelay); - if (config.receiver.shard) { - set('SHARD_START', config.receiver.shard.start); - set('SHARD_END', config.receiver.shard.end); - } - set('LIGHT_MAP_CONFIG', config.receiver.lightMap); - - if (config.osc.beyond) { - set('BEYOND_HOST', config.osc.beyond.host); - set('BEYOND_PORT', config.osc.beyond.port); - set('BEYOND_GRID_ORDER', config.osc.beyond.gridOrder); - } - if (config.osc.fb4) { - set('FB4_HOST', config.osc.fb4.host); - set('FB4_PORT', config.osc.fb4.port); - } - set('ROUTING_CONFIG', config.osc.routingConfig); - - if (config.debug.osc) set('DEBUG_OSC', '1'); - set('DEBUG_UI_PORT', config.debug.uiPort); - - return env; -} - -/** Set config-derived env vars that aren't already present (operator env wins). */ -export function applyConfigToEnv(config: WavegridConfig): void { - const map = configEnvMap(config); - for (const [k, v] of Object.entries(map)) { - if (!process.env[k]) process.env[k] = v; - } -} +// The config→env projection lives in @wavegrid/layout beside the loader that +// parses it back, so the desktop app applies exactly what the CLI does. +export { applyConfigToEnv, configEnvMap } from '@wavegrid/layout'; /** * Build the `.env` lines for a project from its resolved config + secrets. diff --git a/packages/desktop/__tests__/receiver-env.test.ts b/packages/desktop/__tests__/receiver-env.test.ts new file mode 100644 index 0000000..39490e2 --- /dev/null +++ b/packages/desktop/__tests__/receiver-env.test.ts @@ -0,0 +1,145 @@ +/** + * The desktop show has to reach the same lasers the OSC debugger does. + * + * `startReceiver` takes its OSC target from `process.env` alone — the resolved + * config it is handed is only geometry — so the desktop app used to start a + * receiver with no target at all: paint reached the brain over the WebSocket, + * the show looked healthy, and nothing reached BEYOND. These tests pin the env + * the receiver is handed for a given project. + */ +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + DEFAULT_CONFIG, + loadWavegridConfig, + type ResolvedConfig, + type WavegridConfig +} from '@wavegrid/layout'; +import type { SettingsStore } from '@wavegrid/settings'; + +import { applyReceiverEnv, resolveProjectConfig } from '@/main/receiver-env'; + +const root = mkdtempSync(join(tmpdir(), 'wavegrid-receiver-env-')); + +/** Just the store surface the receiver env needs. */ +const store = { + requireSecret: () => 'receiver-key', + stateDir: (project: string) => join(root, project, 'state'), + logsDir: (project: string) => join(root, project, 'logs'), + getDevice: () => ({ id: 'device-1', name: 'booth' }), + getDeviceRecord: () => ({ shard: { start: 0, end: 11 } }) +} as unknown as SettingsStore; + +/** Resolve a project config the way the appstash mirror would, env-free. */ +function resolve(config: Partial): ResolvedConfig { + return loadWavegridConfig({ + cwd: join(root, 'nonexistent'), + env: {}, + overrides: { ...DEFAULT_CONFIG, ...config } + }); +} + +const beyond = resolve({ + layout: { preset: 'grace-cathedral' }, + osc: { beyond: { host: '10.0.0.5', port: 8000, gridOrder: 'row' } } +}); +const consoleOnly = resolve({ osc: {} }); + +/** Keys `applyReceiverEnv` owns, cleared so each test starts from a cold app. */ +const OWNED = [ + 'BEYOND_HOST', + 'BEYOND_PORT', + 'BEYOND_GRID_ORDER', + 'FB4_HOST', + 'FB4_PORT', + 'ROUTING_CONFIG', + 'SHARD_START', + 'SHARD_END', + 'WAVEGRID_LAYOUT', + 'WG_RECEIVER_KEY', + 'WG_STATE_DIR', + 'WG_DEVICE_ID', + 'WG_DEVICE_NAME', + 'RECEIVER_LOG' +]; + +beforeEach(() => { + for (const key of OWNED) delete process.env[key]; +}); + +describe('applyReceiverEnv', () => { + it('hands the receiver the project’s BEYOND target', () => { + applyReceiverEnv(store, 'grace', beyond); + expect(process.env.BEYOND_HOST).toBe('10.0.0.5'); + expect(process.env.BEYOND_PORT).toBe('8000'); + expect(process.env.BEYOND_GRID_ORDER).toBe('row'); + expect(process.env.WAVEGRID_LAYOUT).toBe('grace-cathedral'); + }); + + it('hands it an FB4 target', () => { + applyReceiverEnv(store, 'fb4', resolve({ osc: { fb4: { host: '192.168.1.40', port: 8000 } } })); + expect(process.env.FB4_HOST).toBe('192.168.1.40'); + expect(process.env.FB4_PORT).toBe('8000'); + }); + + // The desktop app is long-lived: one process starts many projects, so a stale + // target here would keep firing at the previous project's lasers. + it('drops the previous project’s target on a switch', () => { + applyReceiverEnv(store, 'grace', beyond); + applyReceiverEnv(store, 'rehearsal', consoleOnly); + expect(process.env.BEYOND_HOST).toBeUndefined(); + expect(process.env.BEYOND_PORT).toBeUndefined(); + expect(process.env.ROUTING_CONFIG).toBeUndefined(); + }); + + it('repoints at the switched-to project’s own target', () => { + applyReceiverEnv(store, 'grace', beyond); + applyReceiverEnv( + store, + 'other', + resolve({ osc: { beyond: { host: '10.0.0.9', port: 7001, gridOrder: 'column' } } }) + ); + expect(process.env.BEYOND_HOST).toBe('10.0.0.9'); + expect(process.env.BEYOND_PORT).toBe('7001'); + }); + + it('points at a routing file generated from a multi-target spec', () => { + const routed = resolve({ + layout: { preset: 'grace-cathedral' }, + osc: { + routing: { + targets: { + left: { type: 'beyond', host: '10.0.0.5', port: 8000 }, + right: { type: 'beyond', host: '10.0.0.6', port: 8000 } + }, + cannons: Array.from({ length: 25 }, (_, logical) => ({ + logical, + target: logical < 13 ? 'left' : 'right' + })) + } + } + }); + applyReceiverEnv(store, 'routed', routed); + expect(process.env.ROUTING_CONFIG).toBe(join(root, 'routed', 'state', 'routing', 'this-device.json')); + }); + + it('names the project’s own state dir and receiver log', () => { + applyReceiverEnv(store, 'grace', beyond); + expect(process.env.WG_STATE_DIR).toBe(join(root, 'grace', 'state')); + expect(process.env.RECEIVER_LOG).toBe(join(root, 'grace', 'logs', 'receiver.log')); + expect(process.env.WG_DEVICE_NAME).toBe('booth'); + }); +}); + +describe('resolveProjectConfig', () => { + // Env outranks the project layer in the loader, and applyReceiverEnv writes + // into env — so reading the live env would resolve one project's show against + // another project's lasers. + it('ignores the env the app itself wrote', () => { + applyReceiverEnv(store, 'grace', beyond); + expect(process.env.BEYOND_HOST).toBe('10.0.0.5'); + expect(resolveProjectConfig().config.osc.beyond?.host).not.toBe('10.0.0.5'); + }); +}); diff --git a/packages/desktop/__tests__/show-output.test.ts b/packages/desktop/__tests__/show-output.test.ts new file mode 100644 index 0000000..1d5c084 --- /dev/null +++ b/packages/desktop/__tests__/show-output.test.ts @@ -0,0 +1,30 @@ +import { hasOscOutput, oscOutputs } from '@/renderer/lib/show-output'; +import type { BrainStatus } from '@/types/ipc'; + +const status = (receiverOutputs: string[]): BrainStatus => ({ + running: true, + url: 'http://127.0.0.1:3000', + project: 'grace', + runMode: 'simple', + receiverRunning: true, + lanUrls: [], + receiverError: null, + receiverOutputs, + lastError: null +}); + +describe('what the show is driving', () => { + // A receiver with no target starts cleanly and reports no error, so this is + // the only signal that separates a healthy show from a dark rig. + it('treats a console-only receiver as no OSC output', () => { + expect(hasOscOutput(status(['Console']))).toBe(false); + expect(hasOscOutput(status([]))).toBe(false); + expect(oscOutputs(status(['Console']))).toEqual([]); + }); + + it('recognises any real output', () => { + const driving = status(['Console', 'BEYOND OSC → 10.0.0.5:8000 (row-major, rgb)']); + expect(hasOscOutput(driving)).toBe(true); + expect(oscOutputs(driving)).toEqual(['BEYOND OSC → 10.0.0.5:8000 (row-major, rgb)']); + }); +}); diff --git a/packages/desktop/src/main/brain.ts b/packages/desktop/src/main/brain.ts index efe5e7d..1189318 100644 --- a/packages/desktop/src/main/brain.ts +++ b/packages/desktop/src/main/brain.ts @@ -9,11 +9,12 @@ import { createRequire } from 'node:module'; import { networkInterfaces } from 'node:os'; import { join } from 'node:path'; -import { loadWavegridConfig, type ResolvedConfig } from '@wavegrid/layout'; +import type { ResolvedConfig } from '@wavegrid/layout'; import type { ReceiverHandle } from '@wavegrid/receiver'; import type { ServerHandle } from '@wavegrid/server'; import { openStore, type SettingsStore } from '@wavegrid/settings'; +import { applyReceiverEnv, resolveProjectConfig } from '@/main/receiver-env'; import { runtime, sendToRenderer } from '@/main/runtime'; import type { BrainStatus } from '@/types/ipc'; @@ -70,15 +71,6 @@ function applyServerEnv(store: SettingsStore, project: string): void { if (uiDir) process.env.WG_UI_DIR = uiDir; } -function applyReceiverEnv(store: SettingsStore, project: string): void { - if (!process.env.WG_RECEIVER_KEY) process.env.WG_RECEIVER_KEY = store.requireSecret(project, 'receiverKey'); - process.env.WG_STATE_DIR = store.stateDir(project); - process.env.RECEIVER_LOG = join(store.logsDir(project), 'receiver.log'); - const device = store.getDevice(); - process.env.WG_DEVICE_ID = device.id; - process.env.WG_DEVICE_NAME = device.name; -} - export function status(): BrainStatus { const s: BrainStatus = current ? { @@ -89,6 +81,7 @@ export function status(): BrainStatus { receiverRunning: current.receiver != null, lanUrls: lanAddresses().map((ip) => `http://${ip}:${new URL(current!.url).port}`), receiverError: current.receiverError, + receiverOutputs: current.receiver?.outputs ?? [], lastError: null } : { @@ -99,6 +92,7 @@ export function status(): BrainStatus { receiverRunning: false, lanUrls: [], receiverError: null, + receiverOutputs: [], lastError }; runtime.lastStatus = s; @@ -128,9 +122,8 @@ async function start(project: string): Promise { if (!store.hasProject(project)) throw new Error(`Unknown project: ${project}`); if (store.getActiveProject() !== project) store.setActiveProject(project); - const resolved: ResolvedConfig = loadWavegridConfig(); + const resolved: ResolvedConfig = resolveProjectConfig(); applyServerEnv(store, project); - applyReceiverEnv(store, project); const { startServer } = await import('@wavegrid/server'); const server = startServer(resolved); @@ -146,6 +139,7 @@ async function start(project: string): Promise { let receiver: ReceiverHandle | null = null; let receiverError: string | null = null; try { + applyReceiverEnv(store, project, resolved); const { startReceiver } = await import('@wavegrid/receiver'); receiver = startReceiver(resolved); } catch (err) { @@ -174,7 +168,7 @@ async function start(project: string): Promise { export function runningBind(): { host: string; port: number } | null { if (!current) return null; return { - host: loadWavegridConfig().config.server.host, + host: resolveProjectConfig().config.server.host, port: Number(new URL(current.url).port) }; } @@ -195,10 +189,11 @@ export async function startLocalReceiver(): Promise { if (current.receiver) return status(); const store = openStore(); - applyReceiverEnv(store, current.project); const { startReceiver } = await import('@wavegrid/receiver'); try { - current.receiver = startReceiver(loadWavegridConfig()); + const resolved = resolveProjectConfig(); + applyReceiverEnv(store, current.project, resolved); + current.receiver = startReceiver(resolved); current.receiverError = null; } catch (err) { current.receiverError = err instanceof Error ? err.message : String(err); diff --git a/packages/desktop/src/main/doctor.ts b/packages/desktop/src/main/doctor.ts index 9ca8bc7..ca3098f 100644 --- a/packages/desktop/src/main/doctor.ts +++ b/packages/desktop/src/main/doctor.ts @@ -5,11 +5,11 @@ * time formatting happens here so the renderer stays free of node deps. */ import { collectDiagnostics } from '@wavegrid/doctor'; -import { loadWavegridConfig } from '@wavegrid/layout'; import { formatRanges } from '@wavegrid/server'; import { openStore } from '@wavegrid/settings'; import { receiverRunning } from '@/main/brain'; +import { resolveProjectConfig } from '@/main/receiver-env'; import type { DoctorReceiver, DoctorReport } from '@/types/ipc'; export async function buildDoctorReport(project: string): Promise { @@ -17,7 +17,9 @@ export async function buildDoctorReport(project: string): Promise diag.devices.find((d) => d.id === id)?.name ?? `${id.slice(0, 8)}…`; diff --git a/packages/desktop/src/main/receiver-env.ts b/packages/desktop/src/main/receiver-env.ts new file mode 100644 index 0000000..e646051 --- /dev/null +++ b/packages/desktop/src/main/receiver-env.ts @@ -0,0 +1,111 @@ +/** + * The env the in-process receiver reads, derived from the selected project. + * + * `startReceiver` takes its OSC target from `process.env` alone — the resolved + * config it is handed is only geometry — so an embedding host that skips this + * gets a receiver with no output: paint reaches the brain over the WebSocket, + * the show looks healthy, and nothing reaches BEYOND. `wavegrid start` projects + * config into env before starting its receiver; this is the desktop's copy of + * that step, sharing the projection in @wavegrid/layout so the two can't drift. + */ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { + generateDeviceRouting, + loadWavegridConfig, + resetConfigEnv, + type ResolvedConfig, + RoutingValidationError +} from '@wavegrid/layout'; +import type { SettingsStore } from '@wavegrid/settings'; + +/** + * The env this process was launched with. Anything here is an explicit operator + * override and outranks project config, exactly as in the CLI; every other + * config-derived key is ours to rewrite on each project start. + */ +const ambientEnv: NodeJS.ProcessEnv = { ...process.env }; + +/** + * The selected project's config, as the appstash holds it. Resolved against the + * *ambient* env, never the live one: `applyReceiverEnv` writes a project's + * target into `process.env`, and env outranks the project layer in the loader — + * so reading the live env would resolve project B's show against project A's + * lasers. One store, one project, everywhere. + */ +export function resolveProjectConfig(): ResolvedConfig { + return loadWavegridConfig({ env: ambientEnv }); +} + +export function applyReceiverEnv( + store: SettingsStore, + project: string, + resolved: ResolvedConfig +): void { + if (!process.env.WG_RECEIVER_KEY) { + process.env.WG_RECEIVER_KEY = store.requireSecret(project, 'receiverKey'); + } + resetConfigEnv(resolved.config, ambientEnv); + process.env.WG_STATE_DIR = store.stateDir(project); + process.env.RECEIVER_LOG = join(store.logsDir(project), 'receiver.log'); + const device = store.getDevice(); + process.env.WG_DEVICE_ID = device.id; + process.env.WG_DEVICE_NAME = device.name; + if (resolved.runMode === 'distributed') applyAssignedShard(store, project, device.id); + applyGeneratedRouting(store, project, resolved, device.name); +} + +/** This laptop's operator-assigned shard, unless the env already pinned one. */ +function applyAssignedShard(store: SettingsStore, project: string, deviceId: string): void { + if (ambientEnv.SHARD_START !== undefined) return; + const record = store.getDeviceRecord(project, deviceId); + if (record?.shard) { + process.env.SHARD_START = String(record.shard.start); + process.env.SHARD_END = String(record.shard.end); + } +} + +/** + * Turn the project's unified routing spec into this laptop's routing file and + * point the receiver at it — the same derived state `wavegrid start` writes, so + * a multi-target project drives its lasers from the desktop app too. A config or + * ambient `ROUTING_CONFIG` wins: that is the hand-written escape hatch. + */ +function applyGeneratedRouting( + store: SettingsStore, + project: string, + resolved: ResolvedConfig, + deviceName: string +): string | null { + const spec = resolved.config.osc.routing; + if (!spec || process.env.ROUTING_CONFIG) return null; + + const start = process.env.SHARD_START; + const end = process.env.SHARD_END; + const shard = + start !== undefined && end !== undefined + ? { start: parseInt(start, 10), end: parseInt(end, 10) } + : undefined; + try { + const { devices } = generateDeviceRouting( + spec, + [{ name: deviceName, ...(shard ? { shard } : {}) }], + resolved.layout.count + ); + const dir = join(store.stateDir(project), 'routing'); + mkdirSync(dir, { recursive: true }); + const file = join(dir, 'this-device.json'); + writeFileSync(file, `${JSON.stringify(devices[0], null, 2)}\n`); + process.env.ROUTING_CONFIG = file; + return file; + } catch (err) { + // Emitting routing we know is wrong would light the wrong lasers, which is + // worse than no OSC output — refuse, and let the caller report it. + const problems = + err instanceof RoutingValidationError ? err.problems : [(err as Error).message]; + throw new Error( + `Routing spec for ${project} is invalid — OSC output disabled: ${problems.join('; ')}` + ); + } +} diff --git a/packages/desktop/src/main/runtime.ts b/packages/desktop/src/main/runtime.ts index 87e8aa2..7830203 100644 --- a/packages/desktop/src/main/runtime.ts +++ b/packages/desktop/src/main/runtime.ts @@ -18,6 +18,7 @@ export const runtime: Runtime = { receiverRunning: false, lanUrls: [], receiverError: null, + receiverOutputs: [], lastError: null } }; diff --git a/packages/desktop/src/renderer/App.tsx b/packages/desktop/src/renderer/App.tsx index 45da6ad..63d363a 100644 --- a/packages/desktop/src/renderer/App.tsx +++ b/packages/desktop/src/renderer/App.tsx @@ -526,7 +526,7 @@ export function App() { 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 === 'osc' && } {route === 'settings' && ( label !== 'Console'); +} + +/** The OSC outputs only, for display ('Console' is never news to an operator). */ +export function oscOutputs(status: BrainStatus): string[] { + return status.receiverOutputs.filter((label) => label !== 'Console'); +} diff --git a/packages/desktop/src/renderer/lib/use-wavegrid.ts b/packages/desktop/src/renderer/lib/use-wavegrid.ts index 7585766..15895f6 100644 --- a/packages/desktop/src/renderer/lib/use-wavegrid.ts +++ b/packages/desktop/src/renderer/lib/use-wavegrid.ts @@ -32,6 +32,7 @@ const EMPTY_STATUS: BrainStatus = { receiverRunning: false, lanUrls: [], receiverError: null, + receiverOutputs: [], lastError: null }; diff --git a/packages/desktop/src/renderer/routes/osc-route.tsx b/packages/desktop/src/renderer/routes/osc-route.tsx index b1330a9..bc3f14f 100644 --- a/packages/desktop/src/renderer/routes/osc-route.tsx +++ b/packages/desktop/src/renderer/routes/osc-route.tsx @@ -26,10 +26,12 @@ import { 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'; +import { oscOutputs } from '@/renderer/lib/show-output'; +import type { BrainStatus, OscDebugPreset, OscDebugState, OscProbeState } from '@/types/ipc'; interface OscRouteProps { activeProject: string | null; + status: BrainStatus; } /** What each probe verdict actually means, in the operator's terms. UDP has no @@ -65,7 +67,7 @@ function clock(at: number): string { return new Date(at).toLocaleTimeString(); } -export function OscRoute({ activeProject }: OscRouteProps) { +export function OscRoute({ activeProject, status: brain }: OscRouteProps) { const api = window.wavegrid.oscDebug; const [state, setState] = React.useState(null); const [zone, setZone] = React.useState(''); @@ -192,6 +194,42 @@ export function OscRoute({ activeProject }: OscRouteProps) { )} + {/* The question this panel exists to answer: the buttons below send from + here, but the show sends from the receiver — and until now nothing said + whether the receiver had a target at all. */} +
+ What the running show is driving + + {!brain.running ? ( +

+ No show running. The buttons below still send — they do not need one. +

+ ) : brain.project !== activeProject ? ( +

+ The running show is {brain.project}, but you are editing and debugging{' '} + {activeProject} — they can have different targets. Start the show on + this project before trusting what you see here. +

+ ) : brain.receiverError ? ( +

+ The receiver isn’t running: {brain.receiverError} +

+ ) : oscOutputs(brain).length === 0 ? ( +

+ Console only — the receiver is running with no OSC output, so painting reaches the brain + and nothing reaches BEYOND. Set a target under Set up → Output, then restart the show. +

+ ) : ( +
+ {oscOutputs(brain).map((label) => ( + + {label} + + ))} +
+ )} +
+ {state?.beyond && (
BEYOND on this machine diff --git a/packages/desktop/src/renderer/routes/show-route.tsx b/packages/desktop/src/renderer/routes/show-route.tsx index 8df8098..df826cf 100644 --- a/packages/desktop/src/renderer/routes/show-route.tsx +++ b/packages/desktop/src/renderer/routes/show-route.tsx @@ -12,6 +12,7 @@ import { EmptyTitle } from '@/components/ui/empty'; import { watchOverlays } from '@/renderer/lib/overlay-present'; +import { hasOscOutput } from '@/renderer/lib/show-output'; import { ShareShow } from '@/renderer/routes/share-show'; import type { BrainStatus } from '@/types/ipc'; @@ -105,6 +106,18 @@ export function ShowRoute({ status, activeProject, onStart, onStop, busy }: Show )} + {/* The receiver can start perfectly and still drive nothing, which looks + identical to a healthy show until someone notices the lasers are dark. */} + {running && !status.receiverError && status.receiverRunning && !hasOscOutput(status) && ( +
+ + + Console only — no OSC output. Painting reaches the + brain, but this project has no OSC target, so nothing is sent to BEYOND. Set one in + Advanced → OSC (or wavegrid projects osc), then restart the show. + +
+ )}
{running ? ( diff --git a/packages/desktop/src/types/ipc.ts b/packages/desktop/src/types/ipc.ts index 8ad9686..21a4af7 100644 --- a/packages/desktop/src/types/ipc.ts +++ b/packages/desktop/src/types/ipc.ts @@ -17,6 +17,10 @@ export interface BrainStatus { /** Why the output stage isn't running while the brain is up (OSC target, * network) — the show plays on screen but nothing reaches the lasers. */ receiverError: string | null; + /** What the running receiver is driving, e.g. `['Console', 'BEYOND OSC → + * 10.0.0.5:8000 (row-major, rgb)']`. `['Console']` alone means the show is + * running with no OSC output — the lasers stay dark. */ + receiverOutputs: string[]; /** Why the last start attempt failed, while the brain is down. Cleared by a * successful start. */ lastError: string | null; diff --git a/packages/layout/__tests__/config-env.test.ts b/packages/layout/__tests__/config-env.test.ts new file mode 100644 index 0000000..8694921 --- /dev/null +++ b/packages/layout/__tests__/config-env.test.ts @@ -0,0 +1,103 @@ +import { DEFAULT_CONFIG, loadWavegridConfig } from '../src/config'; +import { applyConfigToEnv, CONFIG_ENV_KEYS, configEnvMap, resetConfigEnv } from '../src/config-env'; +import type { WavegridConfig } from '../src/types'; + +const beyondProject: WavegridConfig = { + ...DEFAULT_CONFIG, + layout: { preset: 'grace-cathedral' }, + osc: { beyond: { host: '10.0.0.5', port: 8000, gridOrder: 'row' } } +}; + +const consoleOnlyProject: WavegridConfig = { ...DEFAULT_CONFIG, osc: {} }; + +describe('configEnvMap', () => { + it('projects a BEYOND target the receiver can find', () => { + const env = configEnvMap(beyondProject); + expect(env.BEYOND_HOST).toBe('10.0.0.5'); + expect(env.BEYOND_PORT).toBe('8000'); + expect(env.BEYOND_GRID_ORDER).toBe('row'); + expect(env.WAVEGRID_LAYOUT).toBe('grace-cathedral'); + }); + + it('projects an FB4 target', () => { + const env = configEnvMap({ ...DEFAULT_CONFIG, osc: { fb4: { host: '192.168.1.40', port: 8000 } } }); + expect(env.FB4_HOST).toBe('192.168.1.40'); + expect(env.FB4_PORT).toBe('8000'); + expect(env.BEYOND_HOST).toBeUndefined(); + }); + + it('names no OSC key for a project that sends nowhere', () => { + const env = configEnvMap(consoleOnlyProject); + for (const key of ['BEYOND_HOST', 'FB4_HOST', 'ROUTING_CONFIG']) { + expect(env[key]).toBeUndefined(); + } + }); + + it('only produces keys it declares as its own', () => { + const keys = Object.keys( + configEnvMap({ + ...beyondProject, + receiver: { ...DEFAULT_CONFIG.receiver, shard: { start: 0, end: 11 }, lightMap: '/map.json' }, + debug: { osc: true, uiPort: 3099 } + }) + ); + expect(keys.filter((k) => !CONFIG_ENV_KEYS.includes(k))).toEqual([]); + }); + + // The projection is only useful if the loader reads back what it wrote. + it('round-trips through the loader that parses it', () => { + const resolved = loadWavegridConfig({ env: configEnvMap(beyondProject), cwd: '/nonexistent' }); + expect(resolved.config.osc.beyond).toEqual({ host: '10.0.0.5', port: 8000, gridOrder: 'row' }); + expect(resolved.layout.count).toBe(25); + }); +}); + +describe('applyConfigToEnv', () => { + it('fills config values without touching what the operator set', () => { + const env: NodeJS.ProcessEnv = { BEYOND_HOST: '127.0.0.1' }; + applyConfigToEnv(beyondProject, env); + expect(env.BEYOND_HOST).toBe('127.0.0.1'); + expect(env.BEYOND_PORT).toBe('8000'); + }); +}); + +describe('resetConfigEnv', () => { + it('clears the previous project’s target instead of leaving it firing', () => { + const env: NodeJS.ProcessEnv = {}; + resetConfigEnv(beyondProject, {}, env); + expect(env.BEYOND_HOST).toBe('10.0.0.5'); + + resetConfigEnv(consoleOnlyProject, {}, env); + expect(env.BEYOND_HOST).toBeUndefined(); + expect(env.BEYOND_PORT).toBeUndefined(); + }); + + it('repoints a switched project at its own target', () => { + const env: NodeJS.ProcessEnv = {}; + resetConfigEnv(beyondProject, {}, env); + resetConfigEnv({ ...DEFAULT_CONFIG, osc: { beyond: { host: '10.0.0.9', port: 7001, gridOrder: 'column' } } }, {}, env); + expect(env.BEYOND_HOST).toBe('10.0.0.9'); + expect(env.BEYOND_PORT).toBe('7001'); + expect(env.BEYOND_GRID_ORDER).toBe('column'); + }); + + it('keeps an env the host process was launched with as an override', () => { + const ambient: NodeJS.ProcessEnv = { BEYOND_HOST: '127.0.0.1' }; + const env: NodeJS.ProcessEnv = { ...ambient }; + resetConfigEnv(beyondProject, ambient, env); + expect(env.BEYOND_HOST).toBe('127.0.0.1'); + expect(env.BEYOND_PORT).toBe('8000'); + + // ...and restores it even after another project overwrote the live env. + env.BEYOND_HOST = '10.0.0.5'; + resetConfigEnv(consoleOnlyProject, ambient, env); + expect(env.BEYOND_HOST).toBe('127.0.0.1'); + }); + + it('leaves env it does not own alone', () => { + const env: NodeJS.ProcessEnv = { WG_JWT_SECRET: 'secret', PATH: '/usr/bin' }; + resetConfigEnv(beyondProject, {}, env); + expect(env.WG_JWT_SECRET).toBe('secret'); + expect(env.PATH).toBe('/usr/bin'); + }); +}); diff --git a/packages/layout/src/config-env.ts b/packages/layout/src/config-env.ts new file mode 100644 index 0000000..f9f1098 --- /dev/null +++ b/packages/layout/src/config-env.ts @@ -0,0 +1,110 @@ +/** + * Project a resolved config back into the env-var names the server and receiver + * read directly — the inverse of `envLayer` in ./config. + * + * This lives beside the loader on purpose: the receiver reads its OSC target + * from `process.env` alone, so any process that embeds `startReceiver` (the CLI + * *and* the desktop app) has to run config through here first. A host that + * skipped it got a receiver with no OSC output at all — the show looked healthy + * over the WebSocket while nothing reached the lasers. + */ +import type { WavegridConfig } from './types'; + +export function configEnvMap(config: WavegridConfig): Record { + const env: Record = {}; + const set = (k: string, v: string | number | undefined) => { + if (v !== undefined && v !== '') env[k] = String(v); + }; + + if (config.layout.preset) set('WAVEGRID_LAYOUT', config.layout.preset); + set('WAVEGRID_MODE', config.mode); + set('WAVEGRID_HOST', config.server.host); + set('WAVEGRID_PORT', config.server.port); + set('WAVEGRID_UI_PORT', config.ui.port); + set('SIMULATOR_URL', `ws://localhost:${config.server.port}`); + + set('RECEIVER_ALPHA', config.receiver.alpha); + set('FALLBACK_DELAY', config.receiver.fallbackDelay); + if (config.receiver.shard) { + set('SHARD_START', config.receiver.shard.start); + set('SHARD_END', config.receiver.shard.end); + } + set('LIGHT_MAP_CONFIG', config.receiver.lightMap); + + if (config.osc.beyond) { + set('BEYOND_HOST', config.osc.beyond.host); + set('BEYOND_PORT', config.osc.beyond.port); + set('BEYOND_GRID_ORDER', config.osc.beyond.gridOrder); + } + if (config.osc.fb4) { + set('FB4_HOST', config.osc.fb4.host); + set('FB4_PORT', config.osc.fb4.port); + } + set('ROUTING_CONFIG', config.osc.routingConfig); + + if (config.debug.osc) set('DEBUG_OSC', '1'); + set('DEBUG_UI_PORT', config.debug.uiPort); + + return env; +} + +/** Every key `configEnvMap` can own, whether or not this config sets it. */ +export const CONFIG_ENV_KEYS: readonly string[] = [ + 'WAVEGRID_LAYOUT', + 'WAVEGRID_MODE', + 'WAVEGRID_HOST', + 'WAVEGRID_PORT', + 'WAVEGRID_UI_PORT', + 'SIMULATOR_URL', + 'RECEIVER_ALPHA', + 'FALLBACK_DELAY', + 'SHARD_START', + 'SHARD_END', + 'LIGHT_MAP_CONFIG', + 'BEYOND_HOST', + 'BEYOND_PORT', + 'BEYOND_GRID_ORDER', + 'FB4_HOST', + 'FB4_PORT', + 'ROUTING_CONFIG', + 'DEBUG_OSC', + 'DEBUG_UI_PORT' +]; + +/** + * Set config-derived env vars that aren't already present — operator env wins. + * Right for a one-shot CLI process, where the ambient env is the operator's + * explicit intent. + */ +export function applyConfigToEnv(config: WavegridConfig, env: NodeJS.ProcessEnv = process.env): void { + const map = configEnvMap(config); + for (const [k, v] of Object.entries(map)) { + if (!env[k]) env[k] = v; + } +} + +/** + * Make the env match `config` exactly, clearing config-derived keys this config + * doesn't set — except keys the host process started with, which stay operator + * overrides. + * + * A long-lived host (the desktop app) starts many projects in one process, so + * "don't overwrite what's there" would pin the whole session to whichever + * project started first: switch from a BEYOND project to a console-only one and + * the old target would keep firing. + */ +export function resetConfigEnv( + config: WavegridConfig, + ambient: NodeJS.ProcessEnv, + env: NodeJS.ProcessEnv = process.env +): void { + const map = configEnvMap(config); + for (const key of CONFIG_ENV_KEYS) { + if (ambient[key] != null) { + env[key] = ambient[key]; + continue; + } + if (map[key] != null) env[key] = map[key]; + else delete env[key]; + } +} diff --git a/packages/layout/src/index.ts b/packages/layout/src/index.ts index d3f007c..4f53942 100644 --- a/packages/layout/src/index.ts +++ b/packages/layout/src/index.ts @@ -73,6 +73,9 @@ export { validateUnifiedRouting } from './routing'; +// Config → env projection (the receiver reads its OSC target from env only) +export { applyConfigToEnv, CONFIG_ENV_KEYS, configEnvMap, resetConfigEnv } from './config-env'; + // Config loading (confstash) + run-mode derivation export { createWavegridLoader, diff --git a/packages/receiver/src/main.ts b/packages/receiver/src/main.ts index 7288e75..f9d47f7 100644 --- a/packages/receiver/src/main.ts +++ b/packages/receiver/src/main.ts @@ -28,6 +28,13 @@ import { Receiver, ShardConfig } from './receiver'; export interface ReceiverHandle { receiver: Receiver; stop: () => void; + /** + * What this receiver is actually driving, e.g. `['Console', 'BEYOND OSC → + * 127.0.0.1:8000 (row-major, rgb)']`. Surfaced so an embedding app can tell + * an operator that the show is console-only instead of leaving them to guess + * from dark lasers. + */ + outputs: string[]; } const RECEIVER_VERSION = '0.4.1'; @@ -228,7 +235,7 @@ export function startReceiver(resolved: ResolvedConfig = loadWavegridConfig()): console.log(` → Log file: ${LOG_FILE}`); const stop = () => receiver.stop(); - return { receiver, stop }; + return { receiver, stop, outputs: outputLabels }; function loadPhysicalLightMap(numCannons: number): number[] | null { if (!fs.existsSync(LIGHT_MAP_FILE)) return null;