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:
- Each ancestor's already-computed
_commands/_in_metrics/_out_metrics(walked over the MRO) - Entries from the current class's own
Metaclass only - 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:
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 False — ServiceBase.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.
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 |