Skip to content

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 vs string-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 is context.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.