MQTT Topic Reference¶
Complete topic schema reference for cosalette MQTT topics. For conceptual background — rationale, routing internals, wildcard monitoring — see MQTT Topics (concept).
Topic Schema¶
Every cosalette application uses a flat, Home Assistant-aligned topic
hierarchy. The {prefix} is the application name, and {device} is
the device name registered with @app.device(), @app.command(), or
@app.telemetry(). When the device name is omitted, the device publishes
to root-level topics without a {device} segment.
| Topic Pattern | Direction | QoS | Retain | Description |
|---|---|---|---|---|
{prefix}/{device}/state |
Outbound | 1 | Yes | Device state (JSON) |
{prefix}/{device}/set |
Inbound | — | — | Command input; also used by telemetry with an MQTT trigger source (triggerable=True / "mqtt" / "both"). A triggerable="local" telemetry entity or device subscribes nothing extra. |
{prefix}/{device}/{sub}/set |
Inbound | — | — | Sub-topic command input |
{prefix}/{device}/{sub}/state |
Outbound | 1 | Yes | Sub-entity state (JSON) |
{prefix}/{device}/{sub}/availability |
Outbound | 1 | Yes | Sub-entity online/offline (string) |
{prefix}/{device}/availability |
Outbound | 1 | Yes | Per-device online/offline (string) |
{prefix}/{device}/error |
Outbound | 1 | No | Per-device error event (JSON) |
{prefix}/error |
Outbound | 1 | No | Global error event (JSON) |
{prefix}/status |
Outbound | 1 | Yes | App heartbeat (JSON) and LWT ("offline") |
Root Device Topics¶
When name is omitted from a decorator, the device publishes at the
root level:
| Topic Pattern | Direction | QoS | Retain | Description |
|---|---|---|---|---|
{prefix}/state |
Outbound | 1 | Yes | Root device state (JSON) |
{prefix}/set |
Inbound | — | — | Root device command input |
{prefix}/availability |
Outbound | 1 | Yes | Root device online/offline |
Root device errors appear only on the global {prefix}/error topic —
there is no per-device error topic since it would be identical.
At most one root device per app. See Device Archetypes for naming rules.
QoS and retain
All framework-managed publishes use QoS 1 (at-least-once) — this is
hard-coded and not configurable. For custom channels that need QoS 0,
use ctx.publish(channel, payload, qos=0). Retain defaults match the
table above; publish_state() accepts an optional retain keyword
argument to override per-call.
Framework Metadata Topics¶
The reserved _meta/ namespace carries framework introspection metadata. Both
topics are retained, QoS 1, always published (no setting), republished on every
broker connect, and never validated against the AsyncAPI schema or turned into a
Home Assistant discovery entity.
| Topic Pattern | Direction | QoS | Retain | Description |
|---|---|---|---|---|
{prefix}/_meta/registry |
Outbound | 1 | Yes | Canonical AsyncAPI document, with inbound command channels stripped |
{prefix}/_meta/state_model_drift |
Outbound | 1 | Yes | state_model= declaration drift snapshot (ADR-069) |
_meta/state_model_drift is the machine-readable rendering of the ADR-068
registration warning: it lists every @app.telemetry / @app.command handler
that declares state_model=M but is annotated -> N. A clean app publishes
drift_count: 0, so "no drift" is distinguishable from "never ran a version
that publishes drift".
{
"schema_version": 1,
"drift_count": 1,
"entries": [
{
"handler": "brightness",
"archetype": "telemetry",
"kind": "annotation_conflict",
"declared_model": "Reading",
"effective_annotation": "dict"
}
]
}
The snapshot covers declaration drift only — a runtime ReturnValidationError
is published to {prefix}/{name}/error as usual.
Shared brokers
Both topics expose channel addresses, payload schemas and handler/model
names. Protect _meta/# with broker ACLs on shared brokers.
Topic Prefix¶
The {prefix} placeholder resolves at runtime from two sources,
checked in order:
Settings.mqtt.topic_prefix— when set (e.g. viaMQTT__TOPIC_PREFIX=staging-velux), this value is used as{prefix}.App(name="velux2mqtt")— whentopic_prefixis empty (the default), thenameargument is used.
This lets you deploy the same binary with different topic namespaces (e.g. staging vs production) by setting an environment variable.
For example, App(name="velux2mqtt") with a device "blind" produces:
velux2mqtt/blind/state
velux2mqtt/blind/set
velux2mqtt/blind/availability
velux2mqtt/error
velux2mqtt/status
Prefixes may contain multiple segments — MQTT__TOPIC_PREFIX=house/velux
publishes house/velux/blind/state. Only +, # and NUL are rejected;
outer slashes are stripped.
Prefix vs app identity¶
The prefix is transport; App(name=...) is identity. They are
never interchangeable (ADR-072):
| Derived from the prefix (transport) | Derived from App(name=...) (identity) |
|---|---|
| Every MQTT topic the app publishes or subscribes to | x-cosalette-app channel ownership tag |
channel.address in generated AsyncAPI |
Home Assistant node_id |
ACL grants from cosalette schema acl |
The slice network-level enforcement filters |
HA discovery state_topic / availability_topic |
AsyncAPI info.title |
Device names — and therefore Home Assistant object_id and unique_id —
are parsed past the whole prefix, so adopting or changing a prefix never
orphans existing entities.
Because the prefix never changes identity, two instances of one app that
differ only in their prefix would share discovery ids. Give each one its own
MQTT__INSTANCE_ID, which replaces the app name in the Home Assistant and
openHAB identity fields; see
Several instances of one app.
Generated artefacts¶
Generated AsyncAPI composes channel.address from the resolved prefix and
records it in info.x-cosalette-topic-prefix. The key is emitted only when
the prefix differs from the app name; when it is absent, readers fall back
to info.title, which is exactly the behaviour of documents generated
before this extension existed.
This matters because consumers such as schema acl, schema ha-discovery
and schema openhab read a serialised document with no App in hand —
the extension is how they recover the prefix.
Resolving the prefix requires settings, so it is applied when you pass
--resolve-settings:
For CI gates that cannot load settings (or deliberately skip configure hooks), pass the prefix explicitly instead — it takes precedence over the resolved value:
See Settings Reference for all MQTT configuration options.
State Topics¶
Published by ctx.publish_state() in device functions.
- Topic:
{prefix}/{device}/state - QoS: 1 — at-least-once delivery
- Retain: Yes — subscribers receive the last-known state immediately
- Payload: JSON dict (user-defined structure)
Command Topics¶
Inbound topics the framework subscribes to for command & control devices.
- Topic:
{prefix}/{device}/set - Direction: Inbound — the broker delivers messages to the framework
- Payload: Plain string, decoded by the user's command handler
The TopicRouter subscribes to {prefix}/{device}/set and
{prefix}/{device}/+/set for each device that has a registered command
handler (via @app.command(), @ctx.on_command, or ctx.commands() inside
an @app.device() function). The +/set wildcard captures sub-topic
commands. Telemetry-only devices are not subscribed.
Routing behaviour¶
The router extracts the device name and optional sub-topic using a three-step resolution. MQTT wildcards are used only for subscription, not for parsing:
- Exact registered-name match —
{prefix}/{device}/setresolves to(device, None). Handles both simple names and slash-composed Router names such as"sensors/temperature". - One-level sub-topic prefix match — when a registered name is a
path-prefix of the topic's middle segment,
{prefix}/{device}/{sub}/setresolves to(device, sub). - Syntactic fallback — for topics with no matching registered name, a one- or two-segment parse is attempted so that a useful "No handler registered" warning can be emitted. This never dispatches to a handler.
Topics that do not match {prefix}/…/set (or {prefix}/set for root
devices) are silently ignored. Messages for a device with no handler
produce a WARNING log entry.
Availability Topics¶
Published automatically by the HealthReporter at device startup and
during graceful shutdown.
- Topic:
{prefix}/{device}/availability - QoS: 1
- Retain: Yes
- Payload:
"online"or"offline"(plain string, not JSON)
Compatible with Home Assistant's MQTT availability schema.
Re-announced on every (re)connect
On the first MQTT connect and on every reconnect, the framework publishes the
live availability of each device: "offline" if it is currently unavailable,
otherwise "online". A device is never announced "online" while it is
failing. Retained availability therefore recovers automatically after a broker
restart or network interruption — including an "offline" transition whose
publish was dropped while the broker was unreachable, before the first connect
or during an outage. Once that connect callback has completed, late subscribers
see the broker's latest retained state; subscribers that arrive before any
successful connection can only see what the broker retained previously. See
ADR-012 (2026-10-02
amendment) and
ADR-077.
Stream Availability¶
A named stream (@app.stream("feed")) owns {prefix}/{stream}/availability
with the same QoS, retain flag and payload as a device. It is announced
"online" at startup and on every (re)connect, published "offline" at
shutdown, and goes "offline" while any source holds it unavailable: a task
crash (supervisor), ctx.mark_unavailable() (manual), or a lapsed
stale_after= (freshness). The next yielded item clears the crash and
freshness marks; a manual mark requires ctx.mark_available(). It returns
"online" only when every source has cleared. A stream declared
with feeds=[...] also holds each fed entity's own availability topic
"offline" while it is offline.
A root stream (@app.stream()) is heartbeat-only: it never publishes an
availability topic and never touches the app-wide {prefix}/availability.
See
ADR-081
(2026-10-03 amendment) and Streaming.
Sub-Entity Availability¶
Sub-entities follow the same pattern one level deeper:
velux2mqtt/cover/calibrate/availability → "online"
velux2mqtt/cover/calibrate/availability → "offline"
Sub-entity availability is managed automatically by ctx.sub_entity() — "online" on
enter, "offline" on exit, with retained state cleared on teardown. See
ADR-031.
Availability in the Generated AsyncAPI Document¶
Since contract version "2", the generated AsyncAPI document describes these
topics as framework-owned channels
(ADR-086):
| Owner | Channel id | Address |
|---|---|---|
| Named device, telemetry or command | {name}Availability |
{prefix}/{name}/availability |
| Named stream | {name}Availability |
{prefix}/{stream}/availability |
| Root entity | availability |
{prefix}/availability |
| Root stream, sub-entity | — | not generated |
A telemetry and a command that share a name share one channel. Each channel has
a string payload enum (online, offline), an MQTT binding with qos: 1 and
retain: true, and a publish{Name}Availability send operation. It carries
x-cosalette-framework: "availability" and x-cosalette-discoverable: false
instead of an x-cosalette-archetype, so Home Assistant discovery, openHAB and
broker ACL output do not change.
Error Topics¶
Published by the ErrorPublisher service. Every error is published to
the global topic; when a device name is known, a copy goes to the
per-device topic as well.
- Global topic:
{prefix}/error - Per-device topic:
{prefix}/{device}/error - QoS: 1
- Retain: No — errors are events, not state
- Payload: JSON (
ErrorPayloadschema)
velux2mqtt/error → {"error_type": "error", "message": "...", ...}
velux2mqtt/blind/error → {"error_type": "invalid_command", "message": "...", ...}
See Payload Schemas for the full ErrorPayload structure.
App Status and LWT¶
The {prefix}/status topic serves two roles on the same topic:
Last Will and Testament (LWT)¶
When the MQTT client connects, the framework registers a Last Will and
Testament via build_will_config():
| Property | Value |
|---|---|
| Topic | {prefix}/status |
| Payload | "offline" |
| QoS | 1 |
| Retain | Yes |
If the client disconnects unexpectedly (crash, network loss), the broker
publishes "offline" to {prefix}/status on the application's behalf.
During graceful shutdown, the HealthReporter publishes "offline"
explicitly.
Heartbeat¶
The HealthReporter publishes a structured JSON heartbeat to the
same {prefix}/status topic. An initial heartbeat is published
on the first MQTT connection (overwriting the LWT "offline"), then
periodically at the configured heartbeat_interval (default 60 s,
set to None to disable). A fresh heartbeat is also published on
every subsequent reconnect, ensuring the retained payload is current
after a broker restart:
{
"status": "online",
"uptime_s": 3600,
"version": "0.3.0",
"devices": {
"blind": {"status": "ok"},
"temp": {"status": "ok"}
}
}
- QoS: 1
- Retain: Yes
Consumers can distinguish LWT from heartbeat by attempting JSON parse:
the LWT payload is a plain string "offline", while heartbeats are
valid JSON objects.
See Payload Schemas for the full HeartbeatPayload and
DeviceStatus structures.
Wildcards¶
The framework uses explicit per-device subscriptions, not MQTT wildcards. However, external consumers (monitoring tools, Home Assistant) can use wildcards for fleet-level monitoring:
| Pattern | Use case |
|---|---|
+/status |
Monitor all apps in a fleet |
velux2mqtt/+/state |
All device states in one app |
+/error |
Global errors across all apps |
velux2mqtt/+/availability |
Per-device availability in one app |
velux2mqtt/blind/+/set |
All sub-topic commands for one device |
+/_meta/state_model_drift |
Scrape state_model= drift across a fleet |
# Subscribe to all errors across all bridges
mosquitto_sub -t '+/error' -v
# Subscribe to all state updates from a single bridge
mosquitto_sub -t 'velux2mqtt/+/state' -v
See Also¶
- MQTT Topics (concept) — retained vs not-retained rationale and wildcard monitoring strategy
- Payload Schemas — JSON payload structures
- Error Handling (concept) — error semantics
- Health & Availability (concept) — heartbeat and LWT details
- Settings Reference —
MQTT__TOPIC_PREFIXand other MQTT settings - ADR-002 — MQTT Topic Conventions
- ADR-025 — Command Channel and Sub-Topic Routing