Skip to content

Plugin System

Why Plugins Are Separate from Services

Services own the connection lifecycle and routing. Plugins own domain logic and hardware interaction. This separation means:

  • A plugin can be tested without a NATS connection
  • Multiple plugin types can coexist in a single service
  • The same plugin class can be used with different service configurations

Declarative Registration via Meta

Plugins declare their commands and metrics in an inner Meta class. The PluginMetaclass processes this at class definition time, collecting items from the full MRO chain so subclasses automatically inherit base commands:

class MyPlugin(SensorPlugin):
    class Meta:
        commands = [MyCommand]           # added to base SensorPlugin commands
        out_metrics = [Metric("v", "V")] # added to any inherited metrics

For each of commands, in_metrics, and out_metrics, PluginMetaclass delegates to a shared collector (ItemCollector and its subclasses CommandsCollector/InputMetricCollector/OutputMetricCollector in tolomeo/collectors.py), which assembles the final list in this order:

  1. Each ancestor's already-computed _commands/_in_metrics/_out_metrics (walked over the MRO)
  2. Entries from the current class's own Meta class only
  3. Entries declared directly on the class body (outside Meta)

The combined list is then deduplicated by identity, preserving order.

Options are declared the same way, but resolved differently

A plugin's configuration options are declared alongside its commands, as a Pydantic model:

class MyPlugin(SensorPlugin):
    class Meta:
        config_model = MyOptions   # not a list — see below

config_model is a scalar, so it cannot be accumulated the way command lists are. ConfigModelResolver, in tolomeo/plugins/base.py, resolves it instead: a class either inherits its ancestor's model, or declares one that extends it. Declaring an unrelated model raises TypeError at class-definition time, because methods on the ancestor read fields from the model the ancestor declared.

PluginBase.options validates the plugin's [plugins.<id>] table against the model and caches the result. See the Configuration Reference.

Abstract Methods Are Enforced

PluginMetaclass derives from abc.ABCMeta, so the @abstractmethod markers on PluginABC are real: a plugin that has not implemented connect and disconnect cannot be instantiated.

TypeError: Can't instantiate abstract class MyPlugin without an
           implementation for abstract method 'disconnect'

PluginBase, SensorPlugin, OTAPlugin and BLESensorPlugin are therefore all abstract. They supply everything except the part only the concrete plugin can know — how to reach its hardware, and for BLE, how to decode its wire format (parse_data). Each is a base to derive from, never a plugin to deploy.

Lifecycle: attach → detach

Every plugin goes through a two-phase lifecycle:

attach() -> bool
  └─ before_connect() → connect() → after_connect()
  └─ before_setup()   → setup()   → after_setup()

detach()
  └─ before_disconnect() → disconnect() → after_disconnect()

setup() is implemented by PluginBase. It validates the plugin's options and registers all declared commands and metrics. Use after_setup to start background tasks — all commands will be registered by then.

attach() reports whether it worked

attach() returns False when connect or setup fails, and skips setup entirely when connect fails, so a plugin that never connected does not register commands or metrics. A caller must not treat a plugin as usable when attach() returns FalseServiceBase.attach_plugin handles this, keeping such a plugin out of the registry.

Hook Methods

The six hook methods (before_connect, after_connect, before_setup, after_setup, before_disconnect, after_disconnect) are called by _call_with_hooks. They default to no-ops. Override them to add logic without replacing the lifecycle contract.

OTAPlugin uses before_setup to wire task manager callbacks into the FSM, and after_setup to bootstrap the state machine. This pattern keeps initialization logic isolated and testable.

before_setup is also where anything depending on both the plugin id and its configuration belongs, since connect has assigned the id by then. OTAPlugin resolves its state-machine checkpoint location there.

That ordering is why OTAStateMachine defers bootstrapping. It is built in the plugin's __init__, before any id exists, so it cannot yet know where its checkpoint lives — and entering its initial bootstrapping state both reads a snapshot and, if one is found, writes another. Rather than do that against the default path and correct itself later, it stays in bootstrapping until checkpoint_file is assigned; after_setup then bootstraps once, from the configured location, leaving the default one untouched.

Mixins Carry the Reusable Behaviour

A plugin family is assembled from two parts: a dependency-free mixin holding the logic, and a thin concrete class combining it with PluginBase.

class SensorPlugin(PluginBase, SensorMixin): ...

The mixins are additive only — they override nothing on PluginBase, which is why listing the mixin last is safe. Because they own the state they touch, they can be instantiated and tested bare, with no service and no NATS connection.

The sensor family splits along the lines its members actually reuse:

Mixin Provides Used by
SensorDataMixin push_reading, push_info, output metric group SensorMixin, BLESensorMixin
SensorConnectionMixin conn_context, on_disconnect, on_reconnect SensorMixin, BLESensorMixin
SensorMixin the two above, plus SensorState tracking SensorPlugin
BLESensorMixin the two above, plus Bleak client and FSM state BLESensorPlugin