Skip to main content

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 suffixDirectionQoSRetainedPurpose
statusdevice → backend1yesLifecycle state; publish on every state change
heartbeatdevice → backend0noTelemetry every heartbeat_secs (default 60)
configbackend → device1yesFull desired config; device applies on boot + on message
cmdbackend → device1noGeneric commands with req_idping, reboot, identify, play, stop, play_test, set_volume, update_firmware, get_state
ackdevice → backend1noResponse to cmd and to config application
logdevice → backend0noOptional 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 suffixDirectionQoSRetainedPurpose
statusdevice → backend1yesLifecycle state; publish on every state change and once after every reconnect
heartbeatdevice → backend0noTelemetry every heartbeat_secs (default 300)
configbackend → device1yesFull desired config, including current mosque_id
cmdbackend → device1noCommands with req_id, including the azaan play
ackdevice → backend1noResponse to cmd and to config, plus azan_played
logdevice → backend0noOptional 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 own cmd topic, published after authorization and membership checks — a shared topic can't be gated per device.

Topic reference — quick lookup

TopicDirectionPublisherSubscriberPurpose
masjidconnect/{mosqueId}/device/{deviceId}/statusdevice → backendTransmitterBackendLifecycle state (retained)
masjidconnect/{mosqueId}/device/{deviceId}/heartbeatdevice → backendTransmitterBackendTelemetry
masjidconnect/{mosqueId}/device/{deviceId}/configbackend → deviceBackendTransmitterDesired config (retained)
masjidconnect/{mosqueId}/device/{deviceId}/cmdbackend → deviceBackendTransmitterCommands incl. play (azaan)
masjidconnect/{mosqueId}/device/{deviceId}/ackdevice → backendTransmitterBackendCommand/config acks, azan_played
masjidconnect/{mosqueId}/device/{deviceId}/logdevice → backendTransmitterBackendOptional diagnostics
masjidconnect/rx/{deviceId}/statusdevice → backendStream playerBackendLifecycle state (retained)
masjidconnect/rx/{deviceId}/heartbeatdevice → backendStream playerBackendTelemetry
masjidconnect/rx/{deviceId}/configbackend → deviceBackendStream playerDesired config incl. mosque_id (retained)
masjidconnect/rx/{deviceId}/cmdbackend → deviceBackendStream playerCommands incl. play (azaan)
masjidconnect/rx/{deviceId}/ackdevice → backendStream playerBackendCommand/config acks, azan_played
masjidconnect/rx/{deviceId}/logdevice → backendStream playerBackendOptional 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.