NATS Messaging¶
Why NATS¶
NATS is a lightweight, high-performance pub/sub messaging system. On an edge device with limited resources, it provides:
- Low overhead — the broker runs in a few megabytes of RAM
- No per-message storage — no queue persistence needed for real-time sensor data
- Subject-based routing — services subscribe to specific topic patterns
- Request/reply — synchronous command/response without additional infrastructure
The ToloMEO platform uses NATS as the transport between edge devices and the cloud.
Py ToloMEO uses the nats-py async client wrapped by NATSMessageStrategy.
Topic Naming Convention¶
Topics are prefixed by category. Service-addressed topics include {service} as the second segment; broadcast event
topics do not:
| Subject | Category | Owner | Description |
|---|---|---|---|
commands.{service}.req |
commands | Service | Inbound commands from operator |
events.data |
events | Service | Outbound sensor readings |
events.params |
events | Service | Outbound command responses, command and plugin announcements |
events.infos |
events | Service | Outbound OTA status and info events |
heartbeat.{service}.service |
heartbeat | Service | Liveness pulse every 10 s |
Why SenML¶
SenML (RFC 8428) is a standard format for IoT sensor measurements. Py ToloMEO uses a SenML-inspired structure because:
- Standardised — well-understood in the IoT ecosystem
- Compact — short field names (
bn,n,v,u,t) - Extensible — the
vsstring-value field carries JSON command payloads without breaking the SenML schema
The vs field holding a JSON string (not a nested object) is an explicit
design choice: it keeps the outer SenML record valid and makes it
straightforward to add new command types without modifying the outer envelope.
Announcing Commands and Plugins¶
A frontend that wants to build a form for a command, or list the plugins of a
service, should not have to guess or poll. Every NATSService — not only
sensor services — therefore announces two records on events.params, next to
its command responses:
Record (n) |
Content | Sent |
|---|---|---|
commands |
A JSON schema for every service command and every plugin command | Once, at the end of service setup |
plugins |
The plugin class name and the ids of the attached plugins | At startup, and after every plugin attach and detach |
The command definitions depend only on code, so they are sent once; a BLE
service with no device attached still announces the commands its plugins will
accept. The plugin topology changes at runtime, so it is re-sent whenever the
plugin registry changes. Changes that arrive close together are merged into a
single record, and the last record always reflects the final registry state —
including ids: [] after the last plugin detaches.
attach_plugin and detach_plugin trigger the announcement themselves, so a
service does not have to remember to. Calling notify_params_changed()
explicitly remains harmless.
The same definitions are available on demand: ListServiceCommands and
ListPluginCommands return a schemas key next to the existing commands
list, and ListPluginIds returns the same ids as the plugins record. See
SenML Message Format for
the record layouts.
Payload Validation¶
A command can declare its payload as a Pydantic model. Before the command runs, the dispatcher validates the payload against it. The model is also where the announced schema comes from, so the form a frontend renders and the check the service applies cannot drift apart.
- A command without a model is not validated and is announced with an empty object schema, so existing commands keep working unmodified.
- Models ignore unknown fields, so a client that sends extra keys keeps working.
- The raw payload is still available to the command as
context.payload; the validated model iscontext.params. - A payload that is not a JSON object is rejected for commands that declare a model and for commands routed to a plugin.
An invalid payload is rejected before execute runs, logged without a stack
trace, and answered with an error record that names each offending field:
[{"bn": "urn:cpt:device:sn:abc123:", "n": "error",
"vs": "Invalid payload for 'BLEDiscover': address: Field required"}]
Commands that already report errors in-band — the OTA commands — keep that format instead of raising, so an invalid payload is answered under the command's own name:
[{"bn": "urn:cpt:device:sn:abc123:", "n": "NotifyOTAUpdate",
"vs": "{\"id\": \"swupdate\", \"result\": \"error\", \"status\": \"updated\", \"error\": \"Invalid payload for 'NotifyOTAUpdate': fw_version: Field required\"}"}]
See Build Your First ToloMEO Microservice for how to declare a payload model.
Synchronous Reply-To¶
NATS supports a reply-to subject pattern for synchronous request/response.
NATSService.remote_handler always publishes the command response to
events.params; when the inbound message carries a reply-to value, that value
is passed through to the underlying NATS publish call as request/reply
correlation metadata (the message's Reply-To field), not as an alternative
publish destination. This lets an operator correlate the response with the
originating request without changing where the response is published.