Compare commits

...
113 Commits
Author SHA1 Message Date
Tobias GesellchenandClaude Sonnet 4.6 3be654da17 chore(build): add -trimpath to GitHub workflow build commands
Consistent with the Makefile which already applies -trimpath globally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:55:14 +02:00
Tobias GesellchenandClaude Sonnet 4.6 3891c08dd1 docs(migration): add Docker Compose quickstart with .env config guidance
Adds a "Docker Compose (recommended for home servers and VMs)" section
to Step 1, pointing users to the existing docker-compose.yml and
.env.example. Clarifies the purpose of docker-compose.ci.yml (CI tests
only) and docker-compose.override.yml (local modifications, not in VCS).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:36:34 +02:00
Tobias GesellchenandClaude Sonnet 4.6 f6e733de6b fix(certmanager): use hostname as server cert CN instead of a random Bose domain
domains[0] was non-deterministic (Go map iteration) and could resolve to
any domain in the list including Bose-owned domains. Adds CommonName field
to CertificateManager, defaulting to "localhost", set to the device hostname
at startup. All Bose domains remain in the SAN where clients actually look.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:15:48 +02:00
Tobias GesellchenandClaude Sonnet 4.6 84585b8034 perf(service): move TLS cert generation off the startup path
On constrained hardware (e.g. ARMv7), RSA key generation can block
startup for minutes. HTTP now starts immediately; HTTPS is brought up
in a background goroutine once cert generation completes. A log message
informs the user that HTTPS will be available shortly after startup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:11:41 +02:00
Tobias GesellchenandClaude Sonnet 4.6 ac44fdace6 perf(certmanager): reduce CA key size from RSA-4096 to RSA-2048
RSA-4096 CA generation blocks service startup for minutes on slow ARM
hardware. The CA key is only used to sign server certs, never in TLS
handshakes, so 2048 bits provides sufficient security for a local CA
while being ~4-8x faster to generate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:11:41 +02:00
Tobias GesellchenandClaude Sonnet 4.6 06042f8728 chore(build): add ARMv7 target and apply trimpath/-s/-w flags globally
Adds build-linux-armv7 target (GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0)
for deployment to old embedded Linux devices (kernel 3.14+). Introduces
BUILDFLAGS=-trimpath -ldflags="-s -w" applied to all build targets for
smaller, reproducible binaries without local path leakage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:11:41 +02:00
Tobias GesellchenandClaude Sonnet 4.6 ca19bb32f7 feat(setup): harden hostname resolution before migration (#204)
- resolveIP now returns (string, error): error when result did not come
  from the device's own SSH ping (service-side fallback or total
failure)
- migrateViaResolvConf and parseTargetURLAndResolveIP abort on error,
  preventing a bad IP from being written to the device
- GetMigrationSummary captures the error in ResolveIPError and falls
back
  to the hostname for the preview display; XML migration is unaffected
- Web UI shows a warning box with the error and a docs link when
resolution
  is uncertain; migrate button stays enabled for the XML method
- Add hostname resolution troubleshooting section to TROUBLESHOOTING.md

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 20:00:24 +02:00
Tobias GesellchenandClaude Sonnet 4.6 c931510384 feat(setup): add 'original' option and harden backup before migration (#202)
- Rename proxy option values: 'upstream' → 'proxied', 'official' →
'original'
- Add 'original' option to preserve current device URL as-is per field
- Drop proxyURL guard in applyProxyOptions so 'original' works without a
proxy
- Abort migration if on-device backup cannot be created (was
warning-only)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 18:32:27 +02:00
Tobias GesellchenandClaude Sonnet 4.6 3a1a93333e chore(compose): slim down base config, move test concerns to ci overlay (#203)
- Move spotify-mock and amazon-mock services to docker-compose.ci.yml
- Move soundtouch-test-net network definition to docker-compose.ci.yml
- Pin image version via SOUNDTOUCH_VERSION env var (defaults to
'latest')
- Document SOUNDTOUCH_VERSION in .env.example

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 18:30:25 +02:00
Tobias GesellchenandClaude Sonnet 4.6 fb0465bf5f docs: add UI screenshots to migration guide and device setup (#159)
Copy 5 screenshots from _/screenshots/ into docs/images/ and wire them
into the migration guide (Settings, Devices, Sync, Migration tabs) and
the device initial setup guide (speaker AP mode Wi-Fi page). Replace the
images README wishlist with a table of what is actually present.

Also correct the AP mode IP address (192.0.2.1, verified on ST10) and
update the Settings step to match actual UI labels (Target Domain, DNS
Bind Address).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 22:58:29 +02:00
Tobias GesellchenandClaude Sonnet 4.6 624da2c2b8 docs: rewrite migration guide, fix broken images (#159)
Replace the placeholder MIGRATION-GUIDE.md (which had a "planned to be"
header, a nonexistent install.sh reference, and 9 broken screenshot links)
with a complete, image-free step-by-step walkthrough covering all 6 steps:
install, configure URL, enable SSH via USB stick, discover/sync, migrate
(XML or DNS/DHCP), and verify.

Add the Migration Guide to the README docs section and link to it from
the Survival Guide.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 22:58:29 +02:00
Tobias GesellchenandClaude Sonnet 4.6 6b90d2c994 docs: rewrite README and survival guide for post-shutdown user journey
Rewrite README.md to be concise and tool-focused (no code snippets),
clearly presenting all five tools and their use cases. Expand the
soundtouch-service section to cover both user scenarios and redirect
method trade-offs.

Rewrite SURVIVAL-GUIDE.md around the same two scenarios with step-by-step
instructions. Remove deprecated hosts-file method from all user-facing
docs; update MIGRATION-SAFETY.md, HTTPS-SETUP.md, and SOUNDTOUCH-SERVICE.md
to reflect only the two supported methods (XML redirect and DNS/DHCP).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 22:58:29 +02:00
Tobias GesellchenandClaude Sonnet 4.6 16ab9dbba1 feat(alexa): stub POST /alexa/certificate with 501 and add voice.api.bose.io to DNS (#200)
Registers HandleAlexaCertificate on POST /alexa/certificate. The handler
logs the device MAC from the request body and returns 501 Not
Implemented with a JSON error explaining that AWS IoT integration is
required to provision Alexa device certificates.

Adds voice.api.bose.io to both /etc/hosts domain lists in setup.go (DNS
intercept was already covered by the bose.io wildcard entry in dns.go).

Relates to https://github.com/gesellix/Bose-SoundTouch/discussions/84

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 21:37:30 +02:00
Tobias GesellchenandClaude Sonnet 4.6 e99c04888c feat: implement missing endpoints and serve static resources from downloads/media hosts (#199)
Endpoints:

- POST /streaming/music/musicprovider/{id}/trial/is_eligible (reuses
is_eligible handler)
- POST /bmx/tunein/v1/favorite/{stationID} with datastore persistence
(SaveTuneInFavorite)
- DELETE /bmx/tunein/v1/favorite/{stationID} (DeleteTuneInFavorite)
- POST /bmx/core02/svc-bmx-adapter-orion/prod/orion/token (anonymous
Orion token)
- GET /bmx-icons/* serving embedded static/media assets (media.bose.io)
- GET /ced/* serving embedded firmware index, release notes, and 10
app-help XMLs (downloads.bose.com)

Add media.bose.io and downloads.bose.com to DNS redirect lists (setup.go
both domain slices, dns.go shouldIntercept list, main.go getDomains
map). Document implemented endpoints in
tests/interactions_20260502_missing_external.md; mark rows 0246–0247 as
self/☑ in the interactions table.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 21:22:36 +02:00
Tobias GesellchenandClaude Sonnet 4.6 0b2e03820b docs: add CAPTURE-MIGRATION-TRAFFIC.md to SUMMARY.md
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 18:08:39 +02:00
Tobias GesellchenandClaude Sonnet 4.6 faacba5d91 docs(mitm): add .mitm to .http conversion script and document workflow
- Add scripts/convert_mitm_script.py (mitmproxy addon, converts flows to .http files)
- Gitignore scripts/android/mitm/ (converted output, derived from captures)
- Document conversion step in CAPTURE-DEVICE-PAIRING.md Phase 5
- Document conversion step in CAPTURE-MIGRATION-TRAFFIC.md Step 6.2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 18:08:39 +02:00
Tobias GesellchenandClaude Sonnet 4.6 aecd41bdfa docs(migration): add migration traffic capture runbook with session trace
- Add CAPTURE-MIGRATION-TRAFFIC.md with step-by-step migration runbook
- Include session trace from first interactive ST10 migration run
- Genericize example IP addresses in BOSE-APP-ADB-Emulator.md and CAPTURE-DEVICE-PAIRING.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 18:08:39 +02:00
Tobias GesellchenandClaude Sonnet 4.6 cf7f3431f6 feat(android): scripted MITM setup with emulator snapshot and Frida SSL unpinning
- Add scripts/android/ with setup-mitm-avd.sh (one-time) and start-mitm-session.sh (per-session)
- Move frida Dockerfile to scripts/android/; extract frida-server + SSL scripts via Docker
- Use native macOS mitmproxy app for capture (Docker NAT blocks emulator traffic)
- Add native-connect-hook.js to Frida launch — required for Bose app's native networking
- Document verified AP mode Wi-Fi provisioning endpoint (POST :8090/addWirelessProfile)
- Correct factory reset sequences for ST10/ST20 from official Bose guides
- Remove old scripts/setup-mitm-avd.sh and scripts/start-mitm-session.sh (moved to android/)
- Add session trace with lessons learned from first interactive capture run

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 18:08:39 +02:00
Tobias GesellchenandClaude Sonnet 4.6 86825c44af feat(backup): add soundtouch-backup tool for cloud and local speaker backup (#197)
Introduces a standalone `soundtouch-backup` CLI with three subcommands:
- `all`: authenticates with the Bose cloud, backs up account data, then
reads device IPs from devices.xml and backs up each reachable speaker
- `cloud`: fetches account profile, devices, sources, presets, and full
endpoint from streaming.bose.com
- `local`: backs up each speaker via HTTP API (12 endpoints) and
optionally via SSH (individual files + /opt/Bose/etc/ and
/mnt/nv/BoseApp-Persistence/1/ directories)

Also centralises pkg/service/ssh → pkg/ssh so both the service and the
backup tool share the same SSH client; adds ReadFile and ReadDir
methods, and handles the firmware quirk where cat exits 1 on empty
files.

Output is a single dated .tar.gz or .zip archive.

Example flow:

```shell
gesellix@Mac Bose-SoundTouch % go run ./cmd/soundtouch-backup all --output _/cloud-backup --email user@example.com
Password: 
Authenticating as user@example.com...
  ✓ Authenticated (account ID: 1234567)
  ✓ email address (107 bytes)
  ✓ devices (1492 bytes)
  ✓ sources (1111 bytes)
  ✓ presets (2585 bytes)
  ✓ full account (55037 bytes)
Found 2 device(s) in cloud account, attempting local backup...
  ✓ ST20: 12 files via HTTP
  ⚠ ST20: SSH skipped /etc/remote_services (Process exited with status 1)
  ⚠ ST20: SSH empty file /mnt/nv/remote_services
  ✓ ST20: 64 files via SSH
  ✓ ST10: 12 files via HTTP
  ⚠ ST10: SSH empty file /etc/remote_services
  ⚠ ST10: SSH skipped /mnt/nv/remote_services (Process exited with status 1)
  ✓ ST10: 48 files via SSH
Archive written: _/cloud-backup/soundtouch-backup-2026-05-02.tar.gz (141 files)
```

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 14:00:23 +02:00
Tobias GesellchenandClaude Sonnet 4.6 ee44526d25 docs(amazon): confirm amazon_music:access scope requires device client ID
Attempting to request amazon_music:access with a standard application
client ID (amzn1.application-oa2-client.*) returns HTTP 400
lwa-invalid-parameter-bad-scope from the LWA authorization endpoint.
The scope is gated to Amazon Music partner device client IDs.

Revert scope to "profile" (working state) and document the confirmed
blocker with the exact error. Path forward: Amazon Music partner
registration for a device client ID; one-line change to AmazonScopes
when available.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 20:30:06 +02:00
Tobias GesellchenandClaude Sonnet 4.6 50c40be763 feat(amazon): fix bridge fallback, source display name, and document streaming blocker
- Amazon bridge: fall back to sync/legacy on any error from
  SetMusicServiceOAuthAccount (not only error 1029); timeouts from
  unresponsive speakers no longer silently skip the fallback chain
- Amazon bridge: reduce speaker client timeout from 30s to 5s for
  faster failure on local network calls
- marge: resolveSourceName now prefers SourceName/DisplayName over
  SourceKeyAccount, so Amazon (and Spotify) sources show the account
  holder's name instead of the raw account ID
- docs: update amazon-music-oauth.md with real-world test results;
  music-api.amazon.com returns 401 because standard LWA apps lack
  music::* partner scopes — infrastructure is complete but streaming
  is blocked pending Amazon partner access
- docs: add SELF-HOSTING.md and MUSIC-SERVICES.md user guides; link
  both in SUMMARY.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 20:30:06 +02:00
Tobias GesellchenandClaude Sonnet 4.6 147a1a8490 feat: add Spotify and Amazon credential fields to Settings UI
- Add SpotifyClientID/Secret/RedirectURI and AmazonClientID/Secret/RedirectURI
  fields to datastore.Settings for persistent storage
- Server: add amazonClientID/Secret/RedirectURI fields, SetAmazonConfig,
  GetSpotifyConfig/GetAmazonConfig, ReinitSpotifyService/ReinitAmazonService,
  and applyMusicServiceCredentials (called under lock from HandleUpdateSettings)
- GET /setup/settings: expose credential fields; mask secrets as "***" when set
- POST /setup/settings: apply credential updates and reinitialize services live
- applyPersistedSettings: fill in music credentials from settings.json when not
  set via CLI/env (CLI takes precedence)
- Settings tab: replace read-only Spotify status with editable Client ID / Secret /
  Redirect URI inputs for both Spotify and Amazon; save via existing Save button
- script.js: populate and collect the six new fields in fetchSettings/updateSettings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 20:30:06 +02:00
Tobias GesellchenandClaude Sonnet 4.6 ca7f8d5453 feat: wire Amazon mock into http-client integration tests
- Add cmd/mock-amazon/main.go (mirrors mock-spotify, uses testutils/amazon)
- Add amazon-mock service to docker-compose.yml (port 8082)
- Add AMAZON_CLIENT_ID/SECRET/TOKEN_URL/PROFILE_URL to docker-compose.ci.yml
- Add amazon_registration.http: registers account via /mgmt/amazon/callback
  before the token-refresh test runs (mirrors spotify_registration.http)
- Update {{amazonRefreshToken}} in env to match mock response (Atzr|amazon-refresh-token)
- Log amazon-mock output on test failure in Makefile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 20:30:06 +02:00
Tobias GesellchenandClaude Sonnet 4.6 2ab98f2b6f docs: update amazon-music-oauth.md with setup guide and implementation status
- Mark status as Implemented
- Add "Trying It Out" section: LWA app setup, service flags, OAuth flow,
  account verification, speaker priming, DNS requirement, site_id open question
- Fix stale endpoint table entry (no longer a stub)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 20:30:06 +02:00
Tobias GesellchenandClaude Sonnet 4.6 70d05554a0 feat: add Amazon LWA mock server (testutils + integration mocks)
Mirror the Spotify equivalents: pkg/testutils/amazon/handlers.go provides
HandleToken and HandleProfile for use in unit tests; tests/integration/mocks/amazon.go
wraps them in an AmazonMock with TokenURL() and ProfileURL() accessors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 20:30:06 +02:00
Tobias GesellchenandClaude Sonnet 4.6 cb037df831 feat: add Amazon Music management handlers and CLI wiring
- Add HandleMgmtAmazonInit/Callback/Confirm/Accounts/Token/PrimeDeviceAmazon
- Add bridgeAmazonToMarge (AmazonSecret JSON envelope, Marge registration, speaker notification with OAuth/sync/legacy fallbacks)
- Add PrimeDeviceWithAmazon and pushAmazonTokenToDevice to Server
- Wire --amazon-client-id/secret/redirect-uri/token-url/profile-url CLI flags
- Initialize Amazon service on startup alongside Spotify
- Register /mgmt/amazon/* routes (callback unauthenticated, rest Basic Auth)
- Update router_routes.txt snapshot with 6 new Amazon routes
- Fix errchkjson lint: use typed amazon.Account in test fixtures
- Fix gocyclo lint: extract initMusicServices helper from main action

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 20:30:06 +02:00
Tobias GesellchenandClaude Sonnet 4.6 f1c2b7a53f feat: implement HandleBoseAmazonToken and wire amazonService into Server
- Add GetAccountByRefreshToken to amazon.Service — the speaker sends
  the bare Atzr| refresh token (extracted from AmazonSecret JSON), not
  a surrogate, so lookup must match against Account.RefreshToken
- Add amazonService field, SetAmazonService and IsAmazonConfigured to
  Server (step 5 essentials required by the handler)
- Replace HandleBoseAmazonToken 501 stub with full implementation:
  lookup by refresh token → RefreshAccessToken; fallback to
  GetFreshToken; fallback to HandleBoseProxy if no service configured;
  scope intentionally omitted from response
- Add handler tests covering the by-refresh-token path (mock LWA
  server), the default-account path, and the no-service fallback
- Unlock assertions in post_oauth_token_amazon.http integration test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 20:30:06 +02:00
Tobias GesellchenandClaude Sonnet 4.6 466e9eca97 feat: extract shared ZeroConf package and add Amazon Music OAuth service
- Extract DH key exchange crypto from pkg/service/spotify into new
  pkg/service/zeroconf package with exported functions and
  AuthTypeOAuthToken constant (both Spotify and Amazon use auth type 4)
- Reduce pkg/service/spotify/zeroconf.go to thin wrappers around the
  shared package; public API (PushSpotifyCredentials, ZeroConfGetInfo)
  is preserved
- Add pkg/service/amazon package mirroring the Spotify service with
  Amazon-specific differences: LWA endpoints, POST body credentials
  (not Basic Auth), user_id/name profile fields, amazon/accounts.json
- Add PushAmazonCredentials delegating to shared zeroconf.PushCredentials

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 20:30:06 +02:00
Tobias Gesellchen 406180e4ce Prepare http-client test for Amazon 2026-04-29 20:30:06 +02:00
Tobias Gesellchen 3e96e95a7d Update implementation plan/spec for Amazon Music OAuth integration 2026-04-29 20:30:06 +02:00
Tobias GesellchenandClaude Sonnet 4.6 5fbad7d315 feat: add Amazon Music source classification and fix ETag caching
- Recognize Amazon Music in learned sources (classifyAsAmazon) and
  AddSource dispatch, using CredentialTypeToken (cs1) not cs3
- Exclude Amazon from default sources: an empty-credential Amazon entry
  triggers the speaker's AmazonController to fail JSON parsing with
  MUSIC_SERVICE_ACCOUNT_LOGIN_FAILED; Amazon must only appear once a
  real OAuth token is present
- Merge missing defaults into stored sources at request time so devices
  with older Sources.xml still receive all current defaults
- Fix source providers ETag: was time.Now().UnixMilli() (always new),
  now a content hash so If-None-Match/304 works correctly
- Include default sources fingerprint in GetETagForAccount so adding a
  new default invalidates cached /full responses on speakers
- Refactor createLearnedSource into classifyLearnedSource +
  classifyAsX helpers to reduce cyclomatic complexity below linter limit
- Add regression test for two-device scenario matching production setup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 20:30:06 +02:00
Tobias Gesellchen c8f280f9d4 Add implementation plan/spec for Amazon Music OAuth integration 2026-04-29 20:30:06 +02:00
Tobias Gesellchen 1bf083ae0d Add endpoint for handling Amazon token exchange 2026-04-29 20:30:06 +02:00
Tobias Gesellchen 4f76c82f9b cleanup 2026-04-28 17:57:46 +02:00
Tobias Gesellchen c6fbc45be5 lint 2026-04-28 17:57:46 +02:00
Tobias GesellchenandClaude Sonnet 4.6 376c85a641 docs: update soundcork parity and community tools analysis
- Mark ZeroConf Spotify priming and 404 handler as addressed in both docs
- Remove stale "Remaining gaps" and "Already adopted" tracking tables from
  community-tools.md; detail now lives in PARITY-SOUNDCORK.md
- Update PARITY-SOUNDCORK.md summary to reflect Groups and ZeroConf as done;
  add cross-reference to community-tools.md
- Rename remaining "gesellix" project references to "AfterTouch" throughout
  community-tools.md (URLs and author attribution unchanged)
- Add soundcork-stockholm-app (entry 7) to community projects list
- Correct DNS priority entry: built-in DNS server requires no external tools

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 17:57:46 +02:00
Tobias GesellchenandClaude Sonnet 4.6 968312aa39 Implement Spotify Connect ZeroConf DH blob encryption (#192)
Replace the simplified tokenType=accesstoken push with the full Spotify
Connect ZeroConf protocol: GET getInfo to fetch the speaker's 768-bit DH
public key, derive AES-128-CTR + HMAC-SHA1 keys from the shared secret,
and POST an encrypted LoginCredentials protobuf blob. Speakers that
receive a proper blob can self-refresh their Spotify session
independently, eliminating the need for periodic re-priming on token
expiry. Falls back to the raw token approach automatically when getInfo
fails, preserving compatibility with older firmware.

SHA1 is mandated by the Spotify Connect ZeroConf protocol spec for DH key derivation. This cannot be changed without breaking protocol compatibility.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 15:34:05 +02:00
Tobias GesellchenandClaude Sonnet 4.6 9412b5ffa0 feat: add group CRUD endpoints (POST add, POST modify, DELETE delete) (#191)
Groups (stereo pairs of ST10 speakers) were read-only — the GET endpoint
always returned an empty <group/>. Add POST /account/{account}/group,
POST /account/{account}/group/{groupId}, and DELETE
/account/{account}/group/{groupId} with datastore persistence, matching
the API shape observed in soundcork. The GET endpoint now reads live
group state from the datastore.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 15:28:38 +02:00
Tobias GesellchenandClaude Sonnet 4.6 ff6edc5383 feat: log [UNHANDLED] for routes with no local handler (#190)
feat: log [UNHANDLED] for routes with no local handler

Every request that falls through to HandleNotFound now emits an
[UNHANDLED] METHOD path log line, making it immediately visible when a
speaker calls an endpoint we have not implemented. When proxyLogBody is
enabled the request body is also included (truncated to 512 bytes) and
restored before forwarding, so the proxy still sees the full payload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 15:28:25 +02:00
Tobias GesellchenandClaude Sonnet 4.6 a2f952495e fix: use proper URL manipulation for TuneIn render=json parameter (#189)
Naive string concatenation (`rawURL + "&render=json"`) produced
malformed URLs when the input had no query string yet, or already
contained render=json. Replace with tuneInRenderJSONURI which parses and
sets the parameter cleanly. Also fix TuneIn search query encoding in the
self link and section href, and replace the http-prefix check for OPML
URIs with a proper host comparison.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 15:24:43 +02:00
Tobias Gesellchen 29c904b7e4 The official Bose SoundTouch USB update website is not available anymore (#187)
The previous link
https://downloads.bose.com/ced/soundtouch/soundtouch_usb/index.html
responds with status code 403 and redirects to
[`/index.html`](https://downloads.bose.com/index.html), which ultimately
lands at https://www.bose.com/support/international
2026-04-25 21:35:07 +02:00
Tobias Gesellchen 4a46df1167 Make the soundtouch-web port configurable via env (#186)
See
https://github.com/gesellix/Bose-SoundTouch/issues/181#issuecomment-4313151490
2026-04-25 21:29:13 +02:00
Tobias Gesellchen cdaf9f0c0a Build and publish a soundtouch-web Docker image (#184)
Relates to https://github.com/gesellix/Bose-SoundTouch/issues/181
2026-04-23 22:23:00 +02:00
Tobias Gesellchen 174d087b8e Do not duplicate existing sources with default sources 2026-04-23 22:08:54 +02:00
Tobias Gesellchen 066e381737 Fix/beautify the account overview 2026-04-23 22:08:54 +02:00
Tobias Gesellchen 522492177d Embed web resources in soundtouch-web (#182)
Relates to https://github.com/gesellix/Bose-SoundTouch/issues/181
2026-04-23 20:51:00 +02:00
Tobias Gesellchen 885967aafc The RADIOPLAYER source is deprecated (#180)
See https://www.radioplayer.de/apps/bose.html

> Der Radioplayer in BOSE Lautsprechersystemen (ARCHIV)
>
> Bose Soundbar und Bose Soundtouch
>
> ACHTUNG: BOSE steht seit jeher für glasklaren Sound. Im Jahr 2018
wurden daher auch sämtliche Sender des Radioplayers in den SoundBar und
SoundTouch Geräten des Audio-Herstellers aus Massachussets verfügbar
gemacht. Trotz des großen Erfolges der Geräte, besondern auch in
Deutschland, hat sich BOSE jedoch dazu entschieden die Linie der
SoundTouch-Geräte nicht mehr fortzuführen. Die letzte Aktualisierung der
BOSE SoundTouch-App (in der der Radioplayer integriert war, siehe unten)
erfolgte in den App-Stores in 2021. Seither sind einige (neuere) Sender
nicht mehr wie gewohnt verfügbar. BOSE hat zudem verkündet, den Support
der SoundTouch-Geräte zum 18. Februar 2026 komplett einzustellen, was
den Zugriff auf Musikdienste wie den Radioplayer vollends beendet.
2026-04-22 18:26:14 +02:00
Tobias Gesellchen c6748eda41 Serialize all WebSocket writes (#179) 2026-04-21 21:57:19 +02:00
Tobias Gesellchen 469a91ad80 Fix logo filenames (#178) 2026-04-21 21:49:11 +02:00
Tobias Gesellchen ceb08cd6bf Fix ETag for account-level endpoints (#177) 2026-04-20 21:09:00 +02:00
Tobias Gesellchen 7a3eef110b Allow multiple sources for the same source type and different provider 2026-04-20 19:18:39 +02:00
Tobias Gesellchen 5e6885cfe8 Add missing RADIO_BROWSER default source 2026-04-20 19:18:39 +02:00
Tobias Gesellchen 747a9cec97 Add app analyzing/debugging docs and scripts (#174) 2026-04-19 22:27:54 +02:00
Tobias Gesellchen 88c83b6131 Fix security issues 2026-04-19 21:59:55 +02:00
Tobias Gesellchen 5943abfddd Add soundtouch-web release build 2026-04-19 21:59:55 +02:00
Tobias Gesellchen 56e82d5a01 Add TuneIn search/browse/playback
We might peek into https://github.com/core-hacked/tunein-api for more advanced use cases
2026-04-19 21:59:55 +02:00
Tobias Gesellchen 56256de47b lint 2026-04-19 21:59:55 +02:00
Tobias Gesellchen 5b99d7f46b Add a web-based app 2026-04-19 21:59:55 +02:00
Tobias Gesellchen d0ce48ef03 Fix a mismatch where the local service was incorrectly wrapping the single preset in a <presets> element (#172) 2026-04-18 21:49:16 +02:00
Tobias Gesellchen 9704e2d8ac Make the get_full_account test more comprehensive (#171)
https://github.com/gesellix/Bose-SoundTouch/issues/135
2026-04-17 23:15:52 +02:00
Tobias Gesellchen aa5a25b382 Enhance version-info (#170) 2026-04-17 22:16:47 +02:00
Tobias Gesellchen f14cb45680 Enhance and group device discovery settings in web UI (#169) 2026-04-17 21:51:11 +02:00
Tobias Gesellchen 1fecb3948e Refactor constants for sources and source providers (#168) 2026-04-17 19:08:50 +02:00
Tobias Gesellchen 0e2f05e6e5 Improve source sync by adding deduction of known source IDs (#167) 2026-04-17 18:50:51 +02:00
Tobias Gesellchen ffe61dd7a6 Prevent loops for proxied requests on unknown endpoints (#166)
Follow-up for https://github.com/gesellix/Bose-SoundTouch/issues/161
2026-04-17 18:20:46 +02:00
Tobias Gesellchen 76bb19ebcb Fix migration to use the correct URL format (#165)
Fixes https://github.com/gesellix/Bose-SoundTouch/issues/161
2026-04-15 19:05:07 +02:00
dependabot[bot] 13b8e7be82 ci(deps): bump softprops/action-gh-release from 2 to 3
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-15 08:43:21 +02:00
dependabot[bot] 57d020c407 ci(deps): bump the actions-core group with 2 updates
Bumps the actions-core group with 2 updates: [actions/github-script](https://github.com/actions/github-script) and [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact).


Updates `actions/github-script` from 8 to 9
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v8...v9)

Updates `actions/upload-pages-artifact` from 4 to 5
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](https://github.com/actions/upload-pages-artifact/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions-core
- dependency-name: actions/upload-pages-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions-core
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-15 08:42:47 +02:00
dependabot[bot] 4348d22c5c deps(deps): bump the golang group with 6 updates
Bumps the golang group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [golang.org/x/crypto](https://github.com/golang/crypto) | `0.49.0` | `0.50.0` |
| [golang.org/x/image](https://github.com/golang/image) | `0.38.0` | `0.39.0` |
| [golang.org/x/mod](https://github.com/golang/mod) | `0.34.0` | `0.35.0` |
| [golang.org/x/net](https://github.com/golang/net) | `0.52.0` | `0.53.0` |
| [golang.org/x/text](https://github.com/golang/text) | `0.35.0` | `0.36.0` |
| [golang.org/x/tools](https://github.com/golang/tools) | `0.43.0` | `0.44.0` |


Updates `golang.org/x/crypto` from 0.49.0 to 0.50.0
- [Commits](https://github.com/golang/crypto/compare/v0.49.0...v0.50.0)

Updates `golang.org/x/image` from 0.38.0 to 0.39.0
- [Commits](https://github.com/golang/image/compare/v0.38.0...v0.39.0)

Updates `golang.org/x/mod` from 0.34.0 to 0.35.0
- [Commits](https://github.com/golang/mod/compare/v0.34.0...v0.35.0)

Updates `golang.org/x/net` from 0.52.0 to 0.53.0
- [Commits](https://github.com/golang/net/compare/v0.52.0...v0.53.0)

Updates `golang.org/x/text` from 0.35.0 to 0.36.0
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.35.0...v0.36.0)

Updates `golang.org/x/tools` from 0.43.0 to 0.44.0
- [Release notes](https://github.com/golang/tools/releases)
- [Commits](https://github.com/golang/tools/compare/v0.43.0...v0.44.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.50.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/image
  dependency-version: 0.39.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/mod
  dependency-version: 0.35.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/net
  dependency-version: 0.53.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/text
  dependency-version: 0.36.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/tools
  dependency-version: 0.44.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 15:12:44 +02:00
Tobias Gesellchen 82fd77c8e2 Add Bose SoundTouch Web API v1.1 docs 2026-04-08 19:35:27 +02:00
dependabot[bot] 0b59e66f70 deps(deps): bump golang.org/x/sys in the golang group
Bumps the golang group with 1 update: [golang.org/x/sys](https://github.com/golang/sys).


Updates `golang.org/x/sys` from 0.42.0 to 0.43.0
- [Commits](https://github.com/golang/sys/compare/v0.42.0...v0.43.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sys
  dependency-version: 0.43.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-08 19:30:30 +02:00
Tobias Gesellchen 3678719627 Update to Golang 1.26.2 2026-04-08 19:22:24 +02:00
Tobias Gesellchen ccfd49778e Update to Golang 1.26.2 2026-04-08 19:22:24 +02:00
dependabot[bot] bdc1f71ece docker(deps): bump golang from 1.26.1-alpine to 1.26.2-alpine
Bumps golang from 1.26.1-alpine to 1.26.2-alpine.

---
updated-dependencies:
- dependency-name: golang
  dependency-version: 1.26.2-alpine
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-08 19:22:24 +02:00
Tobias Gesellchen 68f8efce4e Improve parity with upstream (#155)
See https://github.com/gesellix/Bose-SoundTouch/issues/135
2026-04-07 14:44:05 +02:00
Tobias GesellchenandJunie 276d01fe42 feat(spotify): improve Spotify registration flow and speaker notification
- Implement full SoundTouch app flow for Spotify registration in the Web UI.
- Update `/mgmt/spotify/init` to pass `accountID` via OAuth `state`.
- Add "Connect Spotify" button to Local Account tab in Web UI with polling.
- Implement legacy and Marge-sync fallbacks for speaker notifications (Error 1029).
- Add support for parsing multi-error XML responses (`<errors>`) from speakers.
- Add `NotifySourcesUpdated` to client for triggering manual source synchronization.
- Improve test coverage for error parsing and Spotify initialization handlers.

Co-authored-by: Junie <junie@jetbrains.com>
2026-04-06 22:39:30 +02:00
Tobias Gesellchen 4de7911817 Fix data race in TestSpotifyBridge 2026-04-06 21:15:15 +02:00
Tobias Gesellchen 5d933f7ebc Use a constant prefix for our internal token 2026-04-06 21:15:15 +02:00
Tobias Gesellchen 153d387aaf Fix a complete flow for Spotify registration, preset 2026-04-06 21:15:15 +02:00
Tobias Gesellchen fea6df32f3 Implement the Spotify source bridge 2026-04-06 21:15:15 +02:00
Tobias Gesellchen 3b1c639892 Completely ignore integration testdata 2026-04-06 15:22:06 +02:00
Tobias Gesellchen 740cf54b9d Cleanup Spotify tests 2026-04-06 15:22:06 +02:00
Tobias Gesellchen e5b94158e6 Use modern docker compose command syntax 2026-04-06 15:05:44 +02:00
Tobias Gesellchen c54ee79320 No need for that mock Spotify account to be version controlled 2026-04-06 15:05:44 +02:00
Tobias Gesellchen c4cf078d2a Add Spotify mock server and integration tests 2026-04-06 15:05:44 +02:00
Tobias Gesellchen 382567d67d Add .../api_versions.xml and .../musicprovider/{providerID}/is_eligible (#150) 2026-04-05 23:25:30 +02:00
Tobias Gesellchen de96b1f119 Add /streaming/account/{account}/presets/all (#149) 2026-04-05 23:10:53 +02:00
Tobias Gesellchen d22dc99c9e Add /streaming/account/{account}/devices (#148) 2026-04-05 10:16:22 +02:00
Tobias Gesellchen bd0e3d64a3 Add /streaming/account/{account}/sources (#147) 2026-04-05 01:09:02 +02:00
Tobias Gesellchen 379ac758f6 Add /bmx/tunein/v1/navigate and /bmx/tunein/v1/search (dummy) 2026-04-05 00:55:40 +02:00
Tobias Gesellchen 6d0b5f2c78 Add /bmx/registry/v1/servicesAvailability 2026-04-05 00:55:40 +02:00
Tobias Gesellchen f354c63bac Add /v1/report (#145)
https://github.com/gesellix/Bose-SoundTouch/issues/135
2026-04-04 23:23:04 +02:00
Tobias Gesellchen 50e45ab5f2 Add/improve e2e test cases (#144)
https://github.com/gesellix/Bose-SoundTouch/issues/135
2026-04-04 21:05:34 +02:00
Tobias Gesellchen c1e7d513b4 Add/improve e2e test cases 2026-04-04 18:53:59 +02:00
Tobias Gesellchen cc92430e69 Add /blacklist handler 2026-04-04 18:53:59 +02:00
Tobias Gesellchen 181cd550e3 Fix doc check 2026-04-04 18:53:59 +02:00
Tobias Gesellchen 7c92a785a4 Add/improve e2e tests (#142)
https://github.com/gesellix/Bose-SoundTouch/issues/135
2026-04-04 13:10:13 +02:00
Tobias Gesellchen b79a168084 Add/improve e2e test cases (#141)
https://github.com/gesellix/Bose-SoundTouch/issues/135
2026-04-04 12:22:58 +02:00
Tobias Gesellchen 21ce44fa2e Update the "bose-lab" runbook for app activity tracing (#140) 2026-04-03 23:50:28 +02:00
Tobias GesellchenandJunie 65f1a2565c feat: add spotify source registration and environment config for set_preset_5 integration test (#139)
Co-authored-by: Junie <junie@jetbrains.com>
2026-04-01 22:12:30 +02:00
aa7b2c28ab feat: improve Bose SoundTouch parity, Spotify integration, and data reliability (#138)
feat: improve Bose SoundTouch parity, Spotify integration, and data
reliability

- Update XML marshaling for ServicePreset and ServiceRecent to match
Bose parity requirements.
- Add support for adding music sources via
`/streaming/account/{account}/source`.
- Implement HandleBoseAccountToken for Spotify OAuth code exchange and
token persistence.
- Implement atomic file writes in the datastore to prevent data
corruption.
- Add startup logic to initialize default sources for existing devices.
- Expand test coverage with new parity regression and Spotify
integration tests.

---------

Co-authored-by: Junie <junie@jetbrains.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-04-01 21:55:52 +02:00
dependabot[bot] 8ef8d71121 ci(deps): bump actions/configure-pages in the actions-core group
Bumps the actions-core group with 1 update: [actions/configure-pages](https://github.com/actions/configure-pages).


Updates `actions/configure-pages` from 5 to 6
- [Release notes](https://github.com/actions/configure-pages/releases)
- [Commits](https://github.com/actions/configure-pages/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/configure-pages
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions-core
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-01 19:31:27 +02:00
Tobias Gesellchen c5c88f32c3 Fix internal links 2026-03-30 00:52:00 +02:00
Tobias Gesellchen 0b8f561077 Ignore tests/ in doc link check 2026-03-30 00:52:00 +02:00
Tobias Gesellchen 71e3260823 Extend TuneIn support, add e2e tests 2026-03-30 00:52:00 +02:00
Tobias Gesellchenandlnx01 bc1b70b8a5 Potential fix for code scanning alert no. 88: Uncontrolled data used in path expression
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-03-29 19:14:48 +02:00
Tobias Gesellchen a8140ad4fd Fix AddDeviceToAccount 2026-03-29 19:14:48 +02:00
Tobias Gesellchen 766671f02b Cleanup, snapshot all routes 2026-03-29 19:14:48 +02:00
Tobias Gesellchen a06657f3f5 Add more e2e tests 2026-03-29 19:14:48 +02:00
Tobias Gesellchen 505a189ce5 Simplify route config 2026-03-29 19:14:48 +02:00
Tobias Gesellchen 509f613e34 Make test less dependent on the environment 2026-03-29 19:14:48 +02:00
Tobias Gesellchen 1478d97886 Cleanup .http client tests 2026-03-29 19:14:48 +02:00
Tobias Gesellchen a40fd8cdac bump 2026-03-29 19:14:48 +02:00
233 changed files with 27359 additions and 2673 deletions
+5
View File
@@ -0,0 +1,5 @@
# Files intentionally not linked in docs/SUMMARY.md.
# Paths are relative to the docs/ directory.
# Lines starting with # and blank lines are ignored.
#analysis/bose-soundtouch-community-tools.md
+1
View File
@@ -3,6 +3,7 @@
# Docker/Service Settings
SOUNDTOUCH_HOSTNAME=soundtouch.local
SOUNDTOUCH_VERSION=latest
# Discovery Settings
DISCOVERY_TIMEOUT=5s
+15
View File
@@ -28,6 +28,21 @@
},
{
"pattern": "^\\.\\./images/(dashboard-home|account-creation|account-dashboard|usb-remote-services|device-discovery|device-registration|account-migration|migration-setup|migration-progress|migration-health|migration-complete|backup-setup)\\.png$"
},
{
"pattern": "https://www.contributor-covenant.org/version/2/0/code_of_conduct.html"
},
{
"pattern": "https://www.apkmirror.com/apk/bose-corporation/bose-soundtouch/"
},
{
"pattern": "https://apkpure.com/bose-soundtouch/com.bose.soundtouch"
},
{
"pattern": "^https://bose\\.fandom\\.com/"
},
{
"pattern": "^https://www\\.reddit\\.com/"
}
],
"replacementPatterns": [
+42 -13
View File
@@ -34,6 +34,9 @@ jobs:
restore-keys: |
${{ runner.os }}-go-
- name: Install libpcap
run: sudo apt-get install -y libpcap-dev
- name: Download dependencies
run: go mod download
@@ -70,6 +73,9 @@ jobs:
with:
go-version-file: "go.mod"
- name: Install libpcap
run: sudo apt-get install -y libpcap-dev
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v9
with:
@@ -106,7 +112,7 @@ jobs:
if [ "${{ matrix.goos }}" = "windows" ]; then
output_name="${output_name}.exe"
fi
go build -o "$output_name" ./cmd/soundtouch-cli
go build -trimpath -ldflags="-s -w" -o "$output_name" ./cmd/soundtouch-cli
- name: Upload build artifacts
uses: actions/upload-artifact@v7
@@ -127,6 +133,9 @@ jobs:
with:
go-version-file: "go.mod"
- name: Install libpcap
run: sudo apt-get install -y libpcap-dev
- name: Run basic vulnerability check
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
@@ -147,11 +156,9 @@ jobs:
uses: actions/checkout@v6
- name: Check documentation links
uses: gaurav-nelson/github-action-markdown-link-check@v1
with:
use-quiet-mode: "yes"
use-verbose-mode: "yes"
config-file: ".github/markdown-link-check.json"
run: |
npm install -g markdown-link-check
find . -name "*.md" -not -path "./tests/*" -not -path "./node_modules/*" -print0 | xargs -0 -n1 markdown-link-check -q -v -c .github/markdown-link-check.json
- name: Warn on pending images
run: |
@@ -213,7 +220,7 @@ jobs:
- name: Test CLI build and help
run: |
go build -o soundtouch-cli ./cmd/soundtouch-cli
go build -trimpath -ldflags="-s -w" -o soundtouch-cli ./cmd/soundtouch-cli
./soundtouch-cli -help
- name: Test library imports
@@ -272,8 +279,8 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
- name: Extract metadata (tags, labels) for soundtouch-service
id: meta-service
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository }}
@@ -281,14 +288,36 @@ jobs:
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
type=ref,event=pr
- name: Build and push Docker image
- name: Build and push soundtouch-service Docker image
uses: docker/build-push-action@v7
with:
context: .
target: soundtouch-service
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ steps.meta-service.outputs.tags }}
labels: ${{ steps.meta-service.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Extract metadata (tags, labels) for soundtouch-web
id: meta-web
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository }}-web
tags: |
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
type=ref,event=pr
- name: Build and push soundtouch-web Docker image
uses: docker/build-push-action@v7
with:
context: .
target: soundtouch-web
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
tags: ${{ steps.meta-web.outputs.tags }}
labels: ${{ steps.meta-web.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -326,7 +355,7 @@ jobs:
- name: Update commit status
if: always()
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
try {
+2 -2
View File
@@ -22,14 +22,14 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Pages
uses: actions/configure-pages@v5
uses: actions/configure-pages@v6
- name: Build with Jekyll
uses: actions/jekyll-build-pages@v1
with:
source: 'docs/'
destination: '_site'
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
uses: actions/upload-pages-artifact@v5
with:
path: '_site'
- name: Deploy to GitHub Pages
+68 -13
View File
@@ -68,6 +68,9 @@ jobs:
with:
go-version-file: ${{ env.GO_VERSION_FILE }}
- name: Install libpcap
run: sudo apt-get install -y libpcap-dev
- name: Run tests before release
run: |
echo "Running final tests before release..."
@@ -148,6 +151,7 @@ jobs:
rm -f "$OUTPUT_NAME" "$OUTPUT_NAME.sha256" "$OUTPUT_NAME.sha512"
if ! go build \
-trimpath \
-ldflags="-s -w" \
-o "$OUTPUT_NAME" \
"$CMD_PATH"; then
@@ -165,12 +169,20 @@ jobs:
# Build Service
build_binary "soundtouch-service" "./cmd/soundtouch-service"
# Build Web
build_binary "soundtouch-web" "./cmd/soundtouch-web"
# Build Backup
build_binary "soundtouch-backup" "./cmd/soundtouch-backup"
id: build
- name: Generate individual checksums
run: |
CLI_NAME="${{ steps.build.outputs.soundtouch-cli }}"
SVC_NAME="${{ steps.build.outputs.soundtouch-service }}"
WEB_NAME="${{ steps.build.outputs.soundtouch-web }}"
BCK_NAME="${{ steps.build.outputs.soundtouch-backup }}"
# Use atomic operations to avoid conflicts
TEMP_DIR=$(mktemp -d)
@@ -186,6 +198,8 @@ jobs:
generate_checksums "$CLI_NAME"
generate_checksums "$SVC_NAME"
generate_checksums "$WEB_NAME"
generate_checksums "$BCK_NAME"
# Cleanup
rm -rf "$TEMP_DIR"
@@ -198,6 +212,8 @@ jobs:
path: |
build/soundtouch-cli-v*
build/soundtouch-service-v*
build/soundtouch-web-v*
build/soundtouch-backup-v*
retention-days: 1
checksums:
@@ -224,7 +240,7 @@ jobs:
mkdir -p release-files
# Move all files from subdirectories to the collection directory
find . -mindepth 2 -type f \( -name "soundtouch-cli-*" -o -name "soundtouch-service-*" \) -exec mv {} release-files/ \;
find . -mindepth 2 -type f \( -name "soundtouch-cli-*" -o -name "soundtouch-service-*" -o -name "soundtouch-web-*" -o -name "soundtouch-backup-*" \) -exec mv {} release-files/ \;
# Remove empty directories
find . -type d -empty -delete
@@ -239,14 +255,14 @@ jobs:
# Generate combined checksums (exclude individual .sha256/.sha512 files)
if ls soundtouch-* 1> /dev/null 2>&1; then
# Only checksum the actual binaries, not the .sha256/.sha512 files
ls soundtouch-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha256sum > checksums.sha256
ls soundtouch-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha512sum > checksums.sha512
ls soundtouch-cli-* soundtouch-service-* soundtouch-web-* soundtouch-backup-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha256sum > checksums.sha256
ls soundtouch-cli-* soundtouch-service-* soundtouch-web-* soundtouch-backup-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha512sum > checksums.sha512
echo "📋 Generated combined checksums:"
cat checksums.sha256
# Verify all expected files are present (binaries only, not checksum files)
EXPECTED_COUNT=14 # 7 platforms * 2 binaries
EXPECTED_COUNT=28 # 7 platforms * 4 binaries
ACTUAL_COUNT=$(ls soundtouch-* | grep -v '\.sha256$' | grep -v '\.sha512$' | wc -l)
if [[ $ACTUAL_COUNT -ne $EXPECTED_COUNT ]]; then
@@ -377,6 +393,18 @@ jobs:
./soundtouch-service
\`\`\`
### SoundTouch Web
\`\`\`bash
# Start the web app
./soundtouch-web
\`\`\`
### SoundTouch Backup
\`\`\`bash
# Back up cloud account and all paired speakers in one go
./soundtouch-backup all
\`\`\`
## 🧪 Tested Hardware
- Bose SoundTouch 10
@@ -395,7 +423,7 @@ jobs:
- Windows (amd64)
- FreeBSD (amd64)
Both `soundtouch-cli` and `soundtouch-service` are included.
`soundtouch-cli`, `soundtouch-service`, `soundtouch-web`, and `soundtouch-backup` are included.
## 🔐 Checksums
@@ -440,7 +468,7 @@ jobs:
echo "release_notes_file=release_notes.md" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ github.event.inputs.tag }}
name: "Bose SoundTouch Go Library ${{ github.event.inputs.tag }}"
@@ -450,6 +478,8 @@ jobs:
files: |
release-assets/soundtouch-cli-v*
release-assets/soundtouch-service-v*
release-assets/soundtouch-web-v*
release-assets/soundtouch-backup-v*
release-assets/checksums.sha256
release-assets/checksums.sha512
fail_on_unmatched_files: true
@@ -470,12 +500,14 @@ jobs:
path: ./release-assets
- name: Upload additional assets to existing release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ github.event.release.tag_name }}
files: |
release-assets/soundtouch-cli-v*
release-assets/soundtouch-service-v*
release-assets/soundtouch-web-v*
release-assets/soundtouch-backup-v*
release-assets/checksums.sha256
release-assets/checksums.sha512
fail_on_unmatched_files: true
@@ -501,8 +533,8 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
- name: Extract metadata (tags, labels) for soundtouch-service
id: meta-service
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository }}
@@ -511,14 +543,37 @@ jobs:
type=semver,pattern={{major}}.{{minor}},value=v${{ needs.validate.outputs.version }}
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
- name: Build and push Docker image
- name: Build and push soundtouch-service Docker image
uses: docker/build-push-action@v7
with:
context: .
target: soundtouch-service
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ steps.meta-service.outputs.tags }}
labels: ${{ steps.meta-service.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Extract metadata (tags, labels) for soundtouch-web
id: meta-web
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository }}-web
tags: |
type=semver,pattern={{version}},value=v${{ needs.validate.outputs.version }}
type=semver,pattern={{major}}.{{minor}},value=v${{ needs.validate.outputs.version }}
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
- name: Build and push soundtouch-web Docker image
uses: docker/build-push-action@v7
with:
context: .
target: soundtouch-web
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
push: true
tags: ${{ steps.meta-web.outputs.tags }}
labels: ${{ steps.meta-web.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -532,7 +587,7 @@ jobs:
- name: Notify success
run: |
echo "🎉 Release ${{ needs.validate.outputs.version }} completed successfully!"
echo "📦 Binaries built for 7 platforms (CLI and Service)"
echo "📦 Binaries built for 7 platforms (CLI, Service, Web, and Backup)"
echo "🐳 Docker image published to ghcr.io"
echo "🔐 Checksums generated and verified"
echo "📋 Release notes automatically generated"
+9
View File
@@ -26,6 +26,9 @@ jobs:
with:
go-version-file: "go.mod"
- name: Install libpcap
run: sudo apt-get install -y libpcap-dev
- name: Install security scanning tools
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
@@ -67,6 +70,9 @@ jobs:
with:
go-version-file: "go.mod"
- name: Install libpcap
run: sudo apt-get install -y libpcap-dev
- name: Install static analysis tools
run: |
go install honnef.co/go/tools/cmd/staticcheck@latest
@@ -106,6 +112,9 @@ jobs:
- name: Checkout code
uses: actions/checkout@v6
- name: Install libpcap
run: sudo apt-get install -y libpcap-dev
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
+11
View File
@@ -12,8 +12,10 @@ dist/
#example-upnp
# Root-level binary executables (exclude built binaries in root)
/soundtouch-backup
/soundtouch-cli
/soundtouch-service
/soundtouch-web
/example-mdns
/example-upnp
/example-unified
@@ -56,6 +58,15 @@ vendor/
ehthumbs.db
Thumbs.db
# Android MITM setup — downloaded/generated artefacts, not committed
scripts/android/bose.apk
scripts/android/frida-server
scripts/android/frida-server.xz
scripts/android/frida/
scripts/android/frida-venv/
scripts/android/captures/
scripts/android/mitm/
# Temporary files
*.tmp
*.temp
+25 -9
View File
@@ -1,5 +1,5 @@
# Build stage
FROM --platform=$BUILDPLATFORM golang:1.26.1-alpine AS builder
FROM --platform=$BUILDPLATFORM golang:1.26.2-alpine AS builder
# Declare automatic platform ARGs to make them available in build stage
# See https://docs.docker.com/reference/dockerfile#automatic-platform-args-in-the-global-scope
@@ -24,31 +24,47 @@ RUN if [ "${TARGETARCH}" = "arm" ] && [ -n "${TARGETVARIANT}" ]; then \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /soundtouch-service ./cmd/soundtouch-service; \
fi
# Final stage
FROM alpine:3.23
# Build the soundtouch-web
RUN if [ "${TARGETARCH}" = "arm" ] && [ -n "${TARGETVARIANT}" ]; then \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} GOARM=${TARGETVARIANT#v} go build -o /soundtouch-web ./cmd/soundtouch-web; \
else \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /soundtouch-web ./cmd/soundtouch-web; \
fi
# soundtouch-service image
FROM alpine:3.23 AS soundtouch-service
# Install necessary runtime dependencies
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
# Copy the binary from the builder stage
COPY --from=builder /soundtouch-service /app/soundtouch-service
# Verify the binary works on the target platform
RUN /app/soundtouch-service version || echo "Binary verification complete"
# Create data directory for persistence
RUN mkdir -p /app/data
# Set environment variables with defaults
ENV PORT=8000
ENV DATA_DIR=/app/data
ENV LOG_PROXY_BODY=false
ENV REDACT_PROXY_LOGS=true
# Expose the service port
EXPOSE 8000
# Run the service
ENTRYPOINT ["/app/soundtouch-service"]
# soundtouch-web image
FROM alpine:3.23 AS soundtouch-web
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /soundtouch-web /app/soundtouch-web
ENV PORT=8080
EXPOSE 8080
ENTRYPOINT ["/app/soundtouch-web"]
+135 -46
View File
@@ -14,6 +14,8 @@ BINARY_NAME=soundtouch-cli
BINARY_PATH=./cmd/$(BINARY_NAME)
SERVICE_NAME=soundtouch-service
SERVICE_PATH=./cmd/$(SERVICE_NAME)
WEB_NAME=soundtouch-web
WEB_PATH=./cmd/$(WEB_NAME)
EXAMPLE_MDNS_NAME=example-mdns
EXAMPLE_MDNS_PATH=./cmd/$(EXAMPLE_MDNS_NAME)
EXAMPLE_UPNP_NAME=example-upnp
@@ -22,76 +24,99 @@ SCANNER_NAME=mdns-scanner
SCANNER_PATH=./cmd/$(SCANNER_NAME)
FAVICON_GEN_NAME=favicon-gen
FAVICON_GEN_PATH=./cmd/$(FAVICON_GEN_NAME)
BACKUP_NAME=soundtouch-backup
BACKUP_PATH=./cmd/$(BACKUP_NAME)
BUILD_DIR=./build
# Version info
# No ldflags needed - using debug.BuildInfo since Go 1.18
# Build flags: strip debug info/DWARF for smaller binaries, remove local paths for reproducibility
BUILDFLAGS=-trimpath -ldflags="-s -w"
all: check build
build: build-cli build-service build-examples build-favicon-gen
build: build-cli build-service build-web build-examples build-favicon-gen build-backup
build-cli:
@echo "Building $(BINARY_NAME)..."
@mkdir -p $(BUILD_DIR)
$(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME) $(BINARY_PATH)
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) $(BINARY_PATH)
build-service:
@echo "Building $(SERVICE_NAME)..."
@mkdir -p $(BUILD_DIR)
$(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME) $(SERVICE_PATH)
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME) $(SERVICE_PATH)
build-web:
@echo "Building $(WEB_NAME)..."
@mkdir -p $(BUILD_DIR)
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(WEB_NAME) $(WEB_PATH)
build-examples:
@echo "Building $(EXAMPLE_MDNS_NAME)..."
@mkdir -p $(BUILD_DIR)
$(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME) $(EXAMPLE_MDNS_PATH)
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME) $(EXAMPLE_MDNS_PATH)
@echo "Building $(EXAMPLE_UPNP_NAME)..."
$(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME) $(EXAMPLE_UPNP_PATH)
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME) $(EXAMPLE_UPNP_PATH)
@echo "Building $(SCANNER_NAME)..."
$(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME) $(SCANNER_PATH)
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME) $(SCANNER_PATH)
build-favicon-gen:
@echo "Building $(FAVICON_GEN_NAME)..."
@mkdir -p $(BUILD_DIR)
$(GOBUILD) -o $(BUILD_DIR)/$(FAVICON_GEN_NAME) $(FAVICON_GEN_PATH)
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(FAVICON_GEN_NAME) $(FAVICON_GEN_PATH)
build-all: build-linux build-darwin build-windows build-examples-all
build-backup:
@echo "Building $(BACKUP_NAME)..."
@mkdir -p $(BUILD_DIR)
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME) $(BACKUP_PATH)
build-all: build-linux build-linux-armv7 build-darwin build-windows build-examples-all
build-linux:
@echo "Building for Linux..."
@mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 $(BINARY_PATH)
GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME)-linux-amd64 $(SERVICE_PATH)
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 $(BINARY_PATH)
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-linux-amd64 $(SERVICE_PATH)
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME)-linux-amd64 $(BACKUP_PATH)
build-linux-armv7:
@echo "Building for Linux ARMv7 (CGO_ENABLED=0 for kernel 3.14+ compatibility)..."
@mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-linux-armv7 $(SERVICE_PATH)
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 $(BINARY_PATH)
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME)-linux-armv7 $(BACKUP_PATH)
build-darwin:
@echo "Building for macOS..."
@mkdir -p $(BUILD_DIR)
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 $(BINARY_PATH)
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 $(BINARY_PATH)
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME)-darwin-amd64 $(SERVICE_PATH)
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME)-darwin-arm64 $(SERVICE_PATH)
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 $(BINARY_PATH)
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 $(BINARY_PATH)
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-darwin-amd64 $(SERVICE_PATH)
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-darwin-arm64 $(SERVICE_PATH)
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME)-darwin-amd64 $(BACKUP_PATH)
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME)-darwin-arm64 $(BACKUP_PATH)
build-windows:
@echo "Building for Windows..."
@mkdir -p $(BUILD_DIR)
GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe $(BINARY_PATH)
GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME)-windows-amd64.exe $(SERVICE_PATH)
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe $(BINARY_PATH)
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-windows-amd64.exe $(SERVICE_PATH)
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME)-windows-amd64.exe $(BACKUP_PATH)
build-examples-all:
@echo "Building examples for all platforms..."
@mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-linux-amd64 $(EXAMPLE_MDNS_PATH)
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-darwin-amd64 $(EXAMPLE_MDNS_PATH)
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-darwin-arm64 $(EXAMPLE_MDNS_PATH)
GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-windows-amd64.exe $(EXAMPLE_MDNS_PATH)
GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-linux-amd64 $(EXAMPLE_UPNP_PATH)
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-darwin-amd64 $(EXAMPLE_UPNP_PATH)
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-darwin-arm64 $(EXAMPLE_UPNP_PATH)
GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-windows-amd64.exe $(EXAMPLE_UPNP_PATH)
GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME)-linux-amd64 $(SCANNER_PATH)
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME)-darwin-amd64 $(SCANNER_PATH)
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME)-darwin-arm64 $(SCANNER_PATH)
GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME)-windows-amd64.exe $(SCANNER_PATH)
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-linux-amd64 $(EXAMPLE_MDNS_PATH)
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-darwin-amd64 $(EXAMPLE_MDNS_PATH)
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-darwin-arm64 $(EXAMPLE_MDNS_PATH)
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-windows-amd64.exe $(EXAMPLE_MDNS_PATH)
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-linux-amd64 $(EXAMPLE_UPNP_PATH)
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-darwin-amd64 $(EXAMPLE_UPNP_PATH)
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-darwin-arm64 $(EXAMPLE_UPNP_PATH)
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-windows-amd64.exe $(EXAMPLE_UPNP_PATH)
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME)-linux-amd64 $(SCANNER_PATH)
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME)-darwin-amd64 $(SCANNER_PATH)
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME)-darwin-arm64 $(SCANNER_PATH)
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME)-windows-amd64.exe $(SCANNER_PATH)
test:
@echo "Running tests..."
@@ -106,33 +131,52 @@ test-coverage:
check: fmt vet test test-http-client
test-http-client:
@echo "Running HTTP client integration tests..."
@docker network create soundtouch-test-net || true
@docker build -t soundtouch-service-test .
@docker run -d --name soundtouch-service --network soundtouch-test-net \
-e PORT=8000 \
soundtouch-service-test
@echo "Waiting for service to start..."
@sleep 5
@echo "Starting services with docker compose..."
@docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d --build
@echo "Waiting for services to start..."
@sleep 10
@echo "Running .http tests..."
@docker run --rm --network soundtouch-test-net \
-v $(PWD)/tests/integration/http-client:/workdir \
-v "$(PWD)/tests/integration/http-client:/workdir" \
jetbrains/intellij-http-client:2026.1 \
--env-file /workdir/http-client.env.json \
--env ci \
/workdir/spotify_registration.http \
/workdir/amazon_registration.http \
/workdir/create_account.http \
/workdir/register_device.http \
/workdir/spotify_full_flow.http \
/workdir/customer_support.http \
/workdir/power_on.http \
/workdir/get_bmx_services.http \
/workdir/get_sourceproviders.http \
/workdir/get_software_update.http \
/workdir/get_soundtouch_updates.http \
/workdir/get_streaming_token.http \
/workdir/post_oauth_token.http \
/workdir/post_oauth_token_amazon.http \
/workdir/get_provider_settings.http \
/workdir/tunein_playback_station.http \
/workdir/set_preset_6.http \
/workdir/get_presets.http \
/workdir/delete_preset_6.http \
/workdir/set_preset_5.http \
/workdir/post_recent.http \
/workdir/get_recents.http \
/workdir/get_account_presets.http \
/workdir/get_account_devices.http \
/workdir/get_account_sources.http \
/workdir/get_api_versions.http \
/workdir/post_musicprovider_is_eligible.http \
/workdir/get_full_account.http \
/workdir/get_group.http \
/workdir/unregister_device.http \
--report; \
EXIT_CODE=$$?; \
docker logs soundtouch-service; \
docker stop soundtouch-service; \
docker rm soundtouch-service; \
docker rmi soundtouch-service-test; \
docker network rm soundtouch-test-net; \
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs soundtouch-service; \
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs spotify-mock; \
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs amazon-mock; \
docker compose -f docker-compose.yml -f docker-compose.ci.yml down; \
exit $$EXIT_CODE
fmt:
@@ -224,10 +268,44 @@ dev-scan-http: build-examples
@echo "Scanning for HTTP mDNS services..."
$(BUILD_DIR)/$(SCANNER_NAME) -service _http._tcp -v
install: build-cli build-service
dev-web: build-web
@echo "Starting web UI (default port 8080)..."
cd cmd/soundtouch-web && ../../$(BUILD_DIR)/$(WEB_NAME)
dev-web-port: build-web
@echo "Starting web UI on custom port..."
@if [ -z "$(PORT)" ]; then \
echo "Usage: make dev-web-port PORT=8888"; \
exit 1; \
fi
cd cmd/soundtouch-web && ../../$(BUILD_DIR)/$(WEB_NAME) -port $(PORT)
dev-backup: build-backup
@echo "Running backup tool..."
$(BUILD_DIR)/$(BACKUP_NAME) --help
dev-backup-cloud: build-backup
@echo "Running cloud backup..."
$(BUILD_DIR)/$(BACKUP_NAME) cloud
dev-backup-local: build-backup
@echo "Running local backup (auto-discover)..."
$(BUILD_DIR)/$(BACKUP_NAME) local --discover
dev-web-host: build-web
@echo "Starting web UI with specific host..."
@if [ -z "$(HOST)" ]; then \
echo "Usage: make dev-web-host HOST=192.168.1.10"; \
exit 1; \
fi
cd cmd/soundtouch-web && ../../$(BUILD_DIR)/$(WEB_NAME) -host $(HOST)
install: build-cli build-service build-web build-backup
@echo "Installing binaries to $(GOPATH)/bin..."
cp $(BUILD_DIR)/$(BINARY_NAME) $(GOPATH)/bin/
cp $(BUILD_DIR)/$(SERVICE_NAME) $(GOPATH)/bin/
cp $(BUILD_DIR)/$(WEB_NAME) $(GOPATH)/bin/
cp $(BUILD_DIR)/$(BACKUP_NAME) $(GOPATH)/bin/
clean:
@echo "Cleaning..."
@@ -247,7 +325,7 @@ release: clean check build-all
docker-build:
@echo "Building Docker image..."
docker build -t soundtouch-service .
docker build --target soundtouch-service -t soundtouch-service .
docker-run-host:
@echo "Running Docker container..."
@@ -263,9 +341,11 @@ help:
@echo " build - Build the CLI tool, service, and examples"
@echo " build-cli - Build only the CLI tool"
@echo " build-service - Build only the service"
@echo " build-backup - Build only the backup tool"
@echo " build-favicon-gen - Build the favicon generator"
@echo " build-examples - Build only the example programs"
@echo " build-all - Build for all platforms"
@echo " build-linux-armv7 - Build for Linux ARMv7 (kernel 3.14+ compatible, CGO_ENABLED=0)"
@echo " test - Run tests"
@echo " test-coverage - Run tests with coverage report"
@echo " check - Run fmt, vet, and tests"
@@ -287,6 +367,12 @@ help:
@echo " dev-scan-all - Scan all mDNS services on network"
@echo " dev-scan-soundtouch - Scan specifically for SoundTouch mDNS services"
@echo " dev-scan-http - Scan for HTTP mDNS services"
@echo " dev-backup - Build and show backup tool help"
@echo " dev-backup-cloud - Build and run cloud backup (prompts for credentials)"
@echo " dev-backup-local - Build and run local backup (auto-discover speakers)"
@echo " dev-web - Build and run web UI (default port 8080)"
@echo " dev-web-port - Build and run web UI on custom port (PORT=8888)"
@echo " dev-web-host - Build and run web UI with specific device (HOST=ip)"
@echo " install - Install binaries to GOPATH/bin"
@echo " clean - Clean build artifacts"
@echo " release - Create release binaries"
@@ -308,5 +394,8 @@ help:
@echo " make dev-upnp-timeout TIMEOUT=10s"
@echo " make dev-scan-all"
@echo " make dev-scan-soundtouch"
@echo " make dev-web"
@echo " make dev-web-port PORT=8888"
@echo " make dev-web-host HOST=192.168.1.10"
@echo " make test"
@echo " make build-all"
+97 -519
View File
@@ -1,549 +1,127 @@
# Bose SoundTouch Toolkit
A comprehensive solution for controlling and preserving Bose SoundTouch devices, including a Go library, CLI tool, and a local service for cloud emulation.
[![Go Reference](https://pkg.go.dev/badge/github.com/gesellix/bose-soundtouch.svg)](https://pkg.go.dev/github.com/gesellix/bose-soundtouch)
[![Go Report Card](https://goreportcard.com/badge/github.com/gesellix/bose-soundtouch)](https://goreportcard.com/report/github.com/gesellix/bose-soundtouch)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
> **Note**: This is an independent project based on the [official Bose SoundTouch Web API documentation](https://assets.bosecreative.com/m/496577402d128874/original/SoundTouch-Web-API.pdf). Not affiliated with or endorsed by Bose Corporation.
> Independent project. Not affiliated with or endorsed by Bose Corporation.
## Features
## Context: Cloud Shutdown
-**Complete API Coverage**: All available SoundTouch Web API endpoints implemented
- 🎵 **Media Control**: Play, pause, stop, volume, bass, balance, source selection
- 🔔 **Smart Notifications**: TTS messages, URL audio content, notification beeps (ST-10)
- 🏠 **Multiroom Support**: Create and manage zones across multiple speakers
-**Real-time Events**: WebSocket connection for live device state monitoring
- 🔍 **Device Discovery**: Automatic discovery via UPnP/SSDP and mDNS
- 📻 **Content Navigation**: Browse and search TuneIn, Pandora, Spotify, local music
- 📻 **Custom Radio**: Play any stream URL via [flexible proxying](docs/guides/CLI-REFERENCE.md#custom-radio-selection-via-soundtouch-service)
- 📻 **RadioBrowser**: Access thousands of internet radio stations via [radio-browser.info](docs/reference/radio-browser.md)
- 🎙️ **Station Management**: Add and play radio stations without presets
- 🖥️ **CLI Tool**: Comprehensive command-line interface
- 🌐 **SoundTouch Service**: Emulate Bose cloud services for offline device operation
- 🔧 **Service Migration**: Migrate devices to use local services instead of Bose cloud (XML, Hosts, or DNS redirection)
- 🔍 **DNS Discovery & Interception**: Dynamic DNS server for intercepting and logging Bose service queries (requires port 53)
- 📊 **DNS Discovery Analysis**: Track and deduplicate all device DNS queries to discover hidden hostnames
- 📊 **Traffic Analysis**: Proxy and log device communications
- 📝 **HTTP Recording**: Persist interactions as re-playable `.http` files
- 🔄 **Endpoint Mirroring**: Asynchronously mirror local requests to Bose cloud for parity testing
- ⚖️ **Parity Logging**: Detect and record discrepancies between local and official Bose responses
- 🧹 **Session Management**: Manage and cleanup recorded interaction sessions
- 🔒 **Production Ready**: Extensive testing with real SoundTouch hardware
- 🌐 **Cross-Platform**: Windows, macOS, Linux support
Bose is shutting down SoundTouch cloud services on **May 6, 2026**. After that, music service browsing, preset sync, and the official SoundTouch app stop working. This toolkit lets you keep your speakers fully functional.
## Quick Start
See the [Survival Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SURVIVAL-GUIDE.html) for the full picture.
### Installation
---
## Tools
### soundtouch-service — AfterTouch
A local server that replaces the Bose cloud ("AfterTouch"). Once your speaker is redirected to it, you have full control without any Bose cloud dependency. The built-in web UI at `http://localhost:8000` handles all setup — no config files needed to get started.
**Two scenarios:**
**Before shutdown — migrate your existing setup**
While the Bose cloud is still running, use `soundtouch-backup` to save your account data. The local service web UI then helps with the migration so your speaker keeps its presets and credentials.
**After shutdown or factory reset — start fresh**
Create a local account, configure your speakers, and start using them immediately. No Bose infrastructure required.
**Redirecting your speaker**
The service needs a stable address on your local network (e.g. `soundtouch.fritz.box` or `soundtouch.local`). The speaker must then be redirected to resolve the Bose cloud hostnames to that address. Two supported methods:
| Method | How it works | Notes |
|--------------|-------------------------------------|--------------------------------------------------------------|
| XML redirect | Upload a config XML via the Web API | Surgical; covers only registered endpoints; best for testing |
| DNS/DHCP | Serve custom DNS on your network | Covers all devices at once; requires port 53 and TLS |
The web UI walks you through each method. DNS redirect requires HTTPS — the service manages its own CA certificate and the web UI guides you through trusting it on each speaker.
> **Note:** A hosts-file method (direct SSH edits to `/etc/hosts`) also exists in the codebase but is deprecated and not exposed in the web UI.
**Enabling SSH via USB stick**
Some setup steps require SSH access to the speaker. Enable it once per device: create a file named `remote_services` on a FAT-formatted USB drive (the drive may need its bootable flag set — see [SoundCork issue #172](https://github.com/deborahgu/soundcork/issues/172)), and insert it while the speaker is powered on. After reboot, root SSH is available with no password.
See [Device Initial Setup](https://gesellix.github.io/Bose-SoundTouch/guides/DEVICE-INITIAL-SETUP.html) and [Migration Guide](https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-GUIDE.html) for step-by-step instructions.
---
### soundtouch-backup
Backs up your Bose cloud account (presets, paired devices, music sources) and each speaker's local state before the shutdown. Run `soundtouch-backup all` to capture everything in one step; it authenticates with the Bose cloud, then polls each paired speaker over the local network.
See the [soundtouch-backup README](cmd/soundtouch-backup/README.md) for usage.
---
### soundtouch-cli
Command-line control of any SoundTouch device: play/pause/volume, presets, source selection, multiroom zones, device discovery, and more. Works entirely over the local network — no cloud dependency. Well-suited for scripting and home automation.
See the [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html) for full usage.
---
### soundtouch-web
A standalone web UI for device control — play, pause, volume, preset selection, real-time status — served from a local Go binary. Complements `soundtouch-service` when you want a dedicated device-control interface separate from the setup/admin UI.
See the [soundtouch-web README](cmd/soundtouch-web/README.md) for usage.
---
### Go library
`pkg/client` provides a Go API for all SoundTouch device endpoints: media control, volume, presets, sources, zones, real-time WebSocket events, and device discovery. Use it to build your own integrations.
#### Install CLI and Service Tools
```bash
go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-cli@latest
go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-service@latest
```
#### Add Library to Your Project
```bash
go get github.com/gesellix/bose-soundtouch
```
### CLI Usage
See the [API Reference](https://gesellix.github.io/Bose-SoundTouch/reference/API-ENDPOINTS.html) and [pkg.go.dev](https://pkg.go.dev/github.com/gesellix/bose-soundtouch) for documentation.
Find SoundTouch devices on your network:
```bash
soundtouch-cli discover devices
```
Control a device (replace `192.168.1.100` with your speaker's IP):
```bash
# Basic information
soundtouch-cli --host 192.168.1.100 info
# Media controls
soundtouch-cli --host 192.168.1.100 play start
soundtouch-cli --host 192.168.1.100 volume set --level 50
# Preset management
soundtouch-cli --host 192.168.1.100 preset list
```
For full CLI documentation, see the [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html).
### SoundTouch Service (Cloud Shutdown Protection)
The `soundtouch-service` is a local server that emulates Bose's cloud services. This is critical for keeping your speakers functional after the **Bose Cloud Shutdown in May 2026**.
#### Key Features:
- **🏠 Local Emulation**: BMX and Marge service implementation
- **🔌 Easy Setup**: Activate SSH via USB stick (`remote_services` file)
- **🔧 Device Migration**: Seamlessly transition devices to local control
- **🌐 Web Management UI**: Easy browser-based setup and management
- **💾 Persistent Data**: Store presets, recents, and sources locally
- **🔄 Endpoint Mirroring**: Asynchronously mirror local requests to Bose cloud for parity testing
- **⚖️ Parity Logging**: Detect and record discrepancies between local and official Bose responses
- **📝 HTTP Recording**: Persist all interactions as re-playable `.http` files
- **🧹 Session Management**: Manage and cleanup recorded interaction sessions
#### Quick Start:
```bash
# Start the service
soundtouch-service
```
Open `http://localhost:8000` in your browser to manage your devices. Documentation is also available directly through the web interface.
For a comprehensive guide on transitioning your system, see the [Bose Cloud Shutdown: Survival Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SURVIVAL-GUIDE.html).
Detailed service configuration and Docker instructions can be found in [SoundTouch Service Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SOUNDTOUCH-SERVICE.html).
For professional migration tips and safety measures, see the [Migration & Safety Guide](https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-SAFETY.html).
### Library Usage
#### Basic Control
```go
package main
import (
"fmt"
"log"
"github.com/gesellix/bose-soundtouch/pkg/client"
)
func main() {
// Connect to your SoundTouch device
c := client.NewClient(&client.Config{
Host: "192.168.1.100",
Port: 8090,
})
// Get device information
info, err := c.GetDeviceInfo()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Device: %s\n", info.Name)
// Control playback
err = c.Play()
if err != nil {
log.Fatal(err)
}
// Set volume
err = c.SetVolume(50)
if err != nil {
log.Fatal(err)
}
}
```
#### Device Discovery
```go
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
)
func main() {
// Discover SoundTouch devices
service := discovery.NewService(5 * time.Second)
devices, err := service.DiscoverDevices(context.Background())
if err != nil {
log.Fatal(err)
}
for _, device := range devices {
fmt.Printf("Found: %s at %s:%d\n",
device.Name, device.Host, device.Port)
}
}
```
#### Real-time Events
```go
package main
import (
"context"
"fmt"
"log"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
func main() {
c := client.NewClient(&client.Config{
Host: "192.168.1.100",
Port: 8090,
})
// Subscribe to device events
events, err := c.SubscribeToEvents(context.Background())
if err != nil {
log.Fatal(err)
}
for event := range events {
switch e := event.(type) {
case *models.NowPlayingUpdated:
fmt.Printf("Now playing: %s by %s\n", e.Track, e.Artist)
case *models.VolumeUpdated:
fmt.Printf("Volume changed to: %d\n", e.ActualVolume)
case *models.ConnectionStateUpdated:
fmt.Printf("Connection state: %s\n", e.State)
}
}
}
```
#### Preset Management
```go
package main
import (
"fmt"
"log"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
func main() {
c := client.NewClient(&client.Config{
Host: "192.168.1.100",
Port: 8090,
})
// Get current presets
presets, err := c.GetPresets()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Found %d presets\n", len(presets.Preset))
// Store currently playing content as preset 1
err = c.StoreCurrentAsPreset(1)
if err != nil {
log.Fatal(err)
}
// Store Spotify playlist as preset 2
spotifyContent := &models.ContentItem{
Source: "SPOTIFY",
Type: "uri",
Location: "spotify:playlist:37i9dQZF1DXcBWIGoYBM5M",
SourceAccount: "your_username",
IsPresetable: true,
ItemName: "Today's Top Hits",
}
err = c.StorePreset(2, spotifyContent)
if err != nil {
log.Fatal(err)
}
// Store radio station as preset 3
radioContent := &models.ContentItem{
Source: "TUNEIN",
Type: "stationurl",
Location: "/v1/playbook/station/s33828",
IsPresetable: true,
ItemName: "K-LOVE Radio",
}
err = c.StorePreset(3, radioContent)
if err != nil {
log.Fatal(err)
}
// Select preset 1
err = c.SelectPreset(1)
if err != nil {
log.Fatal(err)
}
fmt.Println("Preset management complete!")
}
```
#### Multiroom Zones
```go
package main
import (
"log"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
func main() {
master := client.NewClient(&client.Config{
Host: "192.168.1.100", // Master speaker
Port: 8090,
})
// Create a multiroom zone
zone := &models.Zone{
Master: "192.168.1.100",
Members: []models.ZoneMember{
{IPAddress: "192.168.1.101"}, // Living room
{IPAddress: "192.168.1.102"}, // Kitchen
},
}
err := master.SetZone(zone)
if err != nil {
log.Fatal(err)
}
fmt.Println("Multiroom zone created!")
}
```
#### Speaker Notifications (ST-10 only)
```go
package main
import (
"log"
"github.com/gesellix/bose-soundtouch/pkg/client"
)
func main() {
c := client.NewClient(&client.Config{
Host: "192.168.1.100",
Port: 8090,
})
// Play Text-to-Speech message (language code "EN", "DE", etc.)
err := c.PlayTTS("Welcome home!", "your-app-key", "EN", 70)
if err != nil {
log.Fatal(err)
}
// Play audio content from URL
err = c.PlayURL(
"https://example.com/doorbell.mp3",
"your-app-key",
"Doorbell",
"Front Door",
"Visitor Alert",
80,
)
if err != nil {
log.Fatal(err)
}
// Play notification beep
err = c.PlayNotificationBeep()
if err != nil {
log.Fatal(err)
}
fmt.Println("Notifications sent!")
}
```
## Supported Devices
This library supports all Bose SoundTouch-compatible devices, including:
- SoundTouch 10, 20, 30 series
- SoundTouch Portable
- Wave SoundTouch music system
- SoundTouch-enabled Bose speakers
**Tested Hardware**:
- ✅ SoundTouch 10
- ✅ SoundTouch 20
## API Coverage
| Feature | Status | Description |
|---------|--------|-------------|
| Device Info | ✅ Complete | Device details, name, capabilities |
| Media Control | ✅ Complete | Play/pause/stop, track navigation |
| Volume & Audio | ✅ Complete | Volume, bass, balance control |
| Source Selection | ✅ Complete | Spotify, Bluetooth, AUX, etc. |
| Content Navigation | ✅ Complete | Browse music libraries, radio stations |
| Station Management | ✅ Complete | Search, add, remove stations |
| Preset Management | ✅ Complete | Store, select, remove presets |
| Real-time Events | ✅ Complete | WebSocket event streaming |
| Multiroom Zones | ✅ Complete | Zone creation and management |
| Speaker Notifications | ✅ Complete | TTS, URL audio, beep alerts (ST-10) |
| System Settings | ✅ Complete | Clock, display, network info |
| Advanced Audio | ✅ Complete | DSP controls, tone controls |
**API Limitations**: None - all documented SoundTouch Web API functionality is implemented, including endpoints discovered via the comprehensive [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API).
---
## Documentation
- 📖 [Contributing Guide](CONTRIBUTING.md) - How to contribute to the project
- 📚 [API Reference](https://gesellix.github.io/Bose-SoundTouch/reference/API-ENDPOINTS.html) - Complete endpoint documentation
- 🔧 [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html) - Command-line tool guide
- 🌐 [SoundTouch Service Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SOUNDTOUCH-SERVICE.html) - Local service setup and migration
- 🎯 [Getting Started](https://gesellix.github.io/Bose-SoundTouch/guides/GETTING-STARTED.html) - Detailed setup and usage
- 📻 [Preset Quick Start](https://gesellix.github.io/Bose-SoundTouch/PRESET-QUICKSTART.md) - Favorite content management
- 🧭 [Navigation Guide](https://gesellix.github.io/Bose-SoundTouch/NAVIGATION-GUIDE.md) - Content browsing and station management
- 📋 [Navigation API Reference](https://gesellix.github.io/Bose-SoundTouch/API-NAVIGATION-REFERENCE.md) - Navigation API documentation
- ⚙️ [Advanced Features](https://gesellix.github.io/Bose-SoundTouch/reference/SYSTEM-ENDPOINTS.html) - Advanced functionality
- 🏠 [Multiroom Setup](https://gesellix.github.io/Bose-SoundTouch/reference/ZONE-MANAGEMENT.html) - Zone configuration guide
- ⚡ [WebSocket Events](https://gesellix.github.io/Bose-SoundTouch/reference/WEBSOCKET-EVENTS.html) - Real-time event handling
- 🔔 [Speaker Notifications](https://gesellix.github.io/Bose-SoundTouch/reference/SPEAKER-ENDPOINT.html) - TTS and audio notifications guide
- 🔍 [Device Discovery](https://gesellix.github.io/Bose-SoundTouch/reference/DISCOVERY.html) - Discovery configuration
- 🛠️ [Troubleshooting](https://gesellix.github.io/Bose-SoundTouch/guides/TROUBLESHOOTING.html) - Common issues and solutions
- [Getting Started](https://gesellix.github.io/Bose-SoundTouch/guides/GETTING-STARTED.html)
- [Survival Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SURVIVAL-GUIDE.html)
- [Migration Guide](https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-GUIDE.html)
- [Device Initial Setup](https://gesellix.github.io/Bose-SoundTouch/guides/DEVICE-INITIAL-SETUP.html)
- [Migration & Safety Guide](https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-SAFETY.html)
- [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html)
- [SoundTouch Service Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SOUNDTOUCH-SERVICE.html)
- [HTTPS & CA Setup](https://gesellix.github.io/Bose-SoundTouch/guides/HTTPS-SETUP.html)
- [API Reference](https://gesellix.github.io/Bose-SoundTouch/reference/API-ENDPOINTS.html)
## Development
---
### Prerequisites
- Go 1.25.6 or later
- Optional: SoundTouch device for testing
## Related projects
### Building from Source
```bash
# Clone the repository
git clone https://github.com/gesellix/bose-soundtouch.git
cd Bose-SoundTouch
- **[SoundCork](https://github.com/deborahgu/soundcork)** (Deborah Kaplan et al.) — Python service interception; pioneered the cloud emulation approach this project builds on
- **[SoundCork Stockholm App](https://github.com/krahl/soundcork-stockholm-app)** — Companion app for SoundCork
- **[SoundTouch Plus](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus)** (Todd Lucas) — Home Assistant integration; extensive undocumented API documentation
- **[ÜberBöse API](https://github.com/julius-d/ueberboese-api)** (Julius) — API research and advanced endpoint discovery
- **[Bose SoundTouch Hook](https://github.com/CodeFinder2/bose-soundtouch-hook)** (Adrian Böckenkamp) — `LD_PRELOAD` hooking for reverse engineering device internals
# Install dependencies
go mod download
# Build CLI tool
make build
# Run tests
make test
# Install CLI locally
go install ./cmd/soundtouch-cli
```
### Contributing
We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details on:
- Setting up your development environment
- Coding guidelines and best practices
- Testing with real devices
- Submitting pull requests
## Examples
Check out the [examples/](examples/) directory for more usage patterns:
- **Basic HTTP Client**: Simple device control
- **Preset Management**: Store and manage favorite content
- **Navigation & Stations**: Browse content and manage radio stations
- **WebSocket Events**: Real-time monitoring
- **Device Discovery**: Finding devices on your network
- **Multiroom Management**: Zone operations
- **Advanced Audio**: DSP and tone controls
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## Disclaimer
This is an independent project based on the official Bose SoundTouch Web API documentation provided by Bose Corporation. It is not affiliated with, endorsed by, or supported by Bose Corporation. Use at your own risk.
SoundTouch is a trademark of Bose Corporation.
## SoundTouch End of Life Notice
**Important:** Bose has announced that [SoundTouch cloud support will end on May 6, 2026](https://www.bose.com/soundtouch-end-of-life).
**What will continue to work:**
- ✅ Local API control (this library's primary functionality)
- ✅ Bluetooth, AirPlay, Spotify Connect, and AUX streaming
- ✅ Remote control features (Play, Pause, Skip, Volume)
- ✅ Multiroom grouping
**What will stop working:**
- ❌ Cloud-based preset sync between devices and SoundTouch app
- ❌ Browsing music services directly from the SoundTouch app
- ❌ Cloud-based features and updates
**What continues to work:**
- ✅ Local preset management via this API client (store, select, remove)
- ✅ Direct content playback (stations, playlists, etc.)
This Go library will continue to work as it uses the local Web API for direct device control, which is unaffected by the cloud service discontinuation. The local preset management functionality implemented in this library (discovered through the [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)) provides an alternative to the cloud-based preset features that will be discontinued.
**Community Alternatives**: See the [Related Projects](#related-projects) section below for additional tools like SoundCork that provide cloud service alternatives and the SoundTouch Plus project that offers comprehensive Home Assistant integration.
## Related Projects & Credits
This project builds upon the excellent work of several community projects:
### SoundCork 🍾
- **Project**: [SoundCork - SoundTouch API Intercept](https://github.com/deborahgu/soundcork)
- **Authors**: Deborah Kaplan and contributors
- **Our Implementation**: The `soundtouch-service` in this project is heavily inspired by SoundCork's Python implementation. SoundCork pioneered the approach of intercepting and emulating Bose's cloud services, providing the foundation for offline SoundTouch operation.
- **Key Contributions**: Service emulation architecture, BMX/Marge endpoint discovery, device migration strategies
- **License**: MIT License
### ÜberBöse API 🎵
- **Project**: [ÜberBöse API](https://github.com/julius-d/ueberboese-api)
- **Author**: Julius
- **Our Implementation**: This project provided valuable insights into advanced SoundTouch API endpoints and helped make our implementation more complete, particularly for content navigation and advanced device features.
- **Key Contributions**: Extended API endpoint documentation, advanced feature discovery
- **License**: MIT License
### SoundTouch Plus 🏠
- **Project**: [SoundTouch Plus Home Assistant Component](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus)
- **Wiki**: [SoundTouch WebServices API Documentation](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)
- **Author**: Todd Lucas
- **Our Implementation**: The comprehensive API documentation in the SoundTouch Plus Wiki provided invaluable insights into undocumented endpoints beyond the official API, enabling our preset management and content navigation features.
- **Key Contributions**: Extensive API endpoint documentation, real-world usage patterns
- **License**: MIT License
### SoundTouch Hook 🪝
- **Project**: [Bose SoundTouch Hook](https://github.com/CodeFinder2/bose-soundtouch-hook)
- **Author**: Adrian Böckenkamp
- **Our Implementation**: This project provides a powerful framework for intercepting and hooking into internal device processes using `LD_PRELOAD`. It was instrumental in verifying internal function calls and understanding how the device validates cloud domains.
- **Key Contributions**: Reverse engineering framework, process hooking, cross-compilation toolchain
- **License**: GPL-3.0 License
### Community Ecosystem
These projects together form a comprehensive ecosystem for SoundTouch device management:
- **This Project**: Go library + CLI + service for programmatic control and offline operation
- **SoundCork**: Python-based service interception and cloud replacement
- **SoundTouch Plus**: Home Assistant integration with extensive device support
- **ÜberBöse**: API research and advanced endpoint discovery
- **SoundTouch Hook**: Advanced reverse engineering and process instrumentation
We are grateful to these projects and their maintainers for paving the way and providing the foundation that made this comprehensive Go implementation possible. The SoundTouch community's collaborative approach to reverse engineering and documentation has been invaluable.
### Contributing Back
If you discover new endpoints, features, or improvements through this library, please consider contributing back to these projects as well. The stronger our community ecosystem becomes, the better we can support SoundTouch devices beyond Bose's official support timeline.
---
## Support
- 🐛 **Bug Reports**: [Create an issue](https://github.com/gesellix/bose-soundtouch/issues/new)
- 💡 **Feature Requests**: [Start a discussion](https://github.com/gesellix/bose-soundtouch/discussions)
-**Questions**: Check [existing discussions](https://github.com/gesellix/bose-soundtouch/discussions)
- 📖 **Documentation**: [Online Documentation](https://gesellix.github.io/Bose-SoundTouch/)
- 🔍 **New Discoveries**: [Undocumented Community Features](https://gesellix.github.io/Bose-SoundTouch/UNDOCUMENTED-COMMUNITY-FEATURES.md)
- 🌐 **Upstream Analysis**: [Upstream URLs & Domains](https://gesellix.github.io/Bose-SoundTouch/analysis/UPSTREAM-URLS.html)
- 🔧 **Redirection Guide**: [Device Redirect Methods](https://gesellix.github.io/Bose-SoundTouch/analysis/DEVICE-REDIRECT-METHODS.html)
- 🐣 **Initial Setup**: [Device Initial Setup Variants](https://gesellix.github.io/Bose-SoundTouch/guides/DEVICE-INITIAL-SETUP.html)
- 📜 **Logging & Debugging**: [Device Logging Guide](https://gesellix.github.io/Bose-SoundTouch/DEVICE-LOGGING.md)
- 🔒 **HTTPS & CA Setup**: [HTTPS & Custom CA Guide](https://gesellix.github.io/Bose-SoundTouch/guides/HTTPS-SETUP.html)
- Bug reports: [GitHub Issues](https://github.com/gesellix/bose-soundtouch/issues/new)
- Questions & discussions: [GitHub Discussions](https://github.com/gesellix/bose-soundtouch/discussions)
---
**Star this project** ⭐ if you find it useful!
---
## License
MIT — see [LICENSE](LICENSE).
SoundTouch is a trademark of Bose Corporation.
+23
View File
@@ -0,0 +1,23 @@
// Package main provides a mock Amazon LWA server for testing purposes.
package main
import (
"flag"
"fmt"
"log"
"net/http"
"github.com/gesellix/bose-soundtouch/pkg/testutils/amazon"
)
func main() {
port := flag.Int("port", 8080, "Port to listen on")
flag.Parse()
log.Printf("Starting mock Amazon LWA server on port %d", *port)
if err := http.ListenAndServe(fmt.Sprintf(":%d", *port), amazon.NewAmazonHandler()); err != nil {
log.Fatal(err)
}
}
+23
View File
@@ -0,0 +1,23 @@
// Package main provides a mock Spotify server for testing purposes.
package main
import (
"flag"
"fmt"
"log"
"net/http"
"github.com/gesellix/bose-soundtouch/pkg/testutils/spotify"
)
func main() {
port := flag.Int("port", 8080, "Port to listen on")
flag.Parse()
log.Printf("Starting mock Spotify server on port %d", *port)
if err := http.ListenAndServe(fmt.Sprintf(":%d", *port), spotify.NewSpotifyHandler()); err != nil {
log.Fatal(err)
}
}
+212
View File
@@ -0,0 +1,212 @@
# soundtouch-backup
A standalone tool for backing up Bose SoundTouch data — both your **cloud account** (presets, devices, sources) and the **local filesystem** of each speaker — before the Bose cloud services shut down on May 6, 2026.
## Overview
| Subcommand | What it backs up |
|------------|----------------------------------------------------------------------------------------------------|
| `all` | Cloud account **and** all paired speakers in one step — the recommended starting point |
| `cloud` | Bose account profile, paired devices, cloud presets, music service sources |
| `local` | Speaker HTTP API data (presets, sources, volume, …) and optionally device filesystem files via SSH |
Output is a single `.tar.gz` archive (or `.zip`) with a dated root directory.
## Building
```bash
make build-backup
# binary: ./build/soundtouch-backup
```
Or install alongside the other tools:
```bash
make install
```
## Usage
### Combined backup (recommended)
The `all` command is the simplest way to capture everything: it authenticates with the Bose cloud, backs up your account data, then reads the IP addresses from `devices.xml` and backs up each reachable speaker over HTTP.
```bash
# Interactive — prompts for email and password
soundtouch-backup all
# Non-interactive
soundtouch-backup all --email you@example.com --password secret
# Include SSH filesystem backup for each speaker
soundtouch-backup all --ssh
# Environment variables
BOSE_EMAIL=you@example.com BOSE_PASSWORD=secret soundtouch-backup all --ssh
```
**Flags**
| Flag | Short | Default | Description |
|--------------|--------|---------------------------------------|--------------------------------------------------------|
| `--email` | `-e` | — | Bose account email (`$BOSE_EMAIL`) |
| `--password` | `--pw` | — | Bose account password (`$BOSE_PASSWORD`) |
| `--ssh` | | on | Also capture filesystem files via SSH for each speaker |
| `--output` | `-o` | `soundtouch-backup-YYYY-MM-DD.tar.gz` | Output archive path |
| `--format` | | `tar.gz` | Archive format: `tar.gz` or `zip` |
Speakers that are offline or unreachable at the time of backup are skipped with a `✗` warning; the cloud data is still saved.
---
### Cloud backup
Backs up data from your Bose account at `streaming.bose.com`. Credentials are prompted interactively if not supplied as flags.
```bash
# Interactive — prompts for email, masked password input
soundtouch-backup cloud
# Non-interactive
soundtouch-backup cloud --email you@example.com --password secret
# Environment variables (avoids secrets in shell history)
BOSE_EMAIL=you@example.com BOSE_PASSWORD=secret soundtouch-backup cloud
# Zip output
soundtouch-backup cloud --format zip --output my-bose-cloud.zip
```
**Flags**
| Flag | Short | Default | Description |
|--------------|--------|---------------------------------------|---------------------------------------------------|
| `--email` | `-e` | — | Bose account email (`$BOSE_EMAIL`) |
| `--password` | `--pw` | — | Bose account password (`$BOSE_PASSWORD`) |
| `--output` | `-o` | `soundtouch-backup-YYYY-MM-DD.tar.gz` | Output archive path (`$SOUNDTOUCH_BACKUP_OUTPUT`) |
| `--format` | | `tar.gz` | Archive format: `tar.gz` or `zip` |
**What gets fetched**
| File in archive | Source endpoint |
|--------------------------|---------------------------------------------------------------------------------|
| `cloud/emailaddress.xml` | `GET /streaming/account/{id}/emailaddress` |
| `cloud/devices.xml` | `GET /streaming/account/{id}/devices` |
| `cloud/sources.xml` | `GET /streaming/account/{id}/sources` |
| `cloud/presets.xml` | `GET /streaming/account/{id}/presets/all` |
| `cloud/full.xml` | `GET /streaming/account/{id}/full` (may overlap with the above; skipped if 4xx) |
---
### Local backup
Backs up each speaker over its HTTP API on port 8090. With `--ssh`, also captures key filesystem files via SSH.
```bash
# Auto-discover all speakers on the local network
soundtouch-backup local
# Specific speaker
soundtouch-backup local --host 192.168.178.28
# Multiple speakers
soundtouch-backup local --host 192.168.178.28 --host 192.168.178.35
# Include SSH filesystem backup
soundtouch-backup local --ssh
# Longer discovery window on busy networks
soundtouch-backup local --discover-timeout 10s
```
**Flags**
| Flag | Short | Default | Description |
|----------------------|-------|---------------------------------------|--------------------------------------------------|
| `--host` | `-H` | — | Speaker host/IP, repeatable (`$SOUNDTOUCH_HOST`) |
| `--port` | `-p` | `8090` | Speaker HTTP port (`$SOUNDTOUCH_PORT`) |
| `--discover` | `-d` | auto | Force mDNS/UPnP discovery |
| `--discover-timeout` | | `5s` | Discovery timeout |
| `--ssh` | | on | Also capture filesystem files via SSH |
| `--output` | `-o` | `soundtouch-backup-YYYY-MM-DD.tar.gz` | Output archive path |
| `--format` | | `tar.gz` | Archive format: `tar.gz` or `zip` |
**What gets fetched via HTTP**
| File | Device endpoint |
|---------------------|-----------------|
| `info.xml` | `/info` |
| `name.xml` | `/name` |
| `presets.xml` | `/presets` |
| `sources.xml` | `/sources` |
| `now_playing.xml` | `/now_playing` |
| `volume.xml` | `/volume` |
| `bass.xml` | `/bass` |
| `balance.xml` | `/balance` |
| `capabilities.xml` | `/capabilities` |
| `network_info.xml` | `/networkInfo` |
| `clock_display.xml` | `/clockDisplay` |
| `zone.xml` | `/getZone` |
Endpoints that return HTTP 4xx (not supported on the device model) are silently skipped.
**What gets fetched via SSH** (`--ssh`)
SSH connects as `root@<host>:22` with an empty password, which is the default for SoundTouch firmware.
Individual files:
| Remote path | Notes |
|---------------------------|--------------------------------------------|
| `/etc/hosts` | DNS redirect state |
| `/etc/resolv.conf` | DNS resolver configuration |
| `/etc/remote_services` | Service registration (post-migration only) |
| `/mnt/nv/remote_services` | Alternative location for remote services |
Directories (all regular files recursively):
| Remote path | Contents |
|----------------------------------|----------------------------------------------------------------------------|
| `/opt/Bose/etc/` | Full Bose configuration directory, including `SoundTouchSdkPrivateCfg.xml` |
| `/mnt/nv/BoseApp-Persistence/1/` | Persisted app state |
Missing files and directories are silently skipped with a `⚠` warning.
---
## Archive structure
Both subcommands write into a single dated archive:
```
soundtouch-backup-2026-05-02/
├── cloud/
│ ├── emailaddress.xml
│ ├── devices.xml
│ ├── sources.xml
│ └── presets.xml
└── local/
├── A_Sound_Machine/
│ ├── info.xml
│ ├── presets.xml
│ ├── sources.xml
│ ├── volume.xml
│ ├── …
│ └── ssh/
│ ├── etc/
│ │ ├── hosts
│ │ └── resolv.conf
│ ├── opt/Bose/etc/
│ │ └── SoundTouchSdkPrivateCfg.xml
│ └── mnt/nv/BoseApp-Persistence/1/
└── Sound_Machinechen/
└── …
```
Running `cloud` and `local` separately produces two archives. To combine them, use the same `--output` path for both invocations — each adds its own subdirectory so they won't collide (`.tar.gz` does not support appending; use `--format zip` if you need a single archive from two runs, or just keep them separate).
## See also
- [Cloud Shutdown Survival Guide](../../docs/guides/SURVIVAL-GUIDE.md) — full migration context
- [`soundtouch-cli`](../soundtouch-cli/) — live device control
- [`soundtouch-service`](../soundtouch-service/) — local cloud replacement
+119
View File
@@ -0,0 +1,119 @@
package main
import (
"encoding/xml"
"fmt"
"net/http"
"time"
"github.com/urfave/cli/v2"
)
func allCommand() *cli.Command {
return &cli.Command{
Name: "all",
Usage: "Back up cloud account then all paired speakers in one go",
Description: "Authenticates with the Bose cloud, backs up account data, then reads" +
" the device IP addresses from the cloud device list and backs up each reachable" +
" speaker over HTTP (and optionally SSH).",
Flags: append(outputFlags,
&cli.StringFlag{
Name: "email",
Aliases: []string{"e"},
Usage: "Bose account email",
EnvVars: []string{"BOSE_EMAIL"},
},
&cli.StringFlag{
Name: "password",
Aliases: []string{"pw"},
Usage: "Bose account password",
EnvVars: []string{"BOSE_PASSWORD"},
},
&cli.BoolFlag{
Name: "ssh",
Usage: "Also back up device filesystem files via SSH (root@host:22, no password required)",
Value: true,
},
),
Action: runAllBackup,
}
}
func runAllBackup(c *cli.Context) error {
doSSH := c.Bool("ssh")
output := resolveOutputPath(c.String("output"), c.String("format"))
format := c.String("format")
// 1. Cloud backup
client, err := setupCloudClient(c.String("email"), c.String("password"))
if err != nil {
return err
}
root := archiveRoot()
files := collectCloudFiles(client, root)
if len(files) == 0 {
return fmt.Errorf("no cloud data fetched")
}
// 2. Resolve speakers from devices.xml, then back each one up
devicesData := files[root+"/cloud/devices.xml"]
if devicesData == nil {
printWarn("devices.xml not available — skipping local backup")
} else {
targets := parseDevicesXML(devicesData)
if len(targets) == 0 {
printWarn("no device IP addresses found in devices.xml")
} else {
fmt.Printf("Found %d device(s) in cloud account, attempting local backup...\n", len(targets))
}
hc := &http.Client{Timeout: 10 * time.Second}
for k, v := range collectLocalFiles(hc, targets, root, doSSH) {
files[k] = v
}
}
if err := writeArchive(output, format, files); err != nil {
return fmt.Errorf("writing archive: %w", err)
}
fmt.Printf("Archive written: %s (%d files)\n", output, len(files))
return nil
}
type xmlDevice struct {
Name string `xml:"name"`
IPAddress string `xml:"ipaddress"`
}
type xmlDevices struct {
XMLName xml.Name `xml:"devices"`
Devices []xmlDevice `xml:"device"`
}
// parseDevicesXML extracts speaker targets from a devices.xml cloud response.
func parseDevicesXML(data []byte) []speakerTarget {
var d xmlDevices
if err := xml.Unmarshal(data, &d); err != nil {
return nil
}
var targets []speakerTarget
for _, dev := range d.Devices {
if dev.IPAddress == "" {
continue
}
// Pass name as a hint for error messages; backupSpeakerHTTP re-fetches
// from /info to get the current name and include info.xml in the archive.
targets = append(targets, speakerTarget{host: dev.IPAddress, port: 8090, name: dev.Name})
}
return targets
}
+252
View File
@@ -0,0 +1,252 @@
package main
import (
"bytes"
"encoding/xml"
"fmt"
"io"
"net/http"
"regexp"
"time"
"github.com/urfave/cli/v2"
)
const (
streamingBase = "https://streaming.bose.com"
streamingCT = "application/vnd.bose.streaming-v1.1+xml"
stockholmVer = "27.0.13-4277+8963611.epdbuild.develop.hepdswbld04.2025-10-02T13:17:00"
nativeFrameVer = "27.0.2 -3353+4ae7c78.epdbuild.HEAD.ssgbld02.2023-10-12T15:10Z"
protocolVer = "67"
appGUID = "b94dedd1-a61b-492b-b86b-2bc32c9261f4"
appUserAgent = "Mozilla/5.0 (Linux; Android 13; Android SDK built for arm64 Build/TE1A.220922.034; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/101.0.4951.61 Mobile Safari/537.36 Manufacturer/unknown DeviceModel/Android-SDK-built-for-arm64 SOUNDTOUCH_MOBILE_APP/" + appGUID
)
func cloudCommand() *cli.Command {
return &cli.Command{
Name: "cloud",
Usage: "Back up your Bose SoundTouch cloud account (devices, presets, sources)",
Flags: append(outputFlags,
&cli.StringFlag{
Name: "email",
Aliases: []string{"e"},
Usage: "Bose account email",
EnvVars: []string{"BOSE_EMAIL"},
},
&cli.StringFlag{
Name: "password",
Aliases: []string{"pw"},
Usage: "Bose account password",
EnvVars: []string{"BOSE_PASSWORD"},
},
),
Action: runCloudBackup,
}
}
func runCloudBackup(c *cli.Context) error {
output := resolveOutputPath(c.String("output"), c.String("format"))
format := c.String("format")
client, err := setupCloudClient(c.String("email"), c.String("password"))
if err != nil {
return err
}
root := archiveRoot()
files := collectCloudFiles(client, root)
if len(files) == 0 {
return fmt.Errorf("no data fetched")
}
if err := writeArchive(output, format, files); err != nil {
return fmt.Errorf("writing archive: %w", err)
}
fmt.Printf("Archive written: %s (%d files)\n", output, len(files))
return nil
}
// setupCloudClient prompts for missing credentials, then authenticates with the Bose cloud.
func setupCloudClient(email, password string) (*cloudClient, error) {
if email == "" || password == "" {
var err error
email, password, err = promptCredentials(email)
if err != nil {
return nil, fmt.Errorf("credentials: %w", err)
}
}
if email == "" || password == "" {
return nil, fmt.Errorf("email and password are required")
}
fmt.Printf("Authenticating as %s...\n", email)
client, err := loginToCloud(email, password)
if err != nil {
return nil, fmt.Errorf("authentication failed: %w", err)
}
printOK(fmt.Sprintf("Authenticated (account ID: %s)", client.accountID))
return client, nil
}
// collectCloudFiles fetches all cloud account data and returns a files map ready for
// archiving. Keys are prefixed with root (e.g. "soundtouch-backup-2026-05-02/cloud/").
func collectCloudFiles(client *cloudClient, root string) map[string][]byte {
type cloudEndpoint struct {
label string
filename string
fetch func(*cloudClient) ([]byte, error)
}
endpoints := []cloudEndpoint{
{"email address", "emailaddress.xml", fetchEmailAddress},
{"devices", "devices.xml", fetchDevices},
{"sources", "sources.xml", fetchSources},
{"presets", "presets.xml", fetchPresets},
{"full account", "full.xml", fetchFull},
}
files := make(map[string][]byte)
for _, ep := range endpoints {
data, err := ep.fetch(client)
if err != nil {
printFail(fmt.Sprintf("%s: %v", ep.label, err))
continue
}
files[root+"/cloud/"+ep.filename] = data
printOK(fmt.Sprintf("%s (%d bytes)", ep.label, len(data)))
}
return files
}
type cloudClient struct {
http *http.Client
accountID string
token string
}
type loginXML struct {
XMLName xml.Name `xml:"login"`
Username string `xml:"username"`
Password string `xml:"password"`
}
var accountIDRe = regexp.MustCompile(`<account\s+id="([^"]+)"`)
func loginToCloud(email, password string) (*cloudClient, error) {
loginBody, err := xml.Marshal(loginXML{Username: email, Password: password})
if err != nil {
return nil, err
}
body := []byte(`<?xml version="1.0" encoding="UTF-8"?>`)
body = append(body, loginBody...)
req, err := http.NewRequest("POST", streamingBase+"/streaming/account/login", bytes.NewReader(body))
if err != nil {
return nil, err
}
setStreamingHeaders(req, "")
hc := &http.Client{Timeout: 30 * time.Second}
resp, err := hc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
token := resp.Header.Get("credentials")
if token == "" {
return nil, fmt.Errorf("no credentials in response — check your email and password")
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return nil, err
}
m := accountIDRe.FindSubmatch(data)
if len(m) < 2 {
return nil, fmt.Errorf("could not extract account ID from login response")
}
return &cloudClient{http: hc, accountID: string(m[1]), token: token}, nil
}
func setStreamingHeaders(req *http.Request, token string) {
req.Header.Set("content-type", streamingCT)
req.Header.Set("accept", streamingCT)
req.Header.Set("clienttype", "SOUNDTOUCH_MOBILE_APP")
req.Header.Set("version_stockholmversion", stockholmVer)
req.Header.Set("version_nativeframeversion", nativeFrameVer)
req.Header.Set("version_protocolversion", protocolVer)
req.Header.Set("user-agent", appUserAgent)
req.Header.Set("guid", appGUID)
req.Header.Set("x-requested-with", "com.bose.soundtouch")
req.Header.Set("pragma", "no-cache")
req.Header.Set("cache-control", "no-cache")
if token != "" {
req.Header.Set("authorization", token)
}
}
func (c *cloudClient) get(path string) ([]byte, error) {
url := fmt.Sprintf("%s%s?_=%d", streamingBase, path, time.Now().UnixMilli())
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
setStreamingHeaders(req, c.token)
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
return io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
}
func fetchEmailAddress(c *cloudClient) ([]byte, error) {
return c.get("/streaming/account/" + c.accountID + "/emailaddress")
}
func fetchDevices(c *cloudClient) ([]byte, error) {
return c.get("/streaming/account/" + c.accountID + "/devices")
}
func fetchSources(c *cloudClient) ([]byte, error) {
return c.get("/streaming/account/" + c.accountID + "/sources")
}
func fetchPresets(c *cloudClient) ([]byte, error) {
return c.get("/streaming/account/" + c.accountID + "/presets/all")
}
func fetchFull(c *cloudClient) ([]byte, error) {
return c.get("/streaming/account/" + c.accountID + "/full")
}
+288
View File
@@ -0,0 +1,288 @@
package main
import (
"context"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
"github.com/gesellix/bose-soundtouch/pkg/config"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/gesellix/bose-soundtouch/pkg/ssh"
"github.com/urfave/cli/v2"
)
var localEndpoints = []struct {
path string
file string
}{
{"/info", "info.xml"},
{"/name", "name.xml"},
{"/presets", "presets.xml"},
{"/sources", "sources.xml"},
{"/now_playing", "now_playing.xml"},
{"/volume", "volume.xml"},
{"/bass", "bass.xml"},
{"/balance", "balance.xml"},
{"/capabilities", "capabilities.xml"},
{"/networkInfo", "network_info.xml"},
{"/clockDisplay", "clock_display.xml"},
{"/getZone", "zone.xml"},
}
// sshFiles lists individual device filesystem paths captured via SSH.
// Paths that may not exist on all devices are silently skipped.
var sshFiles = []string{
"/etc/hosts",
"/etc/resolv.conf",
"/etc/remote_services",
"/mnt/nv/remote_services",
}
// sshDirs lists device directories whose contents are recursively captured via SSH.
var sshDirs = []string{
"/opt/Bose/etc",
"/mnt/nv/BoseApp-Persistence/1",
}
func localCommand() *cli.Command {
return &cli.Command{
Name: "local",
Usage: "Back up one or more SoundTouch speakers on your local network",
Flags: append(outputFlags,
&cli.StringSliceFlag{
Name: "host",
Aliases: []string{"H"},
Usage: "Speaker host/IP (repeatable for multiple speakers)",
EnvVars: []string{"SOUNDTOUCH_HOST"},
},
&cli.IntFlag{
Name: "port",
Aliases: []string{"p"},
Usage: "Speaker HTTP port",
Value: 8090,
EnvVars: []string{"SOUNDTOUCH_PORT"},
},
&cli.BoolFlag{
Name: "discover",
Aliases: []string{"d"},
Usage: "Auto-discover speakers on the local network",
},
&cli.DurationFlag{
Name: "discover-timeout",
Usage: "Discovery timeout",
Value: 5 * time.Second,
},
&cli.BoolFlag{
Name: "ssh",
Usage: "Also back up device filesystem files via SSH (root@host:22, no password required)",
Value: true,
},
),
Action: runLocalBackup,
}
}
type speakerTarget struct {
host string
port int
name string
}
func runLocalBackup(c *cli.Context) error {
hosts := c.StringSlice("host")
port := c.Int("port")
doDiscover := c.Bool("discover") || len(hosts) == 0
discoverTimeout := c.Duration("discover-timeout")
doSSH := c.Bool("ssh")
output := resolveOutputPath(c.String("output"), c.String("format"))
format := c.String("format")
var targets []speakerTarget
if doDiscover {
fmt.Printf("Discovering speakers (timeout: %s)...\n", discoverTimeout)
ctx, cancel := context.WithTimeout(c.Context, discoverTimeout)
defer cancel()
cfg, _ := config.LoadFromEnv()
svc := discovery.NewUnifiedDiscoveryService(cfg)
found, discErr := svc.DiscoverDevices(ctx)
if discErr != nil {
printWarn(fmt.Sprintf("Discovery failed: %v", discErr))
}
for _, d := range found {
targets = append(targets, speakerTarget{host: d.Host, port: d.Port, name: d.Name})
printOK(fmt.Sprintf("Found: %s (%s:%d)", d.Name, d.Host, d.Port))
}
}
for _, h := range hosts {
targets = append(targets, speakerTarget{host: h, port: port})
}
if len(targets) == 0 {
return fmt.Errorf("no speakers found — use --host <ip> or --discover")
}
hc := &http.Client{Timeout: 10 * time.Second}
root := archiveRoot()
files := collectLocalFiles(hc, targets, root, doSSH)
if len(files) == 0 {
return fmt.Errorf("no data collected")
}
if err := writeArchive(output, format, files); err != nil {
return fmt.Errorf("writing archive: %w", err)
}
fmt.Printf("Archive written: %s (%d files)\n", output, len(files))
return nil
}
// collectLocalFiles backs up all targets over HTTP (and optionally SSH) and returns
// a files map ready for archiving. Keys are prefixed with root.
func collectLocalFiles(hc *http.Client, targets []speakerTarget, root string, doSSH bool) map[string][]byte {
files := make(map[string][]byte)
for _, t := range targets {
name, entries, err := backupSpeakerHTTP(hc, t)
if err != nil {
printFail(fmt.Sprintf("%s:%d — %v", t.host, t.port, err))
continue
}
dir := root + "/local/" + sanitizeName(name) + "/"
for filename, data := range entries {
files[dir+filename] = data
}
printOK(fmt.Sprintf("%s: %d files via HTTP", name, len(entries)))
if doSSH {
sshEntries := backupSpeakerSSH(t.host, name)
for filename, data := range sshEntries {
files[dir+filename] = data
}
if len(sshEntries) > 0 {
printOK(fmt.Sprintf("%s: %d files via SSH", name, len(sshEntries)))
}
}
}
return files
}
func backupSpeakerHTTP(hc *http.Client, t speakerTarget) (name string, files map[string][]byte, err error) {
base := fmt.Sprintf("http://%s:%d", t.host, t.port)
files = make(map[string][]byte)
name = t.name
infoFetched := false
if name == "" {
data, ferr := fetchRaw(hc, base+"/info")
if ferr != nil {
return "", nil, fmt.Errorf("cannot reach %s: %w", base, ferr)
}
files["info.xml"] = data
infoFetched = true
if extracted := xmlFirst(data, "name"); extracted != "" {
name = extracted
} else {
name = t.host
}
}
for _, ep := range localEndpoints {
if ep.path == "/info" && infoFetched {
continue
}
data, ferr := fetchRaw(hc, base+ep.path)
if ferr != nil {
printWarn(fmt.Sprintf("%s: skipped %s (%v)", name, ep.file, ferr))
continue
}
files[ep.file] = data
}
return name, files, nil
}
// backupSpeakerSSH connects to the device via SSH and reads the key filesystem paths.
// Files that don't exist on the device are silently skipped.
// Returned map keys are relative paths within the device backup directory (e.g. "ssh/etc/hosts").
func backupSpeakerSSH(host, deviceName string) map[string][]byte {
client := ssh.NewClient(host)
files := make(map[string][]byte)
for _, remotePath := range sshFiles {
data, err := client.ReadFile(remotePath)
if err != nil {
// Most missing files are expected (e.g. /etc/remote_services only exists post-migration)
printWarn(fmt.Sprintf("%s: SSH skipped %s (%v)", deviceName, remotePath, err))
continue
}
if len(data) == 0 {
printWarn(fmt.Sprintf("%s: SSH empty file %s", deviceName, remotePath))
}
files["ssh"+remotePath] = data
}
for _, remoteDir := range sshDirs {
dirFiles, err := client.ReadDir(remoteDir)
if err != nil {
printWarn(fmt.Sprintf("%s: SSH skipped dir %s (%v)", deviceName, remoteDir, err))
continue
}
for path, data := range dirFiles {
files["ssh"+path] = data
}
}
return files
}
func fetchRaw(hc *http.Client, url string) ([]byte, error) {
resp, err := hc.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
return io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
}
func xmlFirst(data []byte, field string) string {
re := regexp.MustCompile(`<` + regexp.QuoteMeta(field) + `[^>]*>([^<]+)</` + regexp.QuoteMeta(field) + `>`)
m := re.FindSubmatch(data)
if len(m) >= 2 {
return strings.TrimSpace(string(m[1]))
}
return ""
}
+172
View File
@@ -0,0 +1,172 @@
package main
import (
"archive/tar"
"archive/zip"
"bufio"
"compress/gzip"
"fmt"
"os"
"strings"
"time"
"github.com/urfave/cli/v2"
"golang.org/x/term"
)
const (
FormatTarGz = "tar.gz"
FormatZip = "zip"
)
var outputFlags = []cli.Flag{
&cli.StringFlag{
Name: "output",
Aliases: []string{"o"},
Usage: "Output archive file (default: soundtouch-backup-YYYY-MM-DD.tar.gz)",
EnvVars: []string{"SOUNDTOUCH_BACKUP_OUTPUT"},
},
&cli.StringFlag{
Name: "format",
Usage: "Archive format: tar.gz or zip",
Value: FormatTarGz,
},
}
func resolveOutputPath(output, format string) string {
date := time.Now().Format("2006-01-02")
ext := ".tar.gz"
if format == FormatZip {
ext = ".zip"
}
filename := "soundtouch-backup-" + date + ext
if output == "" {
return filename
}
if info, err := os.Stat(output); err == nil && info.IsDir() {
return output + string(os.PathSeparator) + filename
}
return output
}
func archiveRoot() string {
return "soundtouch-backup-" + time.Now().Format("2006-01-02")
}
func writeArchive(outputPath, format string, files map[string][]byte) error {
if format == FormatZip {
return writeZip(outputPath, files)
}
return writeTarGz(outputPath, files)
}
func writeTarGz(outputPath string, files map[string][]byte) error {
f, err := os.Create(outputPath)
if err != nil {
return err
}
defer f.Close()
gz := gzip.NewWriter(f)
defer gz.Close()
tw := tar.NewWriter(gz)
defer tw.Close()
now := time.Now()
for name, data := range files {
hdr := &tar.Header{
Name: name,
Mode: 0644,
Size: int64(len(data)),
ModTime: now,
Typeflag: tar.TypeReg,
}
if err := tw.WriteHeader(hdr); err != nil {
return fmt.Errorf("tar header %s: %w", name, err)
}
if _, err := tw.Write(data); err != nil {
return fmt.Errorf("tar write %s: %w", name, err)
}
}
return nil
}
func writeZip(outputPath string, files map[string][]byte) error {
f, err := os.Create(outputPath)
if err != nil {
return err
}
defer f.Close()
zw := zip.NewWriter(f)
defer zw.Close()
for name, data := range files {
w, err := zw.Create(name)
if err != nil {
return fmt.Errorf("zip entry %s: %w", name, err)
}
if _, err := w.Write(data); err != nil {
return fmt.Errorf("zip write %s: %w", name, err)
}
}
return nil
}
func promptCredentials(emailHint string) (email, password string, err error) {
r := bufio.NewReader(os.Stdin)
if emailHint != "" {
email = emailHint
} else {
fmt.Print("Bose account email: ")
email, err = r.ReadString('\n')
if err != nil {
return
}
email = strings.TrimSpace(email)
}
fmt.Print("Password: ")
raw, termErr := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if termErr != nil {
err = fmt.Errorf("reading password: %w (tip: use --password flag or BOSE_PASSWORD env var)", termErr)
return
}
password = string(raw)
return
}
func sanitizeName(name string) string {
r := strings.NewReplacer(
"/", "_", "\\", "_", ":", "_",
"*", "_", "?", "_", "\"", "_",
"<", "_", ">", "_", "|", "_",
" ", "_",
)
return r.Replace(name)
}
func printOK(msg string) { fmt.Printf(" ✓ %s\n", msg) }
func printFail(msg string) { fmt.Printf(" ✗ %s\n", msg) }
func printWarn(msg string) { fmt.Printf(" ⚠ %s\n", msg) }
+37
View File
@@ -0,0 +1,37 @@
// Package main implements the soundtouch-backup tool for backing up Bose SoundTouch
// cloud account data and local speaker filesystem files.
package main
import (
"log"
"os"
"runtime/debug"
"github.com/urfave/cli/v2"
)
var version = "dev"
func init() {
if info, ok := debug.ReadBuildInfo(); ok {
if info.Main.Version != "" && info.Main.Version != "(devel)" {
version = info.Main.Version
}
}
}
func main() {
app := &cli.App{
Name: "soundtouch-backup",
Usage: "Back up Bose SoundTouch account and speaker data",
Version: version,
Commands: []*cli.Command{
allCommand(),
cloudCommand(),
localCommand(),
},
}
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}
+18 -18
View File
@@ -37,16 +37,16 @@ func TestFetchTuneInMetadata(t *testing.T) {
if metadata == nil {
t.Fatal("fetchTuneInMetadata() returned nil metadata")
}
} else {
expectedName := "WDR 2 Rheinland"
if metadata.Name != expectedName {
t.Errorf("metadata.Name = %v, want %v", metadata.Name, expectedName)
}
expectedName := "WDR 2 Rheinland"
if metadata.Name != expectedName {
t.Errorf("metadata.Name = %v, want %v", metadata.Name, expectedName)
}
expectedArtwork := "https://cdn-radiotime-logos.tunein.com/s213886g.png"
if metadata.Artwork != expectedArtwork {
t.Errorf("metadata.Artwork = %v, want %v", metadata.Artwork, expectedArtwork)
expectedArtwork := "https://cdn-radiotime-logos.tunein.com/s213886g.png"
if metadata.Artwork != expectedArtwork {
t.Errorf("metadata.Artwork = %v, want %v", metadata.Artwork, expectedArtwork)
}
}
}
@@ -192,15 +192,15 @@ func TestFetchSpotifyMetadata(t *testing.T) {
if metadata == nil {
t.Fatal("fetchSpotifyMetadata() returned nil metadata")
}
} else {
expectedName := "Terminal Caribe - Album by Santi & Tuğçe"
if metadata.Name != expectedName {
t.Errorf("metadata.Name = %v, want %v", metadata.Name, expectedName)
}
expectedName := "Terminal Caribe - Album by Santi & Tuğçe"
if metadata.Name != expectedName {
t.Errorf("metadata.Name = %v, want %v", metadata.Name, expectedName)
}
expectedArtwork := "https://i.scdn.co/image/ab67616d0000b273f0e55478f4a15182405bcb47"
if metadata.Artwork != expectedArtwork {
t.Errorf("metadata.Artwork = %v, want %v", metadata.Artwork, expectedArtwork)
expectedArtwork := "https://i.scdn.co/image/ab67616d0000b273f0e55478f4a15182405bcb47"
if metadata.Artwork != expectedArtwork {
t.Errorf("metadata.Artwork = %v, want %v", metadata.Artwork, expectedArtwork)
}
}
}
+342 -74
View File
@@ -19,6 +19,7 @@ import (
"time"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/gesellix/bose-soundtouch/pkg/service/amazon"
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
@@ -34,10 +35,15 @@ var (
version = "dev"
commit = "unknown"
date = "unknown"
repoURL = "https://github.com/gesellix/bose-soundtouch"
)
func updateBuildInfo() {
if info, ok := debug.ReadBuildInfo(); ok {
if info.Main.Path != "" {
repoURL = "https://" + info.Main.Path
}
if info.Main.Version != "" && info.Main.Version != "(devel)" {
version = info.Main.Version
}
@@ -48,13 +54,124 @@ func updateBuildInfo() {
commit = setting.Value
case "vcs.time":
if t, err := time.Parse(time.RFC3339, setting.Value); err == nil {
date = t.Format("2006-01-02_15:04:05")
date = t.Format("2006-01-02 15:04:05")
}
}
}
}
}
func initializeDefaultSources(ds *datastore.DataStore) {
// Ensure default sources exist for all known devices on startup
allDevices, _ := ds.ListAllDevices()
for i := range allDevices {
dev := &allDevices[i]
if sources, errGet := ds.GetConfiguredSources(dev.AccountID, dev.DeviceID); errGet == nil {
log.Printf("Initializing default Sources.xml for existing device %s", dev.DeviceID)
// Find default sources and merge them if missing or outdated tokens.
// claimed tracks which stored sources have already been matched by a default,
// so two defaults with the same SourceKeyType but different SourceProviderIDs
// (e.g. INTERNET_RADIO/2 and INTERNET_RADIO/39) are treated as distinct entries.
defaults := ds.GetDefaultSources()
modified := false
claimed := make(map[int]bool)
for i := range defaults {
def := defaults[i]
foundIdx := -1
for j := range sources {
if claimed[j] || sources[j].SourceKeyType != def.SourceKeyType {
continue
}
// When both sides have a providerID, require it to match.
if def.SourceProviderID != "" && sources[j].SourceProviderID != "" && sources[j].SourceProviderID != def.SourceProviderID {
continue
}
foundIdx = j
break
}
if foundIdx >= 0 {
claimed[foundIdx] = true
if sources[foundIdx].Secret == "" && def.Secret != "" {
log.Printf("Initializing missing token for source %s on device %s", def.SourceKeyType, dev.DeviceID)
sources[foundIdx].Secret = def.Secret
sources[foundIdx].SecretType = def.SecretType
modified = true
}
} else {
log.Printf("Adding missing default source %s (providerID=%s) to device %s", def.SourceKeyType, def.SourceProviderID, dev.DeviceID)
sources = append(sources, def)
modified = true
}
}
if modified {
if errSave := ds.SaveConfiguredSources(dev.AccountID, dev.DeviceID, sources); errSave != nil {
log.Printf("Failed to save updated sources for %s: %v", dev.DeviceID, errSave)
}
}
}
}
}
func initMusicServices(config serviceConfig, server *handlers.Server) {
if config.spotifyClientID != "" {
spotifyService := spotify.NewSpotifyService(
config.spotifyClientID,
config.spotifyClientSecret,
config.spotifyRedirectURI,
config.dataDir,
)
if config.spotifyTokenURL != "" || config.spotifyAPIBase != "" {
spotifyService.SetEndpoints(config.spotifyTokenURL, config.spotifyAPIBase)
}
if err := spotifyService.Load(); err != nil {
log.Printf("[Spotify] Failed to load accounts: %v", err)
}
server.SetSpotifyService(spotifyService)
clientIDPrefix := config.spotifyClientID
if len(clientIDPrefix) > 8 {
clientIDPrefix = clientIDPrefix[:8]
}
log.Printf("Spotify service initialized (client ID: %s...)", clientIDPrefix)
}
if config.amazonClientID != "" {
amazonService := amazon.NewAmazonService(
config.amazonClientID,
config.amazonClientSecret,
config.amazonRedirectURI,
config.dataDir,
)
if config.amazonTokenURL != "" || config.amazonProfileURL != "" {
amazonService.SetEndpoints(config.amazonTokenURL, config.amazonProfileURL)
}
if err := amazonService.Load(); err != nil {
log.Printf("[Amazon] Failed to load accounts: %v", err)
}
server.SetAmazonService(amazonService)
clientIDPrefix := config.amazonClientID
if len(clientIDPrefix) > 8 {
clientIDPrefix = clientIDPrefix[:8]
}
log.Printf("Amazon Music service initialized (client ID: %s...)", clientIDPrefix)
}
}
func main() {
updateBuildInfo()
@@ -158,10 +275,44 @@ func main() {
},
&cli.StringFlag{
Name: "spotify-redirect-uri",
Usage: "Spotify OAuth redirect URI",
Value: "ueberboese-login://spotify",
Usage: "Spotify OAuth redirect URI (defaults to <server-url>/mgmt/spotify/callback)",
EnvVars: []string{"SPOTIFY_REDIRECT_URI"},
},
&cli.StringFlag{
Name: "spotify-token-url",
Usage: "Spotify OAuth token URL (for testing)",
EnvVars: []string{"SPOTIFY_TOKEN_URL"},
},
&cli.StringFlag{
Name: "spotify-api-base",
Usage: "Spotify API base URL (for testing)",
EnvVars: []string{"SPOTIFY_API_BASE"},
},
&cli.StringFlag{
Name: "amazon-client-id",
Usage: "Amazon LWA OAuth client ID",
EnvVars: []string{"AMAZON_CLIENT_ID"},
},
&cli.StringFlag{
Name: "amazon-client-secret",
Usage: "Amazon LWA OAuth client secret",
EnvVars: []string{"AMAZON_CLIENT_SECRET"},
},
&cli.StringFlag{
Name: "amazon-redirect-uri",
Usage: "Amazon LWA OAuth redirect URI (defaults to <server-url>/mgmt/amazon/callback)",
EnvVars: []string{"AMAZON_REDIRECT_URI"},
},
&cli.StringFlag{
Name: "amazon-token-url",
Usage: "Amazon LWA token URL (for testing)",
EnvVars: []string{"AMAZON_TOKEN_URL"},
},
&cli.StringFlag{
Name: "amazon-profile-url",
Usage: "Amazon LWA profile URL (for testing)",
EnvVars: []string{"AMAZON_PROFILE_URL"},
},
&cli.StringFlag{
Name: "mgmt-username",
Usage: "Management API username for HTTP Basic Auth",
@@ -236,37 +387,23 @@ func main() {
config.domains = getDomains(config.serverURL, config.httpsServerURL, hostname)
cm := initCertificateManager(config.dataDir)
cm := initCertificateManager(config.dataDir, config.hostname)
sm := setup.NewManager(config.serverURL, ds, cm)
sm.MgmtUsername = config.mgmtUsername
sm.MgmtPassword = config.mgmtPassword
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record)
sm.GetDNSRunning = server.GetDNSRunning
server.SetHTTPServerURL(config.httpsServerURL)
server.SetVersionInfo(version, commit, date)
server.SetVersionInfo(version, commit, date, repoURL)
server.SetDiscoverySettings(config.discoveryInterval, persisted.DiscoveryEnabled)
server.SetDNSSettings(persisted.DNSEnabled, strings.Join(persisted.DNSUpstream, ","), persisted.DNSBindAddr)
server.SetMirrorSettings(persisted.MirrorEnabled, persisted.MirrorEndpoints, persisted.SkipMirrorEndpoints, persisted.PreferredSource)
server.SetInternalPaths(persisted.InternalPaths)
server.SetSpotifyConfig(config.spotifyClientID, config.spotifyClientSecret, config.spotifyRedirectURI)
server.SetAmazonConfig(config.amazonClientID, config.amazonClientSecret, config.amazonRedirectURI)
server.SetMgmtConfig(config.mgmtUsername, config.mgmtPassword)
if config.spotifyClientID != "" {
spotifyService := spotify.NewSpotifyService(
config.spotifyClientID,
config.spotifyClientSecret,
config.spotifyRedirectURI,
config.dataDir,
)
server.SetSpotifyService(spotifyService)
clientIDPrefix := config.spotifyClientID
if len(clientIDPrefix) > 8 {
clientIDPrefix = clientIDPrefix[:8]
}
log.Printf("Spotify service initialized (client ID: %s...)", clientIDPrefix)
}
initMusicServices(config, server)
// Load and set initial DNS discoveries
dnsDiscoveries, err := ds.LoadDNSDiscoveries()
@@ -321,10 +458,7 @@ func main() {
server.SetRecorder(recorder)
tlsConfig, err := cm.GetServerTLSConfig(config.domains)
if err != nil {
log.Printf("Warning: Failed to setup TLS: %v", err)
}
initializeDefaultSources(ds)
startDeviceDiscovery(server)
@@ -332,9 +466,19 @@ func main() {
log.Printf("Go service starting on %s", config.serverURL)
if tlsConfig != nil {
// TLS cert generation can be slow on constrained hardware; run it in the
// background so the HTTP server is available immediately.
log.Printf("HTTPS setup running in background; %s will be available shortly", config.httpsServerURL)
go func() {
tlsConfig, err := cm.GetServerTLSConfig(config.domains)
if err != nil {
log.Printf("Warning: Failed to setup TLS: %v", err)
return
}
startHTTPSServer(config.httpsAddr, r, tlsConfig, config.httpsServerURL)
}
}()
return http.ListenAndServe(config.addr, r)
},
@@ -368,6 +512,7 @@ type serviceConfig struct {
bindAddr string
addr string
dataDir string
hostname string
serverURL string
httpsServerURL string
httpsAddr string
@@ -386,6 +531,13 @@ type serviceConfig struct {
spotifyClientID string
spotifyClientSecret string
spotifyRedirectURI string
spotifyTokenURL string
spotifyAPIBase string
amazonClientID string
amazonClientSecret string
amazonRedirectURI string
amazonTokenURL string
amazonProfileURL string
mgmtUsername string
mgmtPassword string
migrationEnabled bool
@@ -450,6 +602,13 @@ func loadConfig(c *cli.Context) serviceConfig {
spotifyClientID := c.String("spotify-client-id")
spotifyClientSecret := c.String("spotify-client-secret")
spotifyRedirectURI := c.String("spotify-redirect-uri")
spotifyTokenURL := c.String("spotify-token-url")
spotifyAPIBase := c.String("spotify-api-base")
amazonClientID := c.String("amazon-client-id")
amazonClientSecret := c.String("amazon-client-secret")
amazonRedirectURI := c.String("amazon-redirect-uri")
amazonTokenURL := c.String("amazon-token-url")
amazonProfileURL := c.String("amazon-profile-url")
mgmtUsername := c.String("mgmt-username")
mgmtPassword := c.String("mgmt-password")
mirrorEnabled := c.Bool("mirror-enabled")
@@ -465,6 +624,7 @@ func loadConfig(c *cli.Context) serviceConfig {
bindAddr: bindAddr,
addr: addr,
dataDir: dataDir,
hostname: hostname,
serverURL: serverURL,
httpsServerURL: httpsServerURL,
httpsAddr: httpsAddr,
@@ -483,6 +643,13 @@ func loadConfig(c *cli.Context) serviceConfig {
spotifyClientID: spotifyClientID,
spotifyClientSecret: spotifyClientSecret,
spotifyRedirectURI: spotifyRedirectURI,
spotifyTokenURL: spotifyTokenURL,
spotifyAPIBase: spotifyAPIBase,
amazonClientID: amazonClientID,
amazonClientSecret: amazonClientSecret,
amazonRedirectURI: amazonRedirectURI,
amazonTokenURL: amazonTokenURL,
amazonProfileURL: amazonProfileURL,
mgmtUsername: mgmtUsername,
mgmtPassword: mgmtPassword,
migrationEnabled: migrationEnabled,
@@ -508,6 +675,7 @@ func getDomains(serverURL, httpsServerURL, hostname string) []string {
"bose.io": true,
"bose-prod.apigee.net": true,
"bose-test.apigee.net": true,
"downloads.bose.com": true,
// Local service domains
setup.TestDomain: true,
hostname: true,
@@ -577,9 +745,40 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
config.preferredSource = persisted.PreferredSource
config.internalPaths = persisted.InternalPaths
// CLI/env args take precedence; only apply persisted credentials when not set via CLI.
applyPersistedMusicServiceCredentials(config, persisted)
return persisted
}
// applyPersistedMusicServiceCredentials fills in music service credentials from persisted
// settings when they have not been supplied via CLI flags or environment variables.
func applyPersistedMusicServiceCredentials(config *serviceConfig, persisted datastore.Settings) {
if config.spotifyClientID == "" {
config.spotifyClientID = persisted.SpotifyClientID
}
if config.spotifyClientSecret == "" {
config.spotifyClientSecret = persisted.SpotifyClientSecret
}
if config.spotifyRedirectURI == "" {
config.spotifyRedirectURI = persisted.SpotifyRedirectURI
}
if config.amazonClientID == "" {
config.amazonClientID = persisted.AmazonClientID
}
if config.amazonClientSecret == "" {
config.amazonClientSecret = persisted.AmazonClientSecret
}
if config.amazonRedirectURI == "" {
config.amazonRedirectURI = persisted.AmazonRedirectURI
}
}
func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datastore.Settings {
settings := datastore.Settings{
ServerURL: config.serverURL,
@@ -617,8 +816,10 @@ func initDataStore(dataDir string) *datastore.DataStore {
return ds
}
func initCertificateManager(dataDir string) *certmanager.CertificateManager {
func initCertificateManager(dataDir, hostname string) *certmanager.CertificateManager {
cm := certmanager.NewCertificateManager(filepath.Join(dataDir, "certs"))
cm.CommonName = hostname
if err := cm.EnsureCA(); err != nil {
log.Printf("Warning: Failed to ensure CA: %v", err)
}
@@ -656,77 +857,129 @@ func setupRouter(server *handlers.Server) *chi.Mux {
})
r.Get("/media/*", server.HandleMedia())
r.Get("/bmx-icons/*", server.HandleBmxIcons())
r.Get("/ced/*", server.HandleCedStatic())
r.Get("/web/*", server.HandleWeb())
r.Post("/alexa/certificate", server.HandleAlexaCertificate)
r.Get("/docs/*", server.HandleDocs)
r.Route("/bmx", func(r chi.Router) {
r.Get("/registry/v1/services", server.HandleBMXRegistry)
r.Get("/tunein/v1/playback/station/{stationID}", server.HandleTuneInPlayback)
r.Get("/tunein/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo)
r.Get("/tunein/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast)
r.Get("/registry/v1/servicesAvailability", server.HandleBMXServicesAvailability)
r.Route("/tunein", func(r chi.Router) {
r.Get("/v1/playback/station/{stationID}", server.HandleTuneInPlayback)
r.Get("/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo)
r.Get("/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast)
r.Post("/v1/token", server.HandleTuneInToken)
r.Post("/v1/report", server.HandleTuneInReport)
r.Get("/v1/navigate", server.HandleTuneInNavigate)
r.Get("/v1/navigate/*", server.HandleTuneInNavigate)
r.Get("/v1/search", server.HandleTuneInSearch)
r.Post("/v1/favorite/{stationID}", server.HandleTuneInFavorite)
r.Delete("/v1/favorite/{stationID}", server.HandleTuneInDeleteFavorite)
})
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
r.Post("/core02/svc-bmx-adapter-orion/prod/orion/token", server.HandleOrionToken)
})
// Legacy or direct domain calls without /bmx prefix
r.Get("/registry/v1/services", server.HandleBMXRegistry)
r.Get("/tunein/v1/playback/station/{stationID}", server.HandleTuneInPlayback)
r.Get("/tunein/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo)
r.Get("/tunein/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast)
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
r.Get("/custom/v1/playback/{encodedURL}", server.HandleCustomPlayback)
streamingRoutes := func(r chi.Router) {
r.Route("/streaming", func(r chi.Router) {
r.Get("/sourceproviders", server.HandleMargeSourceProviders)
r.Post("/account", server.HandleMargeCreateAccount)
r.Post("/account/login", server.HandleMargeLogin)
r.Get("/account/{account}/device/{device}/recent", server.HandleMargeRecents)
r.Post("/account/{account}/device/{device}/recent", server.HandleMargeAddRecent)
r.Get("/account/{account}/device/{device}/presets", server.HandleMargePresets)
r.Post("/account/{account}/device/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
r.Post("/support/power_on", server.HandleMargePowerOn)
r.Get("/account/{account}/provider_settings", server.HandleMargeProviderSettings)
r.Post("/account/{account}/source", server.HandleMargeAddSource)
r.Route("/account/{account}", func(r chi.Router) {
r.Get("/emailaddress", server.HandleMargeGetEmailAddress)
r.Get("/full", server.HandleMargeAccountFull)
r.Get("/sources", server.HandleMargeAccountSources)
r.Get("/devices", server.HandleMargeAccountDevices)
r.Get("/presets", server.HandleMargeAccountPresets)
r.Get("/presets/all", server.HandleMargeAccountPresets)
r.Get("/provider_settings", server.HandleMargeProviderSettings)
r.Route("/device", func(r chi.Router) {
r.Post("/", server.HandleMargeAddDevice)
r.Post("/{device}", server.HandleMargeAddDevice)
})
r.Route("/device/{device}", func(r chi.Router) {
r.Get("/presets", server.HandleMargePresets)
r.Post("/presets/{presetNumber}", server.HandleMargeUpdatePreset)
r.Put("/preset/{presetNumber}", server.HandleMargeUpdatePreset)
r.Delete("/preset/{presetNumber}", server.HandleMargeRemovePreset)
r.Get("/recent", server.HandleMargeRecents)
r.Get("/recents", server.HandleMargeRecents)
r.Post("/recent", server.HandleMargeAddRecent)
r.Get("/group", server.HandleMargeDeviceGroup)
r.Get("/group/", server.HandleMargeDeviceGroup)
r.Get("/group/server", server.HandleMargeDeviceGroupServer)
r.Get("/group/member", server.HandleMargeDeviceGroupMember)
})
r.Post("/group", server.HandleMargeAddGroup)
r.Post("/group/{groupId}", server.HandleMargeModifyGroup)
r.Delete("/group/{groupId}", server.HandleMargeDeleteGroup)
r.Delete("/device/{device}", server.HandleMargeRemoveDevice)
})
r.Get("/device/{device}/streaming_token", server.HandleMargeStreamingToken)
r.Post("/support/customersupport", server.HandleMargeCustomerSupport)
r.Get("/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
r.Get("/account/{account}/device/{device}/group", server.HandleMargeDeviceGroup)
r.Get("/account/{account}/device/{device}/group/", server.HandleMargeDeviceGroup)
r.Get("/account/{account}/device/{device}/group/server", server.HandleMargeDeviceGroupServer)
r.Get("/account/{account}/device/{device}/group/member", server.HandleMargeDeviceGroupMember)
r.Post("/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
r.Get("/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
r.Get("/account/{account}/full", server.HandleMargeAccountFull)
r.Get("/software/update/account/{account}", server.HandleMargeSoftwareUpdate)
r.Route("/support", func(r chi.Router) {
r.Post("/power_on", server.HandleMargePowerOn)
r.Post("/customersupport", server.HandleMargeCustomerSupport)
})
r.Route("/stats", func(r chi.Router) {
r.Post("/usage", server.HandleUsageStats)
r.Post("/error", server.HandleErrorStats)
})
}
accountsRoutes := func(r chi.Router) {
r.Get("/{account}/full", server.HandleMargeAccountFull)
r.Get("/{account}/devices/{device}/presets", server.HandleMargePresets)
r.Post("/{account}/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
r.Get("/{account}/devices/{device}/recents", server.HandleMargeRecents)
r.Post("/{account}/devices/{device}/recents", server.HandleMargeAddRecent)
r.Post("/{account}/devices", server.HandleMargeAddDevice)
r.Delete("/{account}/devices/{device}", server.HandleMargeRemoveDevice)
r.Get("/{account}/devices/{device}/group", server.HandleMargeDeviceGroup)
r.Get("/{account}/devices/{device}/group/", server.HandleMargeDeviceGroup)
r.Get("/{account}/devices/{device}/group/server", server.HandleMargeDeviceGroupServer)
r.Get("/{account}/devices/{device}/group/member", server.HandleMargeDeviceGroupMember)
}
r.Route("/music", func(r chi.Router) {
r.Route("/musicprovider/{providerID}", func(r chi.Router) {
r.Post("/is_eligible", server.HandleMusicProviderIsEligible)
r.Post("/trial/is_eligible", server.HandleMusicProviderIsEligible)
})
})
r.Route("/marge", func(r chi.Router) {
r.Route("/streaming", streamingRoutes)
r.Route("/accounts", accountsRoutes)
r.Get("/resources/api_versions.xml", server.HandleMargeAPIVersions)
})
r.Get("/updates/soundtouch", server.HandleMargeSoftwareUpdate)
r.Route("/accounts", func(r chi.Router) {
r.Route("/{account}", func(r chi.Router) {
r.Get("/full", server.HandleMargeAccountFull)
r.Get("/sources", server.HandleMargeAccountSources)
r.Get("/devices", server.HandleMargeAccountDevices)
r.Post("/devices", server.HandleMargeAddDevice)
r.Delete("/devices/{device}", server.HandleMargeRemoveDevice)
r.Get("/devices/{device}/group", server.HandleMargeDeviceGroup)
r.Get("/devices/{device}/group/", server.HandleMargeDeviceGroup)
r.Get("/devices/{device}/group/server", server.HandleMargeDeviceGroupServer)
r.Get("/devices/{device}/group/member", server.HandleMargeDeviceGroupMember)
r.Post("/group", server.HandleMargeAddGroup)
r.Post("/group/{groupId}", server.HandleMargeModifyGroup)
r.Delete("/group/{groupId}", server.HandleMargeDeleteGroup)
r.Get("/devices/{device}/presets", server.HandleMargePresets)
r.Get("/devices/{device}/recents", server.HandleMargeRecents)
r.Post("/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
r.Post("/devices/{device}/recents", server.HandleMargeAddRecent)
})
})
// Legacy or direct domain calls without /marge prefix
r.Route("/streaming", streamingRoutes)
r.Route("/accounts", accountsRoutes)
r.Get("/updates/soundtouch", server.HandleMargeSoftwareUpdate)
r.Route("/customer", func(r chi.Router) {
@@ -736,21 +989,28 @@ func setupRouter(server *handlers.Server) *chi.Mux {
})
r.Route("/oauth", func(r chi.Router) {
r.Post("/device/{deviceID}/music/musicprovider/{sourceID}/token/cs3", server.HandleBoseToken)
r.Post("/device/{deviceID}/music/musicprovider/{sourceID}/token", server.HandleBoseLegacyToken)
r.Post("/account/{account}/music/musicprovider/{sourceID}/token/cs", server.HandleBoseAccountToken)
r.Post("/device/{deviceID}/music/musicprovider/{sourceID}/token/cs1", server.HandleBoseToken)
r.Post("/device/{deviceID}/music/musicprovider/{sourceID}/token/cs3", server.HandleBoseToken)
r.HandleFunc("/*", server.HandleBoseProxy)
})
r.Route("/v1", func(r chi.Router) {
r.Post("/stapp/{deviceId}", server.HandleAppEvents)
r.Post("/scmudc/{deviceId}", server.HandleAppEvents)
// Return 405 Method Not Allowed as the upstream behavior also returns 405
r.Get("/blacklist/{deviceId}", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusMethodNotAllowed)
})
})
r.Route("/mgmt", func(r chi.Router) {
// Browser OAuth callback — no auth required (Spotify redirects the
// Browser OAuth callbacks — no auth required (provider redirects the
// user's browser here directly). The authorization code is single-use,
// short-lived, and useless without the client_secret.
r.Get("/spotify/callback", server.HandleMgmtSpotifyCallback)
r.Get("/amazon/callback", server.HandleMgmtAmazonCallback)
// All other management endpoints require Basic Auth.
r.Group(func(r chi.Router) {
@@ -773,6 +1033,14 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Post("/prime", server.HandleMgmtPrimeDevice)
})
r.Route("/amazon", func(r chi.Router) {
r.Post("/init", server.HandleMgmtAmazonInit)
r.Post("/confirm", server.HandleMgmtAmazonConfirm)
r.Get("/accounts", server.HandleMgmtAmazonAccounts)
r.Get("/token", server.HandleMgmtAmazonToken)
r.Post("/prime", server.HandleMgmtPrimeDeviceAmazon)
})
r.Get("/devices/{deviceId}/events", server.HandleMgmtDeviceEvents)
})
})
+104
View File
@@ -0,0 +1,104 @@
package main
import (
"fmt"
"net/http"
"os"
"reflect"
"runtime"
"sort"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
"github.com/go-chi/chi/v5"
)
func TestPrintRoutes(t *testing.T) {
// Initialize a minimal server to get the router
server := handlers.NewServer(nil, nil, "http://localhost:8000", true, true, true)
r := setupRouter(server)
var routes []string
walkFunc := func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
route = strings.ReplaceAll(route, "/*/", "/")
handlerName := runtime.FuncForPC(reflect.ValueOf(handler).Pointer()).Name()
// Clean up the handler name (remove package path)
// For example, "github.com/gesellix/bose-soundtouch/cmd/soundtouch-service.setupRouter.func1"
// or "command-line-arguments.setupRouter.func1"
// or "main.setupRouter.func1"
parts := strings.Split(handlerName, "/")
if len(parts) > 0 {
handlerName = parts[len(parts)-1]
}
// Now we might have "soundtouch-service.setupRouter.func1"
// or "command-line-arguments.setupRouter.func1"
// or "main.setupRouter.func1"
// Let's remove the first part if it's a known varying package name
if idx := strings.Index(handlerName, "setupRouter"); idx != -1 {
handlerName = handlerName[idx:]
}
// In case it's not setupRouter but still has a package prefix
for {
dotIdx := strings.Index(handlerName, ".")
if dotIdx == -1 {
break
}
prefix := handlerName[:dotIdx]
if prefix == "main" || prefix == "command-line-arguments" || strings.Contains(prefix, "soundtouch-service") {
handlerName = handlerName[dotIdx+1:]
} else {
break
}
}
// Also remove any ".funcN" suffix if it's an anonymous function
if idx := strings.Index(handlerName, ".func"); idx != -1 {
handlerName = handlerName[:idx]
}
routes = append(routes, fmt.Sprintf("%-8s %-60s %s", method, route, handlerName))
return nil
}
if err := chi.Walk(r, walkFunc); err != nil {
t.Fatalf("Failed to walk routes: %v", err)
}
sort.Strings(routes)
output := strings.Join(routes, "\n") + "\n"
// Define snapshot path
snapshotPath := "testdata/router_routes.txt"
actualPath := "testdata/router_routes.actual.txt"
// Always write the current (actual) routes to a file
if err := os.WriteFile(actualPath, []byte(output), 0644); err != nil {
t.Fatalf("Failed to write actual routes: %v", err)
}
// Check if snapshot exists
if _, err := os.Stat(snapshotPath); os.IsNotExist(err) {
// Create testdata directory if it doesn't exist
if err := os.MkdirAll("testdata", 0755); err != nil {
t.Fatalf("Failed to create testdata directory: %v", err)
}
// Initial snapshot creation
if err := os.WriteFile(snapshotPath, []byte(output), 0644); err != nil {
t.Fatalf("Failed to write snapshot: %v", err)
}
t.Logf("Initial snapshot created at %s", snapshotPath)
return
}
// Read existing snapshot
existingOutput, err := os.ReadFile(snapshotPath)
if err != nil {
t.Fatalf("Failed to read snapshot: %v", err)
}
if string(existingOutput) != output {
t.Errorf("Router routes changed! Diff the snapshot at %s with %s", snapshotPath, actualPath)
}
}
@@ -0,0 +1 @@
*.actual.txt
+154
View File
@@ -0,0 +1,154 @@
CONNECT /oauth/* handlers.(*Server).HandleBoseProxy-fm
DELETE /accounts/{account}/devices/{device} handlers.(*Server).HandleMargeRemoveDevice-fm
DELETE /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
DELETE /bmx/tunein/v1/favorite/{stationID} handlers.(*Server).HandleTuneInDeleteFavorite-fm
DELETE /oauth/* handlers.(*Server).HandleBoseProxy-fm
DELETE /setup/devices/{deviceId} handlers.(*Server).HandleRemoveDevice-fm
DELETE /setup/dns-discoveries handlers.(*Server).HandleClearDNSDiscoveries-fm
DELETE /setup/interactions/sessions handlers.(*Server).HandleCleanupSessions-fm
DELETE /setup/interactions/sessions/{session} handlers.(*Server).HandleDeleteSession-fm
DELETE /setup/parity-mismatches handlers.(*Server).HandleClearParityMismatches-fm
DELETE /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeRemovePreset-fm
DELETE /streaming/account/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
GET / handlers.(*Server).HandleRoot-fm
GET /accounts/{account}/devices handlers.(*Server).HandleMargeAccountDevices-fm
GET /accounts/{account}/devices/{device}/group handlers.(*Server).HandleMargeDeviceGroup-fm
GET /accounts/{account}/devices/{device}/group/ handlers.(*Server).HandleMargeDeviceGroup-fm
GET /accounts/{account}/devices/{device}/group/member handlers.(*Server).HandleMargeDeviceGroupMember-fm
GET /accounts/{account}/devices/{device}/group/server handlers.(*Server).HandleMargeDeviceGroupServer-fm
GET /accounts/{account}/devices/{device}/presets handlers.(*Server).HandleMargePresets-fm
GET /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleMargeRecents-fm
GET /accounts/{account}/full handlers.(*Server).HandleMargeAccountFull-fm
GET /accounts/{account}/sources handlers.(*Server).HandleMargeAccountSources-fm
GET /bmx-icons/* handlers.(*Server).HandleBmxIcons
GET /bmx/registry/v1/services handlers.(*Server).HandleBMXRegistry-fm
GET /bmx/registry/v1/servicesAvailability handlers.(*Server).HandleBMXServicesAvailability-fm
GET /bmx/tunein/v1/navigate handlers.(*Server).HandleTuneInNavigate-fm
GET /bmx/tunein/v1/navigate/* handlers.(*Server).HandleTuneInNavigate-fm
GET /bmx/tunein/v1/playback/episode/{podcastID} handlers.(*Server).HandleTuneInPlaybackPodcast-fm
GET /bmx/tunein/v1/playback/episodes/{podcastID} handlers.(*Server).HandleTuneInPodcastInfo-fm
GET /bmx/tunein/v1/playback/station/{stationID} handlers.(*Server).HandleTuneInPlayback-fm
GET /bmx/tunein/v1/search handlers.(*Server).HandleTuneInSearch-fm
GET /ced/* handlers.(*Server).HandleCedStatic
GET /custom/v1/playback/{encodedURL} handlers.(*Server).HandleCustomPlayback-fm
GET /customer/account/{account} handlers.(*Server).HandleMargeAccountProfile-fm
GET /docs/* handlers.(*Server).HandleDocs-fm
GET /favicon.ico setupRouter
GET /health handlers.(*Server).HandleHealth-fm
GET /media/* handlers.(*Server).HandleMedia
GET /mgmt/accounts/ handlers.(*Server).HandleMgmtListAccounts-fm
GET /mgmt/accounts/{accountId} handlers.(*Server).HandleMgmtAccountDetails-fm
GET /mgmt/accounts/{accountId}/speakers handlers.(*Server).HandleMgmtListSpeakers-fm
GET /mgmt/amazon/accounts handlers.(*Server).HandleMgmtAmazonAccounts-fm
GET /mgmt/amazon/callback handlers.(*Server).HandleMgmtAmazonCallback-fm
GET /mgmt/amazon/token handlers.(*Server).HandleMgmtAmazonToken-fm
GET /mgmt/devices/{deviceId}/events handlers.(*Server).HandleMgmtDeviceEvents-fm
GET /mgmt/spotify/accounts handlers.(*Server).HandleMgmtSpotifyAccounts-fm
GET /mgmt/spotify/callback handlers.(*Server).HandleMgmtSpotifyCallback-fm
GET /mgmt/spotify/token handlers.(*Server).HandleMgmtSpotifyToken-fm
GET /oauth/* handlers.(*Server).HandleBoseProxy-fm
GET /proxy/* handlers.(*Server).HandleProxyRequest-fm
GET /setup/ca.crt handlers.(*Server).HandleGetCACert-fm
GET /setup/devices handlers.(*Server).HandleListDiscoveredDevices-fm
GET /setup/devices/{deviceId}/events handlers.(*Server).HandleGetDeviceEvents-fm
GET /setup/discovery-status handlers.(*Server).HandleGetDiscoveryStatus-fm
GET /setup/dns-discoveries handlers.(*Server).HandleGetDNSDiscoveries-fm
GET /setup/dns-discoveries/download handlers.(*Server).HandleDownloadDNSDiscoveries-fm
GET /setup/info/{deviceId} handlers.(*Server).HandleGetDeviceInfo-fm
GET /setup/interaction-content handlers.(*Server).HandleGetInteractionContent-fm
GET /setup/interaction-stats handlers.(*Server).HandleGetInteractionStats-fm
GET /setup/interactions handlers.(*Server).HandleListInteractions-fm
GET /setup/interactions/sessions/{session}/download handlers.(*Server).HandleDownloadSession-fm
GET /setup/parity-mismatches handlers.(*Server).HandleListParityMismatches-fm
GET /setup/proxy-settings handlers.(*Server).HandleGetProxySettings-fm
GET /setup/settings handlers.(*Server).HandleGetSettings-fm
GET /setup/summary/{deviceId} handlers.(*Server).HandleGetMigrationSummary-fm
GET /setup/version handlers.(*Server).HandleGetVersionInfo-fm
GET /streaming/account/{account}/device/{device}/group handlers.(*Server).HandleMargeDeviceGroup-fm
GET /streaming/account/{account}/device/{device}/group/ handlers.(*Server).HandleMargeDeviceGroup-fm
GET /streaming/account/{account}/device/{device}/group/member handlers.(*Server).HandleMargeDeviceGroupMember-fm
GET /streaming/account/{account}/device/{device}/group/server handlers.(*Server).HandleMargeDeviceGroupServer-fm
GET /streaming/account/{account}/device/{device}/presets handlers.(*Server).HandleMargePresets-fm
GET /streaming/account/{account}/device/{device}/recent handlers.(*Server).HandleMargeRecents-fm
GET /streaming/account/{account}/device/{device}/recents handlers.(*Server).HandleMargeRecents-fm
GET /streaming/account/{account}/devices handlers.(*Server).HandleMargeAccountDevices-fm
GET /streaming/account/{account}/emailaddress handlers.(*Server).HandleMargeGetEmailAddress-fm
GET /streaming/account/{account}/full handlers.(*Server).HandleMargeAccountFull-fm
GET /streaming/account/{account}/presets handlers.(*Server).HandleMargeAccountPresets-fm
GET /streaming/account/{account}/presets/all handlers.(*Server).HandleMargeAccountPresets-fm
GET /streaming/account/{account}/provider_settings handlers.(*Server).HandleMargeProviderSettings-fm
GET /streaming/account/{account}/sources handlers.(*Server).HandleMargeAccountSources-fm
GET /streaming/device/{device}/streaming_token handlers.(*Server).HandleMargeStreamingToken-fm
GET /streaming/device_setting/account/{account}/device/{device}/device_settings handlers.(*Server).HandleMargeGetDeviceSettings-fm
GET /streaming/resources/api_versions.xml handlers.(*Server).HandleMargeAPIVersions-fm
GET /streaming/software/update/account/{account} handlers.(*Server).HandleMargeSoftwareUpdate-fm
GET /streaming/sourceproviders handlers.(*Server).HandleMargeSourceProviders-fm
GET /updates/soundtouch handlers.(*Server).HandleMargeSoftwareUpdate-fm
GET /v1/blacklist/{deviceId} setupRouter
GET /web/* setupRouter.(*Server).HandleWeb
HEAD /oauth/* handlers.(*Server).HandleBoseProxy-fm
OPTIONS /oauth/* handlers.(*Server).HandleBoseProxy-fm
PATCH /oauth/* handlers.(*Server).HandleBoseProxy-fm
POST /accounts/{account}/devices handlers.(*Server).HandleMargeAddDevice-fm
POST /accounts/{account}/devices/{device}/presets/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
POST /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleMargeAddRecent-fm
POST /accounts/{account}/group handlers.(*Server).HandleMargeAddGroup-fm
POST /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeModifyGroup-fm
POST /alexa/certificate handlers.(*Server).HandleAlexaCertificate-fm
POST /bmx/core02/svc-bmx-adapter-orion/prod/orion/token handlers.(*Server).HandleOrionToken-fm
POST /bmx/orion/v1/playback/station/{data} handlers.(*Server).HandleOrionPlayback-fm
POST /bmx/tunein/v1/favorite/{stationID} handlers.(*Server).HandleTuneInFavorite-fm
POST /bmx/tunein/v1/report handlers.(*Server).HandleTuneInReport-fm
POST /bmx/tunein/v1/token handlers.(*Server).HandleTuneInToken-fm
POST /customer/account/{account} handlers.(*Server).HandleMargeUpdateAccountProfile-fm
POST /customer/account/{account}/password handlers.(*Server).HandleMargeChangePassword-fm
POST /mgmt/accounts/{accountId}/language handlers.(*Server).HandleMgmtUpdateAccountLanguage-fm
POST /mgmt/accounts/{accountId}/provider-settings handlers.(*Server).HandleMgmtUpdateAccountProviderSetting-fm
POST /mgmt/amazon/confirm handlers.(*Server).HandleMgmtAmazonConfirm-fm
POST /mgmt/amazon/init handlers.(*Server).HandleMgmtAmazonInit-fm
POST /mgmt/amazon/prime handlers.(*Server).HandleMgmtPrimeDeviceAmazon-fm
POST /mgmt/spotify/confirm handlers.(*Server).HandleMgmtSpotifyConfirm-fm
POST /mgmt/spotify/entity handlers.(*Server).HandleMgmtSpotifyEntity-fm
POST /mgmt/spotify/init handlers.(*Server).HandleMgmtSpotifyInit-fm
POST /mgmt/spotify/prime handlers.(*Server).HandleMgmtPrimeDevice-fm
POST /oauth/* handlers.(*Server).HandleBoseProxy-fm
POST /oauth/account/{account}/music/musicprovider/{sourceID}/token/cs handlers.(*Server).HandleBoseAccountToken-fm
POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token handlers.(*Server).HandleBoseLegacyToken-fm
POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token/cs1 handlers.(*Server).HandleBoseToken-fm
POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token/cs3 handlers.(*Server).HandleBoseToken-fm
POST /setup/backup/{deviceId} handlers.(*Server).HandleBackupConfig-fm
POST /setup/devices handlers.(*Server).HandleAddManualDevice-fm
POST /setup/discover handlers.(*Server).HandleTriggerDiscovery-fm
POST /setup/ensure-remote-services/{deviceId} handlers.(*Server).HandleEnsureRemoteServices-fm
POST /setup/migrate/{deviceId} handlers.(*Server).HandleMigrateDevice-fm
POST /setup/proxy-settings handlers.(*Server).HandleUpdateProxySettings-fm
POST /setup/reboot/{deviceId} handlers.(*Server).HandleRebootDevice-fm
POST /setup/remove-remote-services/{deviceId} handlers.(*Server).HandleRemoveRemoteServices-fm
POST /setup/revert/{deviceId} handlers.(*Server).HandleRevertMigration-fm
POST /setup/settings handlers.(*Server).HandleUpdateSettings-fm
POST /setup/sync/{deviceId} handlers.(*Server).HandleInitialSync-fm
POST /setup/test-connection/{deviceId} handlers.(*Server).HandleTestConnection-fm
POST /setup/test-dns/{deviceId} handlers.(*Server).HandleTestDNSRedirection-fm
POST /setup/test-hosts/{deviceId} handlers.(*Server).HandleTestHostsRedirection-fm
POST /setup/trust-ca/{deviceId} handlers.(*Server).HandleTrustCACert-fm
POST /streaming/account handlers.(*Server).HandleMargeCreateAccount-fm
POST /streaming/account/login handlers.(*Server).HandleMargeLogin-fm
POST /streaming/account/{account}/device/ handlers.(*Server).HandleMargeAddDevice-fm
POST /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeAddDevice-fm
POST /streaming/account/{account}/device/{device}/presets/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
POST /streaming/account/{account}/device/{device}/recent handlers.(*Server).HandleMargeAddRecent-fm
POST /streaming/account/{account}/group handlers.(*Server).HandleMargeAddGroup-fm
POST /streaming/account/{account}/group/{groupId} handlers.(*Server).HandleMargeModifyGroup-fm
POST /streaming/account/{account}/source handlers.(*Server).HandleMargeAddSource-fm
POST /streaming/device_setting/account/{account}/device/{device}/device_settings handlers.(*Server).HandleMargeUpdateDeviceSettings-fm
POST /streaming/music/musicprovider/{providerID}/is_eligible handlers.(*Server).HandleMusicProviderIsEligible-fm
POST /streaming/music/musicprovider/{providerID}/trial/is_eligible handlers.(*Server).HandleMusicProviderIsEligible-fm
POST /streaming/stats/error handlers.(*Server).HandleErrorStats-fm
POST /streaming/stats/usage handlers.(*Server).HandleUsageStats-fm
POST /streaming/support/customersupport handlers.(*Server).HandleMargeCustomerSupport-fm
POST /streaming/support/power_on handlers.(*Server).HandleMargePowerOn-fm
POST /v1/scmudc/{deviceId} handlers.(*Server).HandleAppEvents-fm
POST /v1/stapp/{deviceId} handlers.(*Server).HandleAppEvents-fm
PUT /oauth/* handlers.(*Server).HandleBoseProxy-fm
PUT /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
TRACE /oauth/* handlers.(*Server).HandleBoseProxy-fm
+2
View File
@@ -0,0 +1,2 @@
soundtouch-web
soundtouch-web-test
+276
View File
@@ -0,0 +1,276 @@
# SoundTouch Web Implementation
## Overview
The `soundtouch-web` tool provides a modern single-page application (SPA) for controlling Bose SoundTouch devices. Built with a JSON API backend and client-side JavaScript rendering, it offers superior performance and eliminates template rendering issues.
## Architecture
### Single-Page Application Design
The architecture eliminates Go template dependencies and provides:
- **JSON API Backend**: Pure Go server returning only JSON responses
- **Client-Side Rendering**: JavaScript handles all HTML generation
- **WebSocket Real-time**: Bi-directional communication for live updates
- **Better Performance**: No server-side template processing
- **Easier Development**: Clear separation of frontend/backend concerns
### Core Components
#### 1. Main Application (`main.go`)
- **Entry Point**: Handles command-line arguments and application initialization
- **SPA Routing**: Serves static HTML file for all non-API routes
- **Device Discovery**: Automatic discovery of SoundTouch devices using unified discovery service
- **JSON API Server**: Configures API routes and serves the SPA
- **Context Management**: Proper context handling for timeouts and cancellation
#### 2. HTTP Handlers (`handlers/handlers.go`)
- **WebApp Structure**: Central application state management
- **JSON API Endpoints**: RESTful API returning only JSON responses
- **Device Control**: Device control with proper validation and error handling
- **Modular Design**: Separated control actions into focused functions
#### 3. WebSocket Support (`handlers/websocket.go`)
- **Real-time Updates**: Live device status streaming to web clients
- **Device WebSocket Connections**: Maintains persistent connections to SoundTouch devices
- **Event Handling**: Processes nowPlaying, volume, and connection state updates
- **Status Synchronization**: Keeps device status current across all connected clients
#### 4. Type Definitions (`webtypes/types.go`)
- **Device Management**: Structures for device connections and status
- **API Responses**: Standardized JSON response format
- **WebSocket Messages**: Real-time message types
- **Template Data**: HTML template data structures
### Key Features Implemented
#### Device Discovery & Management
- **Auto-discovery**: Finds SoundTouch devices on local network using mDNS/UPnP
- **Multi-device Support**: Manages multiple devices simultaneously
- **Connection Tracking**: Monitors device availability and connection status
- **Device Information**: Displays device details (name, type, IP address)
#### Real-time Control Interface
- **Now Playing**: Live track information with artwork display
- **Playback Controls**: Play/pause/stop/next/previous with visual feedback
- **Volume Control**: Real-time volume slider with mute functionality
- **Bass Adjustment**: Bass level control for supported devices
- **Preset Management**: Quick access to saved presets (1-6)
- **Source Selection**: Input switching (Spotify, TuneIn, Bluetooth, AUX, etc.)
#### Web Interface
- **Single-Page Application**: Self-contained HTML file with embedded CSS and JavaScript
- **Responsive Design**: Bootstrap 5-based UI optimized for desktop and mobile
- **Client-Side Routing**: JavaScript handles page navigation without page reloads
- **Dynamic Rendering**: All HTML generated client-side from JSON data
- **Real-time Updates**: WebSocket-powered live status updates
- **Performance Optimized**: Fast loading and no template rendering delays
#### API Endpoints
```
GET / # SPA - serves static/index.html
GET /api/devices # List all devices (JSON)
GET /api/device/{id} # Get device info (JSON)
POST /api/discover # Trigger device discovery
GET /api/control/{id}/play # Playback control
GET /api/control/{id}/pause # Pause playback
GET /api/control/{id}/stop # Stop playback
GET /api/control/{id}/next # Next track
GET /api/control/{id}/previous # Previous track
POST /api/control/{id}/volume # Set volume (JSON body)
GET /api/control/{id}/mute # Toggle mute
POST /api/control/{id}/bass # Set bass level (JSON body)
GET /api/control/{id}/preset?id=N # Select preset
GET /api/control/{id}/source?name=X # Select source
```
#### WebSocket Events
- **Connection**: `ws://localhost:8080/ws`
- **Device Updates**: Real-time device list changes
- **Status Updates**: Live playback and volume changes
- **Connection Monitoring**: Device availability status
## Technical Implementation
### Frontend Architecture
- **Single HTML File**: Complete application in `static/index.html`
- **Embedded CSS**: Bootstrap 5 with custom Bose-inspired styling
- **Vanilla JavaScript**: No framework dependencies, fast performance
- **Client-Side Routing**: JavaScript manages page state without reloads
- **Dynamic Components**: HTML elements generated from JSON API responses
### Error Handling & Validation
- **Input Validation**: Proper bounds checking for volume (0-100) and bass (-9 to 9)
- **HTTP Status Codes**: Appropriate response codes for different error conditions
- **JSON Error Responses**: Structured error messages for API consumers
- **Client-Side Error Display**: JavaScript toast notifications for user feedback
### Code Quality
- **golangci-lint Compliance**: Passes all configured lint checks
- **Context Handling**: Proper context propagation and timeout management
- **Error Checking**: All JSON encoding/decoding operations checked
- **Type Safety**: Strong typing with dedicated type package
- **Test Coverage**: Comprehensive unit tests for handlers and types
### WebSocket Integration
- **Gabbo Protocol**: Native SoundTouch WebSocket protocol implementation
- **Event Processing**: Handles all documented SoundTouch WebSocket events
- **Connection Management**: Automatic reconnection and health monitoring
- **Bi-directional Communication**: Both status monitoring and device control
## Dependencies
### Core Libraries
- **chi v5**: HTTP router (inherited from existing codebase)
- **gorilla/websocket**: WebSocket implementation
- **Go standard library**: html/template, net/http, encoding/json
### Project Dependencies
- **pkg/client**: SoundTouch HTTP and WebSocket client library
- **pkg/discovery**: Device discovery service (mDNS/UPnP)
- **pkg/models**: XML/JSON data structures for SoundTouch API
- **pkg/config**: Configuration management
### Frontend Dependencies
- **Bootstrap 5**: CSS framework for responsive design
- **Bootstrap Icons**: Icon library for UI elements
- **Vanilla JavaScript**: No external JS frameworks, pure WebSocket implementation
## Build & Testing
### Build Commands
```bash
# Build the web application
cd cmd/soundtouch-web
go build -o soundtouch-web
# Build all project components (includes soundtouch-web)
make build
# Cross-platform builds
make build-all
```
### Testing
```bash
# Run unit tests
go test ./cmd/soundtouch-web/...
# Run with coverage
go test -cover ./cmd/soundtouch-web/...
# Lint checking
golangci-lint run cmd/soundtouch-web/...
```
### Development Server
```bash
# Run development server
cd cmd/soundtouch-web
go run main.go -port 8080
# Access the web interface
open http://localhost:8080
```
## Configuration
### Command Line Options
```bash
soundtouch-web [options]
Options:
-port string Web server port (default "8080")
-host string Specific device host for single-device mode (optional)
```
### File Structure
```
cmd/soundtouch-web/
├── main.go # Application entry point
├── soundtouch-web # Built binary
├── handlers/
│ ├── handlers.go # HTTP request handlers
│ ├── handlers_test.go # Handler tests
│ └── websocket.go # WebSocket functionality
├── webtypes/
│ ├── types.go # Type definitions
│ └── types_test.go # Type tests
├── templates/
│ ├── layout.html # Base HTML layout
│ ├── index.html # Device list page
│ └── device.html # Device control page
├── static/
│ └── style.css # Additional CSS styles
└── README.md # User documentation
```
## Browser Compatibility
### Supported Browsers
- **Chrome 80+** (recommended)
- **Firefox 75+**
- **Safari 13+**
- **Edge 80+**
### Required Features
- WebSocket support
- CSS Grid and Flexbox
- ES6 JavaScript features
- JSON API support
## Security Considerations
### Design Principles
- **Local Network Only**: Designed for trusted local network environments
- **No Authentication**: Assumes local network security
- **CORS Policy**: Restricted to same-origin requests
- **Input Validation**: All user inputs validated on server side
### Network Security
- **Port Usage**: Uses standard HTTP port (configurable)
- **WebSocket Security**: Same-origin WebSocket connections only
- **No External Dependencies**: All resources served locally
## Performance Characteristics
### Resource Usage
- **Memory**: Minimal footprint, scales with number of discovered devices
- **CPU**: Low usage, event-driven architecture
- **Network**: Efficient WebSocket connections, HTTP REST for control
### Scalability
- **Device Limits**: Designed for typical home networks (5-20 devices)
- **Concurrent Users**: Multiple browser sessions supported
- **Update Frequency**: Real-time updates without polling
## Future Enhancements
### Potential Features
- **Zone Management**: Multi-room audio control
- **Preset Programming**: Advanced preset configuration
- **Mobile PWA**: Progressive Web App for mobile installation
- **Theme Support**: Additional UI themes
- **Device Grouping**: Logical device organization
### Technical Improvements
- **Caching**: Enhanced device status caching
- **Compression**: WebSocket message compression
- **Persistence**: Device settings persistence
- **Metrics**: Usage analytics and performance monitoring
## Integration with Main Project
### Project Alignment
- **Consistent Architecture**: Follows established project patterns
- **Shared Libraries**: Leverages existing pkg/ modules
- **Build Integration**: Included in main Makefile targets
- **Documentation**: Consistent with project documentation standards
### Migration Path
- **Cloud Replacement**: Serves as local alternative to Bose cloud services
- **API Compatibility**: Maintains compatibility with existing SoundTouch APIs
- **User Experience**: Familiar interface for existing SoundTouch app users
- **Long-term Support**: Designed for continued operation post-2026
This implementation provides a robust, feature-complete web interface for SoundTouch device control, ensuring continued functionality beyond the official app's lifecycle while maintaining high code quality and user experience standards.
+330
View File
@@ -0,0 +1,330 @@
# SoundTouch Web UI
A modern single-page web application (SPA) for controlling Bose SoundTouch devices. Built with a JSON API backend and client-side JavaScript rendering for superior performance and maintainability.
## Architecture
```
Browser → Static HTML → JavaScript → JSON API → Go Server
Client-Side Rendering
```
### Key Benefits
- **Better Performance**: No server-side template processing overhead
- **Improved Maintainability**: Clear separation between frontend (JavaScript) and backend (Go)
- **Real-time Experience**: Smooth client-side updates without page reloads
- **Mobile Ready**: The JSON API can power both this web interface and mobile applications
## Features
Based on captured WebSocket interactions and device API capabilities, this web UI provides:
### Device Management
- **Auto-discovery** of SoundTouch devices on the network
- **Real-time status monitoring** via WebSocket connections
- **Multi-device support** with centralized control
- **Connection status** indicators and health monitoring
### Playback Control
- **Play/Pause/Stop/Next/Previous** controls
- **Now playing information** with artwork, track details, and progress
- **Real-time updates** of playback state changes
- **Source selection** from available inputs (Spotify, TuneIn, Bluetooth, AUX, etc.)
### Audio Controls
- **Volume control** with real-time slider updates
- **Mute/Unmute** functionality
- **Bass adjustment** (on supported models)
- **Audio level monitoring** and statistics
### Preset Management
- **6 preset buttons** with visual feedback
- **Preset content display** showing station/playlist names
- **One-click preset selection**
### Advanced Features
- **WebSocket real-time updates** for instant state synchronization
- **Responsive design** optimized for desktop and mobile
- **Dark mode support** (auto-detects system preference)
- **Accessibility features** (keyboard navigation, screen reader support)
- **Network statistics** and device health monitoring
## Screenshots
### Main Device Overview
The main page shows all discovered devices with their current status, now-playing information, and quick controls.
### Detailed Device Control
Individual device pages provide full control over:
- Detailed now-playing information with artwork
- Comprehensive audio controls (volume, bass)
- Full preset and source selection
- Real-time status updates
## Installation
### Prerequisites
- Go 1.21 or later
- Access to SoundTouch devices on the same network
- Modern web browser with WebSocket support
### Building
```bash
# From project root
make build
# Or manually
cd cmd/soundtouch-web
go build -o soundtouch-web
```
### Running
```bash
# Run with default settings (port 8080)
./soundtouch-web
# Specify custom port
./soundtouch-web -port 8888
# Connect to specific device
./soundtouch-web -host 192.168.1.100
```
### Command Line Options
```
-port string Web server port (default "8080")
-host string Specific SoundTouch device host (optional, enables single-device mode)
-help Show help information
```
## Usage
### Accessing the Interface
1. Start the application
2. Open your web browser and navigate to `http://localhost:8080`
3. Click "Discover Devices" to find SoundTouch devices on your network
4. Click on any device for detailed control, or use quick controls from the main page
### Device Discovery
The application automatically discovers SoundTouch devices using:
- **mDNS discovery** for local network devices
- **UPnP/SSDP discovery** as fallback
- **Manual device addition** via IP address
### Real-time Updates
The interface maintains WebSocket connections to each device for instant updates of:
- Now playing information and artwork
- Volume and audio settings changes
- Playback status (play/pause/stop)
- Connection status and device health
### Responsive Design
- **Desktop**: Full-featured interface with side-by-side panels
- **Tablet**: Optimized layout with touch-friendly controls
- **Mobile**: Stacked interface with gesture support
## API Endpoints
The web UI exposes a REST API for programmatic control:
### Device Management
```
GET /api/devices # List all discovered devices
GET /api/device/{id} # Get specific device info
POST /api/discover # Trigger device discovery
```
### Device Control
```
GET /api/control/{id}/play # Start playback
GET /api/control/{id}/pause # Pause playback
GET /api/control/{id}/stop # Stop playback
GET /api/control/{id}/next # Next track
GET /api/control/{id}/previous # Previous track
POST /api/control/{id}/volume # Set volume (body: {"level": 50})
GET /api/control/{id}/mute # Mute audio
GET /api/control/{id}/unmute # Unmute audio
POST /api/control/{id}/bass # Set bass (body: {"level": 0})
GET /api/control/{id}/preset?id=1 # Select preset
GET /api/control/{id}/source?name=SPOTIFY # Select source
```
### WebSocket Events
Connect to `/ws` for real-time updates:
```javascript
const ws = new WebSocket('ws://localhost:8080/ws');
ws.onmessage = function(event) {
const data = JSON.parse(event.data);
// Handle device updates, status changes, etc.
};
```
## Architecture
### Single-Page Application Architecture
- **JSON API Backend**: Go server providing RESTful endpoints
- **Client-Side Rendering**: JavaScript handles all UI rendering
- **WebSocket Real-time**: Bi-directional real-time communication
- **No Template Dependencies**: Eliminates server-side template issues
### Backend Components
- **Discovery Service**: Finds and manages SoundTouch devices
- **WebSocket Manager**: Maintains real-time connections to devices
- **JSON API Server**: RESTful interface returning only JSON
- **Device Manager**: Tracks device state and health
### Frontend Components
- **Bootstrap 5**: Modern responsive UI framework
- **Vanilla JavaScript**: No framework dependencies, fast loading
- **WebSocket Client**: Real-time bidirectional communication
- **Dynamic Rendering**: Client-side HTML generation from JSON
### Communication Flow
1. **SPA Loading**: Single HTML file with embedded CSS and JavaScript
2. **JSON API**: Device discovery and control via REST endpoints
3. **WebSocket (Device)**: Real-time status updates from SoundTouch devices
4. **WebSocket (Browser)**: Real-time UI updates to web clients
5. **Client Rendering**: JavaScript dynamically creates all UI elements
## Development
### Project Structure
```
cmd/soundtouch-web/
├── main.go # Application entry point and SPA routing
├── handlers/ # HTTP and WebSocket handlers
│ ├── handlers.go # JSON API endpoints
│ └── websocket.go # WebSocket management
├── webtypes/ # Type definitions
│ └── types.go # Request/response types
├── static/ # Static assets
│ ├── index.html # Single-page application
│ └── js/ # Legacy JS files (reference)
├── templates/ # Legacy templates (unused in SPA)
└── README.md # This file
```
### Adding New Features
1. **API Endpoints**: Add new JSON routes in `setupRoutes()` and `handlers.go`
2. **WebSocket Events**: Extend event handlers in WebSocket client
3. **UI Components**: Add JavaScript rendering functions in `static/index.html`
4. **Device Controls**: Implement new control commands and update client-side handlers
### Testing
```bash
# Unit tests
go test ./...
# Manual testing with multiple devices
./soundtouch-web -port 8080
# API testing
curl http://localhost:8080/api/devices
```
## WebSocket Protocol Analysis
This UI is based on extensive analysis of captured SoundTouch WebSocket interactions, including:
### Message Types Implemented
- **SoundTouchSdkInfo**: Initial handshake and version info
- **nowPlayingUpdated**: Real-time track information
- **volumeUpdated**: Audio level changes
- **recentsUpdated**: Recently played items
- **userActivityUpdate**: User interaction notifications
### Request/Response Patterns
- **Device Information**: System details and capabilities
- **Audio Controls**: Volume, bass, mute controls
- **Playback Control**: Play/pause/stop/skip commands
- **Source Selection**: Input switching (Spotify, TuneIn, etc.)
- **Preset Management**: Saved station/playlist access
### Gabbo Protocol Features
- **Persistent Connections**: Maintains long-lived WebSocket connections
- **Request Correlation**: Uses request IDs for response matching
- **Real-time Events**: Instant updates for all device state changes
- **Bi-directional Control**: Both status monitoring and device control
## Browser Compatibility
### Supported Browsers
- **Chrome 80+** (recommended)
- **Firefox 75+**
- **Safari 13+**
- **Edge 80+**
### Required Features
- WebSocket support
- CSS Grid and Flexbox
- ES6 JavaScript features
- Responsive CSS media queries
## Security Considerations
- **Local Network Only**: Designed for local network device control
- **No Authentication**: Assumes trusted local network environment
- **CORS Policy**: Restricted to same-origin requests
- **WebSocket Security**: Uses same-origin WebSocket connections
## Troubleshooting
### Common Issues
**Devices Not Found**
- Ensure devices are on the same network
- Check firewall settings (ports 8090, 8080)
- Click "Discover Devices" button to trigger discovery
**WebSocket Connection Failed**
- Verify device supports WebSocket connections
- Check browser console for connection errors
- Refresh the page to reconnect WebSocket
**Control Commands Not Working**
- Check device is powered on and connected
- Verify device is not in exclusive mode (e.g., Spotify Connect active)
- Look for error notifications in the UI
**Page Shows Template Errors**
- This has been fixed in the SPA implementation
- Ensure you're accessing the correct URL (localhost:8080)
- Clear browser cache if you see old template-based content
### Debug Mode
Add verbose logging by setting environment variable:
```bash
export DEBUG=true
./soundtouch-web
```
## Contributing
This web UI is part of the larger SoundTouch Go library project. See the main project README for contribution guidelines.
### Architecture Benefits
The new SPA approach provides:
- **Better Performance**: No server-side template rendering
- **Easier Development**: Clear separation of frontend/backend
- **Mobile Ready**: Same JSON API can power mobile apps
- **Scalable**: Single-page app architecture
### Feature Requests
Based on WebSocket interaction analysis, potential future features:
- Zone/multi-room management
- Clock display control
- Software update management
- Advanced preset programming
- Progressive Web App (PWA) features
## License
Same as the parent project - see main repository LICENSE file.
## Acknowledgments
- Built on the comprehensive SoundTouch Go library
- UI design inspired by modern audio control interfaces
- WebSocket protocol reverse-engineered from captured device interactions
- Bootstrap and Bootstrap Icons for responsive design components
+648
View File
@@ -0,0 +1,648 @@
// Package handlers contains HTTP handlers for the SoundTouch web UI.
package handlers
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"sync"
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
"github.com/gesellix/bose-soundtouch/pkg/models"
bmxpkg "github.com/gesellix/bose-soundtouch/pkg/service/bmx"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
)
// WebApp holds the application state and dependencies
type WebApp struct {
Devices map[string]*webtypes.DeviceConnection
Upgrader websocket.Upgrader
WSClients map[*websocket.Conn]bool
WSMutex sync.RWMutex
}
// NewWebApp creates a new WebApp instance for SPA mode
func NewWebApp() *WebApp {
return &WebApp{
Devices: make(map[string]*webtypes.DeviceConnection),
WSClients: make(map[*websocket.Conn]bool),
Upgrader: websocket.Upgrader{
CheckOrigin: func(_ *http.Request) bool { return true },
},
}
}
// HandleAPIDevices returns all devices as JSON
func (app *WebApp) HandleAPIDevices(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
// Return all devices as JSON
devices := make(map[string]interface{})
for id, device := range app.Devices {
devices[id] = map[string]interface{}{
"info": device.DeviceInfo,
"status": device.Status,
"lastSeen": device.LastSeen,
}
}
response := webtypes.APIResponse{
Success: true,
Data: devices,
}
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandleAPIDevice returns a specific device as JSON
func (app *WebApp) HandleAPIDevice(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
if deviceID == "" {
app.sendError(w, "Device ID required", http.StatusBadRequest)
return
}
device, exists := app.Devices[deviceID]
if !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
// Update device status to get fresh power state
app.UpdateDeviceStatus(deviceID, device)
// Connect WebSocket for real-time updates if not already connected
if device.WebSocket == nil {
go app.ConnectDeviceWebSocket(deviceID, device)
}
w.Header().Set("Content-Type", "application/json")
response := webtypes.APIResponse{
Success: true,
Data: map[string]interface{}{
"info": device.DeviceInfo,
"status": device.Status,
},
}
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandleAPIControl handles device control commands
func (app *WebApp) HandleAPIControl(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
action := chi.URLParam(r, "action")
if deviceID == "" {
app.sendError(w, "Device ID required", http.StatusBadRequest)
return
}
device, exists := app.Devices[deviceID]
if !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
// Connect WebSocket for real-time updates if not already connected
if device.WebSocket == nil {
go app.ConnectDeviceWebSocket(deviceID, device)
}
w.Header().Set("Content-Type", "application/json")
app.handleControlAction(w, r, action, device)
}
// handleControlAction processes different control actions
func (app *WebApp) handleControlAction(w http.ResponseWriter, r *http.Request, action string, device *webtypes.DeviceConnection) {
switch action {
case "play":
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
err := device.Client.Play()
app.sendControlResponse(w, err, "Started playback")
case "pause":
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
err := device.Client.Pause()
app.sendControlResponse(w, err, "Paused playback")
case "stop":
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
err := device.Client.Stop()
app.sendControlResponse(w, err, "Stopped playback")
case "next":
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
err := device.Client.NextTrack()
app.sendControlResponse(w, err, "Next track")
case "previous":
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
err := device.Client.PrevTrack()
app.sendControlResponse(w, err, "Previous track")
case "volume":
app.handleVolumeControl(w, r, device)
case "mute":
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
err := device.Client.SendKey(models.KeyMute)
app.sendControlResponse(w, err, "Toggled mute")
case "preset":
app.handlePresetControl(w, r, device)
case "bass":
app.handleBassControl(w, r, device)
case "source":
app.handleSourceControl(w, r, device)
default:
app.sendError(w, "Unknown action", http.StatusBadRequest)
}
}
// handleVolumeControl processes volume control requests
func (app *WebApp) handleVolumeControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
if r.Method != http.MethodPost {
app.sendError(w, "POST required for volume control", http.StatusMethodNotAllowed)
return
}
var volumeReq webtypes.VolumeRequest
if err := json.NewDecoder(r.Body).Decode(&volumeReq); err != nil {
app.sendError(w, "Invalid volume data", http.StatusBadRequest)
return
}
if volumeReq.Level < 0 || volumeReq.Level > 100 {
app.sendError(w, "Volume must be between 0 and 100", http.StatusBadRequest)
return
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
err := device.Client.SetVolume(volumeReq.Level)
app.sendControlResponse(w, err, fmt.Sprintf("Volume set to %d", volumeReq.Level))
}
// handlePresetControl processes preset control requests
func (app *WebApp) handlePresetControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
presetParam := r.URL.Query().Get("id")
if presetParam == "" {
app.sendError(w, "Preset ID required", http.StatusBadRequest)
return
}
presetID, err := strconv.Atoi(presetParam)
if err != nil {
app.sendError(w, "Invalid preset ID", http.StatusBadRequest)
return
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
err = device.Client.SelectPreset(presetID)
app.sendControlResponse(w, err, fmt.Sprintf("Selected preset %d", presetID))
}
// handleBassControl processes bass control requests
func (app *WebApp) handleBassControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
if r.Method != http.MethodPost {
app.sendError(w, "POST required for bass control", http.StatusMethodNotAllowed)
return
}
var bassReq webtypes.BassRequest
if err := json.NewDecoder(r.Body).Decode(&bassReq); err != nil {
app.sendError(w, "Invalid bass data", http.StatusBadRequest)
return
}
if bassReq.Level < -9 || bassReq.Level > 9 {
app.sendError(w, "Bass must be between -9 and 9", http.StatusBadRequest)
return
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
err := device.Client.SetBass(bassReq.Level)
app.sendControlResponse(w, err, fmt.Sprintf("Bass set to %d", bassReq.Level))
}
// handleSourceControl processes source control requests
func (app *WebApp) handleSourceControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
sourceParam := r.URL.Query().Get("name")
if sourceParam == "" {
app.sendError(w, "Source name required", http.StatusBadRequest)
return
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
err := device.Client.SelectSource(sourceParam, "")
app.sendControlResponse(w, err, fmt.Sprintf("Selected source %s", sourceParam))
}
// sendControlResponse sends a control command response
func (app *WebApp) sendControlResponse(w http.ResponseWriter, err error, successMessage string) {
if err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
response := webtypes.APIResponse{
Success: true,
Data: map[string]string{"message": successMessage},
}
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// sendError sends an error response
func (app *WebApp) sendError(w http.ResponseWriter, message string, statusCode int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
response := webtypes.APIResponse{
Success: false,
Error: message,
}
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Failed to encode error response", http.StatusInternalServerError)
}
}
// HandleDeviceKey handles sending key commands to devices
func (app *WebApp) HandleDeviceKey(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
key := chi.URLParam(r, "key")
device, exists := app.Devices[deviceID]
if !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
// Connect WebSocket for real-time updates if not already connected
if device.WebSocket == nil {
go app.ConnectDeviceWebSocket(deviceID, device)
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
err := device.Client.SendKey(key)
app.sendControlResponse(w, err, fmt.Sprintf("Sent key command: %s", key))
}
// HandleDirectVolumeControl handles direct volume setting via URL parameter
func (app *WebApp) HandleDirectVolumeControl(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
volumeLevel, err := strconv.Atoi(chi.URLParam(r, "volume"))
if err != nil || volumeLevel < 0 || volumeLevel > 100 {
app.sendError(w, "Invalid volume level (0-100)", http.StatusBadRequest)
return
}
device, exists := app.Devices[deviceID]
if !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
// Connect WebSocket for real-time updates if not already connected
if device.WebSocket == nil {
go app.ConnectDeviceWebSocket(deviceID, device)
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
err = device.Client.SetVolume(volumeLevel)
app.sendControlResponse(w, err, fmt.Sprintf("Volume set to %d", volumeLevel))
}
// HandleDevicePower handles power toggle commands for devices
func (app *WebApp) HandleDevicePower(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
device, exists := app.Devices[deviceID]
if !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
// Connect WebSocket for real-time updates if not already connected
if device.WebSocket == nil {
go app.ConnectDeviceWebSocket(deviceID, device)
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
// Send POWER key command to toggle device power
err := device.Client.SendKey("POWER")
app.sendControlResponse(w, err, "Power toggle command sent")
}
// HandleDevicePowerStatus handles lightweight power status check
func (app *WebApp) HandleDevicePowerStatus(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
device, exists := app.Devices[deviceID]
if !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
// Quick power status check by getting now playing
nowPlaying, err := device.Client.GetNowPlaying()
if err != nil {
app.sendControlResponse(w, err, "Failed to get power status")
return
}
isPoweredOn := nowPlaying != nil && nowPlaying.Source != "STANDBY"
response := webtypes.APIResponse{
Success: true,
Data: map[string]interface{}{
"deviceId": deviceID,
"isPoweredOn": isPoweredOn,
"source": nowPlaying.Source,
},
}
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// BroadcastDeviceList sends updated device list to all connected WebSocket clients
func (app *WebApp) BroadcastDeviceList() {
app.WSMutex.RLock()
defer app.WSMutex.RUnlock()
devices := make(map[string]interface{})
for id, device := range app.Devices {
devices[id] = map[string]interface{}{
"info": device.DeviceInfo,
"status": device.Status,
"lastSeen": device.LastSeen,
}
}
message := webtypes.WebSocketMessage{
Type: "devices",
Data: devices,
}
// Send to all connected clients
var failedClients []*websocket.Conn
for client := range app.WSClients {
if err := client.WriteJSON(message); err != nil {
log.Printf("Failed to send device update to WebSocket client: %v", err)
// Mark for removal to avoid modifying map during iteration
failedClients = append(failedClients, client)
}
}
// Remove failed clients
for _, client := range failedClients {
delete(app.WSClients, client)
client.Close()
}
}
// BroadcastDiscoveryStatus sends discovery progress updates to all connected WebSocket clients
func (app *WebApp) BroadcastDiscoveryStatus(status string, deviceCount int) {
app.WSMutex.RLock()
defer app.WSMutex.RUnlock()
message := webtypes.WebSocketMessage{
Type: "discovery_status",
Data: map[string]interface{}{
"status": status,
"deviceCount": deviceCount,
},
}
// Send to all connected clients
var failedClients []*websocket.Conn
for client := range app.WSClients {
if err := client.WriteJSON(message); err != nil {
log.Printf("Failed to send discovery status to WebSocket client: %v", err)
// Mark for removal to avoid modifying map during iteration
failedClients = append(failedClients, client)
}
}
// Remove failed clients
for _, client := range failedClients {
delete(app.WSClients, client)
client.Close()
}
}
// HandleTuneInSearch handles TuneIn search requests, proxying directly to the bmx package.
func (app *WebApp) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" {
app.sendError(w, "query parameter 'q' is required", http.StatusBadRequest)
return
}
resp, err := bmxpkg.TuneInSearch(query)
if err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: resp}); encErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandleTuneInNavigate handles TuneIn browse/navigate requests, proxying directly to the bmx package.
// Supported path suffixes (relative to /api/tunein/navigate):
// - (empty) → top-level browse
// - /{encodedURI} → browse the given TuneIn URI
// - /sub/{n}/{encodedURI} → single subsection
// - /profiles/{type}/{id}/{encodedURI} → artist/program profile
func (app *WebApp) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request) {
wildcard := chi.URLParam(r, "*")
var (
resp interface{}
err error
)
if wildcard == "" {
resp, err = bmxpkg.TuneInNavigate("", nil)
} else {
firstSlash := strings.Index(wildcard, "/")
if firstSlash == -1 {
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
} else {
pfx := wildcard[:firstSlash]
rest := wildcard[firstSlash+1:]
switch pfx {
case "sub":
secondSlash := strings.Index(rest, "/")
if secondSlash == -1 {
resp, err = bmxpkg.TuneInNavigate(rest, nil)
} else {
n, parseErr := strconv.Atoi(rest[:secondSlash])
if parseErr != nil {
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
} else {
resp, err = bmxpkg.TuneInNavigate(rest[secondSlash+1:], &n)
}
}
case "profiles":
parts := strings.SplitN(rest, "/", 3)
if len(parts) < 3 {
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
} else {
resp, err = bmxpkg.TuneInNavigateProfile(parts[2])
}
default:
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
}
}
}
if err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: resp}); encErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandlePlayTuneIn plays a TuneIn content item on a specific device via POST /select.
func (app *WebApp) HandlePlayTuneIn(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
if deviceID == "" {
app.sendError(w, "Device ID required", http.StatusBadRequest)
return
}
device, exists := app.Devices[deviceID]
if !exists {
app.sendError(w, fmt.Sprintf("Device '%s' not found", deviceID), http.StatusNotFound)
return
}
var req struct {
Location string `json:"location"`
Name string `json:"name"`
Type string `json:"type"`
ContainerArt string `json:"containerArt"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
app.sendError(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Location == "" {
app.sendError(w, "location is required", http.StatusBadRequest)
return
}
itemType := req.Type
if itemType == "" {
itemType = "stationurl"
}
contentItem := &models.ContentItem{
Source: "TUNEIN",
Type: itemType,
Location: req.Location,
ItemName: req.Name,
IsPresetable: true,
ContainerArt: req.ContainerArt,
}
if err := device.Client.SelectContentItem(contentItem); err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: map[string]string{"message": "Playing " + req.Name}}); encErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
@@ -0,0 +1,588 @@
// Package handlers contains tests for HTTP handlers.
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/go-chi/chi/v5"
)
func createTestApp() *WebApp {
app := NewWebApp()
// Add test device with minimal data
deviceInfo := &models.DeviceInfo{
Name: "Test Speaker",
Type: "SoundTouch 30",
NetworkInfo: []models.NetworkInfo{
{MacAddress: "TEST123", IPAddress: "192.168.1.100"},
},
}
device := &webtypes.DeviceConnection{
Client: nil, // No real client for unit tests
DeviceInfo: deviceInfo,
LastSeen: time.Now(),
Status: webtypes.DeviceStatus{
Volume: &models.Volume{ActualVolume: 50, MuteEnabled: false},
Bass: &models.Bass{ActualBass: 0},
IsConnected: true,
LastActivity: time.Now(),
},
}
app.Devices["test-device"] = device
return app
}
func withChiParams(r *http.Request, params map[string]string) *http.Request {
rctx := chi.NewRouteContext()
for k, v := range params {
rctx.URLParams.Add(k, v)
}
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
}
func TestNewWebApp(t *testing.T) {
app := NewWebApp()
// Use require-style checks that satisfy static analyzer
if app == nil {
t.Fatal("NewWebApp returned nil")
}
if app.Devices == nil {
t.Fatal("Devices map not initialized")
}
// At this point we know app and app.Devices are not nil
if len(app.Devices) != 0 {
t.Errorf("Expected empty devices map, got %d devices", len(app.Devices))
}
}
func TestHandleAPIDevices(t *testing.T) {
app := createTestApp()
req := httptest.NewRequest("GET", "/api/devices", nil)
w := httptest.NewRecorder()
app.HandleAPIDevices(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
contentType := w.Header().Get("Content-Type")
if !strings.Contains(contentType, "application/json") {
t.Errorf("Expected JSON content type, got %s", contentType)
}
var response webtypes.APIResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if !response.Success {
t.Errorf("Expected success=true, got false")
}
// Check that devices data is present
data, ok := response.Data.(map[string]interface{})
if !ok {
t.Fatalf("Expected data to be map[string]interface{}")
}
if _, exists := data["test-device"]; !exists {
t.Errorf("Expected 'test-device' in response data")
}
}
func TestHandleAPIDevice(t *testing.T) {
app := createTestApp()
tests := []struct {
name string
path string
chiID string
expectedStatus int
expectSuccess bool
}{
{
name: "valid device",
path: "/api/device/test-device",
chiID: "test-device",
expectedStatus: http.StatusOK,
expectSuccess: true,
},
{
name: "missing device ID",
path: "/api/device/",
chiID: "",
expectedStatus: http.StatusBadRequest,
expectSuccess: false,
},
{
name: "unknown device",
path: "/api/device/unknown",
chiID: "unknown",
expectedStatus: http.StatusNotFound,
expectSuccess: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", tt.path, nil)
if tt.chiID != "" {
req = withChiParams(req, map[string]string{"id": tt.chiID})
}
w := httptest.NewRecorder()
app.HandleAPIDevice(w, req)
if w.Code != tt.expectedStatus {
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
}
contentType := w.Header().Get("Content-Type")
if !strings.Contains(contentType, "application/json") {
t.Errorf("Expected JSON content type, got %s", contentType)
}
var response webtypes.APIResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if response.Success != tt.expectSuccess {
t.Errorf("Expected success=%v, got %v", tt.expectSuccess, response.Success)
}
})
}
}
func TestHandleAPIControl_InvalidDevice(t *testing.T) {
app := createTestApp()
req := httptest.NewRequest("GET", "/api/control/unknown-device/play", nil)
req = withChiParams(req, map[string]string{"id": "unknown-device", "action": "play"})
w := httptest.NewRecorder()
app.HandleAPIControl(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("Expected status 404, got %d", w.Code)
}
var response webtypes.APIResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if response.Success {
t.Errorf("Expected success=false, got true")
}
if response.Error != "Device not found" {
t.Errorf("Expected 'Device not found' error, got '%s'", response.Error)
}
}
func TestHandleAPIControl_InvalidPath(t *testing.T) {
app := createTestApp()
tests := []struct {
name string
path string
}{
{"missing action", "/api/control/test-device"},
{"missing device and action", "/api/control/"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", tt.path, nil)
w := httptest.NewRecorder()
app.HandleAPIControl(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
var response webtypes.APIResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if response.Success {
t.Errorf("Expected success=false, got true")
}
})
}
}
func TestHandleAPIControl_VolumeValidation(t *testing.T) {
app := createTestApp()
tests := []struct {
name string
method string
body string
expectedStatus int
expectSuccess bool
}{
{
name: "invalid method",
method: "GET",
body: "",
expectedStatus: http.StatusMethodNotAllowed,
expectSuccess: false,
},
{
name: "invalid JSON",
method: "POST",
body: `invalid json`,
expectedStatus: http.StatusBadRequest,
expectSuccess: false,
},
{
name: "volume too low",
method: "POST",
body: `{"level": -1}`,
expectedStatus: http.StatusBadRequest,
expectSuccess: false,
},
{
name: "volume too high",
method: "POST",
body: `{"level": 101}`,
expectedStatus: http.StatusBadRequest,
expectSuccess: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var req *http.Request
if tt.body != "" {
req = httptest.NewRequest(tt.method, "/api/control/test-device/volume", strings.NewReader(tt.body))
} else {
req = httptest.NewRequest(tt.method, "/api/control/test-device/volume", nil)
}
req = withChiParams(req, map[string]string{"id": "test-device", "action": "volume"})
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
app.HandleAPIControl(w, req)
if w.Code != tt.expectedStatus {
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
}
var response webtypes.APIResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if response.Success != tt.expectSuccess {
t.Errorf("Expected success=%v, got %v", tt.expectSuccess, response.Success)
}
})
}
}
func TestHandleAPIControl_BassValidation(t *testing.T) {
app := createTestApp()
tests := []struct {
name string
method string
body string
expectedStatus int
expectSuccess bool
}{
{
name: "bass too low",
method: "POST",
body: `{"level": -10}`,
expectedStatus: http.StatusBadRequest,
expectSuccess: false,
},
{
name: "bass too high",
method: "POST",
body: `{"level": 10}`,
expectedStatus: http.StatusBadRequest,
expectSuccess: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, "/api/control/test-device/bass", strings.NewReader(tt.body))
req = withChiParams(req, map[string]string{"id": "test-device", "action": "bass"})
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
app.HandleAPIControl(w, req)
if w.Code != tt.expectedStatus {
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
}
var response webtypes.APIResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if response.Success != tt.expectSuccess {
t.Errorf("Expected success=%v, got %v", tt.expectSuccess, response.Success)
}
})
}
}
func TestHandleAPIControl_PresetValidation(t *testing.T) {
app := createTestApp()
tests := []struct {
name string
query string
expectedStatus int
expectSuccess bool
}{
{
name: "missing preset ID",
query: "",
expectedStatus: http.StatusBadRequest,
expectSuccess: false,
},
{
name: "invalid preset ID",
query: "?id=abc",
expectedStatus: http.StatusBadRequest,
expectSuccess: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/control/test-device/preset"+tt.query, nil)
req = withChiParams(req, map[string]string{"id": "test-device", "action": "preset"})
w := httptest.NewRecorder()
app.HandleAPIControl(w, req)
if w.Code != tt.expectedStatus {
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
}
var response webtypes.APIResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if response.Success != tt.expectSuccess {
t.Errorf("Expected success=%v, got %v", tt.expectSuccess, response.Success)
}
})
}
}
func TestHandleAPIControl_SourceValidation(t *testing.T) {
app := createTestApp()
req := httptest.NewRequest("GET", "/api/control/test-device/source", nil)
req = withChiParams(req, map[string]string{"id": "test-device", "action": "source"})
w := httptest.NewRecorder()
app.HandleAPIControl(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
var response webtypes.APIResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if response.Success {
t.Errorf("Expected success=false, got true")
}
if response.Error != "Source name required" {
t.Errorf("Expected 'Source name required' error, got '%s'", response.Error)
}
}
func TestHandleAPIDiscover(t *testing.T) {
app := createTestApp()
tests := []struct {
name string
method string
expectedStatus int
expectSuccess bool
}{
{
name: "valid POST request",
method: "POST",
expectedStatus: http.StatusOK,
expectSuccess: true,
},
{
name: "invalid GET request",
method: "GET",
expectedStatus: http.StatusMethodNotAllowed,
expectSuccess: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, "/api/discover", nil)
w := httptest.NewRecorder()
app.HandleAPIDiscover(w, req)
if w.Code != tt.expectedStatus {
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
}
var response webtypes.APIResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if response.Success != tt.expectSuccess {
t.Errorf("Expected success=%v, got %v", tt.expectSuccess, response.Success)
}
})
}
}
func TestSendError(t *testing.T) {
app := createTestApp()
w := httptest.NewRecorder()
app.sendError(w, "Test error", http.StatusBadRequest)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
var response webtypes.APIResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if response.Success {
t.Errorf("Expected success=false, got true")
}
if response.Error != "Test error" {
t.Errorf("Expected 'Test error', got '%s'", response.Error)
}
contentType := w.Header().Get("Content-Type")
if contentType != "application/json" {
t.Errorf("Expected Content-Type 'application/json', got '%s'", contentType)
}
}
func TestHandleWebSocket_InvalidUpgrade(t *testing.T) {
app := createTestApp()
// Test without proper WebSocket headers (should fail gracefully)
req := httptest.NewRequest("GET", "/ws", nil)
w := httptest.NewRecorder()
// This will fail because it's not a real WebSocket upgrade, but should not panic
app.HandleWebSocket(w, req)
// We're just checking that the handler doesn't panic
// The actual upgrade will fail in test environment without proper headers
}
func TestHandleAPIControl_UnsupportedAction(t *testing.T) {
app := createTestApp()
req := httptest.NewRequest("GET", "/api/control/test-device/unsupported", nil)
req = withChiParams(req, map[string]string{"id": "test-device", "action": "unsupported"})
w := httptest.NewRecorder()
app.HandleAPIControl(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
var response webtypes.APIResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if response.Success {
t.Errorf("Expected success=false, got true")
}
if response.Error != "Unknown action" {
t.Errorf("Expected 'Unknown action' error, got '%s'", response.Error)
}
}
// Benchmark tests
func BenchmarkHandleAPIDevices(b *testing.B) {
app := createTestApp()
// Add more devices for realistic benchmarking
for i := 0; i < 10; i++ {
deviceID := "device-" + string(rune('0'+i))
app.Devices[deviceID] = &webtypes.DeviceConnection{
Client: &client.Client{},
DeviceInfo: &models.DeviceInfo{Name: "Test Device " + deviceID},
Status: webtypes.DeviceStatus{IsConnected: true},
}
}
req := httptest.NewRequest("GET", "/api/devices", nil)
b.ResetTimer()
for i := 0; i < b.N; i++ {
w := httptest.NewRecorder()
app.HandleAPIDevices(w, req)
}
}
func BenchmarkHandleAPIDevice(b *testing.B) {
app := createTestApp()
req := httptest.NewRequest("GET", "/api/device/test-device", nil)
req = withChiParams(req, map[string]string{"id": "test-device"})
b.ResetTimer()
for i := 0; i < b.N; i++ {
w := httptest.NewRecorder()
app.HandleAPIDevice(w, req)
}
}
func BenchmarkSendError(b *testing.B) {
app := createTestApp()
b.ResetTimer()
for i := 0; i < b.N; i++ {
w := httptest.NewRecorder()
app.sendError(w, "Test error", http.StatusBadRequest)
}
}
+329
View File
@@ -0,0 +1,329 @@
// Package handlers contains WebSocket handlers for real-time communication.
package handlers
import (
"encoding/json"
"log"
"net/http"
"time"
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
)
// HandleWebSocket handles WebSocket connections for real-time updates
func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := app.Upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade failed: %v", err)
return
}
defer func() {
// Unregister client
app.WSMutex.Lock()
delete(app.WSClients, conn)
app.WSMutex.Unlock()
conn.Close()
}()
// Register client
app.WSMutex.Lock()
app.WSClients[conn] = true
app.WSMutex.Unlock()
// Send initial device list
devices := make(map[string]interface{})
for id, device := range app.Devices {
devices[id] = map[string]interface{}{
"info": device.DeviceInfo,
"status": device.Status,
"lastSeen": device.LastSeen,
}
}
initialMessage := webtypes.WebSocketMessage{
Type: "devices",
Data: devices,
}
if err := conn.WriteJSON(initialMessage); err != nil {
log.Printf("Failed to send initial data: %v", err)
return
}
// Keep connection alive and send updates
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
// Set up ping handler to detect client disconnects
conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
// Set initial read deadline
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
// Handle incoming messages in a separate goroutine
go func() {
defer conn.Close()
for {
if _, _, err := conn.NextReader(); err != nil {
log.Printf("WebSocket read error: %v", err)
return
}
}
}()
// Main loop for sending periodic updates
for range ticker.C {
// Send ping to check if client is still connected
if err := conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
log.Printf("Failed to send ping: %v", err)
return
}
// Send periodic status updates
for id, device := range app.Devices {
if device.Status.IsConnected {
statusMessage := webtypes.WebSocketMessage{
Type: "status_update",
DeviceID: id,
Data: device.Status,
}
if err := conn.WriteJSON(statusMessage); err != nil {
log.Printf("Failed to send status update: %v", err)
return
}
}
}
}
}
// HandleAPIDiscover triggers device discovery
func (app *WebApp) HandleAPIDiscover(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
app.sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Discovery will be triggered by the main app
w.Header().Set("Content-Type", "application/json")
response := webtypes.APIResponse{
Success: true,
Data: map[string]string{"message": "Discovery started"},
}
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// ConnectDeviceWebSocket establishes a WebSocket connection to a device
func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.DeviceConnection) {
// Skip WebSocket connection if client is not available (e.g., in tests)
if conn.Client == nil {
return
}
wsClient := conn.Client.NewWebSocketClient(nil)
// Setup event handlers
wsClient.OnNowPlaying(func(event *models.NowPlayingUpdatedEvent) {
conn.Status.NowPlaying = &event.NowPlaying
conn.Status.LastActivity = time.Now()
})
wsClient.OnVolumeUpdated(func(event *models.VolumeUpdatedEvent) {
conn.Status.Volume = &event.Volume
conn.Status.LastActivity = time.Now()
})
wsClient.OnConnectionState(func(event *models.ConnectionStateUpdatedEvent) {
conn.Status.IsConnected = event.ConnectionState.IsConnected()
conn.Status.LastActivity = time.Now()
})
wsClient.OnPresetUpdated(func(event *models.PresetUpdatedEvent) {
conn.Status.Presets = &event.Presets
conn.Status.LastActivity = time.Now()
})
// Connect WebSocket
if err := wsClient.Connect(); err != nil {
log.Printf("Failed to connect WebSocket for device %s: %v", deviceID, err)
return
}
conn.WebSocket = wsClient
conn.Status.IsConnected = true
log.Printf("WebSocket connected for device %s", deviceID)
// Wait for disconnection
wsClient.Wait()
conn.Status.IsConnected = false
log.Printf("WebSocket disconnected for device %s", deviceID)
}
// UpdateDeviceStatus fetches current status from device
func (app *WebApp) UpdateDeviceStatus(_ string, conn *webtypes.DeviceConnection) {
// Skip status update if client is not available (e.g., in tests)
if conn.Client == nil {
return
}
statusUpdated := false
// Get current now playing
if nowPlaying, err := conn.Client.GetNowPlaying(); err == nil {
conn.Status.NowPlaying = nowPlaying
statusUpdated = true
}
// Get current volume
if volume, err := conn.Client.GetVolume(); err == nil {
conn.Status.Volume = volume
statusUpdated = true
}
// Get presets
if presets, err := conn.Client.GetPresets(); err == nil {
conn.Status.Presets = presets
statusUpdated = true
}
// Update last activity if any status was updated
if statusUpdated {
conn.Status.LastActivity = time.Now()
}
// Get sources
if sources, err := conn.Client.GetSources(); err == nil {
conn.Status.Sources = sources
statusUpdated = true
}
// Get bass (if available)
if bass, err := conn.Client.GetBass(); err == nil {
conn.Status.Bass = bass
statusUpdated = true
}
// Mark as connected if we successfully got at least one status
conn.Status.IsConnected = statusUpdated
conn.Status.LastActivity = time.Now()
}
// HandleDeviceWebSocket handles individual device WebSocket connections for real-time device-specific updates
func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
if deviceID == "" {
http.Error(w, "Device ID required", http.StatusBadRequest)
return
}
device, exists := app.Devices[deviceID]
if !exists {
http.Error(w, "Device not found", http.StatusNotFound)
return
}
conn, err := app.Upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("Device WebSocket upgrade failed for %s: %v", deviceID, err)
return
}
defer conn.Close()
log.Printf("Device WebSocket connected for %s", deviceID)
// Send initial device status
initialMessage := webtypes.WebSocketMessage{
Type: "device_status",
DeviceID: deviceID,
Data: map[string]interface{}{
"info": device.DeviceInfo,
"status": device.Status,
},
}
if err := conn.WriteJSON(initialMessage); err != nil {
log.Printf("Failed to send initial device status: %v", err)
return
}
// Set up ping handler to detect client disconnects
conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
// Set initial read deadline
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
// Handle incoming messages in a separate goroutine
go func() {
defer conn.Close()
for {
if _, _, err := conn.NextReader(); err != nil {
log.Printf("Device WebSocket read error for %s: %v", deviceID, err)
return
}
}
}()
// Send periodic device status updates
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for range ticker.C {
// Send ping to check if client is still connected
if err := conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
log.Printf("Failed to send ping to device WebSocket %s: %v", deviceID, err)
return
}
// Send device status update
statusMessage := webtypes.WebSocketMessage{
Type: "device_status",
DeviceID: deviceID,
Data: map[string]interface{}{
"info": device.DeviceInfo,
"status": device.Status,
},
}
if err := conn.WriteJSON(statusMessage); err != nil {
log.Printf("Failed to send device status update for %s: %v", deviceID, err)
return
}
// If device has active WebSocket connection to SoundTouch device,
// also send any real-time updates from that connection
if device.WebSocket != nil && device.Status.IsConnected {
realtimeMessage := webtypes.WebSocketMessage{
Type: "device_realtime",
DeviceID: deviceID,
Data: map[string]interface{}{
"nowPlaying": device.Status.NowPlaying,
"volume": device.Status.Volume,
"timestamp": time.Now(),
},
}
if err := conn.WriteJSON(realtimeMessage); err != nil {
log.Printf("Failed to send realtime update for %s: %v", deviceID, err)
return
}
}
}
}
+215
View File
@@ -0,0 +1,215 @@
// Package main provides a web UI for controlling Bose SoundTouch devices.
package main
import (
"context"
"embed"
"io/fs"
"log"
"net/http"
"os"
"time"
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/handlers"
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/config"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/go-chi/chi/v5"
"github.com/urfave/cli/v2"
)
//go:embed static
var staticFS embed.FS
func main() {
app := &cli.App{
Name: "soundtouch-web",
Usage: "Web UI for controlling Bose SoundTouch devices",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "port",
Aliases: []string{"p"},
Usage: "HTTP port to listen on",
Value: "8080",
EnvVars: []string{"PORT"},
},
&cli.StringFlag{
Name: "bind",
Usage: "Network interface to bind to",
EnvVars: []string{"BIND_ADDR"},
},
},
Action: func(c *cli.Context) error {
port := c.String("port")
bindAddr := c.String("bind")
addr := ":" + port
if bindAddr != "" {
addr = bindAddr + ":" + port
}
// Create web app without templates (SPA mode)
webApp := handlers.NewWebApp()
// Initialize discovery service
cfg, err := config.LoadFromEnv()
if err != nil {
log.Printf("Failed to load config: %v, using defaults", err)
cfg = config.DefaultConfig()
}
cfg.DiscoveryTimeout = 10 * time.Second
cfg.CacheEnabled = true
discoveryService := discovery.NewUnifiedDiscoveryService(cfg)
// Discover devices on startup
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
webApp.BroadcastDiscoveryStatus("starting", len(webApp.Devices))
discoverDevices(ctx, webApp, discoveryService)
webApp.BroadcastDiscoveryStatus("completed", len(webApp.Devices))
webApp.BroadcastDeviceList()
}()
r := setupRoutes(webApp, discoveryService)
log.Printf("SoundTouch Web UI starting on http://%s", addr)
return http.ListenAndServe(addr, r)
},
}
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}
func setupRoutes(app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) *chi.Mux {
r := chi.NewRouter()
// Static assets (embedded in binary)
subFS, _ := fs.Sub(staticFS, "static")
r.Get("/static/*", http.StripPrefix("/static", http.FileServer(http.FS(subFS))).ServeHTTP)
// Serve index.html for SPA routes
serveIndex := func(w http.ResponseWriter, _ *http.Request) {
data, _ := staticFS.ReadFile("static/index.html")
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write(data)
}
// WebSocket endpoint
r.Get("/ws", app.HandleWebSocket)
// API endpoints
r.Get("/api/devices", app.HandleAPIDevices)
r.Get("/api/device/{id}", app.HandleAPIDevice)
r.Post("/api/discover", func(w http.ResponseWriter, r *http.Request) {
app.HandleAPIDiscover(w, r)
// Trigger discovery
//nolint:contextcheck // Context is created within goroutine
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Broadcast discovery start
app.BroadcastDiscoveryStatus("starting", len(app.Devices))
discoverDevices(ctx, app, discoveryService)
// Broadcast discovery completion and updated device list
app.BroadcastDiscoveryStatus("completed", len(app.Devices))
app.BroadcastDeviceList()
}()
})
// Device control endpoints (GET for most actions, POST for volume/bass)
r.Get("/api/control/{id}/{action}", app.HandleAPIControl)
r.Post("/api/control/{id}/{action}", app.HandleAPIControl)
// TuneIn browse, search, and playback
r.Get("/api/tunein/search", app.HandleTuneInSearch)
r.Get("/api/tunein/navigate", app.HandleTuneInNavigate)
r.Get("/api/tunein/navigate/*", app.HandleTuneInNavigate)
r.Post("/api/tunein/play/{id}", app.HandlePlayTuneIn)
// Enhanced device control endpoints
r.Post("/api/device-key/{id}/{key}", app.HandleDeviceKey)
r.Post("/api/device-volume/{id}/{volume}", app.HandleDirectVolumeControl)
r.Post("/api/device-power/{id}", app.HandleDevicePower)
r.Get("/api/device-power-status/{id}", app.HandleDevicePowerStatus)
r.Get("/api/device-ws/{id}", app.HandleDeviceWebSocket)
// SPA routes - serve index.html for client-side routing
r.Get("/", serveIndex)
r.Get("/devices", serveIndex)
r.Get("/device/*", serveIndex)
return r
}
func discoverDevices(ctx context.Context, app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) {
log.Println("Starting device discovery...")
devices, err := discoveryService.DiscoverDevices(ctx)
if err != nil {
log.Printf("Discovery failed: %v", err)
app.BroadcastDiscoveryStatus("failed", len(app.Devices))
return
}
log.Printf("Found %d devices", len(devices))
for _, device := range devices {
deviceID := device.Host // Use host as unique ID for now
// Skip if we already have this device
if _, exists := app.Devices[deviceID]; exists {
app.Devices[deviceID].LastSeen = time.Now()
continue
}
// Create new device connection
clientConfig := &client.Config{
Host: device.Host,
Port: device.Port,
Timeout: 10 * time.Second,
}
soundTouchClient := client.NewClient(clientConfig)
// Get device info
deviceInfo, err := soundTouchClient.GetDeviceInfo()
if err != nil {
log.Printf("Failed to get device info for %s: %v", device.Host, err)
continue
}
// Create device connection
conn := &webtypes.DeviceConnection{
Client: soundTouchClient,
DeviceInfo: deviceInfo,
LastSeen: time.Now(),
Status: webtypes.DeviceStatus{
IsConnected: false,
LastActivity: time.Now(),
},
}
// Initial status fetch asynchronously to avoid blocking discovery
go app.UpdateDeviceStatus(deviceID, conn)
app.Devices[deviceID] = conn
log.Printf("Added device: %s (%s) at %s", deviceInfo.Name, deviceInfo.Type, device.Host)
}
}
+368
View File
@@ -0,0 +1,368 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/handlers"
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/go-chi/chi/v5"
)
func withChiParams(r *http.Request, params map[string]string) *http.Request {
rctx := chi.NewRouteContext()
for k, v := range params {
rctx.URLParams.Add(k, v)
}
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
}
func TestSPARouting(t *testing.T) {
tests := []struct {
name string
path string
expectedStatus int
expectedHTML bool
}{
{
name: "root path serves HTML",
path: "/",
expectedStatus: http.StatusOK,
expectedHTML: true,
},
{
name: "device path serves HTML",
path: "/device/test-device",
expectedStatus: http.StatusOK,
expectedHTML: true,
},
{
name: "arbitrary path serves HTML",
path: "/some/random/path",
expectedStatus: http.StatusOK,
expectedHTML: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", tt.path, nil)
w := httptest.NewRecorder()
// Simulate SPA routing handler
spaHandler := func(w http.ResponseWriter, r *http.Request) {
// If it's an API route, let it pass through
if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/static/") || strings.HasPrefix(r.URL.Path, "/ws") {
http.NotFound(w, r)
return
}
// Serve the SPA index.html content (simulated)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>SoundTouch Control Center</title>
</head>
<body>
<div id="app">SPA Content</div>
</body>
</html>`))
}
spaHandler(w, req)
if w.Code != tt.expectedStatus {
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
}
if tt.expectedHTML {
contentType := w.Header().Get("Content-Type")
if !strings.Contains(contentType, "text/html") {
t.Errorf("Expected HTML content type, got %s", contentType)
}
body := w.Body.String()
if !strings.Contains(body, "<!doctype html>") {
t.Errorf("Expected HTML content, got: %s", body)
}
}
})
}
}
func TestAPIEndpoints(t *testing.T) {
app := handlers.NewWebApp()
tests := []struct {
name string
path string
method string
expectedStatus int
expectedJSON bool
}{
{
name: "devices API returns JSON",
path: "/api/devices",
method: "GET",
expectedStatus: http.StatusOK,
expectedJSON: true,
},
{
name: "discover API accepts POST",
path: "/api/discover",
method: "POST",
expectedStatus: http.StatusOK,
expectedJSON: true,
},
{
name: "device API with ID",
path: "/api/device/test-device",
method: "GET",
expectedStatus: http.StatusNotFound, // Device won't exist in test
expectedJSON: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, tt.path, nil)
w := httptest.NewRecorder()
switch tt.path {
case "/api/devices":
app.HandleAPIDevices(w, req)
case "/api/discover":
app.HandleAPIDiscover(w, req)
default:
if strings.HasPrefix(tt.path, "/api/device/") {
deviceID := strings.TrimPrefix(tt.path, "/api/device/")
req = withChiParams(req, map[string]string{"id": deviceID})
app.HandleAPIDevice(w, req)
}
}
if w.Code != tt.expectedStatus {
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
}
if tt.expectedJSON {
contentType := w.Header().Get("Content-Type")
if !strings.Contains(contentType, "application/json") {
t.Errorf("Expected JSON content type, got %s", contentType)
}
// Validate JSON response structure
var response webtypes.APIResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Errorf("Invalid JSON response: %v", err)
}
}
})
}
}
func TestAPIResponseFormat(t *testing.T) {
app := handlers.NewWebApp()
req := httptest.NewRequest("GET", "/api/devices", nil)
w := httptest.NewRecorder()
app.HandleAPIDevices(w, req)
var response webtypes.APIResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("Failed to decode JSON response: %v", err)
}
// Check API response structure
if !response.Success {
t.Errorf("Expected success=true, got success=%v", response.Success)
}
if response.Data == nil {
t.Errorf("Expected data field to be present")
}
// Data should be an empty map for no devices
dataMap, ok := response.Data.(map[string]interface{})
if !ok {
t.Errorf("Expected data to be a map, got %T", response.Data)
}
if len(dataMap) != 0 {
t.Errorf("Expected empty device map, got %d devices", len(dataMap))
}
}
func TestControlAPIValidation(t *testing.T) {
app := handlers.NewWebApp()
tests := []struct {
name string
path string
method string
body string
expectedStatus int
chiParams map[string]string
}{
{
name: "missing device ID",
path: "/api/control//play",
method: "GET",
expectedStatus: http.StatusBadRequest,
},
{
name: "invalid control path",
path: "/api/control/device",
method: "GET",
expectedStatus: http.StatusBadRequest,
},
{
name: "unknown action",
path: "/api/control/nonexistent/invalid",
method: "GET",
expectedStatus: http.StatusNotFound,
chiParams: map[string]string{"id": "nonexistent", "action": "invalid"},
},
{
name: "nonexistent device",
path: "/api/control/nonexistent/play",
method: "GET",
expectedStatus: http.StatusNotFound,
chiParams: map[string]string{"id": "nonexistent", "action": "play"},
},
{
name: "unknown action with valid device",
path: "/api/control/testdevice/unknownaction",
method: "GET",
expectedStatus: http.StatusBadRequest,
chiParams: map[string]string{"id": "testdevice", "action": "unknownaction"},
},
}
// Add a mock device for testing unknown action validation
mockDevice := &webtypes.DeviceConnection{
Client: nil,
DeviceInfo: &models.DeviceInfo{Name: "Test Device"},
LastSeen: time.Now(),
Status: webtypes.DeviceStatus{IsConnected: true},
}
app.Devices["testdevice"] = mockDevice
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var req *http.Request
if tt.body != "" {
req = httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(tt.method, tt.path, nil)
}
if tt.chiParams != nil {
req = withChiParams(req, tt.chiParams)
}
w := httptest.NewRecorder()
app.HandleAPIControl(w, req)
if w.Code != tt.expectedStatus {
t.Errorf("Test %s: Expected status %d, got %d. Response: %s", tt.name, tt.expectedStatus, w.Code, w.Body.String())
}
// Validate error response format
contentType := w.Header().Get("Content-Type")
if !strings.Contains(contentType, "application/json") {
t.Errorf("Expected JSON content type, got %s", contentType)
}
var response webtypes.APIResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Errorf("Invalid JSON response: %v", err)
}
if response.Success {
t.Errorf("Expected success=false for error case, got success=true")
}
if response.Error == "" {
t.Errorf("Expected error message, got empty string")
}
})
}
}
func TestWebSocketUpgrade(t *testing.T) {
app := handlers.NewWebApp()
// Test WebSocket upgrade request
req := httptest.NewRequest("GET", "/ws", nil)
req.Header.Set("Connection", "upgrade")
req.Header.Set("Upgrade", "websocket")
req.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
req.Header.Set("Sec-WebSocket-Version", "13")
w := httptest.NewRecorder()
// The actual WebSocket upgrade will fail in test environment,
// but we can check that the handler exists and accepts the request
app.HandleWebSocket(w, req)
// In a real test environment, this would fail with a websocket upgrade error
// We're just checking the handler doesn't panic and processes the request
}
func TestJSONAPIConsistency(t *testing.T) {
app := handlers.NewWebApp()
endpoints := []string{
"/api/devices",
"/api/device/test",
}
for _, endpoint := range endpoints {
t.Run("JSON consistency for "+endpoint, func(t *testing.T) {
req := httptest.NewRequest("GET", endpoint, nil)
w := httptest.NewRecorder()
switch endpoint {
case "/api/devices":
app.HandleAPIDevices(w, req)
default:
if strings.HasPrefix(endpoint, "/api/device/") {
deviceID := strings.TrimPrefix(endpoint, "/api/device/")
req = withChiParams(req, map[string]string{"id": deviceID})
app.HandleAPIDevice(w, req)
}
}
// All API endpoints should return JSON
contentType := w.Header().Get("Content-Type")
if !strings.Contains(contentType, "application/json") {
t.Errorf("Endpoint %s should return JSON, got %s", endpoint, contentType)
}
// All responses should follow APIResponse structure
var response webtypes.APIResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Errorf("Endpoint %s returned invalid JSON: %v", endpoint, err)
}
// Response should have either data or error
if response.Success && response.Data == nil {
t.Errorf("Endpoint %s: success response should have data", endpoint)
}
if !response.Success && response.Error == "" {
t.Errorf("Endpoint %s: error response should have error message", endpoint)
}
})
}
}
File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

+200
View File
@@ -0,0 +1,200 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SoundTouch Control Center</title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
rel="stylesheet"
/>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css"
rel="stylesheet"
/>
<link href="/static/css/app.css" rel="stylesheet" />
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark">
<div class="container">
<a class="navbar-brand" href="#" onclick="showPage('devices')">
<i class="bi bi-speaker"></i>
SoundTouch Control
</a>
<div class="navbar-nav ms-auto">
<a
class="nav-link"
href="#"
onclick="showPage('devices')"
title="Home"
>
<i class="bi bi-house"></i>
</a>
<a
class="nav-link tunein-nav-link"
href="#"
onclick="showPage('tunein')"
title="TuneIn Browse"
>
<img
src="/static/img/tunein-mono.svg"
alt="TuneIn"
class="tunein-nav-icon"
/>
</a>
<a
class="nav-link"
href="#"
onclick="discoverDevices()"
title="Discover Devices"
>
<i class="bi bi-search"></i>
</a>
<button
class="theme-toggle nav-link"
onclick="toggleTheme()"
title="Toggle Dark Mode"
>
<i id="theme-icon" class="bi bi-moon"></i>
</button>
</div>
</div>
</nav>
<div class="container mt-4">
<!-- Device List Page -->
<div id="devices-page" class="page active">
<div
class="d-flex justify-content-between align-items-center mb-4"
>
<h2>Your SoundTouch Devices</h2>
<button class="btn btn-primary" onclick="discoverDevices()">
<i class="bi bi-search"></i>
Discover Devices
</button>
</div>
<div id="devices-loading" class="loading-spinner"></div>
<div id="devices-list" class="row">
<!-- Device cards will be inserted here by JavaScript -->
</div>
<div
id="no-devices"
style="display: none"
class="text-center py-5"
>
<i class="bi bi-speaker display-1 text-muted"></i>
<h4 class="mt-3">No Devices Found</h4>
<p class="text-muted">
Click "Discover Devices" to search for SoundTouch
speakers on your network.
</p>
<button class="btn btn-primary" onclick="discoverDevices()">
<i class="bi bi-search"></i>
Start Discovery
</button>
</div>
</div>
<!-- TuneIn Browse Page -->
<div id="tunein-page" class="page">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2><img src="/static/img/tunein-dark.svg" alt="TuneIn" class="tunein-heading-icon me-2" />TuneIn Browse</h2>
</div>
<div class="tunein-search-bar mb-3">
<div class="input-group">
<input
type="text"
id="tunein-search-input"
class="form-control"
placeholder="Search stations, podcasts..."
/>
<button
class="btn btn-primary"
onclick="tuneInSearch(document.getElementById('tunein-search-input').value)"
>
<i class="bi bi-search"></i>
Search
</button>
<button
class="btn btn-outline-secondary"
onclick="tuneInBrowse()"
title="Browse top level"
>
<i class="bi bi-house"></i>
</button>
</div>
</div>
<nav id="tunein-breadcrumb" class="mb-3" style="display: none">
<!-- filled by JavaScript -->
</nav>
<div id="tunein-results">
<!-- filled by JavaScript -->
</div>
</div>
<!-- Device Control Page -->
<div id="device-page" class="page">
<div class="back-button">
<button
class="btn btn-outline-secondary"
onclick="showPage('devices')"
>
<i class="bi bi-arrow-left"></i>
Back to Devices
</button>
</div>
<div id="device-content">
<!-- Device control content will be inserted here by JavaScript -->
</div>
</div>
</div>
<footer class="footer">
<div class="container text-center">
<small>
SoundTouch Web Control Interface -
<a
href="https://github.com/gesellix/Bose-SoundTouch"
target="_blank"
class="text-decoration-none"
>
Open Source Project
</a>
</small>
</div>
</footer>
<!-- Toast container for notifications -->
<div class="toast-container"></div>
<!-- Device picker for TuneIn playback -->
<div class="modal fade" id="devicePickerModal" tabindex="-1" aria-labelledby="devicePickerLabel" aria-hidden="true">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title" id="devicePickerLabel">
<i class="bi bi-speaker me-2"></i>Play on device
</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-2" id="devicePickerList">
<!-- device buttons filled by JavaScript -->
</div>
</div>
</div>
</div>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- Application JavaScript -->
<script src="/static/js/app.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
// Package webtypes contains type definitions for the SoundTouch web UI.
package webtypes
import (
"time"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
// SoundTouchClient defines the interface for SoundTouch client operations
type SoundTouchClient interface {
Play() error
Pause() error
Stop() error
NextTrack() error
PrevTrack() error
SetVolume(level int) error
SetBass(level int) error
SelectPreset(id int) error
SelectSource(source, account string) error
SendKey(key string) error
GetDeviceInfo() (*models.DeviceInfo, error)
GetNowPlaying() (*models.NowPlaying, error)
GetVolume() (*models.Volume, error)
GetPresets() (*models.Presets, error)
GetSources() (*models.Sources, error)
GetBass() (*models.Bass, error)
NewWebSocketClient(config interface{}) *client.WebSocketClient
}
// DeviceConnection wraps a SoundTouch client with WebSocket connection
type DeviceConnection struct {
Client *client.Client
WebSocket *client.WebSocketClient
DeviceInfo *models.DeviceInfo
LastSeen time.Time
Status DeviceStatus
}
// DeviceStatus represents the current device state
type DeviceStatus struct {
NowPlaying *models.NowPlaying `json:"nowPlaying,omitempty"`
Volume *models.Volume `json:"volume,omitempty"`
Presets *models.Presets `json:"presets,omitempty"`
Sources *models.Sources `json:"sources,omitempty"`
Bass *models.Bass `json:"bass,omitempty"`
IsConnected bool `json:"isConnected"`
LastActivity time.Time `json:"lastActivity"`
}
// APIResponse is a standard JSON response wrapper
type APIResponse struct {
Success bool `json:"success"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
// VolumeRequest represents a volume control request
type VolumeRequest struct {
Level int `json:"level"`
}
// BassRequest represents a bass control request
type BassRequest struct {
Level int `json:"level"`
}
// WebSocketMessage represents messages sent over WebSocket
type WebSocketMessage struct {
Type string `json:"type"`
DeviceID string `json:"deviceId,omitempty"`
Data interface{} `json:"data,omitempty"`
}
+280
View File
@@ -0,0 +1,280 @@
// Package types contains tests for type definitions.
package webtypes
import (
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
func TestAPIResponse(t *testing.T) {
tests := []struct {
name string
response APIResponse
wantJSON string
}{
{
name: "success response",
response: APIResponse{
Success: true,
Data: map[string]string{"message": "OK"},
},
wantJSON: `{"success":true,"data":{"message":"OK"}}`,
},
{
name: "error response",
response: APIResponse{
Success: false,
Error: "Something went wrong",
},
wantJSON: `{"success":false,"error":"Something went wrong"}`,
},
{
name: "success with nil data",
response: APIResponse{
Success: true,
},
wantJSON: `{"success":true}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Test that the struct fields are correctly set
if tt.response.Success != (tt.name == "success response" || tt.name == "success with nil data") {
t.Errorf("Expected success to match test case")
}
})
}
}
func TestVolumeRequest(t *testing.T) {
tests := []struct {
name string
req VolumeRequest
level int
}{
{"zero volume", VolumeRequest{Level: 0}, 0},
{"mid volume", VolumeRequest{Level: 50}, 50},
{"max volume", VolumeRequest{Level: 100}, 100},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.req.Level != tt.level {
t.Errorf("Expected level %d, got %d", tt.level, tt.req.Level)
}
})
}
}
func TestBassRequest(t *testing.T) {
tests := []struct {
name string
req BassRequest
level int
}{
{"min bass", BassRequest{Level: -9}, -9},
{"neutral bass", BassRequest{Level: 0}, 0},
{"max bass", BassRequest{Level: 9}, 9},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.req.Level != tt.level {
t.Errorf("Expected level %d, got %d", tt.level, tt.req.Level)
}
})
}
}
func TestWebSocketMessage(t *testing.T) {
tests := []struct {
name string
msg WebSocketMessage
wantType string
}{
{
name: "devices message",
msg: WebSocketMessage{
Type: "devices",
Data: map[string]interface{}{"device1": "data"},
},
wantType: "devices",
},
{
name: "status update message",
msg: WebSocketMessage{
Type: "status_update",
DeviceID: "device1",
Data: DeviceStatus{IsConnected: true},
},
wantType: "status_update",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.msg.Type != tt.wantType {
t.Errorf("Expected type %s, got %s", tt.wantType, tt.msg.Type)
}
})
}
}
func TestDeviceConnection(t *testing.T) {
deviceInfo := &models.DeviceInfo{
Name: "Test Speaker",
Type: "SoundTouch 30",
NetworkInfo: []models.NetworkInfo{
{MacAddress: "TEST123", IPAddress: "192.168.1.100"},
},
}
nowPlaying := &models.NowPlaying{
Track: "Test Track",
Artist: "Test Artist",
Album: "Test Album",
PlayStatus: models.PlayStatusPlaying,
Source: "SPOTIFY",
}
volume := &models.Volume{
ActualVolume: 50,
MuteEnabled: false,
}
conn := &DeviceConnection{
DeviceInfo: deviceInfo,
LastSeen: time.Now(),
Status: DeviceStatus{
NowPlaying: nowPlaying,
Volume: volume,
IsConnected: true,
LastActivity: time.Now(),
},
}
t.Run("device connection fields", func(t *testing.T) {
if conn.DeviceInfo.Name != "Test Speaker" {
t.Errorf("Expected device name 'Test Speaker', got '%s'", conn.DeviceInfo.Name)
}
if conn.Status.NowPlaying.Track != "Test Track" {
t.Errorf("Expected track 'Test Track', got '%s'", conn.Status.NowPlaying.Track)
}
if conn.Status.Volume.ActualVolume != 50 {
t.Errorf("Expected volume 50, got %d", conn.Status.Volume.ActualVolume)
}
if !conn.Status.IsConnected {
t.Error("Expected device to be connected")
}
})
}
func TestDeviceStatus(t *testing.T) {
status := DeviceStatus{
NowPlaying: &models.NowPlaying{
Track: "Test Track",
PlayStatus: models.PlayStatusPlaying,
},
Volume: &models.Volume{
ActualVolume: 75,
MuteEnabled: false,
},
Bass: &models.Bass{
ActualBass: 3,
},
IsConnected: true,
LastActivity: time.Now(),
}
t.Run("device status fields", func(t *testing.T) {
if status.NowPlaying == nil {
t.Error("Expected now playing to be set")
}
if status.Volume == nil {
t.Error("Expected volume to be set")
}
if status.Bass == nil {
t.Error("Expected bass to be set")
}
if !status.IsConnected {
t.Error("Expected device to be connected")
}
if status.LastActivity.IsZero() {
t.Error("Expected last activity to be set")
}
})
t.Run("nil fields", func(t *testing.T) {
emptyStatus := DeviceStatus{}
if emptyStatus.NowPlaying != nil {
t.Error("Expected now playing to be nil")
}
if emptyStatus.Volume != nil {
t.Error("Expected volume to be nil")
}
if emptyStatus.IsConnected {
t.Error("Expected device to be disconnected by default")
}
})
}
// Benchmark tests
func BenchmarkAPIResponse(b *testing.B) {
response := APIResponse{
Success: true,
Data: map[string]string{"message": "OK"},
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = response.Success
_ = response.Data
}
}
func BenchmarkDeviceStatus(b *testing.B) {
status := DeviceStatus{
NowPlaying: &models.NowPlaying{Track: "Test Track"},
Volume: &models.Volume{ActualVolume: 50},
IsConnected: true,
LastActivity: time.Now(),
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = status.IsConnected
_ = status.NowPlaying.Track
_ = status.Volume.ActualVolume
}
}
func BenchmarkWebSocketMessage(b *testing.B) {
msg := WebSocketMessage{
Type: "status_update",
DeviceID: "device1",
Data: DeviceStatus{
IsConnected: true,
LastActivity: time.Now(),
},
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = msg.Type
_ = msg.DeviceID
_ = msg.Data
}
}
+46
View File
@@ -0,0 +1,46 @@
services:
soundtouch-service:
build:
context: .
target: soundtouch-service
networks:
- soundtouch-test-net
volumes:
- ./tests/integration/testdata:/app/data
environment:
- SPOTIFY_CLIENT_ID=mock-id
- SPOTIFY_CLIENT_SECRET=mock-secret
- SPOTIFY_TOKEN_URL=http://spotify-mock:8080/api/token
- SPOTIFY_API_BASE=http://spotify-mock:8080
- AMAZON_CLIENT_ID=mock-amazon-id
- AMAZON_CLIENT_SECRET=mock-amazon-secret
- AMAZON_TOKEN_URL=http://amazon-mock:8080/auth/o2/token
- AMAZON_PROFILE_URL=http://amazon-mock:8080/user/profile
spotify-mock:
image: golang:1.26.2-alpine
container_name: spotify-mock
working_dir: /app
volumes:
- .:/app
command: go run ./cmd/mock-spotify/main.go -port 8080
ports:
- "8081:8080"
networks:
- soundtouch-test-net
amazon-mock:
image: golang:1.26.2-alpine
container_name: amazon-mock
working_dir: /app
volumes:
- .:/app
command: go run ./cmd/mock-amazon/main.go -port 8080
ports:
- "8082:8080"
networks:
- soundtouch-test-net
networks:
soundtouch-test-net:
name: soundtouch-test-net
+1 -1
View File
@@ -1,6 +1,6 @@
services:
soundtouch-service:
image: ghcr.io/gesellix/bose-soundtouch:latest
image: ghcr.io/gesellix/bose-soundtouch:${SOUNDTOUCH_VERSION:-latest}
# build: .
container_name: soundtouch-service
# Linux only, required for discovery. Swarm requires host network at the task level.
Binary file not shown.
+114
View File
@@ -0,0 +1,114 @@
# Bose SoundTouch Device Setup Flow
This document details the multi-step process required to fully set up a Bose SoundTouch device, as derived from the Stockholm firmware (`setup/js/`) analysis.
A complete setup flow involves a sequence of local (WebSocket) and cloud (HTTP) actions that move the device from a factory-reset state to a fully registered, functional system.
## 1. Local Coordination Stage (WebSocket)
Before a device can be controlled, it must be configured on the local network and named. These actions occur via a WebSocket connection to the device on port 8080.
### 1.1 Language Configuration (Optional)
If the device is in a factory-reset state, the UI typically ensures the device language matches the user's choice.
- **WebSocket Action**: `set_language`
- **Internal Logic**: `SetupWizard.js` handles this via `set_device_language`.
### 1.2 Network Configuration (WiFi)
Configures the device to connect to a specific wireless access point.
- **File Reference**: `setup/js/workflow_wifi_setup.js`
- **Logic**: Triggers a site survey, then sends SSID and credentials.
- **WebSocket Command**: `set_WIFI_OLED` or similar internal method calls to configure the network profile.
### 1.3 Device Naming (Rename Step)
Assigns a user-friendly name (e.g., "Living Room") to the device.
- **File Reference**: `setup/js/workflow_rename.js`
- **WebSocket Action**: `name`
- **XML Payload**:
```xml
<name>Living Room</name>
```
- **Implementation**: The `RenameDevices.do_rename_devices()` function sends this to the device. The device then updates its local name and mDNS/SSDP broadcasts.
## 2. Cloud Interaction Stage (HTTP)
The device needs to be linked to a Bose "Marge" account to enable cloud-based features and music services.
### 2.1 Account Creation (Registration)
If a user doesn't have an account, the setup client creates one.
- **File Reference**: `setup/js/workflow_marge.js`
- **Cloud Endpoint**: `POST https://streaming.bose.com/streaming/account`
- **Payload**: XML containing name, email, password, and country.
- **Content-Type**: `application/vnd.bose.customer-v1.0+xml`
### 2.2 Cloud Authentication (Login)
The setup client must obtain a valid `accountId` and `userAuthToken` to pair the device.
- **File Reference**: `setup/js/workflow_marge.js`
- **Cloud Endpoint**: `POST https://streaming.bose.com/streaming/account/login`
- **Payload**: XML containing username and password.
- **Content-Type**: `application/vnd.bose.streaming-v1.2+xml`
- **Result**: Returns a session token in the `Credentials` response header and the user's `account ID` in the XML body.
## 3. Registration Bridge (WebSocket to Cloud)
This is the final "pairing" step where the client tells the device which account it belongs to.
### 3.1 Device Registration (The "Pair" Step)
The client sends the user's credentials to the device, which then registers itself with the cloud.
- **File Reference**: `setup/js/workflow_add_devices.js`
- **WebSocket Action**: `setMargeAccount`
- **XML Payload**:
```xml
<PairDeviceWithAccount>
<accountId>12345</accountId>
<userAuthToken>jGwE... (truncated)</userAuthToken>
</PairDeviceWithAccount>
```
- **Device Reaction**: Upon receiving this, the device makes its own outbound HTTP POST to the Marge service:
`POST https://streaming.bose.com/{accountId}/devices`
## 4. Finalization
Once the registration is complete, the setup application (Stockholm) performs final cleanup. It's important to distinguish between **App State** (the Stockholm UI's persistent settings) and **Device State** (the physical speaker's configuration).
### 4.1 Exiting Setup Mode (App Settings)
The Stockholm app communicates with its "native container" (the WebView bridge on iOS/Android/Windows/macOS) using a `setData` command in **JSON format**. This is an internal message to the application's persistent storage, **not a network command sent to the physical speaker**.
This command tells the Stockholm app which page to load on startup, effectively marking the setup as complete in the UI.
- **Internal Command**: `setData`
- **Parameter**: `startupPage`
- **Normal Value**: `index.html` (Normal mode)
- **Setup Value**: `setup/index.html` (Setup mode)
**JSON Payload (Internal to Stockholm App)**:
```json
{
"method": "setData",
"params": {
"name": "startupPage",
"value": "index.html"
}
}
```
**Other Common Internal Parameters**:
- `changeStartupPage`: Set to `false` after a successful setup or update.
- `tipsEnabled`: Set to `false` to suppress the "Getting Started" tutorials.
- `promptUpdate`: Set to `true` if a firmware update was deferred during setup.
### 4.2 Device Finalization
The physical speaker considers the setup "done" once it successfully processes the `<PairDeviceWithAccount>` XML message and completes its own handshake with the Marge cloud. There is no specific "Finalize" XML command sent to the speaker; the successful registration is the signal.
The `SetupWizard.js` calls `single_device_setup_done()` to trigger the internal `setData` updates described above. If these are not saved in the app's local storage, the Stockholm UI may return to the setup flow on next launch, even if the speaker is already paired.
---
## Summary of Scriptable Requirements
To automate a device setup using a custom tool (like `soundtouch-cli`), you must perform the following:
1. **Configure WiFi**: (Assumed if device is reachable over IP).
2. **Set Name**: Send the `<name>` WebSocket message (XML) to update the device identity.
3. **Obtain Token**: Authenticate against the cloud service (Marge) via HTTP.
4. **Pair Device**: Send the `<PairDeviceWithAccount>` WebSocket message (XML) with the account ID and token.
**Note**: The JSON `setData` commands are only necessary if you are building/controlling a version of the Stockholm UI itself. They are not required to configure the physical hardware.
+66
View File
@@ -0,0 +1,66 @@
# Technical Proposal: External Service Provider Abstraction
This document outlines a strategy to refactor the SoundTouch Service's content handling into a modular provider-based system.
## 1. Problem Statement
Currently, content handling for BMX (Bose Media Exchange) services like TuneIn or RadioBrowser is deeply intertwined with the HTTP handlers and XML models. Adding a new content provider (e.g., Local Media, Podcast RSS) requires modifying several files and duplicating boilerplate code for HTTP requests and error handling.
## 2. Proposed Architecture
### 2.1 The Provider Interface
We define a generic `ContentProvider` interface that abstracts away the source-specific logic (API calls, data parsing).
```go
package provider
import "github.com/gesellix/bose-soundtouch/pkg/models"
type ContentProvider interface {
// ID returns the unique identifier for this provider (e.g. "RADIO_BROWSER")
ID() string
// Resolve returns playback details for a given content identifier
Resolve(id string) (*models.BmxPlaybackResponse, error)
// Search allows finding content within this provider
Search(query string) ([]models.ContentItem, error)
}
```
### 2.2 Provider Registry
A central registry in `soundtouch-service` manages the lifecycle and selection of providers.
```go
type Registry struct {
providers map[string]ContentProvider
}
func (r *Registry) Register(p ContentProvider) { ... }
func (r *Registry) Get(id string) ContentProvider { ... }
```
## 3. Implementation Plan
### 3.1 Phase 1: Modularize RadioBrowser
1. **Extract Logic**: Move current RadioBrowser logic from `bmx.go` into a new package `pkg/service/providers/radiobrowser`.
2. **Add Failover**: Implement the **API Failover** logic inspired by OpenCloudTouch.
- Maintain a list of active RadioBrowser mirrors (e.g., `de1.api.radio-browser.info`, `nl1.api.radio-browser.info`).
- Implement a round-robin or health-based selection strategy.
3. **Implements Interface**: Ensure the new package satisfies the `ContentProvider` interface.
### 3.2 Phase 2: Refactor BMX Handlers
- Update `HandleTuneInPlayback` and `HandleOrionPlayback` to use the registry.
- The handlers will look up the provider based on the request context or URL parameters and delegate the resolution.
### 3.3 Phase 3: Dynamic Service Advertising
- Modify `HandleBMXRegistry` to dynamically generate the `bmx_services.json` content based on the currently registered and enabled providers.
## 4. Benefits
- **Resilience**: Centralized error handling and failover strategies for all external APIs.
- **Extensibility**: New services can be added by simply implementing the interface and registering them at startup.
- **Testability**: Providers can be unit-tested in isolation without mocking the entire HTTP server stack.
- **Unified UI**: A future Web UI can query the registry to show available content sources and their statuses.
## 5. Next Steps
1. Refine the `ContentProvider` interface to include metadata (icons, user-friendly names).
2. Create a prototype for the `radiobrowser` provider with failover support.
+45
View File
@@ -0,0 +1,45 @@
# Parity Analysis: Bose-SoundTouch (Go) vs. OpenCloudTouch (Python)
This document provides a comparative analysis of the current Go implementation and the `scheilch/opencloudtouch` project, identifying functional gaps and potential improvements.
## 1. Core Architecture and Language
- **Bose-SoundTouch (Go)**: A high-performance, strongly typed backend with a CLI and background service. Focuses on full API coverage, parity testing, and robust hardware control (DSP, zones).
- **OpenCloudTouch (OCT)**: A modern full-stack application (FastAPI + React/TypeScript). Prioritizes user experience with a web-based setup wizard and a clean abstraction for internet radio.
## 2. Functional Comparison
| Feature | Bose-SoundTouch (Go) | OpenCloudTouch (Python) |
|:------------------------|:-----------------------------------------------------------|:------------------------------------------------------------------------|
| **Setup Experience** | CLI-driven or manual API calls for migration (SSH, XML). | Web-based **Setup Wizard** guides through SSH, backup, and redirection. |
| **Radio Support** | Static integration of **RadioBrowser** and TuneIn. | Dynamic **RadioBrowserAdapter** with automatic **API Failover**. |
| **Commercial Services** | Deep integration (Spotify priming, Pandora, Deezer, etc.). | Basic support, focus is on local content and radio. |
| **Hardware Control** | Extensive (Bass, Treble, Soundbar levels, Clock display). | Basic playback and zone controls. |
| **Cloud Emulation** | High-fidelity parity (mirroring, discrepancy logging). | Functional emulation for local preset/recent persistence. |
| **Notifications** | Built-in **TTS** and custom URL audio alerts. | Not a primary focus. |
## 3. Key Strengths of OpenCloudTouch
- **Guided Onboarding**: The setup wizard reduces the entry barrier for non-technical users significantly.
- **Resilient Radio**: The API failover for RadioBrowser ensures continuous service even if specific community-hosted API instances go offline.
- **Modern API Stack**: Uses OpenAPI and generated TypeScript types for a seamless frontend integration.
- **Provider Abstraction**: A cleaner internal separation between the "Bose World" (XML/BMX) and external content providers (RadioBrowser).
## 4. Suggested Improvements for Bose-SoundTouch
### A. Web-based Setup Wizard (High Priority)
- Implement a state-driven wizard in the `soundtouch-service` to handle:
- SSH activation (checking `/remote_services` via USB).
- Automated backup of speaker configuration.
- Verification of DNS/Hosts redirection.
- Expose this via a simple embedded Web UI (using Go's `embed` package).
### B. RadioBrowser Failover (Medium Priority)
- Adapt the failover logic from OCT:
- Periodically refresh the list of available RadioBrowser API servers.
- Implement a retry mechanism that switches servers on 5xx errors or timeouts.
### C. External Service Abstraction (Medium Priority)
- Refactor the hardcoded BMX logic into a more modular **Provider System** (see `EXTERNAL-SERVICES-ABSTRACTION.md`).
- This will allow easier addition of new sources (e.g., local DLNA, generic M3U playlists) without touching the core BMX handlers.
## 5. Summary
While our Go project provides the most complete technical coverage of SoundTouch hardware and commercial services, OpenCloudTouch sets a higher standard for **user onboarding** and **service resilience** for community-driven content. Integrating a setup wizard and a more robust radio backend would make our project significantly more accessible and reliable.
+31 -15
View File
@@ -8,34 +8,50 @@ This document provides a comparative analysis of the current Go implementation a
## 2. Functional Comparison
| Feature | Bose-SoundTouch (Go) | SoundCork (Python) |
|:---------------------|:-------------------------------------------------|:----------------------------------------------------------------------------------------|
| **Group Management** | Placeholder handlers (return `<group/>` or 404). | Active group management (`groups.py`), supporting `/addGroup` and stereo pairing logic. |
| **BMX Services** | Supports TuneIn, Orion, and custom streams. | More modular `bmx_services.json` registry with broader mock support. |
| **Persistence** | Mixed JSON/XML datastore. | Pure XML-based persistence per device/account. |
| **Admin UI** | CLI-based (`soundtouch-cli`) or API-driven. | Draft Web UI for device discovery and account management (`admin.py`). |
| **Discovery** | Integrated setup tools and SSDP/MDNS awareness. | Leverages `bosesoundtouchapi` Python library for active discovery. |
| Feature | Bose-SoundTouch (Go) | SoundCork (Python) |
|:---------------------|:----------------------------------------------------------------------------------------------------------------------|:----------------------------------------------------------------------------------------|
| **Group Management** | Full CRUD: `POST /group`, `POST /group/{id}`, `DELETE /group/{id}` with XML datastore persistence (`Group_{id}.xml`). | Active group management (`groups.py`), supporting `/addGroup` and stereo pairing logic. |
| **ZeroConf Priming** | Full DH key exchange + encrypted blob; fallback to `tokenType=accesstoken` for older firmware. | Simple `tokenType=accesstoken` push only; token expires after ~60 minutes. |
| **BMX Services** | Supports TuneIn, Orion, and custom streams. | More modular `bmx_services.json` registry with broader mock support. |
| **Persistence** | Mixed JSON/XML datastore. | Pure XML-based persistence per device/account. |
| **Admin UI** | CLI-based (`soundtouch-cli`) or API-driven. | Draft Web UI for device discovery and account management (`admin.py`). |
| **Discovery** | Integrated setup tools and SSDP/MDNS awareness. | Leverages `bosesoundtouchapi` Python library for active discovery. |
## 3. Key Strengths of SoundCork
- **Group Pairing Logic**: Includes logic to manage master/slave relationships for SoundTouch 10 stereo pairs.
- **Service Extensibility**: JSON-based registry for BMX services makes it easier to mock multiple providers (SiriusXM, Spotify) without code changes.
- **Mock Coverage**: Better coverage of "dummy" endpoints that respond with plausible XML (e.g., `customerSupport`).
- ~~**Mock Coverage**: Better coverage of "dummy" endpoints that respond with plausible XML (e.g., `customerSupport`).~~ **Addressed**: AfterTouch's `HandleNotFound` (registered via `r.NotFound`) logs every unimplemented endpoint as `[UNHANDLED]` and forwards the request to the Bose upstream via `HandleBoseProxy`. This provides at least the same coverage as static dummy responses, while also aiding discovery of new endpoints.
## 4. Suggested Implementation Steps for Bose-SoundTouch
### A. Implement Full Group Support (High Priority)
- Add logic to `pkg/service/marge` to handle `/addGroup` and `/updateGroup`.
- Persist group memberships in the datastore to allow speakers to function as stereo pairs or multi-room zones.
### A. Implement Full Group Support (Completed)
- `POST /group`, `POST /group/{id}`, `DELETE /group/{id}` implemented in `pkg/service/handlers/handlers_marge.go`.
- Group CRUD persisted in XML datastore (`Group_{id}.xml`) via `pkg/service/datastore/datastore.go`.
- `GET /group` on device registration reads the group the device belongs to.
### B. Modularize BMX Registry (Medium Priority)
### B. Proper ZeroConf Spotify Blob (Completed)
- Full Spotify Connect ZeroConf protocol implemented in `pkg/service/spotify/zeroconf.go`.
- Flow: `getInfo` (fetch speaker DH public key) → 768-bit DH key exchange → AES-128-CTR encrypted `LoginCredentials` protobuf blob → `addUser`.
- Speaker can self-refresh credentials independently; no periodic re-priming needed for token expiry.
- Automatic fallback to `tokenType=accesstoken` if `getInfo` fails (older firmware without DH support).
- See `docs/concepts/spotify-priming-strategy.md` for full protocol details.
- **Remaining gap**: Background watchdog to re-prime devices that lose their session (reboot / power loss). Not required for token expiry on modern firmware; only needed for the "speaker rebooted and lost state" recovery path and for older firmware on the fallback path (~45 min token expiry).
### C. Modularize BMX Registry (Medium Priority)
- Extract the hardcoded service list in `HandleBMXRegistry` into an external `bmx-services.json` file.
- Allow users to customize which mocked services are advertised to the speaker.
### C. Enhanced Source Management (Medium Priority)
### D. Enhanced Source Management (Medium Priority)
- Refine source learning logic to ensure all `sourceAccount` and `sourceName` metadata is correctly captured during synchronization, using patterns from `soundcork`'s `learnSource`.
### D. Basic Admin Web UI (Low Priority)
### E. Basic Admin Web UI (Low Priority)
- Develop a minimal internal status page to list active accounts and connected devices, improving usability over raw API calls.
## 5. Summary
While our Go implementation is structurally more consistent with recent reference recordings (e.g., `buttonNumber`, detailed `components`), SoundCork provides better coverage of multi-device coordination (Groups) and service emulation (BMX) that we should adopt for a more complete offline experience.
Group support and ZeroConf Spotify priming are now feature-complete in AfterTouch. The Go implementation is structurally more consistent with recent reference recordings (e.g., `buttonNumber`, detailed `components`). SoundCork's remaining functional advantages are:
- **BMX service extensibility**: the `bmx_services.json` registry makes it trivial to add or mock new streaming providers without code changes (step C above).
- **Group pairing logic**: master/slave relationship management for SoundTouch 10 stereo pairs goes beyond the CRUD AfterTouch implements.
For the broader ecosystem context (feature matrix across all community projects, AfterTouch open tasks, and cross-project observations) see [docs/analysis/bose-soundtouch-community-tools.md](analysis/bose-soundtouch-community-tools.md).
+44 -44
View File
@@ -21,7 +21,7 @@ This document describes the most important patterns for the Bose SoundTouch API
**Key Aspects:**
- **Native Builds**: Full API functionality for CLI and server
- **WASM Builds**: Browser-compatible subset functionality
- **WASM Builds**: Browser-compatible subset functionality
- **Cross-Platform**: Linux, macOS, Windows support
- **Embedded Assets**: Web UI directly embedded in binary
@@ -66,7 +66,7 @@ func (c *Client) GetNowPlaying() (*models.NowPlaying, error) {
return nil, err
}
defer resp.Body.Close()
var nowPlaying models.NowPlaying
err = xml.NewDecoder(resp.Body).Decode(&nowPlaying)
return &nowPlaying, err
@@ -77,7 +77,7 @@ func (c *Client) GetNowPlaying() (*models.NowPlaying, error) {
```go
func (c *Client) SendKey(key models.Key) error {
keyXML := fmt.Sprintf(`<key state="press" sender="GoClient">%s</key>`, key)
resp, err := c.httpClient.Post(
c.baseURL+"/key",
"application/xml",
@@ -117,14 +117,14 @@ func (d *DiscoveryService) DiscoverDevices() ([]Device, error) {
return nil, err
}
defer conn.Close()
// Send M-SEARCH request
searchRequest := "M-SEARCH * HTTP/1.1\r\n" +
"HOST: 239.255.255.250:1900\r\n" +
"MAN: \"ssdp:discover\"\r\n" +
"ST: urn:schemas-upnp-org:device:MediaRenderer:1\r\n" +
"MX: 3\r\n\r\n"
// Implementation details...
return devices, nil
}
@@ -158,13 +158,13 @@ func (e *EventClient) Subscribe(eventType string, handler EventHandler) {
func (e *EventClient) Start() error {
u := url.URL{Scheme: "ws", Host: e.client.host + ":8090", Path: "/"}
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
if err != nil {
return err
}
e.conn = conn
go e.eventLoop()
return nil
}
@@ -184,7 +184,7 @@ func (e *EventClient) eventLoop() {
}
return
}
if handler, exists := e.handlers[event.Type]; exists {
go handler(event)
}
@@ -220,7 +220,7 @@ func wasmDiscoverDevices(this js.Value, args []js.Value) interface{} {
handler := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
go func() {
devices, err := discovery.NewDiscoveryService(5*time.Second).DiscoverDevices()
result := make(map[string]interface{})
if err != nil {
result["error"] = err.Error()
@@ -228,13 +228,13 @@ func wasmDiscoverDevices(this js.Value, args []js.Value) interface{} {
devicesJSON, _ := json.Marshal(devices)
result["devices"] = string(devicesJSON)
}
// Call JavaScript callback
args[0].Invoke(js.ValueOf(result))
}()
return nil
})
return handler
}
```
@@ -280,7 +280,7 @@ func main() {
if err != nil {
return err
}
for i, device := range devices {
fmt.Printf("%d: %s (%s)\n", i+1, device.Name, device.Host)
}
@@ -300,7 +300,7 @@ func main() {
},
},
}
app.Run(os.Args)
}
@@ -311,7 +311,7 @@ func getClientFromContext(c *cli.Context) *client.Client {
devices, _ := discovery.DiscoverDevices()
deviceHost = selectDeviceInteractive(devices)
}
return client.NewClient(deviceHost, 8090)
}
```
@@ -327,34 +327,34 @@ var webAssets embed.FS
func main() {
mux := http.NewServeMux()
// Embedded web assets
webFS, err := fs.Sub(webAssets, "web")
if err != nil {
log.Fatal(err)
}
// SPA routing
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.FileServer(http.FS(webFS)).ServeHTTP(w, r)
return
}
data, err := webAssets.ReadFile("web/index.html")
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html")
w.Write(data)
})
// API endpoints
mux.HandleFunc("/api/devices", handleDeviceDiscovery)
mux.HandleFunc("/api/client/", handleClientProxy)
log.Println("SoundTouch Web UI starting on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
@@ -370,36 +370,36 @@ func handleClientProxy(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid path", http.StatusBadRequest)
return
}
deviceIP := pathParts[3]
apiPath := "/" + strings.Join(pathParts[4:], "/")
// Proxy request to SoundTouch device
targetURL := fmt.Sprintf("http://%s:8090%s", deviceIP, apiPath)
proxyReq, err := http.NewRequest(r.Method, targetURL, r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Copy headers
for k, v := range r.Header {
proxyReq.Header[k] = v
}
resp, err := http.DefaultClient.Do(proxyReq)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer resp.Body.Close()
// Enable CORS
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
// Copy response
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
@@ -448,7 +448,7 @@ func (p *PlayStatus) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error
if err := d.DecodeElement(&s, &start); err != nil {
return err
}
switch s {
case string(PlayStatusPlaying), string(PlayStatusPaused), string(PlayStatusStopped):
*p = PlayStatus(s)
@@ -469,41 +469,41 @@ type Config struct {
// Server configuration
WebPort int `env:"WEB_PORT" default:"8080"`
APITimeout time.Duration `env:"API_TIMEOUT" default:"10s"`
// Discovery configuration
DiscoveryTimeout time.Duration `env:"DISCOVERY_TIMEOUT" default:"5s"`
CacheDevices bool `env:"CACHE_DEVICES" default:"true"`
// CORS configuration (for web proxy)
CORSOrigins []string `env:"CORS_ORIGINS" default:"*"`
// Logging
LogLevel string `env:"LOG_LEVEL" default:"info"`
}
func Load() Config {
var cfg Config
// Load from .env file
loadDotEnv()
// Parse environment variables with reflection
parseEnvVars(&cfg)
return cfg
}
func parseEnvVars(cfg interface{}) {
v := reflect.ValueOf(cfg).Elem()
t := v.Type()
for i := 0; i < v.NumField(); i++ {
field := v.Field(i)
fieldType := t.Field(i)
envTag := fieldType.Tag.Get("env")
defaultTag := fieldType.Tag.Get("default")
if envTag != "" {
if envValue := os.Getenv(envTag); envValue != "" {
setFieldValue(field, envValue)
@@ -545,11 +545,11 @@ func (m *MockClient) GetNowPlaying() (*models.NowPlaying, error) {
if err, exists := m.errors["now_playing"]; exists {
return nil, err
}
if resp, exists := m.responses["now_playing"]; exists {
return resp.(*models.NowPlaying), nil
}
return &models.NowPlaying{
Track: "Mock Track",
Artist: "Mock Artist",
@@ -577,8 +577,8 @@ CMD ["go", "test", "-v", "./..."]
```bash
# Makefile test target
test-integration:
docker-compose -f test/docker-compose.yml up --build --abort-on-container-exit
docker-compose -f test/docker-compose.yml down
docker compose -f test/docker-compose.yml up --build --abort-on-container-exit
docker compose -f test/docker-compose.yml down
```
## Recommended Project Structure
@@ -741,7 +741,7 @@ type APIError struct {
Message string `xml:",innerxml"`
}
// pkg/models/device.go
// pkg/models/device.go
type DeviceInfo struct {
XMLResponse
Name string `xml:"name"`
@@ -773,7 +773,7 @@ func main() {
},
},
}
app.Run(os.Args)
}
```
@@ -802,4 +802,4 @@ func main() {
## Conclusion
This pattern collection enables the development of robust API clients for hardware devices that function both as native tools and as web applications. The combination of Go's type safety, WASM support, and a structured build system makes it possible to use a single codebase for various deployment scenarios.
This pattern collection enables the development of robust API clients for hardware devices that function both as native tools and as web applications. The combination of Go's type safety, WASM support, and a structured build system makes it possible to use a single codebase for various deployment scenarios.
+5 -2
View File
@@ -8,8 +8,9 @@ Welcome to the documentation for the Bose SoundTouch Toolkit. This comprehensive
- **[Complete Migration Guide](guides/MIGRATION-GUIDE.md)** - Step-by-step guide from Bose Cloud to local control
- **[Getting Started](guides/GETTING-STARTED.md)** - Quick introduction to the toolkit
### For Existing Users
### For Existing Users
- **[Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)** - Prepare for the May 2026 shutdown
- **[Backup Tool](../cmd/soundtouch-backup/README.md)** - Back up your cloud account and speaker data before shutdown
- **[SoundTouch Service Guide](guides/SOUNDTOUCH-SERVICE.md)** - Advanced service configuration
## 📋 Essential Documentation
@@ -17,7 +18,7 @@ Welcome to the documentation for the Bose SoundTouch Toolkit. This comprehensive
The documentation is organized into three main categories:
### 1. **User Guides** - For everyday users migrating and managing devices
### 2. **Technical Reference** - For developers and advanced configuration
### 2. **Technical Reference** - For developers and advanced configuration
### 3. **Concept Documentation** - For contributors and system architects
## 🗂 Documentation Structure
@@ -40,6 +41,7 @@ The documentation is organized into three main categories:
### Advanced Features
- [MAC Address Mapping](guides/MAC-ADDRESS-MAPPING.md) - Device identification
- [CLI Reference](guides/CLI-REFERENCE.md) - Command-line tools
- [Backup Tool](../cmd/soundtouch-backup/README.md) - Cloud account and speaker data backup
- [IoT Implementation Guide](guides/IOT-IMPLEMENTATION-GUIDE.md) - IoT integrations
- [MQTT Integration Design](guides/MQTT-INTEGRATION-DESIGN.md) - MQTT setup
@@ -47,6 +49,7 @@ The documentation is organized into three main categories:
### API Documentation
- [API Endpoints](reference/API-ENDPOINTS.md) - REST API reference
- [Spotify Account Addition](reference/spotify-account-addition.md) - Technical requests for Spotify
- [WebSocket Events](reference/WEBSOCKET-EVENTS.md) - Real-time events
- [Zone Management](reference/ZONE-MANAGEMENT.md) - Multi-room control
- [Preset Management](reference/PRESET-MANAGEMENT.md) - Preset operations
+14
View File
@@ -4,11 +4,17 @@
## User Guides
* [Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)
* [Self-Hosting AfterTouch](guides/SELF-HOSTING.md)
* [Connecting Music Services](guides/MUSIC-SERVICES.md)
* [Migration & Safety Guide](guides/MIGRATION-SAFETY.md)
* [CLI Reference](guides/CLI-REFERENCE.md)
* [Backup Tool](../cmd/soundtouch-backup/README.md)
* [Getting Started](guides/GETTING-STARTED.md)
* [SoundTouch Service](guides/SOUNDTOUCH-SERVICE.md)
* [Initial Device Setup](guides/DEVICE-INITIAL-SETUP.md)
* [Capture Device Pairing Traffic](guides/CAPTURE-DEVICE-PAIRING.md)
* [Capture Migration Traffic](guides/CAPTURE-MIGRATION-TRAFFIC.md)
* [Device Setup Flow](DEVICE-SETUP.md)
* [MAC Address Mapping](guides/MAC-ADDRESS-MAPPING.md)
* [HTTPS Setup](guides/HTTPS-SETUP.md)
* [Deployment](guides/DEPLOYMENT.md)
@@ -28,10 +34,12 @@
## Technical Reference
* [API Cookbook](reference/API-COOKBOOK.md)
* [API Endpoints](reference/API-ENDPOINTS.md)
* [Spotify Account Addition](reference/spotify-account-addition.md)
* [Cloud API Emulation](reference/CLOUD-API.md)
* [System Endpoints](reference/SYSTEM-ENDPOINTS.md)
* [Speaker Endpoint](reference/SPEAKER-ENDPOINT.md)
* [WebSocket Events](reference/WEBSOCKET-EVENTS.md)
* [Device Pairing Flow](reference/DEVICE-PAIRING-FLOW.md)
* [Discovery](reference/DISCOVERY.md)
* [Zone Management](reference/ZONE-MANAGEMENT.md)
* [Preset Management](reference/PRESET-MANAGEMENT.md)
@@ -56,12 +64,18 @@
* [Wiki API Comparison](analysis/WIKI-COMPARISON.md)
* [IoT Config Summary](analysis/IOT-CONFIG-SUMMARY.md)
* [IoT Configuration Analysis](analysis/IOT-CONFIGURATION-ANALYSIS.md)
* [Bose Lab Runbook](analysis/BOSE-LAB-RUNBOOK.md)
* [Missing Routes Spotify](analysis/MISSING-ROUTES-SPOTIFY.md)
* [Bose App ADB Emulator](analysis/BOSE-APP-ADB-Emulator.md)
* [Community Tools](analysis/bose-soundtouch-community-tools.md)
## Parity Analysis
* [Parity Improvements](PARITY-IMPROVEMENTS.md)
* [Parity SoundCork](PARITY-SOUNDCORK.md)
* [Parity OpenCloudTouch](PARITY-OPENCLOUDTOUCH.md)
## Appendix (Other Documents)
* [External Services Abstraction](EXTERNAL-SERVICES-ABSTRACTION.md)
* [API Navigation Reference](API-NAVIGATION-REFERENCE.md)
* [Claude Instructions](CLAUDE.md)
* [Content Selection Implementation](CONTENT-SELECTION-IMPLEMENTATION.md)
+364
View File
@@ -0,0 +1,364 @@
# Bose SoundTouch Traffic Interception Runbook
Intercept HTTPS/WebSocket traffic from the Bose SoundTouch Android app using an Android emulator, mitmproxy, and Frida. Tested on Apple Silicon (ARM64) Mac.
## Automated Setup
The steps in this document are scripted for reproducibility:
```bash
scripts/android/setup-mitm-avd.sh # one-time: create AVD, install cert & APK, save snapshot
scripts/android/start-mitm-session.sh # per-session: restore snapshot, refresh proxy, start frida-server
```
Read on for the full manual walkthrough and the rationale behind each step.
> **Note:** The manual steps below use `/tmp/` for intermediate files and reflect the original approach. The automated scripts supersede them — use the scripts for day-to-day use and refer here only to understand how things work.
---
## Prerequisites
- Android Studio installed (for SDK tools and emulator)
- Docker installed
- mitmproxy installed (`pip install mitmproxy` or via your preferred method)
- The Bose SoundTouch APK (extracted from a real device, see below)
> **BLE limitation**: Android emulators do not expose Bluetooth hardware. The Bose app's default setup path (BLE Wi-Fi provisioning) therefore cannot be used to configure a factory-reset speaker from the emulator. Use **AP mode** instead: provision the speaker's Wi-Fi credentials via the Mac command line first (see [DEVICE-INITIAL-SETUP.md § 6](../guides/DEVICE-INITIAL-SETUP.md)), then the app can discover the already-networked speaker via mDNS/SSDP without BLE.
> **Emulator ↔ local network**: The emulator routes all traffic through the Mac's active network interface. Once the speaker is on the same LAN as the Mac, the emulator can reach it at its normal LAN IP (e.g. `192.168.1.50`) — no extra routing is needed. Use `adb shell ping 192.168.1.50` to confirm reachability.
Add Android SDK tools to your PATH (add to `~/.zshrc`):
```bash
export PATH=$PATH:~/Library/Android/sdk/emulator
export PATH=$PATH:~/Library/Android/sdk/platform-tools
```
---
## 1. Extract APK from Real Device
Connect your Android device via USB with USB debugging enabled.
```bash
adb devices
# note your device ID, e.g. "ABC123"
adb -s ABC123 shell pm path com.bose.soundtouch
# output e.g.: package:/data/app/~~xyz/com.bose.soundtouch-abc/base.apk
adb -s ABC123 pull /data/app/~~xyz/com.bose.soundtouch-abc/base.apk bose.apk
```
---
## 2. Create Android Emulator (ARM64, API 33)
On Apple Silicon you need an ARM64 image. Use the `avdmanager` and `sdkmanager` CLI tools.
```bash
# Install the system image
~/Library/Android/sdk/cmdline-tools/latest/bin/sdkmanager \
"system-images;android-33;google_apis;arm64-v8a"
# Create the AVD
~/Library/Android/sdk/cmdline-tools/latest/bin/avdmanager create avd \
-n Pixel_6_API33 \
-k "system-images;android-33;google_apis;arm64-v8a" \
-d "pixel_6"
```
Alternatively create the AVD via Android Studio Device Manager (choose "Google APIs", arm64-v8a, API 33).
---
## 3. Start Emulator with Writable System
```bash
# List available AVDs
~/Library/Android/sdk/emulator/emulator -list-avds
# Start with writable system partition
~/Library/Android/sdk/emulator/emulator -avd Pixel_6_API33 -writable-system
```
Wait until the emulator has fully booted, then:
```bash
adb -s emulator-5554 root
adb -s emulator-5554 shell avbctl disable-verification
adb -s emulator-5554 reboot
# After reboot:
adb -s emulator-5554 root
```
---
## 4. Install Bose APK
```bash
adb -s emulator-5554 install bose.apk
```
---
## 5. Set Up mitmproxy
```bash
# Start mitmproxy (generates CA cert on first run)
# Use the native macOS app — Docker mitmproxy does not work (NAT blocks emulator traffic)
mitmweb --listen-port 8080 --mode regular -w bose_traffic.mitm
```
Extract the CA certificate (without private key):
```bash
openssl x509 -in ~/.mitmproxy/mitmproxy-ca.pem -out ~/.mitmproxy/mitmproxy-ca-cert.pem
# Verify it's the mitmproxy cert, not another cert:
openssl x509 -in ~/.mitmproxy/mitmproxy-ca-cert.pem -noout -issuer
# should show: issuer= /CN=mitmproxy/O=mitmproxy
```
---
## 6. Install mitmproxy CA Certificate in Emulator
```bash
HASH=$(openssl x509 -inform PEM -subject_hash_old \
-in ~/.mitmproxy/mitmproxy-ca-cert.pem | head -1)
adb -s emulator-5554 push ~/.mitmproxy/mitmproxy-ca-cert.pem /data/local/tmp/mitmproxy.pem
adb -s emulator-5554 shell su 0 mkdir -p /data/misc/user/0/cacerts-added
adb -s emulator-5554 shell su 0 \
cp /data/local/tmp/mitmproxy.pem /data/misc/user/0/cacerts-added/${HASH}.0
adb -s emulator-5554 shell su 0 \
chmod 644 /data/misc/user/0/cacerts-added/${HASH}.0
```
---
## 7. Set System Proxy in Emulator
Find your Mac's local IP:
```bash
ipconfig getifaddr en0
# e.g. 192.168.1.123
```
Set the proxy:
```bash
adb -s emulator-5554 shell settings put global http_proxy 192.168.1.123:8080
```
---
## 8. Set Up Frida (via Python venv)
```bash
python3 -m venv /tmp/frida-venv
/tmp/frida-venv/bin/pip install frida==17.9.1 frida-tools==14.8.1
```
Download the frida-server binary for ARM64 Android:
```bash
FRIDA_VERSION=17.9.1
curl -L "https://github.com/frida/frida/releases/download/${FRIDA_VERSION}/frida-server-${FRIDA_VERSION}-android-arm64.xz" \
-o /tmp/frida-server.xz
unxz /tmp/frida-server.xz
mv /tmp/frida-server-${FRIDA_VERSION}-android-arm64 /tmp/frida-server
```
Push to emulator and start:
```bash
adb -s emulator-5554 push /tmp/frida-server /data/local/tmp/frida-server
adb -s emulator-5554 shell su 0 chmod 755 /data/local/tmp/frida-server
adb -s emulator-5554 shell su 0 /data/local/tmp/frida-server &
```
---
## 9. Download SSL Bypass Scripts
```bash
BASE=https://raw.githubusercontent.com/httptoolkit/frida-interception-and-unpinning/main
curl -L "${BASE}/config.js" -o /tmp/config.js
curl -L "${BASE}/android/android-system-certificate-injection.js" \
-o /tmp/android-system-certificate-injection.js
curl -L "${BASE}/android/android-proxy-override.js" \
-o /tmp/android-proxy-override.js
curl -L "${BASE}/android/android-certificate-unpinning.js" \
-o /tmp/android-certificate-unpinning.js
curl -L "${BASE}/android/android-certificate-unpinning-fallback.js" \
-o /tmp/android-certificate-unpinning-fallback.js
```
---
## 10. Configure config.js
Edit `/tmp/config.js` and set:
```javascript
const CERT_PEM = `<contents of ~/.mitmproxy/mitmproxy-ca-cert.pem>`;
const PROXY_HOST = '192.168.1.123'; // your Mac IP
const PROXY_PORT = 8080;
```
Insert the full PEM content (from `-----BEGIN CERTIFICATE-----` to `-----END CERTIFICATE-----`) between the backticks.
Quick check that the right cert is in place:
```bash
# The issuer inside config.js should be mitmproxy, not SoundTouch
grep -A3 "CERT_PEM" /tmp/config.js | head -5
```
---
## 11. Start Interception
Make sure mitmweb is running, then:
```bash
scripts/android/frida-venv/bin/frida \
-U \
-f com.bose.soundtouch \
-l scripts/android/frida/config.js \
-l scripts/android/frida/native-connect-hook.js \
-l scripts/android/frida/android/android-system-certificate-injection.js \
-l scripts/android/frida/android/android-proxy-override.js \
-l scripts/android/frida/android/android-certificate-unpinning.js \
-l scripts/android/frida/android/android-certificate-unpinning-fallback.js
```
> `native-connect-hook.js` is required — the Bose app uses native networking that bypasses Java proxy settings.
Expected output in the Frida REPL:
```
== System certificate trust injected ==
== Proxy system configuration overridden to 192.168.1.123:8080 ==
== Proxy configuration overridden to 192.168.1.123:8080 ==
== Certificate unpinning completed ==
== Unpinning fallback auto-patcher installed ==
```
Open mitmweb at `http://127.0.0.1:8081` to observe traffic live.
---
## 12. Save & Replay Recordings
Traffic is saved to `bose_traffic.mitm` (set via `-w` flag in step 5).
```bash
# Replay/analyse a saved recording:
mitmweb -r bose_traffic.mitm
```
---
## Cleanup
```bash
# Remove proxy setting from emulator
adb -s emulator-5554 shell settings delete global http_proxy
# Remove venv
rm -rf /tmp/frida-venv /tmp/frida-server /tmp/frida-server.xz
rm /tmp/config.js /tmp/android-*.js
# Stop emulator
adb -s emulator-5554 emu kill
```
---
## Troubleshooting
| Symptom | Cause | Fix |
|-----------------------------------------|--------------------------------------------------|--------------------------------------------------------------------------------|
| `remount failed` | ARM64 emulator doesn't support overlayfs remount | Use `/data/misc/user/0/cacerts-added/` method instead |
| `TLS: Trust anchor not found` | Wrong certificate in config.js | Check issuer: must be mitmproxy, not SoundTouch |
| `Chain validation failed` | Private key included in cert | Re-extract with `openssl x509 -in mitmproxy-ca.pem -out mitmproxy-ca-cert.pem` |
| `frida-server: connection refused` | frida-server not running | Re-run `adb shell su 0 /data/local/tmp/frida-server &` |
| frida and frida-server version mismatch | Versions must be identical | Pin both to same version (e.g. `17.9.1`) |
| `emulator: multiple AVDs` error | Emulator already running | Kill first: `adb emu kill`, then restart with `-writable-system` |
---
## App Automation Options
For most traffic-recording purposes, manually operating the app while mitmproxy captures is sufficient. If you need to automate specific interactions (e.g. to repeatably capture the requests triggered by startup or a particular action), the following tools are available.
### Starting the App
```bash
# Via app drawer: swipe up on the home screen and tap "Bose SoundTouch"
# Via adb monkey (simplest)
adb -s emulator-5554 shell monkey -p com.bose.soundtouch 1
# Via explicit intent (if the activity name is known)
adb -s emulator-5554 shell am start -n com.bose.soundtouch/.MainActivity
# Look up all activities if the name is unknown
adb -s emulator-5554 shell dumpsys package com.bose.soundtouch | grep Activity
```
### adb — sufficient for simple cases
```bash
# Tap at screen coordinates
adb shell input tap 540 960
# Swipe
adb shell input swipe 540 1500 540 500
# Type text
adb shell input text "mytext"
# Take a screenshot
adb shell screencap /sdcard/screen.png && adb pull /sdcard/screen.png
```
### UIAutomator2 — inspect UI elements
```bash
# Dump the current UI hierarchy to find element IDs
adb shell uiautomator dump /sdcard/ui.xml
adb pull /sdcard/ui.xml
```
Open `ui.xml` to find element resource IDs, then target them precisely in scripts.
### Appium — full scripted automation
```python
from appium import webdriver
driver = webdriver.Remote('http://localhost:4723/wd/hub', {
'platformName': 'Android',
'appPackage': 'com.bose.soundtouch',
'appActivity': '.MainActivity',
})
# Find an element by resource ID and tap it
driver.find_element('id', 'com.bose.soundtouch:id/play_button').click()
```
> **Note:** `monkey` is a stress-test tool that sends random events — use it only to launch the app, not to drive specific interactions.
+892
View File
@@ -0,0 +1,892 @@
# Bose SoundTouch Traffic Analysis Runbook
> **Goal:** Set up a Raspberry Pi as a transparent access point to fully observe the traffic of the Bose SoundTouch app specifically the pairing flow with the Bose Cloud. This serves as a basis for later reverse engineering / simulation of the cloud endpoints.
---
## Prerequisites
| Component | Details |
|--------------------|------------------------------------------------------------------|
| Raspberry Pi | Pi 3 or newer, Raspberry Pi OS (Bullseye, Bookworm, Trixie) |
| Network interfaces | `eth0` → LAN cable to FritzBox, `wlan0` → own Access Point |
| FritzBox | Unchanged, assigns an IP to the Pi via DHCP on eth0 |
| Custom DNS Server | Already present (or see Appendix A), incl. custom CA certificate |
| Phone | Android, connects to the Pi's Wi-Fi |
### Network Architecture
```
Internet
FritzBox (existing, unchanged)
↓ LAN cable (eth0)
Raspberry Pi
├── DNS Server → selective logging / redirection
├── hostapd → custom Wi-Fi Access Point ("Bose-Lab")
├── dnsmasq → DHCP for clients, DNS to custom server
├── iptables → NAT, Forwarding eth0 ↔ wlan0
├── tcpdump → full traffic capture
└── (optional) mitmproxy → HTTPS decryption
↓ Wi-Fi ("Bose-Lab")
Android Phone
└── Bose SoundTouch App
```
---
## Step 1 Install Packages
```bash
sudo apt update && sudo apt install -y \
hostapd \ # Wi-Fi Access Point daemon
dnsmasq \ # DHCP + DNS forwarding
nftables \ # Modern NAT / firewall / forwarding
tcpdump \ # Packet capture at all levels
wireshark-common # tshark CLI (optional, for live analysis)
```
---
## Step 2 Enable IP Forwarding
The Pi must forward packets between `wlan0` (phone) and `eth0` (FritzBox).
```bash
# Active immediately (no reboot required)
sudo sysctl -w net.ipv4.ip_forward=1
# Permanent (survives reboots)
# On modern Debian, using a dedicated file in sysctl.d/ is more reliable:
echo "net.ipv4.ip_forward=1" | sudo tee /etc/sysctl.d/99-ip-forward.conf
# Apply changes immediately
sudo sysctl --system
```
**Verify:**
```bash
# After a reboot, ensure it is still '1'
cat /proc/sys/net/ipv4/ip_forward
```
---
## Step 3 Static IP on wlan0 (systemd-networkd)
On modern Debian (Bookworm/Trixie), `dhcpcd` is replaced by `systemd-networkd`.
```bash
# Create network configuration
sudo tee /etc/systemd/network/08-wlan0.network << 'EOF'
[Match]
Name=wlan0
[Network]
Address=192.168.10.1/24
IPForward=yes
ConfigureWithoutCarrier=yes
DHCP=no
IPv6AcceptRA=no
EOF
# Restart service
sudo systemctl enable systemd-networkd
sudo systemctl restart systemd-networkd
# Ensure wpa_supplicant and NetworkManager don't interfere
sudo nmcli device set wlan0 managed no
sudo systemctl stop wpa_supplicant@wlan0
sudo systemctl mask wpa_supplicant@wlan0
```
**Verify:**
```bash
ip addr show wlan0
# Expected: ONLY inet 192.168.10.1/24 (NO second DHCP IP)
```
---
## Step 4 hostapd (Access Point)
```bash
sudo tee /etc/hostapd/hostapd.conf << 'EOF'
interface=wlan0
driver=nl80211
ssid=Bose-Lab
hw_mode=b
#hw_mode=g
channel=1
#channel=6
wmm_enabled=0
auth_algs=1
wpa=2
wpa_passphrase=secret123
wpa_key_mgmt=WPA-PSK
wpa_pairwise=CCMP
EOF
# The modern way is to just use hostapd.service which defaults to /etc/hostapd/hostapd.conf
sudo systemctl unmask hostapd
sudo systemctl enable --now hostapd
```
**Verify:**
```bash
sudo systemctl status hostapd
# Expected: active (running)
```
---
## Step 5 dnsmasq (DHCP + DNS)
dnsmasq gives the phone an IP and forwards DNS queries to the custom DNS server.
```bash
# Back up original config
sudo mv /etc/dnsmasq.conf /etc/dnsmasq.conf.bak
sudo tee /etc/dnsmasq.conf << 'EOF'
interface=wlan0
dhcp-range=192.168.10.100,192.168.10.200,24h
dhcp-option=3,192.168.10.1
dhcp-option=6,192.168.10.1
# DNS Upstream: custom server on localhost (adjust port if necessary)
server=127.0.0.1#5353 # Example: custom server on port 5353
# Alternatively: server=1.1.1.1 if DNS server runs directly on port 53
# Log all DNS queries (for initial analysis)
log-queries
log-facility=/var/log/dnsmasq.log
EOF
sudo systemctl restart dnsmasq
```
**Observe DNS log live:**
```bash
sudo tail -f /var/log/dnsmasq.log
```
---
## Step 6 NAT and Forwarding (nftables)
On modern Debian (Bookworm/Trixie), `nftables` is the default and recommended way to manage NAT and traffic forwarding.
```bash
# Define the NAT and Forwarding rules
sudo tee /etc/nftables.conf << 'EOF'
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain forward {
type filter hook forward priority 0; policy drop;
# Allow traffic from phone (wlan0) to internet (eth0)
iifname "wlan0" oifname "eth0" accept
# Allow established/related traffic back to the phone
iifname "eth0" oifname "wlan0" ct state established,related accept
}
}
table ip nat {
chain posterouting {
type nat hook postrouting priority 100; policy accept;
# MASQUERADE outgoing packets on eth0
oifname "eth0" masquerade
}
}
EOF
# Enable and start nftables
sudo systemctl enable nftables
sudo systemctl restart nftables
```
**Verify:**
```bash
sudo nft list ruleset
# Expected: ruleset showing the forward and nat chains
```
### WiFi "Bose-Lab" not visible?
If you cannot see the `Bose-Lab` SSID on your phone:
1. **Check hostapd status:** `sudo systemctl status hostapd`. If it failed with "nl80211: Driver does not support configured mode", try changing `hw_mode=g` to `hw_mode=b`.
2. **Interface blocking:** Ensure `rfkill` hasn't blocked WiFi: `sudo rfkill unblock wlan`.
3. **Country Code:** Some systems require a country code in `hostapd.conf` to enable the radio. Add `country_code=DE` (or your country) to the top of `/etc/hostapd/hostapd.conf` and restart hostapd: `sudo systemctl restart hostapd`.
4. **Local Radio Check:** You can verify that the radio is actually configured as an AP: `iw dev wlan0 info`. Look for `type AP` and your SSID.
> **Note:** Do NOT rely on `iw dev wlan0 scan` for your own SSID; many WiFi drivers cannot "scan" and "broadcast" simultaneously.
5. **Debug Mode:** If the scan still returns nothing, stop the service and run hostapd in the foreground to see real-time errors:
```bash
sudo systemctl stop hostapd
sudo hostapd -dd /etc/hostapd/hostapd.conf
```
Look for messages like `nl80211: Failed to set interface wlan0 into AP mode`. This usually means the hardware is busy or doesn't support the current `hw_mode` / `channel` combination.
6. **Conflicting Services:** Ensure nothing else is managing `wlan0`. NetworkManager is common on modern Debian:
```bash
sudo nmcli device set wlan0 managed no
```
7. **Ghost IP Conflict:** If `ip addr show wlan0` shows both `192.168.10.1` and another IP (like `192.168.178.x`), `hostapd` will fail. This is usually caused by NetworkManager managing the interface. Ensure you've run:
```bash
sudo nmcli device set wlan0 managed no
# If the ghost IP is still there, remove it manually:
sudo ip addr del 192.168.178.X/24 dev wlan0
```
---
## Step 7 Install Custom CA Certificate on the Phone
Since a custom DNS server with a custom CA certificate is used, it must be trusted on the phone otherwise, the app will block HTTPS connections to redirected domains.
### Copy CA Certificate to the Pi (if not already there)
If you haven't created a CA yet, follow **Appendix A** first.
```bash
# Certificate is located e.g. at /etc/my-dns-ca/ca.crt
# Temporarily make reachable via HTTP for easy download:
cd /etc/my-dns-ca/
python3 -m http.server 8080
# → Reachable at http://192.168.10.1:8080/ca.crt
```
### Install on Android
1. Connect phone to `Bose-Lab`
2. Open browser → `http://192.168.10.1:8080/ca.crt`
3. Download certificate
4. **Settings → Security → Credentials → Install CA Certificate**
5. Select certificate and confirm
> **Note:** Android distinguishes between system CAs and user CAs. User-installed CAs are accepted by many apps, but apps with certificate pinning (hardcoded certificate hashes) ignore them. Whether Bose uses pinning will be visible in the capture (Connection Reset after TLS ClientHello).
### Android 14+ Special Case
From Android 14 onwards, apps do not trust user CAs by default unless explicitly declared in the manifest. If the Bose app rejects the CA certificate:
```bash
# Option A: Root + Magisk module "MagiskTrustUserCerts"
# → moves user CAs to the system store
# Option B: Root + manually copy to system CA directory
adb push ca.crt /system/etc/security/cacerts/
adb shell chmod 644 /system/etc/security/cacerts/ca.crt
```
---
## Step 8 Capture Traffic
### All at once (recommended)
```bash
# Full capture of all protocols on wlan0
# Filename with timestamp for multiple sessions
sudo tcpdump -i wlan0 \
-w /tmp/bose-$(date +%Y%m%d-%H%M%S).pcap \
-s 0 # full packet length (no truncation)
# End session: Ctrl+C
```
### Targeted by protocol
```bash
# DNS only (Port 53) shows if app uses standard DNS
sudo tcpdump -i wlan0 -n port 53
# HTTPS only TLS connections to Bose Cloud
sudo tcpdump -i wlan0 -n 'tcp port 443'
# mDNS (ZeroConf) device discovery in LAN
# Multicast group 224.0.0.1, Port 5353
sudo tcpdump -i wlan0 -n 'udp port 5353'
# SSDP/UPnP alternative device discovery
sudo tcpdump -i wlan0 -n 'udp port 1900'
# Everything except DNS (reduces noise)
sudo tcpdump -i wlan0 -n 'not port 53' -w /tmp/bose-nodns.pcap
# Traffic of a specific host only (filter by phone IP)
# Read phone IP from dnsmasq.leases beforehand (see below)
sudo tcpdump -i wlan0 -n host 192.168.10.101
```
### Read SNI from TLS Traffic (without decryption)
```bash
# Extract domains from TLS ClientHello (SNI is unencrypted)
sudo tcpdump -i wlan0 -n 'tcp port 443' -A 2>/dev/null \
| grep -oP '(?<=\x00)([a-zA-Z0-9.-]+\.(?:com|net|io|cloud|bose\.com))'
```
### Readable mDNS Announcements output
```bash
# tshark decodes mDNS directly
sudo tshark -i wlan0 -f 'udp port 5353' -T fields \
-e dns.qry.name \
-e dns.resp.name \
-e dns.a
```
---
## Step 9 Analysis with Wireshark (on PC)
Transfer `.pcap` files from the Pi to the PC:
```bash
# From the PC (scp)
scp pi@192.168.10.1:/tmp/bose-*.pcap ~/Desktop/
```
**Important Wireshark Filters:**
```
# DNS only
dns
# HTTPS only
tcp.port == 443
# WebSocket connections (HTTP Upgrade)
websocket
# mDNS
mdns
# TLS Handshakes (SNI visible)
tls.handshake.extensions_server_name
# Traffic of a specific domain (resolve by IP)
http.host contains "bose"
# WebSocket frames
websocket.payload
```
> **Tip:** Wireshark decodes WebSocket frames automatically if it sees the HTTP Upgrade handshake in the same capture. For the pairing flow: filtering for `tls.handshake.extensions_server_name` shows all domains the app contacts, even without decryption.
---
## Step 10 mitmproxy (optional, for HTTPS content)
Only useful if the CA certificate on the phone is trusted and no certificate pinning is active. `mitmproxy` acts as a Man-in-the-Middle by generating fake, on-the-fly certificates for any domain (e.g., `global.api.bose.io`) using your custom CA.
### 1. Configure mitmproxy to use your Custom CA
By default, `mitmproxy` creates its own CA in `~/.mitmproxy/`. To ensure the phone (which already trusts your `ca.crt`) accepts the traffic, you must tell `mitmproxy` to use your existing CA:
```bash
# mitmproxy expects the CA in a specific PEM format (cert + key in one file)
sudo mkdir -p ~/.mitmproxy
sudo cat /etc/my-dns-ca/ca.crt /etc/my-dns-ca/ca.key | sudo tee ~/.mitmproxy/mitmproxy-ca.pem > /dev/null
```
### 2. Install and Start mitmproxy
```bash
# Install mitmproxy binary (stable version for aarch64)
cd /tmp
wget https://downloads.mitmproxy.org/12.2.1/mitmproxy-12.2.1-linux-aarch64.tar.gz
tar -xzf mitmproxy-12.2.1-linux-aarch64.tar.gz
sudo mv mitmproxy mitmdump mitmweb /usr/local/bin/
rm mitmproxy-12.2.1-linux-aarch64.tar.gz
mitmproxy --version
# Transparent proxy on port 8080
# It will now use the CA from ~/.mitmproxy/mitmproxy-ca.pem
mitmproxy --mode transparent --listen-port 8080
# Alternatively: mitmdump for automatic logging to file
# mitmdump --mode transparent --listen-port 8080 -w /tmp/bose-https.mitm
```
### 3. Troubleshooting: TLS Handshake Failures
If you see `Client TLS handshake failed. The client does not trust the proxy's certificate for www.google.com` (or other domains) in the `mitmproxy` logs:
1. **HSTS and Pre-installed Pinning:** High-security sites like `www.google.com` use **HSTS (HTTP Strict Transport Security)** and have their certificates hardcoded (pinned) into browsers like Chrome and the Android system. **These will always fail with a User-installed CA.**
2. **User vs. System CA Store:** On Android 7.0+, apps **do not trust User-installed CAs by default**. They only trust the "System" store.
* **The Bose app:** If it fails, it's because it only trusts the System store or uses its own certificate pinning.
* **The Fix (Rooted Phone):** Use a Magisk module like `AlwaysTrustUserCerts` or manually move your `ca.crt` to `/system/etc/security/cacerts/` (see Step 7).
3. **The "Golden Rule" - Verify the Proxy is Working:**
To confirm your CA and `mitmproxy` are correctly configured, test with a non-HSTS site on the phone's browser (e.g., `http://neverssl.com`). Once redirected to HTTPS, **inspect the certificate**. It should say it was issued by your "Bose-Lab Root CA" (or "SoundTouch Root CA").
* **If this works:** Your "factory" (mitmproxy + CA) is 100% correct. Any failure in the Bose app is due to its own security policy (ignore User Store or Pinning).
* **If this fails:** Your CA is not trusted by the browser or `mitmproxy` is not using your PEM file.
Alternatively, use `curl` from a terminal emulator on the phone:
```bash
# This should work if the CA is in the user store and curl is told to use it
curl -v --cacert /path/to/ca.crt https://example.com
```
4. **Check mitmproxy CA:** Ensure `mitmproxy` is actually using your CA. When it starts, it should NOT generate a new CA in `~/.mitmproxy/mitmproxy-ca.pem` if you've already placed yours there.
---
**nftables rule: redirect HTTPS traffic to mitmproxy**
```bash
# Create a temporary file for the redirection rule
sudo nft add table ip mitm
sudo nft add chain ip mitm prerouting { type nat hook prerouting priority -100 \; }
sudo nft add rule ip mitm prerouting iifname "wlan0" tcp dport 443 redirect to :8080
```
**Remove rule when no longer needed:**
```bash
sudo nft delete table ip mitm
```
> **Detecting Certificate Pinning:** If the app immediately disconnects after mitmproxy redirection (connection reset directly after TLS ClientHello), pinning is active. In this case, Frida + root is needed to patch the pinning.
---
## Step 11 Bypassing Android Trust Restrictions
If `neverssl.com` works in the browser but the Bose app shows `TLS handshake failed` in `mitmproxy`, the app is either ignoring the **User CA store** (common on Android 7+) or using **Certificate Pinning**.
### Option A: Move CA to System Store (Requires Root/Magisk)
This is the most reliable way to make apps trust your CA without modifying the app itself.
1. **Using Magisk (Recommended):**
Install the **"AlwaysTrustUserCerts"** or **"Move Certificates"** module in Magisk. It automatically mirrors all certificates from the User store to the System store on every boot.
2. **Manual Move (via ADB):**
Android system certificates are stored in `/system/etc/security/cacerts/` and must be named using the hash of the certificate.
```bash
# 1. Get the hash of your certificate
hash=$(openssl x509 -inform PEM -subject_hash_old -in ca.crt | head -1)
# 2. Rename the certificate locally
cp ca.crt ${hash}.0
# 3. Push to the phone (requires remounting /system as read-write)
adb push ${hash}.0 /sdcard/
adb shell
su
mount -o rw,remount /
cp /sdcard/${hash}.0 /system/etc/security/cacerts/
chmod 644 /system/etc/security/cacerts/${hash}.0
chown root:root /system/etc/security/cacerts/${hash}.0
reboot
```
### Option B: Patching the App (No Root Required)
If you cannot root your phone, you can modify the app's APK to trust user-installed certificates. This involves obtaining the APK, decompiling it, adding a network security configuration, and then repackaging and signing it.
#### 0. How to get the .apk file?
You have two main ways to get the official Bose SoundTouch APK:
**Method 1: Extract from your phone (Safest)**
If the app is already installed on your phone, you can pull it using `adb`:
```bash
# 1. Find the package name (usually com.bose.soundtouch)
adb shell pm list packages | grep bose
# 2. Get the full path to the APK on the phone
adb shell pm path com.bose.soundtouch
# Output: package:/data/app/~~...==/com.bose.soundtouch-.../base.apk
# 3. Pull the file to your computer
adb pull /data/app/~~...==/com.bose.soundtouch-.../base.apk Bose-SoundTouch.apk
```
**Method 2: Download from a Mirror (Easiest)**
You can download the APK from reputable third-party sites.
> **Warning:** Always verify the site's reputation.
* [APKMirror](https://www.apkmirror.com/apk/bose-corporation/bose-soundtouch/)
* [APKPure](https://apkpure.com/bose-soundtouch/com.bose.soundtouch)
#### 1. Automated Method: apk-mitm (Recommended)
The easiest way is to use `apk-mitm`, which automates the entire process including fixing common certificate pinning libraries.
```bash
# Requires Node.js installed on your PC
npx apk-mitm Bose-SoundTouch.apk
```
This will produce a `Bose-SoundTouch-patched.apk` which you can install on your phone.
#### 2. Manual Method: Network Security Config
If you prefer to do it manually:
1. **Decompile the APK:**
```bash
apktool d Bose-SoundTouch.apk
```
2. **Create/Modify `res/xml/network_security_config.xml`:**
```xml
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config>
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>
```
3. **Update `AndroidManifest.xml`:**
Ensure the `<application>` tag includes: `android:networkSecurityConfig="@xml/network_security_config"`.
4. **Repackage and Sign:**
```bash
apktool b Bose-SoundTouch -o Bose-SoundTouch-patched.apk
# Sign with your own key
# 1. Generate a keystore (if you don't have one)
# Note: You can use ANY name/values here. The phone does not need to "know" or "trust" this key beforehand.
# It only needs the APK to be digitally signed so the Android installer accepts it.
keytool -genkey -v -keystore my-release-key.keystore -alias alias_name -keyalg RSA -keysize 2048 -validity 10000
# 2. Sign the APK
apksigner sign --ks my-release-key.keystore --out Bose-SoundTouch-patched-signed.apk Bose-SoundTouch-patched.apk
# Alternatively, use uber-apk-signer (recommended for simplicity)
# It handles zipalign and signing automatically.
java -jar uber-apk-signer.jar --apk Bose-SoundTouch-patched.apk
```
#### 3. Install the Patched APK
Once you have your `Bose-SoundTouch-patched.apk` (and it is signed), you need to install it on your phone.
**Important:** You must **uninstall the original Bose app first**. Android will not allow you to "update" the official app with your patched version because the digital signatures won't match.
**Method 1: via ADB (Recommended)**
```bash
# 1. Uninstall the original app
adb uninstall com.bose.soundtouch
# 2. Install your patched version
adb install Bose-SoundTouch-patched.apk
```
**Method 2: Manual Transfer**
1. Copy the `Bose-SoundTouch-patched.apk` to your phone's storage (via USB, Google Drive, or the Pi's HTTP server).
2. On the phone, use a File Manager to open the APK.
3. If prompted, allow "Install from Unknown Sources" for your File Manager.
### Option C: Using the macOS Bose SoundTouch App (No Root/Patching Required)
If you have a Mac, using the macOS version of the Bose SoundTouch app is often a good alternative. However, because the app is built on an **older version of Qt (5.7.0)**, it has specific trust and TLS compatibility issues that require extra steps.
#### 1. Install the Custom CA in macOS Keychain
1. Open **Keychain Access** on your Mac.
2. Select the **System** keychain (or **login** if System is locked).
3. Drag and drop your `ca.crt` file into the list.
4. Double-click the newly added certificate (e.g., "Bose-Lab Root CA").
5. Expand the **Trust** section.
6. Set "When using this certificate" to **Always Trust**.
7. Close the window and authenticate with your Mac password.
#### 2. Configure the Proxy
You can either configure the macOS system proxy manually or use `mitmproxy`'s automatic interception.
**Method 1: System Proxy (Manual)**
1. Go to **System Settings → Network → Wi-Fi → Details... → Proxies**.
2. Enable **HTTP Proxy** and **HTTPS Proxy**.
3. Set Server to your Pi's IP (`192.168.10.1`) and Port to `8080`.
4. Click **OK** and **Apply**.
**Method 2: mitmproxy Local Redirect (Automatic)**
If you are running `mitmproxy` directly on your Mac (instead of the Pi), you can use the modern "Local Redirect" mode which doesn't require proxy settings:
```bash
# Install mitmproxy via Homebrew
brew install mitmproxy
# Start mitmproxy in local redirect mode
# This uses a macOS Network Extension to intercept traffic from specific apps
mitmproxy --mode local
```
#### 3. Special Troubleshooting: Legacy Qt 5.7.0 SSL Failures
If you see `SSL handshake failed` in the `mitmproxy` logs or the app's internal log (`log.txt`), the app's older networking stack is rejecting the connection. This is common because Qt 5.7.0 (2016) lacks support for **TLS 1.3** and many modern root certificates (like Let's Encrypt's **ISRG Root X1**).
**The Solution: Launch with SSL Bypass Flags**
Since the Bose macOS app is a hybrid of **Qt/Chromium** and **Node.js**, you must bypass the trust checks for both engines by launching the app from the terminal:
```bash
# 1. Bypass QtWebEngine/Chromium (Qt 5.7) trust
export QTWEBENGINE_CHROMIUM_FLAGS="--ignore-certificate-errors"
# 2. Bypass Node.js (SoundTouch Music Server) trust
export NODE_TLS_REJECT_UNAUTHORIZED=0
# 3. (Optional) Provide your custom CA directly to Node.js
export NODE_EXTRA_CA_CERTS="/path/to/your/ca.crt"
# 4. Launch the application
"/Applications/SoundTouch/SoundTouch.app/Contents/MacOS/SoundTouch"
```
#### 4. Verify and Capture
1. Open Safari and visit `https://neverssl.com`. Verify the certificate is issued by your custom CA.
2. Launch the Bose app using the terminal command above.
3. Watch the traffic flow in `mitmproxy`.
> **Note:** Even on macOS, **Certificate Pinning** is still possible if Bose implemented it specifically in the desktop app code. However, it is much less common on desktop apps than on mobile apps. If it works, you've saved yourself hours of Android patching!
### Option D: Patching the App with Frida (Requires Root)
If the app uses **Certificate Pinning** (hardcoded hashes), even moving the CA to the System store won't work. You must disable the pinning check in the app's code.
1. **Install Frida** on your PC and `frida-server` on the rooted phone.
2. **Use a universal bypass script:**
```bash
frida -U -f com.bose.soundtouch -l https://codeshare.frida.re/@pcipolloni/universal-android-ssl-pinning-bypass-with-frida/ --no-pause
```
*(Replace `com.bose.soundtouch` with the actual package name if different).*
## Step 12 Alternative: Regular HTTP Proxy Mode
If the **Transparent AP** setup (Steps 16) is too complex or you are experiencing routing issues, you can use `mitmproxy` as a **Regular HTTP Proxy**.
### 1. How it works
In this mode, the Pi acts as a simple server on port 8080. You tell your phone's Wi-Fi settings to send all traffic to `192.168.10.1:8080`.
* **Pros:** No complex `nftables` or NAT rules required.
* **Cons:** Many Android apps (and background processes) ignore system-wide proxy settings. **HTTPS still requires a trusted CA for decryption.**
### 2. Start mitmproxy in Regular Mode
```bash
# Stop transparent mode first if it's running
# No special flags needed for regular mode
mitmproxy --listen-port 8080
```
### 3. Configure the Phone
1. Go to **Settings → Wi-Fi → Bose-Lab**.
2. Select **Modify Network** (or the "i" icon).
3. Set **Proxy** to **Manual**.
4. **Proxy hostname:** `192.168.10.1`
5. **Proxy port:** `8080`
6. Save and try to browse a site.
---
## Step 13 Extracting for soundtouch-service
You can extract interactions (especially unencrypted WebSockets on port 8090) from a `.pcap` and format them for use in `soundtouch-service`.
### 1. Extract Traffic using Go
A helper script is provided in `scripts/extract-ws.go`. It automatically detects, unmasks, and decompresses (GZIP) WebSocket frames, and also extracts DNS, MDNS, and SSDP traffic.
```bash
# Install dependencies
go get github.com/google/gopacket
# Run extraction (outputs multiple files: .ws.http, .dns.txt, .mdns.txt, .ssdp.txt)
# The results will be saved beside your .pcap file
go run scripts/extract-ws.go your_capture.pcap [filter_ip]
# Example: Filter for a specific speaker's IP in WebSocket messages
go run scripts/extract-ws.go capture.pcap 192.168.100.1
```
### 2. Manual Extraction with tshark
If you only need a quick look at the payloads:
```bash
# Extract all WebSocket text payloads
tshark -r your_capture.pcap -Y "websocket.payload.text" -T fields -e websocket.payload.text
```
---
## Step 14 Extracting from Internal App Logs (macOS)
If you are using the macOS app and cannot decrypt the cloud traffic due to pinning, you can still extract the JSON/XML messages from the app's internal communication log.
A helper script is provided in `scripts/extract-log-interactions.go`. It parses the interleaved "Native" and "Network" calls to reconstruct the application's internal state and cloud requests.
```bash
# Run extraction from the log file
# Outputs a chronological record of internal events and network URLs
go run scripts/extract-log-interactions.go path/to/log.txt > extracted-interactions.http
```
**What this shows:**
- **TO NETWORK:** The URLs the app is about to call (intercepted before encryption).
- **FROM NATIVE:** Data being returned from the OS or Cloud to the UI.
- **TO NATIVE:** Commands being sent from the UI to the underlying engines.
This is a powerful "Plan B" when HTTPS decryption is blocked, as the app essentially logs its own decrypted data for you.
---
## Helper Commands / Troubleshooting
After a Pi reboot, everything should come up automatically. If not:
```bash
# Restart and enable all core services
sudo systemctl restart systemd-networkd
sudo systemctl enable --now hostapd
sudo systemctl enable --now dnsmasq
sudo systemctl restart nftables
# Verify the unmanaged state of wlan0 (nmcli)
sudo nmcli device set wlan0 managed no
```
---
## What to Expect
| Protocol | Port | Tool | Visibility |
|----------------------|------------|--------------------------|------------------------------------------------|
| DNS (Standard) | UDP 53 | tcpdump, dnsmasq log | Full, plaintext |
| HTTPS / REST | TCP 443 | tcpdump (SNI), mitmproxy | SNI without decryption, content with mitmproxy |
| WebSockets | TCP 443/80 | Wireshark | Frames decoded if TLS is broken |
| mDNS / ZeroConf | UDP 5353 | tcpdump, tshark | Full, plaintext |
| SSDP / UPnP | UDP 1900 | tcpdump | Full, plaintext |
| SoundTouch local API | TCP 8090 | tcpdump | Full, plaintext (no TLS) |
> **Expectation for Bose SoundTouch:** The app likely uses standard DNS (older app generation), REST/HTTPS for the pairing flow with the cloud, WebSockets for push events from the device, and mDNS for local device discovery. The local device API on port 8090 is HTTP without TLS this traffic is always readable.
---
## Next Steps After Analysis
1. Extract domains from DNS log and SNI → List of all Bose endpoints
2. HTTP methods and paths from mitmproxy log → Reconstruct API structure
3. Document auth flow (OAuth2? Proprietary? Token format?)
4. Build a minimal mock server simulating the critical endpoints
5. Testing: App against mock server → does pairing work offline?
---
## Appendix A Generating a Custom CA Certificate
If you don't have a custom DNS server with a CA yet, you can create one directly on the Pi. Alternatively, if you are already using the `soundtouch-service` from this repository, you can reuse its CA certificate located in the `data/certs/` directory.
### 0. (Optional) Copy an Existing CA from another host
If you are already using the `soundtouch-service` on another machine (e.g., your notebook), you can copy the existing CA to the Pi instead of generating a new one:
```bash
# On your Pi:
sudo mkdir -p /etc/my-dns-ca
sudo chown $USER:$USER /etc/my-dns-ca
# Run this on your notebook (replace hostnames and paths):
# Note: This is easiest if your SSH key is added to the Pi and soundtouch-service host.
# If you run into permission issues with sudo, ensure the source user has passwordless sudo for 'cat'.
# Step A: Download from source to your notebook
ssh soundtouch-service "sudo cat /var/lib/soundtouch-service/certs/ca.crt" > ca.crt
ssh soundtouch-service "sudo cat /var/lib/soundtouch-service/certs/ca.key" > ca.key
# Step B: Upload from notebook to the Pi
scp ca.crt ca.key soundtouch-access-point:/tmp/
ssh soundtouch-access-point "sudo mv /tmp/ca.crt /tmp/ca.key /etc/my-dns-ca/ && sudo chown root:root /etc/my-dns-ca/ca.*"
rm ca.crt ca.key
```
### 1. Create CA Key and Certificate
```bash
sudo mkdir -p /etc/my-dns-ca
cd /etc/my-dns-ca
# Generate CA private key
sudo openssl genrsa -out ca.key 4096
# Generate Root CA certificate
# Note: we explicitly add basicConstraints=CA:TRUE for modern TLS clients
sudo openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 \
-out ca.crt \
-subj "/C=DE/O=Bose-Lab/CN=Bose-Lab Root CA" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign"
```
### 2. Generate a Certificate for Interception (Example)
To intercept `global.api.bose.io`, you need a certificate for it, signed by your CA:
```bash
# Generate server key
sudo openssl genrsa -out bose.key 2048
# Create CSR (Certificate Signing Request) configuration
sudo tee bose.ext << 'EOF'
authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
subjectAltName = @alt_names
[alt_names]
DNS.1 = global.api.bose.io
DNS.2 = *.bose.io
EOF
# Generate CSR
sudo openssl req -new -key bose.key -out bose.csr \
-subj "/C=DE/O=Bose-Lab/CN=global.api.bose.io"
# Sign the certificate with your CA
sudo openssl x509 -req -in bose.csr -CA ca.crt -CAkey ca.key \
-CAcreateserial -out bose.crt -days 365 -sha256 -extfile bose.ext
```
### 3. Usage in your DNS/HTTPS Server
Your custom server (e.g., a small Go or Python script) would then use `bose.crt` and `bose.key` to serve HTTPS traffic for those domains.
## Appendix B Helpful Commands
```bash
# Which IPs did the phone receive?
cat /var/lib/misc/dnsmasq.leases
# Is the access point active?
sudo systemctl status hostapd
# Is dnsmasq active?
sudo systemctl status dnsmasq
# Check interfaces and IPs
ip addr show
# Check routing table
ip route show
# Show active nftables rules
sudo nft list ruleset
# All running tcpdump processes
pgrep -a tcpdump
# Test the Pi's own DNS resolution
dig @127.0.0.1 -p 5353 global.api.bose.io
# Check network connectivity from the phone (from the Pi)
ping 192.168.10.101 # Phone IP from dnsmasq.leases
```
+43
View File
@@ -0,0 +1,43 @@
# Spotify Account Addition Implementation Status
To fully replace Bose cloud services for the Spotify account addition flow in the "Stockholm" SoundTouch application, the following routes have been implemented in the `soundtouch-service`:
## 1. OAuth Token Exchange (Bose Cloud)
The Stockholm background worker (in `worker_common.js` and `spotify_worker.js`) performs a token exchange using an authorization code.
* **Route**: `POST /oauth/account/{account}/music/musicprovider/{sourceID}/token/cs`
* **Purpose**: To exchange the Spotify authorization code for a Bose-mediated token.
* **Implementation**: `HandleBoseAccountToken` in `pkg/service/handlers/handlers_oauth.go`.
* **Registration**: Registered in `cmd/soundtouch-service/main.go` under the `/oauth` route group.
## 2. Cloud Source Registration (Marge Service)
The SoundTouch application registers a new music source (e.g., Spotify) with the Bose cloud profile.
* **Route**: `POST /streaming/account/{account}/source`
* **Purpose**: To add the new source (username, credentials, display name) to the user's emulated cloud profile.
* **Implementation**: `HandleMargeAddSource` in `pkg/service/handlers/handlers_marge.go`.
* **Registration**: Registered in `cmd/soundtouch-service/main.go` under the `/streaming` route group.
* **Payload Format**: XML `application/vnd.bose.streaming-v1.1+xml` containing `<source>` with `<username>`, `<sourceproviderid>`, and `<credential type="token_version_3">`.
## 3. Redirect Handling (Browser to App)
The `soundtouch://` deep link redirect URI is handled by the management interface which provides the OAuth callback.
* **Callback Route**: `GET /mgmt/spotify/callback`
* **Implementation**: `HandleMgmtSpotifyCallback` in `pkg/service/handlers/handlers_mgmt.go`.
* **Confirmation Route**: `POST /mgmt/spotify/confirm` (used by mobile apps for deep-link codes).
* **Implementation**: `HandleMgmtSpotifyConfirm` in `pkg/service/handlers/handlers_mgmt.go`.
## Implementation Details
1. **Marge Add Source**:
* `HandleMargeAddSource` in `pkg/service/handlers/handlers_marge.go` parses the incoming XML and persists the new source to the `DataStore` for the corresponding account.
2. **OAuth Account Token Exchange**:
* `HandleBoseAccountToken` in `pkg/service/handlers/handlers_oauth.go` supports the `/oauth/account/.../token/cs` path.
* It responds with a JSON payload including `access_token` and `token_type` "Bearer" after exchanging the code via `ExchangeCodeAndStore`.
3. **Router Registration**:
* These paths are registered in `cmd/soundtouch-service/main.go` within the `/streaming`, `/oauth`, and `/mgmt` route blocks.
@@ -0,0 +1,297 @@
# Bose SoundTouch — Community Tools for Post-EOL Preservation
> **Context:** Bose announced the shutdown of SoundTouch cloud services, extended to **May 6, 2026**. On that date the official SoundTouch app will update to a local-only version. Bose has released the [SoundTouch Web API documentation](https://assets.bosecreative.com/m/496577402d128874/original/SoundTouch-Web-API.pdf) as open-source to enable community-driven development. This document surveys the active community projects, their feature coverage, and open development opportunities.
---
## What Bose Is Doing
After the May 6, 2026 shutdown, the following will **continue to work**:
- Streaming via Bluetooth, AirPlay, Spotify Connect, and AUX
- Local device control and grouping via an updated SoundTouch app
- Remote control features (play, pause, skip, volume)
- HDMI/optical connections on soundbars
The following will **stop working**:
- Physical and app-based presets
- In-app music service browsing (TuneIn, Pandora, etc.)
- Stereo pairing for SoundTouch 10
- Security and firmware updates
---
## Community Projects
### 1. soundcork
**[github.com/deborahgu/soundcork](https://github.com/deborahgu/soundcork)**
| | |
|---|---|
| Language | Python |
| License | MIT |
| Stars | 111 |
| Contributors | 8 |
| Commits | 287 |
| Status | Pre-alpha, actively developed |
A reverse-engineered intercept API that replaces the Bose cloud servers locally. Works by redirecting the speaker's internal `SoundTouchSdkPrivateCfg.xml` to a self-hosted FastAPI server, emulating the `marge` server (required for basic network functionality) and the `bmx` server (required for TuneIn). Deployable as a Docker container or systemd daemon. The most community-engaged project, with a dedicated discussion thread tracking Bose cloud service status.
---
### 2. Überböse API
**[github.com/julius-d/ueberboese-api](https://github.com/julius-d/ueberboese-api)**
| | |
|---|---|
| Language | Java (Spring Boot) |
| License | MIT |
| Stars | 10 |
| Contributors | 1 |
| Commits | 224 |
| Tags/Releases | 163 |
| Documentation | [julius-d.github.io/ueberboese-api](https://julius-d.github.io/ueberboese-api/) |
Reverse-engineers and rebuilds the Bose streaming HTTP API. Unique in publishing a machine-readable OpenAPI specification (`ueberboese-api.yaml`) and comprehensive request logging — making it the best research instrument for understanding what speakers actually call upstream. Implements Spotify OAuth integration and TuneIn. Companion to the Überböse App.
---
### 3. Überböse App
**[github.com/julius-d/ueberboese-app](https://github.com/julius-d/ueberboese-app)**
| | |
|---|---|
| Language | Flutter (Dart) |
| License | MIT |
| Latest version | 0.26.0 (March 2026) |
| Distribution | [F-Droid](https://f-droid.org/en/packages/io.github.juliusd.ueberboese.app/) |
| Platform | Android |
The only native installable phone app in the ecosystem. Pairs with the Überböse API server. Features: preset view/play/reprogram, multi-room zone management, volume control, now-playing display, Spotify authentication setup. Controls speakers directly via the local SoundTouch WebServices API (no server required for basic control).
---
### 4. SoundTouch Hybrid 2026
**[github.com/TJGigs/Bose-SoundTouch-Hybrid-2026](https://github.com/TJGigs/Bose-SoundTouch-Hybrid-2026)**
| | |
|---|---|
| Language | Node.js (JavaScript) |
| License | — |
| Stars | 1 |
| Commits | 3 (V1) / 12 (V3) |
| Status | Experimental / testing |
A self-hosted private cloud that emulates and replaces the Bose Cloud Service. Runs locally on a NAS or PC, intercepts the complex server handshakes needed to keep the SoundTouch infrastructure functional. Relies on **Music Assistant** for backend audio routing and provider aggregation. Features a setup wizard including USB config generation (`OverrideSdkPrivateCfg.xml`) and an on-screen Bose Cloud Emulation Setup guide. Targets users who want the broadest streaming provider support via Music Assistant's ecosystem.
---
### 5. OpenCloudTouch (OCT)
**[github.com/scheilch/opencloudtouch](https://github.com/scheilch/opencloudtouch)**
| | |
|---|---|
| Language | Python (FastAPI) + TypeScript (React) |
| License | Apache 2.0 |
| Stars | 9 |
| Commits | 313 |
| Latest release | v1.1.1 (April 12, 2026) |
| Documentation | GitHub Wiki (EN/DE) |
A single Docker container combining a FastAPI backend and React frontend. The most production-ready project in the ecosystem in terms of release discipline and deployment accessibility. Features: internet radio with full hardware preset support (buttons 16), responsive web UI, device discovery via SSDP/UPnP, multi-room zone management, BMX-compatible endpoints, TuneIn stream resolver, RadioBrowser as a built-in first-class search provider, and pre-built Raspberry Pi SD card images for Pi 3/4/5. Deployable on amd64, arm64, and arm/v7. Documented in English and German. Spotify and Music Assistant integration are on the roadmap.
---
### 6. AfterTouch
**[github.com/gesellix/Bose-SoundTouch](https://github.com/gesellix/Bose-SoundTouch)** by gesellix
| | |
|---|---|
| Language | Go |
| License | MIT |
| Stars | 16 |
| Contributors | 2 |
| Commits | 217 |
| Releases | 51 (latest: v0.28.0, Feb 15, 2026) |
| Documentation | [gesellix.github.io/Bose-SoundTouch](https://gesellix.github.io/Bose-SoundTouch/) |
The most comprehensive single toolkit in the ecosystem. Comprises three components: a Go library (importable package), a CLI (`soundtouch-cli`), and a local cloud emulation service (`soundtouch-service`). Covers the widest range of dimensions of any single project. Implements the complete Bose Spotify OAuth relay including surrogate secret generation and token refresh proxy. Includes a built-in DNS server for device redirection without SSH, HTTPS/custom CA injection, HTTP session recording, traffic proxy/logging, and a web management UI. Tested on real SoundTouch 10 and 20 hardware. Has a Patreon for ongoing support.
---
### 7. soundcork-stockholm-app
**[github.com/krahl/soundcork-stockholm-app](https://github.com/krahl/soundcork-stockholm-app)**
| | |
|---|---|
| Language | Java |
| License | — |
| Stars | 2 |
| Commits | 21 |
| Status | Active development, bugs expected |
A Java-based middleware that hosts the original Bose Stockholm frontend (extracted from the APK) in a local web browser at `http://127.0.0.1:8088/`. Bridges the Stockholm UI to local speakers via an HTTP proxy that resolves cross-origin issues, with SSDP-based device discovery and JSON state persistence. Unlike every other tool in the ecosystem, it runs the **official Bose UI** rather than a custom replacement — preserving the familiar Bose UX at the cost of requiring the Stockholm APK. Notable limitations: OAuth flows are unreliable, and WebSocket connections to speakers over HTTPS have blocking issues. Works alongside soundcork's backend for full cloud emulation.
---
### 8. jaas666/bose-soundtouch-web-api (Reference)
**[github.com/jaas666/bose-soundtouch-web-api](https://github.com/jaas666/bose-soundtouch-web-api)**
Community-maintained Markdown conversion of the official Bose SoundTouch Web API PDF (v1.0, January 7, 2026). Useful as a developer reference. Not a deployable tool.
---
## Feature Coverage Matrix
Legend: ● Yes/complete · ◑ Partial/planned · ○ No
| Dimension | soundcork | Überböse API | Überböse App | ST Hybrid 2026 | OpenCloudTouch | AfterTouch | Stockholm App |
|----------------------------------------------------|:---------:|:------------:|:------------:|:--------------:|:--------------:|:----------:|:-------------:|
| **① App layer — local HTTP/WS control** | | | | | | | |
| Playback control (play/pause/vol) | ○ | ○ | ● | ● | ● | ● | ● |
| Preset view & trigger | ○ | ○ | ● | ● | ● | ● | ● |
| Preset write / reprogram | ○ | ○ | ● | ● | ◑ | ● | ● |
| Multi-room zone management | ○ | ○ | ● | ● | ● | ● | ● |
| Now playing / status display | ○ | ○ | ● | ● | ● | ● | ● |
| Device discovery (SSDP/mDNS) | ○ | ○ | ● | ○ | ● | ● | ● |
| WebSocket real-time events | ○ | ○ | ◑ | ● | ◑ | ● | ◑ |
| **② Cloud/service layer — replaces Bose upstream** | | | | | | | |
| Marge server emulation | ● | ● | ○ | ● | ○ | ● | ○ |
| BMX / content registry | ◑ | ◑ | ○ | ● | ● | ● | ○ |
| Account / OAuth token relay | ○ | ● | ○ | ◑ | ○ | ● | ◑ |
| Preset sync (cloud-side) | ● | ● | ○ | ● | ○ | ● | ○ |
| Recents sync | ● | ◑ | ○ | ◑ | ○ | ● | ○ |
| Sources / device info persistence | ● | ● | ○ | ● | ○ | ● | ○ |
| Stereo group CRUD (ST10 pairs) | ● | ○ | ○ | ○ | ○ | ● | ○ |
| **③ Device redirection — USB/SSH setup** | | | | | | | |
| Setup wizard / guided redirect | ◑ | ◑ | ○ | ● | ● | ● | ○ |
| USB image / config generation | ○ | ○ | ○ | ● | ○ | ◑ | ○ |
| HTTPS / custom CA support | ○ | ○ | ○ | ○ | ○ | ● | ○ |
| **④ Streaming provider integration** | | | | | | | |
| Internet radio (RadioBrowser) | ○ | ◑ | ◑ | ◑ | ● | ◑ | ○ |
| TuneIn stream resolver | ● | ● | ● | ● | ● | ● | ● |
| Spotify OAuth / Connect | ○ | ● | ● | ◑ | ◑ | ● | ◑ |
| Pandora | ○ | ○ | ○ | ○ | ○ | ● | ◑ |
| Music Assistant backend | ○ | ○ | ○ | ● | ◑ | ○ | ○ |
| **⑤ Mobile / native app** | | | | | | | |
| Android app (installable) | ○ | ○ | ● | ○ | ○ | ○ | ○ |
| iOS app | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Mobile-responsive web UI | ○ | ○ | ○ | ● | ● | ● | ● |
| **⑥ Smart home / ecosystem integration** | | | | | | | |
| Home Assistant integration | ○ | ○ | ○ | ○ | ○ | ◑ | ○ |
| Music Assistant integration | ○ | ○ | ○ | ● | ◑ | ○ | ○ |
| **⑦ CLI / automation tools** | | | | | | | |
| CLI for scripting / automation | ○ | ○ | ○ | ○ | ○ | ● | ○ |
| Traffic proxy / API logging | ◑ | ● | ○ | ○ | ○ | ● | ◑ |
| HTTP session recording | ○ | ○ | ○ | ○ | ○ | ● | ○ |
| **⑧ Library / SDK** | | | | | | | |
| Importable library / package | ○ | ○ | ○ | ○ | ○ | ● | ○ |
| Published API spec / docs | ○ | ● | ○ | ○ | ○ | ● | ○ |
| Docker deployment | ● | ● | ○ | ● | ● | ● | ● |
| Raspberry Pi SD card image | ○ | ○ | ○ | ○ | ● | ○ | ○ |
---
## Making AfterTouch the One-Stop Solution — Open Tasks
AfterTouch is the strongest single project across the service and developer layers. Its remaining gaps are on the consumer-facing and ecosystem-integration sides.
### Priority 1 — PWA installability
The web UI is already fully responsive — it has Bootstrap grid columns, `@media (max-width: 768px)` and `@media (max-width: 576px)` breakpoints, and a proper viewport meta tag. It works on iPhone and Android browsers today. What's missing is **installability**: no `manifest.json` and no service worker, so it cannot be added to the home screen as a standalone app. Adding these would close the iOS app gap ecosystem-wide (no project has an iOS app) at minimal effort.
### Priority 2 — RadioBrowser as a first-class provider
AfterTouch can proxy and play any stream URL, but there is no built-in station search. OpenCloudTouch's RadioBrowser integration is the reference. Tasks:
- Wire the [RadioBrowser API](https://www.radio-browser.info/) into the `soundtouch-web` web UI as a browsable/searchable source.
- Make discovered stations directly presetable to hardware buttons.
- This is the most common replacement for TuneIn for users who listened to internet radio via presets.
### Priority 3 — Raspberry Pi SD card image
OpenCloudTouch ships a flashable Pi image and it dramatically lowers the barrier for the most common "always-on local server" deployment. AfterTouch already has Docker and a web management UI; this is largely a CI/packaging task:
- Build a Pi image (using e.g. `pi-gen` or `rpi-imager`-compatible tooling) that boots directly into `soundtouch-service`.
- Auto-starts on boot, auto-discovers devices, opens the web UI on a known port.
- Target Pi 3/4/5 with amd64/arm64/arm/v7 variants (mirroring OCT's approach).
### Priority 4 — USB config generation in the web UI
AfterTouch modifies `SoundTouchSdkPrivateCfg.xml` via SSH (`pkg/service/setup/setup.go`) and documents the redirect process thoroughly, but does not yet generate the USB stick content for users without SSH access. A "prepare USB stick" button in the web UI would remove the last manual step:
- Generate `OverrideSdkPrivateCfg.xml` pre-populated with the running server's URL.
- Optionally include the custom CA certificate for HTTPS-capable devices.
- Surface alongside the existing guided migration wizard.
### Priority 5 — Music Assistant integration
SoundTouch Hybrid 2026 uses Music Assistant as its streaming backend, giving access to Apple Music, Deezer, local libraries, and many other providers. A formal Music Assistant **player provider** for AfterTouch would give power users a path to sources beyond Spotify, TuneIn, Pandora, and RadioBrowser. The Music Assistant community has an open discussion thread on this ([#4766](https://github.com/orgs/music-assistant/discussions/4766)).
### Priority 6 — DNS-based migration documentation
AfterTouch includes a built-in DNS server (`ENABLE_DNS_DISCOVERY`, `DNS_BIND_ADDR`, `DNS_UPSTREAM`) that intercepts `*.bose.com` queries and forwards everything else upstream — no Pi-hole, AdGuard, or any other external tool required. The ResolvConf migration path already treats DNS as a first-class option. The remaining gap is awareness: users unfamiliar with the project may not realise no external DNS infrastructure is needed. Tasks:
- Surface the built-in DNS server more prominently in the getting-started documentation.
- Document Pi-hole / AdGuard Home as an *alternative* for users who already run those, not a requirement.
### Priority 7 — MQTT integration
A design document exists (`docs/guides/MQTT-INTEGRATION-DESIGN.md`) but no code has been written. Implementing it would unlock home automation use cases without requiring the full Home Assistant stack — enabling triggers like "play preset 1 when front door opens" via any MQTT-capable automation platform.
---
## soundcork ↔ AfterTouch
soundcork and AfterTouch share the most functional overlap of any two projects in the ecosystem. For the implementation-level parity analysis and remaining tasks see [docs/PARITY-SOUNDCORK.md](../PARITY-SOUNDCORK.md).
### Architectural differences (not gaps)
These exist in soundcork but are deliberate architectural choices in AfterTouch, not missing features:
| Area | soundcork | AfterTouch |
|--------------------------|---------------------------------------|-----------------------------------------------------------|
| Web UI | FastAPI + Jinja2 miniapp and admin UI | Separate `soundtouch-web` component (Go + plain HTML/JS) |
| Direct device management | SSH/SCP access into speakers | HTTP API only; no SSH |
| Device discovery client | Python `upnpclient` library | mDNS + UPnP in Go, with dedicated DNS interception server |
| Token delivery | Push (ZeroConf priming to port 8200) | Pull (device calls back to fetch) |
| Persistence format | Flat files | XML flat files + atomic writes |
### AfterTouch capabilities soundcork lacks
| Feature | Notes |
|-------------------------------------------|-----------------------------------------------------------------|
| DNS server for device redirect | Intercepts Bose domain queries; no Pi-hole required |
| HTTPS / custom CA injection | Full TLS with certificate generation and trust workflow |
| HTTP interaction recording & replay | Captures real device traffic for debugging and regression tests |
| Device migration (serial → MAC path) | Handles legacy device ID formats automatically |
| Transparent proxy mode with upstream sync | Can mirror to real Bose cloud while running locally |
| CLI (`soundtouch-cli`) | Scriptable control of speakers |
| Importable Go library | `github.com/gesellix/bose-soundtouch/pkg/client` |
---
### Ecosystem fragmentation vs. convergence
The community is currently covering different parts of the problem in parallel rather than converging. AfterTouch explicitly credits soundcork, Überböse, and SoundTouch Plus in its README and describes its `soundtouch-service` as "heavily inspired by SoundCork". There is an opportunity — and arguably a need — for these projects to formally coordinate: shared test fixtures, a common compatibility matrix against specific firmware versions, and agreed-on API contracts would all reduce duplicated effort.
### Firmware version sensitivity
The SoundTouch 10 is most dependent on Marge for basic network functionality; the 20 and 30 are somewhat more tolerant. Compatibility across firmware versions is not systematically documented anywhere. A community firmware compatibility matrix (model × firmware version × which emulation features work) would be high value and is currently missing.
### Security posture
All projects warn that speakers should only be used on a private, firewalled network after cloud shutdown. AfterTouch is the only project to implement HTTPS/custom CA, which matters if devices are ever on a network where traffic could be inspected. soundcork's SECURITY.md explicitly warns against running on open networks.
### No iOS app — a structural gap
The original SoundTouch app was iOS-first. Every community replacement is Android-only (Überböse App) or browser-based. This is the largest unaddressed user segment in the ecosystem.
### Bose's open-source move as a precedent
Bose's decision to release API documentation rather than simply shutting down is notable — it mirrors what Pebble users did themselves with Rebble after that shutdown, but here the manufacturer initiated it. This sets a useful precedent and gives the community a solid legal and technical foundation to build on.
### Related community resources
- [Bose SoundTouch Plus (Home Assistant component)](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus) — comprehensive HA integration by Todd Lucas, extensive API wiki
- [Bose SoundTouch Hook](https://github.com/CodeFinder2/bose-soundtouch-hook) — `LD_PRELOAD`-based reverse engineering framework used by AfterTouch for protocol research
- [Bose SoundTouch Web API (community Markdown)](https://github.com/jaas666/bose-soundtouch-web-api) — official API PDF converted to Markdown
- [Bose Wiki — SoundTouch App Alternatives](https://bose.fandom.com/wiki/SoundTouch_app_alternatives) — community-maintained living list of workarounds and projects
- [Reddit megathread — Bose alternatives](https://www.reddit.com/r/bose) — ongoing community discussion
- [Radio Browser](https://www.radio-browser.info/) — the free, community-maintained internet radio directory used as a TuneIn replacement
---
*Document compiled April 2026. Project details sourced directly from GitHub repositories and official documentation. Star counts, commit counts, and release dates reflect the state at time of writing and will change as projects evolve. soundcork-stockholm-app added April 2026.*
+19 -19
View File
@@ -185,7 +185,7 @@ type NowPlaying struct {
type PlayStatus string
const (
PlayStatusPlaying PlayStatus = "PLAY_STATE"
PlayStatusPaused PlayStatus = "PAUSE_STATE"
PlayStatusPaused PlayStatus = "PAUSE_STATE"
PlayStatusStopped PlayStatus = "STOP_STATE"
)
@@ -277,19 +277,19 @@ type Config struct {
// Server configuration
WebPort int `env:"WEB_PORT" default:"8080"`
APITimeout time.Duration `env:"API_TIMEOUT" default:"10s"`
// Discovery configuration
// Discovery configuration
DiscoveryTimeout time.Duration `env:"DISCOVERY_TIMEOUT" default:"5s"`
CacheDevices bool `env:"CACHE_DEVICES" default:"true"`
CacheTTL time.Duration `env:"CACHE_TTL" default:"5m"`
// CORS configuration (for web proxy)
CORSOrigins []string `env:"CORS_ORIGINS" default:"*"`
// Logging
LogLevel string `env:"LOG_LEVEL" default:"info"`
LogFormat string `env:"LOG_FORMAT" default:"json"`
// Development
DevMode bool `env:"DEV_MODE" default:"false"`
}
@@ -537,7 +537,7 @@ build-all: build-linux build-darwin build-windows
dev-cli:
air -c .air-cli.toml
dev-webapp:
dev-webapp:
air -c .air-webapp.toml
dev-wasm:
@@ -556,7 +556,7 @@ check: fmt vet lint test
# Docker development environment
docker-dev:
docker-compose up --build
docker compose up --build
# Release packaging
release: build-all
@@ -596,7 +596,7 @@ import (
"fmt"
"log"
"time"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/gesellix/bose-soundtouch/pkg/models"
@@ -609,36 +609,36 @@ func main() {
if err != nil {
log.Fatal(err)
}
if len(devices) == 0 {
log.Fatal("No SoundTouch devices found")
}
// Create client for first device
client := client.NewClient(client.ClientConfig{
Host: devices[0].Host,
Port: 8090,
Timeout: 10 * time.Second,
})
// Get device info
info, err := client.GetDeviceInfo()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Connected to: %s\n", info.Name)
// Get current playback
nowPlaying, err := client.GetNowPlaying()
if err != nil {
log.Fatal(err)
}
if nowPlaying.PlayStatus == models.PlayStatusPlaying {
fmt.Printf("Playing: %s - %s (%s)\n",
fmt.Printf("Playing: %s - %s (%s)\n",
nowPlaying.Artist, nowPlaying.Track, nowPlaying.Album)
}
// Control playback
if nowPlaying.PlayStatus == models.PlayStatusPlaying {
client.SendKey(models.KeyPause)
@@ -737,11 +737,11 @@ docker run -p 8080:8080 soundtouch-webapp
```bash
# Local development with hot reload
make dev-webapp # Web app development
make dev-wasm # WASM development
make dev-wasm # WASM development
make dev-cli # CLI development
# Full development environment
docker-compose up # Mock devices + web app
docker compose up # Mock devices + web app
```
## Success Criteria
@@ -781,7 +781,7 @@ docker-compose up # Mock devices + web app
- [Bose SoundTouch Web API Documentation](https://assets.bosecreative.com/m/496577402d128874/original/SoundTouch-Web-API.pdf)
- [Go WebAssembly](https://github.com/golang/go/wiki/WebAssembly)
- [UPnP Device Architecture](http://upnp.org/specs/arch/UPnP-arch-DeviceArchitecture-v1.0.pdf)
- [UPnP Device Architecture](http://upnp.org/specs/arch/UPnP-arch-DeviceArchitecture-v1.0.pdf)
- [Go Embed Directive](https://pkg.go.dev/embed)
- [Gorilla WebSocket](https://github.com/gorilla/websocket)
- [PROJECT-PATTERNS.md](../PROJECT-PATTERNS.md) - Detailed pattern documentation
+369
View File
@@ -0,0 +1,369 @@
# Amazon Music OAuth Integration
This document describes the plan and specification for adding Amazon Music OAuth support to the SoundTouch service, enabling continued Amazon Music playback after the Bose cloud shutdown (May 2026).
The implementation mirrors the [Spotify OAuth integration](spotify-oauth.md) closely. Read that document first — this one calls out only the differences.
## Status
**Infrastructure complete — streaming blocked by API access.**
All eight implementation steps are done. The OAuth flow (account linking, token storage, token refresh) works end-to-end with a standard Login with Amazon app. Token exchange (`/oauth/device/.../token/cs1`) succeeds and the speaker receives a valid `Atza|` access token.
However, real-world testing shows that the speaker then calls `https://music-api.amazon.com/` with that token and receives a `401 Unauthorized` (no redirect to a regional endpoint). This means the token does not carry the scopes required to access the Amazon Music streaming API.
**Root cause (confirmed):** Amazon Music streaming requires the `amazon_music:access` scope, which is only available to **device client IDs** — a separate credential type obtained through Amazon's Music partner programme. Standard Login with Amazon application client IDs (`amzn1.application-oa2-client.*`) cannot request this scope: attempting to include it in the authorization URL returns `lwa-invalid-parameter-bad-scope` (HTTP 400) from the LWA authorization endpoint. Bose would have held a device client ID as a registered Amazon Music partner.
**What still works:**
- Account linking and token storage
- Token refresh (the service correctly exchanges the refresh token for a fresh access token)
- Marge source registration (the speaker sees Amazon Music as a configured source)
**What does not work:**
- Actual music playback — the speaker's `AmazonClient` cannot authenticate to `music-api.amazon.com` with a standard LWA token
**Path forward:** Obtaining a device client ID requires registering with Amazon's Music partner programme. If such a credential is obtained, the only code change needed is swapping the `client_id`/`client_secret` for the device credentials and adding `amazon_music:access` to `AmazonScopes` in `pkg/service/amazon/service.go` — everything else is already in place. The `site_id` field is a secondary open question that may also affect regional routing once the scope issue is resolved.
---
## Secret Format (confirmed from a live Bose system)
A real Amazon source entry from a migrated device's `Sources.xml`:
```xml
<source secretType="token">
<credential type="token">{"AmazonSecret":{"refresh_token":"Atzr|...","site_id":"1464855981"}}</credential>
<sourceKey type="AMAZON" account="user@example.com"/>
</source>
```
Key observations:
- **Secret envelope**: `{"AmazonSecret":{"refresh_token":"...","site_id":"..."}}` — JSON-encoded, HTML-entity-escaped in XML attributes, stored as the credential value.
- **`Atzr|` prefix**: This is the standard Amazon LWA (Login with Amazon) refresh token prefix from the **authorization code grant** — confirming that Web OAuth is the correct flow, not CBL.
- **`site_id`**: A numeric string (`"1464855981"`). Origin is not yet fully confirmed; candidates are:
- A static Bose partner identifier baked into the Bose app/firmware (same value for all users), or
- A per-user Amazon Music identifier returned by a Music API device registration call.
- Needs verification — possibly obtained by calling the Amazon Music API after initial authentication.
- **`account` field**: The user's Amazon email address (obtained from the LWA `/user/profile` endpoint).
When `HandleBoseAmazonToken` receives a refresh request from the speaker, it must:
1. Parse the `AmazonSecret` JSON from the stored credential to extract `refresh_token`.
2. Call the LWA token endpoint with a `refresh_token` grant.
3. Return the fresh `access_token` to the speaker.
4. Persist the rotated `refresh_token` back into the `AmazonSecret` envelope.
---
## How the Speaker Uses This
When the SoundTouch firmware tries to play Amazon Music after migration, it sends a token refresh request to the local service:
```
POST /oauth/device/{deviceID}/music/musicprovider/20/token/cs1
```
The service must respond with a fresh Amazon access token. The speaker then uses that token directly with Amazon's playback infrastructure.
The `cs1` suffix (credential schema 1) is Amazon-specific; Spotify uses `cs3`. This route is already registered.
> **DNS note:** The speaker constructs the OAuth hostname by appending `oauth` to the streaming service subdomain. If the service is reachable at `myhost.local`, the speaker will call `myhostoauth.local`. A DNS alias pointing `myhostoauth.<domain>` to the same IP as the service is required.
---
## OAuth Flows
### 1. Browser-based Flow
```mermaid
sequenceDiagram
participant Client as Client (curl/app)
participant Service as Service
participant Amazon as Amazon Auth Server (LWA)
participant Browser as User's Browser
Client->>Service: POST /mgmt/amazon/init [Basic Auth]
Service-->>Client: {"redirectUrl": "https://www.amazon.com/ap/oa?..."}
Client->>Browser: User opens URL
Browser->>Amazon: User logs in & grants access
Amazon-->>Browser: Redirect to /mgmt/amazon/callback?code=abc
Browser->>Service: GET /mgmt/amazon/callback?code=abc
Note over Service: No auth needed for callback
Service->>Amazon: POST /auth/o2/token (exchange code)
Amazon-->>Service: {access_token, refresh_token}
Service->>Amazon: GET /user/profile (fetch profile)
Amazon-->>Service: {user_id, name, email}
Note over Service: Store account to disk
Service-->>Browser: HTML: "Amazon Music Connected. You can close this window."
```
### 2. Mobile App Flow (ueberboese)
```mermaid
sequenceDiagram
participant App as ueberboese Flutter App
participant Service as Service
participant Amazon as Amazon Auth Server (LWA)
App->>Service: POST /mgmt/amazon/init [Basic Auth]
Service-->>App: {"redirectUrl": "https://www.amazon.com/ap/oa?..."}
App->>Amazon: Open in-app browser (User authorizes)
Amazon-->>App: Deep link redirect: ueberboese-login://amazon?code=abc
App->>Service: POST /mgmt/amazon/confirm?code=abc [Basic Auth]
Service->>Amazon: POST /auth/o2/token (exchange code)
Amazon-->>Service: {access_token, refresh_token}
Service->>Amazon: GET /user/profile (fetch profile)
Amazon-->>Service: {profile}
Service-->>App: {"ok": true}
```
### 3. Token Retrieval (Speaker Token Refresh)
```mermaid
sequenceDiagram
participant Speaker as SoundTouch Speaker
participant Service as Service
participant Amazon as Amazon Token API (LWA)
Speaker->>Service: POST /oauth/device/{deviceID}/music/musicprovider/20/token/cs1
Note over Service: Body contains stored AmazonSecret JSON;<br/>extract refresh_token from {"AmazonSecret":{...}}
alt Token expired or near expiry
Service->>Amazon: POST /auth/o2/token (refresh_token grant, body credentials)
Amazon-->>Service: {access_token, refresh_token, expires_in}
Note over Service: Persist rotated refresh_token back into AmazonSecret envelope
end
Service-->>Speaker: {"access_token": "...", "token_type": "Bearer", "expires_in": 3600}
```
---
## Implementation Steps
### Step 1 — Extract ZeroConf into a shared package
**Why first:** The DH-blob encryption in `pkg/service/spotify/zeroconf.go` is entirely provider-agnostic. Extracting it to `pkg/service/zeroconf/` before adding Amazon avoids duplicating ~200 lines of crypto code.
**What changes:**
- Create `pkg/service/zeroconf/zeroconf.go` — move `generateDHKeyPair`, `computeSharedSecret`, `deriveKeys`, `buildCredentialsBlob`, `encryptBlob` and helpers. Expose `authType` as a parameter (Spotify and Amazon both use `AuthTypeOAuthToken = 4`, but this makes it explicit).
- Update `pkg/service/spotify/zeroconf.go` — delete moved code; `PushSpotifyCredentials` becomes a one-line wrapper calling `zeroconf.PushCredentials(...)`.
### Step 2 — Create `pkg/service/amazon/service.go`
Mirror `pkg/service/spotify/service.go`. The `Account` struct is identical; copy it unchanged.
**Amazon-specific differences:**
| Item | Spotify | Amazon |
|---------------------------|------------------------------------------|-------------------------------------------------|
| Authorization URL | `https://accounts.spotify.com/authorize` | `https://www.amazon.com/ap/oa` |
| Token endpoint | `https://accounts.spotify.com/api/token` | `https://api.amazon.com/auth/o2/token` |
| Profile endpoint | `https://api.spotify.com/v1/me` | `https://api.amazon.com/user/profile` |
| Token request credentials | HTTP Basic Auth (clientID:clientSecret) | POST body fields `client_id` / `client_secret` |
| Profile fields | `id`, `display_name`, `email` | `user_id`, `name`, `email` |
| Scopes | `streaming user-read-private ...` | `profile` (expand to `music::*` when available) |
| Entity resolution | `ResolveEntity()` via Spotify API | Not implemented (API in closed beta) |
Accounts persist to `{dataDir}/amazon/accounts.json`.
The token request credential difference (body vs. Basic Auth) is the most important implementation detail.
### Step 3 — Create `pkg/service/amazon/zeroconf.go`
A single exported function `PushAmazonCredentials(zcBaseURL, username, accessToken string) error` delegating to the shared `zeroconf.PushCredentials(...)`.
### Step 4 — Implement `HandleBoseAmazonToken`
Replace the 501 stub in `pkg/service/handlers/handlers_oauth.go` with the full mirror of `HandleBoseSpotifyToken`:
- Parse body for `refresh_token` / `code`
- Look up account by BoseSecret; refresh and return token
- Fall back to first account via `GetFreshToken()` if no matching account
- Fall back to `HandleBoseProxy` if no Amazon service is configured
- **Omit `scope` from the response** — Amazon Music scopes are undocumented; sending invented values risks firmware rejection
### Step 5 — Add Amazon fields to `Server`
In `pkg/service/handlers/server.go`, add alongside the Spotify fields:
```go
amazonClientID string
amazonClientSecret string
amazonRedirectURI string
amazonService *amazon.Service
```
Add methods: `SetAmazonConfig`, `SetAmazonService`, `IsAmazonConfigured`, `PrimeDeviceWithAmazon`.
### Step 6 — Add management handlers
In `pkg/service/handlers/handlers_mgmt.go`, add six handlers mirroring Spotify:
| Handler | Notes |
|-------------------------------|-----------------------------------------|
| `HandleMgmtAmazonInit` | Returns LWA authorize URL |
| `HandleMgmtAmazonCallback` | No auth; calls `bridgeAmazonToMarge` |
| `HandleMgmtAmazonConfirm` | Basic Auth; calls `bridgeAmazonToMarge` |
| `HandleMgmtAmazonAccounts` | Returns account list (tokens stripped) |
| `HandleMgmtAmazonToken` | Returns fresh access token |
| `HandleMgmtPrimeDeviceAmazon` | Pushes token to speaker via ZeroConf |
`bridgeAmazonToMarge` must encode the stored secret as `{"AmazonSecret":{"refresh_token":"<token>","site_id":"<id>"}}` and use `CredentialTypeToken` ("token") — **not** `CredentialTypeTokenV3`. Amazon uses `cs1` semantics.
### Step 7 — Wire CLI flags and router
**`main.go` flags** (env vars in parentheses):
- `--amazon-client-id` (`AMAZON_CLIENT_ID`)
- `--amazon-client-secret` (`AMAZON_CLIENT_SECRET`)
- `--amazon-redirect-uri` (`AMAZON_REDIRECT_URI`, default: `ueberboese-login://amazon`)
- `--amazon-token-url` (`AMAZON_TOKEN_URL`, for testing overrides)
- `--amazon-profile-url` (`AMAZON_PROFILE_URL`, for testing overrides)
**Router** (`setupRouter`): Add `/mgmt/amazon/*` sub-routes next to the Spotify block. The `/oauth/.../token/cs1` route is already registered and dispatches to `HandleBoseAmazonToken`.
**`pkg/service/marge/marge.go`**: Extend the `AddSource` provider-label branch to map `AmazonProviderID (20) → "AMAZON"` so stored sources carry the correct type string rather than the raw numeric ID.
**`pkg/models/account.go`**: Add `NewAmazonOAuthCredentials` with `Source: "AMAZON"`, `Version: "token"`.
### Step 8 — Tests
Mirror the Spotify test suite for the Amazon package:
- `TestBuildAuthorizeURL` — verify LWA URL structure
- `TestExchangeCodeAndStore` — mock token + profile servers; assert POST body credentials (not Basic Auth)
- `TestRefreshAccessToken` — verify body credentials, token rotation
- `TestGetFreshToken*` — copy Spotify variants verbatim
- `TestSaveAndLoad` — verify persistence under `amazon/accounts.json`
Add `pkg/testutils/amazon/handlers.go` and `tests/integration/mocks/amazon.go` mock servers mirroring the Spotify equivalents.
Update `cmd/soundtouch-service/testdata/router_routes.txt` snapshot after wiring.
---
## Trying It Out
### 1. Create a Login with Amazon (LWA) app
Go to [developer.amazon.com](https://developer.amazon.com) → **Login with Amazon****Create a New Security Profile**.
You will receive a **Client ID** and **Client Secret**. Under *Web Settings*, add an **Allowed Return URL** that matches `--amazon-redirect-uri`:
- **Browser flow** (easiest to test): `http://<your-host>:8000/mgmt/amazon/callback`
- **Mobile deep-link flow**: `ueberboese-login://amazon` (the default)
The `profile` scope is sufficient — `music::` scopes are in closed beta and not required. The service only brokers tokens; the speaker communicates with Amazon's playback infrastructure directly.
### 2. Start the service
```bash
./soundtouch-service \
--amazon-client-id amzn1.application-oa2-client.xxx \
--amazon-client-secret yyy \
--amazon-redirect-uri http://<your-host>:8000/mgmt/amazon/callback
```
Or set the equivalent environment variables: `AMAZON_CLIENT_ID`, `AMAZON_CLIENT_SECRET`, `AMAZON_REDIRECT_URI`.
### 3. Trigger the OAuth flow
```bash
# Get the LWA authorization URL
curl -u admin:change_me! -X POST http://localhost:8000/mgmt/amazon/init
# → {"redirectUrl":"https://www.amazon.com/ap/oa?client_id=...&scope=profile&..."}
```
Open the `redirectUrl` in a browser, log in with your Amazon account, and authorize the app. Amazon redirects back to `/mgmt/amazon/callback`, which responds with an HTML page saying "Amazon Music Connected".
### 4. Verify the account is linked
```bash
curl -u admin:change_me! http://localhost:8000/mgmt/amazon/accounts
# → {"accounts":[{"user_id":"amzn1.account.xxx","display_name":"Your Name","email":"you@example.com",...}]}
```
### 5. Prime a speaker
```bash
# Discover device IDs first
curl -u admin:change_me! http://localhost:8000/mgmt/accounts/default/speakers
# Push the token to a specific speaker via ZeroConf
curl -u admin:change_me! -X POST \
"http://localhost:8000/mgmt/amazon/prime?deviceId=<deviceId>"
# → {"status":"Priming triggered"}
```
### 6. Verify token refresh from the speaker
Once a speaker has Amazon Music as a source, it will periodically POST to:
```
POST /oauth/device/{deviceID}/music/musicprovider/20/token/cs1
```
The service looks up the account by refresh token, refreshes it via LWA, and returns a fresh `access_token`. Check the service logs for `[Amazon]` entries confirming this flow.
### DNS requirement
The speaker derives the OAuth hostname by appending `oauth` to its configured streaming subdomain. If the service is at `myhost.local`, the speaker calls `myhostoauth.local`. A DNS alias pointing `myhostoauth.<domain>` to the same IP is required — the built-in DNS discovery server handles this automatically when `--dns-discovery` is enabled.
### Open question: `site_id`
The `AmazonSecret` credential envelope contains a `site_id` field (e.g. `"1464855981"` seen in a real migrated device). Its origin is unconfirmed — it may be a static Bose partner ID or a per-user Amazon Music identifier. The service currently stores an empty string.
Real-world testing shows the device's `AmazonClient` calls `CheckBaseUrlRedirect` with empty `data` (the `site_id`) and then tries `https://music-api.amazon.com/` directly, receiving a 401 with no redirect to a regional endpoint (e.g. `music-api.amazon.de` for a German account). This suggests that:
1. A correct `site_id` might cause the device to use the right regional endpoint instead of the US default.
2. Even so, the token scope issue (see Status above) would still block playback — resolving `site_id` alone is not sufficient.
`site_id` is likely secondary to the partner scope problem. It remains an open question for the post-scope-resolution phase.
---
## Endpoints
| Method | Path | Auth | Purpose |
|--------|-------------------------------------------------------------|-------|----------------------------------------------------|
| `POST` | `/oauth/device/{deviceID}/music/musicprovider/20/token/cs1` | None | Token refresh from speaker |
| `GET` | `/mgmt/amazon/callback` | None | Browser OAuth callback (redirect from Amazon LWA) |
| `POST` | `/mgmt/amazon/init` | Basic | Start OAuth flow, returns authorization URL |
| `POST` | `/mgmt/amazon/confirm` | Basic | Mobile app confirm (deep link delivers code) |
| `GET` | `/mgmt/amazon/accounts` | Basic | List linked Amazon accounts (tokens stripped) |
| `GET` | `/mgmt/amazon/token` | Basic | Get fresh access token (auto-refreshes if expired) |
| `POST` | `/mgmt/amazon/prime` | Basic | Push token to speaker via ZeroConf |
## Security
Same model as Spotify:
- `/mgmt/amazon/callback` is intentionally outside Basic Auth to allow direct redirects from Amazon's authorization server.
- All other `/mgmt/*` endpoints require Basic Auth.
- Accounts persist to `{dataDir}/amazon/accounts.json` with `0600` permissions.
- `GetAccounts` strips `AccessToken` and `RefreshToken` from responses.
## Key Design Decisions
**Secret is a JSON envelope, not a bare token.** The stored credential is `{"AmazonSecret":{"refresh_token":"Atzr|...","site_id":"..."}}`, HTML-entity-escaped when written to XML attributes. This was confirmed from a real migrated device's `Sources.xml`. `HandleBoseAmazonToken` must parse this structure to extract the `refresh_token`, and `bridgeAmazonToMarge` must produce it when storing after OAuth.
**`site_id` origin is unconfirmed.** It may be a static Bose partner ID or a per-user Amazon Music identifier. Needs verification — likely obtained by calling the Amazon Music API or the LWA profile endpoint post-authentication.
**Credential type is `token`, not `token_version_3`.** `CredentialTypeTokenV3` is Spotify-specific (`cs3`). Amazon uses `cs1`, which maps to the plain `CredentialTypeToken` ("token") constant. Do not upgrade Amazon credentials to v3 in `marge.go`.
**POST body credentials, not Basic Auth.** The Amazon LWA token endpoint (`/auth/o2/token`) expects `client_id` and `client_secret` as POST body fields, not as an HTTP Basic Auth header. This is the single most important difference from the Spotify implementation.
**No entity resolution.** `ResolveEntity()` is not implemented for Amazon — the Amazon Music API is in closed beta. Return HTTP 501 if an entity endpoint is ever requested.
**`scope` omitted from token response.** The Spotify handler returns a hardcoded scope string. Amazon Music scopes for playback are undocumented; returning an empty or absent `scope` is safer than inventing values.
**ZeroConf extraction is a prerequisite.** The DH-blob crypto in `spotify/zeroconf.go` should be extracted to a shared package before Amazon is added to avoid duplicating cryptographic code.
+35 -7
View File
@@ -4,7 +4,31 @@ This document outlines the strategy for ensuring Bose SoundTouch devices are cor
## Overview
To enable Spotify Connect for SoundTouch devices, especially for remote availability outside the local network, the speaker must be associated with a Spotify account via a process called "priming." This involves sending an `addUser` command to the speaker's ZeroConf API (port 8200) containing a valid Spotify username and OAuth access token.
To enable Spotify Connect for SoundTouch devices, especially for remote availability outside the local network, the speaker must be associated with a Spotify account via a process called "priming." This involves a two-step exchange with the speaker's ZeroConf API (port 8200):
1. **`getInfo`** — retrieve the speaker's Diffie-Hellman public key and device metadata.
2. **`addUser`** — push encrypted Spotify credentials using the shared DH secret.
This is the standard Spotify Connect ZeroConf protocol. Once the speaker holds a properly encrypted credential blob it can independently authenticate with Spotify's servers and refresh its own session without any further involvement from AfterTouch.
### ZeroConf Protocol
The current implementation follows the full Spotify Connect ZeroConf protocol (`pkg/service/spotify/zeroconf.go`):
1. `GET http://{ip}:8200/zc?action=getInfo` → parse `publicKey` (base64 DH key, 768-bit Oakley Group 1 prime) from the response.
2. Generate a client DH key pair using the same group parameters.
3. Compute `sharedSecret = DH(clientPrivate, speakerPublicKey)`.
4. Derive keys: `baseKey = SHA1(sharedSecret)[:16]`, then HMAC-SHA1 with labels `"encryption"` and `"checksum"`.
5. Encrypt a protobuf-encoded `LoginCredentials` blob (username, `AUTHENTICATION_SPOTIFY_TOKEN=4`, access token) using AES-128-CTR + HMAC-SHA1 checksum.
6. `POST http://{ip}:8200/zc?action=addUser` with `blob={encryptedBlob}`, `clientKey={clientPublicKeyBase64}`.
The speaker decrypts the blob, stores long-lived credentials, and can handle token refresh with Spotify independently. No periodic re-priming is required for token expiry.
The algorithm is based on [librespot](https://github.com/librespot-org/librespot) (Rust reference implementation).
### Fallback for Older Firmware
If `getInfo` fails (e.g. firmware that does not implement the DH exchange), `PushSpotifyCredentials` automatically falls back to the simplified `tokenType=accesstoken` approach: the raw OAuth access token is sent as the `blob` with an empty `clientKey`. This token expires after ~60 minutes and the speaker cannot self-refresh, so periodic re-priming is required in that case.
AfterTouch adopts a **Server-Centric Hybrid Model** that prioritizes device cleanliness and user intent while providing automated self-healing.
@@ -14,7 +38,7 @@ AfterTouch adopts a **Server-Centric Hybrid Model** that prioritizes device clea
AfterTouch replicates the native Bose "Add Source" experience. No Spotify priming occurs until a user explicitly links their Spotify account through the AfterTouch Management Dashboard. This ensures privacy and respects users who do not wish to use Spotify.
### 2. Device Cleanliness (Minimalist Footprint)
We avoid invasive modifications to the speaker's filesystem.
We avoid invasive modifications to the speaker's filesystem.
- **No On-Device Scripts:** We deprecate the use of internal boot-primer scripts.
- **Native Communication:** We rely on the speaker's native ability to talk to Bose services, which are intercepted via DNS to point to the AfterTouch server.
@@ -53,6 +77,8 @@ The logic for account management and device interaction remains decoupled:
4. AfterTouch pushes a fresh token from the Spotify Service.
5. UI reflects that the device is "Managed by AfterTouch" and healthy.
> **Note:** With the proper encrypted-blob flow now in place, the watchdog is only needed for the "speaker reboots and loses state" case — not for token expiry. Speakers running older firmware that trigger the `tokenType=accesstoken` fallback still require periodic re-priming (~45 min) because the raw access token expires.
### Manual Override
Users can manually trigger a "Re-prime" or "Refresh Link" from the device list in the UI if they suspect the automated self-healing is delayed or if they want to force a specific account onto a device.
@@ -76,9 +102,11 @@ As AfterTouch moves to the Server-Centric model, we will:
2. **Consolidated Directory:** We maintain the `/mnt/nv/soundtouch-service/` base directory for other configuration needs (e.g., `aftertouch.resolv.conf`), but it will no longer contain Spotify-specific credentials or scripts.
3. **No On-Device Credentials:** The `/mnt/nv/soundtouch-service/spotify-primer.conf` will be removed, ensuring that no sensitive AfterTouch login details are stored on the speaker in plain text.
## Implementation Roadmap (Conceptual)
## Implementation Roadmap
1. **Revert On-Device Migration:** Update the Setup Manager to remove legacy scripts and `rc.local` hooks.
2. **Server-Side Priming Logic:** Implement a `PrimeDevice(ip)` method in the server that fetches a fresh token and calls the ZeroConf API.
3. **Discovery Hook:** Integrate `PrimeDevice` into the discovery handler (`handleDiscoveredDevice`) with a check for unprimed state.
4. **UI Enhancements:** Update the Speaker List to show "Spotify Linked" status and provide manual refresh buttons.
1. **Server-Side Priming Logic:** `PrimeDeviceWithSpotify(ip)` and `pushSpotifyTokenToDevice` in `pkg/service/handlers/server.go`. Triggered on device registration (marge handlers) and via the manual `HandleMgmtPrimeDevice` endpoint.
2. **Discovery Hook:** `handleDiscoveredDevice` calls `PrimeDeviceWithSpotify` when a speaker is found.
3. **Proper ZeroConf Blob:** Full DH key exchange + AES-128-CTR encrypted `LoginCredentials` blob implemented in `pkg/service/spotify/zeroconf.go`. Automatically falls back to `tokenType=accesstoken` if `getInfo` fails (older firmware).
4. **Watchdog / Session Refresh:** Background timer to re-prime all known devices on a schedule. Only strictly needed for older firmware (fallback path) or "speaker lost state" recovery; not required for token expiry on modern firmware.
5. ⬜ **Revert On-Device Migration:** Update the Setup Manager to remove legacy `spotify-boot-primer` scripts and `rc.local` hooks from the speakers.
6. ⬜ **UI Enhancements:** Update the Speaker List to show "Spotify Linked" status and provide manual refresh buttons.
+16 -4
View File
@@ -816,7 +816,7 @@ func TestAccountManager_CreateAccount(t *testing.T) {
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := manager.CreateAccount(tt.input)
@@ -848,6 +848,18 @@ go test ./... -v -cover
go test -bench=. ./...
```
## Performance Requirements
### Response Time Targets
- Local API requests: < 100ms (95th percentile)
- Mirror requests: < 200ms overhead (asynchronous)
- Discovery time: < 5s for network scan
### Resource Constraints
- Memory usage: < 64MB for small deployments
- CPU usage: < 5% on dual-core ARM systems (idle)
- Storage: < 100MB for interaction logs (rotatable)
### Security Considerations
#### Simple Security Model
@@ -949,12 +961,12 @@ func (s *Server) HandleHealthCheck(w http.ResponseWriter, r *http.Request) {
Version: version,
Uptime: time.Since(startTime).String(),
}
// Simple checks
if !s.canWriteToDataDir() {
health.Status = "error"
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(health)
}
@@ -986,4 +998,4 @@ func (m *SimpleMetrics) Save(dataDir string) error {
}
```
This technical specification provides comprehensive details for implementing the enhanced state management system while maintaining compatibility with existing SoundTouch service functionality and meeting the performance requirements for small hardware deployments.
This technical specification provides comprehensive details for implementing the enhanced state management system while maintaining compatibility with existing SoundTouch service functionality and meeting the performance requirements for small hardware deployments.
+432
View File
@@ -0,0 +1,432 @@
# Capture Device Pairing Traffic
Step-by-step runbook for factory-resetting a SoundTouch speaker, pairing it to a Bose cloud account, and capturing every cloud request via mitmproxy. Tested on Apple Silicon Mac.
**Goal:** obtain a full `.mitm` recording of the account-pairing flow (streaming.bose.com) triggered by the official Android app.
---
## Overview
```
Phase 0 Pre-flight checks
Phase 1 Factory reset speaker
Phase 2 Provision speaker Wi-Fi (AP mode, console)
Phase 3 Start mitmproxy + emulator + Frida
Phase 4 Pair speaker via Bose app (adb-driven)
Phase 5 Save & inspect recording
```
The Android emulator does not have Bluetooth, so the standard BLE setup path is unavailable. Instead:
1. Provision Wi-Fi directly over the speaker's AP web server (Phase 2).
2. Once the speaker is on the LAN, the Bose app discovers it via mDNS — no BLE needed.
---
## Phase 0 — Pre-flight
The emulator setup is fully scripted. Run once per machine:
```bash
# Place the Bose APK at scripts/android/bose.apk first (see BOSE-APP-ADB-Emulator.md § 1)
# Run mitmweb once to generate the CA: mitmweb --listen-port 8080 (Ctrl-C after it starts)
scripts/android/setup-mitm-avd.sh
```
This installs the system image, creates an AVD named `bose-mitm`, installs the
mitmproxy cert and Bose APK, and saves an emulator snapshot `mitm-ready` — so
subsequent sessions never repeat the cert/reboot cycle.
For subsequent sessions, Phase 3 below is replaced by a single command:
```bash
scripts/android/start-mitm-session.sh
```
Manual steps are only needed if you want to understand the internals; see
[BOSE-APP-ADB-Emulator.md](../analysis/BOSE-APP-ADB-Emulator.md) for the
full manual walkthrough.
---
## Phase 1 — Factory Reset Speaker
Perform the reset for your model (see
[DEVICE-INITIAL-SETUP.md § 5](DEVICE-INITIAL-SETUP.md) for full table):
| Model | Sequence |
|-----------------------------|-------------------------------------------------------------------------|
| SoundTouch 10 | Power on; hold **Preset 1** + **Vol ** ~10 s → solid amber Wi-Fi LED |
| SoundTouch 20 | Power on; hold **Preset 1** + **Vol ** ~10 s → lights blink L→R, amber |
| SoundTouch 20/30 Series III | Hold **Preset 1** + **Preset 6** ~10 s |
| SoundTouch 300 | Hold **Vol ** ~15 s until light bar blinks |
Wait until the white LED sweep / restart animation completes (~30 s). The speaker
is now in setup mode and broadcasting its own Wi-Fi AP.
---
## Phase 2 — Provision Speaker Wi-Fi (AP Mode)
### 2.1 Connect Mac to Speaker AP
```bash
# Find the SSID — use System Settings → Wi-Fi or:
# sudo wdutil info (macOS Sequoia+, airport command removed)
# Connect (replace SSID with actual value)
SPEAKER_SSID="Bose SoundTouch XXXX"
networksetup -setairportnetwork en0 "$SPEAKER_SSID"
# Confirm: speaker web UI reachable at 192.0.2.1 (client gets 192.0.2.2)
curl -s --connect-timeout 5 http://192.0.2.1/ | head -3
```
### 2.2 Push Home Wi-Fi Credentials
The setup UI at `http://192.0.2.1/` uses the SoundTouch API on **port 8090**. Push credentials directly:
```bash
HOME_SSID="MyHomeNetwork"
HOME_PASS="MyPassword"
# Optional: trigger a site survey first so the speaker finds your SSID
curl -s -X POST http://192.0.2.1:8090/performWirelessSiteSurvey \
-H 'Content-Type: text/xml' \
--data-raw '<PerformWirelessSiteSurvey timeout="5"/>'
curl -s -X POST http://192.0.2.1:8090/addWirelessProfile \
-H 'Content-Type: text/xml' \
--data-raw "<AddWirelessProfile><profile ssid=\"${HOME_SSID}\" password=\"${HOME_PASS}\" securityType=\"wpa_or_wpa2\" /></AddWirelessProfile>"
```
Expected response: `<AddWirelessProfileResponse />`
### 2.3 Reconnect Mac to Home Network
```bash
HOME_SSID="MyHomeNetwork"
HOME_PASS="MyPassword"
networksetup -setairportnetwork en0 "$HOME_SSID" "$HOME_PASS"
```
### 2.4 Wait for Speaker to Join LAN
```bash
# Poll mDNS until the speaker appears (~15-30 s)
echo "Waiting for speaker on LAN..."
until dns-sd -B _soundtouch._tcp local 2>&1 | grep -m1 "Add"; do sleep 2; done
echo "Speaker is online"
# Resolve its IP
dns-sd -L "$(dns-sd -B _soundtouch._tcp local 2>&1 | grep Add | awk '{print $7}')" \
_soundtouch._tcp local 2>&1 | grep -o '[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}'
```
Or just scan for open port 8090:
```bash
# Quick scan of your /24 subnet for port 8090
SUBNET="192.168.1" # adjust to your local subnet
for i in $(seq 1 254); do
(ping -c1 -W1 ${SUBNET}.$i &>/dev/null && \
nc -z -w1 ${SUBNET}.$i 8090 2>/dev/null && \
echo "${SUBNET}.$i") &
done
wait
```
---
## Phase 3 — Start mitmproxy, Emulator, Frida
Run each block in a separate terminal tab.
### 3.1 Start mitmweb (native macOS app)
> **Note:** Docker mitmproxy does not work here — its NAT layer prevents the emulator from reaching it. Use the native macOS app instead (download: `https://downloads.mitmproxy.org/12.2.2/mitmproxy-12.2.2-macos-arm64.tar.gz`).
```bash
CAPTURE="bose-pairing-$(date +%Y%m%d-%H%M%S).mitm"
/Applications/mitmproxy.app/Contents/MacOS/mitmweb \
--web-host 0.0.0.0 --listen-port 8080 --mode regular \
--set web_password=bose \
-w "scripts/android/captures/${CAPTURE}"
# Captures → scripts/android/captures/
# Web UI → http://127.0.0.1:8081/?token=bose
```
### 3.2 Start Emulator
```bash
~/Library/Android/sdk/emulator/emulator -avd Pixel_6_API33 -writable-system &
echo "Waiting for emulator boot..."
adb wait-for-device
adb -s emulator-5554 wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done'
echo "Emulator ready"
```
Enable root and install certificate (only needed once per emulator session):
```bash
adb -s emulator-5554 root
adb -s emulator-5554 shell avbctl disable-verification
adb -s emulator-5554 reboot
adb -s emulator-5554 wait-for-device
adb -s emulator-5554 root
HASH=$(openssl x509 -inform PEM -subject_hash_old \
-in ~/.mitmproxy/mitmproxy-ca-cert.pem | head -1)
adb -s emulator-5554 push ~/.mitmproxy/mitmproxy-ca-cert.pem /data/local/tmp/mitmproxy.pem
adb -s emulator-5554 shell su 0 mkdir -p /data/misc/user/0/cacerts-added
adb -s emulator-5554 shell su 0 \
cp /data/local/tmp/mitmproxy.pem /data/misc/user/0/cacerts-added/${HASH}.0
adb -s emulator-5554 shell su 0 \
chmod 644 /data/misc/user/0/cacerts-added/${HASH}.0
```
Set proxy to Mac IP:
```bash
MAC_IP=$(ipconfig getifaddr en0)
adb -s emulator-5554 shell settings put global http_proxy "${MAC_IP}:8080"
echo "Proxy set to ${MAC_IP}:8080"
```
Confirm emulator can reach the speaker:
```bash
SPEAKER_IP=192.168.1.50 # adjust to your speaker's LAN IP
adb -s emulator-5554 shell ping -c 3 "$SPEAKER_IP"
```
### 3.3 Start frida-server
```bash
adb -s emulator-5554 push scripts/android/frida-server /data/local/tmp/frida-server
adb -s emulator-5554 shell su 0 chmod 755 /data/local/tmp/frida-server
adb -s emulator-5554 shell su 0 "nohup /data/local/tmp/frida-server > /dev/null 2>&1 &"
sleep 2
echo "frida-server running"
```
### 3.4 Configure config.js
`start-mitm-session.sh` patches `scripts/android/frida/config.js` automatically with the current Mac IP and mitmproxy cert. No manual step needed.
### 3.5 Launch App with SSL Unpinning
```bash
scripts/android/frida-venv/bin/frida \
-U \
-f com.bose.soundtouch \
-l scripts/android/frida/config.js \
-l scripts/android/frida/native-connect-hook.js \
-l scripts/android/frida/android/android-system-certificate-injection.js \
-l scripts/android/frida/android/android-proxy-override.js \
-l scripts/android/frida/android/android-certificate-unpinning.js \
-l scripts/android/frida/android/android-certificate-unpinning-fallback.js
```
> `native-connect-hook.js` is required — the Bose app uses native networking that bypasses Java proxy settings.
Expected Frida output:
```
== System certificate trust injected ==
== Proxy system configuration overridden to <IP>:8080 ==
== Proxy configuration overridden to <IP>:8080 ==
== Certificate unpinning completed ==
== Unpinning fallback auto-patcher installed ==
```
---
## Phase 4 — Pair Speaker via App
The Bose app should now be running in the emulator with all traffic going through mitmproxy.
### 4.1 Inspect UI to Find Interactive Elements
```bash
# Dump current screen
adb -s emulator-5554 shell uiautomator dump /sdcard/ui.xml
adb -s emulator-5554 pull /sdcard/ui.xml /tmp/ui.xml
# Helper: list all clickable elements with their text + bounds
grep -o 'text="[^"]*" resource-id="[^"]*" \.\.\. clickable="true"[^/]*' /tmp/ui.xml \
|| python3 -c "
import xml.etree.ElementTree as ET
tree = ET.parse('/tmp/ui.xml')
for n in tree.iter('node'):
if n.get('clickable') == 'true' and n.get('text'):
print(n.get('bounds'), n.get('resource-id'), repr(n.get('text')))
"
```
### 4.2 Navigate Setup Flow (adb)
```bash
# Take a screenshot at any point to see current state
adb -s emulator-5554 shell screencap /sdcard/screen.png
adb -s emulator-5554 pull /sdcard/screen.png /tmp/screen.png
open /tmp/screen.png
# Tap by resource-id (find IDs from ui.xml dump)
adb -s emulator-5554 shell uiautomator runtest ... # or input tap
# Tap by screen coordinates
adb -s emulator-5554 shell input tap X Y
# Type into the focused field
adb -s emulator-5554 shell input text "your@email.com"
# Press Enter / Next
adb -s emulator-5554 shell input keyevent 66
```
### 4.3 Expected Setup Steps in the App
Follow the on-screen flow; mitmproxy captures everything automatically.
1. **Sign in** — enter email + password → triggers `POST /streaming/account/login`
2. **Add speaker** — tap "Set Up a New Speaker" or equivalent
3. **App discovers speaker** via mDNS on LAN (no BLE required)
4. **Wi-Fi already configured** — app skips the Wi-Fi step since speaker is online
5. **Name speaker** — type a name → WebSocket `name` message to speaker port 8080
6. **Pairing** — app sends `setMargeAccount` WebSocket to speaker → speaker POSTs to `streaming.bose.com/{accountId}/devices`
All cloud requests (steps 1, 6) will appear in mitmweb at `http://127.0.0.1:8081`.
---
## Phase 5 — Save & Inspect Recording
```bash
# Stop mitmweb (Ctrl-C in its terminal) — file is already written continuously
# Inspect offline in the web UI
mitmweb -r "$CAPTURE"
# Filter to streaming.bose.com only
mitmdump -r "$CAPTURE" --flow-filter '~u streaming.bose.com' -w bose-cloud-only.mitm
# Quick text summary
mitmdump -r "$CAPTURE" --flow-filter '~u streaming.bose.com' 2>/dev/null \
| grep -E "POST|GET" | head -30
```
### Convert to .http files (IntelliJ-compatible)
Use `scripts/convert_mitm_script.py` to extract each flow as a `.http` file, organized by path:
```bash
NAME=$(basename "$CAPTURE" .mitm)
OUT="scripts/android/mitm/${NAME}"
/Applications/mitmproxy.app/Contents/MacOS/mitmdump \
-n -r "$CAPTURE" \
-s scripts/convert_mitm_script.py \
--set out_dir="${OUT}"
```
Output lands in `scripts/android/mitm/<name>/mirror/` as numbered `.http` files
plus `*-websocket/` subdirectories for WebSocket frames. The directory is gitignored.
---
## Cleanup
```bash
# Remove proxy from emulator
adb -s emulator-5554 shell settings delete global http_proxy
# Kill emulator
adb -s emulator-5554 emu kill
```
Frida artefacts live in `scripts/android/` (gitignored) and persist between sessions — no cleanup needed unless you want to force a fresh setup.
---
## Troubleshooting
| Symptom | Likely cause | Fix |
|-------------------------------------|--------------------------------------------------|-----------------------------------------------------------------------------------|
| `curl http://192.0.2.1` times out | Mac not on speaker AP | Re-run `networksetup -setairportnetwork`; speaker AP gateway is `192.0.2.1` |
| `addWirelessProfile` returns error | Speaker not in AP mode or wrong IP | Confirm speaker AP is active; use `http://192.0.2.1:8090/addWirelessProfile` |
| Speaker not found via mDNS | Speaker still on AP (not home LAN yet) | Wait ~30 s, retry; check router DHCP leases |
| Emulator can't ping speaker | Different subnet or emulator proxy misconfigured | `adb shell ping` the Mac IP first; check proxy setting |
| App shows "No speakers found" | App not detecting mDNS | Ensure emulator is on same `/24` as speaker; disable emulator Wi-Fi and re-enable |
| No traffic in mitmweb | Frida not running or cert mismatch | Check Frida output for `== Certificate unpinning ==`; verify issuer in config.js |
## See Also
- [BOSE-APP-ADB-Emulator.md](../analysis/BOSE-APP-ADB-Emulator.md) — full MITM + Frida setup
- [DEVICE-INITIAL-SETUP.md](DEVICE-INITIAL-SETUP.md) — factory reset sequences + AP mode detail
- [DEVICE-SETUP.md](../DEVICE-SETUP.md) — WebSocket and cloud pairing protocol reference
---
## Session Trace (2026-05-02, ST10)
Raw log of the first interactive run. To be cleaned up into the runbook above.
### Setup
- Ran `scripts/android/setup-mitm-avd.sh` — all 9 steps completed:
- Steps 15 were already done from a prior session (idempotent skips)
- Step 6: Docker image built, `frida-server` extracted to `scripts/android/frida-server`
- Steps 79: Emulator started fresh (`-no-snapshot-load`), AVB disabled, rebooted, cert + APK + frida-server installed, snapshot `mitm-ready` saved
- Emulator running at `emulator-5554`
### Factory Reset (ST10)
- Correct sequence confirmed from official Bose guide (`firmware/FirmwareUpdateGuide/`):
**Power on → hold Preset 1 + Volume for 10 s → Wi-Fi LED glows solid amber**
- Note: original docs said "Vol + Mute" — corrected in DEVICE-INITIAL-SETUP.md and this file
### Pre-reset note
- User inserted USB device containing `remote_services` before rebooting — device needs to be on home Wi-Fi before the USB config takes effect
### Wi-Fi Provisioning (AP mode)
- `airport` command not available on this macOS version (removed in recent releases)
- Connect Mac to speaker AP via **System Settings → Wi-Fi** (SSID: "Bose SoundTouch XXXX")
- Speaker AP gateway confirmed: `192.0.2.1` (client gets `192.0.2.2`), not `192.168.1.1` as previously assumed
- `/gabbo_wifi` endpoint was hallucinated — actual endpoint verified from browser network capture (`_/device-reset/wifi-setup.txt`):
- Site survey: `POST http://192.0.2.1:8090/performWirelessSiteSurvey` with `<PerformWirelessSiteSurvey timeout="5"/>`
- Add profile: `POST http://192.0.2.1:8090/addWirelessProfile` with XML body, `securityType="wpa_or_wpa2"`
- Both use the standard SoundTouch API port 8090, same as normal device operation
- Response: `<AddWirelessProfileResponse />`
- Reconnect Mac to home Wi-Fi; emulator stays running throughout (routes through Mac interface)
### Wi-Fi Provisioning Result
- `POST http://192.0.2.1:8090/addWirelessProfile` succeeded → `<AddWirelessProfileResponse />`
- Speaker joined home LAN at `192.168.x.y`
- SSH access confirmed: `ssh -oHostKeyAlgorithms=ssh-rsa root@192.168.x.y`
- Device name: `Bose SoundTouch XXXXXX`
- Network interfaces on device:
- `wlan0``192.168.x.y` (home LAN)
- `wlan1``192.0.2.1` (AP mode interface, stays up after provisioning)
- `usb0``203.0.113.1` (USB gadget — remote_services USB device inserted before reboot)
### MITM Session Start
- `start-mitm-session.sh` run: snapshot restored, proxy set, frida-server started, config.js patched
- Issues encountered and fixed:
- frida-server start command hung (`adb shell su 0 ... &`) → fixed with `nohup ... > /dev/null 2>&1 &`
- Frida SSL scripts download via `curl` from GitHub timed out → moved to Dockerfile (extracted alongside frida-server)
- Python heredoc cert injection syntax error (multiline cert in string) → fixed using env vars + single-quoted `'PYEOF'` heredoc
- `mitmweb --port` deprecated → `--listen-port`
- frida script paths missing `android/` subdirectory → fixed in session script
- Docker mitmproxy (`-p 8080:8080`) received no traffic from emulator — Docker NAT layer prevented the emulator reaching it
- **Fix: use native macOS mitmproxy app** (`/Users/gesellix/Downloads/mitmproxy.app`) — binds directly to Mac's real network interfaces, traffic flows immediately
- Android system traffic visible (connectivity checks to gstatic.com, www.google.com) — TLS failures for system processes expected since only Bose app has Frida cert injection
### Capture Working
- Full flow confirmed working with:
- Native mitmproxy app (`~/Downloads/mitmproxy.app`, v12.2.2)
- `native-connect-hook.js` added to Frida launch (required for Bose app's native networking)
- All 5 Frida scripts loaded: config.js, native-connect-hook.js, android-system-certificate-injection.js, android-proxy-override.js, android-certificate-unpinning.js, android-certificate-unpinning-fallback.js
- Bose app traffic visible in mitmweb — pairing flow captured successfully
+378
View File
@@ -0,0 +1,378 @@
# Capture Speaker Migration Traffic
Runbook for migrating a SoundTouch speaker to `soundtouch-service` and capturing
all traffic (App→Service and Speaker→Service) to identify unimplemented endpoints.
**Goal:** obtain a complete picture of every cloud request a speaker and the Bose
app make after migration, so missing endpoint implementations can be tracked down.
**Pre-requisites:** the MITM pipeline is already set up and working. See
[CAPTURE-DEVICE-PAIRING.md](CAPTURE-DEVICE-PAIRING.md) for the one-time AVD setup
and the pairing capture runbook.
---
## Overview
```
Step 1 Start soundtouch-service locally (with interaction recording)
Step 2 Start a fresh mitmproxy + Frida session (captures App traffic)
Step 3 Discover or register the speaker in the service UI
Step 4 Migrate the speaker (modifies SoundTouchSdkPrivateCfg.xml via SSH)
Step 5 Operate the Bose app — everything now flows through local service
Step 6 Inspect captured interactions for unimplemented endpoints
Step 7 Revert (optional) / clean up
```
Traffic sources:
- **App → Service** — captured by mitmproxy + Frida (same as pairing capture)
- **Speaker → Service** — captured by the service's built-in interaction recorder
---
## Step 1 — Start soundtouch-service
Build and start the service. Recording is on by default; add `--server-url` so the
service knows its own public address (the speaker needs it for redirections).
```bash
# Determine Mac LAN IP first
MAC_IP=$(ipconfig getifaddr en0)
echo "Mac IP: ${MAC_IP}"
# Build + run with explicit server-url so the service embeds the correct address
make build-service
./build/soundtouch-service \
--server-url "http://${MAC_IP}:8000" \
--record-interactions \
--log-bodies
```
Service listens on `:8000` by default. Web UI: `http://localhost:8000`
> To also enable mirror mode (forward unhandled requests to official Bose servers
> for comparison), add: `--mirror-enabled --mirror-endpoints /streaming/`
---
## Step 2 — Start mitmproxy + Frida (new capture)
In a separate terminal:
```bash
scripts/android/start-mitm-session.sh
```
The script prints ready-to-run commands for mitmweb and Frida. Run each in its own
terminal tab as instructed.
New capture file lands in `scripts/android/captures/`.
---
## Step 3 — Discover the Speaker
Open the service web UI at `http://localhost:8000`.
The service discovers speakers via mDNS automatically on startup. If the speaker
does not appear within ~30 s, add it manually:
```bash
# Via API (replace IP with speaker's current LAN IP)
curl -s -X POST http://localhost:8000/setup/devices \
-H 'Content-Type: application/json' \
-d '{"ip": "192.168.x.y"}'
# Confirm it's registered
curl -s http://localhost:8000/setup/devices | python3 -m json.tool
```
Note the `device_id` from the response — you need it for migration.
```bash
# List all known devices and their IDs
curl -s http://localhost:8000/setup/devices | python3 -m json.tool
# Extract device_id for the speaker by matching its IP
DEVICE_ID=$(curl -s http://localhost:8000/setup/devices \
| python3 -c "import sys,json; devs=json.load(sys.stdin); \
[print(d['device_id']) for d in devs if '35' in d.get('ip_address','')]")
echo "Device ID: ${DEVICE_ID}"
```
---
## Step 4 — Migrate the Speaker
The migration modifies `SoundTouchSdkPrivateCfg.xml` on the speaker via SSH,
redirecting `margeServerUrl` (and optionally other service URLs) to the local
service.
### 4.1 Review the Migration Plan
```bash
# Dry-run: see what will be changed
curl -s "http://localhost:8000/setup/summary/${DEVICE_ID}" | python3 -m json.tool
```
Key fields to check:
- `margeServerUrl` — should become `http://<MAC_IP>:8000/streaming`
- `remoteServicesEnabled` — must be `true` for the speaker to make cloud calls
- `is_migrated``false` before, `true` after
### 4.2 Run Migration
```bash
MAC_IP=$(ipconfig getifaddr en0)
TARGET_URL="http://${MAC_IP}:8000"
curl -s -X POST \
"http://localhost:8000/setup/migrate/${DEVICE_ID}" \
-G --data-urlencode "target_url=${TARGET_URL}" \
| python3 -m json.tool
```
Expected response: `{"ok": true, "message": "Migration started", "output": "..."}`.
The output field contains the SSH transcript of the changes made.
### 4.3 Reboot the Speaker
A reboot applies the new config:
```bash
curl -s -X POST "http://localhost:8000/setup/reboot/${DEVICE_ID}"
```
Wait ~30 s for the speaker to come back online. Verify it's back:
```bash
dns-sd -B _soundtouch._tcp local 2>&1 | grep Add
# or
curl -s http://192.168.x.y:8090/info | head -5
```
### 4.4 Verify Migration
```bash
# Check migration summary again — is_migrated should now be true
curl -s "http://localhost:8000/setup/summary/${DEVICE_ID}" \
| python3 -c "import sys,json; s=json.load(sys.stdin); print('migrated:', s.get('is_migrated'))"
```
You should also see incoming connections from the speaker in the service logs once
it resumes normal operation.
---
## Step 5 — Operate the Bose App
With the speaker migrated and Frida running, every app action triggers traffic
through the service:
1. **Sign in**`POST /streaming/account/login`
2. **Speaker shows as linked** — speaker has called the service to register/sync
3. **Play music** — BMX registry lookup, playback control
4. **Set presets**`POST /streaming/account/{id}/device/{id}/presets/{n}`
5. **Adjust volume, switch source** — direct speaker API (port 8090, not cloud)
6. **Check "Now Playing"** — speaker WebSocket events + marge sync
For each action, both mitmweb and the service's recorder capture the request.
---
## Step 6 — Inspect Captured Interactions
### 6.1 Service Interaction Recorder
The service records all incoming requests to `data/interactions/` (configurable via
`--data-dir`). Browse them via:
```bash
# List recorded sessions
curl -s http://localhost:8000/setup/interactions | python3 -m json.tool
# Download a session as HAR
curl -s "http://localhost:8000/setup/interactions/sessions/<session>/download" \
-o session.har
# Find 404/500 responses (unimplemented endpoints)
curl -s "http://localhost:8000/setup/interaction-content" \
| python3 -c "
import sys, json
for entry in json.load(sys.stdin).get('entries', []):
status = entry.get('response', {}).get('status', 0)
if status >= 400:
print(status, entry.get('request', {}).get('method'), entry.get('request', {}).get('url'))
"
```
### 6.2 mitmproxy Recording
```bash
# Inspect app→service traffic offline
CAPTURE="scripts/android/captures/<filename>.mitm"
mitmweb -r "${CAPTURE}"
# Filter to local service only
mitmdump -r "${CAPTURE}" \
--flow-filter "~u ${MAC_IP}:8000" \
2>/dev/null | grep -E "POST|GET"
# Convert to .http files (IntelliJ-compatible, organized by path)
NAME=$(basename "${CAPTURE}" .mitm)
OUT="scripts/android/mitm/${NAME}"
/Applications/mitmproxy.app/Contents/MacOS/mitmdump \
-n -r "${CAPTURE}" \
-s scripts/convert_mitm_script.py \
--set out_dir="${OUT}"
# Output → scripts/android/mitm/<name>/mirror/
```
### 6.3 Identify Unimplemented Endpoints
Endpoints the service doesn't handle return `404 Not Found`. Check:
```bash
# From service stats
curl -s http://localhost:8000/setup/interaction-stats | python3 -m json.tool
# List parity mismatches (local vs upstream divergence, if mirror enabled)
curl -s http://localhost:8000/setup/parity-mismatches | python3 -m json.tool
```
---
## Step 7 — Revert Migration (Optional)
To restore the speaker to its original config (pointing back to Bose cloud):
```bash
curl -s -X POST "http://localhost:8000/setup/revert/${DEVICE_ID}" | python3 -m json.tool
```
Then reboot the speaker:
```bash
curl -s -X POST "http://localhost:8000/setup/reboot/${DEVICE_ID}"
```
---
## Cleanup
```bash
# Stop mitmweb (Ctrl-C in its terminal)
# Stop Frida (Ctrl-C in its terminal)
# Stop soundtouch-service (Ctrl-C in its terminal)
# Remove emulator proxy (if not running another session)
adb -s emulator-5554 shell settings delete global http_proxy
```
---
## Troubleshooting
| Symptom | Cause | Fix |
|-------------------------------------------|--------------------------------------------|---------------------------------------------------------------------------|
| Speaker not in service device list | mDNS discovery hasn't fired yet | Trigger manually: `POST /setup/discover` or add via `POST /setup/devices` |
| Migration fails with SSH error | Speaker SSH key not trusted | Run `POST /setup/trust-ca/{deviceId}` first, or check SSH connectivity |
| Speaker can't reach service after reboot | Firewall blocking port 8000 from LAN | Allow inbound TCP 8000 on Mac firewall |
| `is_migrated: false` after migration | Wrong `target_url` or config not written | Check SSH output in migration response; re-run with `--method xml` |
| Service logs show no speaker requests | `remote_services` not enabled on speaker | Run `POST /setup/ensure-remote-services/{deviceId}` and reboot |
| App shows speaker offline after migration | Speaker config not pointing to correct URL | Check `margeServerUrl` via `GET /setup/summary/{deviceId}` |
---
## Session Trace (2026-05-02, ST10)
Raw log of the first interactive migration run.
### Service Configuration
Settings applied in the web UI before migration:
| Setting | Value |
|---------------------|---------------------------------------------------------------------|
| Target Domain | `soundtouch.local` (resolvable from speaker to `192.168.x.z`) |
| DNS Discovery | enabled |
| Upstream DNS | home Wi-Fi gateway |
| Mirroring | enabled (for tracing while Bose cloud is still up) |
| Mirrored endpoints | `/bmx/*`, `/streaming/*`, `/accounts/*`, `/v1/scmudc/*`, `/oauth/*` |
| Proxy logging | enabled, including bodies |
| Record interactions | enabled |
| Skip recording | `/setup/*`, `/web/*` |
Settings saved and service restarted.
### Navigation Flow
1. **Tab 1 — Settings**: entered all settings above, clicked **Save Settings**, restarted service
2. **Tab 2 — Devices**: speaker appeared via mDNS discovery; clicked **Sync Data**
3. **Tab 3 — Data Sync**: clicked **Start Sync** to pull account/device data from Bose cloud
4. **Tab 2 — Devices**: clicked **Migrate** on the speaker entry
5. In the Migrate panel: selected **Migration Method → `/etc/resolv.conf`**
6. Ran pre-migration checks (see below)
7. Ran migration steps (see below)
8. Rebooted speaker
9. Paired and configured speaker via the Bose app
### Pre-Migration Checks
All tests run from the **Devices → Migrate** panel after selecting the speaker (`192.168.x.y`, SoundTouch 10):
- **HTTPS test (explicit CA.crt)**: ✅ passed (result not recorded in detail)
- **HTTPS test (shared trust store)**: ✅ passed
- Speaker connected to `soundtouch.local:443``192.168.x.z`
- TLS: TLSv1.2 / ECDHE-RSA-AES128-GCM-SHA256, cert issued by `SoundTouch Local Root CA`
- CA already in speaker's system trust store (`/etc/pki/tls/certs/ca-bundle.crt`)
- **Preliminary DNS Test**: ✅ passed
- Raw DNS query for `aftertouch.test` returned `192.168.x.z` via the service DNS at `192.168.x.z:53`
- **Planned `/etc/resolv.conf`**:
```
# Created by Aftertouch/SoundTouch-Service
# Priority nameserver for Bose service redirection
nameserver 192.168.x.z
```
### Migration Steps
1. **Enable Persistent Remote Services**`Successfully ensured remote services for SoundTouch 10 (192.168.x.y)`
- Note: `touch /etc/remote_services (with rw): sh: rw: command not found` — safe to ignore, `touch` succeeded
2. Reloaded migration view by deselecting and reselecting the speaker in the dropdown
3. **Backup Config Now**`✅ Found .original config at /opt/Bose/etc/SoundTouchSdkPrivateCfg.xml.original`
4. **Confirm Migration**`Successfully started migration for SoundTouch 10 (192.168.x.y). Please reboot the device to activate the changes.`
Command output:
- Off-device backup created ✅
- Write access verified ✅
- `soundtouch.local` resolved to `192.168.x.z`
- `/mnt/nv/soundtouch-service/aftertouch.resolv.conf` uploaded ✅
- `rc.local` already contains Aftertouch hook logic ✅
- `(rw || mount -o remount,rw /): sh: rw: command not found` — safe to ignore (same shell quirk as above)
- `/etc/udhcpc.d/50default` patched and verified ✅
- `/opt/Bose/udhcpc.script` patched and verified ✅
- CA certificate already trusted, skipping injection ✅
5. **Reboot Speaker** → speaker came back online after ~30 s
### Post-Migration
- Paired speaker to Bose account via app — succeeded ✅
- Set presets via app — worked ✅
- Mirroring active and functional during session ✅
- No visible errors in app behaviour; service logs and interaction recordings not yet reviewed in detail
### Known Shell Warning (safe to ignore)
Two commands produced `sh: rw: command not found`. This occurs because the service wraps commands with `(rw || ...)` as a fallback pattern, but the shell on the ST10 interprets `rw` as a bare command rather than a shell variable/flag. The primary command (`touch`, `mount`) still succeeds. This is a known cosmetic issue in the migration output.
---
## See Also
- [CAPTURE-DEVICE-PAIRING.md](CAPTURE-DEVICE-PAIRING.md) — MITM setup and pairing capture
- [MIGRATION-GUIDE.md](MIGRATION-GUIDE.md) — full migration reference
- [SOUNDTOUCH-SERVICE.md](SOUNDTOUCH-SERVICE.md) — service architecture and configuration
- [BOSE-APP-ADB-Emulator.md](../analysis/BOSE-APP-ADB-Emulator.md) — Frida + mitmproxy setup
+99 -11
View File
@@ -25,13 +25,17 @@ Used by most modern SoundTouch devices (ST-10, ST-20/30 Series III, SoundTouch 3
The classic "failover" or "alternate" setup method.
- **Mechanism**: The device creates its own Wi-Fi network (SSID: `Bose SoundTouch ...` or `Bose Home Speaker ...`).
- **IP Address**: Typically `192.168.1.1` or `10.0.0.1` (device-side).
- **IP Address**: Typically `192.0.2.1` (device-side, verified on ST10).
- **Web Interface**: The device hosts a web server on port 80.
- **Process**:
1. Connect a PC/Phone to the device's Wi-Fi.
2. Open a browser to `http://192.168.1.1`.
3. The device serves `setup.html`, which redirects to a setup wizard (`setup/index.html`).
4. Use the `gabbo_wifi` form to select a network and enter credentials.
2. Open a browser to `http://192.0.2.1`.
3. The device serves a Wi-Fi setup form — enter your home network SSID and password and click Submit.
4. The device disconnects from AP mode and joins your home network within ~1530 seconds.
![Speaker AP mode Wi-Fi setup page at 192.0.2.1](../images/speaker-ap-wifi-setup.png)
For command-line provisioning (without a browser), see §6 below.
---
@@ -69,12 +73,96 @@ While the `soundtouch-service` focuses on migrating existing devices, a truly "c
---
---
## 5. Factory Reset Button Sequences
A factory reset wipes Wi-Fi credentials, account pairing, and all presets, returning the device to out-of-box state. The exact sequence varies by hardware generation.
> Sequences verified against official Bose reset guides in `firmware/FirmwareUpdateGuide/`. Confirm the reset succeeded by watching the status LEDs and by verifying the Wi-Fi indicator glows solid amber (setup mode).
| Model | Factory Restore Sequence | Confirm |
|--------------------------|---------------------------------------------------------------------|------------------------------------|
| SoundTouch 10 | Power on; hold **Preset 1** + **Volume ** for 10 s | Wi-Fi indicator glows solid amber |
| SoundTouch 20 | Power on; hold **Preset 1** + **Volume ** for 10 s | Lights blink L→R, then solid amber |
| SoundTouch 20 Series III | Hold **Preset 1** + **Preset 6** simultaneously for ~10 s | White LED sweep |
| SoundTouch 30 Series III | Hold **Preset 1** + **Preset 6** simultaneously for ~10 s | White LED sweep |
| SoundTouch 300 | Hold **Volume ** until light bar blinks rapidly (~15 s) | Rapid blink → off → on |
| SoundTouch 10 (alt) | Press and hold the back recessed **Reset** pinhole for 10 s | Status LED restarts |
| SoundTouch 20 (soft) | Hold **AUX** for 15 s until display goes blank (settings preserved) | Display blanks |
After factory restore the speaker enters setup mode automatically; no power-cycle is needed.
---
## 6. AP Mode Wi-Fi Provisioning via Console
When BLE is unavailable (e.g. when using an Android emulator), use AP mode to push Wi-Fi credentials from the Mac command line.
### 6.1 Connect Mac to Speaker AP
After factory reset the speaker broadcasts an SSID like `Bose SoundTouch XXXX`. Connect the Mac to it:
```bash
# List nearby SSIDs — use System Settings → Wi-Fi (the airport command was removed in macOS Sequoia+)
# Connect (replace with actual SSID)
networksetup -setairportnetwork en0 "Bose SoundTouch XXXX"
```
The speaker's web UI gateway is at `192.0.2.1` (verified: ST10 assigns `192.0.2.2` to the client via DHCP).
```bash
# Confirm reachability
curl -sv http://192.0.2.1/ 2>&1 | head -40
```
### 6.2 Trigger Wi-Fi Site Survey (Optional)
The setup web UI at `http://192.0.2.1/` uses the SoundTouch API on **port 8090** — the same API as normal device operation. Trigger a network scan first so the speaker finds your SSID:
```bash
curl -s -X POST http://192.0.2.1:8090/performWirelessSiteSurvey \
-H 'Content-Type: text/xml' \
--data-raw '<PerformWirelessSiteSurvey timeout="5"/>'
```
### 6.3 Push Home Wi-Fi Credentials
```bash
HOME_SSID="MyHomeNetwork"
HOME_PASS="MyPassword"
curl -s -X POST http://192.0.2.1:8090/addWirelessProfile \
-H 'Content-Type: text/xml' \
--data-raw "<AddWirelessProfile><profile ssid=\"${HOME_SSID}\" password=\"${HOME_PASS}\" securityType=\"wpa_or_wpa2\" /></AddWirelessProfile>"
```
Expected response: `<?xml version="1.0" encoding="UTF-8" ?><AddWirelessProfileResponse />`
The speaker will disconnect from AP mode and join the home network within ~1530 s.
### 6.4 Reconnect Mac to Home Network
```bash
networksetup -setairportnetwork en0 "MyHomeNetwork" "MyPassword"
```
Wait ~15 s for the speaker to join the home network, then verify:
```bash
# Discover the speaker's new IP via mDNS
dns-sd -B _soundtouch._tcp local &
sleep 5 ; kill %1
```
---
## Comparison: Initial Setup vs. Migration
| Feature | Initial Setup | Migration (soundtouch-service) |
| :--- | :--- | :--- |
| **Connectivity** | BLE, AP Mode, USB, WAC | Ethernet/Wi-Fi (existing) |
| **Credentials** | Required (SSID/Pass) | Not required (uses existing) |
| **Access** | Web UI / App protocol | SSH (root) |
| **Primary File** | `setup/index.html` | `SoundTouchSdkPrivateCfg.xml` |
| **Use Case** | Out-of-the-box / Reset | Redirecting active devices |
| Feature | Initial Setup | Migration (soundtouch-service) |
|:-----------------|:-----------------------|:-------------------------------|
| **Connectivity** | BLE, AP Mode, USB, WAC | Ethernet/Wi-Fi (existing) |
| **Credentials** | Required (SSID/Pass) | Not required (uses existing) |
| **Access** | Web UI / App protocol | SSH (root) |
| **Primary File** | `setup/index.html` | `SoundTouchSdkPrivateCfg.xml` |
| **Use Case** | Out-of-the-box / Reset | Redirecting active devices |
+60 -74
View File
@@ -1,81 +1,64 @@
# HTTPS Setup & Custom CA Certificate
# HTTPS & Custom CA Certificate
To use the `/etc/hosts` redirection method safely, SoundTouch devices must communicate over HTTPS. This requires the device to trust the AfterTouch Root CA certificate used by the local service.
SoundTouch speakers communicate with cloud services over HTTPS. For the local service to work over HTTPS, speakers must trust the AfterTouch Root CA. The service manages this automatically — it generates a CA on first start and the web UI guides you through installing it on each speaker as part of the migration flow.
## 1. Automated Migration (Hosts Method)
---
The `soundtouch-service` can automatically configure a device to use the `/etc/hosts` method:
## How TLS works in AfterTouch
```bash
curl -X POST "http://localhost:8000/setup/migrate/{deviceIP}?method=hosts"
The service includes a built-in HTTPS listener (default port `8443`) that presents a certificate covering all Bose cloud hostnames. The certificate is signed by the AfterTouch Root CA, which is generated automatically on first start and stored in `data/certs/`.
**Domain coverage** — the certificate covers:
- Wildcard: `*.api.bose.io`, `*.api.bosecm.com`
- Specific: `streaming.bose.com`, `bmx.bose.com`, `stats.bose.com`, `updates.bose.com`, `worldwide.bose.com`, `bose-prod.apigee.net`, `media.bose.io`, `downloads.bose.com`, `voice.api.bose.io`, and more
> **Note**: The hostname you configure as `HTTPS_SERVER_URL` (e.g. `https://soundtouch.fritz.box:8443`) is also added as a Subject Alternative Name, ensuring valid TLS for direct browser or API access.
---
## CA trust installation (via web UI)
The migration flow in the web UI includes a CA trust step that:
1. Uploads the Root CA to the speaker via SSH
2. Appends it to the speaker's shared trust store (`/etc/pki/tls/certs/ca-bundle.crt`)
3. Verifies connectivity over HTTPS
This is handled automatically — you don't need to manage CA files manually unless you're doing an advanced or manual setup.
---
## Downloading the CA certificate
You can download the Root CA for manual installation on other devices (phones, PCs, additional speakers):
```
http://<server>:8000/setup/ca.crt
```
This command will:
1. Connect to the device via SSH.
2. Update `/etc/hosts` to point Bose domains to the service IP.
3. Inject the auto-generated AfterTouch Root CA into the device's trust store (`/etc/pki/tls/certs/ca-bundle.crt`).
4. Reboot the device.
---
## 2. Managing the Root CA
## Binding to port 443
The AfterTouch service automatically generates a Root CA when it first starts.
Speakers expect HTTPS on the default port 443. Since binding to port 443 requires elevated privileges, you have three options:
- **CA Certificate**: `data/certs/ca.crt`
- **CA Private Key**: `data/certs/ca.key`
1. **Port forwarding (recommended)**: Run the service on port 8443 and forward port 443 to it using `iptables` or your firewall/router.
2. **Capabilities**: Grant the binary permission to bind low ports: `sudo setcap 'cap_net_bind_service=+ep' ./soundtouch-service`
3. **Reverse proxy**: Use Nginx or Caddy in front of the service (see below).
### Downloading the CA Certificate
You can download the CA certificate for manual installation on other devices (like your phone or PC) from:
`http://<server-ip>:8000/setup/ca.crt`
---
### 3. Built-in HTTPS Support
## Reverse proxy (optional)
The `soundtouch-service` now includes a built-in HTTPS listener. This simplifies the `/etc/hosts` redirection method by automatically presenting the correct certificates for Bose domains.
- **HTTPS Port**: Configurable via `HTTPS_PORT` environment variable (defaults to `8443`).
- **HTTPS Server URL**: Configurable via `HTTPS_SERVER_URL` (e.g., `https://mysoundtouch.local:8443`). If not set, the service attempts to guess it using the system hostname.
- **Domain Coverage**: Automatically presents a certificate with comprehensive coverage using wildcard certificates (`*.api.bose.io`, `*.api.bosecm.com`) plus specific domains (`streaming.bose.com`, `updates.bose.com`, `stats.bose.com`, `bmx.bose.com`, `worldwide.bose.com`, `bose-prod.apigee.net`, etc.).
- **Wildcard Support**: Uses RFC-compliant wildcard certificates for automatic coverage of all API subdomains, including event analytics endpoints like `events.api.bosecm.com`, `eventsdev.api.bosecm.com`, and future API services.
- **TLS Error Logging**: Comprehensive logging of TLS handshake attempts, certificate matching, and connection failures for debugging DNS redirection issues.
- **Automatic Setup**: On first start, it generates a server certificate signed by your AfterTouch local Root CA.
#### TLS Security & Debugging
The built-in HTTPS listener is configured to use modern and secure TLS settings while maintaining compatibility with SoundTouch devices (which support up to TLS 1.2 with OpenSSL 1.0.2).
- **Minimum TLS Version**: TLS 1.2
- **Preferred Cipher Suites**:
- `ECDHE-RSA-AES128-GCM-SHA256`
- **TLS Debugging**: Detailed logging of:
- Certificate requests by domain (`[TLS] Certificate request for ServerName: events.api.bosecm.com`)
- Wildcard certificate matching (`[TLS] ✅ Serving certificate for events.api.bosecm.com (matched *.api.bosecm.com)`)
- Handshake failures (`[TLS] ❌ Handshake failed from 192.168.1.50: tls: certificate not found`)
- Successful connections (`[TLS] ✅ Successful connection from 192.168.1.50`)
- `ECDHE-RSA-AES256-GCM-SHA384`
- `ECDHE-RSA-CHACHA20-POLY1305`
- `RSA-AES128-GCM-SHA256` (Legacy support)
- `RSA-AES256-GCM-SHA384` (Legacy support)
#### Binding to Port 443
SoundTouch devices expect HTTPS on the default port 443. Since binding to port 443 usually requires root privileges, you have two options:
1. **Port Forwarding (Recommended)**: Run the service on a high port (e.g., 8443) and use `iptables` or your firewall to forward traffic from 443 to 8443.
2. **Capabilities**: Grant the binary permission to bind to low ports: `sudo setcap 'cap_net_bind_service=+ep' ./soundtouch-service`.
3. **Reverse Proxy**: Use Nginx or Caddy as described below.
### 4. Reverse Proxy (Optional)
1. **Generate a certificate** for the Bose domains signed by your Root CA.
2. **Configure Nginx** to use this certificate and proxy requests to `soundtouch-service`.
If you prefer to use Nginx or another proxy for TLS termination:
```nginx
server {
listen 443 ssl;
server_name streaming.bose.com bmx.bose.com stats.bose.com updates.bose.com;
ssl_certificate /path/to/generated-cert.crt;
ssl_certificate_key /path/to/generated-cert.key;
ssl_certificate /path/to/data/certs/server.crt;
ssl_certificate_key /path/to/data/certs/server.key;
# Secure TLS configuration (matches soundtouch-service defaults)
ssl_protocols TLSv1.2;
ssl_ciphers 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-CHACHA20-POLY1305:AES128-GCM-SHA256:AES256-GCM-SHA384';
@@ -87,23 +70,26 @@ server {
}
```
## 5. Manual CA Injection (Legacy/Manual)
---
If you prefer to inject the CA certificate manually:
## Manual CA injection (advanced)
1. Copy `ca.crt` to the device:
```bash
scp data/certs/ca.crt root@{deviceIP}:/tmp/
```
2. Append it to the trust store on the device:
```bash
ssh root@{deviceIP} "(rw || mount -o remount,rw /) && cat /tmp/ca.crt >> /etc/pki/tls/certs/ca-bundle.crt"
```
If you need to inject the CA manually (e.g. without the web UI migration flow):
## 6. Verifying Connectivity
```bash
# Copy the CA to the speaker
scp data/certs/ca.crt root@<SPEAKER-IP>:/tmp/
You can verify that your device can correctly reach the `soundtouch-service` over HTTPS using the management web UI.
# Make the filesystem writable and append the CA to the trust store
ssh root@<SPEAKER-IP> "(rw || mount -o remount,rw /) && cat /tmp/ca.crt >> /etc/pki/tls/certs/ca-bundle.crt"
```
In the **Migration Summary** for a device, you will find an **HTTPS Connection Test** section:
- **Test with Explicit CA.crt**: Uploads a temporary copy of the Root CA to the device and uses `curl --cacert` to verify the connection. Use this to verify your HTTPS setup *before* modifying the device's shared trust store.
- **Test with Shared Trust Store**: Uses the device's default trust store. Use this to verify that your CA injection was successful and the device now natively trusts your local server.
---
## TLS compatibility
SoundTouch speakers run OpenSSL 1.0.2, supporting up to TLS 1.2. The service is configured accordingly:
- **Minimum TLS version**: TLS 1.2
- **Preferred cipher suites**: `ECDHE-RSA-AES128-GCM-SHA256`, `ECDHE-RSA-AES256-GCM-SHA384`, `ECDHE-RSA-CHACHA20-POLY1305`
- **Legacy support**: `RSA-AES128-GCM-SHA256`, `RSA-AES256-GCM-SHA384`
+158 -383
View File
@@ -1,418 +1,193 @@
# THIS IS A PLANNED TO BE THE MIGRATION GUIDE
# Migration Guide: From Bose Cloud to AfterTouch
> This migration guide is not finalized, yet.
> We're using it as an orientation for the required implementation.
This guide walks through the complete process of migrating your SoundTouch speakers from Bose's cloud services to **AfterTouch**, the local replacement provided by `soundtouch-service`. By the end, your speakers will work fully independently of Bose's servers.
For a shorter overview, see the [Survival Guide](SURVIVAL-GUIDE.md). For safety considerations and rollback options, see the [Migration & Safety Guide](MIGRATION-SAFETY.md).
---
# Complete Migration Guide - From Bose Cloud to Local SoundTouch Service
## What you need
## Overview
- A machine that is **always on** (Raspberry Pi, NAS, home server, or similar) to run the service
- A **USB drive** (FAT-formatted) to enable SSH on each speaker
- Your speakers must be on the **same network** as the service host
- About **1530 minutes per speaker**
This guide will walk you through migrating your Bose SoundTouch speakers from Bose's cloud services to AfterTouch, your own local SoundTouch service. By the end of this process, your speakers will be completely independent of Bose's servers while retaining all their functionality.
---
> **💡 Why Migrate?** Bose announced the shutdown of their SoundTouch cloud services in May 2026. This migration ensures your speakers continue working indefinitely with enhanced local control and monitoring.
## Step 1: Install and start the service
## What You'll Need
Choose the option that fits your setup.
### Hardware Requirements
- **Raspberry Pi 4 or similar** (minimum: Raspberry Pi Zero 2W)
- **MicroSD card** (16GB or larger)
- **USB drive** (for device preparation)
- **Network connection** for your Raspberry Pi
### Before You Start
- **List all your SoundTouch devices** and their current locations
- **Note your current presets and favorites** (they will be preserved)
- **Ensure devices are on the same network** as your future SoundTouch service
- **Basic computer skills** (following instructions, using a web browser)
### Time Estimate
- **Setup**: 30-60 minutes for the service installation
- **Per Device**: 10-15 minutes for each speaker migration
- **Total**: 1-3 hours depending on number of devices
## Step 1: Install SoundTouch Service
### Option A: Raspberry Pi Installation (Recommended)
#### 1.1 Prepare Your Raspberry Pi
1. **Flash Raspberry Pi OS** to your SD card using Raspberry Pi Imager (see the raspberrypi.com documentation)
2. **Enable SSH** during imaging or create an empty `ssh` file on the boot partition
3. **Boot your Pi** and connect it to your network
4. **Find your Pi's IP address** (check your router or use `ping raspberrypi.local`)
#### 1.2 Install SoundTouch Service
Connect to your Pi via SSH and run:
### Binary (go install)
```bash
# Download and install
curl -sSL https://github.com/gesellix/Bose-SoundTouch/releases/latest/download/install.sh | bash
# Start the service
sudo systemctl enable soundtouch-service
sudo systemctl start soundtouch-service
go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-service@latest
soundtouch-service
```
#### 1.3 Verify Installation
The service starts on port 8000. Open `http://localhost:8000` in your browser.
1. Open your web browser
2. Go to `http://[PI_IP_ADDRESS]:8000` (replace with your Pi's IP)
3. You should see the **SoundTouch Service Dashboard**
### Docker Compose (recommended for home servers and VMs)
![SoundTouch Service Dashboard](../images/dashboard-home.png)
*Example: SoundTouch Service main dashboard*
The repository ships a `docker-compose.yml` ready for this use case. Clone or download it, copy the example config, then edit `.env` before starting:
### Option B: Docker Installation
```bash
cp .env.example .env
# Edit .env:
# SOUNDTOUCH_HOSTNAME=192.168.1.100 ← your server's address
# SOUNDTOUCH_VERSION=v0.70.0 ← pin to a release tag instead of 'latest'
docker compose up -d
```
If you prefer Docker, run:
`SOUNDTOUCH_HOSTNAME` is the address your speakers will use to reach the service — use a hostname or IP reachable from the speaker, not `localhost`.
On **Linux** (Debian, Proxmox VE, Raspberry Pi OS, etc.) you can enable host networking for automatic speaker discovery. Uncomment the `network_mode: host` line in `docker-compose.yml` and remove the `ports:` section (they conflict with host networking). Without host networking, add your speakers by IP address in Step 4 instead.
For local overrides (e.g. switching to `build: .` during development), create a `docker-compose.override.yml` — Docker Compose picks it up automatically and it is not tracked in version control.
> **Note on `docker-compose.ci.yml`**: this file contains mock services used only for automated integration tests. It is not needed for your own deployment.
### Docker run (Linux — with host networking for device discovery)
```bash
docker run -d \
--name soundtouch-service \
--restart unless-stopped \
-p 8000:8000 \
-p 8443:8443 \
-v soundtouch-data:/data \
gesellix/soundtouch-service:latest
--network host \
-v $(pwd)/data:/app/data \
ghcr.io/gesellix/bose-soundtouch:latest
```
## Step 2: Create Your Account
### Docker run (macOS / Windows — manual device IP required)
### 2.1 Initial Setup
1. **Open the dashboard** at `http://[SERVICE_IP]:8000`
2. Click **"Create New Account"**
3. **Fill in your details**:
- Account Name: `My Home Audio`
- Email: `your@email.com` (optional, for notifications)
- Migration Strategy: `Gradual` (recommended)
![Account Creation](../images/account-creation.png)
*Example: Account creation form*
### 2.2 Account Configuration
After creation, you'll see your **Account Dashboard**:
- **Account ID**: Unique identifier (e.g., `acc_home_audio_001`)
- **Status**: `Active - Ready for Migration`
- **Device Count**: Initially 0
- **Migration Status**: `Prepared`
![Account Dashboard](../images/account-dashboard.png)
*Example: Fresh account dashboard ready for device migration*
### 2.3 Initial Settings
Once your account is created, configure the global settings:
1. **Settings**:
- Check **Target Domain**: Ensure it's reachable from the speaker (e.g., `soundtouch.fritz.box`).
- **DNS Discovery**: Enable DNS discovery on port `:53`. This is crucial for the DNS hook migration method.
2. **Devices**:
- Go to the **"Device Discovery"** tab.
- Click **"Scan Network"** or manually add a speaker via IP address.
- Your devices should appear with **SSH Status**: `Enabled`.
![Device Discovery](../images/device-discovery.png)
*Example: Discovered devices with remote access enabled*
## Step 3: Prepare Your Devices
> **⚠️ Important**: This step temporarily enables SSH access on your speakers. SSH will be automatically disabled after migration unless you choose to keep it enabled.
### 3.1 Enable Remote Services
For each SoundTouch device:
1. **Prepare a USB drive**:
- Format as FAT32
- Create an empty file named `remote_services` (no extension)
- (Optional) Firmware update/reset, see [Bose SoundTouch USB Update](https://downloads.bose.com/ced/soundtouch/soundtouch_usb/index.html)
2. **Insert USB drive** into your SoundTouch speaker
3. **Power cycle** the device (unplug for 10 seconds, then reconnect)
![USB Preparation](../images/usb-remote-services.png)
*Example: USB drive setup for enabling remote services*
## Step 4: Discover and Register Devices
### 4.1 Automatic Discovery
The service automatically scans for SoundTouch devices every 5 minutes. To trigger immediate discovery:
1. **Dashboard****"Devices"** → **"Discover Devices"**
2. **Wait 30-60 seconds** for scan completion
3. **Review discovered devices** in the list
### 4.2 Register Devices to Your Account
For each discovered device:
1. **Click device name** in the discovery list
2. **Verify device information**:
- Name: `Living Room Speaker`
- Model: `SoundTouch 30`
- MAC Address: `A8:1B:6A:53:6A:98`
- IP Address: `192.168.1.100`
- Status: `Discovered - Ready for Registration`
3. **Click "Register to Account"**
4. **Choose registration type**:
- **Fresh Setup**: For new or factory-reset devices
- **Migrate from Bose**: For devices with existing Bose account (recommended)
![Device Registration](../images/device-registration.png)
*Example: Device registration dialog with migration options*
### 4.3 Device Registration Results
After registration, you'll see:
- **Device Status**: `Registered - Active`
- **Account Association**: Your account name
- **Lifecycle State**: `Active`
- **Data Sources**: `Mirror Primary` (initially uses Bose, falls back to local)
## Step 5: Migrate Individual Devices
### 5.1 Step 3: Data Sync
1. **Dashboard****"Devices"** → Select your device
2. Click **"Data Sync"**
3. This fetches configuration (presets, recents, sources) from the speaker to the SoundTouch service.
### 5.2 Step 4: Migration
Once data is synced, proceed to the migration tab for the device:
1. **Backup XML**: Create an off-device backup of the current configuration.
2. **Enable Persistent Remote Service**: This ensures SSH remains available after reboots.
- *Note*: If you see `'rw: command not found'`, you can safely ignore it.
3. **CA Certificate Configuration**:
- **Test with explicit CA**: Verify the speaker can communicate using the local CA.
- **Trust CA now**: Inject the local Root CA into the speaker's trust store.
- **Test with shared trust store**: Verify general HTTPS communication.
4. **Migration Method**:
- Select **"Redirect via DNS hook"**.
- **Test DNS Redirection**: Ensure the speaker correctly resolves the service domain.
5. **Confirm Migration**: Apply the final changes to the speaker.
#### Example Migration Output:
```text
Successfully created off-device backup of current configuration.
Pre-flight: Write access verified.
Resolved soundtouch.fritz.box to 192.168.1.100
Uploaded /mnt/nv/soundtouch-service/aftertouch.resolv.conf
/mnt/nv/rc.local already contains Aftertouch hook logic
(rw || mount -o remount,rw /): sh: rw: command not found
cp /etc/udhcpc.d/50default /etc/udhcpc.d/50default.original:
Applied patch to /etc/udhcpc.d/50default
Verified patch on /etc/udhcpc.d/50default
cp /opt/Bose/udhcpc.script /opt/Bose/udhcpc.script.original:
Applied patch to /opt/Bose/udhcpc.script
Verified patch on /opt/Bose/udhcpc.script
CA certificate already trusted, skipping injection
```bash
docker run -d \
--name soundtouch-service \
-p 8000:8000 -p 8443:8443 \
-v $(pwd)/data:/app/data \
--env SERVER_URL=http://soundtouch.local:8000 \
--env HTTPS_SERVER_URL=https://soundtouch.local:8443 \
ghcr.io/gesellix/bose-soundtouch:latest
```
## Step 7: Complete Account Migration
On macOS/Windows, device discovery via mDNS won't work inside the container — you'll add devices by IP address in Step 4.
### 7.1 Migrate All Devices
Repeat the migration process for each of your SoundTouch devices. You can migrate multiple devices simultaneously, but we recommend doing 1-2 at a time to monitor progress.
**Migration Dashboard** shows overall progress:
- **Devices Migrated**: `2 of 4 completed`
- **Currently Migrating**: `Living Room Speaker, Kitchen Speaker`
- **Pending Migration**: `Bedroom Speaker, Office Speaker`
- **Estimated Completion**: `3 days remaining`
![Account Migration Status](../images/account-migration.png)
*Example: Account-wide migration progress*
### 7.2 Verify Complete Migration
When all devices are migrated:
1. **Account Status**: `Active - Fully Migrated`
2. **Bose Dependency**: `None`
3. **Local Control**: `100%`
4. **Device Health**: All devices show `Healthy - Local Only`
![Migration Complete](../images/migration-complete.png)
*Example: Completed migration dashboard*
## Step 8: Post-Migration Tasks
1. **Remove USB stick** from the speaker.
2. **Reboot** the device to apply all changes.
### 8.1 Disable Remote Services (Optional)
For enhanced security, you can disable SSH on migrated devices. However, keeping it enabled allows for easier future maintenance or reverts.
### 8.2 Configure Backups
Set up automatic backups of your device configurations:
1. **Dashboard****"Settings"** → **"Backup"**
2. **Enable Automatic Backups**: ✅
3. **Backup Schedule**: `Daily at 2 AM`
4. **Retention**: `Keep 30 days`
5. **Export Location**: `/data/backups` or external storage
![Backup Configuration](../images/backup-setup.png)
*Example: Backup configuration settings*
### 8.3 Set Up Monitoring Alerts (Optional)
Configure notifications for important events:
1. **Dashboard****"Settings"** → **"Notifications"**
2. **Email Notifications**: Enter your email
3. **Alert Types**:
- ✅ Device goes offline
- ✅ Migration failures
- ✅ Service errors
- ✅ Daily health summary
## Troubleshooting Common Issues
### Device Not Discovered
**Problem**: Device doesn't appear in discovery scan
**Solutions**:
1. **Check network**: Ensure device and service are on same network
2. **Verify USB setup**: Confirm `remote_services` file was processed
3. **Power cycle**: Unplug device for 30 seconds, reconnect
4. **Manual add**: Dashboard → "Devices" → "Add Manually" with IP address
### Migration Stuck
**Problem**: Device stuck in "Migrating" status
**Solutions**:
1. **Check device health**: Dashboard → Device → "Health Status"
2. **Review logs**: Dashboard → Device → "View Logs"
3. **Restart migration**: Device → "Migration" → "Restart Process"
4. **Rollback**: Device → "Migration" → "Rollback to Bose"
### Presets Not Working
**Problem**: Saved presets don't work after migration
**Solutions**:
1. **Verify sources**: Check configured sources are still available
2. **Re-authenticate**: Re-login to music services (Spotify, etc.)
3. **Rebuild presets**: Dashboard → Device → "Presets" → "Rebuild from Backup"
### Service Unreachable
**Problem**: Cannot access SoundTouch Service dashboard
**Solutions**:
1. **Check service status**: `sudo systemctl status soundtouch-service`
2. **Restart service**: `sudo systemctl restart soundtouch-service`
3. **Check network**: Verify Pi is connected and accessible
4. **Check ports**: Ensure ports 8000 and 8443 are not blocked
## Advanced Features
### Multi-Zone Management
After migration, your multi-zone setups work seamlessly:
1. **Dashboard****"Zones"**
2. **Create Zone**: Select primary device and slaves
3. **Zone Control**: Play, pause, volume control for entire zone
4. **Individual Control**: Override individual speakers in zone
### Custom Sources
Add custom streaming sources:
1. **Dashboard****"Sources"** → **"Add Custom"**
2. **Configure**:
- Name: `Local Radio Station`
- Stream URL: `http://stream.example.com:8000`
- Image URL: `http://example.com/logo.png`
3. **Assign to devices**: Select which devices can access this source
### API Access
For developers and advanced users:
- **REST API**: `http://[SERVICE_IP]:8000/api/v1/`
- **Documentation**: `http://[SERVICE_IP]:8000/docs`
- **WebSocket Events**: Real-time device status updates
- **Export Data**: JSON/XML export of all device configurations
## Maintenance and Monitoring
### Daily Monitoring
Check your **Dashboard Summary**:
- **All Devices Online**: ✅ Green indicators
- **Response Times**: < 100ms average
- **Error Rate**: < 1%
- **Storage Usage**: Monitor disk space
### Weekly Tasks
1. **Review Health Reports**: Check weekly device health summaries
2. **Update Service**: Check for SoundTouch service updates
3. **Backup Verification**: Ensure backups are completing successfully
4. **Log Review**: Check for any recurring issues or warnings
### Monthly Tasks
1. **Full System Backup**: Export complete account and device data
2. **Performance Review**: Analyze response times and error patterns
3. **Security Update**: Update Raspberry Pi OS and service
4. **Capacity Planning**: Monitor storage and consider expansion
## Getting Help
### Documentation Resources
- **Technical Reference**: `/docs/reference/` - Detailed API and configuration docs
- **Troubleshooting Guide**: `/docs/guides/TROUBLESHOOTING.md` - Common issues and solutions
- **Community Forum**: GitHub Discussions for community support
### Diagnostic Information
When seeking help, provide:
1. **System Information**: Dashboard → "System" → "Download Diagnostic Report"
2. **Device Logs**: Dashboard → Device → "Export Logs"
3. **Migration History**: Dashboard → "Migration" → "Export Timeline"
4. **Current Status**: Screenshot of main dashboard
### Support Channels
- **GitHub Issues**: Technical bugs and feature requests
- **Community Discussions**: User questions and experiences
- **Documentation Updates**: Corrections and improvements
See [Raspberry Pi Setup](RASPBERRY-PI.md) and the [SoundTouch Service Guide](SOUNDTOUCH-SERVICE.md) for more deployment options.
---
## Summary
## Step 2: Configure the service URL
Congratulations! 🎉 You've successfully migrated your SoundTouch speakers to local control. Your devices are now:
Open `http://<server>:8000` and go to the **Settings** tab.
- ✅ **Independent** of Bose cloud services
- ✅ **Fully functional** with all original features preserved
- ✅ **Enhanced** with better monitoring and control
- ✅ **Future-proof** against service shutdowns
![AfterTouch Settings tab](../images/ui-settings.png)
**What's Next?**
Set the **Target Domain** to the address your speakers can reach — for example `https://soundtouch.fritz.box` or `http://192.168.1.100:8000`. This must be the host's address on your local network, not `localhost`.
- **Enjoy your music** with enhanced local control
- **Monitor your system** through the dashboard
- **Share your experience** with the community
- **Explore advanced features** as you become more comfortable
If you plan to use DNS/DHCP redirect, enable the **DNS Discovery Server** and set the **DNS Bind Address** to `:53`. The upstream DNS should be your router's IP, not the service's own address.
Your SoundTouch speakers will now continue working indefinitely, regardless of external service availability. Welcome to true audio independence! 🔊
> **Tip**: If you change settings and they don't seem to take effect, check `data/settings.json` — settings saved in the UI take precedence over environment variables.
---
## Step 3: Enable SSH on each speaker
The migration writes updated configuration to the speaker's filesystem, which requires SSH access. Enable it once per device:
1. Format a USB drive as FAT (FAT32). Some speakers require the **bootable flag** to be set on the partition — see [SoundCork issue #172](https://github.com/deborahgu/soundcork/issues/172) for details.
2. Create an empty file named **`remote_services`** (no extension) in the root of the drive.
3. Insert the drive into the speaker's USB port while it is powered on.
4. Power-cycle the speaker (unplug the power cable, wait 10 seconds, reconnect).
5. After boot, root SSH is available with no password: `ssh -oHostKeyAlgorithms=+ssh-rsa root@<SPEAKER-IP>`
You only need to do this once per speaker. SSH can remain enabled for future maintenance or be disabled after migration — your choice.
---
## Step 4: Add and sync your speaker
### Discover
The service scans for SoundTouch devices automatically every few minutes. Check the **Devices** tab in the web UI. If your speaker doesn't appear, click **Scan Again** to trigger an immediate scan, or enter the IP address manually and click **Add Device**.
![AfterTouch Devices tab showing discovered speakers](../images/ui-devices.png)
### Sync
Once the speaker appears, click **Sync Data**. This connects to the speaker and pulls its current presets, recently played items, and configured sources into the local service's datastore. It also creates an off-device backup of the speaker's configuration.
![Data Sync tab showing a successful sync](../images/ui-sync.png)
If the Bose cloud is still running, Sync also fetches your account data from Bose's servers. This is your preservation step — do it before the cloud shuts down.
---
## Step 5: Migrate
Click **Migrate** next to a device on the Devices tab to open the Migration tab. It shows SSH status, CA trust status, and connection test results before letting you apply the redirect.
![Migration tab showing HTTPS and DNS connection tests](../images/ui-migration.png)
Two redirect methods are available:
### XML redirect (recommended for first-time / testing)
Uploads a configuration file to the speaker via the SoundTouch Web API. This changes the application-level service URLs without touching the speaker's network configuration. It's the least invasive option.
The web UI guides you through:
1. Previewing the config change (current vs. planned XML)
2. Optionally installing the AfterTouch CA certificate on the speaker (requires SSH; needed for HTTPS)
3. Applying the XML redirect
4. Verifying the speaker can reach the local service
### DNS/DHCP redirect (recommended for permanent / all-device setup)
Configures the speaker to use a custom DNS server that resolves Bose cloud hostnames to the local service. This is the most robust method — it covers all Bose endpoints automatically and survives reboots.
Requirements:
- The AfterTouch DNS server must be running and bound to **port 53** on your network. Enable it in the **Settings** tab (`DNS Discovery` → enabled).
- HTTPS is required. The web UI walks you through trusting the CA certificate on the speaker (via SSH).
The web UI guides you through:
1. Verifying the DNS server is running and reachable
2. Installing the CA certificate on the speaker
3. Configuring the speaker to use the AfterTouch DNS server
4. Verifying DNS resolution and HTTPS connectivity
---
## Step 6: Reboot and verify
After migration, **power-cycle the speaker** (unplug and replug). This applies all configuration changes.
After reboot:
- The speaker should appear as **migrated** in the Devices tab
- Presets should load and play (served from the local service)
- TuneIn browsing should work
- Recently played items should appear
If something doesn't work, check the **Interactions** tab in the web UI for failed requests, and the **Troubleshooting** section in the [SoundTouch Service Guide](SOUNDTOUCH-SERVICE.md).
---
## Repeat for each speaker
Each speaker is migrated independently. You can run multiple migrations in parallel, but migrating one at a time makes it easier to diagnose issues.
---
## Rollback
If you need to undo a migration:
- **From the web UI**: Use the **Revert** action on the device — this restores the `.original` backup files created on the speaker during migration.
- **Via SSH**: The original config is backed up on the speaker with a `.original` suffix. Restore it manually if the UI is unreachable.
- **Factory reset**: As a last resort, perform a factory reset (see [Device Initial Setup](DEVICE-INITIAL-SETUP.md) for button sequences). This wipes all configuration and returns the speaker to out-of-box state.
---
## Post-migration
Once all speakers are migrated, the `data/` directory is the source of truth for your presets, recents, and device state. Back it up periodically. The web UI at `http://<server>:8000` is your management interface from this point on.
For the Bose cloud backup you created in Step 4, keep the `.tar.gz` archive in case you need to restore credentials or presets later.
+8 -7
View File
@@ -1,4 +1,4 @@
### Professional Migration & Safety Guide
# Migration & Safety Guide
Starting a migration on real hardware requires a "Safety First" approach. This guide outlines the safety features implemented in the `soundtouch-service` and provides a checklist for a successful migration.
@@ -14,8 +14,8 @@ The following features are built into the `soundtouch-service` to ensure stabili
Before you proceed with the actual migration, follow these steps:
1. **Enable SSH Access (Prerequisite)**: This toolkit requires SSH access to your speakers, which is not enabled by default.
- Create an empty file named `remote_services` on a USB stick.
1. **Enable SSH Access (Prerequisite)**: This toolkit requires SSH access to your speakers, which is not enabled by default.
- Create a file named `remote_services` on a FAT-formatted USB drive. The drive may need its bootable flag set — see [SoundCork issue #172](https://github.com/deborahgu/soundcork/issues/172) for details.
- Insert the USB stick into the SoundTouch speaker's **SERVICE** port.
- Reboot the speaker (unplug and replug).
- The speaker will now allow SSH connections as `root` with no password.
@@ -27,10 +27,11 @@ Before you proceed with the actual migration, follow these steps:
4. **Validate SSH Access**: Confirm the device responds to SSH without a password.
- In the Web UI **Migration** tab, select your speaker and verify that the "SSH Connection" status shows ✅ Success.
- This toolkit automatically handles the necessary SSH parameters (ciphers and key exchanges) required by older Bose firmware.
5. **Migration Methods**:
- **XML Migration (Default)**: Less invasive, only changes the application config. Best for simple redirection.
- **Hosts Migration**: Modifies `/etc/hosts` on the device. Good for system-wide redirection of specific domains.
- **ResolvConf Migration**: Points the device to the AfterTouch DNS server. Best for discovering unknown Bose endpoints and dynamic interception. **Note**: This method requires the DNS Discovery Server to be running on port 53. The service includes a pre-flight check to ensure the server is properly bound before allowing this migration.
5. **Migration Methods**:
- **XML redirect (default)**: Uploads a config file to the speaker via the Web API. Less invasive only changes the application-level service URLs. Best for testing or single-device migration.
- **DNS/DHCP redirect**: Configures the speaker to use a custom DNS server that resolves Bose hostnames to the local service. Best for all-device coverage; requires the AfterTouch DNS server running on port 53. The service includes a pre-flight check before applying this method.
The web UI walks you through both methods. Both require the CA certificate to be trusted on the speaker for HTTPS to work — the web UI handles this as part of the migration flow.
6. **Monitor Logs**: Run the `soundtouch-service` with `DEBUG` or `INFO` logging to see the step-by-step progress of the migration.
#### 🔄 Rollback Strategy
+130
View File
@@ -0,0 +1,130 @@
# Connecting Music Services (Spotify & Amazon Music)
This guide explains how to link your Spotify or Amazon Music account to AfterTouch so your speakers can stream music from those services.
---
## How it works
Connecting a music service happens in three separate steps, each done once:
1. **Register a developer app** with Spotify or Amazon (one-time setup by the person running AfterTouch).
2. **Authorize your personal account** so AfterTouch can access your music library.
3. **Prime your speaker** so the speaker itself learns about the source.
Each step is described below. If someone else is hosting AfterTouch for you, step 1 may already be done — ask them.
---
## Who registers the developer app?
The developer app is what allows AfterTouch to talk to Spotify's or Amazon's servers on behalf of users. It requires registering an account on a developer portal.
**If you run AfterTouch for yourself only:** You register one app and use it yourself.
**If you run AfterTouch for a group** (e.g., your household): You register one app, configure it in AfterTouch, and everyone who uses your AfterTouch instance shares it. They never see your app credentials — those stay on your server. However, they do need to trust you, since their music account tokens are stored by your AfterTouch installation.
**If you don't trust the AfterTouch operator:** Run your own AfterTouch instance and register your own app. That way everything stays under your control.
---
## Spotify
### Step 1: Register a Spotify developer app
1. Go to [developer.spotify.com/dashboard](https://developer.spotify.com/dashboard) and log in with your Spotify account.
2. Click **Create app**.
3. Give it any name and description (e.g., "My AfterTouch").
4. Under **Redirect URIs**, add:
```
http://<your-aftertouch-ip>:8000/mgmt/spotify/callback
```
Replace `<your-aftertouch-ip>:8000` with the address of your AfterTouch server.
5. Save the app.
6. Open the app's settings and note down the **Client ID** and **Client Secret**.
### Step 2: Enter credentials in AfterTouch
1. Open the AfterTouch web interface and go to the **Settings** tab.
2. Scroll to **Spotify Integration**.
3. Enter your **Client ID**, **Client Secret**, and the **Redirect URI** you registered above.
4. Click **Save Settings**.
The status should change to **Active**.
### Step 3: Authorize your Spotify account
1. Go to the **Local Account** tab (tab 7).
2. Click **Connect Spotify to this Account**.
3. A Spotify login window opens. Log in and grant permission.
4. When the window closes, your account is linked. You should see your Spotify username appear.
### Step 4: Prime your speaker
After authorizing, each speaker needs to be told about the Spotify source.
1. Go to the **Devices** tab (tab 2).
2. Find your speaker and click **Prime Spotify**.
3. The speaker will now show Spotify as an available source.
Repeat step 4 for each speaker.
---
## Amazon Music
> **Current status: account linking works, streaming does not.**
>
> The OAuth flow and token storage are fully functional. However, the speaker's `AmazonClient` contacts `music-api.amazon.com` directly with the access token and receives a 401. Amazon Music's streaming API requires scopes that are only available to registered Amazon Music partners — a standard Login with Amazon app does not qualify. The infrastructure is in place and will work if those scopes ever become available, but following these steps will not result in working Amazon Music playback today.
### Step 1: Register an Amazon developer app (LWA)
1. Go to [developer.amazon.com/loginwithamazon/console/site/lwa/overview.html](https://developer.amazon.com/loginwithamazon/console/site/lwa/overview.html) and log in with your Amazon account.
2. Click **Create a New Security Profile**.
3. Give it any name and description (e.g., "My AfterTouch").
4. In the security profile's **Web Settings**, add under **Allowed Return URLs**:
```
http://<your-aftertouch-ip>:8000/mgmt/amazon/callback
```
Replace `<your-aftertouch-ip>:8000` with the address of your AfterTouch server.
5. Save and note down the **Client ID** and **Client Secret**.
### Step 2: Enter credentials in AfterTouch
1. Open the AfterTouch web interface and go to the **Settings** tab.
2. Scroll to **Amazon Music Integration**.
3. Enter your **Client ID**, **Client Secret**, and the **Redirect URI** you registered above.
4. Click **Save Settings**.
The status should change to **Active**.
### Step 3: Authorize your Amazon account
1. Go to the **Local Account** tab (tab 7).
2. Click **Connect Amazon Music to this Account**.
3. An Amazon login window opens. Log in and grant permission.
4. When the window closes, your account is linked.
### Step 4: Prime your speaker
1. Go to the **Devices** tab (tab 2).
2. Find your speaker and click **Prime Amazon**.
3. The speaker will now show Amazon Music as an available source.
Repeat step 4 for each speaker.
---
## Troubleshooting
**"Failed to initialize" when clicking Connect:**
The app credentials in Settings are missing or incorrect. Double-check the Client ID, Client Secret, and Redirect URI. The Redirect URI in AfterTouch must exactly match the one registered in the developer portal.
**The login window opens but redirects to an error page:**
The Redirect URI registered with Spotify/Amazon does not match what AfterTouch is sending. Make sure the address (including the port) is identical in both places.
**The speaker doesn't show the new source after priming:**
Try rebooting the speaker. It may take a minute to update its source list after priming.
**The login window doesn't open (popup blocked):**
Allow popups from the AfterTouch address in your browser settings, then try again. Alternatively, the status message will show a direct link you can click.
+132
View File
@@ -0,0 +1,132 @@
# Self-Hosting AfterTouch
This guide walks you through running AfterTouch on your own computer or server. No programming knowledge required.
---
## What is self-hosting?
AfterTouch is software that runs on a computer in your home and takes over the role of Bose's cloud servers. Your speakers talk to it instead of Bose.
For this to work, the computer running AfterTouch must be:
- **Always on** (or at least on whenever you want to use your speakers)
- **On the same local network** as your speakers
- **Reachable by a stable IP address** (see [Stable IP Address](#stable-ip-address) below)
Good choices: a Raspberry Pi, a NAS (like Synology or QNAP), an always-on PC or Mac, or a small server. A laptop that you close and put away is not ideal.
---
## Step 1: Get the software
Go to the [AfterTouch releases page](https://github.com/gesellix/Bose-SoundTouch/releases) and download the latest release for your operating system:
| Your system | File to download |
|-----------------------|------------------------------------------|
| Raspberry Pi (64-bit) | `soundtouch-service_linux_arm64.tar.gz` |
| Raspberry Pi (32-bit) | `soundtouch-service_linux_arm.tar.gz` |
| Linux (64-bit PC) | `soundtouch-service_linux_amd64.tar.gz` |
| macOS (Apple Silicon) | `soundtouch-service_darwin_arm64.tar.gz` |
| macOS (Intel) | `soundtouch-service_darwin_amd64.tar.gz` |
| Windows | `soundtouch-service_windows_amd64.zip` |
Extract the archive. You will find a single file called `soundtouch-service` (or `soundtouch-service.exe` on Windows).
### Alternative: Docker
If you already use Docker, you can run AfterTouch as a container instead. See the [Deployment Guide](DEPLOYMENT.md) for Docker instructions.
---
## Step 2: Run it
Open a terminal (or Command Prompt on Windows), navigate to the folder where you extracted the file, and run:
```
./soundtouch-service
```
On Windows:
```
soundtouch-service.exe
```
You should see log output like:
```
Starting AfterTouch service on :8000
```
AfterTouch is now running on port 8000.
---
## Step 3: Open the web interface
In a web browser on any device on your network, go to:
```
http://<your-server-ip>:8000
```
Replace `<your-server-ip>` with the actual IP address of the computer running AfterTouch. For example: `http://192.168.1.100:8000`.
If you are on the same computer that is running AfterTouch, you can use `http://localhost:8000`.
You should see the AfterTouch web interface with tabs: Overview, Settings, Devices, and so on.
---
## Step 4: Configure the server URL
This is the most important setting. Go to the **Settings** tab and set the **Target Domain** to the full address of your AfterTouch server — the same address you used to open the web interface:
```
http://192.168.1.100:8000
```
Use the IP address of your server, **not** `localhost`. Your speakers need to reach this address over the network, and they cannot resolve `localhost`.
Click **Save Settings**.
---
## Step 5: Proceed with migration
You are now ready to migrate your speakers. Follow the main [Migration Guide](MIGRATION-GUIDE.md) for the remaining steps (discovering devices, syncing data, and redirecting your speakers to AfterTouch).
---
## Keeping AfterTouch running
By default, AfterTouch stops when you close the terminal. To keep it running permanently:
**Raspberry Pi / Linux:** See the [Raspberry Pi Guide](RASPBERRY-PI.md) for instructions on running AfterTouch as a background service using `systemd`.
**NAS devices:** Most NAS systems support Docker. Use the Docker instructions in the [Deployment Guide](DEPLOYMENT.md).
**macOS:** You can use `launchd` to run AfterTouch at login. Creating a `launchd` plist is beyond this guide, but the [Deployment Guide](DEPLOYMENT.md) has a systemd example you can adapt.
**Windows:** You can use Task Scheduler to run AfterTouch at startup.
---
## Stable IP address
AfterTouch must always be reachable at the same address, because your speakers will be configured to point to it. If the IP changes, your speakers will stop working until you reconfigure them.
The easiest solution is to assign a **static (fixed) IP address** to the computer running AfterTouch in your router's settings. Look for "DHCP reservation" or "static IP" in your router's administration interface, and bind the server's MAC address to a fixed IP.
---
## Security note
AfterTouch's web interface and management API have no login by default. On a typical home network this is fine, since only devices on your local network can reach it.
If you want to restrict access — for example, on a shared network — start the service with a username and password:
```
./soundtouch-service --mgmt-username admin --mgmt-password yourpassword
```
This protects the Settings tab (where your Spotify and Amazon credentials are stored) from being read or changed by others on the network.
+5 -5
View File
@@ -7,7 +7,7 @@ The `soundtouch-service` is a comprehensive local server that emulates Bose's cl
The service provides:
- **🏠 Local Service Emulation**: Complete BMX (Bose Media eXchange) and Marge service implementation
- **🔧 Device Migration**: Seamlessly migrate devices from Bose cloud to local services via XML config, `/etc/hosts`, or `/etc/resolv.conf`
- **🔧 Device Migration**: Migrate devices from Bose cloud to local services via XML redirect or DNS/DHCP redirect
- **🔍 DNS Discovery & Interception**: Built-in DNS server to discover unknown Bose endpoints and selectively intercept cloud traffic
- **📊 Traffic Proxying**: Inspect and log all device communications for debugging
- **🌐 Web Management UI**: Browser-based interface for device management
@@ -116,7 +116,7 @@ volumes:
And run:
```bash
docker-compose up -d
docker compose up -d
```
## Quick Start
@@ -169,7 +169,7 @@ The service supports multiple ways to configure its behavior. When multiple sour
| `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 `resolv.conf` migration) | `:53` |
| `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/*`) | `[]` |
@@ -247,7 +247,7 @@ curl "http://192.168.1.100:8090/presets"
curl "http://localhost:8000/events/192.168.1.100"
```
#### ResolvConf Migration (DHCP-Aware DNS Redirection)
#### DNS/DHCP Migration (DHCP-Aware DNS Redirection)
The most robust and flexible DNS-based migration method. It utilizes the device's persistent `/mnt/nv/rc.local` script to inject a priority DNS hook into the system's DHCP configuration.
@@ -665,7 +665,7 @@ find data/stats/ -name "*.json" -mtime +90 -delete
- `GET /setup/discovery-status`: Check if a scan is currently in progress.
- `POST /setup/sync/{deviceIP}`: Fetch presets, recents, and sources from a device.
- `GET /setup/summary/{deviceIP}`: Get a detailed migration readiness summary.
- `POST /setup/migrate/{deviceIP}`: Migrate a device using the specified method (XML/Hosts).
- `POST /setup/migrate/{deviceIP}`: Migrate a device using the specified method (XML or DNS).
- `GET /setup/ca.crt`: Download the Root CA certificate for manual installation.
#### `GET /setup/interactions`
+81 -59
View File
@@ -1,85 +1,107 @@
### Bose Cloud Shutdown: Survival Guide for SoundTouch
# Bose Cloud Shutdown: Survival Guide
With Bose's announcement of discontinuing cloud support for SoundTouch devices in May 2026, this project provides the necessary tools to keep your speakers fully functional using a local emulation service.
Bose is shutting down SoundTouch cloud services on **May 6, 2026**. After that date, the following stop working:
This guide explains how to set up the `soundtouch-service` to run your devices independently of Bose's servers.
- Music service browsing (TuneIn, Spotify connect via app, etc.)
- Preset and recently-played sync
- The official SoundTouch app
- Software update checks
What **continues to work** regardless:
- Local playback controls via `soundtouch-cli`, `soundtouch-web`, or any app that uses the local Web API
- Bluetooth, AUX, and AirPlay inputs
- Multiroom zones (local, peer-to-peer)
**AfterTouch** — the `soundtouch-service` — restores everything in the first list by running a local replacement for the Bose cloud on your own network.
---
### Supported Use Cases
## How it works
1. **Local Service Emulation**: The service emulates Bose's BMX (Bose Media eXchange) and Marge services, which handle content registries, presets, recents, and software update checks.
2. **Traffic Redirection**: Tools are provided to redirect your speakers to this local service instead of `*.bose.com`.
3. **Offline Operation**: Once redirected, the speakers function without needing to reach Bose's servers.
4. **Preset & Recent Management**: Captures and stores presets and "recently played" items locally.
The service emulates the Bose cloud endpoints that speakers call for music service browsing, device registration, preset sync, and update checks. Once a speaker is redirected to point at the local service instead of Bose's servers, it operates independently. The built-in web UI at `http://<server>:8000` handles all setup steps.
---
### Setup Steps
## Prerequisites
To set up your SoundTouch system for local-only operation, follow these steps:
### 1. A machine that's always on
#### 1. Install and Start the Service
Run the `soundtouch-service` on a machine that is always on (like a Raspberry Pi or a NAS) within your local network.
The service must run on a host that's available whenever your speakers are in use — a Raspberry Pi, NAS, home server, or similar. The host needs a stable local address (e.g. `soundtouch.fritz.box` or a fixed IP) reachable from your speakers.
```bash
# Install the service
go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-service@latest
See [Raspberry Pi Setup](RASPBERRY-PI.md) and the [SoundTouch Service Guide](SOUNDTOUCH-SERVICE.md) for deployment options, including Docker.
# Start the service (defaults to http://localhost:8000)
soundtouch-service
```
### 2. SSH access on your speakers (for migration)
#### 2. Access the Management UI
Open your web browser and navigate to the service's web interface:
`http://<your-server-ip>:8000/`, e.g. `http://localhost:8000/`
Redirecting a speaker's service URLs requires writing to its configuration. This is done via SSH. Enable it once per device:
*Note: The service also supports a `/web/` path for management.*
1. Create a file named `remote_services` on a FAT-formatted USB drive. The drive may need its bootable flag set — see [SoundCork issue #172](https://github.com/deborahgu/soundcork/issues/172) for details.
2. Insert the drive into the speaker's USB port while it's powered on.
3. Power-cycle the speaker (unplug and replug). After boot, root SSH is available with no password.
#### 3. Enable SSH on Your Speakers
To migrate your speakers, the service needs SSH access. You can enable it by:
1. Creating an empty file named `remote_services` on a USB stick.
2. Inserting the USB stick into the SoundTouch speaker's service port.
3. Rebooting the speaker (unplug/replug).
**Verify SSH Access:**
- Confirm the device responds to SSH without a password: `ssh -o HostKeyAlgorithms=+ssh-rsa -o PubkeyAcceptedAlgorithms=+ssh-rsa root@<IP>`
- Or use the **Migration** tab in the Web UI to see if the device shows a "✅ Success" status for SSH.
Once enabled, you can log in as `root` (no password).
#### 4. Setup Through the Web UI
The web interface handles the entire process in a guided flow. Before proceeding, we strongly recommend reviewing the [Migration & Safety Guide](MIGRATION-SAFETY.md).
* **Step 1: Settings**: Configure your server's IP or domain. This ensures the speakers know where to find the local services.
* **Step 2: Devices**: The service automatically scans for SoundTouch devices on your network. If a device is not found, you can manually add its IP address.
* **Step 3: Data Sync**: Select your device and click "Start Sync". This will automatically fetch your presets, recents, and configured sources from the speaker and store them in the local `data/` directory.
* **Step 4: Migration**: Choose your redirection method (XML Recommended) and click "Confirm Migration". After the migration, reboot your speaker to apply the changes.
#### 5. Verify Your Local Data
Once migrated, your speaker will use the data captured during the Sync step.
* The service stores data in the `data/` directory, organized by device serial number (e.g., `data/default/devices/<SERIAL>/`).
* **Automatic Capture**: As you use the device (changing presets, playing new music), the service continues to "learn" and update your local files.
You can leave SSH enabled for future maintenance, or disable it once migration is complete.
---
### Comparison with other implementations (soundcork)
Our implementation (`soundtouch-service`) is largely compatible with the Python-based `soundcork` project but offers several advantages:
- **Web UI**: Integrated management interface for discovery and migration.
- **Surgical Migration**: Uses XML-based redirection by default, which is less invasive than `/etc/hosts`.
- **Automated SSL**: Handles Root CA injection automatically for secure communication.
- **Proxy Support**: Can proxy requests to original Bose servers while "learning" your configuration.
## Scenario A: Migrate before the shutdown
Do this while the Bose cloud is still running. Your existing presets and listening history are preserved.
**Step 1 — Back up your data.**
Run `soundtouch-backup all` to save your Bose account data (presets, paired devices, music sources) and each speaker's local state. See the [soundtouch-backup README](../../cmd/soundtouch-backup/README.md) for usage.
**Step 2 — Start the service and open the web UI** at `http://<server>:8000`.
**Step 3 — Configure the server URL.**
In the Settings tab, set the server URL to the address your speakers can reach (e.g. `http://soundtouch.fritz.box:8000`). If you plan to use DNS/DHCP redirect, also configure the HTTPS server URL.
**Step 4 — Add your speaker.**
The service discovers devices on your network automatically. If a speaker doesn't appear, add it manually by IP address.
**Step 5 — Sync device data.**
Click "Sync" on the device to pull its current presets, recents, and sources from the Bose cloud into the local service's datastore.
**Step 6 — Migrate.**
The web UI offers two redirect methods and walks you through each step:
| Method | How it works | When to use |
|--------------|--------------------------------------------------------|----------------------------------------------------------|
| XML redirect | Uploads a config file to the speaker via the Web API | Testing; simpler setup; covers only registered endpoints |
| DNS/DHCP | Custom DNS resolves Bose hostnames to the local server | All devices at once; full coverage |
Both methods require TLS when the speaker uses HTTPS to contact the service. The web UI guides you through installing the service's CA certificate on the speaker (requires SSH).
**Step 7 — Reboot the speaker.**
Power-cycle the speaker to apply the changes. After reboot it contacts the local service instead of Bose's cloud.
---
### Alternative: DNS Redirection (No SSH)
If you prefer not to modify your speakers via SSH, you can use a local DNS server (like Pi-hole, AdGuard Home, or Unbound) to point the following domains to your local server's IP:
## Scenario B: Set up after the shutdown (or after a factory reset)
* `bmx.bose.com`
* `streaming.bose.com`
* `updates.bose.com`
* `stats.bose.com`
* `content.api.bose.io`
If the Bose cloud is gone, or you've factory-reset a speaker, there's no existing account to migrate from. You start fresh with a local account.
*Note: DNS redirection for HTTPS services requires the speakers to trust your local service's SSL certificate. The SSH-based migration handles this automatically by injecting the CA.*
**Step 1 — Set up DNS/DHCP redirect first** (recommended).
Configure your network's DNS to resolve the Bose cloud hostnames to the local service's address before the speaker tries to register. This way, when the speaker boots and attempts to register, it reaches AfterTouch automatically instead of failing to reach Bose.
See the [SoundTouch Service Guide](SOUNDTOUCH-SERVICE.md) for the built-in DNS server configuration and the list of hostnames to redirect.
**Step 2 — Connect the speaker to Wi-Fi.**
Use the speaker's built-in AP mode or BLE setup flow. See [Device Initial Setup](DEVICE-INITIAL-SETUP.md) for factory reset button sequences and Wi-Fi provisioning.
**Step 3 — Start the service and open the web UI** at `http://<server>:8000`.
**Step 4 — Add the speaker.**
After connecting to Wi-Fi, the speaker should appear in the web UI automatically (or add it manually by IP). If DNS redirect is already in place, the speaker is already communicating with AfterTouch.
**Step 5 — Migrate** (if not already using DNS redirect).
If you didn't set up DNS first, use the XML redirect method from the web UI to update the speaker's service URLs. The web UI walks you through the steps including CA certificate setup.
**Step 6 — Reboot the speaker.**
Power-cycle to ensure all changes take effect.
---
## After migration
Once migrated, your speaker uses the local service for music browsing, preset sync, and device registration. The web UI at `http://<server>:8000` is your management interface going forward. Back up the `data/` directory periodically in case you need to restore.
For the complete step-by-step walkthrough with commands and troubleshooting, see the [Migration Guide](MIGRATION-GUIDE.md). For safety measures and rollback options, see the [Migration & Safety Guide](MIGRATION-SAFETY.md).
+79
View File
@@ -853,6 +853,85 @@ cat data/accounts/3230304/devices/*/DeviceInfo.xml | grep macAddress
---
## 🌐 **Hostname Resolution** {#hostname-resolution}
### Why the service resolves the hostname from the device
When you migrate a speaker using the resolv.conf method, the service needs to write a raw IP address into the speaker's network configuration. That IP must be the address the *speaker itself* can reach — which is not necessarily the same address your computer resolves.
In environments with NAT, split-horizon DNS, or Docker/container networking, `soundtouch.local` (or whatever you set as `SERVER_URL`) may resolve to a different IP depending on who is asking. The service therefore resolves the hostname by running `ping -c 1 <hostname>` over SSH on the speaker and extracting the IP from the output. This is the authoritative result: it is exactly what the speaker would use.
If that SSH ping fails, migration is aborted. Writing an unresolvable or incorrectly resolved hostname into `aftertouch.resolv.conf` would silently break the speaker's DNS config and prevent it from reaching the service after reboot.
**The XML migration method is different.** It writes the full URL (e.g. `http://soundtouch.local:8000`) into `SoundTouchSdkPrivateCfg.xml`. The speaker resolves the hostname at connect time, not at migration time. This means migration can proceed even if the hostname is not yet reachable — for example, when the service will be deployed under that hostname but is not running yet. A warning is still shown in the UI so you are aware, but the Confirm Migration button remains enabled.
### ❌ "Cannot resolve target hostname for migration"
**Symptoms** (migration log or web UI warning):
```
cannot resolve target hostname for migration: cannot resolve "soundtouch.local":
SSH ping from device failed and service-side DNS lookup also failed
```
or:
```
resolved "soundtouch.local" to 192.168.1.100 from service, not from device —
result may be wrong if NAT or split-DNS is in use
```
**What this means:**
The service could not confirm the IP by running `ping` on the speaker via SSH. Either:
- the `ping` binary is not available or not in `$PATH` on this firmware, or
- the hostname is not resolvable from the speaker's network context.
**Diagnosis — run manually over SSH:**
```bash
# SSH into the speaker
ssh root@<speaker-ip>
# Try to resolve the service hostname
ping -c 1 soundtouch.local
# or use the IP directly to verify connectivity
ping -c 1 192.168.1.100
# Check the speaker's current DNS config
cat /etc/resolv.conf
# Check if ping is available
which ping
busybox ping --help
```
**Solutions:**
#### 1. Use an IP address as SERVER_URL
The most reliable fix. If the hostname cannot be resolved from the device, use a raw IP instead. Resolution is skipped entirely when `SERVER_URL` contains an IP.
```bash
# In your .env
SERVER_URL=http://192.168.1.100:8000
HTTPS_SERVER_URL=https://192.168.1.100:8443
```
HTTPS works correctly with IP addresses — the service certificate includes the IP as a Subject Alternative Name (SAN).
#### 2. Ensure the hostname resolves on the speaker's network segment
If you use `soundtouch.local`, verify mDNS is working from another device on the same subnet:
```bash
avahi-resolve -n soundtouch.local # Linux
dns-sd -G v4 soundtouch.local # macOS
```
#### 3. Use the XML migration method
Select the XML method in the migration UI. It writes the full URL and the speaker resolves it at connect time, so hostname resolution is not required during migration. This also allows migrating to a hostname that is not yet live.
---
## 🛟 **Getting More Help**
### Information to Gather
+12 -85
View File
@@ -1,90 +1,17 @@
# Images for Migration Guide
# docs/images
This directory contains images, screenshots, and diagrams referenced in the migration guide and other documentation.
Screenshots and diagrams referenced by the documentation.
## Required Images for Migration Guide
## Current screenshots
The following images need to be created to complete the migration guide:
| File | Shows | Used in |
|------|-------|---------|
| `ui-settings.png` | AfterTouch web UI — Settings tab (Target Domain, DNS Discovery, Mirroring) | Migration Guide |
| `ui-devices.png` | AfterTouch web UI — Devices tab (discovered speakers with Sync/Migrate actions) | Migration Guide |
| `ui-sync.png` | AfterTouch web UI — Data Sync tab (successful sync result) | Migration Guide |
| `ui-migration.png` | AfterTouch web UI — Migration tab (HTTPS test, DNS test, method selector) | Migration Guide |
| `speaker-ap-wifi-setup.png` | Speaker AP mode Wi-Fi setup page at `http://192.0.2.1` | Device Initial Setup |
### Dashboard Screenshots
- **dashboard-home.png** - Main SoundTouch Service dashboard homepage
- **account-creation.png** - Account creation form with fields filled
- **account-dashboard.png** - Fresh account dashboard showing ready state
- **device-discovery.png** - Device discovery page showing found speakers
- **device-registration.png** - Device registration dialog with options
- **migration-setup.png** - Migration configuration dialog
- **migration-progress.png** - Migration progress tracker showing phases
- **migration-health.png** - Migration health monitoring dashboard
- **account-migration.png** - Account-wide migration progress overview
- **migration-complete.png** - Completed migration dashboard view
- **backup-setup.png** - Backup configuration settings page
## Adding new screenshots
### Setup and Preparation
- **usb-remote-services.png** - USB drive setup showing file structure
- **raspberry-pi-setup.png** - Raspberry Pi with connected cables (optional)
### Process Diagrams
- **migration-flow-diagram.png** - Flow chart showing migration phases
- **network-topology.png** - Network diagram showing Pi, router, speakers
- **data-flow-diagram.png** - How data flows between components
## Image Requirements
### Technical Specifications
- **Format**: PNG preferred for screenshots, SVG for diagrams
- **Resolution**: Minimum 1200px width for screenshots
- **File Size**: Keep under 500KB when possible for fast loading
- **Naming**: Use descriptive kebab-case names as shown above
### Content Guidelines
- **Clean Interface**: Show realistic but clean interface states
- **Consistent Styling**: Use consistent colors and styling across images
- **Readable Text**: Ensure all text in screenshots is legible
- **Example Data**: Use realistic example data (Living Room Speaker, etc.)
- **Status Indicators**: Show clear success/error states with appropriate colors
### Placeholder Content
Until real screenshots are available, consider:
- **Mockups**: Create simple mockups showing the expected interface
- **Wireframes**: Basic wireframes indicating layout and content
- **Diagrams**: Technical diagrams can be created immediately
- **Text Placeholders**: Use `[Image: Description]` in documentation
## Creating the Images
### For Dashboard Screenshots
1. Set up the enhanced SoundTouch service
2. Create sample account and register devices
3. Take screenshots at key points in the migration process
4. Edit for clarity (highlight important elements, add annotations)
### For Diagrams
1. Use tools like Lucidchart, draw.io, or similar
2. Follow consistent color scheme:
- Blue: SoundTouch Service components
- Green: Healthy/successful states
- Orange: Warning/in-progress states
- Red: Error/problematic states
- Gray: External/third-party components
### For Physical Setup
1. Take photos of actual hardware setup
2. Show USB drive preparation process
3. Demonstrate network connections if helpful
## Alternative Text Requirements
Each image should have appropriate alt text for accessibility:
```markdown
![Alt text describing the image content](../images/image-name.png)
*Caption: Additional context or explanation*
```
## Future Enhancements
Consider adding:
- **Video Walkthroughs**: Screen recordings of key processes
- **Interactive Demos**: Web-based interactive guides
- **Troubleshooting Screenshots**: Common error states and solutions
- **Mobile Views**: How to access from mobile devices
PNG format, 1200 px or wider. Use descriptive kebab-case names. Update this README when adding files.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

+521
View File
@@ -0,0 +1,521 @@
# SoundTouch Device WebSocket API — Pairing & Operation Flow
Reference document derived from mitmproxy captures of the Bose SoundTouch Android app
(`bose-pairing-20260502-155542`, `bose-pairing-20260502-165549`).
> **Why this matters:** With Bose cloud services shutting down on 2026-05-06, the original
> app may stop working for pairing and playback control. This document captures the exact
> WebSocket message sequences needed to replicate those flows independently.
---
## Connection
All interactions use the SoundTouch WebSocket API on the speaker's local IP, port **8090**
(the same port as the REST API). Connect with the `Gabbo` sub-protocol:
```
GET ws://192.168.x.y:8090/
Upgrade: websocket
Sec-WebSocket-Protocol: Gabbo
```
Upon connection the server immediately sends an identification banner:
```xml
<SoundTouchSdkInfo serverVersion="4" serverBuild="trunk r46330 v4 epdbuild hepdswbld04" />
```
---
## Message Envelope
All subsequent messages (except `selectLastWiFiSource`, see below) use this envelope:
**Client → Server request:**
```xml
<msg>
<header deviceID="{device_id}" url="{endpoint}" method="{GET|POST}">
<request requestID="{n}">
<info type="new"/> <!-- or type="update" -->
<!-- optional: <sourceItem source="TUNEIN"/> -->
</request>
</header>
<body>
<!-- payload, may be empty -->
</body>
</msg>
```
**Server → Client response:**
```xml
<?xml version="1.0" encoding="UTF-8" ?>
<msg>
<header deviceID="{device_id}" url="{endpoint}" method="{GET|POST}">
<request requestID="{n}" msgType="RESPONSE">
<info type="new"/>
</request>
</header>
<body>
<!-- response payload -->
</body>
</msg>
```
**Server → Client push (unsolicited):**
```xml
<updates deviceID="{device_id}">
<nowPlayingUpdated>...</nowPlayingUpdated>
</updates>
```
`requestID` is a monotonically increasing integer per connection (client-side sequence).
`{device_id}` is the speaker's MAC address with colons removed (e.g. `08DF1F0BA325`).
---
## Phase 1 — Discovery: Is the Speaker Already Paired?
```xml
<!-- C→S: fetch device info -->
<msg><header deviceID="{device_id}" url="info" method="GET">
<request requestID="1"><info type="new"/></request>
</header></msg>
<!-- S→C: response -->
<info deviceID="{device_id}">
<name>SoundTouch 10</name>
<type>SoundTouch 10</type>
<margeAccountUUID>9569497</margeAccountUUID> <!-- empty = unpaired -->
<margeURL>https://streaming.bose.com</margeURL>
...
</info>
```
- **Empty `margeAccountUUID`** → device is unpaired, proceed to Phase 2
- **Populated `margeAccountUUID`** → already paired with that account ID
---
## Phase 2 — Pairing a New Speaker
### 2.1 Setup State Machine
The pairing flow uses a setup state machine on the device. States must be sent in order.
```xml
<!-- 1. Start setup -->
<msg><header deviceID="{device_id}" url="setup" method="POST">
<request requestID="21"></request>
</header><body><setupState state="SETUP_START"/></body></msg>
<!-- 2. Enter identify mode — device flashes/beeps; 300 000 ms timeout -->
<msg><header deviceID="{device_id}" url="setup" method="POST">
<request requestID="22"></request>
</header><body><setupState state="SETUP_IDENTIFY_DEVICE_ENTER" timeout="300000"/></body></msg>
<!-- Server pushes: -->
<updates deviceID="{device_id}">
<soundTouchConfigurationUpdated>
<soundTouchConfigurationStatus status="SOUNDTOUCH_CONFIGURING"/>
</soundTouchConfigurationUpdated>
</updates>
<!-- 3. Set language (3 = German; adjust as needed) -->
<msg><header deviceID="{device_id}" url="language" method="POST">
<request requestID="23"></request>
</header><body><sysLanguage>3</sysLanguage></body></msg>
<!-- 4. Enter setup (user has confirmed identification) -->
<msg><header deviceID="{device_id}" url="setup" method="POST">
<request requestID="24"></request>
</header><body><setupState state="SETUP_ENTER"/></body></msg>
<!-- 5. Leave identify mode -->
<msg><header deviceID="{device_id}" url="setup" method="POST">
<request requestID="25"></request>
</header><body><setupState state="SETUP_IDENTIFY_DEVICE_LEAVE"/></body></msg>
<!-- 6. Set device name -->
<msg><header deviceID="{device_id}" url="name" method="POST">
<request requestID="26"></request>
</header><body><name>My SoundTouch 10</name></body></msg>
```
### 2.2 Account Pairing — The Critical Step
```xml
<!-- C→S: pair device with account -->
<msg><header deviceID="{device_id}" url="setMargeAccount" method="POST">
<request requestID="27"></request>
</header><body>
<PairDeviceWithAccount>
<accountId>{accountId}</accountId>
<userAuthToken>Bearer {token}</userAuthToken>
</PairDeviceWithAccount>
</body></msg>
<!-- S→C: device info response with margeAccountUUID now set -->
<info deviceID="{device_id}">
...
<margeAccountUUID>{accountId}</margeAccountUUID>
...
</info>
```
The server also pushes several `sourcesUpdated` events after successful pairing.
**`{accountId}`** — the numeric Bose account ID (e.g. `9569497`), obtainable from
`GET /streaming/account/login` on soundtouch-service.
**`{token}`** — a Bearer token issued by Bose authentication (or soundtouch-service).
The full token from the captures:
```
Bearer NtJDRbNtY3hDhm5K8FC2JprRhRQNH3QdZjG6aR4ASwYQg4rvZMY6dPLc3Bm6zvWNciWzCpMWZ/dbITRQoVdClOdssgDO+Nlh4ZJWp2w3tZiGzB8Flho0c+ipXnT/0Yg5
```
(session-specific; obtain a fresh one from the service's account login flow)
### 2.3 Finish Setup and Telemetry
```xml
<!-- Leave setup state machine -->
<msg><header deviceID="{device_id}" url="setup" method="POST">
<request requestID="28"></request>
</header><body><setupState state="SETUP_LEAVE"/></body></msg>
<!-- Trigger device to sync customer support info to Marge cloud -->
<msg><header deviceID="{device_id}" url="pushCustomerSupportInfoToMarge" method="GET">
<request requestID="29"></request>
</header></msg>
<!-- S→C: -->
<status>/pushCustomerSupportInfoToMarge</status>
```
---
## Phase 3 — Unpairing
```xml
<!-- C→S: remove device from account -->
<msg><header deviceID="{device_id}" url="setMargeAccount" method="POST">
<request requestID="24">
<info mainNode="removeDevice" type="new"/>
<sourceItem source="SETTINGS" sourceAccount="{device_id}"/>
</request>
</header><body><UnPairDeviceWithAccount/></body></msg>
<!-- S→C: response with device info showing empty margeAccountUUID -->
<!-- Server also pushes: <updates><infoUpdated/></updates> -->
```
---
## Phase 4 — App Initialization (Bulk State Fetch)
When the app connects to an already-paired device it sends these in rapid parallel sequence:
```
info (GET) — device metadata, check pairing
sources (GET) — available input sources
presets (GET) — saved presets 16
swUpdateQuery (POST) — check if update is in progress
capabilities (GET) — hardware capabilities, network config
bassCapabilities (GET) — bass range and defaults
now_playing (GET) — current playback state
volume (GET) — current volume
getZone (GET) — multi-room zone membership
clockDisplay (POST) — set clock timezone/format
```
Then a second wave:
```
swUpdateCheck (POST) — check for new firmware
systemtimeout (GET) — power-saving timeout
rebroadcastlatencymode (GET) — zone latency mode
getGroup (GET) — stereo-pair group
language (GET, sourceItem source="settings") — UI language
bass (GET) — current bass level
serviceAvailability (GET, sourceItem source="add_service" or "settings")
webserver/pingRequest (GET) — keepalive
pushCustomerSupportInfoToMarge (GET) — telemetry
netStats (GET, sourceItem source="settings") — network statistics
introspect (POST, sourceItem source="AIRPLAY") — AirPlay2 capabilities
```
`clockDisplay` example with timezone:
```xml
<clockDisplay>
<clockConfig timezoneInfo="Europe/Berlin" timeFormat="TIME_FORMAT_12HOUR_ID"/>
</clockDisplay>
```
`serviceAvailability` response lists availability of all service types (PANDORA, AIRPLAY,
AMAZON, DEEZER, SPOTIFY, TUNEIN, SIRIUSXM_EVEREST, BLUETOOTH, etc.) with `isAvailable`
and optional `reason` attributes.
---
## Playback Control
### Start Playback via `playbackRequest` (preferred — bypasses source checks)
```xml
<msg><header deviceID="{device_id}" url="playbackRequest" method="POST">
<request requestID="{n}"><info type="new"/></request>
</header><body>
<playbackRequest source="TUNEIN" sourceAccount="">
<container type="stationurl"
location="/v1/playback/station/s25260"
isPresetable="true"
source="TUNEIN"
sourceAccount="">
<itemName>1LIVE</itemName>
</container>
</playbackRequest>
</body></msg>
<!-- S→C response: -->
<playbackResponse source="TUNEIN" sourceAccount=""/>
<!-- S→C pushes: nowPlayingUpdated, recentsUpdated -->
```
For a TuneIn podcast episode, use `type="tracklisturl"` and
`location="/v1/playback/episodes/{id}?encoded_name={base64}"`.
### Select Content via `select` (triggers preset/recents UI highlight)
```xml
<msg><header deviceID="{device_id}" url="select" method="POST">
<request requestID="{n}"><info type="new"/></request>
</header><body>
<ContentItem source="TUNEIN"
type="stationurl"
location="/v1/playback/station/s25260"
sourceAccount="TUNEIN"
isPresetable="true">
<itemName>1LIVE</itemName>
</ContentItem>
</body></msg>
```
Note: `select` with a TUNEIN item that the device can't resolve directly may return
`error value="1005" name="UNKNOWN_SOURCE_ERROR"`. Use `playbackRequest` instead for
reliable playback.
### Special: Select Last Wi-Fi Source
A plain-text (non-XML) client message:
```
selectLastWiFiSource
```
Server responds with plain text:
```
<?xml version="1.0" encoding="UTF-8" ?><status>/selectLastWiFiSource</status>
```
### Key Presses
```xml
<!-- press -->
<msg><header deviceID="{device_id}" url="key" method="POST">
<request requestID="{n}"><info mainNode="keyPress" type="new"/><sourceItem source="TUNEIN"/></request>
</header><body><key state="press" sender="Gabbo">{KEY}</key></body></msg>
<!-- release (required for POWER — not for STOP/PAUSE) -->
<msg><header deviceID="{device_id}" url="key" method="POST">
<request requestID="{n}"><info mainNode="keyRelease" type="new"/><sourceItem source="TUNEIN"/></request>
</header><body><key state="release" sender="Gabbo">{KEY}</key></body></msg>
```
Key names observed: `POWER`, `STOP`, `PAUSE`, `ADD_FAVORITE`
`sender="Gabbo"` is the app identifier string used by all Bose mobile apps.
### Volume
```xml
<!-- Set volume (0100) -->
<msg><header deviceID="{device_id}" url="volume" method="POST">
<request requestID="{n}"><info mainNode="volume" type="new"/><sourceItem source="TUNEIN"/></request>
</header><body><volume>30</volume></body></msg>
<!-- S→C push: -->
<updates deviceID="{device_id}">
<volumeUpdated>
<volume><targetvolume>30</targetvolume><actualvolume>30</actualvolume><muteenabled>false</muteenabled></volume>
</volumeUpdated>
</updates>
```
### Bass
```xml
<!-- Get -->
<msg><header deviceID="{device_id}" url="bass" method="GET">
<request requestID="{n}"><info type="new"/></request>
</header></msg>
<!-- Set (range: bassMin to bassMax from bassCapabilities, typically -9 to 0) -->
<msg><header deviceID="{device_id}" url="bass" method="POST">
<request requestID="{n}"><info mainNode="bassSet" type="new"/><sourceItem source="SETTINGS"/></request>
</header><body><bass>-2</bass></body></msg>
<!-- S→C push: <updates><bassUpdated/></updates> -->
```
---
## Browse & Navigate
```xml
<!-- Open recents menu -->
<msg><header deviceID="{device_id}" url="navigate" method="POST">
<request requestID="{n}"><info mainNode="navigateMenu" type="new"/><sourceItem source="RECENTS"/></request>
</header><body><navigate menu="recents"/></body></msg>
<!-- S→C response: -->
<navigateResponse menu="recents">
<totalItems>4</totalItems>
<items>
<item type="stationurl" source="TUNEIN" location="/v1/playback/station/s25260"
sourceAccount="TUNEIN" isPresetable="true" id="0">
<itemName>1LIVE</itemName>
</item>
...
</items>
</navigateResponse>
```
Use `type="update"` on `<info>` for subsequent refresh calls on the same menu.
---
## Settings
### System Timeout (Power-Saving)
```xml
<!-- Read -->
<msg><header deviceID="{device_id}" url="systemtimeout" method="GET">
<request requestID="{n}"><info type="new"/></request>
</header></msg>
<!-- Write: disable auto power-off -->
<msg><header deviceID="{device_id}" url="systemtimeout" method="POST">
<request requestID="{n}"><info mainNode="systemtimeout" type="new"/><sourceItem source="SETTINGS"/></request>
</header><body><systemtimeout><powersaving_enabled>false</powersaving_enabled></systemtimeout></body></msg>
```
### Clock Display
```xml
<msg><header deviceID="{device_id}" url="clockDisplay" method="POST">
<request requestID="{n}"><info mainNode="clockDisplayBypass" type="new"/></request>
</header><body>
<clockDisplay>
<clockConfig timezoneInfo="Europe/Berlin" timeFormat="TIME_FORMAT_12HOUR_ID"/>
</clockDisplay>
</body></msg>
```
---
## Keepalive
The app sends a ping roughly every 30 seconds:
```xml
<!-- C→S -->
<msg><header deviceID="{device_id}" url="webserver/pingRequest" method="GET">
<request requestID="{n}"><info type="new"/></request>
</header></msg>
<!-- S→C -->
<pingRequest pong="true"/>
```
---
## Server Push Events (Unsolicited)
The server wraps push events in `<updates deviceID="{device_id}">`:
| Event element | Trigger |
|----------------------------------|-----------------------------------------------------------------------------------------------------|
| `nowPlayingUpdated` | Source/track changed, playback state changed |
| `nowSelectionUpdated` | Preset slot highlighted (UI selection changed) |
| `recentsUpdated` | Recents list changed |
| `presetsUpdated` | Preset saved or modified |
| `volumeUpdated` | Volume changed (any source) |
| `bassUpdated` | Bass level changed |
| `connectionStateUpdated` | Wi-Fi signal strength changed (`EXCELLENT_SIGNAL`, `GOOD_SIGNAL`, `MARGINAL_SIGNAL`, `POOR_SIGNAL`) |
| `soundTouchConfigurationUpdated` | Setup state changed (e.g. `SOUNDTOUCH_CONFIGURING`) |
| `infoUpdated` | Device info changed (e.g. after un-pairing) |
| `sourcesUpdated` | Available sources list changed |
Separate push (not inside `<updates>`):
```xml
<userActivityUpdate deviceID="{device_id}"/>
```
Sent after any physical or app-initiated user action.
---
## Notification (Client → Device Push)
Used by the app to notify the device of data that has changed on the service side
(e.g. after syncing presets from cloud). Header uses `propagate="false"`:
```xml
<msg>
<header deviceID="{device_id}" url="notification" method="POST" propagate="false">
<request requestID="{n}"><info mainNode="presetsUpdated" type="new"/></request>
</header>
<body>
<updates deviceID="{device_id}"><presetsUpdated/></updates>
</body>
</msg>
<!-- S→C response: -->
<status>/notification</status>
```
---
## Complete Pairing Sequence (Minimal)
To pair a freshly factory-reset speaker to a Bose account (soundtouch-service must be
running and authenticated):
```
1. Connect WebSocket to ws://{speakerIP}:8090/
2. Receive: <SoundTouchSdkInfo .../>
3. GET info → confirm margeAccountUUID is empty
4. POST setup SETUP_START
5. POST setup SETUP_IDENTIFY_DEVICE_ENTER (timeout=300000)
(user physically presses button on speaker to confirm identity)
6. POST language <sysLanguage>3</sysLanguage>
7. POST setup SETUP_ENTER
8. POST setup SETUP_IDENTIFY_DEVICE_LEAVE
9. POST name <name>{desired name}</name>
10. POST setMargeAccount <PairDeviceWithAccount>
<accountId>{accountId}</accountId>
<userAuthToken>Bearer {token}</userAuthToken>
</PairDeviceWithAccount>
→ device responds with info, margeAccountUUID is now set
11. POST setup SETUP_LEAVE
12. GET pushCustomerSupportInfoToMarge (telemetry, safe to skip)
```
---
## Source References
- `bose-pairing-20260502-155542` — Session 1: initial pairing of SoundTouch 10 to account 9569497
- `bose-pairing-20260502-165549` — Session 2: re-pairing and full operation (TuneIn, Spotify, presets)
- Raw WebSocket files: `scripts/android/mitm/{session}/mirror/{n}-websocket/*.txt`
- Companion HTTP upgrade files: `scripts/android/mitm/{session}/mirror/{n}-*.http`
+59 -7
View File
@@ -2,6 +2,42 @@
- https://www.radio-browser.info is a community driven radio station database.
- It provides an API to access the data and allows users to submit new stations or update existing ones.
- RadioBrowser provides a native SoundTouch-compatible API at `https://all.api.radio-browser.info/soundtouch`.
### Architecture
This service registers RadioBrowser in its BMX service registry (provider ID 39) pointing to RadioBrowser's SoundTouch API. The device discovers it from there and communicates directly with RadioBrowser for browsing and playback — the local service does not proxy streams.
The source is registered with type `RADIO_BROWSER` in the Marge sources list. The RadioBrowser provider ID (39) in the source entry identifies it as RadioBrowser within the BMX layer.
The device's own `Sources.xml` (`/mnt/nv/BoseApp-Persistence/1/Sources.xml`) must contain a `RADIO_BROWSER` entry for playback to work:
```xml
<source secret="" secretType="">
<sourceKey type="RADIO_BROWSER" account="" />
</source>
```
**A device reboot is required after adding this entry.** The firmware only registers `RADIO_BROWSER` as a selectable source type during the boot-time `Sources.xml` load. The Marge runtime sync stores the source in the registry but does not complete the activation — without a reboot, selecting a `RADIO_BROWSER` station results in `INVALID_SOURCE`.
A reboot achieves two things in sequence:
1. The speaker fetches all sources from the soundtouch-service via a `/full` request, which updates the device-local `Sources.xml`.
2. The firmware initialises and registers the `RADIO_BROWSER` source type from that updated file.
The `INVALID_SOURCE_TYPE` message from the Bluetooth daemon visible in device logs (e.g. during `GET /serviceAvailability`) is informational noise and does not affect playback.
### Triggering a sources refresh without rebooting
Step 1 above (the `/full` fetch that updates `Sources.xml`) can be triggered independently by posting a `sourcesUpdated` notification directly to the speaker. This is useful for verifying that the soundtouch-service serves the correct sources list before committing to a full reboot:
```bash
curl -v -X POST http://<speaker-ip>:8090/notification \
-H "Content-Type: application/xml" \
-d '<updates deviceID="<deviceID>"><sourcesUpdated/></updates>'
```
Replace `<speaker-ip>` with your speaker's IP address and `<deviceID>` with its device ID (visible in `/info`). After this call the speaker re-fetches its sources from the service. Step 2 (source-type registration) still requires a reboot.
### Search for stations
@@ -9,10 +45,7 @@
- Click on the station and copy the UUID from the URL.
- e.g. `https://www.radio-browser.info/history/d28420a4-eccf-47a2-ace1-088c7e7cb7e0`
### RADIO_BROWSER
- This project supports source type RADIO_BROWSER to play radio stations.
- Set the `location` attribute to `/stations/byuuid/{UUID}`.
### Playing the station
```xml
<ContentItem
@@ -20,15 +53,34 @@
type="stationurl"
isPresetable="true"
location="/stations/byuuid/9610c454-0601-11e8-ae97-52543be04c81">
<itemName>RADIO_BROWSER</itemName>
<itemName>Radio Station Name</itemName>
<containerArt></containerArt>
</ContentItem>
```
### Playing the station
To start the radio stream replace `<uuid>` and `<soundtouch>` and run curl like this:
```bash
curl -d '<ContentItem source="RADIO_BROWSER" type="stationurl" location="/stations/byuuid/<uuid>"/>' <soundtouch>:8090/select
```
### BMX service registry entry
The entry in `pkg/service/handlers/static/bmx_services.json` that enables RadioBrowser:
```json
{
"baseUrl": "https://all.api.radio-browser.info/soundtouch",
"id": {
"name": "RADIO_BROWSER",
"value": 39
},
"streamTypes": ["liveRadio", "onDemand"],
"authenticationModel": {
"anonymousAccount": {
"autoCreate": true,
"enabled": true
}
}
}
```
+197
View File
@@ -0,0 +1,197 @@
# Spotify Account Addition Technical Reference
This document details the exact network requests performed by the Bose SoundTouch "Stockholm" application and the SoundTouch speaker when adding a new Spotify account. This information is based on analysis of the Stockholm firmware version `27.0.13-4277-8963611`.
## Flow Overview
1. **User Authorization Initiation**: The app opens the system browser to Spotify's authorization page.
2. **Redirect Handling**: After authorization, Spotify redirects back to the app via a custom URI scheme, delivering an authorization `code`.
3. **OAuth Token Exchange**: The app sends this `code` to the background worker, which exchanges it for a Bose-mediated token.
4. **Cloud Source Registration**: The app registers the Spotify account as a "source" in the user's Bose Cloud (Marge) profile.
5. **Local Device Sync**: The app notifies the local SoundTouch speaker about the new source, which then updates its internal configuration.
---
## 0. User Authorization Initiation
The process begins in the Stockholm UI when the user selects Spotify to add a new account.
### Request Details (App to Browser)
- **Action**: Open System Browser
- **Base URL**: `[SPOTIFY_AUTH_URL]` (e.g., `https://accounts.spotify.com/authorize`)
- **Query Parameters**:
- `client_id`: Bose Spotify Client ID
- `response_type`: `code`
- `redirect_uri`: `http://localhost` (often used as a placeholder or specifically handled by the app's internal webview/proxy)
- `scope`: `user-read-private user-read-email ...`
- `state`: A base64-encoded JSON object containing metadata, e.g., `{"service": "SPOTIFY"}`.
### Redirect (Browser to App)
Upon successful login and authorization, Spotify redirects the browser to a URL that the SoundTouch app intercepts.
- **URL Format**: `soundtouch://bose/musicservice/spotify/login?code=[AUTH_CODE]&state=[STATE]`
- **App Action**: The `UIMain` component (in `ui_main.js`) handles this "deep link". It extracts the `code` from the query parameters and prepares to send it to the background worker.
---
## 1. OAuth Token Exchange (Bose Cloud)
After the UI intercepts the redirect and extracts the `code`, it sends a `createOAuthAccountRequest` to the background `SpotifyWorker`. The worker then performs the exchange for a Bose-mediated token.
### What is a "Bose-mediated token"?
The "Bose-mediated token" is a token issued by the Bose OAuth proxy. When the app (or device) requests a token via `oauth.streaming.bose.com`, Bose's service performs the actual OAuth2 exchange with Spotify.
- **It is not directly a Spotify refresh token**: Instead, it is a Bose-issued token that *represents* the underlying Spotify session.
- **Token Version 3**: Modern firmware uses `token_version_3`, which signifies that the device doesn't store the raw Spotify tokens but instead uses a Bose-specific "secret" that the Bose Cloud uses to fetch fresh Spotify access tokens on the device's behalf.
- **Access vs Refresh**: The initial response from the `.../token/cs` endpoint typically contains an `access_token` (valid for ~1 hour) and a `token_type: "Bearer"`. The Bose cloud service manages the persistent refresh token internally.
### Internal Message (UI to Worker)
- **Message Type**: `createOAuthAccountRequest`
- **Payload**:
```json
{
"source": "SPOTIFY",
"code": "[AUTH_CODE_FROM_REDIRECT]",
"credentialType": "token_version_3"
}
```
### Outgoing Request (Worker to Bose OAuth Proxy)
- **Endpoint**: `https://oauth.streaming.bose.com/oauth/account/[ACCOUNT_ID]/music/musicprovider/15/token/cs`
- **Method**: `POST`
- **Headers**:
- `Content-Type: application/json`
- `Accept: application/json`
- `Authorization: Bearer [SESSION_TOKEN]` (The user's Bose account session token)
### Payload (JSON)
```json
{
"grant_type": "authorization_code",
"code": "[AUTH_CODE_FROM_SPOTIFY]",
"redirect_uri": "http://localhost"
}
```
### curl Example
```bash
curl -X POST "https://oauth.streaming.bose.com/oauth/account/[ACCOUNT_ID]/music/musicprovider/15/token/cs" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer [SESSION_TOKEN]" \
-d '{
"grant_type": "authorization_code",
"code": "[AUTH_CODE_FROM_SPOTIFY]",
"redirect_uri": "http://localhost"
}'
```
---
## 2. Cloud Source Registration (Marge)
The app now registers the Spotify account with the Bose "Marge" service. This makes the source available across all devices linked to the same Bose account.
### Request Details
- **Endpoint**: `https://streaming.bose.com/streaming/account/[ACCOUNT_ID]/source`
- **Method**: `POST`
- **Headers**:
- `Content-Type: application/vnd.bose.streaming-v1.1+xml`
- `Authorization: [MARGE_TOKEN]`
- `GUID: [DEVICE_GUID]`
- `ClientType: Stockholm`
### Payload (XML)
```xml
<?xml version="1.0" encoding="UTF-8"?>
<source>
<username>[SPOTIFY_USER_ID]</username>
<sourceproviderid>15</sourceproviderid>
<credential type="token_version_3">[SECRET_TOKEN_OBTAINED_IN_STEP_1]</credential>
<sourcename>[DISPLAY_NAME_E_G_EMAIL]</sourcename>
</source>
```
### curl Example
```bash
curl -X POST "https://streaming.bose.com/streaming/account/[ACCOUNT_ID]/source" \
-H "Content-Type: application/vnd.bose.streaming-v1.1+xml" \
-H "Authorization: [MARGE_TOKEN]" \
-d '<?xml version="1.0" encoding="UTF-8"?><source><username>[USER]</username><sourceproviderid>15</sourceproviderid><credential type="token_version_3">[TOKEN]</credential><sourcename>[NAME]</sourcename></source>'
```
---
### Local Device Sync (LISA API)
The app notifies the physical SoundTouch speaker about the new source. This is usually done via the device's management API on port 8090.
#### Modern Flow (OAuth)
- **Endpoint**: `http://[DEVICE_IP]:8090/setMusicServiceOAuthAccount`
- **Method**: `POST`
- **Payload**:
```xml
<OAuthCredentials source="SPOTIFY" displayName="[DISPLAY_NAME]">
<user>[SPOTIFY_USER_ID]</user>
<code>[AUTH_CODE_OR_TOKEN]</code>
<version>token_version_3</version>
</OAuthCredentials>
```
#### Marge-Sync Notification (Fall-back)
If the speaker returns `1029 UNKNOWN_ACTION_ERROR`, it signifies the LISA API version is too old for the OAuth flow. Stockholm-based firmware often expects the account to be registered in Marge first, followed by a notification to sync.
- **Endpoint**: `http://[DEVICE_IP]:8090/notification`
- **Method**: `POST`
- **Payload**:
```xml
<updates deviceID="[DEVICE_UID]">
<sourcesUpdated></sourcesUpdated>
</updates>
```
#### Legacy Flow (Fall-back)
For older firmware that doesn't use Marge for Spotify:
- **Endpoint**: `http://[DEVICE_IP]:8090/setMusicServiceAccount`
- **Method**: `POST`
- **Payload**:
```xml
<credentials source="SPOTIFY" displayName="Spotify Premium">
<user>[USER]</user>
<pass>[TOKEN]</pass>
</credentials>
```
---
## Implementation in SoundTouch-Service
This project implements the "Bose-mediated token" flow as follows:
1. **Surrogate Secrets**: When a user links their Spotify account via `soundtouch-service`, the service generates a 32-character hex string (a "Bose Secret").
2. **Marge & LISA registration**: This secret is sent to the speaker and stored in the emulated Marge cloud as the `credential`. The raw Spotify refresh token never leaves the server.
3. **Token Refresh Proxy**: When the speaker needs a fresh Spotify `access_token`, it calls the `soundtouch-service` proxy (`/oauth/device/.../token/cs3`) providing this secret. The server maps the secret back to the actual Spotify account, performs the refresh with Spotify, and returns a fresh short-lived `access_token` to the speaker.
---
## Placeholders and Constants
| Placeholder | Description |
|:------------------|:----------------------------------------------------|
| `[ACCOUNT_ID]` | The internal Bose account ID (UUID). |
| `[SESSION_TOKEN]` | Temporary token from Bose login. |
| `[MARGE_TOKEN]` | Persistent authorization token for Marge services. |
| `[DEVICE_GUID]` | Unique identifier for the controller app instance. |
| `[DEVICE_IP]` | Local IP address of the SoundTouch speaker. |
| `15` | Constant `sourceproviderid` for Spotify. |
| `token_version_3` | Credential type for modern OAuth2 Spotify accounts. |
---
## Resulting Persistence
Once these requests succeed, the device updates its `/mnt/nv/BoseApp-Persistence/1/Sources.xml` file:
```xml
<source displayName="user@example.com" secret="[SECRET_BLOB]" secretType="token_version_3">
<sourceKey type="SPOTIFY" account="user" />
</source>
```
+2 -2
View File
@@ -1,8 +1,8 @@
module navigation-station-demo
go 1.26.1
go 1.26.2
require github.com/gesellix/bose-soundtouch v0.43.0
require github.com/gesellix/bose-soundtouch v0.57.0
require github.com/gorilla/websocket v1.5.3 // indirect
+2 -2
View File
@@ -1,8 +1,8 @@
module preset-management-example
go 1.26.1
go 1.26.2
require github.com/gesellix/bose-soundtouch v0.43.0
require github.com/gesellix/bose-soundtouch v0.57.0
require github.com/gorilla/websocket v1.5.3 // indirect
+11 -8
View File
@@ -1,27 +1,30 @@
module github.com/gesellix/bose-soundtouch
go 1.26.1
go 1.26.2
require (
github.com/go-chi/chi/v5 v5.2.5
github.com/google/gopacket v1.1.19
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/mdns v1.0.6
github.com/miekg/dns v1.1.72
github.com/russross/blackfriday/v2 v2.1.0
github.com/sergi/go-diff v1.4.0
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
github.com/urfave/cli/v2 v2.27.7
golang.org/x/crypto v0.49.0
golang.org/x/crypto v0.50.0
golang.org/x/term v0.42.0
)
require (
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
golang.org/x/image v0.38.0 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/image v0.39.0 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/tools v0.43.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/tools v0.44.0 // indirect
)
+43 -16
View File
@@ -1,45 +1,64 @@
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/mdns v1.0.6 h1:SV8UcjnQ/+C7KeJ/QeVD/mdN2EmzYfcGfufcuzxfCLQ=
github.com/hashicorp/mdns v1.0.6/go.mod h1:X4+yWh+upFECLOki1doUPaKpgNQII9gy4bUdCYKNhmM=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -50,8 +69,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -62,6 +81,7 @@ golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -73,8 +93,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -85,8 +105,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -97,15 +117,22 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+115 -3
View File
@@ -146,7 +146,9 @@ import (
"encoding/xml"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
@@ -192,12 +194,51 @@ func NewClient(config *Config) *Client {
config.UserAgent = "Bose-SoundTouch-Go-Client/1.0"
}
if config.Port == 0 {
config.Port = 8090
host := config.Host
if !strings.Contains(host, "://") {
host = "http://" + host
}
u, err := url.Parse(host)
if err != nil {
// Fallback for invalid URLs
port := config.Port
if port == 0 {
port = 8090
}
return &Client{
baseURL: fmt.Sprintf("http://%s:%d", config.Host, port),
httpClient: &http.Client{
Timeout: config.Timeout,
},
timeout: config.Timeout,
userAgent: config.UserAgent,
}
}
// Use SplitHostPort to check for port in the host string
_, p, splitErr := net.SplitHostPort(u.Host)
if splitErr != nil {
// No port in the host string, use the one from config or default
port := config.Port
if port == 0 {
port = 8090
}
u.Host = net.JoinHostPort(u.Host, fmt.Sprintf("%d", port))
} else if p == "" {
// Empty port, use config or default
port := config.Port
if port == 0 {
port = 8090
}
u.Host = net.JoinHostPort(u.Hostname(), fmt.Sprintf("%d", port))
}
return &Client{
baseURL: fmt.Sprintf("http://%s:%d", config.Host, config.Port),
baseURL: u.String(),
httpClient: &http.Client{
Timeout: config.Timeout,
},
@@ -1116,6 +1157,19 @@ func (c *Client) post(endpoint string, payload interface{}) error {
if resp.StatusCode != http.StatusOK {
responseBody, _ := io.ReadAll(resp.Body)
// Try to parse as ErrorsResponse (speaker error format)
var errs models.ErrorsResponse
if xmlErr := xml.Unmarshal(responseBody, &errs); xmlErr == nil && len(errs.Errors) > 0 {
return &errs
}
// Try to parse as APIError (standard format)
var apiError models.APIError
if xmlErr := xml.Unmarshal(responseBody, &apiError); xmlErr == nil && apiError.Message != "" {
return &apiError
}
return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(responseBody))
}
@@ -1160,6 +1214,19 @@ func (c *Client) postWithResponse(endpoint string, payload, result interface{})
if resp.StatusCode != http.StatusOK {
responseBody, _ := io.ReadAll(resp.Body)
// Try to parse as ErrorsResponse (speaker error format)
var errs models.ErrorsResponse
if xmlErr := xml.Unmarshal(responseBody, &errs); xmlErr == nil && len(errs.Errors) > 0 {
return &errs
}
// Try to parse as APIError (standard format)
var apiError models.APIError
if xmlErr := xml.Unmarshal(responseBody, &apiError); xmlErr == nil && apiError.Message != "" {
return &apiError
}
return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(responseBody))
}
@@ -1172,6 +1239,11 @@ func (c *Client) postWithResponse(endpoint string, payload, result interface{})
// Parse the actual response first
if err := xml.Unmarshal(responseBody, result); err != nil {
// Check if it might be an API error response instead
var errs models.ErrorsResponse
if xmlErr := xml.Unmarshal(responseBody, &errs); xmlErr == nil && len(errs.Errors) > 0 {
return &errs
}
var apiError models.APIError
if xmlErr := xml.Unmarshal(responseBody, &apiError); xmlErr == nil && apiError.Message != "" {
return &apiError
@@ -1890,6 +1962,46 @@ func (c *Client) SetMusicServiceAccount(credentials *models.MusicServiceCredenti
return nil
}
// SetMusicServiceOAuthAccount adds or updates a music service account using OAuth credentials
func (c *Client) SetMusicServiceOAuthAccount(credentials *models.OAuthCredentials) error {
if credentials == nil {
return fmt.Errorf("credentials cannot be nil")
}
var response models.MusicServiceAccountResponse
// Note: Modern firmware uses /setMusicServiceOAuthAccount, but we reuse the success logic
err := c.postWithResponse("/setMusicServiceOAuthAccount", credentials, &response)
if err != nil {
return fmt.Errorf("failed to set music service OAuth account for %s: %w", credentials.Source, err)
}
// The speaker returns /setMusicServiceOAuthAccount on success
if response.Status != "/setMusicServiceOAuthAccount" {
return fmt.Errorf("music service OAuth account operation failed: unexpected response %s", response.Status)
}
return nil
}
// NotifySourcesUpdated notifies the device that sources have been updated in Marge
func (c *Client) NotifySourcesUpdated(deviceID string) error {
notification := models.NewSourcesUpdatedNotification(deviceID)
var response models.MusicServiceAccountResponse
err := c.postWithResponse("/notification", notification, &response)
if err != nil {
return fmt.Errorf("failed to send sources updated notification: %w", err)
}
if response.Status != "/notification" {
return fmt.Errorf("sources updated notification failed: unexpected response %s", response.Status)
}
return nil
}
// RemoveMusicServiceAccount removes an existing music service account
func (c *Client) RemoveMusicServiceAccount(credentials *models.MusicServiceCredentials) error {
if credentials == nil {
+1 -6
View File
@@ -1059,12 +1059,7 @@ func loadTestData(t *testing.T, filename string) string {
}
func createTestClient(serverURL string) *Client {
config := DefaultConfig()
config.Host = "localhost" // Will be overridden by baseURL
client := NewClient(config)
client.baseURL = serverURL
return client
return NewClientFromHost(serverURL)
}
func contains(s, substr string) bool {
+135
View File
@@ -0,0 +1,135 @@
package client
import (
"encoding/xml"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
func TestClient_Post_ErrorsResponse(t *testing.T) {
// Mock speaker error response
errorXML := `<?xml version="1.0" encoding="UTF-8" ?>
<errors deviceID="08DF1F0BA325">
<error value="1029" name="UNKNOWN_ACTION_ERROR" severity="Unknown">This version of SCM does not support spotify create account functionality.</error>
</errors>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(errorXML))
}))
defer server.Close()
c := createTestClient(server.URL)
// Test post method
err := c.post("/test", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
errs := &models.ErrorsResponse{}
ok := errors.As(err, &errs)
if !ok {
t.Fatalf("expected models.ErrorsResponse, got %T: %v", err, err)
}
if errs.DeviceID != "08DF1F0BA325" {
t.Errorf("expected DeviceID 08DF1F0BA325, got %s", errs.DeviceID)
}
if len(errs.Errors) != 1 {
t.Fatalf("expected 1 error, got %d", len(errs.Errors))
}
if errs.Errors[0].Value != 1029 {
t.Errorf("expected error value 1029, got %d", errs.Errors[0].Value)
}
if errs.Errors[0].Name != "UNKNOWN_ACTION_ERROR" {
t.Errorf("expected error name UNKNOWN_ACTION_ERROR, got %s", errs.Errors[0].Name)
}
expectedMsg := "This version of SCM does not support spotify create account functionality."
if errs.Errors[0].Message != expectedMsg {
t.Errorf("expected message '%s', got '%s'", expectedMsg, errs.Errors[0].Message)
}
if err.Error() != expectedMsg {
t.Errorf("expected Error() to return '%s', got '%s'", expectedMsg, err.Error())
}
}
func TestClient_PostWithResponse_ErrorsResponse(t *testing.T) {
// Mock speaker error response
errorXML := `<?xml version="1.0" encoding="UTF-8" ?>
<errors deviceID="08DF1F0BA325">
<error value="1029" name="UNKNOWN_ACTION_ERROR" severity="Unknown">This version of SCM does not support spotify create account functionality.</error>
</errors>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(errorXML))
}))
defer server.Close()
c := createTestClient(server.URL)
// Test postWithResponse method
var result struct {
XMLName xml.Name `xml:"status"`
Data string `xml:",chardata"`
}
err := c.postWithResponse("/test", nil, &result)
if err == nil {
t.Fatal("expected error, got nil")
}
errs := &models.ErrorsResponse{}
ok := errors.As(err, &errs)
if !ok {
t.Fatalf("expected models.ErrorsResponse, got %T: %v", err, err)
}
if errs.Errors[0].Value != 1029 {
t.Errorf("expected error value 1029, got %d", errs.Errors[0].Value)
}
}
func TestClient_Post_StandardAPIError(t *testing.T) {
// Mock standard API error response
errorXML := `<?xml version="1.0" encoding="UTF-8"?><error code="404">Not Found</error>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(errorXML))
}))
defer server.Close()
c := createTestClient(server.URL)
err := c.post("/test", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
apiErr := &models.APIError{}
ok := errors.As(err, &apiErr)
if !ok {
t.Fatalf("expected models.APIError, got %T: %v", err, err)
}
if apiErr.Code != 404 {
t.Errorf("expected code 404, got %d", apiErr.Code)
}
if apiErr.Message != "Not Found" {
t.Errorf("expected message 'Not Found', got '%s'", apiErr.Message)
}
}
+10 -2
View File
@@ -20,6 +20,7 @@ type WebSocketClient struct {
conn *websocket.Conn
handlers *models.WebSocketEventHandlers
mu sync.RWMutex
writeMu sync.Mutex // serializes all writes; gorilla/websocket allows one concurrent writer
connected bool
reconnect bool
ctx context.Context
@@ -337,8 +338,12 @@ func (ws *WebSocketClient) pingLoop(config *WebSocketConfig) {
}
// Set write deadline for ping
ws.writeMu.Lock()
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
err := conn.WriteMessage(websocket.PingMessage, nil)
ws.writeMu.Unlock()
if err != nil {
ws.logger.Printf("Failed to send ping: %v", err)
return
}
@@ -515,9 +520,12 @@ func (ws *WebSocketClient) SendMessage(message []byte) error {
return fmt.Errorf("not connected")
}
ws.writeMu.Lock()
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
err := conn.WriteMessage(websocket.TextMessage, message)
ws.writeMu.Unlock()
return conn.WriteMessage(websocket.TextMessage, message)
return err
}
// PairWithAccount sends a request to pair the device with a specific account
+1
View File
@@ -171,6 +171,7 @@ func (d *DNSDiscovery) shouldIntercept(hostname string) bool {
"music.api.bose.com",
"bosecm.com",
"bose.io",
"downloads.bose.com",
}
for _, service := range interceptList {
+21 -3
View File
@@ -384,8 +384,24 @@ func TestDNSDiscovery_EmptyUpstream(t *testing.T) {
func TestDNSDiscovery_ForwardTimeout(t *testing.T) {
serviceIP := "192.168.1.100"
// Use an IP that is unroutable or doesn't exist on the network to ensure timeout
upstreamDNS := []string{"192.0.2.1:53"} // TEST-NET-1, usually non-routable
// Mock server that deliberately delays its response
mux := dns.NewServeMux()
mux.HandleFunc("google.com.", func(w dns.ResponseWriter, r *dns.Msg) {
time.Sleep(200 * time.Millisecond) // Longer than the timeout
m := new(dns.Msg)
m.SetReply(r)
_ = w.WriteMsg(m)
})
ts := &dns.Server{Addr: "127.0.0.1:5358", Net: "udp", Handler: mux}
go func() { _ = ts.ListenAndServe() }()
defer func() { _ = ts.Shutdown() }()
// Give the server a moment to start
time.Sleep(50 * time.Millisecond)
upstreamDNS := []string{"127.0.0.1:5358"}
d := NewDNSDiscovery(upstreamDNS, serviceIP)
d.timeout = 100 * time.Millisecond
@@ -398,12 +414,14 @@ func TestDNSDiscovery_ForwardTimeout(t *testing.T) {
d.forward(rw, m)
duration := time.Since(start)
// Since we're forwarding to a local server that sleeps for 200ms,
// and our timeout is 100ms, it should take at least 100ms.
if duration < 100*time.Millisecond {
t.Errorf("Expected forward to take at least 100ms (timeout), but took %v", duration)
}
if rw.msg == nil || rw.msg.Rcode != dns.RcodeServerFailure {
t.Errorf("Expected RcodeServerFailure after timeout")
t.Errorf("Expected RcodeServerFailure after timeout, got msg: %v", rw.msg)
}
}
+48 -1
View File
@@ -110,13 +110,60 @@ func (cred *MusicServiceCredentials) GetDescription() string {
}
}
// OAuthCredentials represents the credentials sent to /setMusicServiceOAuthAccount
type OAuthCredentials struct {
XMLName xml.Name `xml:"OAuthCredentials"`
Source string `xml:"source,attr"`
DisplayName string `xml:"displayName,attr,omitempty"`
User string `xml:"user"`
Code string `xml:"code"`
Version string `xml:"version"`
}
// NewSpotifyOAuthCredentials creates OAuth credentials for Spotify
func NewSpotifyOAuthCredentials(user, code, displayName string) *OAuthCredentials {
if displayName == "" {
displayName = user
}
return &OAuthCredentials{
Source: "SPOTIFY",
DisplayName: displayName,
User: user,
Code: code,
Version: "token_version_3",
}
}
// NewAmazonOAuthCredentials creates OAuth credentials for Amazon Music (cs1 / "token").
// code is the AmazonSecret JSON envelope stored as the credential in Sources.xml.
func NewAmazonOAuthCredentials(user, code, displayName string) *OAuthCredentials {
if displayName == "" {
displayName = user
}
return &OAuthCredentials{
Source: "AMAZON",
DisplayName: displayName,
User: user,
Code: code,
Version: "token",
}
}
// MusicServiceAccountResponse represents the response from account management operations
type MusicServiceAccountResponse struct {
XMLName xml.Name `xml:"status"`
Status string `xml:",chardata"`
}
// SourcesUpdatedResponse represents the response from /notification
type SourcesUpdatedResponse struct {
XMLName xml.Name `xml:"status"`
Status string `xml:",chardata"`
}
// IsSuccess returns true if the account operation was successful
func (resp *MusicServiceAccountResponse) IsSuccess() bool {
return resp.Status == "/setMusicServiceAccount" || resp.Status == "/removeMusicServiceAccount"
return resp.Status == "/setMusicServiceAccount" || resp.Status == "/removeMusicServiceAccount" || resp.Status == "/notification"
}
+39
View File
@@ -36,6 +36,22 @@ type NetworkInfo struct {
IPAddress string `xml:"ipAddress"`
}
// SourcesUpdatedNotification represents the notification XML sent to the device
type SourcesUpdatedNotification struct {
XMLName xml.Name `xml:"updates"`
DeviceID string `xml:"deviceID,attr"`
Sources struct {
XMLName xml.Name `xml:"sourcesUpdated"`
} `xml:"sourcesUpdated"`
}
// NewSourcesUpdatedNotification creates a new sources updated notification
func NewSourcesUpdatedNotification(deviceID string) *SourcesUpdatedNotification {
return &SourcesUpdatedNotification{
DeviceID: deviceID,
}
}
// XMLResponse is a generic wrapper for API responses
type XMLResponse struct {
XMLName xml.Name
@@ -53,6 +69,29 @@ func (e *APIError) Error() string {
return e.Message
}
// ErrorsResponse represents a multi-error response from the API (common in some firmware versions)
type ErrorsResponse struct {
XMLName xml.Name `xml:"errors"`
DeviceID string `xml:"deviceID,attr"`
Errors []DeviceError `xml:"error"`
}
// Error implements the error interface for ErrorsResponse
func (e *ErrorsResponse) Error() string {
if len(e.Errors) > 0 {
return e.Errors[0].Message
}
return "unknown API error"
}
// DeviceError represents a single error in an ErrorsResponse
type DeviceError struct {
Value int `xml:"value,attr"`
Name string `xml:"name,attr"`
Message string `xml:",chardata"`
}
// DiscoveredDevice represents a device found through network discovery
type DiscoveredDevice struct {
Name string `json:"name"`
+25
View File
@@ -0,0 +1,25 @@
package models
import "encoding/xml"
// Group represents a stereo pair of two ST10 SoundTouch speakers.
type Group struct {
XMLName xml.Name `xml:"group"`
ID string `xml:"id,attr,omitempty"`
Name string `xml:"name"`
MasterDeviceID string `xml:"masterDeviceId"`
Roles GroupRoles `xml:"roles"`
SenderIPAddress string `xml:"senderIPAddress,omitempty"`
}
// GroupRoles contains the role assignments for devices in a group.
type GroupRoles struct {
Roles []GroupRole `xml:"groupRole"`
}
// GroupRole describes the role (LEFT or RIGHT) of a single device in a group.
type GroupRole struct {
DeviceID string `xml:"deviceId"`
Role string `xml:"role"`
IPAddress string `xml:"ipAddress,omitempty"`
}
+390 -159
View File
@@ -5,12 +5,20 @@ package models
import (
"encoding/xml"
"strconv"
"strings"
"time"
)
// Link represents a navigational link with URL and client usage preferences.
type Link struct {
Href string `json:"href" xml:"href,attr"`
UseInternalClient string `json:"useInternalClient,omitempty" xml:"useInternalClient,attr,omitempty"`
Href string `json:"href" xml:"href,attr"`
UseInternalClient string `json:"useInternalClient,omitempty" xml:"useInternalClient,attr,omitempty"`
ContainerArt string `json:"containerArt,omitempty" xml:"-"`
Filters interface{} `json:"filters,omitempty" xml:"-"`
Name string `json:"name,omitempty" xml:"-"`
Templated *bool `json:"templated,omitempty" xml:"-"`
Type string `json:"type,omitempty" xml:"-"`
}
// Links contains various navigation links used by BMX services.
@@ -25,6 +33,32 @@ type Links struct {
BmxFavorite *Link `json:"bmx_favorite,omitempty" xml:"bmx_favorite,omitempty"`
BmxNowPlaying *Link `json:"bmx_nowplaying,omitempty" xml:"bmx_nowplaying,omitempty"`
BmxTrack *Link `json:"bmx_track,omitempty" xml:"bmx_track,omitempty"`
BmxSearch *Link `json:"bmx_search,omitempty" xml:"-"`
BmxPlayback *Link `json:"bmx_playback,omitempty" xml:"-"`
BmxPreset *Link `json:"bmx_preset,omitempty" xml:"-"`
}
// BmxNavItem represents a single item in a TuneIn browse or search result.
type BmxNavItem struct {
Links *Links `json:"_links,omitempty"`
ImageUrl string `json:"imageUrl,omitempty"`
Name string `json:"name"`
Subtitle string `json:"subtitle"`
}
// BmxNavSection represents a group of navigation items with a layout hint.
type BmxNavSection struct {
Links *Links `json:"_links,omitempty"`
Items []BmxNavItem `json:"items"`
Layout string `json:"layout,omitempty"`
Name string `json:"name"`
}
// BmxNavResponse is the top-level response for TuneIn navigate and search endpoints.
type BmxNavResponse struct {
Links *Links `json:"_links,omitempty"`
BmxSections []BmxNavSection `json:"bmx_sections"`
Layout string `json:"layout"`
}
// IconSet represents a collection of icons with different sizes for media content.
@@ -135,51 +169,126 @@ type ServiceContentItem struct {
ID string `json:"id" xml:"id,attr"`
Name string `json:"name" xml:"name"`
Source string `json:"source,omitempty" xml:"source,attr,omitempty"`
Type string `json:"type" xml:"type,attr"`
ContentItemType string `json:"content_item_type" xml:"contentItemType"`
Type string `json:"type,omitempty" xml:"type,attr,omitempty"`
ContentItemType string `json:"content_item_type,omitempty" xml:"contentItemType,omitempty"`
Location string `json:"location,omitempty" xml:"location,attr,omitempty"`
SourceAccount string `json:"source_account" xml:"sourceAccount,attr"`
SourceID string `json:"source_id,omitempty" xml:"sourceid,omitempty"`
SourceAccount string `json:"source_account,omitempty" xml:"sourceAccount,attr,omitempty"`
SourceID string `json:"source_id,omitempty" xml:"sourceid"`
IsPresetable string `json:"is_presetable,omitempty" xml:"isPresetable,attr,omitempty"`
Username string `json:"username,omitempty" xml:"username,omitempty"`
ContainerArt string `json:"container_art,omitempty" xml:"containerArt,omitempty"`
}
// ServicePreset represents a user-defined preset for quick access to media content.
type ServicePreset struct {
ServiceContentItem
ID string `json:"id,omitempty" xml:"id,attr"`
ContainerArt string `json:"container_art" xml:"containerArt"`
CreatedOn string `json:"created_on" xml:"createdOn"`
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
ButtonNumber string `json:"button_number,omitempty" xml:"buttonNumber,attr,omitempty"`
Username string `json:"-" xml:"username,omitempty"`
SourceConfig *ConfiguredSource `json:"-" xml:"source,omitempty"`
ID string `json:"id,omitempty" xml:"id,attr,omitempty"`
ContainerArt string `json:"container_art" xml:"containerArt"`
CreatedOn string `json:"created_on" xml:"createdOn"`
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
ButtonNumber string `json:"button_number,omitempty" xml:"buttonNumber,attr,omitempty"`
Username string `json:"-" xml:"username,omitempty"`
}
// ServiceRecent represents recently played media content.
// MarshalXML implements the xml.Marshaler interface for ServicePreset to match upstream parity.
func (p ServicePreset) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
type Alias struct {
ButtonNumber string `xml:"buttonNumber,attr,omitempty"`
ContainerArt string `xml:"containerArt"`
ContentItemType string `xml:"contentItemType"`
CreatedOn string `xml:"createdOn"`
Location string `xml:"location"`
Name string `xml:"name"`
SourceID string `xml:"sourceid,omitempty"`
UpdatedOn string `xml:"updatedOn"`
Username string `xml:"username"`
}
createdOn := p.CreatedOn
if _, err := strconv.ParseInt(createdOn, 10, 64); err == nil {
if t, err := strconv.ParseInt(createdOn, 10, 64); err == nil {
createdOn = time.Unix(t, 0).UTC().Format("2006-01-02T15:04:05.000+00:00")
}
}
updatedOn := p.UpdatedOn
if _, err := strconv.ParseInt(updatedOn, 10, 64); err == nil {
if t, err := strconv.ParseInt(updatedOn, 10, 64); err == nil {
updatedOn = time.Unix(t, 0).UTC().Format("2006-01-02T15:04:05.000+00:00")
}
}
a := Alias{
ButtonNumber: p.ButtonNumber,
ContainerArt: p.ContainerArt,
ContentItemType: p.ContentItemType,
CreatedOn: createdOn,
Location: p.Location,
Name: p.Name,
SourceID: p.SourceID,
UpdatedOn: updatedOn,
Username: p.Username,
}
if a.Username == "" && a.Name != "" {
a.Username = a.Name
}
start.Name.Local = "preset"
// Remove all attributes because they are handled in Alias
start.Attr = nil
return e.EncodeElement(a, start)
}
// ServiceRecent represents recently played media content as stored in Recents.xml.
type ServiceRecent struct {
XMLName xml.Name `json:"-" xml:"recent"`
ServiceContentItem
DeviceID string `json:"device_id" xml:"deviceID,attr"`
UtcTime string `json:"utc_time" xml:"utcTime,attr"`
CreatedOn string `json:"created_on,omitempty" xml:"createdOn"`
UpdatedOn string `json:"updated_on,omitempty" xml:"updatedOn"`
ContainerArt string `json:"container_art,omitempty" xml:"containerArt,omitempty"`
SourceConfig *ConfiguredSource `json:"-" xml:"source,omitempty"`
LastPlayedAt string `json:"last_played_at,omitempty" xml:"lastplayedat"`
ContentItem *struct {
Source string `xml:"source,attr"`
Type string `xml:"type,attr"`
Location string `xml:"location,attr"`
SourceAccount string `xml:"sourceAccount,attr"`
IsPresetable string `xml:"isPresetable,attr"`
ItemName string `xml:"itemName"`
ContainerArt string `xml:"containerArt,omitempty"`
} `xml:"contentItem,omitempty"`
DeviceID string `json:"device_id" xml:"deviceID,attr,omitempty"`
UtcTime string `json:"utc_time" xml:"utcTime,attr,omitempty"`
CreatedOn string `json:"created_on,omitempty" xml:"createdOn,omitempty"`
UpdatedOn string `json:"updated_on,omitempty" xml:"updatedOn,omitempty"`
LastPlayedAt string `json:"last_played_at,omitempty" xml:"lastplayedat,omitempty"`
}
// UnmarshalXML implements the xml.Unmarshaler interface to handle both nested and flat formats.
// RecentItemParity represents recently played media content for web API responses (flat format).
type RecentItemParity struct {
XMLName xml.Name `xml:"recent"`
ID string `xml:"id,attr"`
ContentItemType string `xml:"contentItemType"`
CreatedOn string `xml:"createdOn"`
LastPlayedAt string `xml:"lastplayedat"`
Location string `xml:"location"`
Name string `xml:"name"`
Source *RecentItemParitySource `xml:"source,omitempty"`
SourceID string `xml:"sourceid"`
UpdatedOn string `xml:"updatedOn"`
}
// RecentItemParitySource represents the source in a RecentItemParity.
type RecentItemParitySource struct {
ID string `xml:"id,attr"`
Type string `xml:"type,attr"`
CreatedOn string `xml:"createdOn"`
Credential *RecentItemParityCredential `xml:"credential"`
Name string `xml:"name"`
SourceProviderID string `xml:"sourceproviderid"`
SourceName string `xml:"sourcename"`
SourceSettings string `xml:"sourceSettings"`
UpdatedOn string `xml:"updatedOn"`
Username string `xml:"username"`
}
// RecentItemParityCredential represents the credential in a RecentItemParitySource.
type RecentItemParityCredential struct {
Type string `xml:"type,attr"`
Value string `xml:",chardata"`
}
// UnmarshalXML implements the xml.Unmarshaler interface to handle both nested and flat formats for ServiceRecent.
func (r *ServiceRecent) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
type ContentItem struct {
type NestedContentItem struct {
Source string `xml:"source,attr"`
Type string `xml:"type,attr"`
Location string `xml:"location,attr"`
@@ -192,15 +301,28 @@ func (r *ServiceRecent) UnmarshalXML(d *xml.Decoder, start xml.StartElement) err
type Alias struct {
XMLName xml.Name `xml:"recent"`
ServiceContentItem
DeviceID string `xml:"deviceID,attr"`
UtcTime string `xml:"utcTime,attr"`
ID string `xml:"id,attr"`
CreatedOn string `xml:"createdOn,omitempty"`
UpdatedOn string `xml:"updatedOn,omitempty"`
ContainerArt string `xml:"containerArt,omitempty"`
SourceConfig *ConfiguredSource `xml:"source,omitempty"`
LastPlayedAt string `xml:"lastplayedat"`
ContentItem *ContentItem `xml:"contentItem,omitempty"`
DeviceID string `xml:"deviceID,attr"`
UtcTime string `xml:"utcTime,attr"`
ID string `xml:"id,attr"`
CreatedOn string `xml:"createdOn,omitempty"`
UpdatedOn string `xml:"updatedOn,omitempty"`
ContainerArt string `xml:"containerArt,omitempty"`
SourceConfig *ConfiguredSource `xml:"source,omitempty"`
LastPlayedAt string `xml:"lastplayedat"`
ContentItem *NestedContentItem `xml:"contentItem,omitempty"`
// Flat format might use these tags
FlatLocation string `xml:"location"`
FlatTypeTag string `xml:"type"`
AttrType string `xml:"type,attr"`
FlatSourceAccount string `xml:"sourceAccount"`
FlatIsPresetable string `xml:"isPresetable"`
FlatContentItemType string `xml:"contentItemType"`
AttrContentItemType string `xml:"contentItemType,attr"`
FlatName string `xml:"name"`
FlatSourceID string `xml:"sourceid"`
FlatSourceIDAttr string `xml:"sourceid,attr"`
FlatSource string `xml:"source_key"`
AttrSource string `xml:"source,attr"`
}
var a Alias
@@ -208,26 +330,21 @@ func (r *ServiceRecent) UnmarshalXML(d *xml.Decoder, start xml.StartElement) err
return err
}
r.ServiceContentItem = a.ServiceContentItem
r.DeviceID = a.DeviceID
r.UtcTime = a.UtcTime
r.ID = a.ID
r.SourceID = a.SourceID
if r.SourceID == "" {
r.SourceID = a.SourceID
}
r.CreatedOn = a.CreatedOn
r.UpdatedOn = a.UpdatedOn
r.ContainerArt = a.ContainerArt
r.SourceConfig = a.SourceConfig
r.LastPlayedAt = a.LastPlayedAt
// Ensure the embedded ServiceContentItem.ID is populated from the attribute
r.ID = a.ID
r.SourceID = a.FlatSourceID
// Prefer nested contentItem data if present
if a.ContentItem != nil {
r.Source = a.ContentItem.Source
r.Type = a.ContentItem.Type
r.ContentItemType = a.ContentItem.Type // Set ContentItemType from nested type
r.Location = a.ContentItem.Location
r.SourceAccount = a.ContentItem.SourceAccount
r.IsPresetable = a.ContentItem.IsPresetable
@@ -237,40 +354,60 @@ func (r *ServiceRecent) UnmarshalXML(d *xml.Decoder, start xml.StartElement) err
r.ContainerArt = a.ContentItem.ContainerArt
}
} else {
// Fallback for flat format: populate ContentItem fields from root fields
r.Source = a.Source
r.Type = a.Type
r.Location = a.Location
r.SourceAccount = a.SourceAccount
r.IsPresetable = a.IsPresetable
r.Name = a.Name
}
// Fallback to flat fields
if a.FlatLocation != "" {
r.Location = a.FlatLocation
}
// Always ensure the nested struct is populated for MarshalXML
r.ContentItem = &struct {
Source string `xml:"source,attr"`
Type string `xml:"type,attr"`
Location string `xml:"location,attr"`
SourceAccount string `xml:"sourceAccount,attr"`
IsPresetable string `xml:"isPresetable,attr"`
ItemName string `xml:"itemName"`
ContainerArt string `xml:"containerArt,omitempty"`
}{
Source: r.Source,
Type: r.Type,
Location: r.Location,
SourceAccount: r.SourceAccount,
IsPresetable: r.IsPresetable,
ItemName: r.Name,
ContainerArt: r.ContainerArt,
switch {
case a.FlatContentItemType != "":
r.ContentItemType = a.FlatContentItemType
case a.FlatTypeTag != "":
r.ContentItemType = a.FlatTypeTag
case a.AttrType != "":
r.ContentItemType = a.AttrType
}
switch {
case a.FlatTypeTag != "":
r.Type = a.FlatTypeTag
case a.AttrType != "":
r.Type = a.AttrType
}
switch {
case a.FlatSourceID != "":
r.SourceID = a.FlatSourceID
case a.FlatSourceIDAttr != "":
r.SourceID = a.FlatSourceIDAttr
}
switch {
case a.FlatSource != "":
r.Source = a.FlatSource
case a.AttrSource != "":
r.Source = a.AttrSource
}
if a.FlatName != "" {
r.Name = a.FlatName
}
if a.FlatSourceAccount != "" {
r.SourceAccount = a.FlatSourceAccount
}
if a.FlatIsPresetable != "" {
r.IsPresetable = a.FlatIsPresetable
}
}
return nil
}
// MarshalXML implements the xml.Marshaler interface for custom XML encoding of ServiceRecent.
// MarshalXML implements the xml.Marshaler interface for custom XML encoding of ServiceRecent (nested format).
func (r ServiceRecent) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
type ContentItem struct {
type NestedContentItem struct {
Source string `xml:"source,attr"`
Type string `xml:"type,attr"`
Location string `xml:"location,attr"`
@@ -281,24 +418,28 @@ func (r ServiceRecent) MarshalXML(e *xml.Encoder, start xml.StartElement) error
}
type Alias struct {
XMLName xml.Name `xml:"recent"`
DeviceID string `xml:"deviceID,attr"`
UtcTime string `xml:"utcTime,attr"`
ID string `xml:"id,attr"`
ContentItem ContentItem `xml:"contentItem"`
CreatedOn string `xml:"createdOn,omitempty"`
UpdatedOn string `xml:"updatedOn,omitempty"`
LastPlayedAt string `xml:"lastplayedat,omitempty"`
SourceID string `xml:"sourceid,omitempty"`
Source *ConfiguredSource `xml:"source,omitempty"`
XMLName xml.Name `xml:"recent"`
ID string `xml:"id,attr"`
DeviceID string `xml:"deviceID,attr,omitempty"`
UtcTime string `xml:"utcTime,attr,omitempty"`
ContentItem *NestedContentItem `xml:"contentItem"`
CreatedOn string `xml:"createdOn"`
UpdatedOn string `xml:"updatedOn"`
LastPlayedAt string `xml:"lastplayedat"`
SourceID string `xml:"sourceid"`
Username string `xml:"username"`
}
a := Alias{
DeviceID: r.DeviceID,
UtcTime: r.UtcTime,
ID: r.ID,
SourceID: r.SourceID,
ContentItem: ContentItem{
ID: r.ID,
DeviceID: r.DeviceID,
UtcTime: r.UtcTime,
CreatedOn: r.CreatedOn,
UpdatedOn: r.UpdatedOn,
LastPlayedAt: r.LastPlayedAt,
SourceID: r.SourceID,
Username: r.Name, // Using Name as Username for parity
ContentItem: &NestedContentItem{
Source: r.Source,
Type: r.Type,
Location: r.Location,
@@ -307,35 +448,6 @@ func (r ServiceRecent) MarshalXML(e *xml.Encoder, start xml.StartElement) error
ItemName: r.Name,
ContainerArt: r.ContainerArt,
},
CreatedOn: r.CreatedOn,
UpdatedOn: r.UpdatedOn,
LastPlayedAt: r.LastPlayedAt,
Source: r.SourceConfig,
}
if a.SourceID == "" && r.SourceID != "" {
a.SourceID = r.SourceID
}
if r.ContentItem != nil {
a.ContentItem.Source = r.ContentItem.Source
a.ContentItem.Type = r.ContentItem.Type
a.ContentItem.Location = r.ContentItem.Location
a.ContentItem.SourceAccount = r.ContentItem.SourceAccount
a.ContentItem.IsPresetable = r.ContentItem.IsPresetable
a.ContentItem.ItemName = r.ContentItem.ItemName
if r.ContentItem.ContainerArt != "" {
a.ContentItem.ContainerArt = r.ContentItem.ContainerArt
}
}
if a.Source == nil && r.SourceConfig != nil {
a.Source = r.SourceConfig
}
if a.ContentItem.IsPresetable == "" {
a.ContentItem.IsPresetable = "true"
}
start.Name.Local = "recent"
@@ -348,22 +460,26 @@ type ConfiguredSource struct {
XMLName xml.Name `json:"-" xml:"source"`
DisplayName string `json:"display_name" xml:"displayName,attr,omitempty"`
ID string `json:"id" xml:"id,attr,omitempty"`
Secret string `json:"secret" xml:"secret,attr"`
SecretType string `json:"secret_type" xml:"secretType,attr"`
SourceKey struct {
Secret string `json:"secret" xml:"secret,attr,omitempty"`
SecretType string `json:"secret_type" xml:"secretType,attr,omitempty"`
Credential struct {
Type string `xml:"type,attr"`
Value string `xml:",chardata"`
} `json:"-" xml:"credential"`
SourceKey struct {
Type string `xml:"type,attr"`
Account string `xml:"account,attr"`
} `json:"source_key" xml:"sourceKey"`
Type string `xml:"type,attr,omitempty"`
// Parity fields
CreatedOn string `json:"created_on,omitempty" xml:"createdOn,attr,omitempty"`
UpdatedOn string `json:"updated_on,omitempty" xml:"updatedOn,attr,omitempty"`
SourceProviderID string `json:"sourceproviderid,omitempty" xml:"sourceproviderid,attr,omitempty"`
Username string `json:"username,omitempty" xml:"-"`
SourceName string `json:"source_name,omitempty" xml:"-"`
Name string `json:"name,omitempty" xml:"-"`
SourceSettings string `json:"-" xml:"-"`
CreatedOn string `json:"created_on,omitempty" xml:"createdOn,omitempty"`
UpdatedOn string `json:"updated_on,omitempty" xml:"updatedOn,omitempty"`
SourceProviderID string `json:"sourceproviderid,omitempty" xml:"sourceproviderid,omitempty"`
Username string `json:"username,omitempty" xml:"username,omitempty"`
SourceName string `json:"source_name,omitempty" xml:"sourcename,omitempty"`
Name string `json:"name,omitempty" xml:"name,omitempty"`
SourceSettings string `json:"-" xml:"sourceSettings,omitempty"`
Status string `json:"status,omitempty" xml:"-"`
// Legacy fields for backward compatibility in code if needed,
@@ -372,37 +488,95 @@ type ConfiguredSource struct {
SourceKeyAccount string `json:"source_key_account" xml:"-"`
}
// MarshalXML implements the xml.Marshaler interface for custom XML encoding of ConfiguredSource.
func (s ConfiguredSource) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
type Alias struct {
DisplayName string `xml:"displayName,attr,omitempty"`
Secret string `xml:"secret,attr"`
SecretType string `xml:"secretType,attr"`
ID string `xml:"id,attr,omitempty"`
Type string `xml:"type,attr,omitempty"`
CreatedOn string `xml:"createdOn,attr,omitempty"`
UpdatedOn string `xml:"updatedOn,attr,omitempty"`
SourceProviderID string `xml:"sourceproviderid,attr,omitempty"`
SourceKey struct {
Type string `xml:"type,attr"`
Account string `xml:"account,attr"`
} `xml:"sourceKey"`
type sourceCredential struct {
Type string `xml:"type,attr"`
Value string `xml:",chardata"`
}
type sourceAlias struct {
XMLName xml.Name `xml:"source"`
DisplayName string `xml:"displayName,attr,omitempty"`
ID string `xml:"id,attr,omitempty"`
Type string `xml:"type,attr,omitempty"`
CreatedOn string `xml:"createdOn,omitempty"`
Credential *sourceCredential `xml:"credential,omitempty"`
Name string `xml:"name"`
SourceProviderID string `xml:"sourceproviderid,omitempty"`
SourceName string `xml:"sourcename"`
SourceSettings string `xml:"sourceSettings"`
UpdatedOn string `xml:"updatedOn,omitempty"`
Username string `xml:"username"`
}
func (s ConfiguredSource) getFirstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
a := Alias{
return ""
}
// MarshalXML implements the xml.Marshaler interface for custom XML encoding of ConfiguredSource.
func (s ConfiguredSource) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
a := sourceAlias{
XMLName: xml.Name{Local: start.Name.Local},
DisplayName: s.DisplayName,
Secret: s.Secret,
SecretType: s.SecretType,
ID: s.ID,
Type: s.Type,
CreatedOn: s.CreatedOn,
UpdatedOn: s.UpdatedOn,
Name: s.Name,
SourceProviderID: s.SourceProviderID,
SourceName: s.SourceName,
SourceSettings: s.SourceSettings,
UpdatedOn: s.UpdatedOn,
Username: s.Username,
}
// Bose XML for sources usually does NOT include displayName attribute
// except for when it's explicitly stored in our datastore as such.
// For parity with official responses, we omit it if ID is present or for standard sources.
if s.ID != "" || s.SourceKeyType != "" || s.Type != "" {
a.DisplayName = ""
}
a.Name = s.Name
a.SourceName = s.SourceName
a.Username = s.Username
// Parity: for TuneIn and some others, sourcename, name and username should NOT automatically fall back to displayName
// if they are intended to be empty. However, if they are ALL empty, we need some value.
isTuneIn := strings.EqualFold(s.DisplayName, "TUNEIN") || strings.EqualFold(s.SourceKeyType, "TUNEIN") || strings.EqualFold(s.ID, "TUNEIN")
if a.Name == "" {
a.Name = s.getFirstNonEmpty(s.Name, s.SourceName, s.Username, s.DisplayName)
}
if a.SourceName == "" && !isTuneIn {
a.SourceName = s.getFirstNonEmpty(s.SourceName, s.Name, s.Username, s.DisplayName)
}
if a.Username == "" && !isTuneIn {
a.Username = s.getFirstNonEmpty(s.Username, s.Name, s.SourceName, s.DisplayName)
}
if s.Secret != "" || s.SecretType != "" {
a.Credential = &sourceCredential{
Type: s.SecretType,
Value: s.Secret,
}
} else if s.Credential.Value != "" || s.Credential.Type != "" {
a.Credential = &sourceCredential{
Type: s.Credential.Type,
Value: s.Credential.Value,
}
}
if a.SourceSettings == "" {
a.SourceSettings = ""
}
a.SourceKey.Type = s.SourceKey.Type
a.SourceKey.Account = s.SourceKey.Account
start.Name.Local = "source"
// Important: Clear automatically generated attributes from the start element
// because we are using Alias to control attribute order and presence.
start.Attr = nil
@@ -431,7 +605,7 @@ type ServiceComponent struct {
Category string `json:"category,omitempty" xml:"category,attr,omitempty"`
SoftwareVersion string `json:"firmware_version" xml:"firmware-version"`
SerialNumber string `json:"serial_number" xml:"serialnumber"`
Label string `json:"label,omitempty" xml:"componentlabel,omitempty"`
Label string `json:"label,omitempty" xml:"componentlabel"`
}
// ServiceAccountInfo represents account-level metadata.
@@ -567,7 +741,7 @@ type EmailAddressResponse struct {
type FullResponseSource struct {
ID string `json:"id" xml:"id,attr"`
Type string `json:"type" xml:"type,attr"`
DisplayName string `json:"display_name,omitempty" xml:"displayName,attr,omitempty"`
DisplayName string `json:"display_name" xml:"displayName,attr,omitempty"`
CreatedOn string `json:"created_on" xml:"createdOn"`
Credential struct {
Type string `json:"type" xml:"type,attr"`
@@ -581,6 +755,7 @@ type FullResponseSource struct {
Username string `json:"username" xml:"username"`
Account string `json:"account,omitempty" xml:"account,attr,omitempty"`
SourceLabel string `json:"source_label" xml:"-"`
ProviderLabel string `json:"provider_label,omitempty" xml:"-"`
}
// FullResponsePreset represents a preset specifically for the /full response.
@@ -607,6 +782,7 @@ type FullResponseRecent struct {
Source FullResponseSource `json:"source" xml:"source"`
SourceID string `json:"source_id" xml:"sourceid"`
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
Username string `json:"username" xml:"username"`
}
// AccountFullResponse represents the complete account XML structure.
@@ -621,6 +797,30 @@ type AccountFullResponse struct {
Sources []FullResponseSource `xml:"sources>source"`
}
// AccountSourcesResponse represents the response from /streaming/account/{accountId}/sources.
type AccountSourcesResponse struct {
XMLName xml.Name `xml:"sources"`
Sources []FullResponseSource `xml:"source"`
}
// AccountDevicesResponse represents the response from /streaming/account/{accountId}/devices.
type AccountDevicesResponse struct {
XMLName xml.Name `xml:"devices"`
Devices []MargeAccountDevice `xml:"device"`
ProviderSettings []ProviderSetting `xml:"providerSettings>providerSetting"`
}
// MargeAccountDevice represents a device specifically for the /devices response.
// It matches the structure in 06_orig.xml, which is a subset of AccountDevice.
type MargeAccountDevice struct {
DeviceID string `json:"device_id" xml:"deviceid,attr"`
AttachedProduct *AttachedProduct `json:"attached_product" xml:"attachedProduct"`
CreatedOn string `json:"created_on" xml:"createdOn"`
IPAddress string `json:"ip_address" xml:"ipaddress"`
Name string `json:"name" xml:"name"`
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
}
// AccountDevice represents a device in the account response.
type AccountDevice struct {
DeviceID string `json:"device_id" xml:"deviceid,attr"`
@@ -629,10 +829,10 @@ type AccountDevice struct {
FirmwareVersion string `json:"firmware_version" xml:"firmwareVersion"`
IPAddress string `json:"ip_address" xml:"ipaddress"`
Name string `json:"name" xml:"name"`
Presets []FullResponsePreset `json:"presets" xml:"presets>preset"`
Presets []FullResponsePreset `json:"presets" xml:"presets>preset,omitempty"`
ProductCode string `json:"product_code" xml:"-"`
Recents []FullResponseRecent `json:"recents" xml:"recents>recent"`
SerialNumber string `json:"serial_number" xml:"serialNumber"`
Recents []FullResponseRecent `json:"recents" xml:"recents>recent,omitempty"`
SerialNumber string `json:"serial_number" xml:"serialNumber,omitempty"`
DeviceSerialNumber string `json:"device_serial_number,omitempty" xml:"-"`
MacAddress string `json:"mac_address,omitempty" xml:"-"`
DiscoveryMethod string `json:"discovery_method,omitempty" xml:"-"`
@@ -642,7 +842,7 @@ type AccountDevice struct {
// AttachedProduct represents product information for a device.
type AttachedProduct struct {
ProductCode string `json:"product_code" xml:"product_code,attr"`
Components []ServiceComponent `json:"components" xml:"components>component"`
Components []ServiceComponent `json:"components" xml:"components>component,omitempty"`
ProductLabel string `json:"product_label" xml:"productlabel"`
SerialNumber string `json:"serial_number" xml:"serialnumber"`
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
@@ -675,3 +875,34 @@ type MargeAccountCreateRequest struct {
CountryCode string `xml:"countryCode"`
PreferredLanguage string `xml:"preferredLanguage"`
}
// MargeAddSourceResponse represents the response after adding a source to Marge.
type MargeAddSourceResponse struct {
XMLName xml.Name `xml:"source"`
SourceID string `xml:"sourceID"`
SourceProviderID string `xml:"sourceProviderID"`
CreatedOn string `xml:"createdOn"`
UpdatedOn string `xml:"updatedOn"`
}
// EligibilityResponse represents the XML response for music provider eligibility.
type EligibilityResponse struct {
XMLName xml.Name `xml:"eligibility"`
IsEligible bool `xml:"isEligible"`
}
// MargeAPIVersionsResponse represents the XML response for Marge API versions.
type MargeAPIVersionsResponse struct {
XMLName xml.Name `xml:"marge"`
Version string `xml:"version,attr"`
Project string `xml:"project,attr"`
Apis []MargeAPI `xml:"apis>api"`
Dependencies string `xml:"dependencies"`
}
// MargeAPI represents a single API entry in MargeAPIVersionsResponse.
type MargeAPI struct {
Type string `xml:"type,attr"`
XML string `xml:"xml"`
JSON string `xml:"json"`
}
+190
View File
@@ -0,0 +1,190 @@
package models
import (
"encoding/xml"
"testing"
)
func TestServiceRecent_Parity(t *testing.T) {
t.Run("Unmarshal local response (nested contentItem)", func(t *testing.T) {
localXML := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<recent deviceID="" utcTime="1774176828" id="2568595253">
<contentItem source="Audio" type="" location="/playback/container/c3BvdGlmeTphbGJ1bTo2clQ4eWVyODR4b2gwdDE3cG9Mc21u" sourceAccount="user-name" isPresetable="true">
<itemName>Coco, Pt. 1</itemName>
</contentItem>
<createdOn>2026-03-14T22:39:17.000+00:00</createdOn>
<updatedOn>2026-03-14T22:39:17.000+00:00</updatedOn>
<lastplayedat>2026-03-22T10:53:48.000+00:00</lastplayedat>
<sourceid>10863533</sourceid>
<source displayName="user-name" secret="TOKEN" secretType="token_version_3" id="10863533" type="Audio" createdOn="2016-01-06T08:52:04.000+00:00" updatedOn="2020-04-25T20:29:11.000+00:00" sourceproviderid="15">
<sourceKey type="Audio" account="user-name"></sourceKey>
</source>
</recent>`
var recent ServiceRecent
err := xml.Unmarshal([]byte(localXML), &recent)
if err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if recent.ID != "2568595253" {
t.Errorf("Expected ID 2568595253, got %s", recent.ID)
}
if recent.Name != "Coco, Pt. 1" {
t.Errorf("Expected Name 'Coco, Pt. 1', got %s", recent.Name)
}
if recent.SourceID != "10863533" {
t.Errorf("Expected SourceID 10863533, got %s", recent.SourceID)
}
})
t.Run("Unmarshal upstream response (flat contentItem)", func(t *testing.T) {
upstreamXML := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<recent id="2569047180">
<contentItemType>tracklisturl</contentItemType>
<createdOn>2026-03-22T10:00:04.000+00:00</createdOn>
<lastplayedat>2026-03-22T10:53:48.000+00:00</lastplayedat>
<location>/playback/container/c3BvdGlmeTphbGJ1bTowMUpRS3RjQ1hIZGppVHpHRFk3NXhP</location>
<name>Dopamine</name>
<source id="10863533" type="Audio">
<createdOn>2016-01-06T08:52:04.000+00:00</createdOn>
<credential type="token_version_3">TOKEN</credential>
<name>user-name</name>
<sourceproviderid>15</sourceproviderid>
<sourcename>user-name@mail.internal</sourcename>
<sourceSettings/>
<updatedOn>2020-04-25T20:29:11.000+00:00</updatedOn>
<username>user-name</username>
</source>
<sourceid>10863533</sourceid>
<updatedOn>2026-03-22T10:53:50.719+00:00</updatedOn>
</recent>`
var recent ServiceRecent
err := xml.Unmarshal([]byte(upstreamXML), &recent)
if err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if recent.ID != "2569047180" {
t.Errorf("Expected ID 2569047180, got %s", recent.ID)
}
if recent.Name != "Dopamine" {
t.Errorf("Expected Name 'Dopamine', got %s", recent.Name)
}
if recent.ContentItemType != "tracklisturl" {
t.Errorf("Expected ContentItemType 'tracklisturl', got %s", recent.ContentItemType)
}
if recent.Location != "/playback/container/c3BvdGlmeTphbGJ1bTowMUpRS3RjQ1hIZGppVHpHRFk3NXhP" {
t.Errorf("Expected Location '/playback/container/c3BvdGlmeTphbGJ1bTowMUpRS3RjQ1hIZGppVHpHRFk3NXhP', got %s", recent.Location)
}
if recent.SourceID != "10863533" {
t.Errorf("Expected SourceID 10863533, got %s", recent.SourceID)
}
})
t.Run("Marshal ServiceRecent should follow local style (nested)", func(t *testing.T) {
recent := ServiceRecent{
ServiceContentItem: ServiceContentItem{
ID: "2569047180",
Name: "Dopamine",
ContentItemType: "tracklisturl",
Location: "/playback/container/c3BvdGlmeTphbGJ1bTowMUpRS3RjQ1hIZGppVHpHRFk3NXhP",
SourceID: "10863533",
Source: "SPOTIFY",
Type: "tracklisturl",
SourceAccount: "user-name",
IsPresetable: "true",
},
CreatedOn: "2026-03-22T10:00:04.000+00:00",
UpdatedOn: "2026-03-22T10:53:50.719+00:00",
LastPlayedAt: "2026-03-22T10:53:48.000+00:00",
}
data, err := xml.MarshalIndent(recent, "", " ")
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
xmlStr := string(data)
if !contains_substr(xmlStr, "<contentItem ") || !contains_substr(xmlStr, "<itemName>Dopamine</itemName>") {
t.Errorf("Marshaled ServiceRecent missing nested <contentItem> element\nGot: %s", xmlStr)
}
})
t.Run("Marshal RecentItemParity should follow upstream style (flat)", func(t *testing.T) {
recent := RecentItemParity{
ID: "2569047180",
Name: "Dopamine",
ContentItemType: "tracklisturl",
Location: "/playback/container/c3BvdGlmeTphbGJ1bTowMUpRS3RjQ1hIZGppVHpHRFk3NXhP",
SourceID: "10863533",
CreatedOn: "2026-03-22T10:00:04.000+00:00",
UpdatedOn: "2026-03-22T10:53:50.719+00:00",
LastPlayedAt: "2026-03-22T10:53:48.000+00:00",
Source: &RecentItemParitySource{
ID: "10863533",
Type: "Audio",
Credential: &RecentItemParityCredential{
Type: "token",
Value: "",
},
},
}
data, err := xml.MarshalIndent(recent, "", " ")
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
xmlStr := string(data)
expectedElements := []string{
`<recent id="2569047180">`,
`<contentItemType>tracklisturl</contentItemType>`,
`<createdOn>2026-03-22T10:00:04.000+00:00</createdOn>`,
`<lastplayedat>2026-03-22T10:53:48.000+00:00</lastplayedat>`,
`<location>/playback/container/c3BvdGlmeTphbGJ1bTowMUpRS3RjQ1hIZGppVHpHRFk3NXhP</location>`,
`<name>Dopamine</name>`,
`<sourceid>10863533</sourceid>`,
`<updatedOn>2026-03-22T10:53:50.719+00:00</updatedOn>`,
`<credential type="token"></credential>`,
}
for _, expected := range expectedElements {
if !contains_substr(xmlStr, expected) {
t.Errorf("Marshaled XML missing expected element: %s\nGot: %s", expected, xmlStr)
}
}
// It should NOT have nested contentItem
if contains_substr(xmlStr, "<contentItem ") || contains_substr(xmlStr, "<contentItem>") {
t.Errorf("Marshaled RecentItemParity should not have nested <contentItem> element\nGot: %s", xmlStr)
}
})
t.Run("Round-trip: Nested XML -> ServiceRecent -> Unmarshal -> Marshal -> Nested XML", func(t *testing.T) {
nestedXML := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<recent deviceID="DEVICE_ID" utcTime="1774176828" id="2568595253">
<contentItem source="Audio" type="TRACK" location="/playback/container/c3BvdGlmeTphbGJ1bTo2clQ4eWVyODR4b2gwdDE3cG9Mc21u" sourceAccount="user-name" isPresetable="true">
<itemName>Coco, Pt. 1</itemName>
</contentItem>
</recent>`
var recent1 ServiceRecent
if err := xml.Unmarshal([]byte(nestedXML), &recent1); err != nil {
t.Fatalf("Unmarshal nested failed: %v", err)
}
// Marshal it (should produce nested XML again)
nestedData, err := xml.MarshalIndent(recent1, "", " ")
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
xmlStr := string(nestedData)
if !contains_substr(xmlStr, "<contentItem ") || !contains_substr(xmlStr, "<itemName>Coco, Pt. 1</itemName>") {
t.Errorf("Round-trip failed to maintain nested structure\nGot: %s", xmlStr)
}
})
}
func contains_substr(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || (len(substr) > 0 && (s[:len(substr)] == substr || contains_substr(s[1:], substr))))
}
+456
View File
@@ -0,0 +1,456 @@
// Package amazon provides Amazon Music (Login with Amazon) OAuth integration
// and token management for the SoundTouch service.
package amazon
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const (
// AmazonAuthorizeURL is the Login with Amazon (LWA) authorization endpoint.
AmazonAuthorizeURL = "https://www.amazon.com/ap/oa"
// AmazonTokenURL is the LWA token endpoint.
AmazonTokenURL = "https://api.amazon.com/auth/o2/token"
// AmazonProfileURL is the LWA user profile endpoint.
AmazonProfileURL = "https://api.amazon.com/user/profile"
// AmazonScopes are the OAuth scopes for account linking.
// amazon_music:access is required for music-api.amazon.com but is only available
// to device client IDs (Amazon Music partner apps), not standard application
// client IDs (amzn1.application-oa2-client.*). Requesting it returns a 400
// lwa-invalid-parameter-bad-scope error from the LWA authorization endpoint.
AmazonScopes = "profile"
)
// Account represents a stored Amazon account with tokens.
type Account struct {
UserID string `json:"user_id"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresAt int64 `json:"expires_at"`
BoseSecret string `json:"bose_secret,omitempty"`
// SiteID is written into the AmazonSecret credential envelope. Its origin is
// unconfirmed (may be a static Bose partner ID or a per-user Music API value).
SiteID string `json:"site_id,omitempty"`
}
// Service manages Amazon OAuth flow and token lifecycle.
type Service struct {
clientID string
clientSecret string
redirectURI string
dataDir string
mu sync.RWMutex
accounts map[string]*Account
// Overridable URLs for testing
tokenURL string
profileURL string
}
// NewAmazonService creates a new Service and loads any persisted accounts.
func NewAmazonService(clientID, clientSecret, redirectURI, dataDir string) *Service {
return &Service{
clientID: clientID,
clientSecret: clientSecret,
redirectURI: redirectURI,
dataDir: dataDir,
accounts: make(map[string]*Account),
tokenURL: AmazonTokenURL,
profileURL: AmazonProfileURL,
}
}
// Load loads persisted accounts from disk.
func (s *Service) Load() error {
return s.load()
}
// SetEndpoints allows overriding default Amazon API endpoints (for testing).
func (s *Service) SetEndpoints(tokenURL, profileURL string) {
s.mu.Lock()
defer s.mu.Unlock()
s.tokenURL = tokenURL
s.profileURL = profileURL
}
// BuildAuthorizeURL constructs the LWA OAuth authorization URL.
func (s *Service) BuildAuthorizeURL(state string) string {
params := url.Values{
"client_id": {s.clientID},
"response_type": {"code"},
"redirect_uri": {s.redirectURI},
"scope": {AmazonScopes},
}
if state != "" {
params.Set("state", state)
}
return AmazonAuthorizeURL + "?" + params.Encode()
}
// ExchangeCodeAndStore exchanges an authorization code for tokens,
// fetches the user profile, and stores the account.
func (s *Service) ExchangeCodeAndStore(code string) error {
tokenResp, err := s.exchangeCode(code)
if err != nil {
return fmt.Errorf("token exchange: %w", err)
}
accessToken, _ := tokenResp["access_token"].(string)
refreshToken, _ := tokenResp["refresh_token"].(string)
expiresIn, _ := tokenResp["expires_in"].(float64)
if expiresIn == 0 {
expiresIn = 3600
}
profile, err := s.getUserProfile(accessToken)
if err != nil {
return fmt.Errorf("fetch profile: %w", err)
}
// LWA profile uses "user_id" and "name" (not "id" and "display_name" like Spotify).
userID, _ := profile["user_id"].(string)
displayName, _ := profile["name"].(string)
email, _ := profile["email"].(string)
boseSecret := s.generateBoseSecret()
account := &Account{
UserID: userID,
DisplayName: displayName,
Email: email,
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresAt: time.Now().Unix() + int64(expiresIn),
BoseSecret: boseSecret,
}
s.mu.Lock()
s.accounts[userID] = account
s.mu.Unlock()
if err := s.save(); err != nil {
return fmt.Errorf("save accounts: %w", err)
}
log.Printf("[Amazon] Account linked: %s (%s)", displayName, userID)
return nil
}
// exchangeCode exchanges an authorization code for tokens.
// Amazon LWA requires client_id and client_secret as POST body fields,
// not as HTTP Basic Auth (unlike Spotify).
func (s *Service) exchangeCode(code string) (map[string]interface{}, error) {
data := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {s.redirectURI},
"client_id": {s.clientID},
"client_secret": {s.clientSecret},
}
req, err := http.NewRequest(http.MethodPost, s.tokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("token request: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("token exchange failed (%d): %s", resp.StatusCode, string(body))
}
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
return result, nil
}
func (s *Service) getUserProfile(accessToken string) (map[string]interface{}, error) {
req, err := http.NewRequest(http.MethodGet, s.profileURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("profile request: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("profile fetch failed (%d): %s", resp.StatusCode, string(body))
}
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parse profile: %w", err)
}
return result, nil
}
// RefreshAccessToken refreshes the access token for the given account.
// Amazon LWA requires client credentials as POST body fields.
func (s *Service) RefreshAccessToken(account *Account) error {
data := url.Values{
"grant_type": {"refresh_token"},
"refresh_token": {account.RefreshToken},
"client_id": {s.clientID},
"client_secret": {s.clientSecret},
}
req, err := http.NewRequest(http.MethodPost, s.tokenURL, strings.NewReader(data.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("refresh request: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("token refresh failed (%d): %s", resp.StatusCode, string(body))
}
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return fmt.Errorf("parse response: %w", err)
}
s.mu.Lock()
account.AccessToken, _ = result["access_token"].(string)
expiresIn, _ := result["expires_in"].(float64)
if expiresIn == 0 {
expiresIn = 3600
}
account.ExpiresAt = time.Now().Unix() + int64(expiresIn)
if newRefresh, ok := result["refresh_token"].(string); ok && newRefresh != "" {
account.RefreshToken = newRefresh
}
s.mu.Unlock()
if err := s.save(); err != nil {
return fmt.Errorf("save accounts: %w", err)
}
return nil
}
// GetFreshToken returns a valid access token and username, refreshing if needed.
func (s *Service) GetFreshToken() (accessToken, username string, err error) {
s.mu.RLock()
if len(s.accounts) == 0 {
s.mu.RUnlock()
return "", "", fmt.Errorf("no Amazon accounts linked")
}
var account *Account
for _, a := range s.accounts {
account = a
break
}
s.mu.RUnlock()
// Check if token needs refresh (expired or within 60s of expiry)
if account.ExpiresAt < time.Now().Unix()+60 {
if err := s.RefreshAccessToken(account); err != nil {
return "", "", fmt.Errorf("refresh token: %w", err)
}
}
s.mu.RLock()
defer s.mu.RUnlock()
return account.AccessToken, account.UserID, nil
}
// GetAccounts returns a copy of all accounts with tokens stripped for API responses.
func (s *Service) GetAccounts() []Account {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]Account, 0, len(s.accounts))
for _, a := range s.accounts {
result = append(result, Account{
UserID: a.UserID,
DisplayName: a.DisplayName,
Email: a.Email,
ExpiresAt: a.ExpiresAt,
BoseSecret: a.BoseSecret,
// AccessToken and RefreshToken deliberately omitted
})
}
return result
}
// GetAccountBySecret retrieves an Amazon account by its Bose surrogate secret.
func (s *Service) GetAccountBySecret(secret string) (*Account, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, a := range s.accounts {
if a.BoseSecret == secret {
return a, true
}
}
return nil, false
}
// GetAllAccounts returns all accounts including tokens. Used internally by
// bridgeAmazonToMarge to build the AmazonSecret credential envelope.
func (s *Service) GetAllAccounts() []*Account {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]*Account, 0, len(s.accounts))
for _, a := range s.accounts {
result = append(result, a)
}
return result
}
// GetAccountByRefreshToken retrieves an Amazon account by its current refresh token.
// Used by the token handler because the speaker sends back the actual LWA refresh token
// (extracted from the AmazonSecret JSON in Sources.xml), not a surrogate.
func (s *Service) GetAccountByRefreshToken(refreshToken string) (*Account, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, a := range s.accounts {
if a.RefreshToken == refreshToken {
return a, true
}
}
return nil, false
}
func (s *Service) generateBoseSecret() string {
prefix := "ba-"
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("%s%d", prefix, time.Now().UnixNano())
}
return prefix + hex.EncodeToString(b)
}
// save persists accounts to disk as JSON.
func (s *Service) save() error {
s.mu.RLock()
data := make(map[string]*Account, len(s.accounts))
for k, v := range s.accounts {
data[k] = v
}
s.mu.RUnlock()
dir := filepath.Join(s.dataDir, "amazon")
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create directory: %w", err)
}
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("marshal accounts: %w", err)
}
path := filepath.Join(dir, "accounts.json")
if err := os.WriteFile(path, jsonData, 0600); err != nil {
return fmt.Errorf("write file: %w", err)
}
return nil
}
// load reads persisted accounts from disk.
func (s *Service) load() error {
path := filepath.Join(s.dataDir, "amazon", "accounts.json")
jsonData, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil // No accounts file yet, not an error
}
return fmt.Errorf("read file: %w", err)
}
var accounts map[string]*Account
if err := json.Unmarshal(jsonData, &accounts); err != nil {
return fmt.Errorf("unmarshal accounts: %w", err)
}
s.mu.Lock()
s.accounts = accounts
s.mu.Unlock()
log.Printf("[Amazon] Loaded %d account(s)", len(accounts))
return nil
}
+366
View File
@@ -0,0 +1,366 @@
package amazon
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestBuildAuthorizeURL(t *testing.T) {
svc := NewAmazonService("test-client-id", "test-secret", "ueberboese-login://amazon", t.TempDir())
state := "test-state"
gotURL := svc.BuildAuthorizeURL(state)
if !strings.Contains(gotURL, "client_id=test-client-id") {
t.Errorf("URL should contain client_id, got: %s", gotURL)
}
if !strings.Contains(gotURL, "redirect_uri=") {
t.Errorf("URL should contain redirect_uri, got: %s", gotURL)
}
if !strings.Contains(gotURL, "scope=") {
t.Errorf("URL should contain scope, got: %s", gotURL)
}
if !strings.Contains(gotURL, "response_type=code") {
t.Errorf("URL should contain response_type=code, got: %s", gotURL)
}
if !strings.Contains(gotURL, "state=test-state") {
t.Errorf("URL should contain state=test-state, got: %s", gotURL)
}
if !strings.HasPrefix(gotURL, AmazonAuthorizeURL) {
t.Errorf("URL should start with %s, got: %s", AmazonAuthorizeURL, gotURL)
}
}
func TestGetAccountsStripsTokens(t *testing.T) {
svc := NewAmazonService("cid", "csecret", "ueberboese-login://amazon", t.TempDir())
svc.mu.Lock()
svc.accounts["amzn1.account.EXAMPLE"] = &Account{
UserID: "amzn1.account.EXAMPLE",
DisplayName: "Test User",
Email: "test@example.com",
AccessToken: "secret-access-token",
RefreshToken: "secret-refresh-token",
ExpiresAt: time.Now().Add(1 * time.Hour).Unix(),
}
svc.mu.Unlock()
accounts := svc.GetAccounts()
if len(accounts) != 1 {
t.Fatalf("expected 1 account, got %d", len(accounts))
}
if accounts[0].AccessToken != "" {
t.Errorf("AccessToken should be stripped, got: %s", accounts[0].AccessToken)
}
if accounts[0].RefreshToken != "" {
t.Errorf("RefreshToken should be stripped, got: %s", accounts[0].RefreshToken)
}
if accounts[0].UserID != "amzn1.account.EXAMPLE" {
t.Errorf("UserID should be preserved, got: %s", accounts[0].UserID)
}
if accounts[0].DisplayName != "Test User" {
t.Errorf("DisplayName should be preserved, got: %s", accounts[0].DisplayName)
}
}
func TestExchangeCodeAndStore(t *testing.T) {
// Mock token endpoint — Amazon uses POST body credentials, not Basic Auth.
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
t.Fatal(err)
}
switch r.Form.Get("grant_type") {
case "authorization_code":
if r.Form.Get("code") != "test-auth-code" {
t.Errorf("expected code=test-auth-code, got %s", r.Form.Get("code"))
}
// Amazon uses POST body credentials, not HTTP Basic Auth.
if r.Form.Get("client_id") != "cid" {
t.Errorf("expected client_id=cid in POST body, got %q", r.Form.Get("client_id"))
}
if r.Form.Get("client_secret") != "csecret" {
t.Errorf("expected client_secret=csecret in POST body, got %q", r.Form.Get("client_secret"))
}
_, _, hasBasicAuth := r.BasicAuth()
if hasBasicAuth {
t.Error("Amazon token endpoint must NOT use HTTP Basic Auth")
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"access_token": "new-at",
"refresh_token": "new-rt",
"expires_in": 3600,
})
default:
t.Errorf("unexpected grant_type: %s", r.Form.Get("grant_type"))
http.Error(w, "bad request", 400)
}
}))
defer tokenServer.Close()
// Mock profile endpoint — LWA returns "user_id" and "name" (not "id" / "display_name").
profileServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if auth != "Bearer new-at" {
t.Errorf("expected Bearer new-at, got %s", auth)
}
json.NewEncoder(w).Encode(map[string]interface{}{
"user_id": "amzn1.account.TESTUSER123",
"name": "Amazon User",
"email": "user@amazon.com",
})
}))
defer profileServer.Close()
dir := t.TempDir()
svc := NewAmazonService("cid", "csecret", "ueberboese-login://amazon", dir)
svc.SetEndpoints(tokenServer.URL, profileServer.URL)
err := svc.ExchangeCodeAndStore("test-auth-code")
if err != nil {
t.Fatalf("ExchangeCodeAndStore failed: %v", err)
}
svc.mu.RLock()
account, ok := svc.accounts["amzn1.account.TESTUSER123"]
svc.mu.RUnlock()
if !ok {
t.Fatal("account not found after exchange")
}
if account.DisplayName != "Amazon User" {
t.Errorf("expected Amazon User, got %s", account.DisplayName)
}
if account.Email != "user@amazon.com" {
t.Errorf("expected user@amazon.com, got %s", account.Email)
}
if account.AccessToken != "new-at" {
t.Errorf("expected new-at, got %s", account.AccessToken)
}
if account.RefreshToken != "new-rt" {
t.Errorf("expected new-rt, got %s", account.RefreshToken)
}
// Verify saved to disk under amazon/ (not spotify/).
accountsFile := filepath.Join(dir, "amazon", "accounts.json")
data, err := os.ReadFile(accountsFile)
if err != nil {
t.Fatalf("failed to read accounts file: %v", err)
}
if !strings.Contains(string(data), "amzn1.account.TESTUSER123") {
t.Error("accounts file should contain the user ID")
}
}
func TestRefreshAccessToken(t *testing.T) {
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
if err := r.ParseForm(); err != nil {
t.Fatal(err)
}
if r.Form.Get("grant_type") != "refresh_token" {
t.Errorf("expected grant_type=refresh_token, got %s", r.Form.Get("grant_type"))
}
if r.Form.Get("refresh_token") != "my-refresh-token" {
t.Errorf("expected refresh_token=my-refresh-token, got %s", r.Form.Get("refresh_token"))
}
// Amazon uses POST body credentials.
if r.Form.Get("client_id") != "cid" {
t.Errorf("expected client_id=cid in POST body, got %q", r.Form.Get("client_id"))
}
if r.Form.Get("client_secret") != "csecret" {
t.Errorf("expected client_secret=csecret in POST body, got %q", r.Form.Get("client_secret"))
}
_, _, hasBasicAuth := r.BasicAuth()
if hasBasicAuth {
t.Error("Amazon token endpoint must NOT use HTTP Basic Auth")
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"access_token": "new-access-token",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "new-refresh-token",
})
}))
defer tokenServer.Close()
svc := NewAmazonService("cid", "csecret", "ueberboese-login://amazon", t.TempDir())
svc.tokenURL = tokenServer.URL
account := &Account{
UserID: "amzn1.account.USER",
AccessToken: "old-expired-token",
RefreshToken: "my-refresh-token",
ExpiresAt: time.Now().Add(-1 * time.Hour).Unix(),
}
svc.mu.Lock()
svc.accounts[account.UserID] = account
svc.mu.Unlock()
if err := svc.RefreshAccessToken(account); err != nil {
t.Fatalf("RefreshAccessToken: %v", err)
}
if account.AccessToken != "new-access-token" {
t.Errorf("expected new-access-token, got %s", account.AccessToken)
}
if account.RefreshToken != "new-refresh-token" {
t.Errorf("expected new-refresh-token, got %s", account.RefreshToken)
}
}
func TestGetFreshTokenRefreshesExpired(t *testing.T) {
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
t.Fatal(err)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"access_token": "new-access-token",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "new-refresh-token",
})
}))
defer tokenServer.Close()
svc := NewAmazonService("cid", "csecret", "ueberboese-login://amazon", t.TempDir())
svc.tokenURL = tokenServer.URL
svc.mu.Lock()
svc.accounts["amzn1.account.USER"] = &Account{
UserID: "amzn1.account.USER",
AccessToken: "old-expired-token",
RefreshToken: "my-refresh-token",
ExpiresAt: time.Now().Add(-1 * time.Hour).Unix(),
}
svc.mu.Unlock()
accessToken, username, err := svc.GetFreshToken()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if accessToken != "new-access-token" {
t.Errorf("expected new-access-token, got %s", accessToken)
}
if username != "amzn1.account.USER" {
t.Errorf("expected amzn1.account.USER, got %s", username)
}
svc.mu.RLock()
account := svc.accounts["amzn1.account.USER"]
svc.mu.RUnlock()
if account.RefreshToken != "new-refresh-token" {
t.Errorf("refresh token should be updated, got %s", account.RefreshToken)
}
}
func TestGetFreshTokenNoAccounts(t *testing.T) {
svc := NewAmazonService("cid", "csecret", "ueberboese-login://amazon", t.TempDir())
_, _, err := svc.GetFreshToken()
if err == nil {
t.Error("expected error when no accounts exist")
}
}
func TestGetFreshTokenNotExpired(t *testing.T) {
svc := NewAmazonService("cid", "csecret", "ueberboese-login://amazon", t.TempDir())
svc.mu.Lock()
svc.accounts["amzn1.account.USER"] = &Account{
UserID: "amzn1.account.USER",
AccessToken: "valid-token",
RefreshToken: "rt",
ExpiresAt: time.Now().Add(1 * time.Hour).Unix(),
}
svc.mu.Unlock()
token, username, err := svc.GetFreshToken()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if token != "valid-token" {
t.Errorf("expected valid-token, got %s", token)
}
if username != "amzn1.account.USER" {
t.Errorf("expected amzn1.account.USER, got %s", username)
}
}
func TestSaveAndLoad(t *testing.T) {
dir := t.TempDir()
svc := NewAmazonService("cid", "csecret", "ueberboese-login://amazon", dir)
svc.mu.Lock()
svc.accounts["amzn1.account.USER1"] = &Account{
UserID: "amzn1.account.USER1",
DisplayName: "Test User",
Email: "test@example.com",
AccessToken: "at",
RefreshToken: "rt",
ExpiresAt: 1234567890,
}
svc.accounts["amzn1.account.USER2"] = &Account{
UserID: "amzn1.account.USER2",
DisplayName: "User Two",
Email: "two@example.com",
AccessToken: "at2",
RefreshToken: "rt2",
ExpiresAt: 9876543210,
}
svc.mu.Unlock()
if err := svc.save(); err != nil {
t.Fatalf("save failed: %v", err)
}
accountsFile := filepath.Join(dir, "amazon", "accounts.json")
if _, err := os.Stat(accountsFile); os.IsNotExist(err) {
t.Fatal("amazon/accounts.json was not created")
}
svc2 := NewAmazonService("cid", "csecret", "ueberboese-login://amazon", dir)
if err := svc2.Load(); err != nil {
t.Fatalf("load failed: %v", err)
}
svc2.mu.RLock()
defer svc2.mu.RUnlock()
if len(svc2.accounts) != 2 {
t.Fatalf("expected 2 accounts after load, got %d", len(svc2.accounts))
}
u1, ok := svc2.accounts["amzn1.account.USER1"]
if !ok {
t.Fatal("USER1 not found after load")
}
if u1.DisplayName != "Test User" {
t.Errorf("expected Test User, got %s", u1.DisplayName)
}
if u1.AccessToken != "at" {
t.Errorf("expected at, got %s", u1.AccessToken)
}
if u1.ExpiresAt != 1234567890 {
t.Errorf("expected ExpiresAt 1234567890, got %d", u1.ExpiresAt)
}
}
+11
View File
@@ -0,0 +1,11 @@
package amazon
import "github.com/gesellix/bose-soundtouch/pkg/service/zeroconf"
// PushAmazonCredentials pushes Amazon Music credentials to a speaker using the
// ZeroConf DH key exchange protocol. Falls back to simplified token push if
// the speaker does not support DH (older firmware).
// zcBaseURL is the base URL of the ZeroConf endpoint, e.g. "http://192.168.1.10:8200/zc".
func PushAmazonCredentials(zcBaseURL, username, accessToken string) error {
return zeroconf.PushCredentials(zcBaseURL, username, accessToken)
}
+176
View File
@@ -0,0 +1,176 @@
package amazon
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/zeroconf"
)
func TestPushAmazonCredentials_FullRoundTrip(t *testing.T) {
speakerPrivate, speakerPublicBytes, err := zeroconf.GenerateDHKeyPair()
if err != nil {
t.Fatalf("speaker keygen: %v", err)
}
type received struct {
username string
authData string
authType int
}
var got received
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Query().Get("action") {
case "getInfo":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"status": 101,
"statusString": "OK",
"publicKey": base64.StdEncoding.EncodeToString(speakerPublicBytes),
})
case "addUser":
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
blobBytes, err := base64.StdEncoding.DecodeString(r.FormValue("blob"))
if err != nil {
http.Error(w, "bad blob base64: "+err.Error(), http.StatusBadRequest)
return
}
clientKeyBytes, err := base64.StdEncoding.DecodeString(r.FormValue("clientKey"))
if err != nil {
http.Error(w, "bad clientKey base64: "+err.Error(), http.StatusBadRequest)
return
}
shared := zeroconf.ComputeSharedSecret(speakerPrivate, clientKeyBytes)
encKey, macKey := zeroconf.DeriveKeys(shared)
plaintext, err := zeroconf.DecryptBlob(encKey, macKey, blobBytes)
if err != nil {
http.Error(w, "decrypt failed: "+err.Error(), http.StatusBadRequest)
return
}
// Minimal protobuf parse: field 1 = username, field 4 = authData, field 5 = authType
i := 0
for i < len(plaintext) {
tag := plaintext[i]
i++
fieldNum := tag >> 3
wireType := tag & 0x07
switch wireType {
case 0:
val, n := readVarint(plaintext[i:])
i += n
if fieldNum == 5 {
got.authType = int(val)
}
case 2:
length, n := readVarint(plaintext[i:])
i += n
value := plaintext[i : i+int(length)]
i += int(length)
switch fieldNum {
case 1:
got.username = string(value)
case 4:
got.authData = string(value)
}
default:
http.Error(w, "unexpected wire type", http.StatusBadRequest)
return
}
}
w.WriteHeader(http.StatusOK)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
const wantUsername = "amazonuser@example.com"
const wantToken = "Atza|access-token"
if err := PushAmazonCredentials(srv.URL+"/zc", wantUsername, wantToken); err != nil {
t.Fatalf("PushAmazonCredentials: %v", err)
}
if got.username != wantUsername {
t.Errorf("username = %q, want %q", got.username, wantUsername)
}
if got.authData != wantToken {
t.Errorf("authData = %q, want %q", got.authData, wantToken)
}
if uint64(got.authType) != zeroconf.AuthTypeOAuthToken {
t.Errorf("authType = %d, want %d (AuthTypeOAuthToken)", got.authType, zeroconf.AuthTypeOAuthToken)
}
}
func TestPushAmazonCredentials_FallbackOnGetInfoFailure(t *testing.T) {
var receivedForm map[string]string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Query().Get("action") {
case "getInfo":
http.Error(w, "not supported", http.StatusNotFound)
case "addUser":
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
receivedForm = map[string]string{
"userName": r.FormValue("userName"),
"blob": r.FormValue("blob"),
"clientKey": r.FormValue("clientKey"),
"tokenType": r.FormValue("tokenType"),
}
w.WriteHeader(http.StatusOK)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
const wantUsername = "amazonuser@example.com"
const wantToken = "Atza|raw-access-token"
if err := PushAmazonCredentials(srv.URL+"/zc", wantUsername, wantToken); err != nil {
t.Fatalf("PushAmazonCredentials: %v", err)
}
if receivedForm == nil {
t.Fatal("addUser was never called")
}
if receivedForm["userName"] != wantUsername {
t.Errorf("userName = %q, want %q", receivedForm["userName"], wantUsername)
}
if receivedForm["blob"] != wantToken {
t.Errorf("blob = %q, want raw token %q", receivedForm["blob"], wantToken)
}
if receivedForm["tokenType"] != "accesstoken" {
t.Errorf("tokenType = %q, want %q", receivedForm["tokenType"], "accesstoken")
}
if receivedForm["clientKey"] != "" {
t.Errorf("clientKey = %q, want empty for simplified fallback", receivedForm["clientKey"])
}
}
func readVarint(data []byte) (uint64, int) {
var val uint64
for i, b := range data {
val |= uint64(b&0x7f) << (7 * uint(i))
if b&0x80 == 0 {
return val, i + 1
}
}
return 0, len(data)
}
+534 -2
View File
@@ -12,16 +12,548 @@ import (
"net/url"
"strconv"
"strings"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
// TuneIn endpoint templates used to resolve station and stream URLs.
const (
TuneInDescribe = "https://opml.radiotime.com/describe.ashx?id=%s"
TuneInStream = "http://opml.radiotime.com/Tune.ashx?id=%s&formats=mp3,aac,ogg"
TuneInDescribe = "https://opml.radiotime.com/describe.ashx?id=%s"
TuneInStream = "http://opml.radiotime.com/Tune.ashx?id=%s&formats=mp3,aac,ogg"
TuneInNavigateAshx = "http://opml.radiotime.com/?render=json"
TuneInSearchAPI = "https://api.radiotime.com/profiles?fulltextsearch=true&version=1.3&query="
)
var tuneInClient = &http.Client{Timeout: 10 * time.Second}
// allowedTuneInHosts restricts outbound fetches to known TuneIn domains.
var allowedTuneInHosts = map[string]bool{
"opml.radiotime.com": true,
"api.radiotime.com": true,
}
func isTuneInURL(rawURL string) bool {
u, err := url.Parse(rawURL)
if err != nil {
return false
}
return allowedTuneInHosts[u.Hostname()]
}
// isTuneInOpmlURI returns true when the URL's host is opml.radiotime.com,
// used to select the OPML/ashx parser over the JSON API parser.
func isTuneInOpmlURI(rawURL string) bool {
u, err := url.Parse(rawURL)
if err != nil {
return false
}
return strings.EqualFold(u.Hostname(), "opml.radiotime.com")
}
// tuneInRenderJSONURI returns the URL with render=json set as a query parameter,
// replacing any existing render value instead of appending a duplicate.
func tuneInRenderJSONURI(rawURL string) string {
if rawURL == "" {
return ""
}
u, err := url.Parse(rawURL)
if err != nil {
return rawURL
}
q := u.Query()
q.Set("render", "json")
u.RawQuery = q.Encode()
return u.String()
}
// tuneInSearchURI returns the TuneIn search API URL with the query properly URL-encoded.
func tuneInSearchURI(query string) string {
return TuneInSearchAPI + url.QueryEscape(query)
}
func fetchJSON(fetchURL string) (map[string]interface{}, error) {
if !isTuneInURL(fetchURL) {
return nil, fmt.Errorf("URL not in allowed list: %s", fetchURL)
}
resp, err := tuneInClient.Get(fetchURL)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
}
return result, nil
}
func decodeBase64URI(encoded string) (string, error) {
b, err := base64.URLEncoding.DecodeString(encoded)
if err != nil {
b, err = base64.StdEncoding.DecodeString(encoded)
}
if err != nil {
return "", err
}
return string(b), nil
}
// TuneInNavigate returns a live browse response for the given encoded TuneIn URI.
// Pass subsection as nil for a full page, or a pointer to an int for a single subsection.
func TuneInNavigate(encodedURI string, subsection *int) (*models.BmxNavResponse, error) {
var (
tuneInURI string
bmxSearchLink *models.Link
)
if encodedURI != "" {
decoded, err := decodeBase64URI(encodedURI)
if err != nil {
return nil, err
}
tuneInURI = decoded
} else {
tuneInURI = TuneInNavigateAshx
templated := true
bmxSearchLink = &models.Link{
Filters: []interface{}{},
Href: "/v1/search?q={query}",
Templated: &templated,
}
}
var (
sections []models.BmxNavSection
err error
)
if isTuneInOpmlURI(tuneInURI) {
sections, err = tuneInSectionsAshx(tuneInURI, subsection)
} else {
sections, err = tuneInSectionsJSONAPI(tuneInURI, subsection)
}
if err != nil {
return nil, err
}
var subsectionPart, uriPart string
if subsection != nil {
subsectionPart = fmt.Sprintf("/sub/%d", *subsection)
}
if encodedURI != "" {
uriPart = "/" + encodedURI
}
return &models.BmxNavResponse{
Links: &models.Links{
Self: &models.Link{Href: fmt.Sprintf("/v1/navigate%s%s", subsectionPart, uriPart)},
BmxSearch: bmxSearchLink,
},
BmxSections: sections,
Layout: "classic",
}, nil
}
func tuneInSectionsAshx(tuneInURI string, subsection *int) ([]models.BmxNavSection, error) {
data, err := fetchJSON(tuneInURI)
if err != nil {
return nil, err
}
layout := "list"
var (
sections []models.BmxNavSection
topItems []models.BmxNavItem
)
body, _ := data["body"].([]interface{})
for idx, rawItem := range body {
item, ok := rawItem.(map[string]interface{})
if !ok {
continue
}
itemType, _ := item["type"].(string)
if itemType == "link" {
topItems = append(topItems, tuneInNavigateLink(item))
continue
}
if subsection != nil && *subsection != idx {
continue
}
if len(body) == 1 || subsection != nil {
layout = "responsiveGrid"
} else {
layout = "ribbon"
}
maxCount := 5
if layout == "responsiveGrid" {
maxCount = 500
}
sectionTitle, _ := item["text"].(string)
var sectionItems []models.BmxNavItem
count := 0
children, _ := item["children"].([]interface{})
for _, rawChild := range children {
child, ok := rawChild.(map[string]interface{})
if !ok {
continue
}
childType, _ := child["type"].(string)
switch childType {
case "audio":
sectionItems = append(sectionItems, tuneInNavigatePlayItem(child))
case "link":
sectionItems = append(sectionItems, tuneInNavigateLink(child))
}
count++
if count >= maxCount {
break
}
}
encURI := base64.URLEncoding.EncodeToString([]byte(tuneInURI))
sections = append(sections, models.BmxNavSection{
Links: &models.Links{Self: &models.Link{Href: fmt.Sprintf("/v1/navigate/sub/%d/%s", idx, encURI)}},
Items: sectionItems,
Layout: layout,
Name: sectionTitle,
})
}
head, _ := data["head"].(map[string]interface{})
title, _ := head["title"].(string)
var subsectionPart string
if subsection != nil {
subsectionPart = fmt.Sprintf("sub/%d/", *subsection)
}
encURI := base64.URLEncoding.EncodeToString([]byte(tuneInURI))
sections = append(sections, models.BmxNavSection{
Links: &models.Links{Self: &models.Link{Href: fmt.Sprintf("/v1/navigate/%s%s", subsectionPart, encURI)}},
Items: topItems,
Layout: layout,
Name: title,
})
return sections, nil
}
func tuneInSectionsJSONAPI(tuneInURI string, subsection *int) ([]models.BmxNavSection, error) {
data, err := fetchJSON(tuneInURI)
if err != nil {
return nil, err
}
var sections []models.BmxNavSection
items, _ := data["Items"].([]interface{})
for idx, rawItem := range items {
item, ok := rawItem.(map[string]interface{})
if !ok {
continue
}
if subsection != nil && *subsection != idx {
continue
}
itemType, _ := item["Type"].(string)
containerType, _ := item["ContainerType"].(string)
if itemType == "Container" && containerType != "NotPlayableStations" {
sections = append(sections, tuneInSearchSection(item, idx, "", "shortList"))
}
}
return sections, nil
}
func tuneInNavigatePlayItem(item map[string]interface{}) models.BmxNavItem {
guideID, _ := item["guide_id"].(string)
imageURL, _ := item["image"].(string)
text, _ := item["text"].(string)
subtext, _ := item["subtext"].(string)
playbackHref := fmt.Sprintf("/v1/playback/station/%s", guideID)
return models.BmxNavItem{
Links: &models.Links{
BmxPlayback: &models.Link{Href: playbackHref, Type: "stationurl"},
BmxPreset: &models.Link{ContainerArt: imageURL, Href: guideID, Name: text, Type: "stationurl"},
},
ImageUrl: imageURL,
Name: text,
Subtitle: subtext,
}
}
func tuneInNavigateLink(item map[string]interface{}) models.BmxNavItem {
rawURL, _ := item["URL"].(string)
imageURL, _ := item["image"].(string)
text, _ := item["text"].(string)
subtext, _ := item["subtext"].(string)
encURL := base64.URLEncoding.EncodeToString([]byte(tuneInRenderJSONURI(rawURL)))
return models.BmxNavItem{
Links: &models.Links{BmxNavigate: &models.Link{Href: fmt.Sprintf("/v1/navigate/%s", encURL)}},
ImageUrl: imageURL,
Name: text,
Subtitle: subtext,
}
}
// TuneInSearch returns live search results from TuneIn for the given query.
func TuneInSearch(query string) (*models.BmxNavResponse, error) {
tuneInURI := tuneInSearchURI(query)
templated := true
bmxSearchLink := &models.Link{
Filters: []interface{}{},
Href: "/v1/search?q={query}",
Templated: &templated,
}
data, err := fetchJSON(tuneInURI)
if err != nil {
return nil, err
}
var sections []models.BmxNavSection
items, _ := data["Items"].([]interface{})
for idx, rawItem := range items {
item, ok := rawItem.(map[string]interface{})
if !ok {
continue
}
itemType, _ := item["Type"].(string)
containerType, _ := item["ContainerType"].(string)
if itemType == "Container" && containerType != "NotPlayableStations" {
sections = append(sections, tuneInSearchSection(item, idx, query, "shortList"))
}
}
return &models.BmxNavResponse{
Links: &models.Links{
Self: &models.Link{Href: fmt.Sprintf("/v1/search?q=%s", url.QueryEscape(query))},
BmxSearch: bmxSearchLink,
},
BmxSections: sections,
Layout: "classic",
}, nil
}
func tuneInSearchSection(item map[string]interface{}, idx int, query, layout string) models.BmxNavSection {
pivots, _ := item["Pivots"].(map[string]interface{})
more, _ := pivots["More"].(map[string]interface{})
pivotURL, _ := more["Url"].(string)
var href string
if pivotURL != "" {
href = fmt.Sprintf("/v1/navigate/%s", base64.URLEncoding.EncodeToString([]byte(pivotURL)))
} else {
encodedQuery := base64.URLEncoding.EncodeToString([]byte(tuneInSearchURI(query)))
href = fmt.Sprintf("/v1/navigate/sub/%d/%s", idx, encodedQuery)
}
var sectionItems []models.BmxNavItem
children, _ := item["Children"].([]interface{})
for _, rawChild := range children {
child, ok := rawChild.(map[string]interface{})
if !ok {
continue
}
childType, _ := child["Type"].(string)
switch childType {
case "Station":
sectionItems = append(sectionItems, tuneInSearchPlayItem(child))
case "Topic":
sectionItems = append(sectionItems, tuneInSearchTopic(child))
case "Program":
sectionItems = append(sectionItems, tuneInSearchProfile(child, "Program"))
case "Artist":
sectionItems = append(sectionItems, tuneInSearchProfile(child, "Artist"))
case "Category":
actions, _ := child["Actions"].(map[string]interface{})
browse, _ := actions["Browse"].(map[string]interface{})
categoryHref, _ := browse["Url"].(string)
encHref := base64.URLEncoding.EncodeToString([]byte(categoryHref))
image, _ := child["Image"].(string)
title, _ := child["Title"].(string)
subtitle, _ := child["Subtitle"].(string)
sectionItems = append(sectionItems, models.BmxNavItem{
Links: &models.Links{BmxNavigate: &models.Link{Href: fmt.Sprintf("/v1/navigate/%s", encHref)}},
ImageUrl: image,
Name: title,
Subtitle: subtitle,
})
}
}
title, _ := item["Title"].(string)
return models.BmxNavSection{
Links: &models.Links{Self: &models.Link{Href: href}},
Items: sectionItems,
Layout: layout,
Name: title,
}
}
func tuneInSearchPlayItem(item map[string]interface{}) models.BmxNavItem {
guideID, _ := item["GuideId"].(string)
image, _ := item["Image"].(string)
title, _ := item["Title"].(string)
subtitle, _ := item["Subtitle"].(string)
href := fmt.Sprintf("/v1/playback/station/%s", guideID)
return models.BmxNavItem{
Links: &models.Links{
BmxPlayback: &models.Link{Href: href, Type: "stationurl"},
BmxPreset: &models.Link{ContainerArt: image, Href: href, Name: title, Type: "stationurl"},
},
ImageUrl: image,
Name: title,
Subtitle: subtitle,
}
}
func tuneInSearchTopic(item map[string]interface{}) models.BmxNavItem {
guideID, _ := item["GuideId"].(string)
image, _ := item["Image"].(string)
title, _ := item["Title"].(string)
subtitle, _ := item["Subtitle"].(string)
encodedName := base64.URLEncoding.EncodeToString([]byte(title))
href := fmt.Sprintf("/v1/playback/episodes/%s?encoded_name=%s", guideID, encodedName)
return models.BmxNavItem{
Links: &models.Links{
BmxPlayback: &models.Link{Href: href, Type: "tracklisturl"},
BmxPreset: &models.Link{ContainerArt: image, Href: href, Name: title, Type: "tracklisturl"},
},
ImageUrl: image,
Name: title,
Subtitle: subtitle,
}
}
func tuneInSearchProfile(item map[string]interface{}, name string) models.BmxNavItem {
guideID, _ := item["GuideId"].(string)
image, _ := item["Image"].(string)
title, _ := item["Title"].(string)
subtitle, _ := item["Subtitle"].(string)
actions, _ := item["Actions"].(map[string]interface{})
profile, _ := actions["Profile"].(map[string]interface{})
apiURL, _ := profile["Url"].(string)
apiURLEncoded := base64.URLEncoding.EncodeToString([]byte(apiURL))
return models.BmxNavItem{
Links: &models.Links{
BmxNavigate: &models.Link{Href: fmt.Sprintf("/v1/navigate/profiles/%s/%s/%s", name, guideID, apiURLEncoded)},
BmxPreset: &models.Link{ContainerArt: image, Href: fmt.Sprintf("/v1/preset/program/%s", guideID), Name: title, Type: "tracklisturl"},
},
ImageUrl: image,
Name: title,
Subtitle: subtitle,
}
}
// TuneInNavigateProfile returns a profile (artist/program) navigation response.
func TuneInNavigateProfile(encodedURI string) (*models.BmxNavResponse, error) {
tuneInURI, err := decodeBase64URI(encodedURI)
if err != nil {
return nil, err
}
profileData, err := fetchJSON(tuneInURI)
if err != nil {
return nil, err
}
profileItem, _ := profileData["Item"].(map[string]interface{})
profileTitle, _ := profileItem["Title"].(string)
profileImage, _ := profileItem["Image"].(string)
profileSubtitle, _ := profileItem["Subtitle"].(string)
sections := []models.BmxNavSection{
{
Items: []models.BmxNavItem{{Name: profileTitle, ImageUrl: profileImage, Subtitle: profileSubtitle}},
Layout: "hero",
Name: "",
},
}
pivots, _ := profileItem["Pivots"].(map[string]interface{})
contents, _ := pivots["Contents"].(map[string]interface{})
contentsURL, _ := contents["Url"].(string)
if contentsURL != "" {
if contentsData, fetchErr := fetchJSON(contentsURL); fetchErr == nil {
contentsItems, _ := contentsData["Items"].([]interface{})
for idx, rawItem := range contentsItems {
item, ok := rawItem.(map[string]interface{})
if !ok {
continue
}
itemType, _ := item["Type"].(string)
containerType, _ := item["ContainerType"].(string)
if itemType == "Container" && containerType != "NotPlayableStations" {
sections = append(sections, tuneInSearchSection(item, idx, "", "list"))
}
}
}
}
return &models.BmxNavResponse{
Links: &models.Links{Self: &models.Link{Href: fmt.Sprintf("/v1/navigate/profiles/%s", encodedURI)}},
BmxSections: sections,
Layout: "classic",
}, nil
}
// TuneInPlayback resolves a live radio station and returns a Bose-compatible
// playback response with primary stream and variants.
func TuneInPlayback(stationID string) (*models.BmxPlaybackResponse, error) {
+146
View File
@@ -3,9 +3,155 @@ package bmx
import (
"encoding/base64"
"encoding/json"
"strings"
"testing"
)
func TestTuneInRenderJSONURI(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "empty URL returns empty",
input: "",
want: "",
},
{
name: "URL with no query params gets render=json added",
input: "http://opml.radiotime.com/Browse.ashx",
want: "http://opml.radiotime.com/Browse.ashx?render=json",
},
{
name: "URL with other params gets render=json appended",
input: "http://opml.radiotime.com/Browse.ashx?c=news",
want: "http://opml.radiotime.com/Browse.ashx?c=news&render=json",
},
{
name: "URL already containing render=json is not duplicated",
input: "http://opml.radiotime.com/?render=json",
want: "http://opml.radiotime.com/?render=json",
},
{
name: "URL with render=xml gets render replaced with json",
input: "http://opml.radiotime.com/Browse.ashx?c=podcast&render=xml",
want: "http://opml.radiotime.com/Browse.ashx?c=podcast&render=json",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tuneInRenderJSONURI(tt.input)
if got != tt.want {
t.Errorf("tuneInRenderJSONURI(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestIsTuneInOpmlURI(t *testing.T) {
tests := []struct {
input string
want bool
}{
{"http://opml.radiotime.com/Browse.ashx", true},
{"https://opml.radiotime.com/Browse.ashx", true},
{"http://opml.radiotime.com/?render=json", true},
{"http://api.radiotime.com/profiles?fulltextsearch=true", false},
{"http://example.com", false},
{"not-a-url", false},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := isTuneInOpmlURI(tt.input)
if got != tt.want {
t.Errorf("isTuneInOpmlURI(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
func TestTuneInSearchURI(t *testing.T) {
tests := []struct {
name string
query string
check func(string) bool
}{
{
name: "spaces are percent-encoded",
query: "radio paradise",
check: func(u string) bool { return !strings.Contains(u, " ") && strings.Contains(u, "radio+paradise") },
},
{
name: "ampersand is encoded",
query: "news & talk",
check: func(u string) bool { return !strings.Contains(u, " ") && strings.Contains(u, "%26") },
},
{
name: "plain query is appended to base URL",
query: "jazz",
check: func(u string) bool { return u == TuneInSearchAPI+"jazz" },
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tuneInSearchURI(tt.query)
if !tt.check(got) {
t.Errorf("tuneInSearchURI(%q) = %q: check failed", tt.query, got)
}
})
}
}
func TestTuneInNavigateLinkEncodesRenderJSON(t *testing.T) {
item := map[string]interface{}{
"URL": "http://opml.radiotime.com/Browse.ashx?c=news",
"text": "News",
"subtext": "Latest",
"image": "http://example.com/news.png",
}
result := tuneInNavigateLink(item)
href := result.Links.BmxNavigate.Href
encoded := strings.TrimPrefix(href, "/v1/navigate/")
decoded, err := base64.URLEncoding.DecodeString(encoded)
if err != nil {
t.Fatalf("failed to decode navigate href: %v", err)
}
got := string(decoded)
if !strings.Contains(got, "render=json") {
t.Errorf("navigate href %q missing render=json", got)
}
if strings.Count(got, "render=json") > 1 {
t.Errorf("navigate href %q has duplicate render=json", got)
}
}
func TestTuneInNavigateLinkNoDuplicateRenderJSON(t *testing.T) {
item := map[string]interface{}{
"URL": "http://opml.radiotime.com/Browse.ashx?c=podcast&render=json",
}
result := tuneInNavigateLink(item)
href := result.Links.BmxNavigate.Href
encoded := strings.TrimPrefix(href, "/v1/navigate/")
decoded, err := base64.URLEncoding.DecodeString(encoded)
if err != nil {
t.Fatalf("failed to decode navigate href: %v", err)
}
got := string(decoded)
if strings.Count(got, "render=json") != 1 {
t.Errorf("navigate href %q should contain render=json exactly once", got)
}
}
func TestPlayCustomStream(t *testing.T) {
// Simple test for custom stream XML generation
dataObj := struct {
+9 -3
View File
@@ -17,7 +17,8 @@ import (
// CertificateManager handles CA and certificate generation.
type CertificateManager struct {
CertsDir string
CertsDir string
CommonName string // CN for generated server certs; defaults to "localhost" if empty
}
// NewCertificateManager creates a new CertificateManager.
@@ -137,7 +138,7 @@ func (cm *CertificateManager) GetServerTLSConfig(domains []string) (*tls.Config,
// GenerateCA generates a new CA certificate and key.
func (cm *CertificateManager) GenerateCA() error {
priv, err := rsa.GenerateKey(rand.Reader, 4096)
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return err
}
@@ -255,11 +256,16 @@ func (cm *CertificateManager) GenerateCertificate(domains []string) ([]byte, []b
}
}
cn := cm.CommonName
if cn == "" {
cn = "localhost"
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"AfterTouch"},
CommonName: domains[0],
CommonName: cn,
},
NotBefore: notBefore,
NotAfter: notAfter,
+3 -2
View File
@@ -16,6 +16,7 @@ func TestCertificateManager(t *testing.T) {
defer os.RemoveAll(tempDir)
cm := NewCertificateManager(filepath.Join(tempDir, "certs"))
cm.CommonName = "test.local"
// Test CA generation
if err := cm.EnsureCA(); err != nil {
@@ -67,8 +68,8 @@ func TestCertificateManager(t *testing.T) {
t.Fatalf("Failed to parse certificate: %v", err)
}
if cert.Subject.CommonName != domains[0] {
t.Errorf("Expected CommonName %s, got %s", domains[0], cert.Subject.CommonName)
if cert.Subject.CommonName != cm.CommonName {
t.Errorf("Expected CommonName %s, got %s", cm.CommonName, cert.Subject.CommonName)
}
// Check DNS names

Some files were not shown because too many files have changed in this diff Show More