Skip to main content

Transmitter Audio Uplink

:::info Status Implemented. Source: docs/transmitter-audio-uplink-contract.md v1.0. :::

:::warning Not MQTT This is a dedicated, authenticated WebSocket — not a topic on the broker. It's documented here because it's the other half of a transmitter's protocol surface: everything about identity, claim, and the MQTT topics (status/heartbeat/config/cmd/ack/log) is unchanged and lives in Topic Structure and Device Registration. This page covers only the uplink connection itself. :::

Companion to the transmitter MQTT contract, the same way that contract is a companion to the stream player contract. This document covers the one thing a transmitter can do that the MQTT contract doesn't: send live audio arriving on its amplifier/mic input back to the backend, which identifies the mosque and fans it out exactly the way any other live broadcast fans out — see Azaan Broadcast.

Why a second connection, not MQTT

A transmitter already has an open MQTT session (connection parameters). Continuous audio doesn't go over it:

  • EMQX's per-device rate/message-size limits (emqx/emqx.conf.template, see Device Deployment) are sized for status/heartbeat/cmd traffic — a "chatty device" ceiling, not sustained audio.
  • The one precedent for audio-over-MQTT in this codebase was the admin panel's own mic mirrored as base64 JSON chunks on the (now-retired) mosque-wide trigger topic — unauthenticated, and not the pattern to extend.

Instead: a dedicated, authenticated WebSocket, the same shape the admin panel's own live broadcast already uses (main.py, see Azaan Broadcast) — reusing the exact same FFmpeg → Icecast pipeline and the exact same started/ended fan-out, not a second implementation of either.

Trigger — device-initiated

The transmitter's own hardware decides when to start (VOX/level-detection on the amplifier input, or a physical button) and opens the connection on its own. The backend's job is identify and fan out, not command a start — there is no MQTT cmd that tells a unit to begin capturing.

Config — opt-in, per unit

TransmitterConfig.live_audio_uplink, part of the same retained config document delivered over MQTT (see Topic Structure — config):

{"live_audio_uplink": {"enabled": true, "room_id": "azaan"}}
FieldTypeNotes
enabledboolDefault false. Wiring a mic/line-in is a per-site hardware decision — most units never have one.
room_idstringWhich Icecast room this unit's uplink feeds — one of icecast_helper.ROOMS (azaan, taraweeh, waaz, announcement). Default azaan.

Identity and credential

The connection authenticates with the same per-device secret every transmitter already has: core/device_claim.py:claim() mints and bcrypt-hashes one for every device kind at claim time (devices.device_secret_hash) — not a second credential system. It's returned as broadcast_secret in the POST /mosque-devices/claim and /announce responses (present once, same rule as every other secret in this contract — see Device Registration), and recoverable via POST /mosque-devices/{device_id}/regenerate-broadcast-secret (superadmin) for a unit claimed before this existed.

Connect

wss://host/ws/transmitter-broadcast?device_uid={device_uid}&device_secret={broadcast_secret}&room_id=azaan

device_uid here is devices.device_uid — the same identifier already reported in this unit's MQTT status/heartbeat payloads, not the topic-path device_id.

The backend resolves mosque_id only from this credential (core/device_authz.py:authorize_and_locate) — there is no mosque_id parameter on this endpoint at all, and none would be trusted if there were.

Reconnect: exponential backoff 1s → 60s max, jittered — the same posture as the MQTT contract's own reconnect rule.

Rejected (socket closed, no retained state changes)

Close codeReason
4404room_id isn't a known room
4401Unknown device, wrong secret, or the device is not ACTIVE-and-bound (same eight-step chain the stream-gate uses — core/device_authz.py:evaluate)

Audio format

Raw PCM, 16-bit signed little-endian, 16 kHz, mono (icecast_helper.TRANSMITTER_PCM_SAMPLE_RATE/TRANSMITTER_PCM_CHANNELS) — sent as the WebSocket binary frame payload, no container, no codec. Chosen because an ESP32 I2S ADC has no on-board encoder to produce anything else; the backend's FFmpeg transcodes to the Icecast mount's MP3, same as it already does for the admin panel's WebM/Opus.

On connect the server replies once with:

{"type": "connected", "mosque_id": "...", "room_id": "azaan", "stream_url": "https://.../{mosqueId}_azaan"}

then accepts binary frames indefinitely. No audio for BROADCAST_IDLE_TIMEOUT_SECS (default 60s) ends the session — the same idle rule the admin panel's own broadcast connection uses.

Fan-out — identical to any other live broadcast

Once connected, this is a live broadcast in every sense Azaan Broadcast describes: the Icecast mount goes live, every other stream player and transmitter bound to the mosque gets a per-device play (device_trigger.publish_azaan / publish_play_to_transmitters — see Commands), and mobile push fires (VoIP/FCM for azaan, alert/notification otherwise) — all already authorization-gated per recipient. Nothing new was built for this; it's the same function the admin panel's own mic triggers, core/live_broadcast.py:notify_icecast_broadcast_started.

The source transmitter is excluded from its own fan-out — it isn't told to play the broadcast it's currently sourcing, which would loop its own amplifier output back through itself.

No explicit "stop." Same as every other live broadcast on this platform: disconnecting ends the Icecast mount, and every receiving device notices the stream ended on its own. There is no message telling anyone the uplink stopped.

Firmware acceptance checklist

  • Only opens this connection when live_audio_uplink.enabled is true in the retained config
  • Uses device_uid + the broadcast_secret from claim/announce — never a hardcoded value
  • Sends raw 16-bit LE PCM, 16 kHz, mono — no container, no encoding
  • Reconnects with jittered exponential backoff on drop, same as the MQTT connection
  • Does not assume any response frame from the server beyond the one connected message at connect time — the server never sends anything else on this socket
  • Treats a 4401/4404 close as terminal for that attempt, not something to retry immediately in a tight loop