Skip to main content

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

PortAddressPurpose
42421tcp://127.0.0.1:42421Publish — connect your PUB socket here to send messages to the backend
42422tcp://127.0.0.1:42422Subscribe — 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": {}
}
FieldTypeDescription
sender.namestringName 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.versionstringModule version as dotted quad
envelopeVersionintegerAlways 0
messageTypestringMust match the ZeroMQ topic frame
messageTypeVersionintegerAlways 0
bodyobjectMessage-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
FieldTypeDescription
monitorIdstringHardcoded 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
ttlSecondsintegerHow 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
closeOnExpirybooleanIf true, a missed heartbeat (TTL elapsed) closes the kiosk. If false, it is reported as a warning
statusesobject[]Full list of named statuses. Must include all tracked statuses on every heartbeat, including healthy ones
statuses[].namestringStatus name (e.g. "paperEmpty", "queueLength")
statuses[].valuestringCurrent 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

SituationKiosk behavior
All statuses okCondition cleared; kiosk opens if no other condition blocks it
Any status warning, none errorCondition recorded and reported; kiosk stays open
Any status errorKiosk closes
TTL elapsed, closeOnExpiry: trueTreated as error — kiosk closes
TTL elapsed, closeOnExpiry: falseTreated 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.

Status change — critical (closes kiosk)

TTL expiry — module crashed