ADR-010: Device Archetypes¶
Status¶
Accepted Date: 2026-02-14
Context¶
Analysis of all 8 IoT-to-MQTT bridge projects reveals two fundamental device interaction patterns. The framework needs first-class support for both patterns and must allow them to be mixed within a single application.
Archetype A: Command & Control (Bidirectional)¶
Subscribe to MQTT command topics, translate into device actions, publish state.
| Project | Hardware | Library |
|---|---|---|
| velux2mqtt | GPIO relay | RPi.GPIO |
| wiz2mqtt | WiFi UDP | pywizlight |
| concept2mqtt | BLE | bleak (CSAFE) |
| wallpanel2mqtt | LAN SSH | asyncssh |
| smartmeter2mqtt | USB/IR | pyserial/FTDI |
| vito2mqtt | USB/Optolink | pyserial/vcontrold |
Pattern: MQTT command → parse → device action → read state → publish
Archetype B: Telemetry (Unidirectional)¶
Poll or stream sensor data, publish readings to MQTT.
| Project | Hardware | Library |
|---|---|---|
| airthings2mqtt | BLE | wave-reader/bleak |
| gas2mqtt | I²C (HMC5883) | smbus2 |
Pattern: poll/stream → read → transform → publish
Some projects combine both (e.g., smartmeter2mqtt reads data continuously but also accepts configuration commands).
Decision¶
Use two first-class device archetypes — Command & Control via @app.device with
@ctx.on_command, and Telemetry via @app.telemetry with a configurable polling
interval — because analysis of 8 IoT projects shows these two patterns cover all
use cases, and the framework should make the common cases effortless.
Command & Control (@app.device)¶
@app.device("blind")
async def blind(ctx: DeviceContext) -> None:
gpio = ctx.adapter(GpioPort)
@ctx.on_command
async def handle(payload: str) -> None:
command = parse_command(payload)
await execute(command, gpio, ctx)
await ctx.publish_state({"position": get_position()})
await ctx.publish_state({"position": None, "moving": False})
The framework automatically subscribes to {app}/{device}/set and dispatches incoming
messages to the registered command handler.
Telemetry (@app.telemetry)¶
@app.telemetry("wave-1", interval=30.0)
async def wave_sensor(ctx: DeviceContext) -> dict:
reading = await ble_client.read_characteristic(...)
return {"radon_bq_m3": reading.radon, "temperature": reading.temp}
The framework calls the function at the specified interval, publishes the returned
dict as JSON to {app}/{device}/state, and never subscribes to a /set topic.
Manual telemetry loop¶
For devices that need custom polling logic (e.g., trigger detection with hysteresis),
the @app.device decorator also supports long-running loops:
@app.device("counter")
async def counter(ctx: DeviceContext) -> None:
while not ctx.shutdown_requested:
bz = read_magnetometer(bus)
if trigger_detected(bz):
await ctx.publish_state({"count": count, "trigger": "CLOSED"})
await ctx.sleep(1.0)
Mixed archetypes¶
A single application can register both types — the framework manages them as concurrent asyncio tasks.
Decision Drivers¶
- Analysis of 8 real IoT projects shows exactly two interaction patterns
- The framework should make the common cases (periodic polling, command handling) effortless
- Complex devices (custom polling, stateful trigger detection) must remain expressible
- Telemetry-only devices should not incur command subscription overhead
- Both archetypes must be combinable within a single application
Considered Options¶
Option 1: Single generic device type¶
Provide only @app.device and let projects implement both patterns manually.
- Advantages: Maximum flexibility with minimal API surface. Simpler framework code.
- Disadvantages: Periodic telemetry requires boilerplate (loop, sleep, publish) in
every telemetry project. Misses the opportunity to make the common case trivial.
The
@app.telemetryshorthand eliminates 5-10 lines of polling boilerplate per device.
Option 2: Event-driven only¶
All devices react to events (MQTT messages, timer ticks) — no long-running device functions.
- Advantages: Uniform model, easy to reason about concurrency.
- Disadvantages: Does not fit sensor polling patterns where the device owns the read loop. Event-driven timer ticks for polling add indirection without benefit. The gas2mqtt trigger detection (hysteresis, EWMA filtering) is naturally expressed as a loop, not as discrete events.
Option 3: Two first-class archetypes (chosen)¶
@app.device for command & control (with @ctx.on_command) and @app.telemetry
for periodic polling, with @app.device also supporting manual loops.
- Advantages: Covers 100% of known use cases.
@app.telemetrymakes periodic polling trivial (return dict → published as state).@app.devicewith@ctx.on_commandhandles bidirectional devices cleanly. Manual loops remain possible for complex cases. Telemetry devices automatically skip/setsubscription. - Disadvantages: Two registration decorators to learn. The distinction between
@app.deviceand@app.telemetryis syntactic sugar — they could be unified with parameters.
Decision Matrix¶
| Criterion | Single Generic | Event-Driven Only | Two Archetypes |
|---|---|---|---|
| Common case simplicity | 2 | 3 | 5 |
| Flexibility for complex | 5 | 2 | 5 |
| API surface | 5 | 4 | 3 |
| Learning curve | 5 | 3 | 4 |
| Code elimination | 2 | 3 | 5 |
Scale: 1 (poor) to 5 (excellent)
Consequences¶
Positive¶
- Periodic telemetry devices are trivial to implement — a single decorated function that returns a dict
- Command & control devices have a clear, structured pattern —
@ctx.on_commandregisters the handler, framework manages topic subscription - Telemetry-only devices incur no command subscription overhead
- Both archetypes can coexist in a single application as concurrent tasks
- The gas2mqtt example (telemetry) reduces to ~20 lines of application code
Negative¶
- Two decorators (
@app.device,@app.telemetry) to learn and choose between @app.telemetryis specific to the periodic-return-dict pattern — devices that conditionally publish (e.g., only on trigger) must use@app.devicewith a manual loop instead- The distinction may cause confusion: when to use
@app.telemetryvs.@app.devicewith a while loop
Amendment: @app.command Decorator (2026-02-20)¶
Context¶
Experience building command devices with @app.device + @ctx.on_command revealed
friction in the original two-archetype design. The closure-based pattern
(nonlocal state, inner @ctx.on_command handler, mandatory while loop) was
syntactically heavy for the most common use case: receive a command, act on it, publish
state. Testing required mocking the device lifecycle rather than calling a function
directly. This conflicted with the framework's FastAPI-inspired design philosophy,
where simple use cases should have simple code.
Decision¶
Add @app.command(name) as a third first-class archetype alongside @app.device
and @app.telemetry. @app.command registers a standalone async function that
handles MQTT commands for a device:
- Parameters named
topicandpayloadreceive MQTT message values by name. - All other parameters are injected by type annotation (
DeviceContext, adapters). - If the handler returns a
dict, the framework auto-publishes it as device state. - Return
Noneto skip auto-publishing. - Error-isolated: exceptions are caught, logged, and published to error topics.
@app.command("valve")
async def handle_valve(
topic: str, payload: str, ctx: cosalette.DeviceContext
) -> dict[str, object]:
return {"state": payload}
@app.device + @ctx.on_command remains supported for backward compatibility and
for devices that genuinely need a long-running coroutine.
Updated Archetype Table¶
| Pattern | Use When |
|---|---|
@app.command(name) |
Device reacts to MQTT commands. Most common. |
@app.telemetry(name, interval=N) |
Device polls/streams data on an interval. |
@app.device(name) |
Complex lifecycle — custom loops, state machines. Escape hatch. |
Consequences¶
Positive¶
- The most common command device pattern reduces from ~15 lines (closure + loop) to ~5 lines (function + return)
- Command handlers are plain functions — trivially unit-testable without lifecycle scaffolding
- Aligns with the
@app.telemetryreturn-dict contract — both simple decorators follow the same "return data, framework publishes" pattern - Dependency injection via type annotations is consistent with FastAPI conventions
- No breaking changes — existing
@app.device+@ctx.on_commandcode continues to work
Negative¶
- Three registration decorators to learn and choose between (mitigated by clear decision matrix in documentation)
@app.commandhandlers cannot perform background work between commands — devices needing periodic state refresh still require@app.device
2026-02-20
Amendment: init= Callback Parameter (2026-02-23)¶
Context¶
Real-world telemetry devices often need per-device state — filter instances,
calibration tables, connection pools — initialised once before the handler loop
starts. Before init=, users resorted to module-level globals or closures,
neither of which composes well with dependency injection or unit testing.
Command devices had a similar problem: tracking state across messages required
global variables or @app.device with closures, even when the device didn't
need a long-running coroutine.
Decision¶
Add an init= parameter to all three decorators (@app.telemetry,
@app.command, @app.device). The callback is a synchronous factory invoked
once before the handler loop; its return value is injected into the handler by
type via the existing DI machinery.
This supports stateful initialisation without subclassing, globals, or closures — consistent with the framework's dependency-injection-first design.
Constraints¶
- Synchronous only — async init callbacks raise
TypeErrorat decoration time. The callback runs during bootstrap, before the async event loop processes device tasks. - Type collision guard — returning a framework-provided type (
Settings,DeviceContext,Logger,ClockPort,Event) raisesTypeErrorto prevent accidental shadowing. - Fail-fast — bad signatures are validated at decoration time.
- Command caching — for
@app.command, the init result is created once in_wire_commandsand reused for every inbound message.
Consequences¶
Positive¶
- Filter state, calibration data, and connection pools are scoped to the device registration — no module-level globals
- Init callbacks are independently testable (plain synchronous functions)
- Consistent with the DI model used for handler parameters and adapter factories
- Fail-fast validation catches errors at import time
Negative¶
- One more parameter to learn on each decorator
- The type collision guard may surprise users who want to inject a custom
Loggersubclass (workaround: use a wrapper class)
2026-02-23