Skip to content

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 archetypesCommand & 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.telemetry shorthand 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.telemetry makes periodic polling trivial (return dict → published as state). @app.device with @ctx.on_command handles bidirectional devices cleanly. Manual loops remain possible for complex cases. Telemetry devices automatically skip /set subscription.
  • Disadvantages: Two registration decorators to learn. The distinction between @app.device and @app.telemetry is 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_command registers 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.telemetry is specific to the periodic-return-dict pattern — devices that conditionally publish (e.g., only on trigger) must use @app.device with a manual loop instead
  • The distinction may cause confusion: when to use @app.telemetry vs. @app.device with 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 topic and payload receive 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 None to 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.telemetry return-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_command code continues to work

Negative

  • Three registration decorators to learn and choose between (mitigated by clear decision matrix in documentation)
  • @app.command handlers 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 TypeError at 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) raises TypeError to 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_commands and 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 Logger subclass (workaround: use a wrapper class)

2026-02-23