Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions nodered/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
19 changes: 17 additions & 2 deletions nodered/src/bf-trigger-layout-changed.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
name: { value: "" },
config: { value: "", type: "bf-server-config", required: true },
display_id: { value: "" },
source: { value: "" },
},
inputs: 0,
outputs: 1,
Expand All @@ -30,19 +31,33 @@
<label for="node-input-display_id"><i class="fa fa-desktop"></i> Display ID</label>
<input type="text" id="node-input-display_id" pattern="[0-9a-f-]+" placeholder="(blank = all displays)" />
</div>
<div class="form-row">
<label for="node-input-source"><i class="fa fa-filter"></i> Source</label>
<select id="node-input-source">
<option value="">All (not set)</option>
<option value="server">Server</option>
<option value="kiosk">Kiosk</option>
</select>
</div>
<div class="form-tips">
Fires when a display's active layout changes.
Listens on <code>POST /api/internal/layout.changed</code> internally — no upstream
<code>http in</code> node required.
Emits <code>msg.payload = {display_id, kiosk_id, layout_id, layout_name}</code> for the selected tenant.
Emits <code>msg.payload = {display_id, kiosk_id, layout_id, layout_name, source}</code> 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.
</div>
</script>

<script type="text/html" data-help-name="bf-trigger-layout-changed">
<p>Fires when a display switches to a new layout (admin layout-switch).</p>
<p>Receives server layout-switch events and kiosk reports of layout changes.</p>
<p>Listens on <code>POST /api/internal/layout.changed</code> internally — no upstream
<code>http in</code> node needed.</p>
<p>Select a <b>BF</b> config to scope this trigger to one tenant.</p>
<p>Optional <b>Display ID</b> filter limits this node to a single display.</p>
<p><b>Source</b> defaults to all events. Select <b>Server</b> for server-issued switches
or <b>Kiosk</b> for kiosk-reported changes, including local changes and idle returns.
With all sources enabled, a server-issued switch can produce both events.</p>
<p><code>msg.payload.source</code> preserves the event source; it is <code>null</code>
when absent. Events without a source are only included when Source is unset.</p>
</script>
8 changes: 7 additions & 1 deletion nodered/src/bf-trigger-layout-changed.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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) {
Expand All @@ -38,13 +40,17 @@ 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, {
display_id: displayId,
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({
Expand Down
55 changes: 55 additions & 0 deletions server/tests/nodered-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => {
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";
Expand Down