Skip to content

Architecture

sendspin-bt-bridge is a multi-process Python bridge that connects Music Assistant’s Sendspin audio protocol to Bluetooth speakers. Each configured speaker runs in its own isolated subprocess with a dedicated PulseAudio context, while the main runtime coordinates lifecycle, web/API surfaces, Bluetooth recovery, and Music Assistant integration. Current releases also include HA-aware channel detection plus top-level and per-device port planning (WEB_PORT, BASE_LISTEN_PORT, listen_port).

┌─────────────────────────────────────────────────────────────────┐
│ Docker / LXC / HA Addon │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Main Python Process │ │
│ │ sendspin_client.py · asyncio event loop │ │
│ │ Flask/Waitress API · BluetoothManager × N │ │
│ │ MaMonitor · state.py │ │
│ └───────────────┬──────────────────────────────────────────┘ │
│ │ asyncio.create_subprocess_exec (per device) │
│ ┌─────────┼─────────┐ │
│ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ daemon │ │ daemon │ │ daemon │ PULSE_SINK=bluez_sink… │
│ │ process │ │ process │ │ process │ per subprocess │
│ │ ENEBY20 │ │ Yandex │ │ Lenco │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ └──── Sendspin WebSocket ───┘ │
│ (aiosendspin / Music Assistant) │
└─────────────────────────────────────────────────────────────────┘
│ │
Bluetooth PulseAudio / PipeWire
(bluetoothctl (bluez_sink.XX.a2dp_sink)
+ D-Bus)

graph TD
    subgraph "Container / Host"
        EP[entrypoint.sh<br/>D-Bus · Audio · HA config]
        EP --> MP

        subgraph "Main Process — sendspin_client.py"
            MP[main&#40;&#41;<br/>asyncio event loop]
            MP --> BO[BridgeOrchestrator]
            BO --> CFG[config.py<br/>load_config · port/channel defaults]
            BO --> LS[BridgeLifecycleState<br/>startup/runtime publication]
            BO --> MIS[BridgeMaIntegrationService<br/>MA URL/token/groups]
            BO --> SC[SendspinClient × N]
            BO --> BM[BluetoothManager × N]
            BO --> WS[Waitress HTTP server<br/>daemon thread]
            BO --> MM[MaMonitor<br/>asyncio task]
            BO --> UC[update_checker<br/>asyncio task]

            SC -->|delegates| SUBSVC[SubprocessCommand / IPC / Stderr / Stop services]
            SUBSVC <-->|JSON stdin/stdout| DP
            SC --> PH[PlaybackHealthMonitor]
            SC --> SEB[StatusEventBuilder]
            SC --- ST[state.py<br/>shared runtime state]
            BM --- ST
            LS --- ST
            MM --> ST
            UC --> ST

            WS --> FLASK[Flask app<br/>web_interface.py]
            FLASK --> BP_API[routes/api.py<br/>Blueprint]
            FLASK --> BP_BT[routes/api_bt.py<br/>Blueprint]
            FLASK --> BP_MA[routes/api_ma.py<br/>Blueprint]
            FLASK --> BP_CFG[routes/api_config.py<br/>Blueprint]
            FLASK --> BP_STS[routes/api_status.py<br/>Blueprint]
            FLASK --> BP_VIEW[routes/views.py<br/>Blueprint]
            FLASK --> BP_AUTH[routes/auth.py<br/>Blueprint]
        end

        subgraph "Subprocess per Device"
            DP[daemon_process.py<br/>asyncio event loop]
            DP --> BD[BridgeDaemon<br/>services/bridge_daemon.py]
            BD --> SD[SendspinDaemon<br/>sendspin-cli]
            SD <-->|WebSocket| MA[Music Assistant]
            BD --> PA[PulseAudio context<br/>PULSE_SINK=bluez_sink…]
        end

        subgraph "services/"
            SVC_BT[bluetooth.py<br/>BT helpers]
            SVC_PA[pulse.py<br/>PulseAudio helpers]
            SVC_MAC[ma_client.py<br/>MA REST API]
            SVC_IPC[ipc_protocol.py<br/>protocol_version envelope]
        end

        BM --> SVC_BT
        BM --> SVC_PA
        BD --> SVC_PA
        MM --> SVC_MAC
        BP_API --> SVC_MAC
        SUBSVC --> SVC_IPC
    end

    BT_HW[Bluetooth Hardware<br/>hci0 / hci1 / …]
    PA_HW[PulseAudio / PipeWire]

    BM <-->|bluetoothctl + D-Bus| BT_HW
    PA --> PA_HW
    BT_HW <-->|A2DP| SPK[Bluetooth Speaker]
    PA_HW --> SPK

The runtime entrypoint (sendspin_client.py main()) stays intentionally thin. Bridge-wide sequencing now lives in BridgeOrchestrator, which loads config, resolves channel-aware defaults, publishes lifecycle state, boots the web server, initializes optional MA integration, and assembles the long-running runtime tasks.

sequenceDiagram
    participant SH as entrypoint.sh
    participant MP as main()
    participant BO as BridgeOrchestrator
    participant LS as BridgeLifecycleState
    participant BM as BluetoothManager
    participant SC as SendspinClient
    participant WS as Waitress thread
    participant MM as MaMonitor
    participant UC as UpdateChecker

    SH->>SH: D-Bus setup · audio detect · HA config translate
    SH->>MP: exec python3 sendspin_client.py
    MP->>BO: initialize_runtime()
    BO->>LS: begin_startup()
    BO->>BO: load_config() · resolve channel/web/listen defaults
    loop for each device
        BO->>BM: BluetoothManager(mac, adapter, …)
        BO->>SC: SendspinClient(player_name, …, bt_manager=BM)
    end
    BO->>WS: start_web_server()
    BO->>BO: configure_executor()
    BO->>MM: initialize_ma_integration() if configured
    BO->>UC: asyncio.create_task(run_update_checker(VERSION))
    MP->>MP: asyncio.gather(SC.run()×N, BM.monitor_and_reconnect()×N, MM?, UC)
    BO->>LS: complete_startup()

Bridge-wide orchestration and service seams

Section titled “Bridge-wide orchestration and service seams”

A few explicit service seams now keep the runtime easier to evolve without changing the device contract:

  • BridgeOrchestrator owns bridge-wide bootstrap, signal handling, task assembly, and channel-aware defaults.
  • BridgeLifecycleState publishes startup/runtime progress into state.py for /api/startup-progress, diagnostics, and the UI.
  • BridgeMaIntegrationService resolves MA API credentials, preloads sync groups, and decides whether the MaMonitor task should run.
  • SendspinClient keeps per-speaker lifecycle ownership but delegates focused subprocess concerns to SubprocessCommandService, SubprocessIpcService, SubprocessStderrService, and SubprocessStopService.
  • PlaybackHealthMonitor and StatusEventBuilder keep watchdog/error/event derivation logic out of the transport code path.

Each SendspinClient.run() spawns daemon_process.py as an isolated subprocess. The subprocess gets PULSE_SINK=bluez_sink.<MAC>.a2dp_sink injected into its environment before any PulseAudio connection is made — so audio routes correctly from the very first sample, without needing move-sink-input.

sequenceDiagram
    participant SC as SendspinClient
    participant DP as daemon_process.py
    participant BD as BridgeDaemon
    participant MA as Music Assistant

    SC->>SC: configure_bluetooth_audio() → find sink
    SC->>DP: asyncio.create_subprocess_exec(<br/>env={PULSE_SINK: bluez_sink.MAC.a2dp_sink})
    DP->>DP: _setup_logging() — JSON lines on stdout
    DP->>BD: BridgeDaemon(args, status, sink_name)
    BD->>MA: WebSocket connect (Sendspin protocol)
    MA-->>BD: ServerStatePayload (track/artist/format)
    BD-->>DP: status dict mutation → _emit_status()
    DP-->>SC: stdout: {"type":"status", "playing":true, …}
    SC->>SC: _update_status() → state.notify_status_changed()
    Note over SC,DP: Commands flow parent→child via stdin
    SC->>DP: stdin: {"cmd":"set_volume","value":75}
    DP->>BD: daemon._sync_bt_sink_volume(75)

All inter-process communication between the main process and each daemon subprocess uses newline-delimited JSON envelopes defined by services.ipc_protocol.

The current contract stamps envelopes with protocol_version: 1, but the parent and child remain backward-compatible with legacy messages that omit the field.

typeFieldsWhen
statusFull DeviceStatus dict + protocol_versionOn any state change (de-duplicated)
loglevel, name, msg, protocol_versionEvery forwarded log record
errormessage, details?, protocol_versionFatal daemon/bootstrap failures that deserve structured surfacing
{"type": "status", "protocol_version": 1, "playing": true, "volume": 75, "current_track": "Mooncalf"}
{"type": "log", "protocol_version": 1, "level": "info", "name": "__main__", "msg": "[ENEBY20] Stream started"}
{"type": "error", "protocol_version": 1, "message": "Unsupported sink", "details": {"sink": "bluez_sink..."}}

SubprocessIpcService parses these envelopes, applies protocol-version warnings, and routes status/log/error payloads back into SendspinClient state.

cmdExtra fieldsEffect
set_volumevalue: int, protocol_versionSets PA sink volume + notifies MA
set_mutemuted: bool, protocol_versionToggles mute
stopprotocol_versionClean shutdown
pause / playprotocol_versionSends MediaCommand to MA
reconnectprotocol_versionDisconnects from MA (triggers reconnect)
set_log_levellevel: str, protocol_versionChanges root logger level immediately
{"cmd": "set_volume", "value": 60, "protocol_version": 1}
{"cmd": "stop", "protocol_version": 1}

SubprocessCommandService serializes command envelopes, while SubprocessStopService coordinates graceful stop / terminate fallback during restart or shutdown.


The critical insight: each subprocess gets its own PulseAudio client context with PULSE_SINK pre-set. This eliminates the race condition where audio would start on the default sink before the bridge moved it.

graph LR
    subgraph "Subprocess ENEBY20"
        A1[aiosendspin<br/>Sendspin decoder] -->|PCM frames| PA1[libpulse<br/>PULSE_SINK=bluez_sink.FC_58…]
    end
    subgraph "Subprocess Yandex"
        A2[aiosendspin<br/>Sendspin decoder] -->|PCM frames| PA2[libpulse<br/>PULSE_SINK=bluez_sink.2C_D2…]
    end

    PA1 --> PAS[PulseAudio / PipeWire server]
    PA2 --> PAS

    PAS --> S1[bluez_sink.FC_58_FA_EB_08_6C.a2dp_sink]
    PAS --> S2[bluez_sink.2C_D2_6B_B8_EC_5B.a2dp_sink]

    S1 -->|A2DP Bluetooth| SPK1[ENEBY20 speaker]
    S2 -->|A2DP Bluetooth| SPK2[Yandex mini speaker]

BluetoothManager.configure_bluetooth_audio() tries four sink name patterns in order until pactl list short sinks confirms one exists:

bluez_output.{MAC_UNDERSCORED}.1 # PipeWire
bluez_output.{MAC_UNDERSCORED}.a2dp-sink # PipeWire alt
bluez_sink.{MAC_UNDERSCORED}.a2dp_sink # PulseAudio (HAOS)
bluez_sink.{MAC_UNDERSCORED} # PulseAudio fallback

Retries up to with 3-second delays (the A2DP sink takes a few seconds to appear after BT connects).

When Bluetooth reconnects, PulseAudio’s module-rescue-streams may move sink-inputs to the default sink. BridgeDaemon._ensure_sink_routing() corrects this once per stream start — guarded by _sink_routed flag to prevent re-anchor feedback loops.

Volume Control (Single-Writer Architecture)

Section titled “Volume Control (Single-Writer Architecture)”

Volume and mute are controlled through a single-writer model: only bridge_daemon (running inside each subprocess) writes to PulseAudio. This eliminates feedback loops where multiple writers would compete and cause volume bouncing.

sequenceDiagram
    participant UI as Web UI
    participant API as Flask API
    participant MA as Music Assistant
    participant BD as bridge_daemon (subprocess)
    participant PA as PulseAudio

    Note over UI,PA: MA path (VOLUME_VIA_MA = true, MA connected)
    UI->>API: POST /api/volume (volume 40, group true)
    API->>MA: WS players/cmd/group_volume
    API-->>UI: via ma (no local status update)
    MA->>BD: VolumeChanged echo (sendspin protocol)
    BD->>PA: pactl set-sink-volume (single writer)
    BD->>BD: _bridge_status volume = N, _notify()
    BD-->>API: stdout status volume N
    API-->>UI: SSE status update

    Note over UI,PA: Local fallback (MA offline or force_local)
    UI->>API: POST /api/volume (volume 40, force_local true)
    API->>PA: pactl set-sink-volume (direct)
    API->>BD: stdin set_volume value 40
    API-->>UI: via local + immediate status update

Group volume routing:

Device typeMethodBehavior
In MA sync groupMA group_volume (one call per unique group)Proportional delta — preserves relative volumes between speakers
Solo (no sync group)Direct PulseAudio (pactl)Exact value — slider value = speaker volume

The VOLUME_VIA_MA config option (default: true) controls whether volume changes are routed through MA. Set to false to always use direct PulseAudio, which bypasses MA entirely but means the MA UI won’t reflect volume changes made from the bridge.

MUTE_VIA_MA (default: false) controls mute routing independently. When false, mute commands go directly to PulseAudio for instant response. When true, mute is routed through the MA API — useful for keeping the MA UI in sync but adds network latency.


stateDiagram-v2
    [*] --> Checking: BluetoothManager start

    Checking --> Connected: is_device_connected() = True
    Checking --> Connecting: not connected + bt_management_enabled

    Connecting --> Connected: connect_device() success
    Connecting --> Checking: connect failed (retry after check_interval)

    Connected --> AudioConfigured: configure_bluetooth_audio()
    AudioConfigured --> Monitoring: sink found → on_sink_found(sink_name, volume)

    Monitoring --> Disconnected: D-Bus PropertiesChanged OR poll miss
    Disconnected --> Connecting: bt_management_enabled = True
    Disconnected --> Released: bt_management_enabled = False

    Released --> Connecting: Reclaim → bt_management_enabled = True

    Monitoring --> Released: Release button clicked
    Connected --> Released: Release button clicked
sequenceDiagram
    participant BM as BluetoothManager
    participant BC as bluetoothctl
    participant DBUS as D-Bus / BlueZ
    participant SC as SendspinClient

    BM->>DBUS: Subscribe PropertiesChanged (dbus-fast)
    loop check_interval (default 10s)
        BM->>DBUS: read Connected property (fast path)
        alt disconnected
            BM->>BC: select <adapter_mac>\nconnect <device_mac>
            BC-->>BM: Connection successful
            BM->>BC: scan off
            BM->>DBUS: org.bluez.Device1.ConnectProfile(A2DP UUID)
            BM->>BM: configure_bluetooth_audio()
            BM->>SC: on_sink_found(sink_name, volume)
        end
    end
    DBUS-->>BM: PropertiesChanged{Connected=False}
    BM->>SC: bluetooth_connected = False
    BM->>BM: reconnect loop

When prefer_sbc: true, after every connect BluetoothManager runs:

Terminal window
pactl send-message /card/<card>/bluez5/set_codec a2dp_sink SBC

This forces the simplest mandatory A2DP codec, reducing CPU load on slow hardware. Requires PulseAudio 15+.

bluetooth_manager.py uses dbus-fast (async) to subscribe to org.freedesktop.DBus.Properties.PropertiesChanged on the device path /org/bluez/<hci>/dev_XX_XX_XX_XX_XX_XX. This gives instant disconnect detection instead of waiting for the next poll cycle.

Falls back to bluetoothctl polling if dbus-fast is unavailable.


Each subprocess connects to MA as a Sendspin player via WebSocket. The BridgeDaemon overrides key SendspinDaemon methods to intercept callbacks and update the shared status dict.

graph LR
    subgraph "Music Assistant"
        MA_SRV[MA Server<br/>:9000 WebSocket]
        MA_QUEUE[Player Queue<br/>syncgroup_id]
    end

    subgraph "Bridge Subprocess"
        AC[aiosendspin client<br/>SendspinClient]
        BD[BridgeDaemon callbacks]
        AC <-->|WebSocket| MA_SRV
        AC --> BD
        BD -->|_on_group_update| STATUS[status dict]
        BD -->|_on_metadata_update| STATUS
        BD -->|_on_stream_event| STATUS
        BD -->|_handle_server_command| STATUS
        BD -->|_handle_format_change| STATUS
    end

When MA_API_URL and MA_API_TOKEN are configured (auto-created via “Sign in with Home Assistant” in addon mode, or set manually), the main process runs a MaMonitor task that maintains a persistent WebSocket connection to MA’s /ws endpoint for real-time event subscription.

Supported MA auth providers:

MethodEndpointUse case
Direct MA credentialsPOST /api/ma/loginStandalone installs — username + password sent to MA
HA OAuth (browser-based)GET /api/ma/ha-auth-page → callback”Sign in with Home Assistant” button in the UI
HA credentials via MAPOST /api/ma/ha-loginUsername + password forwarded to HA login_flow through MA
Silent HA auth (addon mode)POST /api/ma/ha-silent-authAutomatic — uses Ingress headers, no user interaction
sequenceDiagram
    participant MM as MaMonitor
    participant MA as MA WebSocket /ws
    participant ST as state.py

    MM->>MA: connect + authenticate (token)
    MM->>MA: subscribe player_queue_updated
    MM->>MA: subscribe player_updated
    MM->>MA: player_queues/all (initial fetch)
    MA-->>MM: queue snapshots
    MM->>ST: set_now_playing(syncgroup_id, metadata)
    MM->>ST: set_ma_groups(groups)
    loop real-time events
        MA-->>MM: player_queue_updated event
        MM->>ST: update now-playing cache
    end
    Note over MM: Falls back to polling every 15s if events unavailable
    Note over MM: Exponential backoff reconnect (2s → 60s max)

When MA resumes a syncgroup (e.g., after device reconnect), the bridge can trigger group playback via the REST API:

POST /api/ma/queue/cmd
{"syncgroup_id": "syncgroup_uwkgkafx", "command": "play"}
→ ma_client.ma_group_play(url, token, syncgroup_id)
→ POST {MA_API_URL}/api/players/cmd/play?player_id={syncgroup_id}

In HA addon mode, the bridge creates an MA API token automatically via MA’s Ingress JSONRPC — no manual token setup needed.

sequenceDiagram
    participant UI as Browser (Ingress)
    participant API as Bridge /api/ma/ha-silent-auth
    participant HA as HA WebSocket
    participant SUP as Supervisor API
    participant MA as MA Ingress :8094

    UI->>API: POST {ha_token, ma_url}
    API->>HA: ws://homeassistant:8123/api/websocket
    API->>HA: auth/current_user
    HA-->>API: {id, name, is_admin}
    API->>SUP: GET /addons/{slug}/info
    SUP-->>API: {hostname, ingress_port}
    API->>MA: POST /api (JSONRPC auth/token/create)
    Note over API,MA: X-Remote-User-ID, X-Remote-User-Name headers
    MA-->>API: long-lived JWT (10-year)
    API->>API: save token to config.json
    API-->>UI: {success: true, username: "..."}

state.py is the single source of truth for shared runtime state, accessed by the Flask API threads, the asyncio loop, and D-Bus callbacks concurrently.

graph TD
    subgraph "state.py"
        CL[clients: list&#91;SendspinClient&#93;]
        CL_LOCK[_clients_lock: threading.Lock]
        SSE[_status_version: int<br/>_status_condition: threading.Condition]
        SCAN[scan_jobs: dict<br/>TTL = 2 min]
        GROUPS[_ma_groups: list&#91;dict&#93;<br/>_now_playing: dict]
        ADAPTER[_adapter_cache: str<br/>_adapter_cache_lock: threading.Lock]
    end

    SC[SendspinClient._update_status&#40;&#41;] -->|notify_status_changed&#40;&#41;| SSE
    FLASK[Flask /api/status/stream] -->|wait on Condition| SSE
    MM[MaMonitor] -->|set_ma_groups / set_now_playing| GROUPS
    BP_API[routes/api.py] -->|get_clients&#40;&#41;| CL
    BP_API -->|create_scan_job / finish_scan_job| SCAN

GET /api/status/stream uses Server-Sent Events with threading.Condition to push live status to the web UI without polling:

# Server side (state.py)
def notify_status_changed():
with _status_condition:
_status_version += 1
_status_condition.notify_all()
# Flask SSE handler (api_status.py)
def api_status_stream():
def generate():
last_version = 0
while True:
with _status_condition:
_status_condition.wait_for(lambda: _status_version > last_version, timeout=25)
last_version = _status_version
yield f"data: {json.dumps(get_client_status())}\n\n"
return Response(generate(), mimetype="text/event-stream")

Events are batched with a 100 ms debounce windownotify_status_changed() coalesces rapid-fire updates (e.g., volume slider drag, multiple devices reconnecting) into a single SSE push to prevent event storms.

The initial SSE response includes a 2 KB padding comment (<!-- ... -->) to flush HA Ingress proxy buffers, ensuring the first real event is delivered immediately rather than being buffered by the reverse proxy.


The Flask app created in web_interface.py is served by Waitress and split across 5 API blueprints plus views/auth routes. The route surfaces are grouped by ownership instead of by UI screen so orchestration, Bluetooth, Music Assistant, config, and status concerns can evolve independently.

graph TD
    CLIENT[Browser / Home Assistant] -->|HTTP| WAITRESS[Waitress :8080]
    WAITRESS --> FLASK[Flask app]
    FLASK --> AUTH[routes/auth.py<br/>login / logout]
    AUTH --> VIEW[routes/views.py<br/>HTML shell]
    AUTH --> API_MOD[5 API blueprints]

    subgraph "routes/api.py — Playback Control (6)"
        API_MOD --> CTRL[restart · volume · mute · pause_all · group_pause · pause/play]
    end

    subgraph "routes/api_bt.py — Bluetooth (16)"
        API_MOD --> BT[reconnect · pair · pair_new jobs · management · enabled · adapters · paired · remove · info · disconnect · adapter power · reset reconnect · scan jobs]
    end

    subgraph "routes/api_ma.py — MA Integration (11)"
        API_MOD --> MAAPI[discover · login · HA auth flows · groups · rediscover · nowplaying · artwork · queue cmd · debug]
    end

    subgraph "routes/api_config.py — Configuration & Updates (12)"
        API_MOD --> CFG[config get/post · download/upload/validate · set-password · log level · logs/download · version · update check/info/apply]
    end

    subgraph "routes/api_status.py — Status & Diagnostics (11)"
        API_MOD --> STATUS[status · groups · startup-progress · runtime-info · SSE stream · diagnostics · bugreport · diagnostics download · health · onboarding assistant · preflight]
    end

Bluetooth scan is a 10-second blocking operation. The API handles it asynchronously:

sequenceDiagram
    participant UI as Web UI
    participant API as /api/bt/scan
    participant SCAN as _run_bt_scan()
    participant BC as bluetoothctl

    UI->>API: POST /api/bt/scan
    API->>SCAN: threading.Thread(target=_run_bt_scan, args=[job_id])
    API-->>UI: {"job_id": "abc123"}
    SCAN->>BC: scan on / list-visible / scan off (10s)
    BC-->>SCAN: device list
    SCAN->>STATE: finish_scan_job(job_id, results)
    loop polling
        UI->>API: GET /api/bt/scan/result/abc123
        API-->>UI: {"status": "running"} or {"status": "done", "devices": […]}
    end

graph TD
    subgraph "config.py"
        LOAD[load_config&#40;&#41;<br/>reads config.json]
        SAVE[save_device_volume&#40;&#41;<br/>debounced 1s write]
        UPDATE[update_config&#40;&#41;<br/>validated merge]
        PORTS[detect_ha_addon_channel&#40;&#41;<br/>resolve_web_port / resolve_base_listen_port]
        LOCK[config_lock<br/>threading.Lock]
    end

    subgraph "config.json fields"
        GLOBAL[Global:<br/>SENDSPIN_SERVER · SENDSPIN_PORT · WEB_PORT · BASE_LISTEN_PORT<br/>PULSE_LATENCY_MSEC · BT_CHECK_INTERVAL · BT_MAX_RECONNECT_FAILS<br/>UPDATE_CHANNEL · CHECK_UPDATES · AUTO_UPDATE<br/>MA_API_URL · MA_API_TOKEN · MA_WEBSOCKET_MONITOR<br/>LOG_LEVEL · AUTH_PASSWORD_HASH · SECRET_KEY · CONFIG_SCHEMA_VERSION]
        DEVICES[Bluetooth Devices:<br/>player_name · mac · adapter · listen_host · listen_port<br/>static_delay_ms · preferred_format · keepalive_silence<br/>keepalive_interval · enabled · LAST_VOLUME]
        ADAPTERS[Bluetooth Adapters:<br/>id · mac · name]
    end

    JSON["/config/config.json"] --> LOAD
    LOAD --> GLOBAL
    LOAD --> DEVICES
    LOAD --> ADAPTERS
    BP_CFG[POST /api/config<br/>POST /api/config/validate] --> UPDATE
    UPDATE -->|thread-safe| JSON
    SAVE -->|thread-safe| JSON
    PORTS --> LOAD

    subgraph "HA Addon Path"
        HA_OPT["/data/options.json<br/>written by HA Supervisor"]
        HA_SCRIPT[scripts/translate_ha_config.py]
        HA_OPT --> HA_SCRIPT
        HA_SCRIPT -->|generates| HA_JSON["/data/config.json"]
        HA_JSON --> LOAD
    end

Channel-aware defaults and add-on semantics

Section titled “Channel-aware defaults and add-on semantics”

In Home Assistant add-on mode, detect_ha_addon_channel() infers the installed addon track from the container hostname suffix (-rc, -beta) and then resolves per-track defaults:

TrackDefault ingress portDefault player port base
stable80808928
rc80819028
beta80829128

UPDATE_CHANNEL is separate: it only controls prerelease lookup / warning surfaces for the update checker. Changing UPDATE_CHANNEL does not switch the installed HA add-on variant.

When WEB_PORT is explicitly set in add-on mode and differs from the track default, resolve_additional_web_port() opens a second direct host-network listener while HA ingress continues to use the fixed per-track port.

flowchart TD
    CF[config.json] -->|load_config&#40;&#41;| CONFIG
    CONFIG --> DEVS[BLUETOOTH_DEVICES list]
    DEVS --> D1[device 0]
    DEVS --> D2[device 1]
    DEVS --> DN[device N]

    D1 --> BM1[BluetoothManager<br/>mac · adapter · check_interval<br/>prefer_sbc · max_fails]
    D1 --> SC1[SendspinClient<br/>player_name · listen_port<br/>static_delay_ms · keepalive]

    BM1 -.->|bt_manager=| SC1

    SC1 --> RUN1[SC.run&#40;&#41;<br/>asyncio loop]
    RUN1 --> MON1[monitor_and_reconnect&#40;&#41;<br/>asyncio loop]
    RUN1 --> SUB1[daemon subprocess<br/>PULSE_SINK=…]

sequenceDiagram
    participant SH as entrypoint.sh
    participant HA as HA Supervisor
    participant TR as translate_ha_config.py
    participant PY as sendspin_client.py main()
    participant DB as D-Bus session
    participant PA as PulseAudio
    participant BM as BluetoothManager
    participant SC as SendspinClient

    alt HA Addon mode
        HA->>SH: write /data/options.json
        SH->>TR: python3 translate_ha_config.py
        TR->>TR: detect adapters via bluetoothctl list
        TR->>TR: merge user options + detected adapters
        TR-->>SH: /data/config.json written
    end

    SH->>SH: link D-Bus socket
    SH->>SH: detect PA / PipeWire socket → export PULSE_SERVER
    SH->>DB: dbus-daemon --session → DBUS_SESSION_BUS_ADDRESS

    SH->>PY: exec python3 sendspin_client.py
    PY->>PY: load_config()
    PY->>PY: configure logging (LOG_LEVEL)

    loop per device
        PY->>BM: BluetoothManager.__init__()
        BM->>BM: _resolve_adapter_select() → adapter MAC
        BM->>BM: _resolve_adapter_hci_name() → hciN
        PY->>SC: SendspinClient.__init__()
        PY->>SC: state.register_client(SC)
    end

    PY->>PY: threading.Thread → waitress.serve(app, port=8080)
    PY->>PY: asyncio.gather(SC.run()×N, BM.monitor_and_reconnect()×N, MaMonitor.run())

    loop per device — concurrent
        BM->>BM: dbus-fast subscribe PropertiesChanged
        BM->>BM: poll is_device_connected()
        BM->>PA: configure_bluetooth_audio() → bluez_sink name
        SC->>SC: _start_sendspin_inner()
        SC->>SC: asyncio.create_subprocess_exec(daemon_process.py, env={PULSE_SINK})
    end

The web UI supports optional password protection via routes/auth.py. Authentication is disabled by default (AUTH_ENABLED = False) and enabled the moment a password is set via the Configuration panel.

flowchart TD
    REQ[Incoming HTTP request] --> HOOK[before_request hook<br/>web_interface.py]
    HOOK -->|AUTH_ENABLED = False| PASS[Allow through]
    HOOK -->|session.authenticated = True| PASS
    HOOK -->|not authenticated| LOGIN[Redirect → /login]

    LOGIN --> MODE{Mode?}
    MODE -->|Standalone| PBKDF2[Compare PBKDF2-SHA256<br/>against AUTH_PASSWORD_HASH<br/>in config.json]
    MODE -->|HA Addon<br/>SUPERVISOR_TOKEN set| HA_FLOW

    subgraph "HA Core Auth Flow"
        HA_FLOW[POST /auth/login_flow<br/>HA Core :8123]
        HA_FLOW -->|step 1: username + password| HA_STEP[POST /auth/login_flow/flow_id]
        HA_STEP -->|type=create_entry| OK[session.authenticated = True]
        HA_STEP -->|type=form step_id=mfa| MFA[2FA step<br/>TOTP code input]
        MFA -->|step 2: code| HA_STEP2[POST /auth/login_flow/flow_id]
        HA_STEP2 -->|type=create_entry| OK
        HA_STEP2 -->|type=abort| FAIL[Error — session expired]
    end

    HA_FLOW -->|HA Core unreachable<br/>network error only| SUPER[Supervisor /auth fallback<br/>bypasses 2FA — safe only<br/>if Core is unreachable]
    SUPER --> OK

    PBKDF2 -->|match| OK
    PBKDF2 -->|mismatch| BF[Brute-force counter]
    BF -->|< 5 fails| FAIL2[Error — invalid password]
    BF -->|≥ 5 fails in 60s| LOCK[Lockout 5 min<br/>HTTP 429]

In-memory rate limiter (_failed dict in routes/auth.py) tracks failures per client IP:

ThresholdWindowAction
5 failed attempts60 secondsIP locked out for 5 minutes
1 successful loginFailure counter cleared
5-minute lockout expiresCounter reset automatically

When SUPERVISOR_TOKEN is present, the bridge authenticates against HA Core (not just the Supervisor API) to support 2FA / TOTP:

  1. Start a login flow via POST {HA_CORE_URL}/auth/login_flow
  2. Submit credentials via POST {HA_CORE_URL}/auth/login_flow/{flow_id}
  3. If the response is type=form, step_id=mfa → prompt for TOTP code
  4. Submit code via another flow step

Fallback to Supervisor /auth is only used if HA Core is network-unreachable (DNS failure, connection refused). If HA Core responds with an HTTP error, the fallback is blocked to prevent MFA bypass.

Flask server-side session with a randomly generated SECRET_KEY stored in config.json. The key persists across restarts (generated once on first start and saved). Session cookies are HttpOnly and expire when the browser closes.


Some Bluetooth speakers auto-disconnect after a period of silence. When keepalive_interval (≥ 30 s) is configured for a device, the main process periodically sends a short burst of silent PCM audio to prevent disconnection.

device.keepalive_interval = 30 → silence burst every 30 s
device.keepalive_interval = 0 → disabled (default)

The bridge is designed to remain functional when optional system libraries or services are unavailable. Each optional dependency has a defined fallback:

graph TD
    subgraph "Optional: dbus-fast"
        DBUS_CHK{dbus-fast<br/>available?}
        DBUS_CHK -->|Yes| DBUS_ON[Instant disconnect detection<br/>via PropertiesChanged signal]
        DBUS_CHK -->|No| DBUS_OFF[Fallback to bluetoothctl polling<br/>check_interval = 10s]
    end

    subgraph "Optional: pulsectl_asyncio"
        PA_CHK{pulsectl_asyncio<br/>available?}
        PA_CHK -->|Yes| PA_ON[Native async PulseAudio control<br/>sink list · volume · sink-input move]
        PA_CHK -->|No| PA_OFF[_PULSECTL_AVAILABLE = False<br/>Fallback: pactl subprocess calls<br/>for every PA operation]
    end

    subgraph "Optional: websockets + MA API"
        WS_CHK{websockets installed<br/>+ MA_API_URL set?}
        WS_CHK -->|Yes| WS_ON[MaMonitor: real-time events<br/>player_queue_updated subscription]
        WS_CHK -->|Events fail| WS_POLL[Polling fallback<br/>every 15s via REST]
        WS_CHK -->|No| WS_OFF[MaMonitor disabled<br/>now-playing from Sendspin WS only]
    end
Optional DependencyFlag / CheckFull ModeDegraded Mode
dbus-fast (async D-Bus)ImportError on importInstant BT disconnect via PropertiesChanged signalbluetoothctl polling every check_interval (10 s)
pulsectl_asyncio_PULSECTL_AVAILABLENative async PulseAudio: sink list, volume, move sink-inputsAll PA operations fall back to pactl subprocess calls
websockets + MA_API_URL configuredImportError + config checkReal-time MA events (player_queue_updated)Polling every 15 s; if MA API not configured, MaMonitor disabled entirely

Note: All fallbacks are logged at WARNING or INFO level at startup so operators can diagnose which features are active. Check container logs for lines like "pulsectl_asyncio unavailable — falling back to pactl subprocess" or "D-Bus monitor unavailable — using bluetoothctl polling".


graph TD
    subgraph "Main Thread — asyncio event loop"
        EL[asyncio.get_event_loop&#40;&#41;]
        EL --> T1[SendspinClient.run&#40;&#41; × N<br/>coroutine]
        EL --> T2[BluetoothManager.monitor_and_reconnect&#40;&#41; × N<br/>coroutine]
        EL --> T3[MaMonitor.run&#40;&#41;<br/>coroutine]
        T1 --> T4[_read_subprocess_output&#40;&#41;<br/>asyncio.Task]
        T1 --> T5[_read_subprocess_stderr&#40;&#41;<br/>asyncio.Task]
        T1 --> T6[_status_monitor_loop&#40;&#41;<br/>asyncio.Task]
        T2 --> T7[run_in_executor&#40;bluetoothctl&#41;<br/>ThreadPoolExecutor]
    end

    subgraph "Daemon Thread — Waitress"
        WT[waitress.serve&#40;&#41;<br/>WSGI thread pool]
        WT --> W1[Flask request handler × M<br/>WSGI worker threads]
    end

    subgraph "Background Threads"
        BT1[_run_bt_scan&#40;&#41;<br/>threading.Thread<br/>per scan request]
    end

    LOCK[threading.Lock<br/>state._clients_lock<br/>config.config_lock<br/>SendspinClient._status_lock]

    W1 <-->|acquire| LOCK
    T7 <-->|acquire| LOCK

Note: All bluetoothctl subprocess calls in the async BT monitor loop are dispatched via loop.run_in_executor(None, …) to avoid blocking the event loop. The _bt_executor is a dedicated ThreadPoolExecutor(max_workers=2).


The main process runs a periodic status monitor (_status_monitor_loop) that detects zombie playback — situations where playing=True but streaming=False for more than 15 seconds. This catches broken audio pipelines where the sendspin connection is alive but no audio data flows.

stateDiagram-v2
    [*] --> Healthy: playing + streaming
    Healthy --> Suspicious: streaming stops
    Suspicious --> Healthy: streaming resumes
    Suspicious --> Zombie: 15s timeout
    Zombie --> Restarting: kill subprocess
    Restarting --> Healthy: new subprocess
    Restarting --> Disabled: 3 retries exhausted

On detection, the subprocess is killed and restarted, up to 3 retries. After 3 failures, the watchdog stops retrying for that device.

Optional feature (BT_CHURN_THRESHOLD, default 0 = disabled) that tracks reconnection frequency per device within a sliding window (BT_CHURN_WINDOW, default 300 s). If a device reconnects more than the threshold within the window, BT management is automatically disabled for that device — preventing a flaky speaker from consuming adapter time and destabilizing other speakers.


graph LR
    SC[sendspin_client.py] --> BM[bluetooth_manager.py]
    SC --> ST[state.py]
    SC --> CFG[config.py]
    SC --> SVC_BD[services/bridge_daemon.py]

    WI[web_interface.py] --> FLASK[Flask + Waitress]
    WI --> R_API[routes/api.py]
    WI --> R_BT[routes/api_bt.py]
    WI --> R_MA[routes/api_ma.py]
    WI --> R_CFG[routes/api_config.py]
    WI --> R_STS[routes/api_status.py]
    WI --> R_VIEW[routes/views.py]
    WI --> R_AUTH[routes/auth.py]

    R_API --> ST
    R_API --> CFG
    R_MA --> SVC_MAC[services/ma_client.py]
    R_BT --> SVC_BT[services/bluetooth.py]

    BM --> SVC_PA[services/pulse.py]
    BM --> SVC_BT

    SVC_BD --> SVC_PA
    SVC_BD --> SENDSPIN[sendspin-cli<br/>aiosendspin]

    DP[services/daemon_process.py] --> SVC_BD
    DP --> SENDSPIN

    ST --> MM[services/ma_monitor.py]
    MM --> SVC_MAC

    SC --> UC[services/update_checker.py]
    UC -.->|GitHub API| GH[(GitHub Releases)]

    DEMO[demo/__init__.py] -.->|patches| SC
    DEMO -.->|patches| BM
    DEMO -.->|patches| SVC_PA
    DEMO_SIM[demo/simulator.py] --> ST
    DEMO_FIX[demo/fixtures.py] --> DEMO

    CFG -.->|config.json| JSON[(config.json)]
    HA_SCRIPT[scripts/translate_ha_config.py] -.->|options.json→config.json| JSON

PackageRole
aiosendspinAsync Sendspin WebSocket client library
sendspin (local)CLI + daemon runner (SendspinDaemon)
Flask + WaitressWeb UI and REST API server
pulsectl_asyncioAsync PulseAudio control (sink routing, volume)
dbus-fastAsync D-Bus for instant BT disconnect detection
websocketsMA API WebSocket connection in MaMonitor
aiohttp / httpxMA REST API calls in ma_client.py
bluetoothctlSystem BT management (subprocess)
pactlAudio sink discovery (subprocess, legacy path)

High-level view of sendspin-bt-bridge and its external interactions.

C4Context
    title System Context — Sendspin Bluetooth Bridge

    Person(user, "User", "Controls speakers via<br/>web UI or HA dashboard")

    System(bridge, "Sendspin BT Bridge", "Multi-process Python service<br/>bridging MA audio → BT speakers")

    System_Ext(ma, "Music Assistant", "Music streaming server<br/>Sendspin protocol (WS + FLAC)")
    System_Ext(ha, "Home Assistant", "Smart home platform<br/>Addon host / Auth provider")
    System_Ext(bt, "Bluetooth Speakers", "A2DP audio sinks<br/>via BlueZ / PulseAudio")
    System_Ext(github, "GitHub Releases", "Version update checks<br/>API polling hourly")

    Rel(user, bridge, "Web UI / REST API", "HTTP / SSE")
    Rel(user, ha, "HA Dashboard", "HTTP")
    Rel(bridge, ma, "Sendspin WebSocket", "WS + FLAC/RAW")
    Rel(bridge, ma, "MA REST API", "HTTP")
    Rel(bridge, bt, "A2DP audio stream", "Bluetooth")
    Rel(bridge, ha, "Ingress / Auth", "HTTP")
    Rel(bridge, github, "Update check", "HTTPS")
    Rel(ha, ma, "Integration", "API")

End-to-end flow when a user adjusts volume via the web UI.

sequenceDiagram
    participant UI as Web UI (browser)
    participant API as Flask API<br/>routes/api.py
    participant SC as SendspinClient
    participant DP as daemon_process.py<br/>(subprocess)
    participant PA as PulseAudio
    participant MA as Music Assistant

    UI->>API: POST /api/volume {mac, volume: 60}
    API->>SC: send_command({cmd: set_volume, value: 60})
    SC->>DP: stdin JSON: {"cmd":"set_volume","value":60}
    DP->>PA: pulsectl set_sink_volume(60)
    PA-->>DP: OK
    DP->>MA: MediaCommand.VOLUME_SET (if VOLUME_VIA_MA)
    DP-->>SC: stdout JSON: {"type":"status","volume":60}
    SC->>SC: _update_status({volume: 60})
    SC->>SC: save_device_volume(mac, 60) [debounced 1s]
    SC-->>API: notify_status_changed()
    API-->>UI: SSE event: {"volume": 60, ...}

Background version polling now uses channel-aware release resolution instead of the stable-only releases/latest endpoint.

flowchart TD
    START([main&#40;&#41; startup]) --> DELAY[Wait 30s<br/>let app initialize]
    DELAY --> LOADCFG[load_config&#40;&#41; · normalize UPDATE_CHANNEL]
    LOADCFG --> FETCH[Fetch GitHub Releases list<br/>api.github.com/repos/.../releases?per_page=100]
    FETCH --> FILTER[Ignore drafts · keep tags for chosen channel]
    FILTER --> PICK[Pick highest semver<br/>stable / rc / beta lane]
    PICK --> CMP{remote > current?}

    CMP -->|Yes| FOUND[Store update info in state.py<br/>version · url · body · channel]
    CMP -->|No| CLEAR[Clear update_available]

    FOUND --> BADGE[UI: channel-aware update badge]
    CLEAR --> SLEEP
    BADGE --> SLEEP[Sleep 3600s]
    SLEEP --> LOADCFG

    subgraph "User-triggered"
        BADGE --> CLICK[User opens update modal]
        CLICK --> MODAL{GET /api/update/info<br/>detect runtime}
        MODAL -->|systemd / LXC| LXC_BTN["POST /api/update/apply<br/>queues upgrade.sh via systemd-run"]
        MODAL -->|docker| DOCKER_CMD["Show channel-aware image guidance<br/>pull stable / rc / beta tag"]
        MODAL -->|ha_addon| HA_MSG["Direct to HA Add-ons UI<br/>installed track updates there"]
    end

For Home Assistant, the installed add-on track still determines what the Supervisor updates. The in-app UPDATE_CHANNEL preference only changes which GitHub release lane is highlighted in bridge UI/API surfaces.

When DEMO_MODE=true, the bridge runs with fully emulated hardware (v2.23.0+).

graph TD
    subgraph "Demo Mode Patches — demo/__init__.py"
        INSTALL["install(config)<br/>Called from main()"]
        INSTALL --> BT_PATCH[Patch BluetoothManager<br/>Simulated connect/disconnect<br/>Random battery levels]
        INSTALL --> PULSE_PATCH[Patch services.pulse<br/>Dict-backed volume/mute state<br/>per-sink tracking]
        INSTALL --> CLIENT_PATCH[Patch SendspinClient<br/>No real subprocess<br/>_FakeProc sentinel]
        INSTALL --> MA_PATCH[Patch MA commands<br/>send_player_cmd → noop<br/>ma_group_play → group propagate]
        INSTALL --> FIXTURES[Load fixtures.py<br/>5 devices + 3 sync groups<br/>BT adapters + MA discovery]
    end

    subgraph "Demo Simulator — demo/simulator.py"
        SIM[run_simulator] --> TRACKS[Curated playlist<br/>10 real tracks with metadata]
        SIM --> CYCLE[Rotate tracks per device<br/>Update elapsed_ms each tick]
        SIM --> PLAY_PAUSE[Random play/pause transitions<br/>Realistic timing]
    end

    subgraph "Result"
        WEB[Web UI at :8080<br/>All features work]
        SSE[SSE updates<br/>Real-time status changes]
        API[REST API<br/>42 endpoints respond]
    end

    INSTALL --> SIM
    SIM --> WEB
    SIM --> SSE
    SIM --> API