mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 17:56:34 +00:00
feat: bf-status (action + trigger) + bf-snapshot (action) nodes
- bf-status: query kiosk state by ID via /api/admin/kiosks/:id - bf-trigger-status: dedicated heartbeat-only topic kiosk.status (skips connect/disconnect noise from kiosk.changed) - bf-snapshot: GET /admin/entities/:id/snapshot as Buffer for motion → email/telegram flows - coordinator-ws now forwards both kiosk.changed (event=heartbeat) AND kiosk.status on every status message
This commit is contained in:
parent
bd48c853e6
commit
acb4a353f9
9 changed files with 293 additions and 3 deletions
|
|
@ -13,12 +13,15 @@ BetterFrame admin REST API and kiosk event ingest.
|
|||
| `bf-trigger-layout-changed` | Triggers | Fires on `layout.changed` |
|
||||
| `bf-trigger-kiosk-changed` | Triggers | Fires on `kiosk.changed` (connect/disconnect/heartbeat) |
|
||||
| `bf-trigger-camera-changed` | Triggers | Fires on `camera.changed` (created/updated/deleted) |
|
||||
| `bf-trigger-status` | Triggers | Fires on `kiosk.status` (heartbeat-only telemetry; optional kiosk_id filter) |
|
||||
| `bf-layout-switch` | BetterFrame | Switch a display's active layout |
|
||||
| `bf-power` | BetterFrame | Wake / standby a kiosk display |
|
||||
| `bf-fan` | BetterFrame | Set fan mode (auto/pwm) on a kiosk |
|
||||
| `bf-cameras` | BetterFrame | Fetch the camera list |
|
||||
| `bf-config-get` | BetterFrame | Fetch BF state (displays/kiosks/cameras/layouts/entities, by id or full list) |
|
||||
| `bf-config-set` | BetterFrame | Mutate BF state (default layout, enabled, priority, name) |
|
||||
| `bf-status` | BetterFrame | Fetch current kiosk state by ID (telemetry, last_seen_at, etc.) |
|
||||
| `bf-snapshot` | BetterFrame | Fetch a JPEG snapshot for a camera entity (binary Buffer payload) |
|
||||
|
||||
## Authentication
|
||||
|
||||
|
|
|
|||
|
|
@ -16,12 +16,15 @@
|
|||
"bf-trigger-layout-changed": "src/bf-trigger-layout-changed.js",
|
||||
"bf-trigger-kiosk-changed": "src/bf-trigger-kiosk-changed.js",
|
||||
"bf-trigger-camera-changed": "src/bf-trigger-camera-changed.js",
|
||||
"bf-trigger-status": "src/bf-trigger-status.js",
|
||||
"bf-layout-switch": "src/bf-layout-switch.js",
|
||||
"bf-power": "src/bf-power.js",
|
||||
"bf-fan": "src/bf-fan.js",
|
||||
"bf-cameras": "src/bf-cameras.js",
|
||||
"bf-config-get": "src/bf-config-get.js",
|
||||
"bf-config-set": "src/bf-config-set.js"
|
||||
"bf-config-set": "src/bf-config-set.js",
|
||||
"bf-status": "src/bf-status.js",
|
||||
"bf-snapshot": "src/bf-snapshot.js"
|
||||
},
|
||||
"icons": [
|
||||
"icons"
|
||||
|
|
|
|||
40
nodered/src/bf-snapshot.html
Normal file
40
nodered/src/bf-snapshot.html
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<script type="text/javascript">
|
||||
RED.nodes.registerType("bf-snapshot", {
|
||||
category: "BetterFrame",
|
||||
color: "#a6d4ff",
|
||||
defaults: {
|
||||
name: { value: "" },
|
||||
config: { value: "", type: "bf-server-config", required: true },
|
||||
entity_id: { value: "" },
|
||||
},
|
||||
inputs: 1,
|
||||
outputs: 1,
|
||||
icon: "betterframe.svg",
|
||||
label: function () {
|
||||
return this.name || "bf snapshot";
|
||||
},
|
||||
paletteLabel: "bf snapshot",
|
||||
});
|
||||
</script>
|
||||
|
||||
<script type="text/html" data-template-name="bf-snapshot">
|
||||
<div class="form-row">
|
||||
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
||||
<input type="text" id="node-input-name" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-config"><i class="fa fa-cog"></i> BF</label>
|
||||
<input type="text" id="node-input-config" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-entity_id"><i class="fa fa-camera"></i> Entity ID</label>
|
||||
<input type="number" id="node-input-entity_id" />
|
||||
</div>
|
||||
<div class="form-tips">
|
||||
Pulls one JPEG frame from the camera entity's main stream.
|
||||
Sets <code>msg.payload</code> = Buffer (binary JPEG) and
|
||||
<code>msg.contentType</code> = <code>image/jpeg</code>.
|
||||
Override via <code>msg.entity_id</code>. Errors on 502 (camera unreachable
|
||||
or ffmpeg/gst missing on the server).
|
||||
</div>
|
||||
</script>
|
||||
59
nodered/src/bf-snapshot.js
Normal file
59
nodered/src/bf-snapshot.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* bf-snapshot — fetch a single JPEG frame for a camera entity.
|
||||
*
|
||||
* GETs /admin/entities/:id/snapshot, which pulls one frame from the entity's
|
||||
* main RTSP stream via ffmpeg/gst on the server. Returns image/jpeg or 502.
|
||||
*
|
||||
* On success: msg.payload = Buffer (binary JPEG), msg.contentType = "image/jpeg".
|
||||
* On 502 / network error: the node errors out with done(err) and shows red.
|
||||
*
|
||||
* config.entity_id: numeric (overridable by msg.entity_id)
|
||||
*
|
||||
* Typical use: motion event → bf-snapshot → email / telegram / save-to-disk.
|
||||
*/
|
||||
module.exports = function (RED) {
|
||||
function BfSnapshotNode(config) {
|
||||
RED.nodes.createNode(this, config);
|
||||
const node = this;
|
||||
const cfg = RED.nodes.getNode(config.config);
|
||||
|
||||
node.on("input", async (msg, send, done) => {
|
||||
if (!cfg || !cfg.server_url || !cfg.api_key) {
|
||||
node.status({ fill: "red", shape: "ring", text: "missing bf-server-config" });
|
||||
return done(new Error("bf-server-config server_url + api_key required"));
|
||||
}
|
||||
const entityId = msg.entity_id || config.entity_id;
|
||||
if (!entityId) {
|
||||
node.status({ fill: "red", shape: "ring", text: "missing entity_id" });
|
||||
return done(new Error("entity_id required"));
|
||||
}
|
||||
const url = cfg.server_url + "/admin/entities/" + encodeURIComponent(String(entityId)) +
|
||||
"/snapshot";
|
||||
try {
|
||||
const r = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
authorization: "Bearer " + cfg.api_key,
|
||||
accept: "image/jpeg",
|
||||
},
|
||||
});
|
||||
if (r.status === 502) {
|
||||
node.status({ fill: "red", shape: "ring", text: "no snapshot" });
|
||||
return done(new Error("snapshot unavailable (HTTP 502)"));
|
||||
}
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
const ab = await r.arrayBuffer();
|
||||
const buf = Buffer.from(ab);
|
||||
msg.payload = buf;
|
||||
msg.contentType = "image/jpeg";
|
||||
node.status({ fill: "green", shape: "dot", text: String(buf.length) + " B" });
|
||||
send(msg);
|
||||
done();
|
||||
} catch (err) {
|
||||
node.status({ fill: "red", shape: "ring", text: err.message });
|
||||
done(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
RED.nodes.registerType("bf-snapshot", BfSnapshotNode);
|
||||
};
|
||||
38
nodered/src/bf-status.html
Normal file
38
nodered/src/bf-status.html
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<script type="text/javascript">
|
||||
RED.nodes.registerType("bf-status", {
|
||||
category: "BetterFrame",
|
||||
color: "#a6d4ff",
|
||||
defaults: {
|
||||
name: { value: "" },
|
||||
config: { value: "", type: "bf-server-config", required: true },
|
||||
kiosk_id: { value: "" },
|
||||
},
|
||||
inputs: 1,
|
||||
outputs: 1,
|
||||
icon: "betterframe.svg",
|
||||
label: function () {
|
||||
return this.name || "bf status";
|
||||
},
|
||||
paletteLabel: "bf status",
|
||||
});
|
||||
</script>
|
||||
|
||||
<script type="text/html" data-template-name="bf-status">
|
||||
<div class="form-row">
|
||||
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
||||
<input type="text" id="node-input-name" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-config"><i class="fa fa-cog"></i> BF</label>
|
||||
<input type="text" id="node-input-config" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-kiosk_id"><i class="fa fa-tv"></i> Kiosk ID</label>
|
||||
<input type="number" id="node-input-kiosk_id" />
|
||||
</div>
|
||||
<div class="form-tips">
|
||||
GETs <code>/api/admin/kiosks/:id</code> and sets <code>msg.payload</code> to the
|
||||
kiosk object (name, enabled, last_seen_at, cpu_temp_c, fan_rpm, fan_pwm, etc.).
|
||||
Override via <code>msg.kiosk_id</code>. Secrets are stripped server-side.
|
||||
</div>
|
||||
</script>
|
||||
54
nodered/src/bf-status.js
Normal file
54
nodered/src/bf-status.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* bf-status — query the current state of a kiosk by ID.
|
||||
*
|
||||
* GETs /api/admin/kiosks/:id and returns the kiosk object as msg.payload.
|
||||
* The server applies stripSecrets() before returning JSON, so key_hash and
|
||||
* other credentials are already removed by the time we see them.
|
||||
*
|
||||
* Useful for: "what's the temperature right now?" / "is this kiosk online?"
|
||||
* style polls. For push-driven telemetry use bf-trigger-status instead.
|
||||
*
|
||||
* config.kiosk_id: numeric (overridable by msg.kiosk_id)
|
||||
*/
|
||||
module.exports = function (RED) {
|
||||
function BfStatusNode(config) {
|
||||
RED.nodes.createNode(this, config);
|
||||
const node = this;
|
||||
const cfg = RED.nodes.getNode(config.config);
|
||||
|
||||
node.on("input", async (msg, send, done) => {
|
||||
if (!cfg || !cfg.server_url || !cfg.api_key) {
|
||||
node.status({ fill: "red", shape: "ring", text: "missing bf-server-config" });
|
||||
return done(new Error("bf-server-config server_url + api_key required"));
|
||||
}
|
||||
const kioskId = msg.kiosk_id || config.kiosk_id;
|
||||
if (!kioskId) {
|
||||
node.status({ fill: "red", shape: "ring", text: "missing kiosk_id" });
|
||||
return done(new Error("kiosk_id required"));
|
||||
}
|
||||
const url = cfg.server_url + "/api/admin/kiosks/" + encodeURIComponent(String(kioskId));
|
||||
try {
|
||||
const r = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
authorization: "Bearer " + cfg.api_key,
|
||||
accept: "application/json",
|
||||
},
|
||||
});
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
const data = await r.json();
|
||||
// Server envelope is { kiosk: {...} } — unwrap to match bf-config-get.
|
||||
const payload = data && data.kiosk ? data.kiosk : data;
|
||||
msg.payload = payload;
|
||||
const label = (payload && (payload.name || payload.id)) || kioskId;
|
||||
node.status({ fill: "green", shape: "dot", text: String(label) });
|
||||
send(msg);
|
||||
done();
|
||||
} catch (err) {
|
||||
node.status({ fill: "red", shape: "ring", text: err.message });
|
||||
done(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
RED.nodes.registerType("bf-status", BfStatusNode);
|
||||
};
|
||||
34
nodered/src/bf-trigger-status.html
Normal file
34
nodered/src/bf-trigger-status.html
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<script type="text/javascript">
|
||||
RED.nodes.registerType("bf-trigger-status", {
|
||||
category: "BetterFrame Triggers",
|
||||
color: "#a6d4ff",
|
||||
defaults: {
|
||||
name: { value: "" },
|
||||
kiosk_id: { value: "" },
|
||||
},
|
||||
inputs: 1,
|
||||
outputs: 1,
|
||||
icon: "betterframe.svg",
|
||||
label: function () {
|
||||
return this.name || "kiosk status";
|
||||
},
|
||||
paletteLabel: "Kiosk Status Trigger",
|
||||
});
|
||||
</script>
|
||||
|
||||
<script type="text/html" data-template-name="bf-trigger-status">
|
||||
<div class="form-row">
|
||||
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
||||
<input type="text" id="node-input-name" placeholder="(optional)" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-kiosk_id"><i class="fa fa-tv"></i> Kiosk ID</label>
|
||||
<input type="number" id="node-input-kiosk_id" placeholder="(blank = all kiosks)" />
|
||||
</div>
|
||||
<div class="form-tips">
|
||||
Fires on <code>kiosk.status</code> heartbeats with hardware telemetry.
|
||||
Wire <code>http in POST /in/kiosk/kiosk.status</code> in front of this node.
|
||||
Emits <code>msg.payload = {kiosk_id, kiosk_name, cpu_temp_c, fan_rpm, fan_pwm}</code>.
|
||||
Leave Kiosk ID blank to receive heartbeats from all kiosks.
|
||||
</div>
|
||||
</script>
|
||||
52
nodered/src/bf-trigger-status.js
Normal file
52
nodered/src/bf-trigger-status.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* bf-trigger-status — fires on kiosk heartbeat telemetry.
|
||||
*
|
||||
* Topic filter: `kiosk.status`. Server emits this from coordinator-ws each
|
||||
* time a kiosk pushes a status frame over the WS channel, separate from
|
||||
* the connect/disconnect/heartbeat envelope on `kiosk.changed`. Listening
|
||||
* here gives you a pure telemetry stream (no connect/disconnect noise).
|
||||
*
|
||||
* Optional config.kiosk_id filter — when set, only fires for that kiosk.
|
||||
*
|
||||
* Output msg.payload:
|
||||
* { kiosk_id, kiosk_name, cpu_temp_c, fan_rpm, fan_pwm }
|
||||
*/
|
||||
module.exports = function (RED) {
|
||||
function BfTriggerStatusNode(config) {
|
||||
RED.nodes.createNode(this, config);
|
||||
const node = this;
|
||||
const filterIdRaw = (config.kiosk_id || "").toString().trim();
|
||||
const filterId = filterIdRaw ? Number(filterIdRaw) : null;
|
||||
|
||||
node.on("input", function (msg, send, done) {
|
||||
const body = (msg && msg.payload && typeof msg.payload === "object") ? msg.payload : {};
|
||||
const topic = msg.topic || body.topic || "kiosk.status";
|
||||
if (String(topic) !== "kiosk.status") {
|
||||
return done && done();
|
||||
}
|
||||
const kioskId = body.kiosk_id !== undefined ? body.kiosk_id : null;
|
||||
if (filterId !== null && Number(kioskId) !== filterId) {
|
||||
return done && done();
|
||||
}
|
||||
const out = {
|
||||
topic: "kiosk.status",
|
||||
payload: {
|
||||
kiosk_id: kioskId,
|
||||
kiosk_name: body.kiosk_name || null,
|
||||
cpu_temp_c: body.cpu_temp_c !== undefined ? body.cpu_temp_c : null,
|
||||
fan_rpm: body.fan_rpm !== undefined ? body.fan_rpm : null,
|
||||
fan_pwm: body.fan_pwm !== undefined ? body.fan_pwm : null,
|
||||
},
|
||||
};
|
||||
const tempStr = out.payload.cpu_temp_c != null ? out.payload.cpu_temp_c + "C" : "";
|
||||
node.status({
|
||||
fill: "green",
|
||||
shape: "dot",
|
||||
text: (out.payload.kiosk_name || String(out.payload.kiosk_id || "")) + " " + tempStr,
|
||||
});
|
||||
send(out);
|
||||
done && done();
|
||||
});
|
||||
}
|
||||
RED.nodes.registerType("bf-trigger-status", BfTriggerStatusNode);
|
||||
};
|
||||
|
|
@ -192,14 +192,21 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
|||
const cpu = typeof msg["cpu_temp_c"] === "number" ? msg["cpu_temp_c"] : null;
|
||||
const fanRpm = typeof msg["fan_rpm"] === "number" ? msg["fan_rpm"] : null;
|
||||
const fanPwm = typeof msg["fan_pwm"] === "number" ? msg["fan_pwm"] : null;
|
||||
nodered.forward("kiosk.changed", {
|
||||
const telemetry = {
|
||||
kiosk_id: kiosk.id,
|
||||
kiosk_name: kioskData.name,
|
||||
event: "heartbeat",
|
||||
cpu_temp_c: cpu,
|
||||
fan_rpm: fanRpm,
|
||||
fan_pwm: fanPwm,
|
||||
};
|
||||
nodered.forward("kiosk.changed", {
|
||||
...telemetry,
|
||||
event: "heartbeat",
|
||||
});
|
||||
// Dedicated status topic — same payload sans the event marker
|
||||
// so bf-trigger-status can listen on a heartbeat-only channel
|
||||
// without filtering connect/disconnect noise out.
|
||||
nodered.forward("kiosk.status", telemetry);
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed
|
||||
|
|
|
|||
Loading…
Reference in a new issue