Skip to content

Payload Schemas

All cosalette MQTT payloads are either JSON objects or plain strings. This page documents the exact schema for each payload type. For topic routing and QoS details, see MQTT Topic Reference.

Device State

State payloads are user-defined JSON dicts. The framework imposes no schema — whatever dict you pass to ctx.publish_state() is serialised to JSON and published to {prefix}/{device}/state.

# In your device function:
await ctx.publish_state({"position": 75, "tilt": 45})

Produces:

{"position": 75, "tilt": 45}

The payload argument must be a dict[str, object]. The framework calls json.dumps() internally.

Error Payload

Published by the ErrorPublisher to {prefix}/error and (when a device name is known) to {prefix}/{device}/error. Defined in cosalette._errors.ErrorPayload.

Example

{
    "error_type": "invalid_command",
    "message": "InvalidCommandError",
    "device": "blind",
    "timestamp": "2026-02-14T12:34:56+00:00",
    "details": {}
}

Fields

Field Type Description
error_type str Machine-readable error category. Determined by the error_type_map — unmapped exceptions fall back to "error".
message str The exception class name. The raw str(exception) only for exact types listed in App(disclose_messages_for=...), or under verbose publishing (App(allow_error_publish_verbose=True) plus MQTT__ERROR_PUBLISH_VERBOSE); App(redact=...) applies to it (ADR-091).
device str | null Device name if the error is device-scoped, otherwise null.
timestamp str ISO 8601 timestamp with timezone (e.g. "2026-02-14T12:34:56+00:00").
details object Optional dict of additional context. Defaults to {} when not provided. Telemetry errors carry count (consecutive failed cycles) and first_seen (ISO 8601 UTC time of the first one); a repeat of the same error is published only as a reminder (ADR-082).

Error Type Mapping

The error_type field is resolved from the error_type_map dict passed to the ErrorPublisher. The map keys are exact exception classes (no subclass matching). Unmapped exceptions produce "error" as the type. The map only assigns labels; it never discloses a message.

error_type_map = {
    ValueError: "invalid_command",
    TimeoutError: "timeout",
}

Common Error Types

The framework labels these exceptions itself. Framework labels are authoritative: an app error_type_map entry for one of these types is overridden, with a startup warning when its label differs.

Error Type Exception Description
invalid_json InvalidJsonError Command payload is not valid JSON (sub-dispatch)
missing_sub_key MissingSubKeyError Required routing field missing from JSON payload (sub-dispatch)
unknown_sub_command UnknownSubCommandError Sub-command value not registered for topic (sub-dispatch)
invalid_payload PayloadValidationError Typed command or telemetry trigger payload is not valid JSON or fails schema validation
timeout TimeoutError Command handler exceeded its watchdog bound (ADR-060)

ReturnValidationError (a handler returned a value that does not match its return contract) is an application fault and keeps the generic error label.

By default every one of these payloads publishes the exception class name as message (for example "message": "InvalidJsonError"). The examples below show the messages published when the app lists the type, for example with App(disclose_messages_for=cosalette.FRAMEWORK_DISCLOSED_ERRORS | {TimeoutError}):

# Invalid JSON
{"error_type": "invalid_json", "message": "unexpected character, expected a JSON value: line 1 column 13 (char 12)", "device": "light"}

# Missing routing field
{"error_type": "missing_sub_key", "message": "Missing field 'command' in command payload", "device": "light"}

# Unknown sub-command: only type, length and a SHA-256 fingerprint of the value
{"error_type": "unknown_sub_command", "message": "Unknown sub-command for 'light' (type=str, len=5, fp=e14a51fd)", "device": "light"}

# Rejected typed-command or typed telemetry trigger input: redacted locations and pydantic error codes only
{"error_type": "invalid_payload", "message": "Payload validation failed (parameter 'cmd'): [field]: type=literal_error", "device": "light"}

# Command watchdog expired (a TimeoutError raised by handler code keeps its own text)
{"error_type": "timeout", "message": "command handler exceeded 30.0s watchdog", "device": "light"}

The invalid_payload message never contains the rejected value or pydantic's msg text.

See Error Handling (concept) for the full error pipeline.

Heartbeat Payload

Published by HealthReporter.publish_heartbeat() to {prefix}/status. Defined in cosalette._health.HeartbeatPayload.

The framework publishes an initial heartbeat on connect, then repeats at the heartbeat_interval (default 60 s). Set App(heartbeat_interval=None) to disable periodic heartbeats.

Example

{
    "status": "online",
    "uptime_s": 3600,
    "version": "0.3.0",
    "devices": {
        "blind": {"status": "ok"},
        "temp": {"status": "ok"}
    }
}

Fields

Field Type Description
status str Always "online" for heartbeats.
uptime_s int Whole seconds (truncated) since the HealthReporter was initialised (monotonic clock).
version str Application version string passed to the HealthReporter. Omitted entirely when heartbeat_include_version=False.
devices object Map of device name → DeviceStatus. Only includes devices currently tracked.

DeviceStatus Fields

Each entry in the devices map is a DeviceStatus object:

Field Type Description
status str Free-form status string. Defaults to "ok" when a device is registered as available.
last_success_at str \| null Telemetry only: ISO 8601 UTC time of the last fresh cycle, null before the first (ADR-080).
consecutive_failures int Telemetry only: failed cycles since the last fresh one (ADR-080).
last_error str \| null Telemetry only: exception class name of the current failure streak, null while healthy (ADR-082).
failing_since str \| null Telemetry only: ISO 8601 UTC time of the streak's first failure, null while healthy (ADR-082).

A telemetry entity with no fresh cycle for its stale_after bound, or a stream with stale_after= and no item for that long, reports status: "stale", which outranks "error" (Staleness). Non-telemetry entries carry status only.

Devices are added to tracking when publish_device_available() is called and removed when publish_device_unavailable() is called.

Availability Messages

Published on {prefix}/{device}/availability and {prefix}/status (LWT). These are plain strings, not JSON.

Payload Meaning When published
"online" Device or app is available publish_device_available(), heartbeat
"offline" Device or app is unavailable publish_device_unavailable(), shutdown(), LWT (broker-published on crash)

Distinguishing heartbeat from LWT on {prefix}/status

The heartbeat payload is a JSON object; the LWT/shutdown payload is the plain string "offline". Consumers can distinguish them by attempting JSON parse.

Command Payloads

Inbound messages on {prefix}/{device}/set topics are plain strings. The framework passes the raw payload to the command handler. The recommended approach is @app.command() — handlers only declare the parameters they need:

@app.command("valve")
async def handle_valve(payload: str) -> dict[str, object]:
    return {"valve_state": payload}

If the handler also needs the full MQTT topic:

@app.command("blind")
async def handle_blind(topic: str, payload: str) -> dict[str, object]:
    position = int(payload)
    return {"position": position}

Alternatively, inside an @app.device() function you can use @ctx.on_command:

@ctx.on_command
async def handle(topic: str, payload: str) -> None:
    position = int(payload)  # User-defined decoding
    await set_blind_position(position)

The framework performs no parsing, validation, or transformation on command payloads — decoding is entirely the responsibility of the command handler.

See Also