From 500593836e74700fe1d75b84da6bfbab76c51647 Mon Sep 17 00:00:00 2001 From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:58:12 +0000 Subject: [PATCH] Add source filtering to Node-RED layout trigger --- nodered/README.md | 8 ++++ nodered/src/bf-trigger-layout-changed.html | 19 +++++++- nodered/src/bf-trigger-layout-changed.js | 8 +++- server/tests/nodered-events.test.ts | 55 ++++++++++++++++++++++ 4 files changed, 87 insertions(+), 3 deletions(-) diff --git a/nodered/README.md b/nodered/README.md index 2ba45a08..f7477264 100644 --- a/nodered/README.md +++ b/nodered/README.md @@ -80,6 +80,14 @@ Each trigger node also offers an optional ID filter (display_id / kiosk_id / camera_id) so you can drop one node per entity without a downstream switch. Tenant scoping happens before those ID filters. +The Layout Changed trigger also has a **Source** dropdown: **All (not set)** +(the default, including existing flows), **Server**, or **Kiosk**. Server events +report server-issued switches; kiosk events report kiosk-side layout changes, +including local switches and idle returns. All sources can produce two events +for one server-issued switch. Select a source to receive just that side. +The output preserves `msg.payload.source` (`null` when absent); events without +a source pass only when the dropdown is unset. + ## Installation ### Dev (single-host BetterFrame install) diff --git a/nodered/src/bf-trigger-layout-changed.html b/nodered/src/bf-trigger-layout-changed.html index de77fdfe..aa11da99 100644 --- a/nodered/src/bf-trigger-layout-changed.html +++ b/nodered/src/bf-trigger-layout-changed.html @@ -6,6 +6,7 @@ name: { value: "" }, config: { value: "", type: "bf-server-config", required: true }, display_id: { value: "" }, + source: { value: "" }, }, inputs: 0, outputs: 1, @@ -30,19 +31,33 @@ +
+ + +
Fires when a display's active layout changes. Listens on POST /api/internal/layout.changed internally — no upstream http in node required. - Emits msg.payload = {display_id, kiosk_id, layout_id, layout_name} for the selected tenant. + Emits msg.payload = {display_id, kiosk_id, layout_id, layout_name, source} for the selected tenant. Leave Display ID blank to receive events from all displays. + Leave Source unset to receive all events, or select Server or Kiosk.
diff --git a/nodered/src/bf-trigger-layout-changed.js b/nodered/src/bf-trigger-layout-changed.js index d6a7856a..af9c5233 100644 --- a/nodered/src/bf-trigger-layout-changed.js +++ b/nodered/src/bf-trigger-layout-changed.js @@ -8,8 +8,9 @@ const { subscribeEvent } = require("./_event-dispatch.js"); * * Optional config: * - display_id: only fire for that display id + * - source: blank for all events, or server/kiosk * - * Output msg.payload: { display_id, kiosk_id, layout_id, layout_name } + * Output msg.payload: { display_id, kiosk_id, layout_id, layout_name, source } */ const { readJsonBody } = require("./_http-body.js"); const { tenantMatchesBody } = require("./_tenant.js"); @@ -24,6 +25,7 @@ module.exports = function (RED) { const node = this; const cfg = RED.nodes.getNode(config.config); const filterId = String(config.display_id || "").trim() || null; + const filterSource = String(config.source || "").trim() || null; async function handler(req, res) { if (!cfg || !cfg.tenant_slug) { @@ -38,6 +40,9 @@ module.exports = function (RED) { if (filterId !== null && displayId !== filterId) { return res.status(200).end(); } + if (filterSource !== null && body.source !== filterSource) { + return res.status(200).end(); + } const out = { topic: TOPIC, payload: withIdentity(body, { @@ -45,6 +50,7 @@ module.exports = function (RED) { kiosk_id: body.kiosk_id !== undefined ? body.kiosk_id : null, layout_id: body.layout_id !== undefined ? body.layout_id : null, layout_name: body.layout_name || null, + source: body.source ?? null, }), }; node.status({ diff --git a/server/tests/nodered-events.test.ts b/server/tests/nodered-events.test.ts index 1d8bc2c4..e73b08a2 100644 --- a/server/tests/nodered-events.test.ts +++ b/server/tests/nodered-events.test.ts @@ -5,6 +5,61 @@ import test from "node:test"; const require = createRequire(import.meta.url); const { subscribeEvent } = require("../../nodered/src/_event-dispatch.js"); +test("layout triggers preserve source and filter independently within tenant and display scope", async () => { + const prior = process.env["BF_NODERED_INTERNAL_TOKEN"]; + process.env["BF_NODERED_INTERNAL_TOKEN"] = "test-only-runtime-token-000000000000000"; + try { + let dispatch: any; + let Trigger: any; + const RED = { + httpNode: { post: (_path: string, fn: unknown) => { dispatch = fn; } }, + nodes: { + registerType: (_name: string, ctor: unknown) => { Trigger = ctor; }, + getNode: () => ({ tenant_slug: "tenant-a" }), + createNode: (node: any) => { + node.messages = []; + node.send = (msg: unknown) => node.messages.push(msg); + node.status = () => {}; + node.on = () => {}; + }, + }, + }; + require("../../nodered/src/bf-trigger-layout-changed.js")(RED); + // Register a filtered subscriber first to verify rejected events still fan out. + const server = new Trigger({ source: "server", display_id: "display-a" }); + const kiosk = new Trigger({ source: "kiosk", display_id: "display-a" }); + const legacy = new Trigger({ display_id: "display-a" }); + const all = new Trigger({ source: "", display_id: "display-a" }); + const everyDisplay = new Trigger({ source: "kiosk" }); + const emit = async (fields: Record) => { + const response = { code: 0, status(n: number) { this.code = n; return this; }, end() {} }; + await dispatch({ + headers: { "x-betterframe-runtime-token": process.env["BF_NODERED_INTERNAL_TOKEN"] }, + body: { tenant_slug: "tenant-a", display_id: "display-a", kiosk_id: "kiosk-a", layout_id: "layout-a", layout_name: "Main", ...fields }, + }, response); + assert.equal(response.code, 200); + }; + await emit({ source: "server" }); + await emit({ source: "kiosk" }); + await emit({}); + await emit({ source: "future-source" }); + await emit({ source: "kiosk", tenant_slug: "tenant-b" }); + await emit({ source: "kiosk", display_id: "display-b" }); + const sources = (node: any) => node.messages.map((msg: any) => msg.payload.source); + assert.deepEqual(sources(server), ["server"]); + assert.deepEqual(sources(kiosk), ["kiosk"]); + assert.deepEqual(sources(legacy), ["server", "kiosk", null, "future-source"]); + assert.deepEqual(all.messages, legacy.messages); + assert.deepEqual(sources(everyDisplay), ["kiosk", "kiosk"]); + assert.equal(kiosk.messages[0].topic, "layout.changed"); + assert.equal(kiosk.messages[0].payload.layout_id, "layout-a"); + assert.equal(kiosk.messages[0].payload.tenant_key, "tenant-a"); + } finally { + if (prior === undefined) delete process.env["BF_NODERED_INTERNAL_TOKEN"]; + else process.env["BF_NODERED_INTERNAL_TOKEN"] = prior; + } +}); + test("internal events require a runtime credential and fan out to every subscriber", async () => { const prior = process.env["BF_NODERED_INTERNAL_TOKEN"]; process.env["BF_NODERED_INTERNAL_TOKEN"] = "test-only-runtime-token-000000000000000";