ADR-031: Sub-Entity Context Manager¶
Status¶
Accepted Date: 2026-04-06
Context¶
cosalette devices currently publish telemetry and availability at the device level. Some devices need temporary sub-components that appear and disappear at runtime. Examples:
- a
calibratesub-entity that is online only during a calibration procedure, publishes step progress, and accepts calibration commands. - a special function sub-entity active only during specific device lifecycle stages.
- per-sensor staleness tracking where individual sensors may go offline while the device remains healthy.
Today there is no framework support for sub-entity lifecycle. Developers must manually publish availability and state to sub-entity topics, manage cleanup on exit, and wire up command handlers — duplicating the patterns the framework already provides at the device level.
Sub-topic routing is available¶
ADR-025 (Command Channel and Sub-Topic Routing) introduced ctx.on_command("sub")
and a TopicRouter that subscribes to {device}/+/set wildcards. Sub-entity command
support can delegate directly to this existing infrastructure — no new routing code is
needed.
Topic conventions are established¶
ADR-002 (MQTT Topic Conventions) defines the device topic layout:
The natural extension for sub-entities adds one level, following ADR-025's precedent:
Decision¶
Add a sub_entity(name) async context manager to DeviceContext that manages
sub-entity lifecycle — availability publishing, scoped state publishing, and command
handler registration.
SubEntityContext¶
A lightweight context object yielded by the context manager:
class SubEntityContext:
name: str
parent: DeviceContext
async def publish_state(self, payload: dict, *, retain: bool = True) -> None:
"""Publish to {parent_topic_base}/{name}/state."""
def on_command(self, handler: CommandHandler) -> CommandHandler:
"""Register a command handler on {parent_topic_base}/{name}/set.
Delegates to parent.on_command(self.name).
"""
SubEntityContext mirrors a subset of DeviceContext's API — publish_state() and
on_command() — scoped to the sub-entity's topic level.
Context manager lifecycle¶
@asynccontextmanager
async def sub_entity(self, name: str) -> AsyncIterator[SubEntityContext]:
self._validate_sub_entity_name(name)
self._active_sub_entities.add(name)
sub = SubEntityContext(name=name, parent=self)
avail_topic = f"{self._topic_base}/{name}/availability"
await self._mqtt.publish(avail_topic, "online", retain=True, qos=1)
try:
yield sub
finally:
# Clear retained state
state_topic = f"{self._topic_base}/{name}/state"
await self._mqtt.publish(state_topic, "", retain=True, qos=1)
# Publish offline
await self._mqtt.publish(avail_topic, "offline", retain=True, qos=1)
self._active_sub_entities.discard(name)
On enter:
- Validate the sub-entity name (see naming restrictions below).
- Track the name in
_active_sub_entities: set[str]onDeviceContext. - Publish
"online"to{device}/{sub}/availability(retained, QoS 1). - Yield a
SubEntityContext.
On exit (__aexit__):
- Publish empty payload to
{device}/{sub}/stateto clear retained state. - Publish
"offline"to{device}/{sub}/availability(retained, QoS 1). - Remove the name from
_active_sub_entities.
Command support via delegation¶
SubEntityContext.on_command(handler) delegates to
self.parent.on_command(self.name)(handler), reusing the existing sub-topic
routing from ADR-025. The TopicRouter already subscribes to {device}/+/set
and dispatches by sub-topic — no additional router changes or subscriptions are
needed.
Lifecycle scope: context manager only¶
Only the async with context manager API is provided. Persistent sub-entities
(always-online sub-components) are supported via a long-running async with block
inside @app.device:
@app.device("cover")
async def cover(ctx):
async with ctx.sub_entity("temperature") as temp:
# temp is online for the entire device lifetime
while not ctx.shutdown_requested:
reading = await read_sensor()
await temp.publish_state({"value": reading})
await ctx.sleep(60)
This avoids a second API (add_sub_entity()) and its attendant shutdown-ordering
complexity. If demand for a dedicated persistent sub-entity API grows, it can be
added backward-compatibly in a future release.
State retention: retain during lifecycle, clear on exit¶
Sub-entity state is published with retain=True by default (same as device state),
so Home Assistant sees the latest state even after reconnection. On __aexit__, the
retained state topic is cleared by publishing an empty payload — the MQTT-standard
deletion mechanism. This prevents stale state data from lingering in the broker after
the sub-entity goes offline.
Naming restrictions¶
Sub-entity names are validated with the following rules:
- MQTT-safe: no
/,+,#characters (same validation as_validate_sub_topicin ADR-025). - Not empty: empty string raises
ValueError. - No concurrent duplicates: a sub-entity name that is already active on the
same
DeviceContextraisesValueError. Tracked via_active_sub_entities. - Reserved names rejected: the following names are reserved because they would collide with existing or planned device-level topic suffixes:
| Reserved name | Reason |
|---|---|
state |
Device state topic (ADR-002) |
set |
Command topic suffix (ADR-002) |
availability |
Device availability topic (ADR-012) |
status |
App-level health topic (ADR-012) |
error |
Error topic (ADR-011) |
config |
Future: HA MQTT discovery config topic |
attributes |
Future: HA extra attributes topic |
json_attributes |
Future: HA JSON attributes topic |
diagnostic |
Future: diagnostic data channel |
firmware |
Future: OTA firmware update channel |
- Same name as device: allowed (topic path
{device}/{device}/stateis unambiguous), but logs aWARNINGsince it is likely a mistake.
Sub-entity availability and HealthReporter¶
Sub-entity availability is published directly by the context manager via MqttPort,
not through HealthReporter. This is intentional:
HealthReportertracks device-level availability and app-level status. Sub-entity lifecycle is scoped and transient — adding tracking toHealthReporterwould complicate its shutdown logic for marginal benefit.- The context manager's
__aexit__guarantees cleanup. If the process crashes, the device-level LWT (availability = "offline") signals Home Assistant that the entire device (and by implication its sub-entities) is unavailable.
Decision Drivers¶
- Consistency: sub-entity topics follow the same conventions as device topics, extended by one level.
- Minimal API surface: one context manager method, one yielded context type.
- Leverage existing infrastructure: command routing, topic conventions, and MQTT publishing are reused without modification.
- Clean broker state: retained messages are cleared on sub-entity exit.
- Safety: naming validation prevents topic collisions and concurrent duplicates.
Considered Options¶
Option A: Context manager only (chosen)¶
async with ctx.sub_entity("name") as sub: — scoped lifecycle, automatic cleanup.
Pros: simple, composable, guaranteed cleanup, small API surface.
Cons: permanent sub-entities require a long-running async with (a workaround,
not a first-class API).
Option B: Context manager + persistent API¶
Add ctx.add_sub_entity("name") that returns a SubEntityContext without scoping.
Pros: first-class persistent sub-entities. Cons: shutdown ordering (what if device exits before cleanup?), larger API, two code paths to test and maintain.
Option C: Independent command implementation¶
Sub-entities subscribe to their own MQTT topics manually, bypassing TopicRouter.
Pros: no dependency on ADR-025 infrastructure. Cons: duplicates routing logic, inconsistent with established command patterns, more code to maintain.
Option D: Extend HealthReporter for sub-entities¶
Route sub-entity availability through HealthReporter for centralized tracking.
Pros: single source of truth for all availability state.
Cons: complicates HealthReporter shutdown logic, adds tracking overhead for
transient entities, marginal benefit since __aexit__ guarantees cleanup.
Decision Matrix¶
| Criterion | A: ctx mgr only | B: + persistent | C: independent cmd | D: via HealthReporter |
|---|---|---|---|---|
| API simplicity | 5 | 3 | 4 | 4 |
| Guaranteed cleanup | 5 | 3 | 4 | 5 |
| Infrastructure reuse | 5 | 5 | 2 | 3 |
| Permanent sub-entities | 3 | 5 | 3 | 3 |
| Maintenance cost | 5 | 3 | 2 | 3 |
Scale: 1 (poor) to 5 (excellent)
Consequences¶
Positive¶
- Devices with transient sub-components get framework-managed lifecycle — no more manual availability publishing and cleanup.
- Home Assistant sees sub-entities appear and disappear cleanly, with no stale retained state.
- Sub-entity commands work immediately via existing ADR-025 infrastructure.
- The API is small and consistent with existing
DeviceContextpatterns.
Negative¶
- Persistent sub-entities use a workaround (long-running
async with) rather than a dedicated API. Acceptable for 0.3.0; can be addressed in a future ADR if demand materialises. - Sub-entity availability is not tracked by
HealthReporter, so framework-level health queries don't include sub-entity status. This is intentional for simplicity but may need revisiting if observability requirements grow.
References¶
- ADR-002: MQTT Topic Conventions
- ADR-011: Error Handling and Publishing
- ADR-012: Health and Availability Reporting
- ADR-025: Command Channel and Sub-Topic Routing
2026-04-06