feat(stockholm): add Go backend integration for Stockholm frontend

Implements pkg/service/stockholm with bridge (appSend/runQueue), HTTP
proxy, static serving, config URL rewriting, native state persistence,
and device discovery. Mounts under a configurable base path (/stockholm
by default) with correct http.StripPrefix routing and apiBase-prefixed
bridge API routes matching the patched JS window.__stockholmBase calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-17 11:37:53 +02:00
co-authored by Claude Sonnet 4.6
parent 439449f0c4
commit 096ed682b1
21 changed files with 4271 additions and 27 deletions
+1
View File
@@ -105,3 +105,4 @@
* [SCMUDC Events Analysis](scmudc-events-analysis.md)
* [Parity Improvements](PARITY-IMPROVEMENTS.md)
* [Parity SoundCork](PARITY-SOUNDCORK.md)
* [Stockholm Port Guide](stockholm-port-guide.md)
+71 -20
View File
@@ -154,26 +154,30 @@ The service supports multiple ways to configure its behavior. When multiple sour
### Configuration Options
| Variable | Flag | Description | Default |
|------------------------------------|----------------------------|---------------------------------------------------------------------------------------------------------|---------------------------|
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL | `https://<hostname>:8443` |
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for DNS/DHCP migration) | `:53` |
| `MIRROR_ENABLED` | | Enable background mirroring of specific endpoints to Bose cloud | `false` |
| `MIRROR_ENDPOINTS` | | Comma-separated list of path patterns to mirror (e.g., `/streaming/account/*/device/*/recent`) | `[]` |
| `INTERNAL_PATHS` | `--internal-paths` | Paths for internal requests to exclude from recording (e.g., `/setup/*`, `/web/*`) | `[]` |
| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` |
| Variable | Flag | Description | Default |
|------------------------------------|----------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL | `https://<hostname>:8443` |
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for DNS/DHCP migration) | `:53` |
| `MIRROR_ENABLED` | | Enable background mirroring of specific endpoints to Bose cloud | `false` |
| `MIRROR_ENDPOINTS` | | Comma-separated list of path patterns to mirror (e.g., `/streaming/account/*/device/*/recent`) | `[]` |
| `INTERNAL_PATHS` | `--internal-paths` | Paths for internal requests to exclude from recording (e.g., `/setup/*`, `/web/*`) | `[]` |
| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` |
| `STOCKHOLM_DIR` | `--stockholm-dir` | Path to extracted Stockholm frontend directory — enables the Stockholm UI when set | *(disabled)* |
| `MARGE_URL` | | Streaming/marge base URL used when rewriting `stockholm/json/config.json`. Defaults to `SERVER_URL`. Set to `SERVER_URL/marge` only when using a soundcork backend. | *(same as `SERVER_URL`)* |
| `MARGE_AUTH_TOKEN` | | Pre-seeds the Stockholm `margeAuthToken` state (skips the login step for the first session) | *(empty)* |
| `MARGE_ACCOUNT_ID` | | Pre-seeds the Stockholm `margeAccountID` state (used to filter device-discovery results by account) | *(empty)* |
### Configuration Examples
@@ -188,6 +192,53 @@ SERVER_URL=https://my-soundtouch.example.com soundtouch-service --port 443
LOG_PROXY_BODY=true REDACT_PROXY_LOGS=false soundtouch-service
```
## Stockholm Frontend
The Stockholm frontend is the patched Bose SoundTouch app UI served directly by the service. When enabled, opening `http://<server>:8000` in a browser shows the full app interface, which communicates with your speakers via the local service instead of Bose's cloud.
### Getting the Stockholm files
The Stockholm UI files are not bundled in this repository — you supply them from [krahl/soundcork-stockholm-app](https://github.com/krahl/soundcork-stockholm-app). See that project's README for how to obtain the `stockholm.zip`. Once you have it:
```bash
# 1. Place stockholm.zip in stockholm_zip/
mkdir -p stockholm_zip
cp /path/to/stockholm.zip stockholm_zip/
# 2. Build the Docker image that applies the patches
make build-stockholm-image
# 3. Extract and patch the frontend into ./stockholm/
make prepare-stockholm
```
The `./stockholm/` directory is now ready to use.
### Enabling the Stockholm UI
Pass the directory to the service at startup:
```bash
# Binary
soundtouch-service --stockholm-dir ./stockholm
# Environment variable
STOCKHOLM_DIR=./stockholm soundtouch-service
# Docker Compose — add to the environment section of docker-compose.yml
# STOCKHOLM_DIR=/app/stockholm
# and mount the stockholm/ directory into the container
```
### Stockholm environment variables
| Variable | Description |
|--------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `STOCKHOLM_DIR` | Path to the extracted Stockholm frontend (enables the UI) |
| `MARGE_URL` | Override the streaming/marge URL written into `config.json`. Defaults to `SERVER_URL`. Only set this to `SERVER_URL/marge` when routing through a soundcork backend. |
| `MARGE_AUTH_TOKEN` | Pre-seed the session auth token so the first app launch skips the login screen |
| `MARGE_ACCOUNT_ID` | Pre-seed the account ID — device discovery will only show speakers on this account |
## Device Migration
### Understanding Migration
+647
View File
@@ -0,0 +1,647 @@
# Stockholm Backend — Port Guide for Bose-SoundTouch (Go)
This document describes everything needed to integrate the
[krahl/soundcork-stockholm-app](https://github.com/krahl/soundcork-stockholm-app)
functionality into the Go service. It is written as a reference; nothing here
implies a specific file layout or package structure.
---
## Table of Contents
1. [What needs porting](#1-what-needs-porting)
2. [What becomes obsolete](#2-what-becomes-obsolete)
3. [Startup: Stockholm frontend preparation](#3-startup-stockholm-frontend-preparation)
4. [Native bridge — appSend / runQueue](#4-native-bridge--appsend--runqueue)
5. [State persistence — native-state.json](#5-state-persistence--native-statejson)
6. [HTTP proxy — /api/http-proxy](#6-http-proxy--apihttp-proxy)
7. [Browser bootstrap injection](#7-browser-bootstrap-injection)
8. [SSDP discovery](#8-ssdp-discovery)
9. [Config file structure (stockholm/json/config.json)](#9-config-file-structure-stockholmjsonconfigjson)
10. [Backend config (backend-config.json)](#10-backend-config-backend-configjson)
11. [Running as a plain process (no Docker)](#11-running-as-a-plain-process-no-docker)
---
## 1. What needs porting
| Component | Java class / file | Notes |
|-----------------------------------------------|---------------------------------------|------------------------------------------------------|
| Stockholm zip extraction + patch application | `docker-entrypoint.sh` | Shell; can be Go at startup |
| URL rewriting in `stockholm/json/config.json` | `update-urls.sh` | Shell + `jq`/`sed`; can be Go |
| Native bridge | `NativeBridgeService` | Core; per-tab message queue |
| State persistence | `NativeBridgeService` (file I/O) | JSON file read/written on every `setData` |
| HTTP proxy | `HttpProxyService` | CORS proxy + cloud header injection |
| Browser bootstrap injection | `BackendApplication` (static handler) | Injects `<script>` into `index.html` |
| SSDP speaker + media-server discovery | `SsdpDiscoveryService` | Already partially in Bose-SoundTouch |
| Config reading | `SoundcorkDataService` | Reads `config.json` + `override.json` |
| Backend config | `BackendConfig` | Single JSON file, only `frontendLoggingLevel` so far |
---
## 2. What becomes obsolete
When Bose-SoundTouch serves Stockholm directly, the following env vars and
concepts collapse because the Go service knows its own URLs:
| Variable | Why it disappears |
|------------------------------------|--------------------------------------------|
| `BACKEND_URL` | Go service knows its own base URL |
| `STREAMING_URL` | Same — the marge path is internal |
| `AUTH_SERVICE_URL` | Same — marge is a local handler |
| `BACKEND_BIND_IP` / `BACKEND_PORT` | Replaced by existing `PORT` / `HTTPS_PORT` |
| `update-urls.sh` | Config rewriting becomes Go startup logic |
| Custom CA cert via `keytool` | Replaced by Bose-SoundTouch `certmanager` |
What does **not** disappear:
- `MARGE_AUTH_TOKEN` / `MARGE_ACCOUNT_ID` — seeding initial session state
- Stockholm zip + versioned patch files — still needed as assets
- `PREFERRED_DEVICES` and other existing Bose-SoundTouch config
---
## 3. Startup: Stockholm frontend preparation
### 3a. Zip extraction
Source file: `docker-entrypoint.sh:prepare_stockholm()`
Look for `stockholm/index.html`. If absent:
1. Find the zip in `stockholm_zip/stockholm.zip` (preferred) or `stockholm.zip`
alongside the binary.
2. Extract the zip into `stockholm/`.
### 3b. Versioned patch application
Patch files are named `stockholm-changes_v<N>.patch` and applied in ascending
order. The current patches are v1 (1 153 lines) and v2 (1 475 lines).
A marker file `stockholm/.soundcork-stockholm-app.json` tracks the last applied
version:
```json
{"project":"soundcork-stockholm-app","patchVersion":2}
```
Algorithm:
1. Read `patchVersion` from the marker (default 0).
2. For each `stockholm-changes_v<N>.patch` with N > current version, in order:
- Strip hunks that don't touch `stockholm/` paths (the patch files include
README and self-referential hunks).
- Dry-run `patch -p1 -R` (reverse) to test if it's already applied.
- Dry-run `patch -p1` (forward) to test if it can apply.
- Apply with `patch -p1 --batch`.
- Write the marker for version N.
3. For v1 only, run `prettier --write "stockholm/**/*.js"` before patching
(the patch was generated against formatted source).
The `patch` and `prettier` (npm) binaries are required. In a container image
these are install-time dependencies. For a plain binary distribution they must
be present on the host.
### 3c. Copy update-urls.sh into place
Copy `update-urls.sh` to `stockholm/json/update-urls.sh` after extraction.
The script is called from that directory so relative paths work.
### 3d. Rewrite config.json URLs (replaces update-urls.sh)
`stockholm/json/config.json` stores most values base64-encoded under a
`"default"` key (`d0``d13`). `update-urls.sh` decodes, rewrites with `sed`,
and re-encodes.
When the Go service knows its own URLs at startup, it can do this in-process:
```
fields to rewrite (sed substitutions in the shell script):
streaming.bose.com → STREAMING_URL (default: BACKEND_URL, soundcork: BACKEND_URL/marge)
events.api.bosecm.com → BACKEND_URL
content.api.bose.io → BACKEND_URL
worldwide.bose.com → BACKEND_URL
downloads.bose.com → BACKEND_URL
d6 field → AUTH_SERVICE_URL (set via jq, not sed)
```
The Go equivalent:
1. Read `config.json`, base64-decode each value in `default`.
2. Replace the hostnames above.
3. Set `default.d6` to the auth service URL.
4. Re-encode all values in `default` as base64.
5. Write back.
---
## 4. Native bridge — appSend / runQueue
Source: `NativeBridgeService.java`
Stockholm communicates with the native layer through two HTTP endpoints. The
bridge emulates the Android `Native` object.
### Endpoints
```
POST /api/native/appSend?clientId=<id> (or X-Stockholm-Client-Id header)
GET /api/native/runQueue?clientId=<id>
```
`clientId` is a per-browser-tab identifier. Falls back to `"default"`.
### appSend request body
JSON:
```json
{"method":"<name>","params":{...},"id":<number or null>}
```
### runQueue response body
```json
{"messages": [<message>, ...] | null}
```
Each message is one of:
**Callback result** (response to a `getData`, `getConstant`, etc.):
```json
{"result":<value>,"error":<value or null>,"id":<id from request>}
```
**Push method** (unsolicited, e.g. device discovery results):
```json
{"method":"devices","params":[...],"id":null}
```
### Supported methods
| Method | Action |
|---------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------|
| `locale`, `htmlReady`, `stopHrmsUpdates` | No-op |
| `log` | Log `params.msg` at debug level |
| `setData` | Store `params.name``params.value` in state; persist to disk |
| `getData` | Return state value for `params.name`; empty string if absent |
| `getLanStatus` | Return `{"result":true,"error":null,"id":<id>}` |
| `getTimeZone` | Return `{"result":{"timezoneInfo":"<IANA zone>","timeFormat":"TIME_FORMAT_24HOUR_ID"}}` |
| `getLegalDocPath` | Return path string (see below) |
| `getConstant` | Return `state["constant.<name>"]`; default for `"kilo"` is `"a7928d7b43dcd49f0af31e5aeed26458"` |
| `canPerformAutoAPSetup` | Return `{"result":{"permission":false,"location":false}}` |
| `getDeviceList` | Run SSDP renderer discovery async; push incremental `"devices"` messages |
| `getHrmsList` | Run SSDP server discovery async; push `"servers"` message |
| `getNetStats`, `getSSIDList`, `setSSID`, `updateSetting`, `oauth`, `downloadNewGui`, `installNewGui`, `sendLogs`, `socketCreate`, `socketSend`, `socketClose` | Return error `"unsupported"` |
**getLegalDocPath logic:**
```
type=lcns → "legal/platform_license.txt"
type=<blank> → "legal/eula_en.txt"
type=<type>,lang=<lang> → "legal/<type>_<lang>.txt" (lang defaults to "en")
```
### Async discovery pattern
`getDeviceList` and `getHrmsList` are fired asynchronously. Each discovered
device is pushed to the client queue immediately via `"devices"` / `"servers"`
method messages before the discovery is complete. The frontend polls
`/api/native/runQueue` continuously, so results arrive as they come in.
### Queue structure
One deque per `clientId`. `appSend` appends; `runQueue` drains the whole deque
atomically and returns all pending messages.
### State seeding from environment
On startup, read these env vars and write to state if present:
| Env var | State key |
|----------------------------------------|------------------|
| `MARGE_AUTH_TOKEN` or `margeAuthToken` | `margeAuthToken` |
| `MARGE_ACCOUNT_ID` or `margeAccountID` | `margeAccountID` |
Also seed on first run:
| State key | Value |
|----------------------|-----------------------------------------------------------------|
| `guid` | Random UUID (hex, no dashes) |
| `deviceGuid` | Same UUID as `guid` |
| `nativeFrameVersion` | Short version prefix extracted from `bose_app` in `config.json` |
| `frame_version` | Full version from `bose_app` |
| `authServer` | `"0"` |
| `constant.kilo` | `"a7928d7b43dcd49f0af31e5aeed26458"` |
---
## 5. State persistence — native-state.json
Source: `NativeBridgeService.loadState()` / `persistState()`
File path (relative to workspace root): `backend/state/native-state.json`
Format: flat JSON object, all values are strings.
```json
{
"guid": "abc123...",
"deviceGuid": "abc123...",
"frame_version": "27.0.13",
"nativeFrameVersion": "27.0.13",
"authServer": "0",
"margeAuthToken": "<token>",
"margeAccountID": "1234567",
"overrideMargeURL": "https://...",
"overrideUpdateURL": "https://...",
"constant.kilo": "a7928d7b43dcd49f0af31e5aeed26458",
... (arbitrary keys from setData calls)
}
```
Written on every `setData` call and on initial seeding. Read once at startup.
---
## 6. HTTP proxy — /api/http-proxy
Source: `HttpProxyService.java`
Stockholm makes all cloud API calls through this proxy to work around browser
CORS restrictions.
### Endpoint
```
<ANY METHOD> /api/http-proxy?url=<url-encoded target URL>
```
### Header filtering
**Blocked outbound (not forwarded to target):**
```
access-control-request-headers, access-control-request-method, connection,
content-length, cookie, forwarded, host, http2-settings, keep-alive, origin,
proxy-authenticate, proxy-authorization, referer, sec-ch-ua, sec-ch-ua-mobile,
sec-ch-ua-platform, sec-fetch-dest, sec-fetch-mode, sec-fetch-site,
sec-fetch-user, te, trailer, transfer-encoding, upgrade, x-forwarded-for,
x-forwarded-host, x-forwarded-port, x-forwarded-proto, x-real-ip,
x-requested-with
```
**Blocked inbound (not relayed to browser):**
```
access-control-allow-credentials, access-control-allow-headers,
access-control-allow-methods, access-control-allow-origin,
access-control-expose-headers, access-control-max-age, connection,
content-length, keep-alive, proxy-authenticate, proxy-authorization,
set-cookie, set-cookie2, te, trailer, transfer-encoding, upgrade
```
Also block HTTP/2 pseudo-headers (names starting with `:`).
Always add `Cache-Control: no-store` to the response.
### Backend-injected headers
Injected only if not already present in the request.
**BMX targets** (host is `content.api.bose.io`, `*.apigee.net`,
`bose-prod.apigee.net`, `test.content.api.bose.io`):
```
x-bmx-api-key: <encryptedBmxToken from config.json d7>
x-software-version: <bose_app version>
```
**Marge targets** (host ends with `.bose.com` or `.apigee.net` AND path
contains `/streaming/` or `/customer/`):
```
Accept: application/vnd.bose.streaming-v<N>+xml
(or customer variant if path contains /customer/)
Content-Type: same as Accept
ClientType: SOUNDTOUCH_COMPUTER_APP
GUID: <guid from state>
version_NativeFrameVersion: <nativeFrameVersion from state>
version_StockholmVersion: <bose_app version>
version_ProtocolVersion: <bose_protocol version>
<margeServerKeyHeader>: <margeServerKey> (if config d13/d10 non-empty)
Authorization: <margeAuthToken> (not injected on login/environment endpoints)
```
Authorization is **not** injected for these paths:
- `*/streaming/account/login`
- `/streaming/account` or `/streaming/account/`
- `*/streaming/account/email/*/environment`
- `/customer/account/password/email/*`
### Login retry (environment switching)
After a login `POST` to `*/streaming/account/login`:
1. If the response XML contains `<status-code>4033</status-code>` (wrong
region), parse the login request body for `<username>` and `<password>`.
2. Fetch `GET <same-origin><marge-prefix>/streaming/account/email/<email>/environment`
with `Authorization: Basic <base64(email:password)>`.
3. Parse the environment response XML for `<streamingURL>` and `<updateURL>`.
4. Store both as `overrideMargeURL` / `overrideUpdateURL` in state.
5. Retry the original login against the new `streamingURL`.
Subsequent marge requests are automatically redirected to `overrideMargeURL`
via `SoundcorkDataService.overrideTarget()`.
### Session capture
After a successful login response (2xx):
- Extract `<account id="...">` from the response XML body → store as `margeAccountID`.
- Extract `Credentials` response header → store as `margeAuthToken`.
On any marge response:
- If there is a `Refresh` response header, store its value as `margeAuthToken`.
### Proxy loop detection
Reject requests whose target URL resolves to the proxy's own
`/api/http-proxy` endpoint. Considers both the direct bind address and the
externally visible address from `X-Forwarded-Host` / `X-Forwarded-Port` /
`Host` headers.
### Header value sanitisation
Drop header values that are `null`, `undefined`, or empty string (these can
come from the Stockholm JS).
---
## 7. Browser bootstrap injection
Source: `BackendApplication.StaticStockholmHandler`
On every request to `index.html` or `setup/index.html`, inject a `<script>`
block before `</head>`. The script is skipped if `window.StockholmBrowserBootstrap`
already exists.
The injected JSON payload:
```json
{
"authServer": "<03, from state>",
"guid": "<guid from state>",
"nativeVersion": "<frame_version from state>",
"frameConfig": {}
}
```
The script does four things:
1. Patches `window.getURLParams` to return `bootstrap.authServer`, `bootstrap.guid`,
and `bootstrap.nativeVersion` for the keys `authServer`, `guid`, and
`native_version` when the original function returns null.
2. Patches `window.getUserAgentValue` to return `bootstrap.guid` for `_app`
when the original returns empty.
3. Sets `window.guid`, `window.frame_version`, `window.auth_server` from
bootstrap values when they are empty.
4. Patches `window.settingsLoad` to merge `bootstrap.frameConfig` into the
config object (keys `f<N>``d<N>`, base64-encoded, only if currently
empty).
`authServer` is an integer string `"0"``"3"`. The Java code normalises to
`"0"` for any invalid value.
### Static file serving
Serve everything under `stockholm/` for all paths. Content types:
| Extension | MIME type |
|----------------|-----------------------------------------|
| `.html` | `text/html; charset=UTF-8` |
| `.js` | `application/javascript; charset=UTF-8` |
| `.css` | `text/css; charset=UTF-8` |
| `.json` | `application/json; charset=UTF-8` |
| `.xml` | `application/xml; charset=UTF-8` |
| `.svg` | `image/svg+xml` |
| `.png` | `image/png` |
| `.jpg`/`.jpeg` | `image/jpeg` |
| `.gif` | `image/gif` |
| `.ttf` | `font/ttf` |
| `.otf` | `font/otf` |
| `.txt` | `text/plain; charset=UTF-8` |
Set `Cache-Control: no-store` on all responses.
For `HEAD` requests send headers only (no body, status -1 in content-length).
For 204/304 responses send no body.
Path traversal: reject any path that resolves outside `stockholm/`.
### Frontend logging cookie
Set a `Set-Cookie` header on every static response:
- If `frontendLoggingLevel > 0`:
`stockholmFrontendLoggingLevel=<level>; Path=/; SameSite=Lax`
- Otherwise (clear it):
`stockholmFrontendLoggingLevel=; Max-Age=0; Path=/; SameSite=Lax`
---
## 8. SSDP discovery
Source: `SsdpDiscoveryService.java`
Bose-SoundTouch already has SSDP/UPnP discovery in `pkg/discovery`. The
Stockholm bridge needs two specific discovery types with specific result shapes.
### Renderer discovery (speakers) — `getDeviceList`
Search target: `urn:schemas-upnp-org:device:MediaRenderer:1`
For each SSDP response, extract the `Location` header URL, take the `host`
part, then fetch `GET http://<host>:8090/info`.
Parse the XML response:
```xml
<info deviceID="AA:BB:CC:DD:EE:FF">
...
<margeAccountUUID>1234567</margeAccountUUID>
...
</info>
```
- `deviceID` attribute → `uID` (uppercased)
- `margeAccountUUID` element text → `accountId`
Filter: if `margeAccountID` is set in state, only include speakers whose
`margeAccountUUID` matches.
Result payload per speaker:
```json
{"uID": "AA:BB:CC:DD:EE:FF", "ip": "192.168.1.10"}
```
Push incremental results as they arrive (push one device at a time via the
`"devices"` method message). At the end, if the list is empty, push an empty
`"devices"` message.
### Network interface selection for SSDP
Priority order: ethernet/en* > wifi/wl* > others.
Exclude: loopback, virtual, docker, vbox, vmware, hyper-v, bluetooth, teredo,
tunnel interfaces.
Require: IPv4 address, multicast support, interface up.
Try each interface in priority order; return results from the first one that
gets responses.
SSDP probe parameters:
- Multicast: `239.255.255.250:1900`
- 3 probes, 350 ms between probes
- 1 250 ms grace period after last probe
- `MX: 1`
### Media server discovery (HRMS) — `getHrmsList`
Search target: `urn:schemas-upnp-org:device:MediaServer:1`
No HTTP fetch needed — extract from SSDP response headers only.
Result payload per server:
```json
{"uID": "<usn uuid or host:port>", "ip": "<host>", "port": "<port>"}
```
`uID` is the UUID portion of the `USN` header (strip `uuid:` prefix and
anything after `::`). Fall back to `host:port` if USN is absent.
Push all results at once (no incremental push) via `"servers"` method message.
---
## 9. Config file structure (stockholm/json/config.json)
Source: `SoundcorkDataService.java`
The file has three top-level objects: `app_versions`, `api_versions`, `default`.
### app_versions
| Key | Used as |
|-----------------|-------------------------------------------------------------------------------|
| `bose_app` | `soundcorkAppVersion` — also `x-software-version`, `version_StockholmVersion` |
| `bose_protocol` | `protocolVersion` — sent as `version_ProtocolVersion` |
### api_versions
| Key | Used as |
|------------------|--------------------------------------------------------------------------|
| `bose_streaming` | Streaming API version — builds `application/vnd.bose.streaming-v<N>+xml` |
| `bose_customer` | Customer API version — builds `application/vnd.bose.customer-v<N>+xml` |
### default (all values base64-encoded)
| Field | Content | Used as |
|-------|------------------------------|--------------------------------------------------------------|
| `d0` | marge base URL | `defaultMargeUrl` (redirected from `streaming.bose.com`) |
| `d1` | update base URL | `defaultUpdateUrl` (redirected from `events.api.bosecm.com`) |
| `d3` | BMX registry URL | `defaultBmxRegistryUrl` |
| `d6` | auth service URL | Written by `update-urls.sh` / `AUTH_SERVICE_URL` |
| `d7` | BMX API token | `encryptedBmxToken` — injected as `x-bmx-api-key` |
| `d8` | BMX server alt URL | stored but not currently used in header injection |
| `d10` | marge server key | injected as `<margeServerKeyHeader>` value on marge requests |
| `d13` | marge server key header name | the header name for d10 |
### override.json
Sits alongside `config.json` at `stockholm/json/override.json`. Currently only
`kilo` is read from it (not used in any live code path yet).
---
## 10. Backend config (backend-config.json)
Source: `BackendConfig.java`
File path: `backend/config/backend-config.json`
```json
{"frontendLoggingLevel": 2}
```
`frontendLoggingLevel`:
- `0` — disable frontend debug logging (clear the cookie)
- `> 0` — enable at that level (set cookie to the numeric value)
The Stockholm JS reads `stockholmFrontendLoggingLevel` from a cookie on load.
---
## 11. Running as a plain process (no Docker)
The entrypoint script does three things beyond launching the JVM. For a plain
process, do these steps once before running the binary:
### Step 1 — extract and patch Stockholm
```shell
# Requires: unzip, patch, npm/prettier@3.8.3
unzip stockholm_zip/stockholm.zip -d stockholm
npx prettier@3.8.3 --ignore-path /dev/null --write "stockholm/**/*.js"
patch -p1 --batch < stockholm-changes_v1.patch
patch -p1 --batch < stockholm-changes_v2.patch
```
Or run the Docker container once and copy the `stockholm/` directory out.
### Step 2 — rewrite URLs in config.json
```shell
cd stockholm/json
BACKEND_URL=http://localhost:8000 \
STREAMING_URL=http://localhost:8000/marge \ # soundcork only
AUTH_SERVICE_URL=http://localhost:8000/marge/ \
source update-urls.sh
cd ../..
```
For Bose-SoundTouch, this step disappears — the Go service rewrites config.json
in-process at startup.
### Step 3 — create state directory
```shell
mkdir -p backend/state
```
### Step 4 — run
```shell
# Java (current):
./gradlew run
# Go (future):
./soundtouch-service # with appropriate env vars
```
The Java `resolveWorkspaceRoot()` searches for a `stockholm/` directory at CWD
or one level up. Run from the project root.
---
## Patches summary — what the Stockholm JS patches do
**v1** (the main patch, applied after prettier formatting):
- `stockholm/index.html` — adds `<meta>` charset and viewport tags
- `stockholm/js/app_comm.js` — rewrites `AppComm` to use the HTTP native bridge
(`/api/native/appSend` + `/api/native/runQueue`) instead of Android native calls
- `stockholm/js/browser_http_proxy.js`**new file** — implements the
`stHttpProxy` function that routes all cloud API calls through `/api/http-proxy`
- `stockholm/js/browser_native_bridge.js`**new file** — implements
`window.Native` shim that calls the bridge endpoints
- `stockholm/js/main.js` — wires up the browser native bridge on load
- `stockholm/setup/index.html` — same charset/viewport fix
- `stockholm/setup/js/app_comm.js` — same AppComm bridge rewrite for the setup flow
**v2** (incremental fixes on top of v1):
- `stockholm/js/app_comm.js` — additional fixes and multi-tab `clientId` support
- `stockholm/js/browser_native_bridge.js` — minor fix
- `stockholm/js/main.js` — minor fix
- `stockholm/js/marge_comm.js` — fixes marge URL handling
- `stockholm/js/presets.js` — minor fix
- `stockholm/js/sources.js` — minor fix
- `stockholm/setup/js/app_comm.js` — same fixes as main app_comm.js