diff --git a/sec-config.yaml b/sec-config.yaml index e6c88fc..fa8b354 100644 --- a/sec-config.yaml +++ b/sec-config.yaml @@ -57,6 +57,7 @@ default: argon2Memory: 65536 argon2TimeCost: 3 argon2Parallelism: 2 + noderedUrl: http://127.0.0.1:1880 # ----- Live kiosk WebSocket channel ----- service-coordinator-ws: diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index 1c95e69..1e9a4c3 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -20,6 +20,7 @@ import { initSecrets } from "../../shared/secrets.js"; import { createAuth } from "../../shared/auth.js"; import { initiatePairing, claimPairing } from "../../shared/pairing.js"; import { generateBundle } from "../../shared/bundle.js"; +import { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.js"; import type { Repository } from "../service-store/repository.js"; import type { AuthApi } from "../../shared/auth.js"; import type { SecretsApi } from "../../shared/secrets.js"; @@ -42,6 +43,7 @@ const ConfigSchema = av.object( loginLockoutThreshold: av.int().min(1).default(8), loginLockoutSeconds: av.int().min(1).default(900), totpIssuer: av.string().minLength(1).default("BetterFrame"), + noderedUrl: av.string().minLength(1).default("http://127.0.0.1:1880"), }, { unknownKeys: "strip" }, ); @@ -99,11 +101,15 @@ export class Plugin extends BSBService, typeof Event cookieName: this.config.cookieName, }); const codeTtl = this.config.codeTtlSeconds; + const nodered = initNoderedBridge( + { baseUrl: this.config.noderedUrl }, + { info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) }, + ); const app = new H3(); registerPairingRoutes(app, repo, auth, secrets, codeTtl); - registerKioskRoutes(app, repo, auth, secrets); + registerKioskRoutes(app, repo, auth, secrets, nodered); this.server = serve(app, { port: this.config.port, @@ -198,6 +204,7 @@ function registerKioskRoutes( repo: Repository, auth: AuthApi, secrets: SecretsApi, + nodered: NoderedBridge, ): void { // Bundle delivery app.get("/api/kiosk/bundle", async (event) => { @@ -289,6 +296,17 @@ function registerKioskRoutes( forwarded_to_nodered: false, }); + // Best-effort forward to Node-RED + nodered.forward(body.topic, { + event_id: eventId, + kiosk_id: kiosk.id, + camera_id: body.camera_id ?? null, + source_type: body.source_type ?? "system", + property_op: body.property_op ?? null, + payload: body.payload ?? {}, + timestamp: new Date().toISOString(), + }); + return { ok: true, event_id: eventId }; }); } diff --git a/server/src/shared/nodered-bridge.ts b/server/src/shared/nodered-bridge.ts index d4b5839..3bc5e69 100644 --- a/server/src/shared/nodered-bridge.ts +++ b/server/src/shared/nodered-bridge.ts @@ -1,4 +1,48 @@ /** - * Node-RED HTTP bridge — shared module stub. - * TODO: implement outbound forwarder + inbound callbacks. + * Node-RED bridge — best-effort outbound event forwarder. + * + * Server fires events (camera motion, kiosk status, layout switch, GPIO + * pulse). This module POSTs them to Node-RED HTTP-in nodes. Failures + * are logged but never block the event flow. */ + +export interface NoderedConfig { + baseUrl: string; + timeoutMs?: number; +} + +export interface NoderedLog { + info(msg: string): void; + warn(msg: string): void; +} + +export interface NoderedBridge { + forward(topic: string, payload: Record): void; +} + +export function initNoderedBridge(config: NoderedConfig, log: NoderedLog): NoderedBridge { + const base = config.baseUrl.replace(/\/+$/, ""); + const timeoutMs = config.timeoutMs ?? 3000; + + return { + forward(topic: string, payload: Record): void { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), timeoutMs); + + // POST to /in/ on Node-RED. Node-RED flows can attach + // http-in nodes at /in/ to consume. + const url = `${base}/in/${encodeURIComponent(topic)}`; + fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }) + .then((r) => { + if (!r.ok) log.warn(`nodered ${topic} → ${r.status}`); + }) + .catch((err) => log.warn(`nodered ${topic} failed: ${(err as Error).message}`)) + .finally(() => clearTimeout(t)); + }, + }; +}