Skip to content

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:

  1. Settings.mqtt.topic_prefix — when set (e.g. via MQTT__TOPIC_PREFIX=staging-velux), this value is used as {prefix}.
  2. App(name="velux2mqtt") — when topic_prefix is empty (the default), the name argument 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:

cosalette schema dump --app myapp.main:app --resolve-settings --config-file app.toml

For CI gates that cannot load settings (or deliberately skip configure hooks), pass the prefix explicitly instead — it takes precedence over the resolved value:

cosalette schema dump --app myapp.main:app --topic-prefix house/velux

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)
velux2mqtt/blind/state → {"position": 75, "tilt": 45}

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.

velux2mqtt/blind/set ← "50"
velux2mqtt/blind/calibrate/set ← "HIGH"    (sub-topic: calibrate)

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:

  1. Exact registered-name match — {prefix}/{device}/set resolves to (device, None). Handles both simple names and slash-composed Router names such as "sensors/temperature".
  2. One-level sub-topic prefix match — when a registered name is a path-prefix of the topic's middle segment, {prefix}/{device}/{sub}/set resolves to (device, sub).
  3. 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)
velux2mqtt/blind/availability → "online"
velux2mqtt/blind/availability → "offline"

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 (ErrorPayload schema)
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