feat: Node-RED outbound bridge — forward kiosk events to Node-RED

- shared/nodered-bridge.ts: fire-and-forget POST to Node-RED HTTP-in
- api-http: kiosk event endpoint now forwards to Node-RED at /in/<topic>
- Best-effort, never blocks. 3s timeout, warn on failure.
- sec-config: noderedUrl on api-http (defaults to http://127.0.0.1:1880)

Node-RED flows can attach http-in nodes at /in/<topic> to receive
camera motion, GPIO events, etc. Inbound commands (Node-RED → server)
go through the admin API with admin Bearer token (no new endpoints
needed for v0.1).
This commit is contained in:
Mitchell R 2026-05-10 22:49:59 +02:00
parent c0704be343
commit f61c3db0e8
3 changed files with 66 additions and 3 deletions

View file

@ -57,6 +57,7 @@ default:
argon2Memory: 65536 argon2Memory: 65536
argon2TimeCost: 3 argon2TimeCost: 3
argon2Parallelism: 2 argon2Parallelism: 2
noderedUrl: http://127.0.0.1:1880
# ----- Live kiosk WebSocket channel ----- # ----- Live kiosk WebSocket channel -----
service-coordinator-ws: service-coordinator-ws:

View file

@ -20,6 +20,7 @@ import { initSecrets } from "../../shared/secrets.js";
import { createAuth } from "../../shared/auth.js"; import { createAuth } from "../../shared/auth.js";
import { initiatePairing, claimPairing } from "../../shared/pairing.js"; import { initiatePairing, claimPairing } from "../../shared/pairing.js";
import { generateBundle } from "../../shared/bundle.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 { Repository } from "../service-store/repository.js";
import type { AuthApi } from "../../shared/auth.js"; import type { AuthApi } from "../../shared/auth.js";
import type { SecretsApi } from "../../shared/secrets.js"; import type { SecretsApi } from "../../shared/secrets.js";
@ -42,6 +43,7 @@ const ConfigSchema = av.object(
loginLockoutThreshold: av.int().min(1).default(8), loginLockoutThreshold: av.int().min(1).default(8),
loginLockoutSeconds: av.int().min(1).default(900), loginLockoutSeconds: av.int().min(1).default(900),
totpIssuer: av.string().minLength(1).default("BetterFrame"), totpIssuer: av.string().minLength(1).default("BetterFrame"),
noderedUrl: av.string().minLength(1).default("http://127.0.0.1:1880"),
}, },
{ unknownKeys: "strip" }, { unknownKeys: "strip" },
); );
@ -99,11 +101,15 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
cookieName: this.config.cookieName, cookieName: this.config.cookieName,
}); });
const codeTtl = this.config.codeTtlSeconds; 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(); const app = new H3();
registerPairingRoutes(app, repo, auth, secrets, codeTtl); registerPairingRoutes(app, repo, auth, secrets, codeTtl);
registerKioskRoutes(app, repo, auth, secrets); registerKioskRoutes(app, repo, auth, secrets, nodered);
this.server = serve(app, { this.server = serve(app, {
port: this.config.port, port: this.config.port,
@ -198,6 +204,7 @@ function registerKioskRoutes(
repo: Repository, repo: Repository,
auth: AuthApi, auth: AuthApi,
secrets: SecretsApi, secrets: SecretsApi,
nodered: NoderedBridge,
): void { ): void {
// Bundle delivery // Bundle delivery
app.get("/api/kiosk/bundle", async (event) => { app.get("/api/kiosk/bundle", async (event) => {
@ -289,6 +296,17 @@ function registerKioskRoutes(
forwarded_to_nodered: false, 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 }; return { ok: true, event_id: eventId };
}); });
} }

View file

@ -1,4 +1,48 @@
/** /**
* Node-RED HTTP bridge shared module stub. * Node-RED bridge best-effort outbound event forwarder.
* TODO: implement outbound forwarder + inbound callbacks. *
* 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<string, unknown>): 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<string, unknown>): void {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
// POST to /in/<topic> on Node-RED. Node-RED flows can attach
// http-in nodes at /in/<topic> 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));
},
};
}