Summary
The Docker DMR bridge (services/dvswitch/docker) cannot work as shipped. Two
defects block it completely; three smaller ones affect the UI. With workarounds
applied I have the bridge fully working — HBP login, AMBE vocoder, live receive
and browser push-to-talk — so everything below is reproducible on a real setup.
Reported after building this from scratch against a private HBLink3 master.
Environment
- TicketsCAD v4.1.2 (
272513b), Docker Compose build pack under Coolify, Ubuntu 24.04
- Bridge from
services/dvswitch/docker, same host as the CAD, attached to the CAD's Docker network
- Master: HBLink3 (
HBLink-org/hblink3) on Debian 12, single MASTER-1, REPEAT: True, UDP 54000
- Second peer for audio testing: DroidStar with ESSID 01
1. BLOCKER — the HTTP control server can never start on Docker
services/dvswitch/hbp_client.py:1468
if bearer and piper_bin and piper_voice:
worker = threading.Thread(target=client.run, name="hbp-loop", daemon=True)
worker.start()
http_server = serve_http(...)
piper_bin / piper_voice come from DMR_PIPER_BIN / DMR_PIPER_VOICE, which
default to "". Those two variables appear only in
services/dvswitch/hbp-client.env.example (the bare-metal file). They are in
none of:
services/dvswitch/docker/.env.example
services/dvswitch/docker/docker-compose.yml
services/dvswitch/docker/Dockerfile
services/dvswitch/docker/entrypoint.sh
So on the Docker path the condition is always false, serve_http() never runs,
and port 18091 never opens. Execution falls through to client.run() inline, so
the DMR side works perfectly and nothing indicates a problem.
This fails silently. The entrypoint still prints starting hbp_client.py (HTTP control on 18091) and the HBP side logs a clean login, so the bridge looks
healthy. Symptoms are only visible from the CAD side:
- Channel never connects
curl to 18091 from the host: curl: (56) Recv failure: Connection reset by peer
(docker-proxy accepts the TCP connection, then finds nothing listening)
Workaround — compose override, no rebuild needed:
services:
dmr-bridge:
environment:
DMR_PIPER_BIN: /usr/bin/true
DMR_PIPER_VOICE: /dev/null
After this: INFO [hbp] HTTP control listening on :18091.
Suggested fix: decouple the HTTP surface from TTS. /health,
/audio-stream, /tx/audio and /tx/stream don't need Piper — only /tx/text
does. Gate on if bearer: and have /tx/text return a clear 503 ("TTS not
configured") when piper_bin/piper_voice are empty, rather than taking the
entire control surface down with it.
Related: Piper isn't installed in the Docker image at all, so /tx/text
can't work on Docker even with the gate fixed. Either ship it, or document Docker
as RX + raw-audio-TX only.
2. BLOCKER — the CAD sends the token hash to the bridge; every server-side call is 401
api/dvswitch.php:284-285 mints the bearer and stores it hashed:
$token = bin2hex(random_bytes(32));
$hash = hash('sha256', $token); // $hash is what goes into bridge_token
The plaintext is returned once and never persisted. But every server-side caller
reads that same column and uses it directly as the Authorization: Bearer value:
api/dmr-tx-audio.php:77
api/dmr-stream.php:87
proxy/dmr-proxy.php:131
inc/channel_registry.php:450-456
inc/weather_radio.php:49, 219
They send the SHA-256 hash; the bridge compares against the plaintext
DMR_BEARER_TOKEN and returns 401.
Symptoms. Push-to-talk:
Transmit failed (HTTP 401): {"error":"bridge tx-audio failed",
"detail":"{\"error\": \"unauthorized\"}","http_code":401}
And live audio never plays, because /audio-stream 401s on a retry loop:
INFO [hbp] HTTP "GET /audio-stream HTTP/1.1" 401 -
INFO [hbp] HTTP "GET /audio-stream HTTP/1.1" 401 -
...every ~3 seconds
Only channel_test_health works, because dvs_bridge_call() uses
$ch['_token_plain'] — a value the operator pastes into the Test dialog by
hand. Nothing that runs unattended can ever authenticate.
Workaround — store the plaintext. api/dmr-ingest.php:130 already accepts
either form:
$ok = hash_equals($stored, $incoming) || hash_equals($stored, $incomingHash);
so ingest still authenticates correctly:
UPDATE dmr_channels SET bridge_token = '<plaintext token>' WHERE label = '<label>';
Immediately after this, /audio-stream returns 200 and PTT works:
INFO [hbp] HTTP "GET /audio-stream HTTP/1.1" 200 -
INFO [hbp] audio-stream subscriber 1f45a76f connected (subs=1)
Suggested fix: a background daemon cannot derive a bearer from a hash, so
hashing at rest can't work here. Encrypting it with the existing app_keys field
encryption (already used for 2FA secrets and encrypted fields) and decrypting in
dvs_bridge_call() would keep it out of the DB in the clear while remaining
usable.
Note also that rotating the token or recreating the channel writes a fresh hash,
putting the deployment straight back to 401.
3. "TX 0.5s 1 kHz tone" posts to an endpoint that doesn't exist
api/dvswitch.php:439 (channel_test_tx):
$resp = dvs_bridge_call($ch, '/tx/test', 'POST', [...]);
hbp_client.py implements /tx/audio (1036), /tx/stream (1040),
/transcript (1045) and /tx/text (1353). There is no /tx/test; the request
falls through to the 404 at line 1386.
{ "ok": false, "status": 404, "body": { "error": "not found" } }
Presumably not Docker-specific. Worth having — it would be the fastest way to
verify the TX audio path.
4. "Recent transcripts" likewise
channel_recent_calls calls GET /calls/recent. The bridge's do_GET handles
only /health, /audio-stream and /recording* before its 404 at line 900.
5. Radio widget hardcodes TG 3127
inc/navbar.php:954 and :989 print TG 3127 and Listening for TG 3127…
regardless of the channel's configured talkgroup (mine is TG 10). Also referenced
in assets/js/radio-widget.js:5 and inc/radio_ai_client.php:170. Cosmetic.
What works, with workarounds applied
Vocoder self-test:
[entrypoint] md380-emu is answering on UDP 2470
INFO [hbp.ambe] round-trip PCM peak amplitude = 43 (should be >> 0)
OK — md380-emu codec round-trip succeeds
HBP login, bridge side:
INFO [hbp] bound local UDP 62032
INFO [hbp] sent RPTL for DMR ID 4300098
INFO [hbp] HTTP control listening on :18091
INFO [hbp] sent RPTK (auth hash)
INFO [hbp] sent RPTC config (302 bytes)
INFO [hbp] RUNNING — authenticated to <master>:54000 as DMR ID 4300098
Same login, HBLink3 side:
(MASTER-1) Repeater Logging in with Radio ID: 4300098
(MASTER-1) Peer 4300098 has completed the login exchange successfully
(MASTER-1) Peer b'A61KT ' (4300098) has sent repeater configuration
Live receive — DroidStar as a second peer (ESSID 01 → 430009801), keyed on TG 10:
{"ok": true, "state": 4, "running": true, "rx_dmrd": 445, "rx_keepalive": 498}
445 DMRD frames, ~9 seconds of speech, decoded through md380-emu.
Transmit — browser push-to-talk:
DMRD seq=62 TS2 voice dtype=1 src=4300098→tg=10 rpt=4300098 stream=4a94e82a
DMRD seq=67 TS2 voice_sync dtype=0 src=4300098→tg=10 rpt=4300098 stream=4a94e82a
voice_sync every sixth frame with dtype=1..5 between — a correct A–F
superframe.
Smaller notes
The voice compose profile isn't needed for either direction.
api/dmr-stream.php (SSE) and api/dmr-tx-audio.php both call the bridge
directly from the app container. dmr-proxy only backs the optional /dmr-ws
WebSocket PTT path; when it isn't running the widget correctly falls back:
[radio] WS TX setup failed; falling back to batched
[radio] tx-audio OK {accepted: true, pcm_bytes: 45120, duration_ms: 2820}
Worth documenting, since docs/RADIO-DMR-DOCKER.md implies the profile is needed
for browser PTT.
The DMR settings page still describes the bare-metal path. "Bring up a new
bridge host" in settings.php documents a Debian 13 VM at 10.0.0.10,
analog-bridge/mmdvm-bridge packages, /etc/ticketscad/dvswitch-<instance>.env
and systemctl enable --now ticketscad-dvswitch@<instance>. The bearer-token
panel shown after channel creation says the same. For a Dockerised CAD this is a
dead end, and it's the first thing a new user reads. The Bridge host field
also pre-fills 10.0.0.10.
HBLink3 startup timing, for anyone else testing: it parses the radioid.net
alias files before reactor.listenUDP, so the master takes 30–45 seconds to
start listening. Checking too early looks like a failure.
I have a working HBLink3 master and a clean Coolify CAD
I've also written up the full
deployment as a guide for other Docker users if that would be useful.
— Khalaf Almazrouei
Summary
The Docker DMR bridge (
services/dvswitch/docker) cannot work as shipped. Twodefects block it completely; three smaller ones affect the UI. With workarounds
applied I have the bridge fully working — HBP login, AMBE vocoder, live receive
and browser push-to-talk — so everything below is reproducible on a real setup.
Reported after building this from scratch against a private HBLink3 master.
Environment
272513b), Docker Compose build pack under Coolify, Ubuntu 24.04services/dvswitch/docker, same host as the CAD, attached to the CAD's Docker networkHBLink-org/hblink3) on Debian 12, singleMASTER-1,REPEAT: True, UDP 540001. BLOCKER — the HTTP control server can never start on Docker
services/dvswitch/hbp_client.py:1468piper_bin/piper_voicecome fromDMR_PIPER_BIN/DMR_PIPER_VOICE, whichdefault to
"". Those two variables appear only inservices/dvswitch/hbp-client.env.example(the bare-metal file). They are innone of:
services/dvswitch/docker/.env.exampleservices/dvswitch/docker/docker-compose.ymlservices/dvswitch/docker/Dockerfileservices/dvswitch/docker/entrypoint.shSo on the Docker path the condition is always false,
serve_http()never runs,and port 18091 never opens. Execution falls through to
client.run()inline, sothe DMR side works perfectly and nothing indicates a problem.
This fails silently. The entrypoint still prints
starting hbp_client.py (HTTP control on 18091)and the HBP side logs a clean login, so the bridge lookshealthy. Symptoms are only visible from the CAD side:
curlto 18091 from the host:curl: (56) Recv failure: Connection reset by peer(docker-proxy accepts the TCP connection, then finds nothing listening)
Workaround — compose override, no rebuild needed:
After this:
INFO [hbp] HTTP control listening on :18091.Suggested fix: decouple the HTTP surface from TTS.
/health,/audio-stream,/tx/audioand/tx/streamdon't need Piper — only/tx/textdoes. Gate on
if bearer:and have/tx/textreturn a clear 503 ("TTS notconfigured") when
piper_bin/piper_voiceare empty, rather than taking theentire control surface down with it.
Related: Piper isn't installed in the Docker image at all, so
/tx/textcan't work on Docker even with the gate fixed. Either ship it, or document Docker
as RX + raw-audio-TX only.
2. BLOCKER — the CAD sends the token hash to the bridge; every server-side call is 401
api/dvswitch.php:284-285mints the bearer and stores it hashed:The plaintext is returned once and never persisted. But every server-side caller
reads that same column and uses it directly as the
Authorization: Bearervalue:api/dmr-tx-audio.php:77api/dmr-stream.php:87proxy/dmr-proxy.php:131inc/channel_registry.php:450-456inc/weather_radio.php:49, 219They send the SHA-256 hash; the bridge compares against the plaintext
DMR_BEARER_TOKENand returns 401.Symptoms. Push-to-talk:
And live audio never plays, because
/audio-stream401s on a retry loop:Only
channel_test_healthworks, becausedvs_bridge_call()uses$ch['_token_plain']— a value the operator pastes into the Test dialog byhand. Nothing that runs unattended can ever authenticate.
Workaround — store the plaintext.
api/dmr-ingest.php:130already acceptseither form:
so ingest still authenticates correctly:
Immediately after this,
/audio-streamreturns 200 and PTT works:Suggested fix: a background daemon cannot derive a bearer from a hash, so
hashing at rest can't work here. Encrypting it with the existing
app_keysfieldencryption (already used for 2FA secrets and encrypted fields) and decrypting in
dvs_bridge_call()would keep it out of the DB in the clear while remainingusable.
Note also that rotating the token or recreating the channel writes a fresh hash,
putting the deployment straight back to 401.
3. "TX 0.5s 1 kHz tone" posts to an endpoint that doesn't exist
api/dvswitch.php:439(channel_test_tx):hbp_client.pyimplements/tx/audio(1036),/tx/stream(1040),/transcript(1045) and/tx/text(1353). There is no/tx/test; the requestfalls through to the 404 at line 1386.
{ "ok": false, "status": 404, "body": { "error": "not found" } }Presumably not Docker-specific. Worth having — it would be the fastest way to
verify the TX audio path.
4. "Recent transcripts" likewise
channel_recent_callscallsGET /calls/recent. The bridge'sdo_GEThandlesonly
/health,/audio-streamand/recording*before its 404 at line 900.5. Radio widget hardcodes TG 3127
inc/navbar.php:954and:989printTG 3127andListening for TG 3127…regardless of the channel's configured talkgroup (mine is TG 10). Also referenced
in
assets/js/radio-widget.js:5andinc/radio_ai_client.php:170. Cosmetic.What works, with workarounds applied
Vocoder self-test:
HBP login, bridge side:
Same login, HBLink3 side:
Live receive — DroidStar as a second peer (ESSID 01 → 430009801), keyed on TG 10:
{"ok": true, "state": 4, "running": true, "rx_dmrd": 445, "rx_keepalive": 498}445 DMRD frames, ~9 seconds of speech, decoded through md380-emu.
Transmit — browser push-to-talk:
voice_syncevery sixth frame withdtype=1..5between — a correct A–Fsuperframe.
Smaller notes
The
voicecompose profile isn't needed for either direction.api/dmr-stream.php(SSE) andapi/dmr-tx-audio.phpboth call the bridgedirectly from the app container.
dmr-proxyonly backs the optional/dmr-wsWebSocket PTT path; when it isn't running the widget correctly falls back:
Worth documenting, since
docs/RADIO-DMR-DOCKER.mdimplies the profile is neededfor browser PTT.
The DMR settings page still describes the bare-metal path. "Bring up a new
bridge host" in
settings.phpdocuments a Debian 13 VM at 10.0.0.10,analog-bridge/mmdvm-bridgepackages,/etc/ticketscad/dvswitch-<instance>.envand
systemctl enable --now ticketscad-dvswitch@<instance>. The bearer-tokenpanel shown after channel creation says the same. For a Dockerised CAD this is a
dead end, and it's the first thing a new user reads. The Bridge host field
also pre-fills
10.0.0.10.HBLink3 startup timing, for anyone else testing: it parses the radioid.net
alias files before
reactor.listenUDP, so the master takes 30–45 seconds tostart listening. Checking too early looks like a failure.
I have a working HBLink3 master and a clean Coolify CAD
I've also written up the full
deployment as a guide for other Docker users if that would be useful.
— Khalaf Almazrouei