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
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.
{
"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 aserrorand closes the kioskcloseOnExpiry: false→ backend treats the silence aswarningand 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 iserror, kiosk closes - Any status with
warningLevel: warning, none witherror→ condition iswarning, kiosk stays open - All statuses
ok→ condition isok, 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
ExternalMonitorStatusPollMessageis 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
{
"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)
{
"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)
{
"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.
{
"sender": { "name": "kioskbackend", "version": "2.0.0.0" },
"envelopeVersion": 0,
"messageType": "ExternalMonitorStatusPollMessage",
"messageTypeVersion": 0,
"body": {}
}
On receiving this, send your current status immediately:
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
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.