Topic Structure
:::info Status
Implemented. Source: docs/user-device-mqtt-contract.md §4, docs/transmitter-mqtt-contract.md §3.
:::
Transmitter topics
Base: masjidconnect/{mosqueId}/device/{deviceId}/ — mosque and
device both named in the path (source: transmitter contract §3).
| Topic suffix | Direction | QoS | Retained | Purpose |
|---|---|---|---|---|
status | device → backend | 1 | yes | Lifecycle state; publish on every state change |
heartbeat | device → backend | 0 | no | Telemetry every heartbeat_secs (default 60) |
config | backend → device | 1 | yes | Full desired config; device applies on boot + on message |
cmd | backend → device | 1 | no | Generic commands with req_id — ping, reboot, identify, play, stop, play_test, set_volume, update_firmware, get_state |
ack | device → backend | 1 | no | Response to cmd and to config application |
log | device → backend | 0 | no | Optional diagnostics, throttled (≤1 msg/s, ≤512 B each) |
There is no mosque-wide topic — azaan arrives as a play on this unit's
own cmd topic, same as any other command. See
Commands — azan delivery.
Transmitter ACL is not broker-enforced
Unlike the stream player, a transmitter's credential is shared, fleet-wide
(MQTT_DEVICE_USER/MQTT_DEVICE_PASS, or the backend's own service
credential when unset) — every transmitter in the fleet authenticates as the
same identity. Isolation today is topic-path convention plus HTTP-side
authorization on mosque_devices.py's endpoints, not a broker-side
guarantee. Don't build firmware or tooling that assumes the broker enforces
one transmitter cannot address another's topic — it doesn't, today.
A transmitter does hold a real per-device secret
(devices.device_secret_hash) — it's just not used for this MQTT
connection. It authenticates the audio uplink WebSocket
instead, returned as broadcast_secret at claim.
Stream player topics
Base: masjidconnect/rx/{deviceId}/ — device-scoped, no mosque id in
the path (source: stream player contract §4).
| Topic suffix | Direction | QoS | Retained | Purpose |
|---|---|---|---|---|
status | device → backend | 1 | yes | Lifecycle state; publish on every state change and once after every reconnect |
heartbeat | device → backend | 0 | no | Telemetry every heartbeat_secs (default 300) |
config | backend → device | 1 | yes | Full desired config, including current mosque_id |
cmd | backend → device | 1 | no | Commands with req_id, including the azaan play |
ack | device → backend | 1 | no | Response to cmd and to config, plus azan_played |
log | device → backend | 0 | no | Optional diagnostics, throttled (≤1 msg/s, ≤512 B) |
Why the mosque is not in the stream player's path
A transmitter is bolted to one mosque for life, so a mosque-scoped topic
suits it. A stream player is not — it belongs to a member who may move
house, change mosque, or travel, and rebinds repeatedly with no
re-provisioning. With the mosque in the path, every rebind would be a topic
migration (clear retained config on the old path, rewrite the broker ACL,
resubscribe, handle the gap where the unit listens to neither). Device-scoped
topics make a rebind one config publish with a new mosque_id — see
Device Lifecycle — rebinding.
The rx literal at segment 2 also guarantees these messages can't be
consumed by the transmitter subscriber, which binds
masjidconnect/+/device/+/… (core/device_status.py) and requires the
literal device at segment 3.
Stream player ACL — normative, broker-enforced
Each unit's credential is restricted to its own subtree — the only thing preventing one member's box from commanding another's:
device credential for {deviceId} may:
SUBSCRIBE masjidconnect/rx/{deviceId}/cmd
masjidconnect/rx/{deviceId}/config
PUBLISH masjidconnect/rx/{deviceId}/status
masjidconnect/rx/{deviceId}/heartbeat
masjidconnect/rx/{deviceId}/ack
masjidconnect/rx/{deviceId}/log
Explicitly forbidden for any device credential:
masjidconnect/rx/+/cmd ← would let one unit read every unit's commands
masjidconnect/rx/+/config ← and every unit's stream URLs
masjidconnect/rx/#
masjidconnect/+/device/# ← the transmitter fleet
masjidconnect/#
A device must never subscribe with a wildcard, and the broker rejects it if it tries — enforced from day one via Postgres-backed authn/authz views (see Device Deployment), no shared-credential pilot mode. Because the topic path is device-scoped and never changes, this ACL is written once at claim and never rewritten — unlike a mosque-scoped ACL, which would need editing on every rebind.
Wildcard subscriptions belong exclusively to the backend's own
subscriber credential (masjidconnect/rx/+/{status,heartbeat,ack,log}),
never issued to hardware.
Stream players never subscribed to
masjidconnect/{mosqueId}/trigger— the transmitter's old mosque-wide fan-out, now retired for transmitters too (no subscriber is left on it). Azaan reaches a stream player only on its owncmdtopic, published after authorization and membership checks — a shared topic can't be gated per device.
Topic reference — quick lookup
| Topic | Direction | Publisher | Subscriber | Purpose |
|---|---|---|---|---|
masjidconnect/{mosqueId}/device/{deviceId}/status | device → backend | Transmitter | Backend | Lifecycle state (retained) |
masjidconnect/{mosqueId}/device/{deviceId}/heartbeat | device → backend | Transmitter | Backend | Telemetry |
masjidconnect/{mosqueId}/device/{deviceId}/config | backend → device | Backend | Transmitter | Desired config (retained) |
masjidconnect/{mosqueId}/device/{deviceId}/cmd | backend → device | Backend | Transmitter | Commands incl. play (azaan) |
masjidconnect/{mosqueId}/device/{deviceId}/ack | device → backend | Transmitter | Backend | Command/config acks, azan_played |
masjidconnect/{mosqueId}/device/{deviceId}/log | device → backend | Transmitter | Backend | Optional diagnostics |
masjidconnect/rx/{deviceId}/status | device → backend | Stream player | Backend | Lifecycle state (retained) |
masjidconnect/rx/{deviceId}/heartbeat | device → backend | Stream player | Backend | Telemetry |
masjidconnect/rx/{deviceId}/config | backend → device | Backend | Stream player | Desired config incl. mosque_id (retained) |
masjidconnect/rx/{deviceId}/cmd | backend → device | Backend | Stream player | Commands incl. play (azaan) |
masjidconnect/rx/{deviceId}/ack | device → backend | Stream player | Backend | Command/config acks, azan_played |
masjidconnect/rx/{deviceId}/log | device → backend | Stream player | Backend | Optional diagnostics |
Retired: the mosque-wide trigger topic
masjidconnect/{mosqueId}/trigger no longer has any subscriber on either
device type — replaced by per-device play commands specifically so
delivery can be authorization-gated per unit rather than broadcast to
everyone bound to a mosque regardless of state. See
Device Authorization for why that gating exists,
and core/device_trigger.py (publish_play_to_transmitters,
publish_azaan) for the publishers that replaced it.