Skip to content

Architecture

Py ToloMEO organises microservice logic into four layers — service, plugin, messaging, and task. Each layer has a single responsibility and communicates with adjacent layers through well-defined interfaces.

The diagram below gives the high-level picture: how an inbound command travels from the ToloMEO cloud down to a plugin, and how outbound data and events travel back up to the cloud.

Service & plugin architecture — light mode Service & plugin architecture — dark mode

A service is configured declaratively through an inner Meta class — its plugin class, messaging strategy, and commands. These settings are assembled across the whole class hierarchy at class-definition time, so subclasses extend behaviour by declaration rather than by overriding.

Service Layer

The service (built on ServiceBase / NATSService) is the top-level orchestrator and owns four collaborators: the messaging strategy, a command registry, a plugin registry, and a task manager. It:

  • Subscribes to commands.{name}.req and decodes inbound commands
  • Runs a service-level command if one is registered, otherwise routes the command to the plugin identified by the payload's id
  • Publishes command responses to events.params, sensor data to events.data, and OTA/status events to events.infos
  • Publishes a heartbeat to heartbeat.{name}.service; sensor data and info arrive via push — plugins call push_reading/push_info, which invoke a callback the service publishes from. There is no polling loop.

A service holds one plugin class but many plugin instances, keyed by plugin ID. SenML records are built in this layer before publishing — the messaging layer never sees SenML.

How plugins get acquired

setup registers the service's own commands and then calls acquire_plugins, the single hook a service overrides to say which plugins it manages and how it obtains them. The steps common to every service live in ServiceBase:

Method Responsibility
acquire_plugins Which plugins to obtain, and how. Default: none
new_plugin Instantiate Meta.plugin_class
wire_plugin Bind the plugin's push callbacks. Default: none
attach_plugin Wire, attach, and register — registering only if attach succeeds

That last guarantee matters: a plugin whose connect failed never enters the registry, so it is never served commands as though it were healthy.

OTAService attaches one plugin and SingleSensorService attaches one and wakes the params loop, both through acquire_plugins. BLESensorService acquires later instead, from start(): its addresses come from a configuration file it loads there, and BLE discovery is slow enough that doing it during setup() would delay startup.

The abstract bases declare no plugin_class

PluginBase, SensorPlugin, OTAPlugin and BLESensorPlugin are all abstract, so SensorService, OTAService and BLESensorService deliberately name no default plugin class — there is no instantiable one to name. Every deployment declares its own, and forgetting fails at construction with a RuntimeError naming Meta.plugin_class rather than later.

Plugin Layer

Plugins encapsulate domain logic and are decoupled from the transport. The built-in base classes are SensorPlugin (continuous metric publishing), BLESensorPlugin (a sensor over Bluetooth Low Energy) and OTAPlugin (firmware update management). Each plugin:

  • Declares its commands, input metrics, output metrics, and configuration options in a Meta class (output metrics are namespaced with the plugin ID)
  • Manages its own connection lifecycle to hardware or external resources
  • Runs background tasks (e.g. sensor read loops) on its own task manager and pushes readings to the service through a data-available callback — delivery is entirely push-driven, with no polling on the service side

Reusable behaviour lives in dependency-free mixins that the base classes combine with PluginBase, which is what lets a plugin's logic be tested without a service or a broker. All the base classes are abstract: hardware access is the concrete plugin's to supply.

Messaging Layer

NATSMessageStrategy wraps the nats-py client and is pure transport: it serialises payloads to JSON and publishes or subscribes on NATS subjects. It performs no SenML encoding — that belongs to the service layer. The service interacts with it only through the MessageStrategyABC interface. Swapping the strategy class in Meta is the only change needed to use a different transport.

ToloMEO Platform Context

A deployed Py ToloMEO microservice runs on an edge device. It connects to the ToloMEO cloud platform through the connhex-edge-agent (a NATS proxy). The platform sends commands inbound (operator triggers an OTA update) and receives data outbound (sensor readings, OTA status events). Py ToloMEO handles the edge side of this contract.