--- sidebar_label: 'External Kiosk Monitor' sidebar_position: 4 --- # External Monitor Protocol This document describes the ZeroMQ protocol for **external monitor modules** — standalone processes that observe hardware or services and report their health status to KioskBackend. The backend uses this to decide whether the kiosk should be open or closed. ## Transport The bus uses **ZeroMQ pub/sub** brokered through an XSUB/XPUB proxy running inside the FO.OP host process. Ports are fixed. ### Ports | Port | Address | Purpose | |---|---|---| | 42421 | `tcp://127.0.0.1:42421` | **Publish** — connect your PUB socket here to send messages to the backend | | 42422 | `tcp://127.0.0.1:42422` | **Subscribe** — connect your SUB socket here to receive messages from the backend | ### Wire frame layout ``` Sending (external monitor → backend): [frame1: topic (string)] [frame2: JSON (string)] Receiving (backend → external monitor): [frame1: topic (string)] [frame2: seqId (4-byte LE int32)] [frame3: JSON (string)] ``` The proxy inserts the sequence ID frame automatically. ### Connecting ```ts import { Publisher, Subscriber } from "zeromq"; const pub = new Publisher(); await pub.connect("tcp://127.0.0.1:42421"); // send to backend const sub = new Subscriber(); await sub.connect("tcp://127.0.0.1:42422"); // receive from backend sub.subscribe("ExternalMonitorStatusPollMessage"); ``` ## Envelope Format All messages share the same JSON envelope. ```json { "sender": { "name": "my-receipt-printer-module", "version": "1.0.0.0" }, "envelopeVersion": 0, "messageType": "ExternalMonitorHeartbeatMessage", "messageTypeVersion": 0, "body": {} } ``` | Field | Type | Description | |---|---|---| | `sender.name` | string | Name of the module (process) sending the message. A single module may contain multiple monitors — e.g. a module named `"peripheral-monitor"` could send heartbeats for both a printer monitor and a card terminal monitor, each with their own `monitorId` | | `sender.version` | string | Module version as dotted quad | | `envelopeVersion` | integer | Always `0` | | `messageType` | string | Must match the ZeroMQ topic frame | | `messageTypeVersion` | integer | Always `0` | | `body` | object | Message-specific payload — see each message type below | Property names are **camelCase** in both directions. Deserialization is case-insensitive. ## Why Heartbeats Are Required ZeroMQ does not reliably notify the application when a peer process crashes or loses connectivity. A periodic heartbeat with a TTL is the only way the backend can detect that a module has gone offline. **If your module goes silent and the TTL expires:** - `closeOnExpiry: true` → backend treats the silence as `error` and closes the kiosk - `closeOnExpiry: false` → backend treats the silence as `warning` and leaves the kiosk open Send your heartbeat at a rate well within the TTL — if `ttlSeconds` is `15`, heartbeat every 5 seconds. ## Status Reporting Every heartbeat carries the **full set of named statuses** — not just the ones that changed. This is required because the backend loses all state on restart and must be able to reconstruct the complete picture from a single heartbeat. Each status entry has a `name`, a `value` (always a string), and a `warningLevel`. The backend derives the aggregate condition level from the worst `warningLevel` across all entries. A condition is `ok` only when every status is `ok`. ### Aggregation rules - Any status with `warningLevel: error` → condition is `error`, kiosk closes - Any status with `warningLevel: warning`, none with `error` → condition is `warning`, kiosk stays open - All statuses `ok` → condition is `ok`, kiosk opens if no other condition blocks it ### Example — printer with multiple concurrent issues ``` paperEmpty=true (error), coverOpen=true (warning), queueLength=4 (warning) → condition: error → kiosk closes coverOpen resolved: paperEmpty=true (error), coverOpen=false (ok), queueLength=4 (warning) → condition: error → kiosk still closed paper refilled: paperEmpty=false (ok), coverOpen=false (ok), queueLength=0 (ok) → condition: ok → kiosk reopens ``` The monitor tracks its own hardware state and sends the full status list on every heartbeat. The backend sees only the current snapshot. ### Status naming Status names must be **camelCase** (e.g. `paperEmpty`, `queueLength`, `isOffline`). Names are displayed as-is in the backoffice, so consistency across heartbeats matters. ### Empty and missing statuses An empty `statuses` array is treated as `ok` — the condition clears and the kiosk may open. Avoid sending an empty array; if the monitor has no statuses to report it should not be sending heartbeats at all. If a status name that appeared in a previous heartbeat is absent from the next one, the backend will likely treat it as `ok`, but this behaviour is not guaranteed. Always send the complete set of statuses on every heartbeat. ## Messages ### `ExternalMonitorHeartbeatMessage` (external → backend) The primary message. Sent periodically by the external monitor to declare its current health. Every heartbeat is **idempotent** — there is no separate registration message. If the backend restarts, it re-learns about the module from the next heartbeat it receives. **Send immediately in these situations — do not wait for the next scheduled interval:** - On startup or restart — the kiosk may be closed due to TTL expiry and needs the current state as fast as possible - When any status changes — waiting for the next interval means the kiosk reacts slowly to failures and recoveries - When an `ExternalMonitorStatusPollMessage` is received from the backend The backend derives the close decision from the worst `warningLevel` across all status entries: - all `ok` — condition cleared; kiosk opens if no other condition blocks it - any `warning` — recorded and reported to IoT Hub; kiosk stays open - any `error` — backend closes the kiosk | Field | Type | Description | |---|---|---| | `monitorId` | string | Hardcoded identifier for this monitor (e.g. `"receipt-printer"`). Must be the same across restarts. Acts as the owner of its condition slot — only heartbeats carrying the same `monitorId` can update or clear that slot | | `ttlSeconds` | integer | How long the backend waits without a heartbeat before treating the monitor as missing. Send heartbeats well within this window — every `ttlSeconds / 3` seconds is recommended | | `closeOnExpiry` | boolean | If `true`, a missed heartbeat (TTL elapsed) closes the kiosk. If `false`, it is reported as a warning | | `statuses` | object[] | Full list of named statuses. Must include all tracked statuses on every heartbeat, including healthy ones | | `statuses[].name` | string | Status name (e.g. `"paperEmpty"`, `"queueLength"`) | | `statuses[].value` | string | Current value as a string (e.g. `"true"`, `"false"`, `"4"`, `"online"`) | | `statuses[].warningLevel` | `"ok"` \| `"warning"` \| `"error"` | Severity of this individual status | #### Example — healthy ```json { "sender": { "name": "receipt-printer-module", "version": "1.0.0.0" }, "envelopeVersion": 0, "messageType": "ExternalMonitorHeartbeatMessage", "messageTypeVersion": 0, "body": { "monitorId": "receipt-printer", "ttlSeconds": 15, "closeOnExpiry": true, "statuses": [ { "name": "isOffline", "value": "false", "warningLevel": "ok" }, { "name": "paperEmpty", "value": "false", "warningLevel": "ok" }, { "name": "coverOpen", "value": "false", "warningLevel": "ok" }, { "name": "queueLength", "value": "0", "warningLevel": "ok" } ] } } ``` #### Example — critical failure (closes kiosk) ```json { "sender": { "name": "receipt-printer-module", "version": "1.0.0.0" }, "envelopeVersion": 0, "messageType": "ExternalMonitorHeartbeatMessage", "messageTypeVersion": 0, "body": { "monitorId": "receipt-printer", "ttlSeconds": 15, "closeOnExpiry": true, "statuses": [ { "name": "isOffline", "value": "false", "warningLevel": "ok" }, { "name": "paperEmpty", "value": "true", "warningLevel": "error" }, { "name": "coverOpen", "value": "false", "warningLevel": "ok" }, { "name": "queueLength", "value": "4", "warningLevel": "warning" } ] } } ``` #### Example — warning only (kiosk stays open) ```json { "sender": { "name": "receipt-printer-module", "version": "1.0.0.0" }, "envelopeVersion": 0, "messageType": "ExternalMonitorHeartbeatMessage", "messageTypeVersion": 0, "body": { "monitorId": "receipt-printer", "ttlSeconds": 15, "closeOnExpiry": true, "statuses": [ { "name": "isOffline", "value": "false", "warningLevel": "ok" }, { "name": "paperEmpty", "value": "false", "warningLevel": "ok" }, { "name": "coverOpen", "value": "false", "warningLevel": "ok" }, { "name": "queueLength", "value": "4", "warningLevel": "warning" } ] } } ``` ### `ExternalMonitorStatusPollMessage` (backend → external) Broadcast by the backend on startup and after a restart to ask all external monitors to report their current status immediately. Subscribe to this topic and respond with an `ExternalMonitorHeartbeatMessage` as soon as it is received. This message has no fields in the body — receiving it is the signal. ```json { "sender": { "name": "kioskbackend", "version": "2.0.0.0" }, "envelopeVersion": 0, "messageType": "ExternalMonitorStatusPollMessage", "messageTypeVersion": 0, "body": {} } ``` On receiving this, send your current status immediately: ```ts for await (const [topic] of sub) { if (topic.toString() === "ExternalMonitorStatusPollMessage") { await sendHeartbeat(currentStatuses); } } ``` ## Kiosk Close Behavior | Situation | Kiosk behavior | |---|---| | All statuses `ok` | Condition cleared; kiosk opens if no other condition blocks it | | Any status `warning`, none `error` | Condition recorded and reported; kiosk stays open | | Any status `error` | Kiosk closes | | TTL elapsed, `closeOnExpiry: true` | Treated as `error` — kiosk closes | | TTL elapsed, `closeOnExpiry: false` | Treated as `warning` — kiosk stays open | ## Sequence Diagrams The downstream effect on the frontend (navigating to a closed/open page) is handled internally by the backend and bridge and is not a concern of the external monitor. ### External monitor startup ```mermaid sequenceDiagram participant M as External Monitor participant B as KioskBackend M->>B: ExternalMonitorHeartbeatMessage (ok) — immediate, don't wait for interval loop Every ttlSeconds / 3 M->>B: ExternalMonitorHeartbeatMessage (ok) end ``` ### Backend restart — poll flow On restart the backend holds the kiosk closed for a grace period before evaluating state. This gives all external monitors time to report in before any open decision is made. Monitors that do not report within the grace period are treated according to their `closeOnExpiry` setting. The default grace period is **10 seconds** and is configured in the kiosk configuration. ```mermaid sequenceDiagram participant M as External Monitor participant B as KioskBackend Note over B: Restarts — all condition state lost Note over B: Kiosk held closed, grace period starts (default 10 s) B-->>M: ExternalMonitorStatusPollMessage (broadcast) M->>B: ExternalMonitorHeartbeatMessage (current statuses) — respond immediately Note over B: Grace period elapsed — evaluate all received statuses Note over B: Kiosk opens or stays closed based on condition state ``` ### Status change — critical (closes kiosk) ```mermaid sequenceDiagram participant M as External Monitor participant B as KioskBackend M->>B: ExternalMonitorHeartbeatMessage (paperEmpty=true, warningLevel: error) Note over B: Kiosk closes M->>B: ExternalMonitorHeartbeatMessage (paperEmpty=false, all ok) Note over B: Kiosk opens ``` ### TTL expiry — module crashed ```mermaid sequenceDiagram participant M as External Monitor participant B as KioskBackend Note over M: Crashes Note over B: TTL elapsed, closeOnExpiry: true Note over B: Kiosk closes Note over M: Restarts M->>B: ExternalMonitorHeartbeatMessage (all statuses, ok) — immediate on restart Note over B: Kiosk opens ```