Events — Status, Heartbeat, Log
:::info Status
Implemented. Source: docs/user-device-mqtt-contract.md §5–§6, §8a, docs/transmitter-mqtt-contract.md §4–§5, §9.
:::
Device → backend traffic, published without waiting for a command.
Status — lifecycle state
Published retained, QoS 1, on every state transition, and once immediately after every (re)connect — even if the state didn't change while disconnected.
Transmitter status
{
"v": "1.0",
"status": "PLAYING",
"prev": "ONLINE",
"reason": "azan_trigger",
"fw": "1.4.2",
"mac": "A0:B7:65:12:34:56",
"ssid": "Masjid Guest WiFi",
"ip": "192.168.1.50",
"rssi": -61,
"temp_c": 41.2,
"uptime_s": 86340,
"config_version": 7,
"detail": {"stream_url": "https://…/{mosqueId}_azaan", "position_s": 42},
"ts": 1789000000
}
| Status | Meaning | detail carries |
|---|---|---|
UNKNOWN | Never used by firmware — backend-only default before first contact | — |
CONNECTING | Booted / reconnecting Wi-Fi or stream not yet open | attempt count |
ONLINE | Idle, ready to play | — |
PLAYING | Actively outputting audio | stream_url or source:"local", position_s |
UPDATING | OTA firmware update in progress | progress_pct, target_fw |
STREAM_ERROR | Tried to play, stream unreachable/failed ≥3 retries | stream_url, error |
CONFIG_ERROR | Received config it cannot apply | offending keys, error |
OFFLINE | Only broker LWT or graceful shutdown | reason: lwt / shutdown / reboot |
boot → CONNECTING → ONLINE ⇄ PLAYING
│ │
│ ├→ STREAM_ERROR → (retry) → PLAYING | ONLINE
│ └→ UPDATING → (reboot) → CONNECTING
└→ CONFIG_ERROR → (new config) → ONLINE
any state → OFFLINE (LWT / shutdown)
Stream player status
Same shape, plus two states unique to a member-owned, mosque-mutable unit:
{
"v": "1.0",
"status": "PLAYING",
"prev": "ONLINE",
"reason": "azan_cmd",
"fw": "1.0.4",
"mac": "A0:B7:65:AA:BB:CC",
"ssid": "Home WiFi",
"ip": "192.168.1.71",
"rssi": -68,
"uptime_s": 86340,
"config_version": 3,
"detail": {"stream_url": "https://…/{mosqueId}_azaan", "position_s": 42},
"ts": 1789000000
}
| Status | Meaning | detail carries |
|---|---|---|
UNBOUND | Connected and healthy, but not entitled to any mosque's azaan — awaiting approval, membership lapsed, no mosque bound, or the entitlement lease expired. Bluetooth works normally. | reason: awaiting_approval / membership_lapsed / no_mosque / lease_expired |
MUTED | Authorized and healthy, but silenced by the user — quiet hours, manual mute, or a disabled prayer | reason |
PLAYING | ... | source: stream / local / bluetooth; bluetooth carries no content metadata |
boot → CONNECTING → UNBOUND ⇄ ONLINE ⇄ PLAYING
▲ │ │
│ │ ├→ STREAM_ERROR → (retry) → PLAYING | ONLINE
config arrives with │ │ └→ UPDATING → (reboot) → CONNECTING
no stream_url, or │ ├→ CONFIG_ERROR → (new config) → ONLINE
unbind_mosque cmd ──┘ └→ MUTED ⇄ ONLINE (user mute / quiet hours)
any state → OFFLINE (LWT / shutdown)
UNBOUND and MUTED both matter for delivery analytics: a unit that
deliberately doesn't play is a correct outcome, not a fault — see
Broadcast Audit for how refused acks and
these states are counted separately from real failures.
Full concept detail: Device Lifecycle and
Playback Entitlement (the entitlement
lease behind lease_expired).
MQTT down, audio still playing
Both contracts are explicit: an MQTT disconnect must never stop audio.
A device keeps playing and keeps retrying the broker; the broker's Last Will
fires OFFLINE, the panel shows "OFFLINE (was PLAYING)" until the device
reconnects and republishes true state. A stream player additionally bounds
this autonomy with the entitlement lease — see
Playback Entitlement.
Heartbeat — telemetry
Not retained, QoS 0. First heartbeat immediately after connect, then every
heartbeat_secs (re-read each cycle so config changes apply without a
reconnect).
Transmitter (default 60s, min 15s):
{
"v": "1.0",
"status": "ONLINE",
"rssi": -61,
"ip": "192.168.1.50",
"uptime_s": 86400,
"free_heap": 148000,
"volume": 80,
"config_version": 7,
"ts": 1789000060
}
Stream player (default 300s, min 60s — a looser cadence deliberately, so a fleet of thousands of home units doesn't dominate the broker's message budget for responsiveness nobody needs):
{
"v": "1.0",
"status": "ONLINE",
"rssi": -68,
"ip": "192.168.1.71",
"uptime_s": 86400,
"free_heap": 151000,
"volume": 65,
"config_version": 3,
"ts": 1789000300
}
status is duplicated in heartbeat so a lost retained status self-heals.
The backend marks a device OFFLINE (stale) after 3 missed heartbeats
even without the LWT firing.
Log — optional diagnostics
Enabled via config log_enabled: true (transmitter) or a temporary
set_log_level command (stream player, self-expiring). Throttled ≤1 msg/s,
≤512 B each; drop rather than queue when offline.
{"v":"1.0","level":"warn","msg":"stream reconnect #2","ts":1789000200}
Contains no audio content and, for a stream player, nothing about what a member plays over Bluetooth beyond whether the radio is enabled.
Remote diagnostics (stream player)
A stream player lives in a member's house — nobody will visit it. Everything
needed to diagnose a fault must be obtainable over the wire, and must be
answerable while UNBOUND:
| Need | Mechanism |
|---|---|
| Current truth on demand | get_state → full status incl. capabilities, config_schema, hw_rev |
| Reachable at all | ping with round-trip latency |
| What it has been doing | mosque_device_events timeline |
| Why it is not playing | GET /devices/{id}/why → effective_playback_state + deciding field |
| Signal quality | rssi + ssid in heartbeat |
| Audio path alive | play_test — short local tone, no stream, no azaan |
| Which physical box | identify — LED blink / chirp |
| Detail beyond the above | temporary set_log_level escalation |
diag_bundle returns one ack with the last N status transitions, config
version, uptime, reset reason, free heap, Wi-Fi and broker counters, and the
last error — one round trip, since the common case is a unit only
intermittently reachable.