ADR-001: Migrate to cosalette Framework¶
Status¶
Accepted Date: 2026-03-24 | Amended Date: 2026-05-14
Context¶
The velux2mqtt application controls Velux covers (blinds and windows) by simulating button presses on KLF 050 radio remotes via GPIO-driven M74HC4066 analog switches. The original implementation was a standalone script with manual MQTT handling, GPIO setup, and an ad-hoc command loop.
Key problems with the pre-cosalette design:
- Tight coupling --- GPIO access, MQTT publishing, position tracking, and command parsing were interleaved in a single loop. Testing any component required the entire stack.
- No health reporting --- no heartbeats, no Last Will and Testament (LWT), no per-device availability. Silent failures on a headless Raspberry Pi were undetectable without SSH access.
- Hardcoded configuration --- GPIO pins, travel durations, and MQTT credentials
required code changes to modify. No support for
.envfiles or Docker deployments. - No error isolation --- a GPIO exception would crash the entire process, potentially leaving a cover mid-travel with no stop command sent.
- Multi-cover complexity --- supporting multiple covers (e.g. a blind and a window on the same Pi) required duplicating the command loop and state management.
Decision¶
Migrate velux2mqtt to the cosalette IoT-to-MQTT framework (v0.1.0+).
cosalette provides a declarative application model for IoT bridges:
@app.device()for full-lifecycle coroutines with shutdown awareness- Automatic MQTT connection management with reconnect
- Built-in health reporting: heartbeats, per-device availability, LWT
- Automatic error isolation and error topics
- Pydantic-based settings with env /
.env/ CLI layering - Dependency injection via type annotations
- Adapter registration with dry-run alternatives
velux2mqtt uses app.add_device() (the imperative form) to register one device per
configured cover, since covers are defined in settings rather than being static
decorators.
Decision Drivers¶
- Testability --- the primary goal was enabling comprehensive test coverage.
cosalette's DI system and adapter pattern make every component independently testable.
The
FakeGpioadapter allows testing GPIO sequences without hardware. - Multi-cover support --- each cover needs independent state (position tracker, drift
compensator, calibration state machine). cosalette's device model naturally supports
this via
app.add_device()in a loop, each with its ownDeviceContext. - Operational visibility --- heartbeats, per-device availability, and LWT are essential for unattended Raspberry Pi deployments where the only interface is MQTT.
- Configuration flexibility --- Pydantic settings eliminate hardcoded values and
support Docker-native
.envfiles. The complex cover configuration (JSON list of objects) validates at startup with clear error messages. - Graceful shutdown --- the cover device must send a stop command to the GPIO when
shutting down to avoid leaving a cover mid-travel. cosalette's
ctx.shutdown_requestedandctx.sleep()make this straightforward.
Considered Options¶
- cosalette framework --- purpose-built for IoT-to-MQTT bridges.
- Manual refactor --- restructure into modules without a framework.
- Home Assistant add-on --- rewrite as an HA integration.
Decision Matrix¶
| Criterion | cosalette | Manual Refactor | HA Add-on |
|---|---|---|---|
| Testability | 5 | 4 | 3 |
| Multi-cover support | 5 | 3 | 4 |
| Operational visibility | 5 | 2 | 4 |
| Migration effort | 4 | 3 | 2 |
| Deployment flexibility | 5 | 5 | 2 |
| Maintenance burden | 5 | 3 | 3 |
Scale: 1 (poor) to 5 (excellent)
Consequences¶
Positive¶
- Ports-and-adapters architecture --- domain logic (
position.py,command.py,drift.py,calibration.py) has zero I/O dependencies. - Comprehensive test suite --- unit tests cover all domain modules, adapters, ports, and settings; integration tests verify the full app wiring and calibration end-to-end.
- Multi-cover by configuration --- adding a second cover requires only a JSON config entry, not code changes.
- Closure-based device factory ---
make_cover()returns an async callable per cover, capturing configuration without global state. - Automatic health reporting --- heartbeats, per-device availability, and LWT come free from cosalette.
- Docker-ready deployment --- Pydantic settings +
.envfiles work naturally withdocker compose. - Dry-run mode ---
FakeGpioadapter enables running without hardware for development and CI.
Negative¶
- Framework dependency --- the application depends on cosalette's lifecycle and conventions. If cosalette's API changes, migration work is needed.
- Python 3.14+ requirement --- cosalette requires Python 3.14+, limiting deployment to systems with recent Python.
- Learning curve --- contributors need to understand cosalette's device model and DI system in addition to the domain logic.
Amendment (2026-05-14) — Corrective¶
Rationale: cosalette 0.4 exposes dict-name device registration via @app.device(name=callable), so configured covers can now be expanded declaratively while preserving one DeviceContext and independent runtime state per cover.
Justification for amendment (not supersession): Supersession is not warranted because the primary decision to use cosalette remains unchanged; the impact is confined to the velux2mqtt composition root, cover handler entry point, tests, and documentation, with no MQTT contract or domain model migration.
Revised Decision¶
Use cosalette dict-name @app.device registration for configured Velux covers because it keeps one device per cover while removing the imperative on_configure/add_device loop from the composition root.
def _cover_map(settings: cosalette.Settings) -> dict[str, CoverConfig]:
if not isinstance(settings, Velux2MqttSettings):
raise TypeError(f"Expected Velux2MqttSettings, got {type(settings).__name__}")
return {cover.name: cover for cover in settings.covers}
@app.device(name=_cover_map, summary="Velux cover: open/close/stop control")
async def cover(
ctx: cosalette.DeviceContext,
cover_cfg: CoverConfig,
settings: Velux2MqttSettings,
):
async for event in cover_device(ctx, cover_cfg, settings):
yield event
Additional Sub-Decision: Keep make_cover() as a compatibility wrapper¶
The application registers the top-level cover_device() through @app.device(name=_cover_map). make_cover() remains as a small wrapper for focused unit tests and external callers that need a pre-bound device function.
Additional Positive Consequences¶
- The composition root now uses cosalette's declarative multi-device API while preserving one independent runtime device per configured cover.
- Direct composition tests can assert the name-mapping registration without duplicating the production add_device loop.
Additional Negative Consequences¶
- Per-cover summary text is now generic at registration time because dict-name expansion does not provide a per-expanded-device summary hook.
- The top-level handler must expose CoverConfig and Velux2MqttSettings as explicit dependencies instead of relying solely on closure capture.
Amendment (2026-05-14) — Corrective¶
Rationale: The first 2026-05-14 amendment correctly moved configured covers to cosalette dict-name device registration, but its code example retained a local decorator-facing wrapper in main.py. The final implementation registers cover_device directly with app.device(...)(cover_device) and places the device contract in declarative registration metadata.
Justification for amendment (not supersession): Supersession is not warranted because the primary decision and the earlier corrective amendment still stand: velux2mqtt uses cosalette dict-name device expansion. The impact is confined to clarifying the composition-root registration form and documentation; MQTT topics, domain models, and runtime device behavior remain unchanged.
Revised Decision¶
Register configured Velux covers with cosalette dict-name device expansion by applying app.device(...)(cover_device) directly. Use registration metadata (summary, behavior, effects) in main.py to declare the device contract while keeping the command-loop implementation in devices/cover.py.
def _cover_map(settings: cosalette.Settings) -> dict[str, CoverConfig]:
if not isinstance(settings, Velux2MqttSettings):
raise TypeError(f"Expected Velux2MqttSettings, got {type(settings).__name__}")
return {cover.name: cover for cover in settings.covers}
app.device(
name=_cover_map,
summary="Velux cover: GPIO-driven open/close/stop/position control",
behavior=[
"Startup homing to a known endpoint for reliable position reference",
"Open/close/stop commands via GPIO button presses on KLF 050 remote",
"Position targeting (0-100%) with time-based position tracking",
"Calibration sub-entity (start/go/mark/cancel phases)",
],
effects=[
"Presses GPIO pins (up/stop/down) via GpioSwitchPort",
"Publishes cover position state to MQTT",
],
)(cover_device)
Additional Sub-Decision: Keep make_cover() as a pre-binding helper, not the app registration path¶
The application registers cover_device directly. make_cover() remains available for focused tests and external callers that need a single-argument cosalette-compatible device callable with CoverConfig and Velux2MqttSettings pre-bound.
Additional Positive Consequences¶
- main.py now reads declaratively: it exposes configured device expansion plus summary, behavior, and effects metadata without a pass-through wrapper.
- The registered function identity is the runtime implementation (
cover_device), so composition tests and manifests point at the actual device handler.
Additional Negative Consequences¶
- The registration uses call-form decoration (
app.device(...)(cover_device)) instead of@app.devicesyntax, because Python decorator syntax requires defining a local wrapper.