Compare commits

...
105 Commits
Author SHA1 Message Date
Tobias Gesellchen 6211e34050 Improve parity with upstream Bose services 2026-02-26 21:08:14 +01:00
Tobias Gesellchen 2132674768 Fix bmx base url 2026-02-26 21:08:14 +01:00
Tobias Gesellchen b4c015ef75 Restrict UPnP timeout 2026-02-26 21:08:14 +01:00
Tobias Gesellchen 5d22a53c8b Fix Content-Type and status code in Marge AddRecent handler
- Reorder header setting and WriteHeader calls in HandleMargeAddRecent to ensure Content-Type is correctly sent.
- Update HandleMargeAddRecent to explicitly use 201 Created status code.
- Improve parity mismatch logging to correctly capture headers from local handlers.
- Enhance mirroring logic to support local testing of upstream parity.
2026-02-26 21:08:14 +01:00
Tobias Gesellchen f4268f3111 Use BuildKit's build args 2026-02-26 09:43:14 +01:00
Tobias Gesellchen 71fd9c1531 Build and publish cross-platform Docker images 2026-02-26 08:43:35 +01:00
Tobias Gesellchen 53184a6bca reduce log noise 2026-02-24 22:20:13 +01:00
Tobias Gesellchen d97cd45b22 Relax TestMACMappingPerformance limit 2026-02-24 21:52:27 +01:00
Tobias Gesellchen 5edab77209 feat: add comprehensive TLS certificate SAN support with wildcard domains
- Add RFC-compliant wildcard certificates (*.api.bose.io, *.api.bosecm.com) for automatic API coverage
- Include additional Bose production domains (worldwide.bose.com, music.api.bose.com, bose-prod.apigee.net)
- Implement TLS certificate request logging and wildcard domain matching logic
- Add detailed TLS handshake debugging with connection state tracking
- Wrap TLS listener with logging to capture certificate selection and handshake failures
- Update documentation with wildcard certificate coverage and debugging features
- Normalize test data to use consistent local IP addresses

This enables automatic coverage of all current and future Bose API subdomains
while providing comprehensive TLS debugging for DNS redirection troubleshooting.
2026-02-24 21:47:20 +01:00
Tobias Gesellchen a1d0213f92 refactor: reorganize device directories to use true deviceId from /info endpoint
- Replace serial number-based directory structure with deviceId from device /info
- Extract migration logic to handle transition from old to new directory structure
- Fix directory resolution bug that prevented proper migration to deviceId-based paths
- Ensure all device data (Presets.xml, Sources.xml, Recents.xml) preserved during transition
- Add configurable migration with --migration-enabled and --migration-dry-run flags
- Update DeviceInfo.xml to reflect authoritative deviceId from device's /info endpoint
- Directory structure now: /devices/{deviceId}/ instead of /devices/{serialNumber}/

This aligns the directory structure with the device's self-declared identity
and ensures data consistency with the device's /info endpoint.
2026-02-24 21:45:40 +01:00
Tobias Gesellchen 0b75a2f70d feat: implement robust MAC address to serial number mapping
Enhances device identification by adding MAC address normalization and comprehensive documentation.

- Add `MAC-ADDRESS-MAPPING.md` guide explaining device identification and troubleshooting.
- Implement `normalizeMAC` in `DataStore` to handle various MAC formats (case-insensitive, with/without separators).
- Export `EnrichDeviceInfo` in UPnP discovery to allow better integration and testing.
- Update `TROUBLESHOOTING.md` with a new section on device identification issues.
- Add comprehensive integration and diagnostic tests for MAC mapping, case sensitivity, and UPnP discovery.
- Update documentation structure (`README.md`, `SUMMARY.md`) to include the new mapping guide.
2026-02-24 21:45:40 +01:00
Tobias Gesellchen 0090746b89 refactor: update recording filename format to include date
- Update `getRecordingPath` to use a timestamp format that includes the date (`20060102-150405.000`).
- Update `parseInteractionFile` and `getFullTimestamp` to handle both the new filename format and the legacy format for backward compatibility.
- Improved parsing logic to reliably extract date, time, and HTTP method from interaction filenames.
2026-02-24 11:49:04 +01:00
Tobias Gesellchen be762dbc22 test(discovery): optimize discovery tests for faster execution
Reduces `pkg/discovery` test suite runtime by ~75% (from ~17s to ~4s) by eliminating unnecessary network timeouts and reducing wait intervals.

- Refactor `discovery.Service` to use an injectable `http.Client`, allowing UPnP enrichment tests to use `httptest.Server` instead of waiting for 5s network timeouts.
- Make `DNSDiscovery` forward timeout configurable and reduce it from 2s to 100ms in unit tests.
- Decrease discovery and context timeouts in mDNS and Unified discovery tests to the minimum required for stable verification (typically 100-200ms).
2026-02-22 23:40:51 +01:00
Tobias Gesellchen 403e2275dc fix(datastore): resolve local data directory using MAC address mapping
Fixes an issue where device data (e.g., Presets.xml) could not be located when accessed via MAC address because the internal directory structure is organized by serial number.

- Add a `macToSerial` mapping in `DataStore` to bridge MAC addresses from API requests to internal serial-numbered directories.
- Implement automatic mapping population during `DataStore` initialization by scanning `DeviceInfo.xml` files.
- Update `AccountDeviceDir` to transparently resolve MAC addresses to serial numbers for file path construction.
- Enhance UPnP discovery to capture the MAC address (as `serialNumber` in the device description) for better device identification.
- Include automated tests for MAC-to-serial resolution and UPnP enrichment.
2026-02-22 23:40:51 +01:00
Tobias Gesellchen 9ee1c96477 feat(mirror): add background mirroring and parity analysis for Bose services
Implements the ability to mirror local requests to the official Bose
Cloud in the background, allowing for real-time comparison and parity
analysis between the emulated service and the original backend.

Core Changes:
- Implement `MirrorMiddleware` for asynchronous and synchronous mirroring.
- Add `Parity Logger` to detect discrepancies in status, headers, and body.
- Implement storage for parity mismatches in `data/parity_mismatches/`.
- Add `Internal Paths` configuration to exclude management traffic from logs.

Web UI & API:
- Add "Parity & Mirroring" tab to the Web UI for discrepancy analysis.
- Integrated "Internal Paths" configuration in Settings.
- Add "mirror" category filter to the Interactions UI.
- Implement endpoints for listing and clearing parity mismatches.

Infrastructure & Tools:
- Extend `setup.Manager` with `HTTPGet` override for reliable testing.
- Add CLI flags `--mirror-enabled`, `--mirror-endpoints`, and `--internal-paths`.
- Update `datastore.Settings` to persist mirroring and internal path configurations.

Tests:
- Add `pkg/service/handlers/mirror_test.go` for middleware verification.
- Update `TestProxySettingsAPI` and `TestRecordMiddleware` for new settings.
- Refactor `TestMigrationAndCA` to use mocked network calls (30x speedup).
2026-02-22 22:20:03 +01:00
Tobias Gesellchen b71a3830ec Add more routes to be handled by ourselves
Group management is only implemented as placeholder
2026-02-22 20:48:42 +01:00
Tobias Gesellchen f50ee1131e Fix migration check 2026-02-22 18:58:20 +01:00
Tobias Gesellchen 6a65376784 Attempt resolution if it's not a numeric IP 2026-02-22 14:17:01 +01:00
Tobias Gesellchen 44d04a2b41 Allow empty dns upstream config (default to system nameservers) 2026-02-22 13:58:30 +01:00
Tobias Gesellchen 0f802e65c6 Fallback to the system's dns resolver by default 2026-02-22 13:36:58 +01:00
Tobias Gesellchen 01d702c745 Fix the Raspberry Pi install script (self-update, env variables) 2026-02-22 01:03:50 +01:00
Tobias Gesellchen 7823b68bdd Prime Spotify only on speaker boot/power_on 2026-02-22 00:33:15 +01:00
Tobias Gesellchen e1f3fc36c8 Fix Spotify link display 2026-02-22 00:07:52 +01:00
Tobias Gesellchen d68599896d Add Spotify primer 2026-02-21 23:40:45 +01:00
Tobias Gesellchen d18b67d80f Remove device-local Spotify primer 2026-02-21 23:40:45 +01:00
Tobias Gesellchen 8642ecfc5c Prepare Spotify primer 2026-02-21 23:40:45 +01:00
Tobias Gesellchen c37e94b5f8 Remove unused BaseURL 2026-02-21 11:22:46 +01:00
Tobias Gesellchen 4e33f6948f Add DNS discovery download 2026-02-21 11:22:46 +01:00
Tobias Gesellchen 743ff5e061 Add streamingoauth.bose.com to the intercepted DNS records 2026-02-21 11:22:46 +01:00
Tobias Gesellchen ec8bbb2f86 Lint: cleanup 2026-02-21 00:42:07 +01:00
Tobias Gesellchen e75e2bea0c Update Raspberry Pi installation script to include Spotify 2026-02-21 00:42:07 +01:00
Tobias Gesellchen dd5aa2ad53 Disable HTML escaping in JSON response 2026-02-21 00:42:07 +01:00
Tobias Gesellchen aced0f3f81 Use the Chi BasicAuth middleware 2026-02-21 00:42:07 +01:00
Tobias Gesellchen a886518cad Add example redirect URIs for both browser and ueberboese-app 2026-02-21 00:42:07 +01:00
Tim Van Wassenhove dc81b0aa81 feat: separate browser callback and mobile app confirm endpoints
- Add GET /mgmt/spotify/callback (no auth) for browser OAuth redirect
- Restore POST /mgmt/spotify/confirm (Basic Auth) for ueberboese mobile app
- Callback returns HTML success/error pages; confirm returns JSON
- Both call the same ExchangeCodeAndStore() logic
2026-02-21 00:21:52 +01:00
Tim Van Wassenhove c648027735 fix: OAuth callback as GET outside auth group, remove dead zeroconf flag, update .env.example
- Change /mgmt/spotify/confirm from POST to GET (Spotify redirects via GET)
- Move confirm endpoint outside Basic Auth group (code is single-use, needs client_secret)
- Remove --zeroconf-primer-enabled flag (no ZeroConf primer code on this branch)
- Add Spotify/mgmt env var documentation to .env.example
2026-02-21 00:21:52 +01:00
Tim Van Wassenhove fced88a8a6 feat: add management API endpoints matching ueberboese-app 2026-02-21 00:21:52 +01:00
Tim Van Wassenhove 0ee673c097 feat: wire Spotify service into server 2026-02-21 00:21:52 +01:00
Tim Van Wassenhove 395b2fec8e feat: add Spotify OAuth service with token management 2026-02-21 00:21:52 +01:00
Tim Van Wassenhove be7e44e14b feat: add Basic Auth middleware for management API 2026-02-21 00:21:52 +01:00
Tim Van Wassenhove a87783d8c6 feat: add Spotify, management, and ZeroConf CLI flags 2026-02-21 00:21:52 +01:00
Tobias Gesellchen be017440b7 Add userInactivity event 2026-02-20 09:18:53 +01:00
Tobias Gesellchen 10de011c18 Simplify the PlayTTS method cmd 2026-02-19 08:46:55 +01:00
Tobias Gesellchen f7b74db3ea Make the linter happy 2026-02-19 08:44:26 +01:00
Tobias Gesellchen 72d75133c4 Capture server references before releasing mutex to avoid race condition 2026-02-19 08:44:26 +01:00
Tobias Gesellchen e4c12471b4 Add more upstream domains to the intercept list 2026-02-19 08:44:26 +01:00
Tobias Gesellchen 3329149282 Add support for RADIO_BROWSER source
This implementation follows the reference from soundcork pull request #158. It adds RADIO_BROWSER to the known providers and includes the service configuration in bmx_services.json. Documentation has also been added to explain how to use the RadioBrowser feature. Credits to @gmuth (https://github.com/gmuth) for the original idea and implementation in soundcork. Reference: https://github.com/deborahgu/soundcork/pull/158
2026-02-16 22:18:33 +01:00
Tobias Gesellchen 523ff0eb17 Fix deadlock in settings update and add efficient DNS settings validation 2026-02-16 21:02:42 +01:00
Tobias Gesellchen 025e15d65c Implement log throttling, loop prevention, and empty upstream handling in DNS discovery server 2026-02-16 21:02:42 +01:00
Tobias Gesellchen 7d140b3e2a Fix TestMigrationAndCA by enhancing mock SSH client
This commit updates the mock SSH client in the handler tests to support the recently added verification steps. It now correctly handles stateful responses for /etc/hosts and properly responds to file existence and CA trust checks.
2026-02-16 20:17:25 +01:00
Tobias Gesellchen 69210638e5 Add verification steps to speaker migration process
This update adds explicit verification checks after applying changes via XML, Hosts, and ResolvConf migration methods. The service now verifies that configuration files are correctly updated on the device before considering the migration successful, preventing unreliable states.
2026-02-16 20:17:25 +01:00
Tobias Gesellchen 6aef2b807d Enhance ResolvConf migration to support multiple DHCP script variants
This update allows the service to correctly patch both /etc/udhcpc.d/50default and /opt/Bose/udhcpc.script (used in SoundTouch 10 firmware) for DNS redirection. It also improves robustness by adding file existence checks in rc.local and ensures clean state by reverting to .original backups during migration.
2026-02-16 18:52:25 +01:00
Tobias Gesellchen 95f5e9c831 fix(setup): prevent and clean up corrupted rc.local with cat error message 2026-02-16 18:20:16 +01:00
Tobias Gesellchen 7337296ae9 refactor(setup): reduce cyclomatic complexity of RevertMigration 2026-02-16 18:04:28 +01:00
Tobias Gesellchen 92a5d3592c feat(setup): replace obsolete resolv method with persistent DHCP-aware DNS hook 2026-02-16 18:04:28 +01:00
Tobias Gesellchen 2f04af872b feat(setup): implement Aftertouch Hook (DHCP-aware DNS redirection); update UI and tests; docs now use aftertouch.resolv.conf 2026-02-16 18:04:28 +01:00
Tobias Gesellchen 9479d6d11d Fix missing request body in recorded proxy interactions 2026-02-16 16:30:41 +01:00
Tobias Gesellchen f687ba0d82 go mod tidy 2026-02-16 12:45:04 +01:00
Tobias Gesellchen ab2bf0731a Add DNS-based discovery and migration via /etc/resolv.conf 2026-02-16 12:18:06 +01:00
Tobias Gesellchen cafaba1be0 Update SOUNDTOUCH-SERVICE.md with recent features (Soundcork proxy, session archiving, enhanced redaction) 2026-02-15 23:54:14 +01:00
Tobias Gesellchen 93082d2cdc Update root endpoint JSON response with AfterTouch and docs link 2026-02-15 23:47:10 +01:00
Tobias Gesellchen 087006c483 Add regression test for settings persistence 2026-02-15 23:28:45 +01:00
Tobias Gesellchen b7013a5ec8 Apply 'Redact Sensitive Headers' to recordings 2026-02-15 23:12:19 +01:00
Tobias Gesellchen 7d76b3fab2 Implement dynamic Bose proxy with detailed origin logging and Soundcork fallback 2026-02-15 22:50:13 +01:00
Tobias Gesellchen 6ca206053f Add session download feature to web UI 2026-02-15 22:20:16 +01:00
Tobias Gesellchen 090eb162fb Fix TypeError in Web UI by renaming proxy-domain to soundcork-url
This commit fixes a JS error in showSummary and migrate functions where they were still trying to access the UI element by its old ID 'proxy-domain' instead of the new 'soundcork-url'.
2026-02-15 22:01:58 +01:00
dependabot[bot] 972824e07f ci(deps): bump the actions-core group with 3 updates
Bumps the actions-core group with 3 updates: [actions/checkout](https://github.com/actions/checkout), [actions/configure-pages](https://github.com/actions/configure-pages) and [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact).


Updates `actions/checkout` from 4 to 6
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-15 21:57:12 +01:00
dependabot[bot] 1e2148d53b deps(deps): bump the golang group with 4 updates
Bumps the golang group with 4 updates: [golang.org/x/crypto](https://github.com/golang/crypto), [golang.org/x/mod](https://github.com/golang/mod), [golang.org/x/net](https://github.com/golang/net) and [golang.org/x/tools](https://github.com/golang/tools).


Updates `golang.org/x/crypto` from 0.47.0 to 0.48.0
- [Commits](https://github.com/golang/crypto/compare/v0.47.0...v0.48.0)

Updates `golang.org/x/mod` from 0.32.0 to 0.33.0
- [Commits](https://github.com/golang/mod/compare/v0.32.0...v0.33.0)

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

Updates `golang.org/x/tools` from 0.41.0 to 0.42.0
- [Release notes](https://github.com/golang/tools/releases)
- [Commits](https://github.com/golang/tools/compare/v0.41.0...v0.42.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.48.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/mod
  dependency-version: 0.33.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/net
  dependency-version: 0.50.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/tools
  dependency-version: 0.42.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-15 21:56:17 +01:00
Tobias Gesellchen 9a070da1ef Fix data race in RecordMiddleware and improve recorder robustness
This commit addresses the data race detected in TestRecordMiddleware: - Updated Recorder.Record to clone Request and Response objects (including bodies) before background processing. - Ensures background workers can safely access data after the main request handler has finished. - Enabled synchronous recording in handler tests to ensure deterministic results and avoid race conditions.
2026-02-15 21:51:55 +01:00
Tobias Gesellchen d4b518da23 Fix proxy and recorder tests by ensuring synchronous recording during testing
This commit addresses the test failures in pkg/service/proxy: - Ensures synchronous recording in tests by setting RECORDER_ASYNC=false. - Adds a Close() method to the Recorder for proper cleanup. - Fixes a panic in TestRecorder_Record_Redaction caused by race conditions.
2026-02-15 21:51:55 +01:00
Tobias Gesellchen 89bafd97b6 Optimize recording performance and add Soundcork proxy toggle
This commit introduces several key improvements: Performance Optimization (asynchronous recording), Legacy Proxy Control (Soundcork proxy toggle), X-Forwarded-For Sanitization, consistent Soundcork naming across the stack, and various code quality improvements.
2026-02-15 21:51:55 +01:00
Tobias Gesellchen d616bc09fd fix unbound variable (tmp) 2026-02-15 20:48:26 +01:00
Tobias Gesellchen 8af60c7e4b fix linter issues 2026-02-15 20:20:47 +01:00
Tobias Gesellchen 8a21db3517 Capture additional redirect methods and improve recorder functionality 2026-02-15 20:20:47 +01:00
Tobias Gesellchen 742484568e feat: implement Stockholm-related cloud API emulation - Added handlers for Stockholm app events (/v1/stapp, /v1/scmudc) - Implemented account profile, password management, and device settings endpoints - Added Go models for new API responses and requests - Created docs/reference/CLOUD-API.md and updated SUMMARY.md - Added comprehensive unit tests for all new handlers - Updated ueberboese-api.yaml with new endpoints and schemas 2026-02-15 20:20:47 +01:00
Tobias Gesellchen ed2d8680e4 fix: align streaming_token with Bose protocol to avoid 502 errors 2026-02-15 18:58:35 +01:00
Tobias Gesellchen 6dc8c23f04 feat: detect migrated devices and prompt for reboot after migration 2026-02-15 18:58:35 +01:00
Tobias Gesellchen fa57ee9574 Rebrand to AfterTouch and cleanup SoundCork references 2026-02-15 18:09:49 +01:00
Tobias Gesellchen e438db05d9 Fix release workflow to avoid +dirty version suffix by building in isolated directory 2026-02-15 17:27:41 +01:00
Tobias Gesellchen 8c02a009dc Update documentation for interaction session management 2026-02-15 16:52:44 +01:00
Tobias Gesellchen f20cfcb319 Enhance interaction session management and cleanup UI 2026-02-15 16:52:44 +01:00
Tobias Gesellchen a453059d6d Enhance interaction recording and analysis features 2026-02-15 16:52:44 +01:00
Tobias Gesellchen 505e6dd760 Refactor data storage to use account-based hierarchy and update Web UI 2026-02-15 15:36:01 +01:00
Tobias Gesellchen 735187cae8 docs: link README.md in SUMMARY.md to fix TestDocsConsistency 2026-02-15 00:50:29 +01:00
Tobias Gesellchen e8622cc382 docs: add Jekyll build step to workflow 2026-02-15 00:38:04 +01:00
Tobias Gesellchen c59052bdb4 docs: improve Jekyll configuration with minimal theme and relative links plugin 2026-02-15 00:35:32 +01:00
Tobias Gesellchen 15a6c4b0a0 docs: add Jekyll configuration with Cayman theme 2026-02-15 00:35:19 +01:00
Tobias Gesellchen ae3a3765db docs: add landing page for GitHub Pages 2026-02-15 00:32:32 +01:00
Tobias Gesellchen 59019cf55c docs: deploy documentation to GitHub Pages and update links in Web UI and README 2026-02-15 00:30:28 +01:00
Tobias Gesellchen 5e612e57ec Fix golangci-lint issues in main.go 2026-02-15 00:27:25 +01:00
Tobias Gesellchen 02026a9f3a Add configurable shortcuts and log them on startup 2026-02-15 00:27:25 +01:00
Tobias Gesellchen aaf067088a Auto-create missing configuration files with default values 2026-02-15 00:27:25 +01:00
Tobias Gesellchen 358ea18138 Implement device merging logic and web-based device removal 2026-02-15 00:15:09 +01:00
Tobias Gesellchen 0c5c1803a5 docs: fix broken documentation links across multiple files 2026-02-14 23:03:53 +01:00
Tobias Gesellchen cdf80a793e docs: fix broken documentation links across multiple files 2026-02-14 23:03:53 +01:00
Tobias Gesellchen b511e052e2 docs: fix broken documentation links and update CI workflow paths 2026-02-14 23:03:53 +01:00
Tobias Gesellchen b7197a8679 Add version visibility and discovery controls to Web UI and API 2026-02-14 23:03:53 +01:00
Tobias Gesellchen 5bfc24b7fb Use v0.18.1 version as default 2026-02-14 22:21:43 +01:00
Tobias Gesellchen 1e61adbb46 Integrate self-update logic into Raspberry Pi installer and simplify update workflow 2026-02-14 22:21:43 +01:00
Tobias Gesellchen b8ab4b5723 Enhance Raspberry Pi installer and modernize systemd deployment documentation 2026-02-14 21:53:24 +01:00
Tobias Gesellchen 5da7e001b2 Add a Systemd install script 2026-02-14 21:53:24 +01:00
Tobias Gesellchen 5269c05e56 Enhance settings management with persistence and explicit saving, including SAN updates and unit tests 2026-02-14 21:50:36 +01:00
Tobias Gesellchen d7a15c4dbe Minor cleanup and formatting fixes in docs handler and setup manager 2026-02-14 18:26:52 +01:00
Tobias Gesellchen 701889076d Refactor documentation structure, add SUMMARY.md sidebar, and automated consistency checks 2026-02-14 18:26:52 +01:00
Tobias Gesellchen 3acc983183 Cleanup the web ui/flow 2026-02-14 18:26:52 +01:00
187 changed files with 22446 additions and 1093 deletions
+17
View File
@@ -41,3 +41,20 @@ PREFERRED_DEVICES="Living Room@192.168.1.100:8090;Kitchen@192.168.1.101;192.168.
# Alternative format examples:
# PREFERRED_DEVICES="192.168.178.35;192.168.178.28"
# PREFERRED_DEVICES="SoundTouch 10@192.168.178.35;SoundTouch 20@192.168.178.28"
# Spotify Integration
# Create an app at https://developer.spotify.com/dashboard
# SPOTIFY_CLIENT_ID=your_client_id
# SPOTIFY_CLIENT_SECRET=your_client_secret
# Auth confirmation url using GET, works in browsers
# SPOTIFY_REDIRECT_URI=https://your-server.example.com/mgmt/spotify/callback
# Auth confirmation url using POST, works with the ueberboese-app (https://github.com/julius-d/ueberboese-app)
# SPOTIFY_REDIRECT_URI=https://your-server.example.com/mgmt/spotify/confirm
# Management API Authentication
# Protects /mgmt/* endpoints (Spotify token access, account management)
MGMT_USERNAME=admin
MGMT_PASSWORD=change_me!
# External base URL (required when behind a reverse proxy for OAuth callbacks)
# BASE_URL=https://your-server.example.com
+4 -3
View File
@@ -149,8 +149,8 @@ jobs:
# Check that all documented endpoints exist in code
echo "Validating API documentation consistency..."
# Extract endpoint patterns from cookbook
if [ -f "docs/API-COOKBOOK.md" ]; then
# Check API cookbook
if [ -f "docs/reference/API-COOKBOOK.md" ]; then
echo "✓ API Cookbook exists"
else
echo "✗ API Cookbook missing"
@@ -158,7 +158,7 @@ jobs:
fi
# Check getting started guide
if [ -f "docs/GETTING-STARTED.md" ]; then
if [ -f "docs/guides/GETTING-STARTED.md" ]; then
echo "✓ Getting Started guide exists"
else
echo "✗ Getting Started guide missing"
@@ -253,6 +253,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
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 }}
+37
View File
@@ -0,0 +1,37 @@
name: Deploy Documentation
on:
push:
branches:
- main
paths:
- 'docs/**'
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Build with Jekyll
uses: actions/jekyll-build-pages@v1
with:
source: 'docs/'
destination: '_site'
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
with:
path: '_site'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+8 -4
View File
@@ -133,10 +133,13 @@ jobs:
local CMD_PATH=$2
local OUTPUT_NAME
# Ensure build directory exists
mkdir -p build
if [[ "${{ matrix.goos }}" == "windows" ]]; then
OUTPUT_NAME="${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}.exe"
OUTPUT_NAME="build/${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}.exe"
else
OUTPUT_NAME="${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}"
OUTPUT_NAME="build/${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}"
fi
echo "Building $BINARY_NAME: $OUTPUT_NAME"
@@ -193,8 +196,8 @@ jobs:
with:
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm }}
path: |
soundtouch-cli-v*
soundtouch-service-v*
build/soundtouch-cli-v*
build/soundtouch-service-v*
retention-days: 1
checksums:
@@ -512,6 +515,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+2
View File
@@ -19,6 +19,7 @@ dist/
/example-unified
/mdns-scanner
/websocket-demo
/main
# Environment configuration
.env
@@ -28,6 +29,7 @@ docker-compose.override.yml
# Test coverage reports
coverage.out
coverage*.out
coverage.html
*.prof
+4 -4
View File
@@ -76,7 +76,7 @@ When filing a bug report, include:
Feature requests are welcome! Please:
1. **Check if the feature already exists** in documentation
2. **Verify it's supported by the SoundTouch API** (see [official API docs](docs/API-Endpoints-Overview.md))
2. **Verify it's supported by the SoundTouch API** (see [official API docs](docs/reference/API-ENDPOINTS.md))
3. **Explain the use case** and how it benefits users
### 🔧 Contributing Code
@@ -469,10 +469,10 @@ Contributors will be:
- [Go Documentation](https://golang.org/doc/)
- [Effective Go](https://golang.org/doc/effective_go.html)
- [Bose SoundTouch API Documentation](docs/API-Endpoints-Overview.md)
- [Bose SoundTouch API Documentation](docs/reference/API-ENDPOINTS.md)
- [Project Architecture](docs/PROJECT-PATTERNS.md)
- [Development Status](docs/STATUS.md)
- [Development Status](docs/archive/STATUS.md)
---
**Thank you for contributing!** Every contribution helps make this library better for the entire SoundTouch community.
**Thank you for contributing!** Every contribution helps make this library better for the entire SoundTouch community.
+16 -2
View File
@@ -1,5 +1,12 @@
# Build stage
FROM golang:1.26.0-alpine AS builder
FROM --platform=$BUILDPLATFORM golang:1.26.0-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
# We should not set defaults here, but rely on BuildKit to set them matching the BUILDPLATFORM
ARG TARGETARCH
ARG TARGETOS
ARG TARGETVARIANT
WORKDIR /app
@@ -11,7 +18,11 @@ RUN go mod download
COPY . .
# Build the soundtouch-service
RUN CGO_ENABLED=0 GOOS=linux go build -o /soundtouch-service ./cmd/soundtouch-service
RUN if [ "${TARGETARCH}" = "arm" ] && [ -n "${TARGETVARIANT}" ]; then \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} GOARM=${TARGETVARIANT#v} go build -o /soundtouch-service ./cmd/soundtouch-service; \
else \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /soundtouch-service ./cmd/soundtouch-service; \
fi
# Final stage
FROM alpine:3.23
@@ -24,6 +35,9 @@ 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
+40 -28
View File
@@ -17,12 +17,18 @@ A comprehensive solution for controlling and preserving Bose SoundTouch devices,
-**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
- 📻 **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
- 🔧 **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
@@ -61,7 +67,7 @@ soundtouch-cli --host 192.168.1.100 volume set --level 50
soundtouch-cli --host 192.168.1.100 preset list
```
For full CLI documentation, see [docs/CLI-REFERENCE.md](docs/CLI-REFERENCE.md).
For full CLI documentation, see the [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html).
### SoundTouch Service (Cloud Shutdown Protection)
@@ -69,21 +75,27 @@ The `soundtouch-service` is a local server that emulates Bose's cloud services.
#### 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.
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](docs/CLOUD-SHUTDOWN-GUIDE.md).
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 [docs/SOUNDTOUCH-SERVICE.md](docs/SOUNDTOUCH-SERVICE.md).
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
@@ -312,8 +324,8 @@ func main() {
Port: 8090,
})
// Play Text-to-Speech message
err := c.PlayTTS("Welcome home!", "your-app-key", 70)
// 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)
}
@@ -376,19 +388,19 @@ This library supports all Bose SoundTouch-compatible devices, including:
## Documentation
- 📖 [Contributing Guide](CONTRIBUTING.md) - How to contribute to the project
- 📚 [API Reference](docs/API-Endpoints-Overview.md) - Complete endpoint documentation
- 🔧 [CLI Reference](docs/CLI-REFERENCE.md) - Command-line tool guide
- 🌐 [SoundTouch Service Guide](docs/SOUNDTOUCH-SERVICE.md) - Local service setup and migration
- 🎯 [Getting Started](docs/GETTING-STARTED.md) - Detailed setup and usage
- 📻 [Preset Quick Start](docs/PRESET-QUICKSTART.md) - Favorite content management
- 🧭 [Navigation Guide](docs/NAVIGATION-GUIDE.md) - Content browsing and station management
- 📋 [Navigation API Reference](docs/API-NAVIGATION-REFERENCE.md) - Navigation API documentation
- ⚙️ [Advanced Features](docs/SYSTEM-ENDPOINTS.md) - Advanced functionality
- 🏠 [Multiroom Setup](docs/zone-management.md) - Zone configuration guide
- ⚡ [WebSocket Events](docs/websocket-events.md) - Real-time event handling
- 🔔 [Speaker Notifications](docs/SPEAKER_ENDPOINT.md) - TTS and audio notifications guide
- 🔍 [Device Discovery](docs/DISCOVERY.md) - Discovery configuration
- 🛠️ [Troubleshooting](docs/TROUBLESHOOTING.md) - Common issues and solutions
- 📚 [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
## Development
@@ -476,7 +488,7 @@ 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 and based on SoundCork's Python implementation. SoundCork pioneered the approach of intercepting and emulating Bose's cloud services, providing the foundation for offline SoundTouch operation.
- **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
@@ -523,13 +535,13 @@ If you discover new endpoints, features, or improvements through this library, p
- 🐛 **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**: Browse the [docs/](docs/) directory
- 🔍 **New Discoveries**: See [Undocumented Community Features](docs/UNDOCUMENTED-COMMUNITY-FEATURES.md) for advanced API research
- 🌐 **Upstream Analysis**: [Upstream URLs & Domains](docs/UPSTREAM-URLS-ANALYSIS.md) for cloud dependency research
- 🔧 **Redirection Guide**: [Device Redirect Methods](docs/DEVICE-REDIRECT-METHODS.md) for custom service setup
- 🐣 **Initial Setup**: [Device Initial Setup Variants](docs/DEVICE-INITIAL-SETUP.md) for out-of-the-box configuration
- 📜 **Logging & Debugging**: [Device Logging Guide](docs/DEVICE-LOGGING.md) for accessing system and traffic logs
- 🔒 **HTTPS & CA Setup**: [HTTPS & Custom CA Guide](docs/HTTPS-SETUP.md) for secure `/etc/hosts` redirection
- 📖 **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)
---
+242
View File
@@ -0,0 +1,242 @@
// Package main provides a debug tool for analyzing device consolidation and migration scenarios.
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func main() {
if len(os.Args) < 2 {
fmt.Println("Usage: debug-consolidation <data-directory>")
fmt.Println("Example: debug-consolidation /var/lib/soundtouch-service")
os.Exit(1)
}
dataDir := os.Args[1]
fmt.Printf("🔍 Analyzing device consolidation in: %s\n", dataDir)
// Initialize datastore
ds := datastore.NewDataStore(dataDir)
// List all devices
devices, err := ds.ListAllDevices()
if err != nil {
log.Fatalf("Failed to list devices: %v", err)
}
fmt.Printf("📱 Found %d device entries:\n", len(devices))
for i := range devices {
device := &devices[i]
fmt.Printf(" %d. %s (Account: %s)\n", i+1, device.DeviceID, device.AccountID)
fmt.Printf(" Name: %s\n", device.Name)
fmt.Printf(" IP: %s, MAC: %s, Serial: %s\n",
device.IPAddress, device.MacAddress, device.DeviceSerialNumber)
// Check directory contents
deviceDir := ds.AccountDeviceDir(device.AccountID, device.DeviceID)
analyzeDeviceDirectory(deviceDir, device.DeviceID)
fmt.Println()
}
// Group devices by potential physical device
fmt.Println("🔄 Analyzing potential consolidation opportunities:")
deviceGroups := groupDevicesByIdentity(devices)
for i, group := range deviceGroups {
if len(group) <= 1 {
continue
}
fmt.Printf(" Group %d - %d entries for same physical device:\n", i+1, len(group))
for i := range group {
device := &group[i]
deviceDir := ds.AccountDeviceDir(device.AccountID, device.DeviceID)
fileCount := countFiles(deviceDir)
fmt.Printf(" - %s (%d files)\n", device.DeviceID, fileCount)
}
// Recommend consolidation target
macDevice := findMACBasedDevice(group)
if macDevice != nil {
fmt.Printf(" → Recommend keeping: %s (MAC-based)\n", macDevice.DeviceID)
} else {
fmt.Printf(" → No clear MAC-based target found\n")
}
fmt.Println()
}
}
func analyzeDeviceDirectory(dirPath, deviceID string) {
entries, err := os.ReadDir(dirPath)
if err != nil {
fmt.Printf(" Directory: %s (Error: %v)\n", dirPath, err)
return
}
fmt.Printf(" Directory: %s (%d files)\n", dirPath, len(entries))
// Check for important files
importantFiles := []string{"DeviceInfo.xml", "Presets.xml", "Recents.xml", "Sources.xml"}
for _, fileName := range importantFiles {
filePath := filepath.Join(dirPath, fileName)
if stat, err := os.Stat(filePath); err == nil {
status := "✓"
if stat.Size() == 0 {
status = "⚠️ (empty)"
} else if stat.Size() < 100 {
status = "⚠️ (very small)"
}
fmt.Printf(" %s %s (%d bytes)\n", status, fileName, stat.Size())
} else {
fmt.Printf(" ❌ %s (missing)\n", fileName)
}
}
// Check if deviceID looks like MAC address
if isLikelyMACAddress(deviceID) {
fmt.Printf(" 📍 Device ID appears to be MAC address format\n")
} else {
fmt.Printf(" 📍 Device ID appears to be %s format\n", guessIDType(deviceID))
}
}
func countFiles(dirPath string) int {
entries, err := os.ReadDir(dirPath)
if err != nil {
return 0
}
count := 0
for _, entry := range entries {
if !entry.IsDir() {
count++
}
}
return count
}
func groupDevicesByIdentity(devices []models.ServiceDeviceInfo) [][]models.ServiceDeviceInfo {
var groups [][]models.ServiceDeviceInfo
// Simple grouping by MAC address and serial number
macGroups := make(map[string][]models.ServiceDeviceInfo)
serialGroups := make(map[string][]models.ServiceDeviceInfo)
ipGroups := make(map[string][]models.ServiceDeviceInfo)
for i := range devices {
device := &devices[i]
// Group by MAC address
if device.MacAddress != "" {
macGroups[device.MacAddress] = append(macGroups[device.MacAddress], *device)
}
// Group by serial number
if device.DeviceSerialNumber != "" {
serialGroups[device.DeviceSerialNumber] = append(serialGroups[device.DeviceSerialNumber], *device)
}
// Group by IP address
if device.IPAddress != "" {
ipGroups[device.IPAddress] = append(ipGroups[device.IPAddress], *device)
}
}
// Merge groups - prioritize MAC address grouping
processed := make(map[string]bool)
for _, macDevices := range macGroups {
if len(macDevices) > 1 {
groups = append(groups, macDevices)
for i := range macDevices {
processed[macDevices[i].DeviceID] = true
}
}
}
// Check for serial number groups not already processed
for _, serialDevices := range serialGroups {
if len(serialDevices) > 1 {
unprocessed := []models.ServiceDeviceInfo{}
for i := range serialDevices {
if !processed[serialDevices[i].DeviceID] {
unprocessed = append(unprocessed, serialDevices[i])
}
}
if len(unprocessed) > 1 {
groups = append(groups, unprocessed)
for i := range unprocessed {
processed[unprocessed[i].DeviceID] = true
}
}
}
}
return groups
}
func findMACBasedDevice(devices []models.ServiceDeviceInfo) *models.ServiceDeviceInfo {
for i := range devices {
if isLikelyMACAddress(devices[i].DeviceID) {
return &devices[i]
}
}
return nil
}
func isLikelyMACAddress(id string) bool {
// MAC addresses are typically 12 hex characters without separators
// or 17 characters with separators (XX:XX:XX:XX:XX:XX)
if len(id) == 12 {
for _, c := range id {
if (c < '0' || c > '9') && (c < 'A' || c > 'F') && (c < 'a' || c > 'f') {
return false
}
}
return true
}
return false
}
func guessIDType(id string) string {
if len(id) > 15 && (id[0] == 'I' || id[0] == 'K') {
return "serial number"
}
// Check if it looks like an IP address
if len(id) >= 7 && len(id) <= 15 {
dotCount := 0
for _, c := range id {
if c == '.' {
dotCount++
} else if c < '0' || c > '9' {
break
}
}
if dotCount == 3 {
return "IP address"
}
}
return "unknown"
}
+10
View File
@@ -389,6 +389,10 @@ func handleSpecialMessage(message *models.SpecialMessage, filters map[string]boo
if !filters["userActivity"] {
return
}
case models.MessageTypeUserInactivity:
if !filters["userInactivity"] {
return
}
}
}
@@ -402,6 +406,12 @@ func handleSpecialMessage(message *models.SpecialMessage, filters map[string]boo
case models.MessageTypeUserActivity:
fmt.Printf("\n👤 User Activity [%s]\n", message.DeviceID)
if verbose {
fmt.Printf(" ⏰ Timestamp: %s\n", message.Timestamp.Format("15:04:05"))
}
case models.MessageTypeUserInactivity:
fmt.Printf("\n💤 User Inactivity [%s]\n", message.DeviceID)
if verbose {
fmt.Printf(" ⏰ Timestamp: %s\n", message.Timestamp.Format("15:04:05"))
}
+8 -19
View File
@@ -2,7 +2,6 @@ package main
import (
"fmt"
"net/url"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/models"
@@ -35,23 +34,12 @@ func playTTS(c *cli.Context) error {
return err
}
// URL encode the text for Google TTS
encodedText := url.QueryEscape(text)
// Build TTS URL with language support
ttsURL := fmt.Sprintf("http://translate.google.com/translate_tts?ie=UTF-8&tl=%s&client=tw-ob&q=%s", language, encodedText)
// Create PlayInfo for TTS
playInfo := &models.PlayInfo{
URL: ttsURL,
AppKey: appKey,
Service: "TTS Notification",
Message: "Google TTS",
Reason: text,
}
var playInfo *models.PlayInfo
if volume > 0 {
playInfo.SetVolume(volume)
playInfo = models.NewTTSPlayInfo(text, appKey, language, volume)
} else {
playInfo = models.NewTTSPlayInfo(text, appKey, language)
}
err = client.PlayCustom(playInfo)
@@ -121,10 +109,11 @@ func playURL(c *cli.Context) error {
}
// Create PlayInfo for URL content
playInfo := models.NewURLPlayInfo(urlStr, appKey, service, message, reason)
var playInfo *models.PlayInfo
if volume > 0 {
playInfo.SetVolume(volume)
playInfo = models.NewURLPlayInfo(urlStr, appKey, service, message, reason, volume)
} else {
playInfo = models.NewURLPlayInfo(urlStr, appKey, service, message, reason)
}
err = client.PlayCustom(playInfo)
+609 -120
View File
@@ -5,10 +5,11 @@ package main
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
@@ -17,11 +18,13 @@ import (
"strings"
"time"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/urfave/cli/v2"
@@ -80,10 +83,15 @@ func main() {
EnvVars: []string{"BIND_ADDR"},
},
&cli.StringFlag{
Name: "target-url",
Usage: "URL for Python-based service components (legacy)",
Name: "soundcork-url",
Usage: "URL for Soundcork-based service components (legacy)",
Value: "http://localhost:8001",
EnvVars: []string{"PYTHON_BACKEND_URL", "TARGET_URL"},
EnvVars: []string{"SOUNDCORK_BACKEND_URL", "TARGET_URL"},
},
&cli.BoolFlag{
Name: "enable-soundcork-proxy",
Usage: "Enable proxying unknown requests to the Soundcork backend",
EnvVars: []string{"ENABLE_SOUNDCORK_PROXY"},
},
&cli.StringFlag{
Name: "data-dir",
@@ -132,25 +140,192 @@ func main() {
Value: "5m",
EnvVars: []string{"DISCOVERY_INTERVAL"},
},
&cli.BoolFlag{
Name: "dns-discovery",
Usage: "Enable DNS discovery server",
EnvVars: []string{"ENABLE_DNS_DISCOVERY"},
},
&cli.StringFlag{
Name: "dns-upstream",
Usage: "Upstream DNS server(s) for non-Bose queries (comma-separated). If empty, /etc/resolv.conf is used.",
Value: "",
EnvVars: []string{"DNS_UPSTREAM"},
},
&cli.StringFlag{
Name: "dns-bind",
Usage: "Bind address for the DNS discovery server",
Value: ":53",
EnvVars: []string{"DNS_BIND_ADDR"},
},
&cli.StringFlag{
Name: "spotify-client-id",
Usage: "Spotify OAuth client ID",
EnvVars: []string{"SPOTIFY_CLIENT_ID"},
},
&cli.StringFlag{
Name: "spotify-client-secret",
Usage: "Spotify OAuth client secret",
EnvVars: []string{"SPOTIFY_CLIENT_SECRET"},
},
&cli.StringFlag{
Name: "spotify-redirect-uri",
Usage: "Spotify OAuth redirect URI",
Value: "ueberboese-login://spotify",
EnvVars: []string{"SPOTIFY_REDIRECT_URI"},
},
&cli.StringFlag{
Name: "mgmt-username",
Usage: "Management API username for HTTP Basic Auth",
Value: "admin",
EnvVars: []string{"MGMT_USERNAME"},
},
&cli.StringFlag{
Name: "mgmt-password",
Usage: "Management API password for HTTP Basic Auth",
Value: "change_me!",
EnvVars: []string{"MGMT_PASSWORD"},
},
&cli.StringFlag{
Name: "base-url",
Usage: "External base URL for OAuth callbacks behind reverse proxy",
EnvVars: []string{"BASE_URL"},
},
&cli.BoolFlag{
Name: "mirror-enabled",
Usage: "Enable background mirroring to Bose Cloud",
EnvVars: []string{"MIRROR_ENABLED"},
},
&cli.StringSliceFlag{
Name: "mirror-endpoints",
Usage: "Endpoints to mirror to Bose Cloud (comma-separated or multiple flags)",
EnvVars: []string{"MIRROR_ENDPOINTS"},
},
&cli.StringSliceFlag{
Name: "internal-paths",
Usage: "Paths for internal requests (comma-separated or multiple flags)",
EnvVars: []string{"INTERNAL_PATHS"},
},
&cli.BoolFlag{
Name: "migration-enabled",
Usage: "Enable device directory migration from serial to MAC-based structure",
Value: true,
EnvVars: []string{"MIGRATION_ENABLED"},
},
&cli.BoolFlag{
Name: "migration-dry-run",
Usage: "Log what would be migrated without actually doing it",
EnvVars: []string{"MIGRATION_DRY_RUN"},
},
&cli.StringFlag{
Name: "preferred-source",
Usage: "Preferred source of truth (local or upstream)",
Value: "local",
EnvVars: []string{"PREFERRED_SOURCE"},
},
},
Action: func(c *cli.Context) error {
config := loadConfig(c)
ds := initDataStore(config.dataDir)
persisted := applyPersistedSettings(ds, &config)
if persisted.ServerURL == "" {
log.Printf("Creating default settings.json in %s", config.dataDir)
persisted = createDefaultSettings(ds, config)
}
// Recalculate domains if settings changed
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "localhost"
}
config.domains = getDomains(config.serverURL, config.httpsServerURL, hostname)
cm := initCertificateManager(config.dataDir)
sm := setup.NewManager(config.serverURL, ds, cm)
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record)
sm.MgmtUsername = config.mgmtUsername
sm.MgmtPassword = config.mgmtPassword
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record, config.enableSoundcorkProxy, config.migrationEnabled, config.migrationDryRun)
sm.GetDNSRunning = server.GetDNSRunning
server.SetSoundcorkURL(config.soundcorkURL)
server.SetHTTPServerURL(config.httpsServerURL)
server.SetVersionInfo(version, commit, date)
server.SetDiscoverySettings(config.discoveryInterval, persisted.DiscoveryEnabled)
server.SetDNSSettings(persisted.DNSEnabled, strings.Join(persisted.DNSUpstream, ","), persisted.DNSBindAddr)
server.SetMirrorSettings(persisted.MirrorEnabled, persisted.MirrorEndpoints, persisted.PreferredSource)
server.SetInternalPaths(persisted.InternalPaths)
server.SetSpotifyConfig(config.spotifyClientID, config.spotifyClientSecret, config.spotifyRedirectURI)
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)
}
// Load and set initial DNS discoveries
dnsDiscoveries, err := ds.LoadDNSDiscoveries()
if err == nil && len(dnsDiscoveries) > 0 {
initial := make(map[string]*discovery.DiscoveredHost)
for _, entry := range dnsDiscoveries {
initial[entry.Hostname] = &discovery.DiscoveredHost{
Hostname: entry.Hostname,
FirstSeen: entry.FirstSeen,
LastSeen: entry.LastSeen,
QueryCount: entry.QueryCount,
IsBoseService: entry.IsBoseService,
IsIntercepted: entry.IsIntercepted,
RemoteAddr: entry.RemoteAddr,
}
}
server.SetDNSDiscoveries(initial)
}
server.SetShortcuts(persisted.Shortcuts)
for path, status := range persisted.Shortcuts {
log.Printf("Warning: configured shortcut: %s -> %d", path, status)
}
recorder := proxy.NewRecorder(config.dataDir)
recorder.Redact = config.redact
patternsPath := filepath.Join(config.dataDir, "patterns.json")
patterns, err := proxy.LoadPatterns(patternsPath)
if err == nil && len(patterns) > 0 {
recorder.Patterns = patterns
} else if err != nil {
if err != nil {
log.Printf("Warning: Failed to load patterns from %s: %v", patternsPath, err)
}
if len(patterns) == 0 {
log.Printf("Creating default patterns at %s", patternsPath)
patterns = proxy.DefaultPatterns()
patternsData, jsonErr := json.MarshalIndent(patterns, "", " ")
if jsonErr != nil {
log.Printf("Warning: Failed to marshal default patterns: %v", jsonErr)
} else {
_ = os.WriteFile(patternsPath, patternsData, 0644)
}
}
if len(patterns) > 0 {
recorder.Patterns = patterns
}
server.SetRecorder(recorder)
tlsConfig, err := cm.GetServerTLSConfig(config.domains)
@@ -158,13 +333,11 @@ func main() {
log.Printf("Warning: Failed to setup TLS: %v", err)
}
pyProxy := setupPythonProxy(config.targetURL, config.redact, config.logBody, recorder, server)
startDeviceDiscovery(server)
startDeviceDiscovery(server, config.discoveryInterval)
r := setupRouter(server)
r := setupRouter(server, pyProxy)
log.Printf("Go service starting on %s, proxying to %s", config.serverURL, config.targetURL)
log.Printf("Go service starting on %s, proxying to %s", config.serverURL, config.soundcorkURL)
if tlsConfig != nil {
startHTTPSServer(config.httpsAddr, r, tlsConfig, config.httpsServerURL)
@@ -198,19 +371,34 @@ func showVersionInfo(_ *cli.Context) error {
}
type serviceConfig struct {
port string
bindAddr string
addr string
targetURL string
dataDir string
serverURL string
httpsServerURL string
httpsAddr string
redact bool
logBody bool
record bool
discoveryInterval time.Duration
domains []string
port string
bindAddr string
addr string
soundcorkURL string
dataDir string
serverURL string
httpsServerURL string
httpsAddr string
redact bool
logBody bool
record bool
enableSoundcorkProxy bool
dnsEnabled bool
dnsUpstream string
dnsBind string
mirrorEnabled bool
mirrorEndpoints []string
internalPaths []string
discoveryInterval time.Duration
domains []string
spotifyClientID string
spotifyClientSecret string
spotifyRedirectURI string
mgmtUsername string
mgmtPassword string
migrationEnabled bool
migrationDryRun bool
preferredSource string
}
func loadConfig(c *cli.Context) serviceConfig {
@@ -222,7 +410,7 @@ func loadConfig(c *cli.Context) serviceConfig {
addr = ":" + port
}
targetURL := c.String("target-url")
soundcorkURL := c.String("soundcork-url")
dataDir := c.String("data-dir")
hostname, _ := os.Hostname()
@@ -254,6 +442,11 @@ func loadConfig(c *cli.Context) serviceConfig {
redact := c.Bool("redact-logs")
logBody := c.Bool("log-bodies")
record := c.Bool("record-interactions")
enableSoundcorkProxy := c.Bool("enable-soundcork-proxy")
dnsEnabled := c.Bool("dns-discovery")
dnsUpstream := c.String("dns-upstream")
dnsBind := c.String("dns-bind")
discoveryIntervalStr := c.String("discovery-interval")
@@ -264,34 +457,72 @@ func loadConfig(c *cli.Context) serviceConfig {
discoveryInterval = 5 * time.Minute
}
spotifyClientID := c.String("spotify-client-id")
spotifyClientSecret := c.String("spotify-client-secret")
spotifyRedirectURI := c.String("spotify-redirect-uri")
mgmtUsername := c.String("mgmt-username")
mgmtPassword := c.String("mgmt-password")
mirrorEnabled := c.Bool("mirror-enabled")
mirrorEndpoints := c.StringSlice("mirror-endpoints")
internalPaths := c.StringSlice("internal-paths")
migrationEnabled := c.Bool("migration-enabled")
migrationDryRun := c.Bool("migration-dry-run")
preferredSource := c.String("preferred-source")
return serviceConfig{
port: port,
bindAddr: bindAddr,
addr: addr,
targetURL: targetURL,
dataDir: dataDir,
serverURL: serverURL,
httpsServerURL: httpsServerURL,
httpsAddr: httpsAddr,
redact: redact,
logBody: logBody,
record: record,
discoveryInterval: discoveryInterval,
domains: domains,
port: port,
bindAddr: bindAddr,
addr: addr,
soundcorkURL: soundcorkURL,
dataDir: dataDir,
serverURL: serverURL,
httpsServerURL: httpsServerURL,
httpsAddr: httpsAddr,
redact: redact,
logBody: logBody,
record: record,
enableSoundcorkProxy: enableSoundcorkProxy,
dnsEnabled: dnsEnabled,
dnsUpstream: dnsUpstream,
dnsBind: dnsBind,
mirrorEnabled: mirrorEnabled,
mirrorEndpoints: mirrorEndpoints,
internalPaths: internalPaths,
discoveryInterval: discoveryInterval,
domains: domains,
spotifyClientID: spotifyClientID,
spotifyClientSecret: spotifyClientSecret,
spotifyRedirectURI: spotifyRedirectURI,
mgmtUsername: mgmtUsername,
mgmtPassword: mgmtPassword,
migrationEnabled: migrationEnabled,
migrationDryRun: migrationDryRun,
preferredSource: preferredSource,
}
}
func getDomains(serverURL, httpsServerURL, hostname string) []string {
domainsMap := map[string]bool{
"streaming.bose.com": true,
"updates.bose.com": true,
"stats.bose.com": true,
"bmx.bose.com": true,
"content.api.bose.io": true,
setup.TestDomain: true,
hostname: true,
"localhost": true,
"127.0.0.1": true,
// RFC-compliant wildcards for API patterns
"*.api.bose.io": true,
"*.api.bosecm.com": true,
// Core Bose domains (keep specific ones for clarity)
"streaming.bose.com": true,
"updates.bose.com": true,
"stats.bose.com": true,
"bmx.bose.com": true,
"worldwide.bose.com": true,
"music.api.bose.com": true,
"streamingoauth.bose.com": true,
"bosecm.com": true,
"bose.io": true,
"bose-prod.apigee.net": true,
"bose-test.apigee.net": true,
// Local service domains
setup.TestDomain: true,
hostname: true,
"localhost": true,
"127.0.0.1": true,
}
if u, err := url.Parse(serverURL); err == nil && u.Hostname() != "" {
@@ -310,6 +541,88 @@ func getDomains(serverURL, httpsServerURL, hostname string) []string {
return domains
}
func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) datastore.Settings {
persisted, err := ds.GetSettings()
if err != nil {
return datastore.Settings{}
}
// Only override CLI values if settings file exists
// If no settings file exists, GetSettings returns empty Settings{} and we should preserve CLI values
settingsPath := filepath.Join(ds.DataDir, "settings.json")
if _, err := os.Stat(settingsPath); os.IsNotExist(err) {
return datastore.Settings{}
}
if persisted.ServerURL != "" {
config.serverURL = persisted.ServerURL
}
if persisted.SoundcorkURL != "" {
config.soundcorkURL = persisted.SoundcorkURL
}
if persisted.HTTPServerURL != "" {
config.httpsServerURL = persisted.HTTPServerURL
}
if persisted.DiscoveryInterval != "" {
if d, durErr := time.ParseDuration(persisted.DiscoveryInterval); durErr == nil {
config.discoveryInterval = d
}
}
config.redact = persisted.RedactLogs
config.logBody = persisted.LogBodies
config.record = persisted.RecordInteractions
config.enableSoundcorkProxy = persisted.EnableSoundcorkProxy
config.dnsEnabled = persisted.DNSEnabled
if len(persisted.DNSUpstream) > 0 {
config.dnsUpstream = strings.Join(persisted.DNSUpstream, ",")
}
if persisted.DNSBindAddr != "" {
config.dnsBind = persisted.DNSBindAddr
}
config.mirrorEnabled = persisted.MirrorEnabled
config.mirrorEndpoints = persisted.MirrorEndpoints
config.preferredSource = persisted.PreferredSource
config.internalPaths = persisted.InternalPaths
return persisted
}
func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datastore.Settings {
settings := datastore.Settings{
ServerURL: config.serverURL,
SoundcorkURL: config.soundcorkURL,
HTTPServerURL: config.httpsServerURL,
RedactLogs: config.redact,
LogBodies: config.logBody,
RecordInteractions: config.record,
DiscoveryInterval: config.discoveryInterval.String(),
DiscoveryEnabled: true,
EnableSoundcorkProxy: config.enableSoundcorkProxy,
DNSEnabled: config.dnsEnabled,
DNSUpstream: strings.Split(config.dnsUpstream, ","),
DNSBindAddr: config.dnsBind,
MirrorEnabled: config.mirrorEnabled,
MirrorEndpoints: config.mirrorEndpoints,
PreferredSource: config.preferredSource,
InternalPaths: config.internalPaths,
Shortcuts: map[string]int{
"/.well-known/appspecific/com.chrome.devtools.json": http.StatusNotFound,
"/sw.js": http.StatusNotFound,
},
}
_ = ds.SaveSettings(settings)
return settings
}
func initDataStore(dataDir string) *datastore.DataStore {
ds := datastore.NewDataStore(dataDir)
if err := ds.Initialize(); err != nil {
@@ -328,55 +641,26 @@ func initCertificateManager(dataDir string) *certmanager.CertificateManager {
return cm
}
func setupPythonProxy(targetURL string, redact, logBody bool, recorder *proxy.Recorder, server *handlers.Server) *httputil.ReverseProxy {
target, err := url.Parse(targetURL)
if err != nil {
log.Fatalf("Failed to parse target URL: %v", err)
}
pyProxy := httputil.NewSingleHostReverseProxy(target)
pyProxy.ModifyResponse = func(res *http.Response) error {
if etags, ok := res.Header["Etag"]; ok {
delete(res.Header, "Etag")
res.Header["ETag"] = etags
}
currentLp := proxy.NewLoggingProxy(target.String(), redact)
currentLp.LogBody = logBody
currentLp.RecordEnabled = server.GetRecordEnabled()
currentLp.SetRecorder(recorder)
currentLp.LogResponse(res)
return nil
}
originalPyDirector := pyProxy.Director
pyProxy.Director = func(req *http.Request) {
originalPyDirector(req)
currentLp := proxy.NewLoggingProxy(target.String(), redact)
currentLp.LogBody = logBody
currentLp.RecordEnabled = server.GetRecordEnabled()
currentLp.SetRecorder(recorder)
currentLp.LogRequest(req)
}
return pyProxy
}
func startDeviceDiscovery(server *handlers.Server, interval time.Duration) {
func startDeviceDiscovery(server *handlers.Server) {
go func() {
for {
server.DiscoverDevices(context.Background())
time.Sleep(interval)
currentInterval, enabled := server.GetDiscoverySettings()
if enabled {
server.DiscoverDevices(context.Background())
}
time.Sleep(currentInterval)
}
}()
}
func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.Mux {
func setupRouter(server *handlers.Server) *chi.Mux {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(server.SnapshotMiddleware)
r.Use(server.OriginMiddleware)
r.Use(middleware.Recoverer)
r.Use(server.ShortcutMiddleware)
r.Use(server.MirrorMiddleware)
r.Use(server.RecordMiddleware)
r.Get("/", server.HandleRoot)
@@ -388,6 +672,7 @@ func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.M
r.Get("/media/*", server.HandleMedia())
r.Get("/web/*", server.HandleWeb())
r.Get("/docs/*", server.HandleDocs)
r.Route("/bmx", func(r chi.Router) {
r.Get("/registry/v1/services", server.HandleBMXRegistry)
@@ -397,24 +682,98 @@ func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.M
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
})
// 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)
streamingRoutes := func(r chi.Router) {
r.Get("/sourceproviders", server.HandleMargeSourceProviders)
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.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("/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("/marge", func(r chi.Router) {
r.Get("/streaming/sourceproviders", server.HandleMargeSourceProviders)
r.Get("/accounts/{account}/full", server.HandleMargeAccountFull)
r.Post("/streaming/support/power_on", server.HandleMargePowerOn)
r.Route("/streaming", streamingRoutes)
r.Route("/accounts", accountsRoutes)
r.Get("/updates/soundtouch", server.HandleMargeSoftwareUpdate)
r.Get("/accounts/{account}/devices/{device}/presets", server.HandleMargePresets)
r.Post("/accounts/{account}/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
r.Post("/accounts/{account}/devices/{device}/recents", server.HandleMargeAddRecent)
r.Post("/accounts/{account}/devices", server.HandleMargeAddDevice)
r.Delete("/accounts/{account}/devices/{device}", server.HandleMargeRemoveDevice)
r.Get("/streaming/account/{account}/provider_settings", server.HandleMargeProviderSettings)
r.Get("/streaming/device/{device}/streaming_token", server.HandleMargeStreamingToken)
r.Post("/streaming/support/customersupport", server.HandleMargeCustomerSupport)
})
r.Route("/streaming/stats", func(r chi.Router) {
r.Post("/usage", server.HandleUsageStats)
r.Post("/error", server.HandleErrorStats)
// 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) {
r.Get("/account/{account}", server.HandleMargeAccountProfile)
r.Post("/account/{account}", server.HandleMargeUpdateAccountProfile)
r.Post("/account/{account}/password", server.HandleMargeChangePassword)
})
r.Route("/oauth", func(r chi.Router) {
r.HandleFunc("/*", server.HandleBoseProxy)
})
r.Route("/v1", func(r chi.Router) {
r.Post("/stapp/{deviceId}", server.HandleAppEvents)
r.Post("/scmudc/{deviceId}", server.HandleAppEvents)
})
r.Route("/mgmt", func(r chi.Router) {
// Browser OAuth callback — no auth required (Spotify 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)
// All other management endpoints require Basic Auth.
r.Group(func(r chi.Router) {
r.Use(server.BasicAuthMgmt())
r.Get("/accounts/{accountId}/speakers", server.HandleMgmtListSpeakers)
r.Get("/devices/{deviceId}/events", server.HandleMgmtDeviceEvents)
r.Post("/spotify/init", server.HandleMgmtSpotifyInit)
r.Post("/spotify/confirm", server.HandleMgmtSpotifyConfirm)
r.Get("/spotify/accounts", server.HandleMgmtSpotifyAccounts)
r.Get("/spotify/token", server.HandleMgmtSpotifyToken)
r.Post("/spotify/entity", server.HandleMgmtSpotifyEntity)
r.Post("/spotify/prime", server.HandleMgmtPrimeDevice)
})
})
r.Get("/proxy/*", server.HandleProxyRequest)
@@ -422,46 +781,176 @@ func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.M
r.Route("/setup", func(r chi.Router) {
r.Get("/devices", server.HandleListDiscoveredDevices)
r.Post("/devices", server.HandleAddManualDevice)
r.Delete("/devices/{deviceId}", server.HandleRemoveDevice)
r.Post("/discover", server.HandleTriggerDiscovery)
r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
r.Get("/settings", server.HandleGetSettings)
r.Get("/info/{deviceIP}", server.HandleGetDeviceInfo)
r.Get("/summary/{deviceIP}", server.HandleGetMigrationSummary)
r.Post("/migrate/{deviceIP}", server.HandleMigrateDevice)
r.Post("/revert/{deviceIP}", server.HandleRevertMigration)
r.Post("/reboot/{deviceIP}", server.HandleRebootDevice)
r.Post("/trust-ca/{deviceIP}", server.HandleTrustCACert)
r.Post("/ensure-remote-services/{deviceIP}", server.HandleEnsureRemoteServices)
r.Post("/remove-remote-services/{deviceIP}", server.HandleRemoveRemoteServices)
r.Post("/backup/{deviceIP}", server.HandleBackupConfig)
r.Post("/sync/{deviceIP}", server.HandleInitialSync)
r.Post("/test-connection/{deviceIP}", server.HandleTestConnection)
r.Post("/test-hosts/{deviceIP}", server.HandleTestHostsRedirection)
r.Post("/settings", server.HandleUpdateSettings)
r.Get("/info/{deviceId}", server.HandleGetDeviceInfo)
r.Get("/summary/{deviceId}", server.HandleGetMigrationSummary)
r.Post("/migrate/{deviceId}", server.HandleMigrateDevice)
r.Post("/revert/{deviceId}", server.HandleRevertMigration)
r.Post("/reboot/{deviceId}", server.HandleRebootDevice)
r.Post("/trust-ca/{deviceId}", server.HandleTrustCACert)
r.Post("/ensure-remote-services/{deviceId}", server.HandleEnsureRemoteServices)
r.Post("/remove-remote-services/{deviceId}", server.HandleRemoveRemoteServices)
r.Post("/backup/{deviceId}", server.HandleBackupConfig)
r.Post("/sync/{deviceId}", server.HandleInitialSync)
r.Post("/test-connection/{deviceId}", server.HandleTestConnection)
r.Post("/test-hosts/{deviceId}", server.HandleTestHostsRedirection)
r.Post("/test-dns/{deviceId}", server.HandleTestDNSRedirection)
r.Get("/ca.crt", server.HandleGetCACert)
r.Get("/proxy-settings", server.HandleGetProxySettings)
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
r.Get("/version", server.HandleGetVersionInfo)
r.Get("/interaction-stats", server.HandleGetInteractionStats)
r.Get("/interactions", server.HandleListInteractions)
r.Get("/interaction-content", server.HandleGetInteractionContent)
r.Get("/parity-mismatches", server.HandleListParityMismatches)
r.Delete("/parity-mismatches", server.HandleClearParityMismatches)
r.Get("/interactions/sessions/{session}/download", server.HandleDownloadSession)
r.Delete("/interactions/sessions/{session}", server.HandleDeleteSession)
r.Delete("/interactions/sessions", server.HandleCleanupSessions)
r.Get("/dns-discoveries", server.HandleGetDNSDiscoveries)
r.Get("/dns-discoveries/download", server.HandleDownloadDNSDiscoveries)
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
})
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
pyProxy.ServeHTTP(w, r)
})
r.NotFound(server.HandleNotFound)
return r
}
func startHTTPSServer(httpsAddr string, r http.Handler, tlsConfig *tls.Config, httpsServerURL string) {
// Add custom error logging and connection state tracking
tlsConfig.GetCertificate = func(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) {
// log.Printf("[TLS] Certificate request for ServerName: %s", clientHello.ServerName)
// Use the default certificate selection logic
for _, cert := range tlsConfig.Certificates {
if cert.Leaf != nil {
for _, name := range cert.Leaf.DNSNames {
if matchesDomain(name, clientHello.ServerName) {
// log.Printf("[TLS] ✅ Serving certificate for %s (matched %s)", clientHello.ServerName, name)
return &cert, nil
}
}
}
}
// If no specific match, return the first certificate and log it
if len(tlsConfig.Certificates) > 0 {
// log.Printf("[TLS] ⚠️ No exact match for %s, using default certificate", clientHello.ServerName)
return &tlsConfig.Certificates[0], nil
}
log.Printf("[TLS] ❌ No certificate available for %s", clientHello.ServerName)
return nil, fmt.Errorf("no certificate available for %s", clientHello.ServerName)
}
httpsServer := &http.Server{
Addr: httpsAddr,
Handler: r,
TLSConfig: tlsConfig,
ErrorLog: log.Default(), // Ensure error logging is enabled
}
log.Printf("Go service starting HTTPS on %s", httpsServerURL)
go func() {
if err := httpsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
listener, err := net.Listen("tcp", httpsAddr)
if err != nil {
log.Printf("[TLS] Failed to create listener: %v", err)
return
}
tlsListener := tls.NewListener(listener, tlsConfig)
// Wrap listener to log connection attempts
wrappedListener := &loggingTLSListener{
Listener: tlsListener,
}
if err := httpsServer.Serve(wrappedListener); err != nil && err != http.ErrServerClosed {
log.Printf("HTTPS server error: %v", err)
}
}()
}
// matchesDomain checks if a certificate domain (which may be a wildcard) matches a server name
func matchesDomain(certDomain, serverName string) bool {
if certDomain == serverName {
return true
}
// Handle wildcard certificates (only at the beginning of a label)
if strings.HasPrefix(certDomain, "*.") {
certBase := certDomain[2:] // Remove "*."
// For *.api.bose.io to match events.api.bose.io but not test.content.api.bose.io
// We need to ensure only one label is replaced by the wildcard
if strings.HasSuffix(serverName, "."+certBase) {
// Count dots to ensure we're not matching too many levels
serverPrefix := strings.TrimSuffix(serverName, "."+certBase)
if !strings.Contains(serverPrefix, ".") {
return true
}
}
// Also match the base domain (e.g., api.bose.io matches *.api.bose.io)
if serverName == certBase {
return true
}
}
return false
}
// loggingTLSListener wraps a TLS listener to log connection attempts and handshake failures
type loggingTLSListener struct {
net.Listener
}
func (l *loggingTLSListener) Accept() (net.Conn, error) {
conn, err := l.Listener.Accept()
if err != nil {
return nil, err
}
// Wrap the connection to log TLS handshake results
return &loggingTLSConn{
Conn: conn,
addr: conn.RemoteAddr(),
}, nil
}
// loggingTLSConn wraps a TLS connection to log handshake failures
type loggingTLSConn struct {
net.Conn
addr net.Addr
handshakeLogged bool
}
func (c *loggingTLSConn) Read(b []byte) (n int, err error) {
n, err = c.Conn.Read(b)
// Log TLS handshake failures on first read attempt
if !c.handshakeLogged {
c.handshakeLogged = true
if err != nil {
// Check if this looks like a TLS handshake failure
if strings.Contains(err.Error(), "tls:") ||
strings.Contains(err.Error(), "handshake") ||
strings.Contains(err.Error(), "certificate") {
log.Printf("[TLS] ❌ Handshake failed from %s: %v", c.addr, err)
}
}
}
return n, err
}
+97
View File
@@ -0,0 +1,97 @@
package main
import (
"os"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func TestApplyPersistedSettings(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "main-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
ds := datastore.NewDataStore(tmpDir)
t.Run("overrides true with false", func(t *testing.T) {
config := &serviceConfig{
redact: true,
logBody: true,
record: true,
enableSoundcorkProxy: true,
}
// Simulate the bug by using the old bitwise OR logic in the test,
// which should fail if we expect false.
// config.redact = config.redact || false -> stays true
settings := datastore.Settings{
RedactLogs: false,
LogBodies: false,
RecordInteractions: false,
EnableSoundcorkProxy: false,
}
err := ds.SaveSettings(settings)
if err != nil {
t.Fatalf("Failed to save settings: %v", err)
}
applyPersistedSettings(ds, config)
if config.redact != false {
t.Errorf("Expected redact to be false, got true")
}
if config.logBody != false {
t.Errorf("Expected logBody to be false, got true")
}
if config.record != false {
t.Errorf("Expected record to be false, got true")
}
if config.enableSoundcorkProxy != false {
t.Errorf("Expected enableSoundcorkProxy to be false, got true")
}
})
t.Run("retains false when settings are false", func(t *testing.T) {
settings := datastore.Settings{
RedactLogs: false,
}
err := ds.SaveSettings(settings)
if err != nil {
t.Fatalf("Failed to save settings: %v", err)
}
config := &serviceConfig{
redact: false,
}
applyPersistedSettings(ds, config)
if config.redact != false {
t.Errorf("Expected redact to be false, got true")
}
})
t.Run("overrides false with true", func(t *testing.T) {
settings := datastore.Settings{
RedactLogs: true,
}
err := ds.SaveSettings(settings)
if err != nil {
t.Fatalf("Failed to save settings: %v", err)
}
config := &serviceConfig{
redact: false,
}
applyPersistedSettings(ds, config)
if config.redact != true {
t.Errorf("Expected redact to be true, got false")
}
})
}
+4
View File
@@ -1,3 +1,7 @@
accounts/
certs/
default/
dns/
interactions/
patterns.json
settings.json
-17
View File
@@ -1,17 +0,0 @@
[
{
"name": "IPv4",
"regexp": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$",
"replacement": "{ip}"
},
{
"name": "DeviceID",
"regexp": "^[A-F0-9]{12}$",
"replacement": "{deviceId}"
},
{
"name": "AccountID",
"regexp": "^\\d{1,10}$",
"replacement": "{accountId}"
}
]
+2 -3
View File
@@ -4,9 +4,9 @@
This document contains important development guidelines for working on the Bose SoundTouch project. Please also read the following documentation:
- **[PLAN.md](PLAN.md)** - Project planning and roadmap
- **[PLAN.md](archive/PLAN.md)** - Project planning and roadmap
- **[PROJECT-PATTERNS.md](PROJECT-PATTERNS.md)** - Project structure and design patterns
- **[API-Endpoints-Overview.md](API-Endpoints-Overview.md)** - API endpoints overview
- **[API-ENDPOINTS.md](reference/API-ENDPOINTS.md)** - API endpoints overview
- **[SoundTouch Web API.pdf](2025.12.18%20SoundTouch%20Web%20API.pdf)** - Official API documentation
## Development Guidelines
@@ -95,4 +95,3 @@ When creating test data for API endpoints, prefer real device responses over hyp
- **Documentation**: Completely in English for international accessibility
- Conduct regular code reviews
- Consider performance from the beginning
+4 -3
View File
@@ -195,8 +195,9 @@ soundtouch-cli --host 192.168.1.100 source internet-radio \
- [SoundTouch WebServices API Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)
- [LOCAL_INTERNET_RADIO - streamUrl format](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API#select-local_internet_radio---streamurl-format)
- [LOCAL_MUSIC](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API#select-local_music)
- [Content Selection Example](/examples/content-selection/)
- [CLI Reference](/docs/CLI-REFERENCE.md)
- [Content Selection Example](../examples/content-selection/README.md)
- [CLI Reference](guides/CLI-REFERENCE.md)
- [Content Selection Example (Direct)](../examples/content-selection/)
## ✅ Verification
@@ -208,4 +209,4 @@ This implementation has been verified to:
5. ✅ Include complete documentation and examples
6. ✅ Maintain backward compatibility
**Status**: 🎉 **COMPLETE** - All requested content selection features are fully implemented and ready for use!
**Status**: 🎉 **COMPLETE** - All requested content selection features are fully implemented and ready for use!
+1 -1
View File
@@ -74,7 +74,7 @@ If you have a managed switch or a router capable of port mirroring, you can use
### "IsItBose" Validation Failures
If the device fails to connect to your custom service despite correct configuration, it may be failing the internal `IsItBose` regex check.
- **Evidence**: Look for SSL handshake failures or "Unauthorized" errors in your service logs.
- **Solution**: See the [Binary Patching section in DEVICE-REDIRECT-METHODS.md](DEVICE-REDIRECT-METHODS.md#method-3-binary-patching).
- **Solution**: See the [Binary Patching section in DEVICE-REDIRECT-METHODS.md](analysis/DEVICE-REDIRECT-METHODS.md#method-3-binary-patching).
### Disappearing Sources (TuneIn/Local Radio)
If `TUNEIN` or `LOCAL_INTERNET_RADIO` sources disappear after a reboot in an offline environment.
+1 -1
View File
@@ -895,4 +895,4 @@ For additional help:
---
*This guide covers the complete navigation and station management functionality. For preset management, see [PRESET-MANAGEMENT.md](PRESET-MANAGEMENT.md).*
*This guide covers the complete navigation and station management functionality. For preset management, see [PRESET-MANAGEMENT.md](reference/PRESET-MANAGEMENT.md).*
+5 -5
View File
@@ -332,14 +332,14 @@ soundtouch-cli --host 192.168.1.100 info
## Next Steps
- 📖 [Complete CLI Reference](CLI-REFERENCE.md)
- 🔧 [Full Implementation Guide](preset-store.md)
- 📡 [WebSocket Events Documentation](websocket-events.md)
- 📖 [Complete CLI Reference](guides/CLI-REFERENCE.md)
- 🔧 [Full Implementation Guide](reference/PRESET-MANAGEMENT.md)
- 📡 [WebSocket Events Documentation](reference/WEBSOCKET-EVENTS.md)
- 💻 [Preset Management Example](../examples/preset-management/)
- 📚 [API Endpoints Overview](API-Endpoints-Overview.md)
- 📚 [API Endpoints Overview](reference/API-ENDPOINTS.md)
## Need Help?
- 🐛 **Bug Reports**: [Create an issue](https://github.com/gesellix/bose-soundtouch/issues)
- 💡 **Feature Requests**: [Start a discussion](https://github.com/gesellix/bose-soundtouch/discussions)
-**Questions**: [Browse discussions](https://github.com/gesellix/bose-soundtouch/discussions)
-**Questions**: [Browse discussions](https://github.com/gesellix/bose-soundtouch/discussions)
+33
View File
@@ -0,0 +1,33 @@
# Bose SoundTouch Toolkit Documentation
Welcome to the documentation for the Bose SoundTouch Toolkit. This toolkit helps you keep your Bose SoundTouch speakers functional even after the Bose Cloud shutdown in May 2026.
## 📖 Quick Links
- [Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)
- [Migration & Safety Guide](guides/MIGRATION-SAFETY.md)
- [CLI Reference](guides/CLI-REFERENCE.md)
- [Getting Started](guides/GETTING-STARTED.md)
- [SoundTouch Service Guide](guides/SOUNDTOUCH-SERVICE.md)
## 🗂 Documentation Structure
### User Guides
- [Initial Device Setup](guides/DEVICE-INITIAL-SETUP.md)
- [HTTPS Setup](guides/HTTPS-SETUP.md)
- [Deployment Guide](guides/DEPLOYMENT.md)
- [Raspberry Pi Setup](guides/RASPBERRY-PI.md)
- [MAC Address Mapping](guides/MAC-ADDRESS-MAPPING.md)
- [Troubleshooting](guides/TROUBLESHOOTING.md)
### Technical Reference
- [API Endpoints](reference/API-ENDPOINTS.md)
- [WebSocket Events](reference/WEBSOCKET-EVENTS.md)
- [Zone Management](reference/ZONE-MANAGEMENT.md)
- [Preset Management](reference/PRESET-MANAGEMENT.md)
### Analysis & Research
- [Upstream URLs](analysis/UPSTREAM-URLS.md)
- [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md)
For a complete list of all documents, see the [Summary](SUMMARY.md).
+337
View File
@@ -0,0 +1,337 @@
# Request Recording Concept
## Problem Statement
The current request recording system has fundamental issues when dealing with request cloning, body consumption, and multiple response scenarios. Specifically:
1. **Body Consumption**: HTTP request bodies can only be read once, leading to missing bodies in recordings
2. **Request Cloning**: A single original request may be cloned multiple times for different purposes (local handling, mirroring, recording)
3. **Multiple Responses**: The same logical request may generate different responses (local vs upstream mirror)
4. **Data Integrity**: No guarantee that recorded requests are identical across different execution paths
## Current Issues (Examples)
### Issue 1: Missing Request Bodies in Mirror Recordings
**Local Recording** (complete):
```http
### POST /v1/scmudc/A81B6A536A98
POST /v1/scmudc/A81B6A536A98
Host: events.api.bosecm.com
Content-Type: text/json; charset=utf-8
Content-Length: 587
Authorization: Bearer jGwEmFWr...
{"envelope":{"monoTime":234906,"payloadProtocolVersion":"3.1","payloadType":"scmudc","protocolVersion":"1.0","time":"2026-02-25T23:03:14.976349+00:00","uniqueId":"A81B6A536A98"},"payload":{"deviceInfo":{"boseID":"3230304","deviceID":"A81B6A536A98","deviceType":"SoundTouch 10","serialNumber":"I6332527703739342000020","softwareVersion":"27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29","systemSerialNumber":"069231P63364828AE"},"events":[{"data":{"play-state":"PAUSE_STATE"},"monoTime":234904,"time":"2026-02-25T23:03:14.973466+00:00","type":"play-state-changed"}]}}
> {%
// Response: 200 OK
%}
```
**Mirror Recording** (missing body):
```http
### POST /v1/scmudc/A81B6A536A98
POST /v1/scmudc/A81B6A536A98
Host: events.api.bosecm.com
Content-Type: text/json; charset=utf-8
Content-Length: 587
Authorization: Bearer jGwEmFWr...
> {%
// Response: 200 OK
// Headers:
// X-Proxy-Origin: upstream-mirror
%}
```
### Issue 2: Request Flow Complexity
Current middleware execution order:
```
1. MirrorMiddleware - Buffers body, creates clones
2. RecordMiddleware - Also buffers body
3. Application Handler - Processes request
4. Mirror Execution - Async/sync mirror to upstream
5. Recording - Multiple recording points
```
Problems:
- Multiple body reads across middleware chain
- Inconsistent request state between clones
- Race conditions in async scenarios
- No guarantee of request equivalence
## Proposed Solution: Context-Bound Request Snapshots
### Core Concept
Create **immutable request snapshots** early in the request lifecycle and propagate them through the **Request Context**. This ensures all downstream consumers (Mirroring, Recording, Parity Check) use identical data without re-reading the request body.
### Architecture (Context-Only)
```
┌─────────────────┐
│ Original Request│
└─────────┬───────┘
┌─────────────────┐ ┌──────────────────┐
│ Snapshot Creator│───▶│ Request Context │
│ (Middleware) │ │ (Pointer-based) │
└─────────┬───────┘ └──────────────────┘
│ │
▼ │ (Safe for async)
┌─────────────────┐ │
│ Middleware │◀─────────────┘
│ Chain │
└─────────┬───────┘
┌───▼────┐ ┌─────────┐ ┌──────────────┐
│ Local │ │ Mirror │ │ Recording │
│Handler │ │Execution│ │ System │
└────────┘ └─────────┘ └──────────────┘
```
### Request Snapshot Structure
```go
type RequestSnapshot struct {
Method string
URL *url.URL
Headers http.Header
Body []byte
Host string
Timestamp time.Time
}
// Typed key for context safety
type contextKey struct{ name string }
var SnapshotKey = &contextKey{"request_snapshot"}
```
### Implementation Strategy
#### Phase 1: Snapshot Middleware
```go
func (s *Server) SnapshotMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1. Capture body once with size limit (e.g. 2MB)
body, _ := io.ReadAll(io.LimitReader(r.Body, 2*1024*1024))
r.Body.Close()
// 2. Create snapshot
snapshot := &RequestSnapshot{
Method: r.Method,
URL: cloneURL(r.URL),
Headers: r.Header.Clone(),
Body: body,
Host: r.Host,
Timestamp: time.Now(),
}
// 3. Inject pointer into context
ctx := context.WithValue(r.Context(), SnapshotKey, snapshot)
// 4. Restore r.Body for downstream compatibility
r = r.WithContext(ctx)
r.Body = io.NopCloser(bytes.NewReader(snapshot.Body))
next.ServeHTTP(w, r)
})
}
```
#### Phase 2: Downstream Consumption
Consumers (Mirror/Record) retrieve the snapshot directly from context:
```go
snapshot, ok := r.Context().Value(SnapshotKey).(*RequestSnapshot)
if ok {
// Use snapshot.Body directly instead of io.ReadAll(r.Body)
}
```
## Hardware Considerations (Raspberry Pi Zero 2W)
To protect MicroSD health and optimize for limited memory:
1. **No Intermediate Disk Storage**: Snapshots exist only in memory; they are never written to disk until the final `.http` recording is generated.
2. **Memory Management**: Use `sync.Pool` for temporary buffers to reduce GC churn on the single-core/low-memory SoC.
3. **Automatic Cleanup**: Snapshots are naturally garbage collected once the Request Context and all child goroutines (detached mirrors/recordings) finish.
4. **Body Capping**: Strict limits on snapshot size prevent OOM (Out-of-Memory) conditions.
#### Phase 2: Response Capture System
```go
type ResponseRecorder struct {
http.ResponseWriter
snapshot *ResponseSnapshot
snapshotID string
source string
startTime time.Time
}
func (r *ResponseRecorder) WriteHeader(statusCode int) {
r.snapshot.StatusCode = statusCode
r.snapshot.Headers = r.Header().Clone()
r.ResponseWriter.WriteHeader(statusCode)
}
func (r *ResponseRecorder) Write(data []byte) (int, error) {
r.snapshot.Body = append(r.snapshot.Body, data...)
return r.ResponseWriter.Write(data)
}
func (r *ResponseRecorder) finalize() {
r.snapshot.Duration = time.Since(r.startTime)
r.snapshot.Timestamp = time.Now()
}
```
#### Phase 3: Recording System Integration
```go
type RecordingManager struct {
storage SnapshotStorage
recorder *Recorder
patterns []string
}
func (rm *RecordingManager) RecordInteraction(snapshotID string, response *ResponseSnapshot) {
// Retrieve immutable request snapshot
request, exists := rm.storage.Get(snapshotID)
if !exists {
log.Printf("Request snapshot not found: %s", snapshotID)
return
}
// Record with guaranteed data integrity
rm.recorder.RecordInteraction(request, response)
}
func (r *Recorder) RecordInteraction(req *RequestSnapshot, res *ResponseSnapshot) error {
// Generate .http file with complete data
var buf bytes.Buffer
// Write request
fmt.Fprintf(&buf, "### %s %s\n", req.Method, req.URL.String())
fmt.Fprintf(&buf, "%s %s\n", req.Method, req.URL.String())
fmt.Fprintf(&buf, "Host: %s\n", req.Host)
for k, vv := range req.Headers {
for _, v := range vv {
fmt.Fprintf(&buf, "%s: %s\n", k, v)
}
}
buf.WriteString("\n")
buf.Write(req.Body)
buf.WriteString("\n\n")
// Write response
buf.WriteString("> {% \n")
fmt.Fprintf(&buf, " // Response: %d %s\n", res.StatusCode, http.StatusText(res.StatusCode))
buf.WriteString(" // Headers:\n")
for k, vv := range res.Headers {
for _, v := range vv {
fmt.Fprintf(&buf, " // %s: %s\n", k, v)
}
}
buf.WriteString("%}\n\n")
if len(res.Body) > 0 {
buf.WriteString("/*\n")
buf.Write(res.Body)
buf.WriteString("\n*/\n")
} else {
buf.WriteString("// [Binary response body: 0 bytes]\n")
}
// Write to file
return r.writeToFile(buf.Bytes(), req, res)
}
```
## Migration Strategy
### Phase 1: Introduce Snapshot System
- Add SnapshotMiddleware as first middleware
- Maintain existing recording system for compatibility
- Gradual migration of recording points
### Phase 2: Update Mirror System
- Modify MirrorMiddleware to use snapshots
- Ensure mirror requests use snapshot data
- Test parity between old and new systems
### Phase 3: Consolidate Recording
- Replace existing recording middleware
- Unified recording system using context-bound snapshots
- Remove duplicate body reading code
### Phase 4: Cleanup
- Remove legacy recording code
- Optimize memory usage with sync.Pool
- Performance validation on target hardware (Pi Zero)
## Benefits
1. **Zero Extra Disk IO**: Protecs MicroSD by avoiding snapshot disk persistence
2. **Memory Efficiency**: Natural lifecycle tied to Request Context
3. **Data Integrity**: Request data is captured once and remains immutable
4. **Consistency**: All consumers use identical request data
5. **Traceability**: Clear lineage from original request to all recordings
6. **Performance**: Reduces duplicate body reads and re-cloning
## Implementation Considerations
### Memory Management
- Use `sync.Pool` for byte buffers
- Strict size limits on captured bodies
- Rely on GC for snapshot cleanup
### Performance Impact
- Single body read vs multiple reads (net positive)
- Memory overhead for snapshot storage (manageable)
- Context propagation overhead (minimal)
### Backward Compatibility
- Maintain existing .http file format
- Preserve existing API contracts
- Gradual migration path
## Testing Strategy
### Unit Tests
- Snapshot creation and immutability
- Response recording accuracy
- Memory cleanup verification
### Integration Tests
- End-to-end request/response recording
- Mirror functionality with snapshots
- Parity validation between old/new systems
### Performance Tests
- Memory usage comparison
- Throughput impact analysis
- Large request body handling
## Future Enhancements
1. **Compression**: Compress stored snapshots for memory efficiency
2. **Streaming**: Support for streaming request/response bodies
3. **Filtering**: Selective snapshot creation based on patterns
4. **Analytics**: Request/response analysis and metrics
5. **Export**: Snapshot export for debugging and analysis
## Conclusion
This snapshot-based approach provides a robust foundation for reliable request recording while solving the current issues with body consumption and data inconsistency. The phased implementation ensures minimal disruption while delivering immediate benefits.
+2 -2
View File
@@ -23,7 +23,7 @@ This document summarizes the implementation of the `/serviceAvailability` endpoi
### Modified Files
1. **`pkg/client/client.go`** - Added `GetServiceAvailability()` method
2. **`docs/API-Endpoints-Overview.md`** - Updated implementation status
2. **`docs/reference/API-ENDPOINTS.md`** - Updated implementation status
3. **`docs/UNIMPLEMENTED-ENDPOINTS.md`** - Marked as implemented
## API Interface
@@ -263,4 +263,4 @@ BenchmarkGetServiceAvailability-8 1000 1.2ms/op
**Performance benchmarks established**
**Error handling verified**
The ServiceAvailability implementation is production-ready and provides a solid foundation for building user-friendly SoundTouch applications with better service discovery and user feedback capabilities.
The ServiceAvailability implementation is production-ready and provides a solid foundation for building user-friendly SoundTouch applications with better service discovery and user feedback capabilities.
+8 -8
View File
@@ -144,10 +144,10 @@ LOG_PROXY_BODY=true soundtouch-service
## 📚 Documentation
- **[Complete Service Guide](SOUNDTOUCH-SERVICE.md)**: Comprehensive setup and configuration
- **[API Reference](SOUNDTOUCH-SERVICE.md#api-reference)**: Full endpoint documentation
- **[Migration Guide](SOUNDTOUCH-SERVICE.md#device-migration)**: Step-by-step device migration
- **[Troubleshooting](SOUNDTOUCH-SERVICE.md#troubleshooting)**: Common issues and solutions
- **[Complete Service Guide](guides/SOUNDTOUCH-SERVICE.md)**: Comprehensive setup and configuration
- **[API Reference](guides/SOUNDTOUCH-SERVICE.md#api-reference)**: Full endpoint documentation
- **[Migration Guide](guides/SOUNDTOUCH-SERVICE.md#device-migration)**: Step-by-step device migration
- **[Troubleshooting](guides/SOUNDTOUCH-SERVICE.md#troubleshooting)**: Common issues and solutions
## 🤝 Contributing
@@ -172,9 +172,9 @@ The collaborative spirit of reverse engineering and documentation in the SoundTo
## 🔗 Links
- **[Main Repository](https://github.com/gesellix/bose-soundtouch)**
- **[Service Documentation](SOUNDTOUCH-SERVICE.md)**
- **[CLI Documentation](CLI-REFERENCE.md)**
- **[Getting Started Guide](GETTING-STARTED.md)**
- **[Service Documentation](guides/SOUNDTOUCH-SERVICE.md)**
- **[CLI Documentation](guides/CLI-REFERENCE.md)**
- **[Getting Started Guide](guides/GETTING-STARTED.md)**
- **[SoundCork Project](https://github.com/deborahgu/soundcork)**
- **[ÜberBöse API](https://github.com/julius-d/ueberboese-api)**
@@ -187,4 +187,4 @@ go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-service@latest
soundtouch-service
```
Open `http://localhost:8000` and start your journey to local SoundTouch control! 🎵
Open `http://localhost:8000` and start your journey to local SoundTouch control! 🎵
+72
View File
@@ -0,0 +1,72 @@
# Table of Contents
* [Introduction](README.md)
## User Guides
* [Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)
* [Migration & Safety Guide](guides/MIGRATION-SAFETY.md)
* [CLI Reference](guides/CLI-REFERENCE.md)
* [Getting Started](guides/GETTING-STARTED.md)
* [SoundTouch Service](guides/SOUNDTOUCH-SERVICE.md)
* [Initial Device Setup](guides/DEVICE-INITIAL-SETUP.md)
* [MAC Address Mapping](guides/MAC-ADDRESS-MAPPING.md)
* [HTTPS Setup](guides/HTTPS-SETUP.md)
* [Deployment](guides/DEPLOYMENT.md)
* [Raspberry Pi Guide](guides/RASPBERRY-PI.md)
* [Troubleshooting](guides/TROUBLESHOOTING.md)
* [Useful Links](#useful-links)
### Useful Links
* [Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)
* [Raspberry Pi Installer](../scripts/raspberry-pi/README.md)
* [Updating the Service](../scripts/raspberry-pi/README.md#updating-to-a-new-version)
* [CLI Reference](guides/CLI-REFERENCE.md)
## Technical Reference
* [API Cookbook](reference/API-COOKBOOK.md)
* [API Endpoints](reference/API-ENDPOINTS.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)
* [Discovery](reference/DISCOVERY.md)
* [Zone Management](reference/ZONE-MANAGEMENT.md)
* [Preset Management](reference/PRESET-MANAGEMENT.md)
* [Source Selection](reference/SOURCE-SELECTION.md)
* [Volume Controls](reference/VOLUME-CONTROLS.md)
* [RadioBrowser](reference/radio-browser.md)
* [Bass Controls](reference/BASS-CONTROLS.md)
* [Key Controls](reference/KEY-CONTROLS.md)
* [Feature Mapping](reference/FEATURE-MAPPING.md)
## Concepts
* [Request Recording](REQUEST_RECORDING_CONCEPT.md)
* [Spotify Priming Strategy](concepts/spotify-priming-strategy.md)
* [Spotify OAuth](concepts/spotify-oauth.md)
## Analysis & Research
* [API Coverage Analysis](analysis/API-COVERAGE.md)
* [Supported URLs](analysis/SUPPORTED-URLS.md)
* [Upstream URLs](analysis/UPSTREAM-URLS.md)
* [Anonymization Summary](analysis/ANONYMIZATION-SUMMARY.md)
* [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md)
* [Wiki API Comparison](analysis/WIKI-COMPARISON.md)
## Appendix (Other Documents)
* [API Navigation Reference](API-NAVIGATION-REFERENCE.md)
* [Claude Instructions](CLAUDE.md)
* [Content Selection Implementation](CONTENT-SELECTION-IMPLEMENTATION.md)
* [Device Customization Setup](DEVICE-CUSTOMIZATION-SETUP.md)
* [Device Logging](DEVICE-LOGGING.md)
* [Feature History](FEATURE_HISTORY.md)
* [Host/Port Parsing](HOST-PORT-PARSING.md)
* [Manual Network Discovery](MANUAL-NETWORK-DISCOVERY.md)
* [Navigation Guide](NAVIGATION-GUIDE.md)
* [Official API Verification](OFFICIAL-API-VERIFICATION.md)
* [Preset Quickstart](PRESET-QUICKSTART.md)
* [Project Patterns](PROJECT-PATTERNS.md)
* [Service Availability Implementation](SERVICE-AVAILABILITY-IMPLEMENTATION.md)
* [SoundTouch Service Announcement](SOUNDTOUCH-SERVICE-ANNOUNCEMENT.md)
* [Undocumented Community Features](UNDOCUMENTED-COMMUNITY-FEATURES.md)
* [Unimplemented Endpoints](UNIMPLEMENTED-ENDPOINTS.md)
* [Preset Store](preset-store.md)
+2 -2
View File
@@ -370,7 +370,7 @@ soundtouch-cli speaker beep
**Go Client Usage:**
```go
// Text-to-Speech
client.PlayTTS("Hello World", "your-app-key", 70)
client.PlayTTS("Hello World", "your-app-key", "EN", 70)
// URL content
client.PlayURL("https://example.com/audio.mp3", "your-app-key", "Service", "Message", "Reason", 60)
@@ -1044,4 +1044,4 @@ The SoundTouch Plus Wiki provides comprehensive documentation for **64 additiona
This documentation provides the complete foundation for implementing all endpoints from the SoundTouch Plus Wiki, enabling this Go library to become the definitive SoundTouch integration solution for everything from basic home automation to professional audio installations.
*All examples and XML structures are verified against real SoundTouch hardware and extensively tested by the SoundTouch Plus community.*
*All examples and XML structures are verified against real SoundTouch hardware and extensively tested by the SoundTouch Plus community.*
+11
View File
@@ -0,0 +1,11 @@
title: Bose SoundTouch Toolkit
description: Documentation for controlling and preserving Bose SoundTouch devices
remote_theme: pages-themes/minimal@v0.2.0
plugins:
- jekyll-remote-theme
- jekyll-relative-links
relative_links:
enabled: true
collections: true
include:
- SUMMARY.md
@@ -9,6 +9,9 @@ SoundTouch devices primarily communicate with the following domains:
- `updates.bose.com`: Software updates
- `stats.bose.com`: Telemetry and analytics
- `bmx.bose.com`: Bose Media eXchange registry
- `events.api.bosecm.com`: Stockholm app analytics
- `bose-prod.apigee.net`: Apigee gateway (used by some services)
- `worldwide.bose.com`: Software update metadata and secondary services
---
@@ -153,7 +156,7 @@ For developers creating a completely isolated "dark" environment (no internet at
1. **XML**: Point all URLs to local services.
2. **Binary Patch**: Neutralize `IsItBose` to allow non-Bose domains/IPs.
3. **`/etc/hosts`**: Redirect hardcoded domains that aren't exposed in the XML (like analytics or NTP) to prevent leakage to the real Bose cloud.
4. **Process Instrumentation**: Use [SoundTouch Hook](https://github.com/CodeFinder2/bose-soundtouch-hook) to monitor and override internal behavior in real-time.
4. **Process Instrumentation**: Use [SoundTouch Hook](https://github.com/CodeFinder2/bose-soundtouch-hook) to monitor and override internal behavior in real-time. This is particularly useful for handling unknown hostnames or deep-hooking into service discovery logic that might bypass standard DNS lookups.
---
+1 -1
View File
@@ -784,4 +784,4 @@ docker-compose up # Mock devices + web app
- [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
- [PROJECT-PATTERNS.md](../PROJECT-PATTERNS.md) - Detailed pattern documentation
+6 -6
View File
@@ -206,14 +206,14 @@ This project implements a comprehensive Go client library and CLI tool for Bose
### ✅ Complete Documentation
- `README.md` - Project overview and usage examples ✅
- `docs/API-Endpoints-Overview.md` - API reference with status ✅
- `docs/KEY-CONTROLS.md` - Media control implementation ✅
- `docs/VOLUME-CONTROLS.md` - Volume management guide ✅
- `docs/PRESET-MANAGEMENT.md` - Preset analysis and limitations ✅
- `docs/reference/API-ENDPOINTS.md` - API reference with status ✅
- `docs/reference/KEY-CONTROLS.md` - Media control implementation ✅
- `docs/guides/VOLUME-CONTROLS.md` - Volume management guide ✅
- `docs/reference/PRESET-MANAGEMENT.md` - Preset analysis and limitations ✅
- `docs/HOST-PORT-PARSING.md` - Enhanced CLI feature ✅
- `docs/PLAN.md` - Development roadmap (updated) ✅
- `docs/archive/PLAN.md` - Development roadmap (updated) ✅
- `docs/PROJECT-PATTERNS.md` - Development guidelines ✅
- `SPEAKER_ENDPOINT.md` - Complete speaker notification documentation ✅
- `docs/reference/SPEAKER-ENDPOINT.md` - Complete speaker notification documentation ✅
### 📝 Documentation Notes
- All docs are synchronized with current implementation
+137
View File
@@ -0,0 +1,137 @@
# Spotify OAuth Integration
The SoundTouch service supports Spotify OAuth integration to broker access tokens for SoundTouch speakers. This is particularly useful for maintaining Spotify Connect functionality after the Bose cloud shutdown (scheduled for May 2026).
## OAuth Flows
The service supports two primary OAuth flows: a browser-based flow and a mobile app-based flow (specifically for the [ueberboese](https://github.com/julius-d/ueberboese-app) app).
### 1. Browser-based Flow
The user initiates the flow, completes authorization in their browser, and is redirected back to the service.
```mermaid
sequenceDiagram
participant Client as Client (curl/app)
participant Service as Service
participant Spotify as Spotify Auth Server
participant Browser as User's Browser
Client->>Service: POST /mgmt/spotify/init [Basic Auth]
Service-->>Client: {"redirectUrl": "https://accounts.spotify.com/authorize?..."}
Client->>Browser: User opens URL
Browser->>Spotify: User logs in & grants access
Spotify-->>Browser: Redirect to /mgmt/spotify/callback?code=abc
Browser->>Service: GET /mgmt/spotify/callback?code=abc
Note over Service: No auth needed for callback
Service->>Spotify: POST /api/token (exchange code)
Spotify-->>Service: {access_token, refresh_token}
Service->>Spotify: GET /v1/me (fetch profile)
Spotify-->>Service: {id, display_name, email}
Note over Service: Store account to disk
Service-->>Browser: HTML: "Spotify Connected. You can close this window."
```
### 2. Mobile App Flow (ueberboese)
The mobile app handles the redirect via a deep link and then confirms the authorization with the service.
```mermaid
sequenceDiagram
participant App as ueberboese Flutter App
participant Service as Service
participant Spotify as Spotify Auth Server
App->>Service: POST /mgmt/spotify/init [Basic Auth]
Service-->>App: {"redirectUrl": "https://..."}
App->>Spotify: Open in-app browser (User authorizes)
Spotify-->>App: Deep link redirect: ueberboese-login://spotify?code=abc
App->>Service: POST /mgmt/spotify/confirm?code=abc [Basic Auth]
Service->>Spotify: POST /api/token (exchange code)
Spotify-->>Service: {access_token, refresh_token}
Service->>Spotify: GET /v1/me (fetch profile)
Spotify-->>Service: {profile}
Service-->>App: {"ok": true}
```
### 3. Token Retrieval (Boot Primer / Speaker Setup)
Once an account is linked, access tokens can be retrieved for use with speakers (e.g., via the `addUser` ZeroConf command).
```mermaid
sequenceDiagram
participant Primer as Boot Primer Script
participant Service as Service
participant Spotify as Spotify Token API
participant Speaker as Speaker (Bose ST 20)
Primer->>Service: GET /mgmt/spotify/token [Basic Auth]
alt Token expired
Service->>Spotify: POST /api/token (refresh)
Spotify-->>Service: new tokens
end
Service-->>Primer: {"access_token": "...", "username": "..."}
Note over Primer: Spotify Connect ZeroConf
Primer->>Speaker: POST /SpotifyConnect (addUser with token)
Speaker-->>Primer: OK
Note over Speaker: Speaker now has Spotify access
```
## Boot Primer Script
A boot primer script that uses these endpoints to feed Spotify tokens to speakers via ZeroConf is available in the `scripts/spotify/` directory: [spotify-boot-primer.sh](../../scripts/spotify/spotify-boot-primer.sh).
This script can be installed on the speaker itself (which runs embedded Linux) to automatically prime Spotify Connect at boot time. See [README.md](../../scripts/spotify/README.md) and [INSTALL.md](../../scripts/spotify/INSTALL.md) for instructions.
### Automated Installation via Service
The SoundTouch service provides a dedicated management endpoint to automatically handle the installation of the Spotify boot primer on the speaker:
`POST /mgmt/devices/{deviceId}/spotify/install-primer`
### Automated Installation Steps
When you run the Spotify primer installation, the service performs the following:
1. **Directories**: Creates `/mnt/nv/bin` and `/mnt/nv/BoseApp-Persistence/1` on the speaker.
2. **Binary**: Uploads the `spotify-boot-primer` script to the speaker.
3. **Configuration**: Automatically generates and uploads `spotify-primer.conf` containing the service's URL and management credentials.
4. **Boot Hook**: Injects a call to the primer in the speaker's `/mnt/nv/rc.local` using idempotent markers.
5. **Environment**: Updates `/mnt/nv/.profile` to include `/mnt/nv/bin` in the `PATH` for easier manual troubleshooting via SSH.
- **Idempotent Patching**: The service uses explicit markers to inject the hook, ensuring it doesn't corrupt existing content.
- **Coexistence**: The service-injected hook is designed to coexist with a manually installed `rc.local` (e.g., from the community gist). It only adds a call to `/mnt/nv/bin/spotify-boot-primer` if it's not already managed by a service-controlled block.
- **Markers**: Look for the following markers in your speaker's `/mnt/nv/rc.local`:
- `# --- Aftertouch Spotify hook START ---`
- `# --- Aftertouch Spotify hook END ---`
- **Cleanup**: Reverting a migration via the service will cleanly remove these marker-delimited blocks.
## Endpoints
| Method | Path | Auth | Purpose |
|--------|---------------------------------------------------|-------|-----------------------------------------------------------------------|
| POST | `/mgmt/devices/{deviceId}/spotify/install-primer` | Basic | Install Spotify boot primer on speaker (deviceId or IP) |
| GET | `/mgmt/spotify/callback` | None | Browser OAuth callback (redirect from Spotify, returns HTML) |
| POST | `/mgmt/spotify/init` | Basic | Start OAuth flow, returns authorization URL |
| POST | `/mgmt/spotify/confirm` | Basic | Mobile app confirm (ueberboese deep link delivers code, returns JSON) |
| GET | `/mgmt/spotify/accounts` | Basic | List linked Spotify accounts (tokens stripped) |
| GET | `/mgmt/spotify/token` | Basic | Get fresh access token (auto-refreshes if expired) |
| POST | `/mgmt/spotify/entity` | Basic | Resolve Spotify URI to name + image URL |
## Security
- `/mgmt/spotify/callback` is intentionally outside Basic Auth to allow direct redirects from Spotify's authorization server.
- All other `/mgmt/*` endpoints require Basic Auth as configured by `--mgmt-username` and `--mgmt-password`.
- Tokens are persisted to disk as JSON with restricted file permissions (`0600`).
- The `GetAccounts` endpoint strips sensitive tokens from the response.
+84
View File
@@ -0,0 +1,84 @@
# Spotify Priming Strategy
This document outlines the strategy for ensuring Bose SoundTouch devices are correctly "primed" for Spotify Connect integration within the AfterTouch ecosystem.
## 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.
AfterTouch adopts a **Server-Centric Hybrid Model** that prioritizes device cleanliness and user intent while providing automated self-healing.
## Core Principles
### 1. User Intent (Opt-in)
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.
- **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.
### 3. Triggers for Priming
Priming is triggered when the speaker signals it is active and ready, specifically:
- **Power On:** When the speaker calls the `/marge/streaming/support/power_on` endpoint, AfterTouch ensures the device's ZeroConf state is correctly primed. This is the primary trigger.
- **Manual Override:** Users can manually trigger a "Prime Spotify" from the device list in the UI if needed.
During any of these events, the server:
1. Checks if a Spotify account is linked in AfterTouch.
2. Checks the device's current priming status (via ZeroConf).
3. If unprimed and an account is linked, it pushes the priming command.
### 4. Automated Recovery
AfterTouch ensures that if a speaker loses its session (due to a crash or power loss), it is re-primed when it next powers on and reaches out to the service.
### 5. Decoupling
The logic for account management and device interaction remains decoupled:
- **Spotify Service:** Manages OAuth tokens and account state.
- **Discovery Service:** Finds devices and tracks their network presence.
- **Orchestrator:** Connects the two, deciding when to push tokens to discovered devices based on the current link status.
## Workflow
### Initial Setup (The "Add Source" UX)
1. User opens the AfterTouch Dashboard.
2. User selects "Link Spotify Account."
3. OAuth flow completes; AfterTouch stores the token.
4. AfterTouch immediately triggers a discovery run to find and prime all compatible speakers.
### Maintenance (The "Watchdog" UX)
1. A speaker reboots or loses its token.
2. A discovery event occurs (periodic or triggered by UI).
3. AfterTouch detects the "Empty" user state on the speaker.
4. AfterTouch pushes a fresh token from the Spotify Service.
5. UI reflects that the device is "Managed by AfterTouch" and healthy.
### 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.
## Network Topology & Deployment Scenarios
The strategy adapts based on where the AfterTouch server is deployed:
### Local Deployment (Home Server / Docker)
- **Mechanism:** Both "Pull" (Marge) and "Push" (ZeroConf side-channel) are used.
- **Advantage:** The server can proactively fix the speaker's state via port 8200 as soon as it sees a "Liveness Signal."
### External Deployment (Cloud VPS)
- **Mechanism:** Primarily relies on "Pull" (Marge).
- **Constraint:** The server cannot reach port 8200 on the speaker due to NAT/Firewall.
- **Strategy:** In this scenario, AfterTouch acts as a passive token provider. The speaker must initiate the connection to our intercepted Bose endpoints to receive its Spotify configuration. If the speaker completely loses its user state and stops "pulling," a manual re-prime from a local machine or a temporary local discovery run might be required.
## Transition & Cleanup
As AfterTouch moves to the Server-Centric model, we will:
1. **Revert On-Device Migration:** Update the Setup Manager to remove legacy `spotify-boot-primer` scripts and `rc.local` hooks from the speakers.
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)
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.
@@ -1248,6 +1248,6 @@ SOUNDTOUCH_DISCOVERY_TIMEOUT=10s
## See Also
- [Getting Started Guide](GETTING-STARTED.md) - Basic setup and usage
- [WebSocket Events](websocket-events.md) - Real-time monitoring
- [Zone Management](zone-management.md) - Multi-room setup
- [API Endpoints](API-Endpoints-Overview.md) - Complete API reference
- [WebSocket Events](../reference/WEBSOCKET-EVENTS.md) - Real-time monitoring
- [Zone Management](../reference/ZONE-MANAGEMENT.md) - Multi-room setup
- [API Endpoints](../reference/API-ENDPOINTS.md) - Complete API reference
@@ -13,6 +13,10 @@ This guide covers everything you need to know to deploy robust, scalable SoundTo
- [Performance Optimization](#performance-optimization)
- [Error Handling Recovery](#error-handling-recovery)
- [Deployment Strategies](#deployment-strategies)
- [Docker Deployment](#docker-deployment)
- [Kubernetes Deployment](#kubernetes-deployment)
- [Systemd Service](#systemd-service)
- [Raspberry Pi Installer](#raspberry-pi-installer)
- [Maintenance Operations](#maintenance-operations)
---
@@ -926,39 +930,49 @@ data:
device_hosts: "192.168.1.100,192.168.1.101,192.168.1.102"
```
### Systemd Service
#### Systemd Service
A standard systemd unit for manual installation. This example assumes the binary is at `/usr/local/bin/soundtouch-service` and data is stored in `/var/lib/soundtouch-service`.
```ini
# /etc/systemd/system/soundtouch.service
# /etc/systemd/system/soundtouch-service.service
[Unit]
Description=SoundTouch Control Service
After=network.target
Wants=network.target
Description=Bose SoundTouch Service
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User=soundtouch
Group=soundtouch
WorkingDirectory=/opt/soundtouch
ExecStart=/opt/soundtouch/bin/soundtouch-app
ExecReload=/bin/kill -HUP $MAINPID
Restart=always
RestartSec=5
Environment=DEVICE_HOSTS=192.168.1.100,192.168.1.101
Environment=LOG_LEVEL=info
Environment=CONFIG_FILE=/opt/soundtouch/config/production.yaml
WorkingDirectory=/var/lib/soundtouch-service
ExecStart=/usr/local/bin/soundtouch-service
Environment=PORT=80
Environment=SERVER_URL=http://soundtouch.local
# Security settings
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/soundtouch/logs
# Allow binding to privileged ports (80/443) without running as root
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
Restart=on-failure
RestartSec=5
# Security hardening
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ReadWritePaths=/var/lib/soundtouch-service
[Install]
WantedBy=multi-user.target
```
#### Raspberry Pi Installer
For users deploying on a Raspberry Pi, we provide a specialized automated installer that handles everything from architecture detection to security hardening.
See the [Raspberry Pi Installation Guide](RASPBERRY-PI.md) for step-by-step instructions.
---
## Maintenance Operations
@@ -1071,4 +1085,4 @@ func init() {
// Set GC target percentage
if os.Getenv("GOGC") == "" {
debug.SetGCPerc
debug.SetGCPerc
@@ -1,6 +1,6 @@
# HTTPS Setup & Custom CA Certificate
To use the `/etc/hosts` redirection method safely, SoundTouch devices must communicate over HTTPS. This requires the device to trust the Root CA certificate used by the local `soundtouch-service`.
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.
## 1. Automated Migration (Hosts Method)
@@ -13,12 +13,12 @@ curl -X POST "http://localhost:8000/setup/migrate/{deviceIP}?method=hosts"
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 Root CA into the device's trust store (`/etc/pki/tls/certs/ca-bundle.crt`).
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
The `soundtouch-service` automatically generates a Root CA when it first starts.
The AfterTouch service automatically generates a Root CA when it first starts.
- **CA Certificate**: `data/certs/ca.crt`
- **CA Private Key**: `data/certs/ca.key`
@@ -33,16 +33,23 @@ The `soundtouch-service` now includes a built-in HTTPS listener. This simplifies
- **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 for `streaming.bose.com`, `updates.bose.com`, `stats.bose.com`, `bmx.bose.com`, and `content.api.bose.io`.
- **Automatic Setup**: On first start, it generates a server certificate signed by your local Root CA.
- **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
#### 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)
+218
View File
@@ -0,0 +1,218 @@
# MAC Address to Serial Number Mapping
**Understanding and troubleshooting device identification in SoundTouch service**
This guide explains how the SoundTouch service handles device identification through MAC address to serial number mapping, and how to troubleshoot related issues.
## 📋 **Overview**
The SoundTouch service uses two different identifiers for devices:
- **MAC Address** (`A81B6A536A98`) - Used in HTTP API requests and UPnP discovery
- **Serial Number** (`I6332527703739342000020`) - Used for internal file storage
The service automatically maps between these identifiers so that API requests using MAC addresses can access files stored using serial numbers.
## 🔍 **How It Works**
### Request Flow
```
1. HTTP Request: GET /streaming/account/3230304/device/A81B6A536A98/presets
2. MAC Resolution: A81B6A536A98 → I6332527703739342000020
3. File Access: accounts/3230304/devices/I6332527703739342000020/Presets.xml
```
### UPnP Discovery Integration
The service extracts MAC addresses from UPnP device descriptions:
```xml
<!-- From http://192.168.1.100:8091/XD/BO5EBO5E-F00D-F00D-FEED-A81B6A536A98.xml -->
<root xmlns="urn:schemas-upnp-org:device-1-0">
<device>
<friendlyName>Sound Machinery</friendlyName>
<modelName>SoundTouch 10</modelName>
<serialNumber>A81B6A536A98</serialNumber> <!-- MAC address here -->
</device>
</root>
```
## ⚙️ **Automatic Setup**
The mapping is created automatically when the service starts:
1. **Directory Scan**: Service scans `data/accounts/{account}/devices/{serial}/`
2. **DeviceInfo.xml**: Reads MAC address from each device's info file
3. **Mapping Creation**: Creates MAC → Serial mapping in memory
4. **Normalization**: Handles different MAC address formats automatically
## 🛠️ **Supported MAC Address Formats**
The service handles all common MAC address formats automatically:
| Format | Example | Status |
|-------------|---------------------|-------------|
| Standard | `A81B6A536A98` | ✅ Supported |
| Lowercase | `a81b6a536a98` | ✅ Supported |
| With Colons | `A8:1B:6A:53:6A:98` | ✅ Supported |
| With Dashes | `A8-1B-6A-53-6A-98` | ✅ Supported |
| Mixed Case | `a81B6a536A98` | ✅ Supported |
| With Spaces | ` A81B6A536A98 ` | ✅ Supported |
## 🔧 **Troubleshooting**
### Problem: API requests fail with "file not found" errors
**Symptoms:**
```
GET /streaming/account/3230304/device/A81B6A536A98/presets
→ 500 Internal Server Error
→ Log: "open .../devices/A81B6A536A98/Presets.xml: no such file or directory"
```
**Diagnosis:**
1. Check if mapping exists:
```bash
# Look for device directory
ls data/accounts/3230304/devices/
# Should show serial numbers like: I6332527703739342000020
```
2. Check DeviceInfo.xml:
```bash
cat data/accounts/3230304/devices/I6332527703739342000020/DeviceInfo.xml
# Look for <macAddress> field
```
**Solutions:**
#### Solution 1: Restart the Service
The mapping is created at startup. Simply restart:
```bash
sudo systemctl restart soundtouch-service
```
#### Solution 2: Check DeviceInfo.xml Format
Ensure the MAC address is present:
```xml
<info deviceID="I6332527703739342000020">
<networkInfo type="SCM">
<macAddress>A81B6A536A98</macAddress> <!-- Must be present -->
<ipAddress>192.168.178.35</ipAddress>
</networkInfo>
</info>
```
#### Solution 3: Manual Device Addition
If the device was added manually, ensure proper structure:
```bash
# Create device directory using serial number
mkdir -p data/accounts/3230304/devices/I6332527703739342000020
# Create DeviceInfo.xml with MAC address
cat > data/accounts/3230304/devices/I6332527703739342000020/DeviceInfo.xml << EOF
<?xml version="1.0" encoding="UTF-8"?>
<info deviceID="I6332527703739342000020">
<name>My SoundTouch Device</name>
<networkInfo type="SCM">
<macAddress>A81B6A536A98</macAddress>
<ipAddress>192.168.1.100</ipAddress>
</networkInfo>
</info>
EOF
```
### Problem: UPnP discovery not creating mappings
**Check UPnP accessibility:**
```bash
# Test UPnP endpoint directly
curl http://192.168.1.100:8091/XD/BO5EBO5E-F00D-F00D-FEED-A81B6A536A98.xml
# Should return XML with <serialNumber> field
```
**Enable debug logging:**
```bash
# Check service logs for UPnP activity
journalctl -u soundtouch-service -f | grep UPnP
```
### Problem: Case or format mismatches
This should be handled automatically, but you can verify:
**Test different formats:**
```bash
# All of these should work the same:
curl http://localhost:8000/streaming/account/3230304/device/A81B6A536A98/presets
curl http://localhost:8000/streaming/account/3230304/device/a81b6a536a98/presets
curl http://localhost:8000/streaming/account/3230304/device/A8:1B:6A:53:6A:98/presets
```
## 📊 **Monitoring and Diagnostics**
### Check Current Mappings
The service logs mapping creation at startup:
```bash
journalctl -u soundtouch-service | grep "MAC.*serial"
```
### Verify File Structure
Ensure proper directory organization:
```
data/
└── accounts/
└── 3230304/
└── devices/
└── I6332527703739342000020/ # Serial number directory
├── DeviceInfo.xml # Contains MAC address
├── Presets.xml
└── Sources.xml
```
## 🔗 **Related Documentation**
- [Device Initial Setup](DEVICE-INITIAL-SETUP.md) - Setting up new devices
- [Troubleshooting Guide](TROUBLESHOOTING.md) - General troubleshooting steps
- [SoundTouch Service](SOUNDTOUCH-SERVICE.md) - Service configuration and management
## 🏗️ **Technical Implementation**
For developers interested in the technical details:
### Normalization Algorithm
```go
// MAC addresses are normalized by:
// 1. Removing spaces, colons, and dashes
// 2. Converting to uppercase
// Examples:
// "a8:1b:6a:53:6a:98" → "A81B6A536A98"
// "A8-1B-6A-53-6A-98" → "A81B6A536A98"
```
### Lookup Process
```go
// 1. Try exact match first
// 2. If not found, try normalized version
// 3. Return serial number for file access
```
### Performance
- **Lookup Time**: O(1) - Hash map lookup
- **Memory Usage**: ~40 bytes per device mapping
- **Initialization**: Scans all devices once at startup
## 📝 **Best Practices**
1. **Use Discovery**: Let UPnP discovery create mappings automatically
2. **Consistent Format**: Store MAC addresses consistently in DeviceInfo.xml
3. **Service Restart**: Restart service after manual device additions
4. **Monitoring**: Check logs for mapping creation during startup
5. **Backup**: Keep DeviceInfo.xml files backed up
## ⚠️ **Known Limitations**
- Mappings are created only at service startup
- Manual device additions require service restart
- MAC addresses must be present in DeviceInfo.xml
- No automatic cleanup of stale mappings (restart required)
+44
View File
@@ -0,0 +1,44 @@
### Professional 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.
#### 🛠 Technical Safety Enhancements
The following features are built into the `soundtouch-service` to ensure stability and easy rollbacks:
1. **Off-Device Backups**: Before any migration starts, the service automatically fetches the original `SoundTouchSdkPrivateCfg.xml` and `/etc/hosts` from your speaker and saves them locally in your `data/default/devices/<SERIAL>/` directory. This ensures you have a recovery path even if the speaker's filesystem becomes inaccessible.
2. **Pre-flight Write Verification**: The migration process includes a mandatory check for SSH write access (`rw`) before attempting any modifications. This prevents "half-baked" migrations where a script might fail halfway through due to a read-only filesystem.
3. **Automatic Safety on Sync**: Running a "Sync" in the Web UI or CLI automatically triggers an off-device backup, making it the perfect first step for any new device discovery.
#### 📋 Professional Migration Checklist
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.
- 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.
- **Verify**: Run `ssh -oHostKeyAlgorithms=+ssh-rsa root@<SPEAKER-IP>` to confirm access. (Note: older devices may require enabling `ssh-rsa` support).
2. **Network Isolation (Optional but Recommended)**: Ensure the device is on a stable wired connection if possible, or a dedicated 2.4GHz SSID to avoid drops during SSH operations.
3. **Initial Discovery & Sync**:
- Run `soundtouch-cli discover devices` to ensure connectivity.
- Use the Web UI or CLI to "Sync" the device. This will automatically backup your presets and system configuration files to your local server.
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.
6. **Monitor Logs**: Run the `soundtouch-service` with `DEBUG` or `INFO` logging to see the step-by-step progress of the migration.
#### 🔄 Rollback Strategy
If something goes wrong or you want to return to the original Bose cloud services:
* **Standard Revert**: Use the "Revert Migration" button in the Web UI or the corresponding CLI command. This restores the `.original` files created on the device.
* **Emergency Recovery**: If the device is unreachable via the UI but SSH still works, you can manually restore the files from your local `data/` directory using `scp` or the backups created on-device (`.original`).
* **Factory Reset**: As a last resort, Bose SoundTouch devices can be factory reset (usually by holding '1' and 'Volume Down' while plugging in). This will wipe all settings and return the device to the stock firmware configuration (the firmware itself remains at the current version, but configurations are reset).
By using the built-in off-device backups and pre-flight checks, the risk of "bricking" or losing configuration during the transition is significantly reduced.
+69
View File
@@ -0,0 +1,69 @@
# Raspberry Pi Installation Guide
This guide explains how to install the `soundtouch-service` as a persistent systemd service on a Raspberry Pi (tested on Raspberry Pi Zero 2W, 3, and 4).
## Automated Installer
We provide a specialized installer script located in the `scripts/raspberry-pi/` directory of the repository.
### Features
* **Automatic start on boot**: Installs a systemd unit.
* **Non-root operation**: Uses `AmbientCapabilities` to bind to ports 80/443 without root privileges.
* **Arch Detection**: Automatically selects the correct binary for `armv7`, `arm64`, or `amd64`.
* **Easy Updates**: Re-running the script updates the binary to the latest version.
### Installation Steps
1. **Download the installer**:
```bash
curl -fsSL -o install.sh https://raw.githubusercontent.com/gesellix/bose-soundtouch/main/scripts/raspberry-pi/install.sh
```
2. **Run with sudo**:
```bash
sudo bash install.sh
```
### Overriding Defaults
You can customize the installation using environment variables:
```bash
sudo \
VERSION=v0.17.0 \
HOSTNAME_FQDN=soundtouch.local \
HTTP_PORT=80 \
HTTPS_PORT=443 \
bash install.sh
```
### Updating the Service
To update the service to a specific version, run the installer with the version as an argument:
```bash
sudo bash install.sh v0.18.1
```
The installer will automatically fetch the latest version of itself for that release and then update the service binary and restart it.
## Management
Once installed, use standard `systemctl` commands to manage the service:
```bash
# Check status
systemctl status soundtouch-service
# Follow logs
journalctl -u soundtouch-service -f
# Restart
sudo systemctl restart soundtouch-service
```
## Configuration
Configuration is stored in `/etc/soundtouch-service/soundtouch-service.env`. Note that settings saved via the Web UI (in `settings.json`) will take precedence over these environment variables once the service is running.
For more details, see the [scripts/raspberry-pi/README.md](../../scripts/raspberry-pi/README.md) in the repository.
@@ -7,13 +7,18 @@ 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
- **🔧 Device Migration**: Seamlessly migrate devices from Bose cloud to local services via XML config, `/etc/hosts`, or `/etc/resolv.conf`
- **🔍 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
- **💾 Persistent Data**: Store device configurations, presets, and usage statistics
- **📝 HTTP Recording**: Persist all 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 Archiving**: Download entire interaction sessions as `.tar.gz` for offline analysis
- **🔍 Auto-Discovery**: Automatically detect and configure SoundTouch devices
- **🔒 Offline Operation**: Continue using full device functionality without internet
- **🔗 Bose Proxy & Soundcork Fallback**: Dynamic proxying with automatic fallback to local [SoundCork](https://github.com/deborahgu/soundcork) emulation if enabled
## Architecture
@@ -136,21 +141,38 @@ Use the web interface or API to migrate devices from Bose cloud services to your
## Configuration
The service can be configured via environment variables or command-line flags:
### Configuration Precedence
| Variable | Flag | Description | Default |
|------------------------------------|----------------------------|--------------------------------------------------|---------------------------|
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL | `https://<hostname>:8443` |
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
The service supports multiple ways to configure its behavior. When multiple sources provide the same setting, the following precedence rules apply (highest to lowest):
1. **`settings.json`**: Settings saved via the Web UI (stored in the data directory) take the highest precedence. This ensures that changes made in the browser persist across service restarts even if environment variables or flags change.
2. **Environment Variables / CLI Flags**: If a setting is not present in `settings.json`, environment variables and flags are used.
3. **Default Values**: If no configuration is provided, the service uses its built-in defaults.
> **Tip**: If you find that changes to environment variables are not taking effect, check the **Settings** tab in the Web UI or inspect the `settings.json` file in your data directory, as it might be overriding your manual configuration.
### Configuration Options
| Variable | Flag | Description | Default |
|------------------------------------|----------------------------|---------------------------------------------------------------------------------------------------------|---------------------------|
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL | `https://<hostname>:8443` |
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for `resolv.conf` migration) | `:53` |
| `MIRROR_ENABLED` | | Enable background mirroring of specific endpoints to Bose cloud | `false` |
| `MIRROR_ENDPOINTS` | | Comma-separated list of path patterns to mirror (e.g., `/streaming/account/*/device/*/recent`) | `[]` |
| `INTERNAL_PATHS` | `--internal-paths` | Paths for internal requests to exclude from recording (e.g., `/setup/*`, `/web/*`) | `[]` |
| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` |
### Configuration Examples
@@ -224,6 +246,103 @@ curl "http://192.168.1.100:8090/presets"
curl "http://localhost:8000/events/192.168.1.100"
```
#### ResolvConf 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.
> **Note**: This method requires the DNS Discovery Server to be bound to **port 53** on your local IP and **actually running**. Most devices do not support custom DNS ports in `/etc/resolv.conf`. If you use a custom port for testing, remember to switch back to `:53` and ensure the server has successfully bound to it (check Settings for status) before the actual migration.
**Advantages:**
- **Discovery**: Automatically discover all Bose endpoints queried by the device.
- **Dynamic Interception**: Intercept new or unknown services without further device modifications.
- **Fail-Safe**: Falls back to the standard network DNS (provided by your router) if the Aftertouch service is unavailable.
- **DHCP Compatible**: Preserves your router's assigned search domain and secondary DNS servers.
- **Wildcard Support**: Seamlessly handles `*.bose.com` redirection via your local DNS server.
- **Persistent**: Survives reboots and DHCP renewals.
**How it works:**
1. **Configuration**: A custom file named `/mnt/nv/aftertouch.resolv.conf` is created on the device's persistent partition.
2. **Boot Hook**: On every boot, `/mnt/nv/rc.local` checks if the system's DHCP scripts (`/etc/udhcpc.d/50default` or `/opt/Bose/udhcpc.script`) have been patched.
3. **Surgical Patch**: If not patched, it injects a one-line check into the relevant DHCP scripts.
4. **Resolution**: Whenever the device acquires a DHCP lease, the scripts now read your `aftertouch.resolv.conf` first, placing your DNS server at the top of `/etc/resolv.conf` while keeping all other DHCP-provided settings.
**Setup:**
1. Enable SSH via the `remote_services` USB trick.
2. Create `/mnt/nv/aftertouch.resolv.conf` with your server details:
```text
# Created by Aftertouch/SoundTouch-Service
# Priority nameserver for Bose service redirection
nameserver 192.168.1.XXX
```
3. Update `/mnt/nv/rc.local` with the idempotent patch:
```sh
#!/bin/sh
# Aftertouch DNS hook: prioritizes our custom nameserver if it exists
HOOK_MARKER="/mnt/nv/aftertouch.resolv.conf"
if [ -f "$HOOK_MARKER" ]; then
# Patch 50default if it exists
TARGET_FILE="/etc/udhcpc.d/50default"
if [ -f "$TARGET_FILE" ] && ! grep -q "$HOOK_MARKER" "$TARGET_FILE"; then
sed -i '/echo "search \$domain"/a \ [ -f '"$HOOK_MARKER"' ] && cat '"$HOOK_MARKER"' && dns=""' "$TARGET_FILE"
fi
# Patch udhcpc.script if it exists (e.g. SoundTouch 10)
TARGET_SCRIPT="/opt/Bose/udhcpc.script"
if [ -f "$TARGET_SCRIPT" ] && ! grep -q "$HOOK_MARKER" "$TARGET_SCRIPT"; then
sed -i '/echo "search \$search_list # \$interface" >> \$RESOLV_CONF/a \ [ -f '"$HOOK_MARKER"' ] && cat '"$HOOK_MARKER"' >> '"\$RESOLV_CONF"' && dns=""' "$TARGET_SCRIPT"
fi
fi
```
4. Make the script executable: `chmod +x /mnt/nv/rc.local`.
5. Reboot the speaker.
### DNS Discovery Server
The SoundTouch service includes a built-in DNS server specifically designed for Bose devices.
#### How it Works
When enabled, the DNS server:
1. Receives DNS queries from migrated SoundTouch devices.
2. **Intercepts** known Bose domains (e.g., `api.bose.com`, `streaming.bose.com`, `bmx.bose.com`) and resolves them to the AfterTouch service IP.
3. **Logs** all other queries for discovery purposes, allowing you to identify new Bose cloud endpoints.
4. **Forwards** unknown or non-Bose queries to the configured upstream DNS server (default: `8.8.8.8`).
#### Configuration
You can enable and configure the DNS server via the Web UI or environment variables:
- `ENABLE_DNS_DISCOVERY=true`: Turns on the DNS server.
- `DNS_BIND_ADDR=:53`: The port to listen on (requires root privileges for port 53).
- `DNS_UPSTREAM=1.1.1.1`: Your preferred upstream DNS provider. **Note:** Ensure this is not set to the same address as the DNS server itself (loopback or local IP) to avoid forwarding loops. The server includes built-in loop prevention, but misconfiguration will cause forwarding to fail. DNS Discovery cannot be enabled if this setting is empty.
#### Manual Discovery via DNS
Even without migrating a device, you can use the DNS server to discover what a device is querying by manually setting your router's DNS or the device's DNS to point to the AfterTouch service.
## Endpoint Mirroring & Parity Logging
The SoundTouch service includes a powerful **Mirroring** feature that allows you to handle requests locally while simultaneously forwarding them to the official Bose cloud in the background. This is primarily used for maintaining long-term compatibility and verifying the accuracy of the local emulation.
### How Mirroring Works
When an endpoint is configured for mirroring:
1. **GET Requests**: Handled locally first (Primary). The response is returned to the speaker immediately. In the background, the same request is sent to Bose.
2. **POST/PUT/DELETE Requests**: Handled locally first. The service then synchronously (but without blocking the speaker's response) forwards the request to Bose to ensure the "official" account state stays in sync with your local changes (e.g., updating a preset).
### Parity Logging
The **Parity Logger** automatically compares the response from your local service with the one received from Bose. If it detects any discrepancies, it:
1. Logs a warning to the console: `[PARITY] Mismatch detected for GET /...`
2. Saves a detailed JSON report to `data/parity_mismatches/`.
Each report includes the full request, both response bodies, and a summary of what differed (status codes, content types, or missing/different XML tags).
### Configuration
Mirroring is configured via the **Settings** tab in the Web UI or through global settings:
- **Mirror Enabled**: Master switch for the mirroring infrastructure.
- **Mirror Endpoints**: A list of URL path patterns to mirror. You can use wildcards (`*`) to match variable parts like account or device IDs.
- Example: `/streaming/account/*/device/*/recent`
- Example: `/accounts/*/devices/*/presets/*`
Mirrored requests are also recorded in the **Interaction Log** under the category `upstream-mirror`, allowing you to see side-by-side exactly how our service's behavior compares to the official one.
## API Reference
### Discovery & Setup
@@ -364,6 +483,15 @@ The web management interface provides a comprehensive dashboard for managing you
- **Statistics Dashboard**: Usage and error analytics
- **Debug Tools**: Device communication testing utilities
#### Interactions & Traffic Analysis
- **Traffic Overview**: View aggregate request counts for self-handled and proxied traffic.
- **Session Browsing**: Browse recorded interactions grouped by session.
- **Advanced Filtering**: Filter interactions by session, category (Self/Upstream), and timestamp.
- **Interaction Viewer**: View raw `.http` recording content directly in the browser.
- **Session Management**: Delete individual sessions or perform bulk cleanup to keep only recent sessions.
- **Session Download**: Download complete interaction sessions as `.tar.gz` archives for offline analysis or bug reports.
- **DNS Discoveries**: Real-time table of all hostnames discovered via the AfterTouch DNS server, categorized by interception status (Self/Upstream).
### Usage Tips
1. **First Time Setup**: The interface will guide you through initial device discovery
@@ -375,6 +503,17 @@ The web management interface provides a comprehensive dashboard for managing you
The service automatically records all HTTP interactions (both those handled locally and those proxied upstream) as `.http` files. These files are compatible with the [IntelliJ IDEA HTTP Client](https://www.jetbrains.com/help/idea/exploring-http-syntax.html).
### Internal Paths (Excluding Traffic)
To prevent internal management traffic (like the Web UI or setup API calls) from cluttering your interaction logs, you can configure **Internal Paths**. Requests matching these patterns will be processed normally but will **not** be recorded by the `RecordMiddleware`.
By default, we recommend adding:
- `/setup/*`: Management API calls
- `/web/*`: Static Web UI resources
- `/media/*`: Icons and static media
You can configure these via the **Settings** tab in the Web UI or using the `--internal-paths` flag.
### Key Features
- **Session Grouping**: All interactions from a single server session are stored in a dedicated directory named `{timestamp}-{pid}`.
@@ -382,6 +521,7 @@ The service automatically records all HTTP interactions (both those handled loca
- **Path-Based Structure**: Recordings are organized into subdirectories based on their URL path for better discoverability.
- **Automatic Sanitization**: Variable path segments like IP addresses, Device IDs, and Account IDs are automatically identified and replaced with placeholders (e.g., `{{ip}}`, `{{deviceId}}`). The original values are preserved as comments at the top of the recorded `.http` files for easy identification.
- **Re-playability**: An `http-client.env.json` file is generated for each session, allowing you to re-play the recorded requests immediately in IntelliJ IDEA.
- **Management UI**: The **5. Interactions** tab provides a built-in viewer and management tools for all recorded data.
### Configuration
@@ -391,6 +531,8 @@ By default, the service redacts sensitive information from the recorded `.http`
- `Authorization` headers
- `Cookie` headers
- `X-Bose-Token` headers
- `X-Bose-Key` headers
- `Proxy-Authorization` headers
This behavior is controlled by the `--redact-logs` flag or the `REDACT_PROXY_LOGS` environment variable.
@@ -440,6 +582,8 @@ data/
│ │ └── {PATH}/
│ │ └── {SEQ}-{TIME}-{METHOD}.http
│ └── http-client.env.json
├── dns/
│ └── discoveries.json
├── stats/
│ ├── usage/
│ │ └── *.json
@@ -461,6 +605,9 @@ data/
- **Presets.xml**: Cross-device preset synchronization
- **Recents.xml**: Recent playback history
#### DNS Data (`dns/`)
- **discoveries.json**: Persisted DNS discovery logs with hostname deduplication
#### Statistics (`stats/`)
- **usage/**: Device usage analytics and patterns
- **error/**: Error logs and diagnostic information
@@ -520,6 +667,34 @@ find data/stats/ -name "*.json" -mtime +90 -delete
- `POST /setup/migrate/{deviceIP}`: Migrate a device using the specified method (XML/Hosts).
- `GET /setup/ca.crt`: Download the Root CA certificate for manual installation.
#### `GET /setup/interactions`
Lists recorded interactions with optional filtering.
**Query Parameters:**
- `session`: Filter by session ID (optional)
- `category`: Filter by category (`self` or `upstream`) (optional)
- `since`: Filter by timestamp (e.g., `2026-02-15 15:00:00`) (optional)
#### `GET /setup/interaction-stats`
Returns aggregate statistics about recorded interactions across all sessions.
#### `GET /setup/interaction-content?file={path}`
Returns the raw content of a specific recorded `.http` file.
#### `DELETE /setup/interactions/sessions/{sessionID}`
Deletes all recordings associated with a specific session.
#### `DELETE /setup/interactions/sessions?keep={N}`
Bulk cleanup: deletes all but the most recent `N` sessions.
### DNS Discovery API
#### `GET /setup/dns-discoveries`
Returns merged in-memory and persisted DNS discoveries, sorted by last seen timestamp.
#### `DELETE /setup/dns-discoveries`
Clears all recorded DNS discovery data from memory and disk.
### Emulated Services
- `/bmx/registry/v1/services`: BMX service registry.
- `/bmx/tunein/v1/*`: TuneIn radio emulation.
@@ -40,16 +40,20 @@ Open your web browser and navigate to the service's web interface:
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.
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. Discover and Sync Device Data
The web interface handles the entire process in a guided flow across four tabs:
#### 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: 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 2: 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 3: Migration**: Choose your redirection method (XML Recommended) and click "Confirm Migration & Reboot".
* **Step 4: Settings**: Configure global server URLs and proxy behavior (logging, redaction).
* **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.
@@ -817,6 +817,42 @@ Use this checklist to systematically troubleshoot issues:
---
## 🆔 **Device Identification & Mapping Issues**
### ❌ "File not found" errors with MAC addresses
**Symptoms:**
```
GET /streaming/account/3230304/device/A81B6A536A98/presets
→ 500 Internal Server Error
→ Log: "open .../devices/A81B6A536A98/Presets.xml: no such file or directory"
```
**Cause:** The service uses MAC addresses in API requests but stores files using device serial numbers. A mapping system resolves MAC addresses to serial numbers automatically.
**Quick Solutions:**
1. **Restart the service** (mappings are created at startup):
```bash
sudo systemctl restart soundtouch-service
```
2. **Check device directory structure**:
```bash
# Files should be stored by serial number, not MAC
ls data/accounts/3230304/devices/
# Should show: I6332527703739342000020/ (not A81B6A536A98/)
```
3. **Verify DeviceInfo.xml contains MAC address**:
```bash
cat data/accounts/3230304/devices/*/DeviceInfo.xml | grep macAddress
```
**For detailed diagnosis and solutions**, see: [**MAC Address Mapping Guide**](MAC-ADDRESS-MAPPING.md)
---
## 🛟 **Getting More Help**
### Information to Gather
@@ -418,10 +418,10 @@ soundtouch-cli -host <discovered-ip> -bass # Verify final state
## Related Documentation
- **[API Endpoints Overview](API-Endpoints-Overview.md)** - Complete API reference
- **[API Endpoints Overview](API-ENDPOINTS.md)** - Complete API reference
- **[Volume Controls](VOLUME-CONTROLS.md)** - Related audio control documentation
- **[Client Usage Examples](../cmd/soundtouch-cli/main.go)** - CLI implementation reference
- **[Models](../pkg/models/bass.go)** - Bass model implementation
- **[Client Usage Examples](../../cmd/soundtouch-cli/main.go)** - CLI implementation reference
- **[Models](../../pkg/models/bass.go)** - Bass model implementation
## API Compliance
@@ -443,4 +443,4 @@ The implementation follows the official SoundTouch API:
**Implementation Date**: 2026-01-09
**Status**: ✅ Complete and tested
**Real Device Validation**: SoundTouch 10, SoundTouch 20
**API Compliance**: Full compliance with SoundTouch Web API specification
**API Compliance**: Full compliance with SoundTouch Web API specification
+73
View File
@@ -0,0 +1,73 @@
# Bose SoundTouch Cloud API Emulation (Marge/BMX/Stats)
This document describes the cloud-emulation APIs provided by the SoundTouch service. These APIs mimic the Bose cloud services (Marge, BMX, Stats) that SoundTouch devices and the SoundTouch controller application (Stockholm) interact with.
## Marge API (Account & Configuration)
Base path: `/marge`
### GET /streaming/sourceproviders
Retrieves a list of available streaming source providers.
### GET /accounts/{accountId}/full
Retrieves the full account configuration including sources, presets, and devices.
### GET /streaming/account/{accountId}/emailaddress
Retrieves the email address associated with the account.
### GET /streaming/device_setting/account/{accountId}/device/{deviceId}/device_settings
Retrieves settings for a specific device (e.g., clock format).
### POST /streaming/device_setting/account/{accountId}/device/{deviceId}/device_settings
Updates settings for a specific device.
### POST /accounts/{accountId}/devices/{deviceId}/presets/{presetNumber}
Updates a preset for a device.
### POST /accounts/{accountId}/devices/{deviceId}/recents
Adds an item to the device's recently played history.
### POST /accounts/{accountId}/devices
Adds a device to the account.
### DELETE /accounts/{accountId}/devices/{deviceId}
Removes a device from the account.
## Customer API (Profile & Password)
Base path: `/customer`
### GET /account/{accountId}
Retrieves the customer account profile.
### POST /account/{accountId}
Updates the customer account profile.
### POST /account/{accountId}/password
Changes the account password.
## Analytics & Stats API
Base path: `/v1` (App Events) or `/streaming/stats` (Device Stats)
### POST /v1/stapp/{deviceId}
Endpoint called by Bose SoundTouch mobile and web applications (Stockholm) to submit event data.
### POST /v1/scmudc/{deviceId}
Endpoint equivalent to `/v1/stapp/{deviceId}` sometimes used by apps or devices.
### POST /streaming/stats/usage
Endpoint used by physical devices to report usage statistics.
### POST /streaming/stats/error
Endpoint used by physical devices to report error statistics.
## BMX API (Streaming & Registry)
Base path: `/bmx`
### GET /registry/v1/services
Retrieves the registry of available streaming services.
### GET /tunein/v1/playback/station/{stationID}
Retrieves playback information for a TuneIn station.
@@ -366,7 +366,7 @@ This implementation now provides the full preset management lifecycle:
## Related Documentation
- [API Endpoints Overview](API-Endpoints-Overview.md) - Complete API reference
- [API Endpoints Overview](API-ENDPOINTS.md) - Complete API reference
- [Volume Controls](VOLUME-CONTROLS.md) - Volume management
- [Key Controls](KEY-CONTROLS.md) - Media control commands
- [Source Selection](SOURCE-SELECTION.md) - Audio source management
@@ -375,4 +375,4 @@ This implementation now provides the full preset management lifecycle:
Preset management in the Bose SoundTouch API is **intentionally read-only** by design. The API provides excellent capabilities for analyzing and understanding preset configurations, but preset creation must be done through official channels (app or device). This is a deliberate design decision that respects user control over their personal preset configurations.
For most use cases, reading preset information is sufficient for building applications that work with existing user configurations. For preset creation, guide users to use the official app or device controls, which provide the proper user experience and validation.
For most use cases, reading preset information is sufficient for building applications that work with existing user configurations. For preset creation, guide users to use the official app or device controls, which provide the proper user experience and validation.
@@ -38,6 +38,7 @@ The Bose SoundTouch Go client provides comprehensive source selection functional
- `IHEARTRADIO` - iHeartRadio streaming
- `STORED_MUSIC` - Local/network stored music
- `AIRPLAY` - Apple AirPlay (device dependent)
- `RADIO_BROWSER` - [RadioBrowser](radio-browser.md) internet radio directory
## Client Library Usage
@@ -345,13 +346,13 @@ The implementation follows the official SoundTouch API:
## Related Documentation
- **[API Endpoints Overview](API-Endpoints-Overview.md)** - Complete API reference
- **[Sources](../pkg/models/sources.go)** - Source model implementation
- **[Now Playing](../pkg/models/nowplaying.go)** - ContentItem model
- **[Client Usage Examples](../cmd/soundtouch-cli/main.go)** - CLI implementation reference
- **[API Endpoints Overview](API-ENDPOINTS.md)** - Complete API reference
- **[Sources](../../pkg/models/sources.go)** - Source model implementation
- **[Now Playing](../../pkg/models/nowplaying.go)** - ContentItem model
- **[Client Usage Examples](../../cmd/soundtouch-cli/main.go)** - CLI implementation reference
---
**Implementation Date**: 2026-01-09
**Status**: ✅ Complete and tested
**Real Device Validation**: SoundTouch 10, SoundTouch 20
**Real Device Validation**: SoundTouch 10, SoundTouch 20
@@ -68,14 +68,14 @@ func main() {
client := client.NewClient(config)
// Play TTS at current volume
err := client.PlayTTS("Hello, this is a test message", "YOUR_APP_KEY")
// Play TTS at current volume (language code "EN", "DE", etc.)
err := client.PlayTTS("Hello, this is a test message", "YOUR_APP_KEY", "EN")
if err != nil {
log.Fatal(err)
}
// Play TTS at specific volume (70)
err = client.PlayTTS("Volume test message", "YOUR_APP_KEY", 70)
err = client.PlayTTS("Volume test message", "YOUR_APP_KEY", "EN", 70)
if err != nil {
log.Fatal(err)
}
@@ -277,7 +277,7 @@ You'll need to provide your own application key. The format and generation metho
```go
// Doorbell notification
client.PlayTTS("Someone is at the front door", "home-automation-key", 80)
client.PlayTTS("Someone is at the front door", "home-automation-key", "EN", 80)
// Security alert
client.PlayURL(
@@ -311,4 +311,4 @@ soundtouch-cli speaker url --url "https://www.soundjay.com/misc/sounds/bell-ring
4. **URL content fails**: Ensure URL is accessible and contains valid audio
5. **Volume not restored**: May occur if device is powered off during playback
For more information, see the [SoundTouch WebServices API documentation](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API).
For more information, see the [SoundTouch WebServices API documentation](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API).
+34
View File
@@ -0,0 +1,34 @@
## radio-browser.info
- 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.
### Search for stations
- Go to https://www.radio-browser.info and find a station you like.
- 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}`.
```xml
<ContentItem
source="RADIO_BROWSER"
type="stationurl"
isPresetable="true"
location="/stations/byuuid/9610c454-0601-11e8-ae97-52543be04c81">
<itemName>RADIO_BROWSER</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
```
+1 -1
View File
@@ -149,4 +149,4 @@ After configuring accounts:
3. Use `browse` commands to explore content
4. Use `play` commands to start playback
See the [CLI Reference](../../docs/CLI-REFERENCE.md) for complete documentation.
See the [CLI Reference](../../docs/guides/CLI-REFERENCE.md) for complete documentation.
+2 -2
View File
@@ -176,5 +176,5 @@ The example gracefully handles missing services:
## Related Documentation
- [SoundTouch WebServices API Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)
- [CLI Reference](../../docs/CLI-REFERENCE.md)
- [Navigation Guide](../../docs/NAVIGATION-GUIDE.md)
- [CLI Reference](../../docs/guides/CLI-REFERENCE.md)
- [Navigation Guide](../../docs/guides/SURVIVAL-GUIDE.md)
+4 -4
View File
@@ -177,11 +177,11 @@ if err != nil {
This introspect data is useful before:
- [Preset Management](../preset-management/) - Verify service state before storing presets
- [Content Selection](../../docs/SOURCE-SELECTION.md) - Check capabilities before switching sources
- [Zone Management](../../docs/zone-management.md) - Ensure all devices support the service
- [Content Selection](../../docs/reference/SOURCE-SELECTION.md) - Check capabilities before switching sources
- [Zone Management](../../docs/reference/ZONE-MANAGEMENT.md) - Ensure all devices support the service
## API Documentation
For complete API documentation, see:
- [API Reference](../../docs/API-Endpoints-Overview.md)
- [Service Availability Implementation](../../docs/SERVICE-AVAILABILITY-IMPLEMENTATION.md)
- [API Reference](../../docs/reference/API-ENDPOINTS.md)
- [Service Availability Implementation](../../docs/SERVICE-AVAILABILITY-IMPLEMENTATION.md)
+4 -4
View File
@@ -275,10 +275,10 @@ go run ./cmd/soundtouch-cli --host 192.168.1.100 info
## Related Documentation
- [CLI Reference](../../docs/CLI-REFERENCE.md) - Browse and station commands
- [Navigation Guide](../../docs/NAVIGATION-GUIDE.md) - Comprehensive navigation documentation
- [CLI Reference](../../docs/guides/CLI-REFERENCE.md) - Browse and station commands
- [Navigation Guide](../../docs/guides/SURVIVAL-GUIDE.md) - Comprehensive navigation documentation
- [Navigation API Reference](../../docs/API-NAVIGATION-REFERENCE.md) - Technical API details
- [WebSocket Events](../../docs/websocket-events.md) - Real-time event handling
- [WebSocket Events](../../docs/reference/WEBSOCKET-EVENTS.md) - Real-time event handling
## Use Cases
@@ -288,4 +288,4 @@ This example demonstrates patterns for:
- **Direct Playback**: Play content without storing as presets first
- **Content Exploration**: Browse large music libraries efficiently
- **Smart Home Integration**: Programmatically start specific content
- **Personalized Experiences**: Access account-specific content from streaming services
- **Personalized Experiences**: Access account-specific content from streaming services
+4 -4
View File
@@ -256,10 +256,10 @@ Error: All preset slots are occupied
## Related Documentation
- [CLI Reference](../../docs/CLI-REFERENCE.md) - Command-line usage
- [CLI Reference](../../docs/guides/CLI-REFERENCE.md) - Command-line usage
- [Preset Implementation Guide](../../docs/preset-store.md) - Technical details
- [WebSocket Events](../../docs/websocket-events.md) - Real-time event handling
- [API Reference](../../docs/API-Endpoints-Overview.md) - Complete API documentation
- [WebSocket Events](../../docs/reference/WEBSOCKET-EVENTS.md) - Real-time event handling
- [API Reference](../../docs/reference/API-ENDPOINTS.md) - Complete API documentation
## Use Cases
@@ -269,4 +269,4 @@ This example demonstrates patterns for:
- **Music Management**: Organize favorite content into quick-access presets
- **Family Scenarios**: Each person gets their own preset slots
- **Party Mode**: Pre-configure playlists for different moods
- **Radio Favorites**: Save frequently listened radio stations
- **Radio Favorites**: Save frequently listened radio stations
+5 -5
View File
@@ -252,8 +252,8 @@ go run main.go -host 192.168.1.100 -type unknown
This recents data is useful for:
- [Preset Management](../preset-management/) - Finding presetable content to save
- [Content Selection](../../docs/SOURCE-SELECTION.md) - Understanding usage patterns
- [Navigation](../../docs/NAVIGATION-GUIDE.md) - Quickly accessing recently played content
- [Content Selection](../../docs/reference/SOURCE-SELECTION.md) - Understanding usage patterns
- [Navigation](../../docs/guides/SURVIVAL-GUIDE.md) - Quickly accessing recently played content
## Related CLI Commands
@@ -274,6 +274,6 @@ soundtouch-cli --host 192.168.1.100 recents latest
## API Documentation
For complete API documentation, see:
- [API Reference](../../docs/API-Endpoints-Overview.md)
- [CLI Reference](../../docs/CLI-REFERENCE.md)
- [Recents Models](../../pkg/models/recents.go)
- [API Reference](../../docs/reference/API-ENDPOINTS.md)
- [CLI Reference](../../docs/guides/CLI-REFERENCE.md)
- [Recents Models](../../pkg/models/recents.go)
+181
View File
@@ -0,0 +1,181 @@
// Package main demonstrates the new recording filename format that includes date information.
package main
import (
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
)
func main() {
fmt.Println("=== Recording Filename Format Demo ===")
fmt.Println()
// Create a temporary directory for the demo
tmpDir, err := os.MkdirTemp("", "recording-filename-demo")
if err != nil {
log.Fatalf("Failed to create temp directory: %v", err)
}
defer func() {
if removeErr := os.RemoveAll(tmpDir); removeErr != nil {
log.Printf("Failed to remove temp directory: %v", removeErr)
}
}()
fmt.Printf("Demo recordings will be saved to: %s\n\n", tmpDir)
// Create a recorder with async disabled for predictable demo output
if envErr := os.Setenv("RECORDER_ASYNC", "false"); envErr != nil {
log.Printf("Failed to set environment variable: %v", envErr)
}
recorder := proxy.NewRecorder(tmpDir)
defer recorder.Close()
fmt.Printf("Recorder session ID: %s\n", recorder.SessionID)
fmt.Println()
// Create some sample HTTP requests to record
requests := []struct {
method string
path string
category string
}{
{"GET", "/info", "self"},
{"POST", "/volume", "self"},
{"GET", "/nowPlaying", "self"},
{"PUT", "/preset_1", "self"},
}
fmt.Println("Recording sample HTTP interactions...")
fmt.Println()
for i, req := range requests {
// Create a mock HTTP request
httpReq, reqErr := http.NewRequest(req.method, "http://soundtouch.local:8090"+req.path, nil)
if reqErr != nil {
log.Printf("Failed to create request: %v", reqErr)
continue
}
// Create a mock response
httpRes := &http.Response{
StatusCode: 200,
Header: make(http.Header),
Request: httpReq,
}
httpRes.Header.Set("Content-Type", "application/xml")
// Record the interaction
err = recorder.Record(req.category, httpReq, httpRes)
if err != nil {
log.Printf("Failed to record interaction: %v", err)
continue
}
fmt.Printf("%d. Recorded: %s %s\n", i+1, req.method, req.path)
// Small delay to show different timestamps
time.Sleep(100 * time.Millisecond)
}
fmt.Println()
fmt.Println("=== Generated Filenames ===")
fmt.Println()
// Walk through the recordings directory to show the generated filenames
interactionsDir := filepath.Join(tmpDir, "interactions")
err = filepath.Walk(interactionsDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if strings.HasSuffix(info.Name(), ".http") {
// Get relative path from interactions directory
rel, _ := filepath.Rel(interactionsDir, path)
fmt.Printf("📁 %s\n", rel)
// Parse and explain the filename format
filename := info.Name()
parts := strings.Split(strings.TrimSuffix(filename, ".http"), "-")
if len(parts) == 4 && len(parts[1]) == 8 {
// New format: count-yyyyMMdd-HHMMSS.sss-method.http
counter := parts[0]
dateStr := parts[1]
timeStr := parts[2]
method := parts[3]
// Format for display
date := dateStr[0:4] + "-" + dateStr[4:6] + "-" + dateStr[6:8]
time := timeStr[0:2] + ":" + timeStr[2:4] + ":" + timeStr[4:]
fmt.Printf(" 📋 Format: count-yyyyMMdd-HHMMSS.sss-method.http\n")
fmt.Printf(" 🔢 Counter: %s\n", counter)
fmt.Printf(" 📅 Date: %s (from %s)\n", date, dateStr)
fmt.Printf(" 🕒 Time: %s (from %s)\n", time, timeStr)
fmt.Printf(" 🔧 Method: %s\n", method)
fmt.Printf(" ✨ Full timestamp: %s %s\n", date, time)
} else {
fmt.Printf(" ⚠️ Legacy format or unexpected structure\n")
}
fmt.Println()
}
return nil
})
if err != nil {
log.Printf("Error walking directory: %v", err)
}
fmt.Println("=== Comparison with Old Format ===")
fmt.Println()
fmt.Println("🔴 OLD format (time only): 0047-21-53-06.128-GET.http")
fmt.Println(" - No date information in filename")
fmt.Println(" - Date extracted from session ID directory")
fmt.Println(" - Confusing when recordings span midnight")
fmt.Println()
fmt.Println("🟢 NEW format (date + time): 0047-20260223-215306.128-GET.http")
fmt.Println(" - Complete timestamp in filename")
fmt.Println(" - Self-contained, no need to check directory")
fmt.Println(" - Clear chronological ordering")
fmt.Println()
fmt.Println("=== Benefits ===")
fmt.Println("✅ No confusion when recordings cross midnight")
fmt.Println("✅ Complete timestamp visible at a glance")
fmt.Println("✅ Better sorting and organization")
fmt.Println("✅ Backwards compatible with existing parsing logic")
fmt.Println()
// Test the list interactions functionality
fmt.Println("=== Using ListInteractions API ===")
fmt.Println()
interactions, err := recorder.ListInteractions("", "", "")
if err != nil {
log.Printf("Failed to list interactions: %v", err)
return
}
fmt.Printf("Found %d recorded interactions:\n", len(interactions))
for i := range interactions {
interaction := &interactions[i]
fmt.Printf("%d. %s %s - %s (File: %s)\n",
i+1, interaction.Method, interaction.Path,
interaction.Timestamp, interaction.ID)
}
fmt.Printf("\nDemo completed! Recordings saved in: %s\n", tmpDir)
fmt.Println("You can explore the generated files to see the new format in action.")
}
+6 -6
View File
@@ -6,18 +6,18 @@ require (
github.com/go-chi/chi/v5 v5.2.5
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/urfave/cli/v2 v2.27.7
golang.org/x/crypto v0.47.0
golang.org/x/crypto v0.48.0
)
require (
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/tools v0.41.0 // indirect
golang.org/x/tools v0.42.0 // indirect
)
+10 -10
View File
@@ -24,16 +24,16 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
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.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
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.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
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=
@@ -44,8 +44,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.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
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=
@@ -79,8 +79,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.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
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=
@@ -98,6 +98,6 @@ 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.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+2 -2
View File
@@ -1769,8 +1769,8 @@ func (c *Client) hasCapability(capabilities *models.Capabilities, capability str
}
// PlayTTS plays a Text-To-Speech message using Google TTS on the speaker
func (c *Client) PlayTTS(text, appKey string, volume ...int) error {
playInfo := models.NewTTSPlayInfo(text, appKey, volume...)
func (c *Client) PlayTTS(text, appKey, language string, volume ...int) error {
playInfo := models.NewTTSPlayInfo(text, appKey, language, volume...)
if err := playInfo.Validate(); err != nil {
return fmt.Errorf("invalid TTS request: %w", err)
+6
View File
@@ -30,6 +30,10 @@ type Config struct {
// Cache settings
CacheEnabled bool `env:"CACHE_ENABLED" default:"true"`
CacheTTL time.Duration `env:"CACHE_TTL" default:"30s"`
// Migration settings (TODO: Remove after 3-4 releases when all devices are migrated)
MigrationEnabled bool `env:"MIGRATION_ENABLED" default:"true"`
MigrationDryRun bool `env:"MIGRATION_DRY_RUN" default:"false"`
}
// DeviceConfig represents a configured SoundTouch device
@@ -48,6 +52,8 @@ func DefaultConfig() *Config {
PreferredDevices: []DeviceConfig{},
HTTPTimeout: 10 * time.Second,
UserAgent: "Bose-SoundTouch-Go-Client/1.0",
MigrationEnabled: true, // TODO: Change to false after 3-4 releases
MigrationDryRun: false,
CacheEnabled: true,
CacheTTL: 30 * time.Second,
}
+463
View File
@@ -0,0 +1,463 @@
// Package discovery provides DNS-based discovery and interception for Bose SoundTouch devices.
package discovery
import (
"fmt"
"log"
"net"
"strings"
"sync"
"time"
"github.com/miekg/dns"
)
// DNSDiscovery handles DNS queries and records discovered hosts.
type DNSDiscovery struct {
// Configuration
upstreamDNS []string
serviceIP string
// State
discovered map[string]*DiscoveredHost
mu sync.RWMutex
// Callbacks
onNewDiscovery func(hostname string)
// Servers for Shutdown
udpServer *dns.Server
tcpServer *dns.Server
// Address for loop prevention
bindAddr string
// Forward timeout
timeout time.Duration
// Log throttling
lastLog map[string]time.Time
lastLogMu sync.Mutex
}
// DiscoveredHost represents a host discovered via DNS queries.
type DiscoveredHost struct {
Hostname string `json:"hostname"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
QueryCount int `json:"query_count"`
IsBoseService bool `json:"is_bose_service"`
IsIntercepted bool `json:"is_intercepted"`
RemoteAddr string `json:"remote_addr,omitempty"`
}
// NewDNSDiscovery creates a new DNSDiscovery instance.
func NewDNSDiscovery(upstreamDNS []string, serviceIP string) *DNSDiscovery {
return &DNSDiscovery{
upstreamDNS: upstreamDNS,
serviceIP: serviceIP,
discovered: make(map[string]*DiscoveredHost),
timeout: 2 * time.Second,
lastLog: make(map[string]time.Time),
}
}
// ServeDNS implements the dns.Handler interface.
func (d *DNSDiscovery) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
if len(r.Question) == 0 {
return
}
q := r.Question[0]
hostname := strings.TrimSuffix(q.Name, ".")
remoteAddr := ""
if w.RemoteAddr() != nil {
remoteAddr = w.RemoteAddr().String()
}
// Decide how to respond
isIntercepted := d.shouldIntercept(hostname) || hostname == "aftertouch.test"
// Record discovery
d.recordQuery(hostname, isIntercepted, remoteAddr)
if isIntercepted {
// Return your service IP
d.respondWithIP(w, r, d.serviceIP)
d.throttledLog(fmt.Sprintf("[DNS] Intercepting %s (type %d) -> %s", hostname, q.Qtype, d.serviceIP))
} else {
// Forward to real DNS
if len(d.upstreamDNS) == 0 {
d.throttledLog("[DNS ERROR] No upstream DNS configured, cannot forward")
m := new(dns.Msg)
m.SetReply(r)
m.Rcode = dns.RcodeServerFailure
_ = w.WriteMsg(m)
return
}
d.throttledLog(fmt.Sprintf("[DNS] Forwarding %s (type %d) to %v", hostname, q.Qtype, d.upstreamDNS))
d.forward(w, r)
}
}
func (d *DNSDiscovery) throttledLog(msg string) {
d.lastLogMu.Lock()
defer d.lastLogMu.Unlock()
now := time.Now()
if last, ok := d.lastLog[msg]; ok && now.Sub(last) < 10*time.Second {
return
}
d.lastLog[msg] = now
log.Print(msg)
}
// recordQuery logs a DNS query and updates the internal state.
func (d *DNSDiscovery) recordQuery(hostname string, isIntercepted bool, remoteAddr string) {
d.mu.Lock()
defer d.mu.Unlock()
host, exists := d.discovered[hostname]
if !exists {
// New discovery!
host = &DiscoveredHost{
Hostname: hostname,
FirstSeen: time.Now(),
LastSeen: time.Now(),
QueryCount: 1,
IsBoseService: d.isBoseRelated(hostname),
IsIntercepted: isIntercepted,
RemoteAddr: remoteAddr,
}
d.discovered[hostname] = host
log.Printf("[NEW DISCOVERY] %s (Bose: %v, Intercepted: %v)",
hostname, host.IsBoseService, host.IsIntercepted)
if d.onNewDiscovery != nil {
go d.onNewDiscovery(hostname)
}
} else {
host.LastSeen = time.Now()
host.QueryCount++
host.IsIntercepted = isIntercepted
if remoteAddr != "" {
host.RemoteAddr = remoteAddr
}
}
}
func (d *DNSDiscovery) shouldIntercept(hostname string) bool {
// Intercept known Bose cloud services
interceptList := []string{
"api.bose.com",
"marge.bose.com",
"bmx.bose.com",
"streaming.bose.com",
"streamingoauth.bose.com",
"updates.bose.com",
"stats.bose.com",
"content.api.bose.io",
"events.api.bosecm.com",
"bose-prod.apigee.net",
"bose-test.apigee.net",
"worldwide.bose.com",
"music.api.bose.com",
"bosecm.com",
"bose.io",
}
for _, service := range interceptList {
if strings.Contains(hostname, service) {
return true
}
}
return false
}
func (d *DNSDiscovery) isBoseRelated(hostname string) bool {
return strings.Contains(hostname, "bose") ||
strings.Contains(hostname, "soundtouch")
}
func (d *DNSDiscovery) respondWithIP(w dns.ResponseWriter, r *dns.Msg, ip string) {
m := new(dns.Msg)
m.SetReply(r)
m.Compress = false // Embedded clients sometimes don't like compression
m.Authoritative = true
m.RecursionAvailable = true
q := r.Question[0]
log.Printf("[DNS] Intercepted query for %s (type %d) from %s", q.Name, q.Qtype, w.RemoteAddr())
resolvedIP := ip
if net.ParseIP(ip) == nil {
// Attempt resolution if it's not a numeric IP
ips, err := net.LookupIP(ip)
if err == nil && len(ips) > 0 {
for _, rIP := range ips {
if rIP.To4() != nil {
resolvedIP = rIP.String()
break
}
}
if resolvedIP == ip && len(ips) > 0 {
resolvedIP = ips[0].String()
}
}
}
switch q.Qtype {
case dns.TypeA, dns.TypeANY:
if net.ParseIP(resolvedIP) == nil || strings.Contains(resolvedIP, ":") {
// If it's still not a valid IPv4 address, we can't create an A record.
// Try CNAME as a fallback if it looks like a hostname.
if !strings.Contains(resolvedIP, ":") {
// Normalize hostname for CNAME
target := resolvedIP
if !strings.HasSuffix(target, ".") {
target += "."
}
rr, err := dns.NewRR(fmt.Sprintf("%s 60 IN CNAME %s", q.Name, target))
if err == nil {
m.Answer = append(m.Answer, rr)
log.Printf("[DNS] Returning CNAME record %s -> %s", q.Name, target)
} else {
log.Printf("[DNS] Error creating CNAME fallback for %s: %v", target, err)
m.Rcode = dns.RcodeServerFailure
}
} else {
m.Rcode = dns.RcodeServerFailure
}
} else {
rr, err := dns.NewRR(fmt.Sprintf("%s 60 IN A %s", q.Name, resolvedIP))
if err == nil {
m.Answer = append(m.Answer, rr)
log.Printf("[DNS] Returning A record %s -> %s", q.Name, resolvedIP)
} else {
log.Printf("[DNS] Error creating A record for %s: %v", resolvedIP, err)
m.Rcode = dns.RcodeServerFailure
}
}
case dns.TypeAAAA:
// Check if we have an IPv6 address
if net.ParseIP(resolvedIP) != nil && strings.Contains(resolvedIP, ":") {
rr, err := dns.NewRR(fmt.Sprintf("%s 60 IN AAAA %s", q.Name, resolvedIP))
if err == nil {
m.Answer = append(m.Answer, rr)
log.Printf("[DNS] Returning AAAA record %s -> %s", q.Name, resolvedIP)
} else {
log.Printf("[DNS] Error creating AAAA record for %s: %v", resolvedIP, err)
m.Rcode = dns.RcodeServerFailure
}
} else {
// Explicitly return SUCCESS with no data for AAAA to prevent fallback issues if no IPv6
log.Printf("[DNS] Returning empty AAAA success (NODATA) for %s", q.Name)
}
default:
log.Printf("[DNS] Returning empty success for type %d", q.Qtype)
}
if err := w.WriteMsg(m); err != nil {
log.Printf("[DNS ERROR] Failed to write response: %v", err)
}
}
func (d *DNSDiscovery) forward(w dns.ResponseWriter, r *dns.Msg) {
if len(r.Question) == 0 {
return
}
q := r.Question[0]
// Don't forward PTR queries for our own service IP to avoid loops or slow timeouts
if q.Qtype == dns.TypePTR {
m := new(dns.Msg)
m.SetReply(r)
m.Rcode = dns.RcodeNameError
if err := w.WriteMsg(m); err != nil {
log.Printf("[DNS ERROR] Failed to write NXDOMAIN: %v", err)
}
return
}
c := new(dns.Client)
c.Timeout = d.timeout
for _, upstream := range d.upstreamDNS {
// Add port 53 if not present
if !strings.Contains(upstream, ":") {
upstream += ":53"
}
// Loop prevention: don't forward to ourselves
if upstream == d.bindAddr || (strings.HasPrefix(upstream, "127.0.0.1:") && strings.HasSuffix(d.bindAddr, upstream[9:])) {
d.throttledLog(fmt.Sprintf("[DNS ERROR] Refusing to forward %s to ourselves (%s)", q.Name, upstream))
continue
}
in, _, err := c.Exchange(r, upstream)
if err == nil {
if in.Rcode == dns.RcodeSuccess {
if writeErr := w.WriteMsg(in); writeErr != nil {
log.Printf("[DNS ERROR] Failed to write forwarded response from %s: %v", upstream, writeErr)
}
return
}
d.throttledLog(fmt.Sprintf("[DNS] Upstream %s returned %s for %s, trying next", upstream, dns.RcodeToString[in.Rcode], q.Name))
} else {
d.throttledLog(fmt.Sprintf("[DNS ERROR] Forward failed for %s (type %d) via %s: %v", q.Name, q.Qtype, upstream, err))
}
}
// If we reach here, all upstreams failed
m := new(dns.Msg)
m.SetReply(r)
m.Rcode = dns.RcodeServerFailure
if err := w.WriteMsg(m); err != nil {
log.Printf("[DNS ERROR] Failed to write failure response: %v", err)
}
}
// GetDiscovered returns a map of all discovered hosts.
func (d *DNSDiscovery) GetDiscovered() map[string]*DiscoveredHost {
d.mu.RLock()
defer d.mu.RUnlock()
// Return copy
result := make(map[string]*DiscoveredHost)
for k, v := range d.discovered {
result[k] = v
}
return result
}
// GetBoseHosts returns a slice of all discovered Bose-related hosts.
func (d *DNSDiscovery) GetBoseHosts() []*DiscoveredHost {
d.mu.RLock()
defer d.mu.RUnlock()
var result []*DiscoveredHost
for _, host := range d.discovered {
if host.IsBoseService {
result = append(result, host)
}
}
return result
}
// SetDiscovered sets the map of discovered hosts.
func (d *DNSDiscovery) SetDiscovered(discovered map[string]*DiscoveredHost) {
d.mu.Lock()
defer d.mu.Unlock()
d.discovered = discovered
}
// Start DNS server starts both UDP and TCP listeners
func (d *DNSDiscovery) Start(addr string) error {
mux := dns.NewServeMux()
mux.HandleFunc(".", d.ServeDNS)
d.mu.Lock()
d.bindAddr = addr
d.udpServer = &dns.Server{
Addr: addr,
Net: "udp",
Handler: mux,
}
d.tcpServer = &dns.Server{
Addr: addr,
Net: "tcp",
Handler: mux,
}
// Capture server references before releasing mutex to avoid race condition
udpServer := d.udpServer
tcpServer := d.tcpServer
d.mu.Unlock()
errChan := make(chan error, 2)
go func() {
log.Printf("[DNS] UDP Discovery server starting on %s", addr)
if err := udpServer.ListenAndServe(); err != nil {
errChan <- fmt.Errorf("UDP server failed: %w", err)
}
}()
go func() {
log.Printf("[DNS] TCP Discovery server starting on %s", addr)
if err := tcpServer.ListenAndServe(); err != nil {
errChan <- fmt.Errorf("TCP server failed: %w", err)
}
}()
log.Printf("[DNS] Discovery servers starting on %s (upstream: %s, intercept IP: %s)", addr, d.upstreamDNS, d.serviceIP)
// Wait for first error
return <-errChan
}
// IsRunning returns true if the DNS server is active and bound to the specified address.
func (d *DNSDiscovery) IsRunning(addr string) bool {
d.mu.RLock()
defer d.mu.RUnlock()
if d.udpServer == nil || d.tcpServer == nil {
return false
}
// We check if the address matches what we expect
return d.udpServer.Addr == addr && d.tcpServer.Addr == addr
}
// Shutdown stops the DNS server listeners
func (d *DNSDiscovery) Shutdown() error {
d.mu.Lock()
defer d.mu.Unlock()
if d.udpServer != nil {
if err := d.udpServer.Shutdown(); err != nil {
log.Printf("[DNS] Error shutting down UDP server: %v", err)
}
d.udpServer = nil
}
if d.tcpServer != nil {
if err := d.tcpServer.Shutdown(); err != nil {
log.Printf("[DNS] Error shutting down TCP server: %v", err)
}
d.tcpServer = nil
}
return nil
}
+529
View File
@@ -0,0 +1,529 @@
package discovery
import (
"log"
"net"
"strings"
"testing"
"time"
"github.com/miekg/dns"
)
func TestDNSDiscovery_Interception(t *testing.T) {
serviceIP := "192.168.1.100"
upstreamDNS := []string{"8.8.8.8"}
d := NewDNSDiscovery(upstreamDNS, serviceIP)
// Test intercepting Bose service
m := new(dns.Msg)
m.SetQuestion("api.bose.com.", dns.TypeA)
rw := &mockResponseWriter{}
d.ServeDNS(rw, m)
if rw.msg == nil {
t.Fatal("Expected a response message, got nil")
}
if len(rw.msg.Answer) == 0 {
t.Fatal("Expected an answer in the response")
}
if a, ok := rw.msg.Answer[0].(*dns.A); ok {
if a.A.String() != serviceIP {
t.Errorf("Expected intercepted IP %s, got %s", serviceIP, a.A.String())
}
} else {
t.Errorf("Expected A record, got %T", rw.msg.Answer[0])
}
// Test intercepting streamingoauth.bose.com
if !d.shouldIntercept("streamingoauth.bose.com") {
t.Error("Expected streamingoauth.bose.com to be intercepted")
}
// Test aftertouch.test
m2 := new(dns.Msg)
m2.SetQuestion("aftertouch.test.", dns.TypeA)
rw2 := &mockResponseWriter{}
d.ServeDNS(rw2, m2)
if rw2.msg == nil || len(rw2.msg.Answer) == 0 {
t.Fatal("Expected response for aftertouch.test")
}
if a, ok := rw2.msg.Answer[0].(*dns.A); ok {
if a.A.String() != serviceIP {
t.Errorf("Expected intercepted IP %s for aftertouch.test, got %s", serviceIP, a.A.String())
}
} else {
t.Errorf("Expected A record for aftertouch.test, got %T", rw2.msg.Answer[0])
}
}
func TestDNSDiscovery_Forwarding(t *testing.T) {
// This test is harder because it needs a real upstream or a mock.
// For now, let's just test that it calls forward and record.
serviceIP := "192.168.1.100"
upstreamDNS := []string{"127.0.0.1:5353"} // Use a port that is likely closed or we can mock
d := NewDNSDiscovery(upstreamDNS, serviceIP)
m := new(dns.Msg)
m.SetQuestion("google.com.", dns.TypeA)
rw := &mockResponseWriter{}
// Start a mock upstream DNS server
mux := dns.NewServeMux()
mux.HandleFunc("google.com.", func(w dns.ResponseWriter, r *dns.Msg) {
m := new(dns.Msg)
m.SetReply(r)
_ = w.WriteMsg(m)
})
ts := &dns.Server{Addr: "127.0.0.1:5353", Net: "udp", Handler: mux, ReadTimeout: 100 * time.Millisecond, WriteTimeout: 100 * time.Millisecond}
go func() {
_ = ts.ListenAndServe()
}()
defer func() { _ = ts.Shutdown() }()
// Give it a moment to start
time.Sleep(100 * time.Millisecond)
// We expect forward to succeed
d.ServeDNS(rw, m)
d.mu.RLock()
host, exists := d.discovered["google.com"]
d.mu.RUnlock()
if !exists {
t.Error("Expected google.com to be recorded in discovery")
}
if host.IsBoseService {
t.Error("google.com should not be identified as a Bose service")
}
}
func TestDNSDiscovery_StartTCP(t *testing.T) {
serviceIP := "192.168.1.100"
upstreamDNS := []string{"8.8.8.8"}
d := NewDNSDiscovery(upstreamDNS, serviceIP)
addr := "127.0.0.1:5354"
go func() {
_ = d.Start(addr)
}()
// Give it a moment to start
time.Sleep(200 * time.Millisecond)
// Test TCP resolution
m := new(dns.Msg)
m.SetQuestion("api.bose.com.", dns.TypeA)
c := new(dns.Client)
c.Net = "tcp"
in, _, err := c.Exchange(m, addr)
if err != nil {
t.Fatalf("Failed to exchange via TCP: %v", err)
}
if len(in.Answer) == 0 {
t.Fatal("Expected answer in TCP response")
}
if a, ok := in.Answer[0].(*dns.A); ok {
if a.A.String() != serviceIP {
t.Errorf("Expected intercepted IP %s via TCP, got %s", serviceIP, a.A.String())
}
} else {
t.Errorf("Expected A record via TCP, got %T", in.Answer[0])
}
// Test Shutdown
err = d.Shutdown()
if err != nil {
t.Errorf("Shutdown failed: %v", err)
}
// Verify it's really shut down by trying to connect
_, _, err = c.Exchange(m, addr)
if err == nil {
t.Error("Expected error after shutdown, but could still exchange")
}
}
func TestDNSDiscovery_SelfForwarding(t *testing.T) {
serviceIP := "soundtouch.local"
upstreamDNS := []string{"127.0.0.1:5357"}
d := NewDNSDiscovery(upstreamDNS, serviceIP)
// Mock upstream DNS server for soundtouch.local
mux := dns.NewServeMux()
mux.HandleFunc("soundtouch.local.", func(w dns.ResponseWriter, r *dns.Msg) {
m := new(dns.Msg)
m.SetReply(r)
rr, _ := dns.NewRR("soundtouch.local. 60 IN A 192.168.178.10")
m.Answer = append(m.Answer, rr)
_ = w.WriteMsg(m)
})
ts := &dns.Server{Addr: "127.0.0.1:5357", Net: "udp", Handler: mux, ReadTimeout: 100 * time.Millisecond, WriteTimeout: 100 * time.Millisecond}
go func() {
_ = ts.ListenAndServe()
}()
defer func() { _ = ts.Shutdown() }()
time.Sleep(100 * time.Millisecond)
m := new(dns.Msg)
m.SetQuestion("soundtouch.local.", dns.TypeA)
rw := &mockResponseWriter{}
d.ServeDNS(rw, m)
if rw.msg == nil {
t.Fatal("Expected a response for soundtouch.local")
}
if rw.msg.Rcode != dns.RcodeSuccess {
t.Errorf("Expected Success (0) for soundtouch.local being forwarded, got %d", rw.msg.Rcode)
}
if len(rw.msg.Answer) == 0 {
t.Fatal("Expected an answer in the response")
}
if a, ok := rw.msg.Answer[0].(*dns.A); ok {
if a.A.String() != "192.168.178.10" {
t.Errorf("Expected IP 192.168.178.10, got %s", a.A.String())
}
}
// Check if d.recordQuery logged it correctly.
d.mu.RLock()
host, exists := d.discovered["soundtouch.local"]
d.mu.RUnlock()
if !exists {
t.Error("Expected soundtouch.local to be recorded")
}
// It should NOT be intercepted anymore
if host != nil && host.IsIntercepted {
t.Error("Expected soundtouch.local NOT to be intercepted anymore, but forwarded")
}
}
func TestDNSDiscovery_ForwardLocal(t *testing.T) {
serviceIP := "192.168.1.100"
upstreamDNS := []string{"127.0.0.1:5356"}
d := NewDNSDiscovery(upstreamDNS, serviceIP)
m := new(dns.Msg)
m.SetQuestion("someone-else.local.", dns.TypeA)
rw := &mockResponseWriter{}
// Start a mock upstream DNS server that returns SUCCESS for .local
mux := dns.NewServeMux()
mux.HandleFunc("someone-else.local.", func(w dns.ResponseWriter, r *dns.Msg) {
m := new(dns.Msg)
m.SetReply(r)
rr, _ := dns.NewRR("someone-else.local. 60 IN A 192.168.1.50")
m.Answer = append(m.Answer, rr)
_ = w.WriteMsg(m)
})
ts := &dns.Server{Addr: "127.0.0.1:5356", Net: "udp", Handler: mux, ReadTimeout: 100 * time.Millisecond, WriteTimeout: 100 * time.Millisecond}
go func() {
_ = ts.ListenAndServe()
}()
defer func() { _ = ts.Shutdown() }()
time.Sleep(100 * time.Millisecond)
d.ServeDNS(rw, m)
if rw.msg == nil {
t.Fatal("Expected a response message")
}
if rw.msg.Rcode != dns.RcodeSuccess {
t.Errorf("Expected Success (0) for .local being forwarded, got %d", rw.msg.Rcode)
}
if len(rw.msg.Answer) == 0 {
t.Fatal("Expected an answer in the response")
}
}
func TestDNSDiscovery_IsRunning(t *testing.T) {
serviceIP := "192.168.1.100"
upstreamDNS := []string{"8.8.8.8"}
d := NewDNSDiscovery(upstreamDNS, serviceIP)
addr := "127.0.0.1:5355"
if d.IsRunning(addr) {
t.Error("Expected IsRunning to be false before Start")
}
go func() {
_ = d.Start(addr)
}()
// Give it a moment to start
time.Sleep(200 * time.Millisecond)
if !d.IsRunning(addr) {
t.Error("Expected IsRunning to be true after Start")
}
if d.IsRunning("127.0.0.1:9999") {
t.Error("Expected IsRunning to be false for wrong address")
}
_ = d.Shutdown()
if d.IsRunning(addr) {
t.Error("Expected IsRunning to be false after Shutdown")
}
}
type mockResponseWriter struct {
msg *dns.Msg
}
func (m *mockResponseWriter) LocalAddr() net.Addr { return nil }
func (m *mockResponseWriter) RemoteAddr() net.Addr { return nil }
func (m *mockResponseWriter) WriteMsg(msg *dns.Msg) error { m.msg = msg; return nil }
func (m *mockResponseWriter) Write([]byte) (int, error) { return 0, nil }
func (m *mockResponseWriter) Close() error { return nil }
func (m *mockResponseWriter) TsigStatus() error { return nil }
func (m *mockResponseWriter) TsigTimersOnly(bool) {}
func (m *mockResponseWriter) Hijack() {}
func TestDNSDiscovery_LogThrottling(t *testing.T) {
d := NewDNSDiscovery([]string{"8.8.8.8"}, "192.168.1.100")
// Capture log output
var logBuf strings.Builder
oldOutput := log.Writer()
log.SetOutput(&logBuf)
defer log.SetOutput(oldOutput)
msg := "Test log message"
d.throttledLog(msg)
d.throttledLog(msg)
d.throttledLog(msg)
count := strings.Count(logBuf.String(), msg)
if count != 1 {
t.Errorf("Expected log message to appear once due to throttling, but appeared %d times", count)
}
// Advance time by 11 seconds to bypass throttling
d.lastLogMu.Lock()
d.lastLog[msg] = time.Now().Add(-11 * time.Second)
d.lastLogMu.Unlock()
d.throttledLog(msg)
count = strings.Count(logBuf.String(), msg)
if count != 2 {
t.Errorf("Expected log message to appear twice after advancing time, but appeared %d times", count)
}
}
func TestDNSDiscovery_LoopPrevention(t *testing.T) {
serviceIP := "192.168.1.100"
bindAddr := "127.0.0.1:53"
upstreamDNS := []string{"127.0.0.1:53"}
d := NewDNSDiscovery(upstreamDNS, serviceIP)
d.bindAddr = bindAddr
// Capture log output to avoid panic if it's being throttled/logged
var logBuf strings.Builder
oldOutput := log.Writer()
log.SetOutput(&logBuf)
defer log.SetOutput(oldOutput)
m := new(dns.Msg)
m.SetQuestion("google.com.", dns.TypeA)
rw := &mockResponseWriter{}
d.forward(rw, m)
if rw.msg == nil {
t.Fatal("Expected a response message")
}
if rw.msg.Rcode != dns.RcodeServerFailure {
t.Errorf("Expected RcodeServerFailure (2), got %d", rw.msg.Rcode)
}
}
func TestDNSDiscovery_EmptyUpstream(t *testing.T) {
serviceIP := "192.168.1.100"
var upstreamDNS []string // Empty upstream
d := NewDNSDiscovery(upstreamDNS, serviceIP)
d.bindAddr = ":53"
m := new(dns.Msg)
m.SetQuestion("google.com.", dns.TypeA)
rw := &mockResponseWriter{}
d.ServeDNS(rw, m)
if rw.msg == nil {
t.Fatal("Expected a response message, got nil")
}
if rw.msg.Rcode != dns.RcodeServerFailure {
t.Errorf("Expected RcodeServerFailure (2) for empty upstream, got %d", rw.msg.Rcode)
}
// Verify log message (optional, but good to check it's the simplified one)
}
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
d := NewDNSDiscovery(upstreamDNS, serviceIP)
d.timeout = 100 * time.Millisecond
m := new(dns.Msg)
m.SetQuestion("google.com.", dns.TypeA)
rw := &mockResponseWriter{}
start := time.Now()
d.forward(rw, m)
duration := time.Since(start)
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")
}
}
func TestDNSDiscovery_MultipleUpstreams(t *testing.T) {
serviceIP := "192.168.1.100"
// Mock server 1: returns NXDOMAIN
mux1 := dns.NewServeMux()
mux1.HandleFunc("test.com.", func(w dns.ResponseWriter, r *dns.Msg) {
m := new(dns.Msg)
m.SetReply(r)
m.Rcode = dns.RcodeNameError
_ = w.WriteMsg(m)
})
ts1 := &dns.Server{Addr: "127.0.0.1:5356", Net: "udp", Handler: mux1}
go func() { _ = ts1.ListenAndServe() }()
defer func() { _ = ts1.Shutdown() }()
// Mock server 2: succeeds
mux2 := dns.NewServeMux()
mux2.HandleFunc("test.com.", func(w dns.ResponseWriter, r *dns.Msg) {
m := new(dns.Msg)
m.SetReply(r)
m.Answer = append(m.Answer, &dns.A{
Hdr: dns.RR_Header{Name: r.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300},
A: net.ParseIP("1.2.3.4"),
})
_ = w.WriteMsg(m)
})
ts2 := &dns.Server{Addr: "127.0.0.1:5357", Net: "udp", Handler: mux2}
go func() { _ = ts2.ListenAndServe() }()
defer func() { _ = ts2.Shutdown() }()
time.Sleep(100 * time.Millisecond)
upstreamDNS := []string{"127.0.0.1:5356", "127.0.0.1:5357"}
d := NewDNSDiscovery(upstreamDNS, serviceIP)
m := new(dns.Msg)
m.SetQuestion("test.com.", dns.TypeA)
rw := &mockResponseWriter{}
d.forward(rw, m)
if rw.msg == nil {
t.Fatal("Expected a response message")
}
// It should succeed because it falls back to the second upstream
if rw.msg.Rcode != dns.RcodeSuccess {
t.Errorf("Expected RcodeSuccess (0), got %d. Fallback failed.", rw.msg.Rcode)
}
if len(rw.msg.Answer) == 0 {
t.Fatal("Expected an answer from the second upstream")
}
}
func TestDNSDiscovery_HostnameServiceIP(t *testing.T) {
// Use localhost which should resolve to 127.0.0.1
serviceIP := "localhost"
upstreamDNS := []string{"8.8.8.8"}
d := NewDNSDiscovery(upstreamDNS, serviceIP)
m := new(dns.Msg)
m.SetQuestion("api.bose.com.", dns.TypeA)
rw := &mockResponseWriter{}
d.ServeDNS(rw, m)
if rw.msg == nil {
t.Fatal("Expected a response message, got nil")
}
if len(rw.msg.Answer) == 0 {
t.Fatal("Expected an answer in the response")
}
if a, ok := rw.msg.Answer[0].(*dns.A); ok {
// It should be resolved to 127.0.0.1 (or whatever localhost resolves to)
if a.A.String() == "" {
t.Error("Expected a non-empty IP address")
}
log.Printf("Resolved localhost to %s", a.A.String())
} else if cname, ok := rw.msg.Answer[0].(*dns.CNAME); ok {
// Fallback to CNAME is also acceptable if resolution failed but it shouldn't for localhost
if cname.Target != "localhost." {
t.Errorf("Expected CNAME to localhost., got %s", cname.Target)
}
} else {
t.Errorf("Expected A or CNAME record, got %T", rw.msg.Answer[0])
}
}
func TestDNSDiscovery_UnresolvableHostname(t *testing.T) {
// Use a likely unresolvable hostname
serviceIP := "this.hostname.does.not.exist.at.all.invalid"
upstreamDNS := []string{"8.8.8.8"}
d := NewDNSDiscovery(upstreamDNS, serviceIP)
m := new(dns.Msg)
m.SetQuestion("api.bose.com.", dns.TypeA)
rw := &mockResponseWriter{}
d.ServeDNS(rw, m)
if rw.msg == nil {
t.Fatal("Expected a response message, got nil")
}
if len(rw.msg.Answer) == 0 {
t.Fatal("Expected an answer in the response (CNAME fallback)")
}
if cname, ok := rw.msg.Answer[0].(*dns.CNAME); ok {
expected := serviceIP + "."
if cname.Target != expected {
t.Errorf("Expected CNAME to %s, got %s", expected, cname.Target)
}
} else {
t.Errorf("Expected CNAME record for unresolvable hostname, got %T", rw.msg.Answer[0])
}
}
+2 -2
View File
@@ -21,9 +21,9 @@ func TestNewMDNSDiscoveryService(t *testing.T) {
}
func TestMDNSDiscoverDevices(t *testing.T) {
service := NewMDNSDiscoveryService(2 * time.Second)
service := NewMDNSDiscoveryService(100 * time.Millisecond)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
// Note: This test will attempt actual mDNS discovery
+6 -6
View File
@@ -78,11 +78,11 @@ func TestUnifiedDiscoveryWithCustomConfig(t *testing.T) {
func TestUnifiedDiscoverDevices(t *testing.T) {
cfg := config.DefaultConfig()
cfg.DiscoveryTimeout = 2 * time.Second
cfg.DiscoveryTimeout = 100 * time.Millisecond
cfg.CacheEnabled = false // Disable cache for testing
service := NewUnifiedDiscoveryService(cfg)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
devices, err := service.DiscoverDevices(ctx)
@@ -119,13 +119,13 @@ func TestUnifiedDiscoverDevices(t *testing.T) {
func TestUnifiedDiscoveryOnlyMDNS(t *testing.T) {
cfg := config.DefaultConfig()
cfg.DiscoveryTimeout = 1 * time.Second
cfg.DiscoveryTimeout = 100 * time.Millisecond
cfg.UPnPEnabled = false // Disable UPnP
cfg.MDNSEnabled = true // Enable only mDNS
cfg.CacheEnabled = false
service := NewUnifiedDiscoveryService(cfg)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
devices, err := service.DiscoverDevices(ctx)
@@ -141,13 +141,13 @@ func TestUnifiedDiscoveryOnlyMDNS(t *testing.T) {
func TestUnifiedDiscoveryOnlySSDP(t *testing.T) {
cfg := config.DefaultConfig()
cfg.DiscoveryTimeout = 1 * time.Second
cfg.DiscoveryTimeout = 100 * time.Millisecond
cfg.UPnPEnabled = true // Enable only UPnP
cfg.MDNSEnabled = false // Disable mDNS
cfg.CacheEnabled = false
service := NewUnifiedDiscoveryService(cfg)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
devices, err := service.DiscoverDevices(ctx)
+72 -26
View File
@@ -2,8 +2,10 @@ package discovery
import (
"context"
"encoding/xml"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
@@ -18,11 +20,12 @@ import (
// Service handles UPnP SSDP discovery of SoundTouch devices
type Service struct {
timeout time.Duration
cache map[string]*models.DiscoveredDevice
cacheTTL time.Duration
mutex sync.RWMutex
config *config.Config
timeout time.Duration
cache map[string]*models.DiscoveredDevice
cacheTTL time.Duration
mutex sync.RWMutex
config *config.Config
httpClient *http.Client
}
// NewService creates a new UPnP discovery service
@@ -32,11 +35,12 @@ func NewService(timeout time.Duration) *Service {
}
return &Service{
timeout: timeout,
cache: make(map[string]*models.DiscoveredDevice),
cacheTTL: defaultCacheTTL,
mutex: sync.RWMutex{},
config: config.DefaultConfig(),
timeout: timeout,
cache: make(map[string]*models.DiscoveredDevice),
cacheTTL: defaultCacheTTL,
mutex: sync.RWMutex{},
config: config.DefaultConfig(),
httpClient: &http.Client{Timeout: 5 * time.Second},
}
}
@@ -53,11 +57,12 @@ func NewServiceWithConfig(cfg *config.Config) *Service {
}
return &Service{
timeout: timeout,
cache: make(map[string]*models.DiscoveredDevice),
cacheTTL: cacheTTL,
mutex: sync.RWMutex{},
config: cfg,
timeout: timeout,
cache: make(map[string]*models.DiscoveredDevice),
cacheTTL: cacheTTL,
mutex: sync.RWMutex{},
config: cfg,
httpClient: &http.Client{Timeout: 5 * time.Second},
}
}
@@ -284,6 +289,15 @@ func (d *Service) listenForResponses(ctx context.Context, listener *net.UDPConn,
// buildMSearchRequest builds the M-SEARCH request for SoundTouch devices
func (d *Service) buildMSearchRequest() string {
mx := int(d.timeout.Seconds())
if mx < 1 {
mx = 1
}
if mx > 5 {
mx = 5
}
return fmt.Sprintf(
"M-SEARCH * HTTP/1.1\r\n"+
"HOST: %s\r\n"+
@@ -293,7 +307,7 @@ func (d *Service) buildMSearchRequest() string {
"\r\n",
ssdpAddr,
soundTouchURN,
int(d.timeout.Seconds()),
mx,
)
}
@@ -374,7 +388,7 @@ func (d *Service) parseResponse(response string) (*models.DiscoveredDevice, erro
log.Printf("UPnP: Successfully parsed device from location: %s at %s:%d", device.Name, device.Host, device.Port)
// Try to get more device info from the location URL
if err := d.enrichDeviceInfo(device, location); err != nil {
if err := d.EnrichDeviceInfo(device, location); err != nil {
log.Printf("UPnP: Could not enrich device info from location '%s': %v", location, err)
// Don't fail if we can't get additional info
// The basic info from URL parsing should be sufficient
@@ -417,15 +431,11 @@ func (d *Service) parseLocationURL(location, usn string) (*models.DiscoveredDevi
return device, nil
}
// enrichDeviceInfo tries to get additional device information from the device description
func (d *Service) enrichDeviceInfo(_ *models.DiscoveredDevice, location string) error {
// EnrichDeviceInfo tries to get additional device information from the device description
func (d *Service) EnrichDeviceInfo(device *models.DiscoveredDevice, location string) error {
log.Printf("UPnP: Attempting to enrich device info by fetching %s", location)
client := &http.Client{
Timeout: 5 * time.Second,
}
resp, err := client.Get(location)
resp, err := d.httpClient.Get(location)
if err != nil {
log.Printf("UPnP: Failed to fetch device description from %s: %v", location, err)
return err
@@ -437,8 +447,44 @@ func (d *Service) enrichDeviceInfo(_ *models.DiscoveredDevice, location string)
log.Printf("UPnP: Successfully fetched device description from %s (Status: %s)", location, resp.Status)
// For now, we'll keep it simple and not parse the full UPnP device description
// This can be enhanced later to extract more detailed device information
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read body: %w", err)
}
var upnpRoot struct {
XMLName xml.Name `xml:"root"`
Device struct {
FriendlyName string `xml:"friendlyName"`
ModelName string `xml:"modelName"`
SerialNumber string `xml:"serialNumber"`
} `xml:"device"`
}
if err := xml.Unmarshal(data, &upnpRoot); err != nil {
log.Printf("UPnP: Failed to parse device description from %s: %v", location, err)
return err
}
if upnpRoot.Device.FriendlyName != "" {
device.Name = upnpRoot.Device.FriendlyName
}
if upnpRoot.Device.ModelName != "" {
device.ModelID = upnpRoot.Device.ModelName
}
if upnpRoot.Device.SerialNumber != "" {
device.UPnPSerial = upnpRoot.Device.SerialNumber
}
log.Printf("UPnP: Enriched device info: Name='%s', Model='%s', UPnPSerial='%s'",
device.Name, device.ModelID, device.UPnPSerial)
return nil
}
+90
View File
@@ -0,0 +1,90 @@
package discovery
import (
"encoding/xml"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
func TestEnrichDeviceInfo(t *testing.T) {
// Mock UPnP device description XML
xmlData := `<?xml version="1.0" encoding="utf-8"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<device>
<friendlyName>Sound Machinery</friendlyName>
<modelName>SoundTouch 10</modelName>
<serialNumber>A81B6A536A09</serialNumber>
</device>
</root>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/xml")
fmt.Fprint(w, xmlData)
}))
defer server.Close()
device := &models.DiscoveredDevice{
Host: "127.0.0.1",
Name: "Initial Name",
}
service := NewService(1 * time.Second)
service.httpClient = server.Client()
err := service.EnrichDeviceInfo(device, server.URL)
if err != nil {
t.Fatalf("enrichDeviceInfo failed: %v", err)
}
if device.Name != "Sound Machinery" {
t.Errorf("expected Name 'Sound Machinery', got '%s'", device.Name)
}
if device.ModelID != "SoundTouch 10" {
t.Errorf("expected ModelID 'SoundTouch 10', got '%s'", device.ModelID)
}
if device.UPnPSerial != "A81B6A536A09" {
t.Errorf("expected UPnPSerial 'A81B6A536A09', got '%s'", device.UPnPSerial)
}
}
func TestUPnP_Unmarshal(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<device>
<friendlyName>Sound Machinery</friendlyName>
<modelName>SoundTouch 10</modelName>
<serialNumber>A81B6A536A09</serialNumber>
</device>
</root>`
var upnpRoot struct {
XMLName xml.Name `xml:"root"`
Device struct {
FriendlyName string `xml:"friendlyName"`
ModelName string `xml:"modelName"`
SerialNumber string `xml:"serialNumber"`
} `xml:"device"`
}
err := xml.Unmarshal([]byte(data), &upnpRoot)
if err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if upnpRoot.Device.FriendlyName != "Sound Machinery" {
t.Errorf("expected FriendlyName 'Sound Machinery', got '%s'", upnpRoot.Device.FriendlyName)
}
if upnpRoot.Device.ModelName != "SoundTouch 10" {
t.Errorf("expected ModelName 'SoundTouch 10', got '%s'", upnpRoot.Device.ModelName)
}
if upnpRoot.Device.SerialNumber != "A81B6A536A09" {
t.Errorf("expected SerialNumber 'A81B6A536A09', got '%s'", upnpRoot.Device.SerialNumber)
}
}
+319
View File
@@ -0,0 +1,319 @@
package discovery
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
func TestUPnP_EnrichDeviceInfo_RealDeviceXML(t *testing.T) {
// This tests the exact UPnP XML format provided by the user
realDeviceXML := `<?xml version="1.0" encoding="utf-8"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<device>
<deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
<friendlyName>Sound Machinechen</friendlyName>
<qq:X_QPlay_SoftwareCapability xmlns:qq="http://www.tencent.com">QPlay:2</qq:X_QPlay_SoftwareCapability>
<manufacturer>Bose Corporation</manufacturer>
<manufacturerURL>http://www.bose.com</manufacturerURL>
<modelName>SoundTouch 10</modelName>
<modelNumber></modelNumber>
<modelDescription>Bose SoundTouch Wireless Streaming Audio Device</modelDescription>
<modelURL>http://www.bose.com</modelURL>
<serialNumber>A81B6A536A98</serialNumber>
<UDN>uuid:BO5EBO5E-F00D-F00D-FEED-A81B6A536A98</UDN>
<serviceList>
<service>
<serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
<serviceId>urn:upnp-org:serviceId:AVTransport</serviceId>
<SCPDURL>/Xml/AVTransport3.xml</SCPDURL>
<controlURL>/AVTransport/Control</controlURL>
<eventSubURL>/AVTransport/Event</eventSubURL>
</service>
<service>
<serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType>
<serviceId>urn:upnp-org:serviceId:ConnectionManager</serviceId>
<SCPDURL>/Xml/ConnectionManager3.xml</SCPDURL>
<controlURL>/ConnectionManager/Control</controlURL>
<eventSubURL>/ConnectionManager/Event</eventSubURL>
</service>
<service>
<serviceType>urn:schemas-upnp-org:service:RenderingControl:1</serviceType>
<serviceId>urn:upnp-org:serviceId:RenderingControl</serviceId>
<SCPDURL>/Xml/RenderingControl3.xml</SCPDURL>
<controlURL>/RenderingControl/Control</controlURL>
<eventSubURL>/RenderingControl/Event</eventSubURL>
</service>
<service>
<serviceType>urn:schemas-tencent-com:service:QPlay:2</serviceType>
<serviceId>urn:tencent-com:serviceId:QPlay</serviceId>
<controlURL>/QPlay/Control</controlURL>
<eventSubURL>/QPlay/Event</eventSubURL>
<SCPDURL>/Xml/QPlay.xml</SCPDURL>
</service>
</serviceList>
</device>
</root>`
// Create a test server that serves the UPnP XML
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/xml; charset=utf-8")
fmt.Fprint(w, realDeviceXML)
}))
defer server.Close()
// Create a discovered device to enrich
device := &models.DiscoveredDevice{
Host: "192.168.1.100",
Port: 8091,
Name: "Initial Device Name",
}
// Create discovery service and enrich the device
service := NewService(5 * time.Second)
err := service.EnrichDeviceInfo(device, server.URL)
if err != nil {
t.Fatalf("enrichDeviceInfo failed: %v", err)
}
// Verify that the MAC address was extracted correctly from serialNumber
expectedMAC := "A81B6A536A98"
if device.UPnPSerial != expectedMAC {
t.Errorf("Expected UPnPSerial '%s', got '%s'", expectedMAC, device.UPnPSerial)
}
// Verify other enriched fields
expectedName := "Sound Machinechen"
if device.Name != expectedName {
t.Errorf("Expected Name '%s', got '%s'", expectedName, device.Name)
}
expectedModel := "SoundTouch 10"
if device.ModelID != expectedModel {
t.Errorf("Expected ModelID '%s', got '%s'", expectedModel, device.ModelID)
}
t.Logf("✓ Successfully extracted MAC address '%s' from UPnP serialNumber field", device.UPnPSerial)
t.Logf("✓ Device name: '%s'", device.Name)
t.Logf("✓ Device model: '%s'", device.ModelID)
}
func TestUPnP_MACAddressDiscovery_Integration(t *testing.T) {
// Test various MAC address formats that might appear in serialNumber
testCases := []struct {
name string
serialNumberInXML string
expectedUPnPSerial string
description string
}{
{
name: "StandardMAC",
serialNumberInXML: "A81B6A536A98",
expectedUPnPSerial: "A81B6A536A98",
description: "Standard MAC address format without separators",
},
{
name: "MACWithColons",
serialNumberInXML: "A8:1B:6A:53:6A:98",
expectedUPnPSerial: "A8:1B:6A:53:6A:98",
description: "MAC address with colon separators",
},
{
name: "MACWithDashes",
serialNumberInXML: "A8-1B-6A-53-6A-98",
expectedUPnPSerial: "A8-1B-6A-53-6A-98",
description: "MAC address with dash separators",
},
{
name: "LowercaseMAC",
serialNumberInXML: "a81b6a536a98",
expectedUPnPSerial: "a81b6a536a98",
description: "Lowercase MAC address",
},
{
name: "MixedCaseMAC",
serialNumberInXML: "a81B6a536A98",
expectedUPnPSerial: "a81B6a536A98",
description: "Mixed case MAC address",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create UPnP XML with the specific serialNumber format
xmlTemplate := `<?xml version="1.0" encoding="utf-8"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<device>
<deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
<friendlyName>Test Device</friendlyName>
<manufacturer>Bose Corporation</manufacturer>
<modelName>SoundTouch 10</modelName>
<serialNumber>%s</serialNumber>
<UDN>uuid:BO5EBO5E-F00D-F00D-FEED-TEST</UDN>
</device>
</root>`
deviceXML := fmt.Sprintf(xmlTemplate, tc.serialNumberInXML)
// Create test server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/xml")
fmt.Fprint(w, deviceXML)
}))
defer server.Close()
// Create and enrich device
device := &models.DiscoveredDevice{
Host: "192.168.1.100",
Port: 8090,
}
service := NewService(5 * time.Second)
err := service.EnrichDeviceInfo(device, server.URL)
if err != nil {
t.Errorf("%s: enrichDeviceInfo failed: %v", tc.description, err)
return
}
if device.UPnPSerial != tc.expectedUPnPSerial {
t.Errorf("%s: Expected UPnPSerial '%s', got '%s'",
tc.description, tc.expectedUPnPSerial, device.UPnPSerial)
} else {
t.Logf("✓ %s: Successfully extracted '%s'", tc.description, device.UPnPSerial)
}
})
}
}
func TestUPnP_URLPattern_Realistic(t *testing.T) {
// Test the exact URL pattern mentioned:
// http://192.168.1.100:8091/XD/BO5EBO5E-F00D-F00D-FEED-A81B6A536A98.xml
realDeviceXML := `<?xml version="1.0" encoding="utf-8"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<device>
<deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
<friendlyName>Sound Machinechen</friendlyName>
<manufacturer>Bose Corporation</manufacturer>
<modelName>SoundTouch 10</modelName>
<serialNumber>A81B6A536A98</serialNumber>
<UDN>uuid:BO5EBO5E-F00D-F00D-FEED-A81B6A536A98</UDN>
</device>
</root>`
// Create server that responds to the specific path
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/XD/BO5EBO5E-F00D-F00D-FEED-A81B6A536A98.xml" {
w.Header().Set("Content-Type", "text/xml")
fmt.Fprint(w, realDeviceXML)
} else {
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
// Test enrichment using the realistic URL path
device := &models.DiscoveredDevice{
Host: "192.168.1.100",
Port: 8091,
Name: "Initial Name",
}
service := NewService(5 * time.Second)
locationURL := server.URL + "/XD/BO5EBO5E-F00D-F00D-FEED-A81B6A536A98.xml"
err := service.EnrichDeviceInfo(device, locationURL)
if err != nil {
t.Fatalf("enrichDeviceInfo failed for realistic URL: %v", err)
}
// Verify MAC address extraction
expectedMAC := "A81B6A536A98"
if device.UPnPSerial != expectedMAC {
t.Errorf("Expected MAC '%s', got '%s'", expectedMAC, device.UPnPSerial)
}
// Note: The MAC address in the URL and in the XML serialNumber should match
if device.UPnPSerial == expectedMAC {
t.Logf("✓ MAC address '%s' extracted from UPnP XML matches expected value", device.UPnPSerial)
t.Logf("✓ This MAC can now be used for datastore mapping")
t.Logf("✓ Request URL pattern: GET /streaming/account/{account}/device/%s/presets", device.UPnPSerial)
}
}
func TestUPnP_ErrorHandling(t *testing.T) {
service := NewService(5 * time.Second)
device := &models.DiscoveredDevice{
Host: "192.168.1.100",
Name: "Test Device",
}
t.Run("InvalidXML", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/xml")
fmt.Fprint(w, "invalid xml content")
}))
defer server.Close()
err := service.EnrichDeviceInfo(device, server.URL)
if err == nil {
t.Error("Expected error for invalid XML, got nil")
} else {
t.Logf("✓ Correctly handled invalid XML: %v", err)
}
})
t.Run("HTTPError", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
err := service.EnrichDeviceInfo(device, server.URL)
if err == nil {
t.Error("Expected error for HTTP 500, got nil")
} else {
t.Logf("✓ Correctly handled HTTP error: %v", err)
}
})
t.Run("MissingSerialNumber", func(t *testing.T) {
xmlWithoutSerial := `<?xml version="1.0" encoding="utf-8"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<device>
<friendlyName>Test Device</friendlyName>
<modelName>Test Model</modelName>
</device>
</root>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/xml")
fmt.Fprint(w, xmlWithoutSerial)
}))
defer server.Close()
deviceCopy := *device // Make a copy to avoid modifying the original
err := service.EnrichDeviceInfo(&deviceCopy, server.URL)
// Should not error, but UPnPSerial should be empty
if err != nil {
t.Errorf("Unexpected error for missing serialNumber: %v", err)
}
if deviceCopy.UPnPSerial != "" {
t.Errorf("Expected empty UPnPSerial, got '%s'", deviceCopy.UPnPSerial)
} else {
t.Logf("✓ Correctly handled missing serialNumber (empty UPnPSerial)")
}
})
}
+63 -31
View File
@@ -2,6 +2,9 @@ package discovery
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -36,27 +39,48 @@ func TestNewDiscoveryServiceWithDefaultTimeout(t *testing.T) {
}
func TestBuildMSearchRequest(t *testing.T) {
service := NewService(5 * time.Second)
request := service.buildMSearchRequest()
t.Run("DefaultTimeout", func(t *testing.T) {
service := NewService(5 * time.Second)
request := service.buildMSearchRequest()
expectedLines := []string{
"M-SEARCH * HTTP/1.1",
"HOST: 239.255.255.250:1900",
"MAN: \"ssdp:discover\"",
"ST: urn:schemas-upnp-org:device:MediaRenderer:1",
"MX: 5",
}
for _, expectedLine := range expectedLines {
if !contains(request, expectedLine) {
t.Errorf("Expected M-SEARCH request to contain '%s'", expectedLine)
expectedLines := []string{
"M-SEARCH * HTTP/1.1",
"HOST: 239.255.255.250:1900",
"MAN: \"ssdp:discover\"",
"ST: urn:schemas-upnp-org:device:MediaRenderer:1",
"MX: 5",
}
}
// Check that request ends with double CRLF
if !contains(request, "\r\n\r\n") {
t.Error("Expected M-SEARCH request to end with double CRLF")
}
for _, expectedLine := range expectedLines {
if !contains(request, expectedLine) {
t.Errorf("Expected M-SEARCH request to contain '%s'", expectedLine)
}
}
if !contains(request, "\r\n\r\n") {
t.Error("Expected M-SEARCH request to end with double CRLF")
}
})
t.Run("LowTimeout", func(t *testing.T) {
// If timeout is less than 1 second, it should still use MX: 1
service := NewService(500 * time.Millisecond)
request := service.buildMSearchRequest()
if !contains(request, "MX: 1") {
t.Errorf("Expected low timeout (500ms) to result in MX: 1, but got something else. Request:\n%s", request)
}
})
t.Run("VeryHighTimeout", func(t *testing.T) {
// MX should probably be capped at 5 for UPnP compatibility
service := NewService(10 * time.Second)
request := service.buildMSearchRequest()
if !contains(request, "MX: 5") {
t.Errorf("Expected high timeout (10s) to result in MX: 5, but got something else. Request:\n%s", request)
}
})
}
func TestParseLocationURL_Valid(t *testing.T) {
@@ -109,18 +133,30 @@ func TestParseLocationURL_Invalid(t *testing.T) {
}
func TestParseResponse_ValidMediaRenderer(t *testing.T) {
service := NewService(1 * time.Second)
xmlPayload := `<?xml version="1.0" encoding="utf-8"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<device>
<friendlyName>Test Device</friendlyName>
<modelName>SoundTouch 10</modelName>
<serialNumber>AABBCCDDEEFF</serialNumber>
</device>
</root>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/xml")
fmt.Fprint(w, xmlPayload)
}))
defer server.Close()
validResponse := `HTTP/1.1 200 OK
service := NewService(1 * time.Second)
service.httpClient = server.Client()
validResponse := fmt.Sprintf(`HTTP/1.1 200 OK
Cache-Control: max-age=1800
Date: Mon, 22 Jun 1998 09:55:21 GMT
EXT:
Location: http://192.168.1.100:8090/device.xml
Server: Linux/3.14.0 UPnP/1.0 Bose-SoundTouch/1.0
Location: %s
ST: urn:schemas-upnp-org:device:MediaRenderer:1
USN: uuid:12345678-1234-5678-9012-123456789012::urn:schemas-upnp-org:device:MediaRenderer:1
`
`, server.URL)
device, err := service.parseResponse(validResponse)
if err != nil {
@@ -131,12 +167,8 @@ USN: uuid:12345678-1234-5678-9012-123456789012::urn:schemas-upnp-org:device:Medi
t.Fatal("Expected device, got nil")
}
if device.Host != "192.168.1.100" {
t.Errorf("Expected host '192.168.1.100', got '%s'", device.Host)
}
if device.UPnPLocation != "http://192.168.1.100:8090/device.xml" {
t.Errorf("Expected UPnP location 'http://192.168.1.100:8090/device.xml', got '%s'", device.UPnPLocation)
if device.Name != "Test Device" {
t.Errorf("Expected name 'Test Device', got '%s'", device.Name)
}
}
+2
View File
@@ -70,6 +70,7 @@ type DiscoveredDevice struct {
// Protocol-specific details
UPnPLocation string `json:"upnp_location,omitempty"` // UPnP device description XML URL
UPnPUSN string `json:"upnp_usn,omitempty"` // UPnP Unique Service Name
UPnPSerial string `json:"upnp_serial,omitempty"` // Serial number from UPnP (MAC address)
MDNSHostname string `json:"mdns_hostname,omitempty"` // mDNS hostname (e.g., "device.local.")
MDNSService string `json:"mdns_service,omitempty"` // mDNS service name
ConfigName string `json:"config_name,omitempty"` // Original name from config
@@ -94,6 +95,7 @@ func (d *DiscoveredDevice) GetProtocolSpecificData() map[string]interface{} {
data["upnp"] = map[string]string{
"location": d.UPnPLocation,
"usn": d.UPnPUSN,
"serial": d.UPnPSerial,
}
}
+86 -8
View File
@@ -177,14 +177,25 @@ type ConfiguredSource struct {
// ServiceDeviceInfo represents information about a SoundTouch device.
type ServiceDeviceInfo struct {
DeviceID string `json:"device_id" xml:"deviceID,attr"`
ProductCode string `json:"product_code" xml:"type"`
DeviceSerialNumber string `json:"device_serial_number" xml:"serialnumber"`
ProductSerialNumber string `json:"product_serial_number" xml:"product_serial_number"`
FirmwareVersion string `json:"firmware_version" xml:"softwareVersion"`
IPAddress string `json:"ip_address" xml:"ipAddress"`
Name string `json:"name" xml:"name"`
DiscoveryMethod string `json:"discovery_method,omitempty"`
DeviceID string `json:"device_id" xml:"deviceID,attr"`
ProductCode string `json:"product_code" xml:"type"`
DeviceSerialNumber string `json:"device_serial_number" xml:"serialnumber"`
ProductSerialNumber string `json:"product_serial_number" xml:"product_serial_number"`
FirmwareVersion string `json:"firmware_version" xml:"softwareVersion"`
IPAddress string `json:"ip_address" xml:"ipAddress"`
Name string `json:"name" xml:"name"`
MacAddress string `json:"mac_address,omitempty" xml:"-"`
DiscoveryMethod string `json:"discovery_method,omitempty"`
AccountID string `json:"account_id,omitempty"`
Components []ServiceComponent `json:"components,omitempty" xml:"-"`
}
// ServiceComponent represents a hardware or software component of a device.
type ServiceComponent struct {
Type string `xml:"type,attr"`
Category string `xml:"category,attr"`
SoftwareVersion string `xml:"softwareVersion"`
SerialNumber string `xml:"serialNumber"`
}
// CustomerSupportDevice represents device information for customer support purposes.
@@ -239,3 +250,70 @@ type DeviceEvent struct {
MonoTime int64 `json:"monoTime"`
Data map[string]interface{} `json:"data"`
}
// DeviceEventsRequest represents a request containing multiple device events (stapp/scmudc).
type DeviceEventsRequest struct {
Envelope struct {
MonoTime int64 `json:"monoTime"`
PayloadProtocolVersion string `json:"payloadProtocolVersion"`
PayloadType string `json:"payloadType"`
ProtocolVersion string `json:"protocolVersion"`
Time string `json:"time"`
UniqueID string `json:"uniqueId"`
} `json:"envelope"`
Payload struct {
DeviceInfo struct {
BoseID string `json:"boseID"`
DeviceID string `json:"deviceID"`
DeviceType string `json:"deviceType"`
SoftwareVersion string `json:"softwareVersion"`
} `json:"deviceInfo"`
Events []struct {
Data map[string]interface{} `json:"data"`
Time string `json:"time"`
Type string `json:"type"`
} `json:"events"`
} `json:"payload"`
}
// DeviceSettingsResponse represents device settings.
type DeviceSettingsResponse struct {
XMLName xml.Name `xml:"deviceSettings"`
Settings []DeviceSetting `xml:"deviceSetting"`
}
// DeviceSetting represents a single device setting.
type DeviceSetting struct {
Name string `xml:"name"`
Value string `xml:"value"`
}
// AccountProfileResponse represents a customer account profile.
type AccountProfileResponse struct {
XMLName xml.Name `xml:"customer"`
AccountID string `xml:"accountID"`
Email string `xml:"email"`
FirstName string `xml:"firstName"`
LastName string `xml:"lastName"`
CountryCode string `xml:"countryCode"`
LanguageCode string `xml:"languageCode"`
Street string `xml:"street"`
City string `xml:"city"`
PostalCode string `xml:"postalCode"`
State string `xml:"state"`
Phone string `xml:"phone"`
MarketingOptIn bool `xml:"marketingOptIn"`
}
// ChangePasswordRequest represents a request to change the account password.
type ChangePasswordRequest struct {
XMLName xml.Name `xml:"passwordChange"`
OldPassword string `xml:"oldPassword"`
NewPassword string `xml:"newPassword"`
}
// EmailAddressResponse represents the account email address.
type EmailAddressResponse struct {
XMLName xml.Name `xml:"emailAddress"`
Email string `xml:",chardata"`
}
+4 -2
View File
@@ -3,6 +3,8 @@ package models
import (
"encoding/xml"
"errors"
"fmt"
"net/url"
)
// Error constants for speaker validation
@@ -57,9 +59,9 @@ func (p *PlayInfo) SetVolume(volume int) *PlayInfo {
}
// NewTTSPlayInfo creates a PlayInfo for Google TTS playback
func NewTTSPlayInfo(text, appKey string, volume ...int) *PlayInfo {
func NewTTSPlayInfo(text, appKey, language string, volume ...int) *PlayInfo {
// URL encode the text for Google TTS
url := "http://translate.google.com/translate_tts?ie=UTF-8&tl=EN&client=tw-ob&q=" + text
url := fmt.Sprintf("http://translate.google.com/translate_tts?ie=UTF-8&tl=%s&client=tw-ob&q=%s", language, url.QueryEscape(text))
playInfo := &PlayInfo{
XMLName: xml.Name{Local: "play_info"},
+3 -3
View File
@@ -35,9 +35,9 @@ func TestNewPlayInfo(t *testing.T) {
func TestNewTTSPlayInfo(t *testing.T) {
// Test without volume
playInfo := NewTTSPlayInfo("Hello World", "test-key")
playInfo := NewTTSPlayInfo("Hello World", "test-key", "EN")
expectedURL := "http://translate.google.com/translate_tts?ie=UTF-8&tl=EN&client=tw-ob&q=Hello World"
expectedURL := "http://translate.google.com/translate_tts?ie=UTF-8&tl=EN&client=tw-ob&q=Hello+World"
if playInfo.URL != expectedURL {
t.Errorf("Expected URL '%s', got '%s'", expectedURL, playInfo.URL)
}
@@ -63,7 +63,7 @@ func TestNewTTSPlayInfo(t *testing.T) {
}
// Test with volume
playInfoWithVolume := NewTTSPlayInfo("Hello World", "test-key", 50)
playInfoWithVolume := NewTTSPlayInfo("Hello World", "test-key", "EN", 50)
if playInfoWithVolume.Volume == nil || *playInfoWithVolume.Volume != 50 {
t.Errorf("Expected Volume to be 50, got %v", playInfoWithVolume.Volume)
}
+38 -2
View File
@@ -304,8 +304,9 @@ type SpecialMessageType string
// Constants for special message types
const (
MessageTypeSdkInfo SpecialMessageType = "sdkInfo"
MessageTypeUserActivity SpecialMessageType = "userActivity"
MessageTypeSdkInfo SpecialMessageType = "sdkInfo"
MessageTypeUserActivity SpecialMessageType = "userActivity"
MessageTypeUserInactivity SpecialMessageType = "userInactivity"
)
// SoundTouchSdkInfo represents the SDK info message sent on connection
@@ -321,6 +322,12 @@ type UserActivityUpdate struct {
DeviceID string `xml:"deviceID,attr"`
}
// UserInactivityUpdate represents user inactivity notifications
type UserInactivityUpdate struct {
XMLName xml.Name `xml:"userInactivityUpdate"`
DeviceID string `xml:"deviceID,attr"`
}
// SpecialMessage represents non-updates WebSocket messages
type SpecialMessage struct {
Type SpecialMessageType
@@ -604,6 +611,22 @@ func ParseSpecialMessage(data []byte) (*SpecialMessage, error) {
}, nil
}
// Check for userInactivityUpdate
if strings.Contains(dataStr, "<userInactivityUpdate") {
var userInactivity UserInactivityUpdate
if err := xml.Unmarshal(data, &userInactivity); err != nil {
return nil, fmt.Errorf("failed to parse userInactivityUpdate: %w", err)
}
return &SpecialMessage{
Type: MessageTypeUserInactivity,
DeviceID: userInactivity.DeviceID,
Data: &userInactivity,
RawData: data,
Timestamp: time.Now(),
}, nil
}
return nil, fmt.Errorf("unknown special message type: %s", dataStr)
}
@@ -629,6 +652,17 @@ func (sm *SpecialMessage) GetUserActivity() *UserActivityUpdate {
return nil
}
// GetUserInactivity returns the parsed UserInactivity data if the message is of that type
func (sm *SpecialMessage) GetUserInactivity() *UserInactivityUpdate {
if sm.Type == MessageTypeUserInactivity {
if userInactivity, ok := sm.Data.(*UserInactivityUpdate); ok {
return userInactivity
}
}
return nil
}
// String returns a string representation of the special message
func (sm *SpecialMessage) String() string {
switch sm.Type {
@@ -638,6 +672,8 @@ func (sm *SpecialMessage) String() string {
}
case MessageTypeUserActivity:
return fmt.Sprintf("User Activity [Device: %s]", sm.DeviceID)
case MessageTypeUserInactivity:
return fmt.Sprintf("User Inactivity [Device: %s]", sm.DeviceID)
}
return fmt.Sprintf("Unknown Special Message - Type: %s", sm.Type)
+3 -3
View File
@@ -148,8 +148,8 @@ func (cm *CertificateManager) GenerateCA() error {
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"SoundTouch Local Service"},
CommonName: "SoundTouch Local Root CA",
Organization: []string{"AfterTouch"},
CommonName: "AfterTouch Local Root CA",
},
NotBefore: notBefore,
NotAfter: notAfter,
@@ -240,7 +240,7 @@ func (cm *CertificateManager) GenerateCertificate(domains []string) ([]byte, []b
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"SoundTouch Local Service"},
Organization: []string{"AfterTouch"},
CommonName: domains[0],
},
NotBefore: notBefore,
+2 -2
View File
@@ -103,7 +103,7 @@ func TestCertificateManager(t *testing.T) {
}
// Test certificate regeneration if domains change
newDomains := append(domains, "mac.fritz.box")
newDomains := append(domains, "foo.local")
tlsConfig2, err := cm.GetServerTLSConfig(newDomains)
if err != nil {
t.Fatalf("Failed to get updated TLS config: %v", err)
@@ -116,7 +116,7 @@ func TestCertificateManager(t *testing.T) {
cert, _ := x509.ParseCertificate(block.Bytes)
found := false
for _, d := range cert.DNSNames {
if d == "mac.fritz.box" {
if d == "foo.local" {
found = true
break
}
+1
View File
@@ -41,6 +41,7 @@ var Providers = []string{
"RADIO.COM",
"RADIO_COM",
"SIRIUSXM_EVEREST",
"RADIO_BROWSER",
}
// Common file and path constants used by the datastore and setup logic.
@@ -0,0 +1,296 @@
package datastore
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
)
func TestMacAddressCaseSensitivity(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "case-sensitivity-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
accountID := "3230304"
serialNumber := "I6332527703739342000020"
// Test scenarios that could occur in production
testCases := []struct {
name string
macInDeviceInfo string
macInRequest string
expectedToWork bool
description string
}{
{
name: "ExactMatch",
macInDeviceInfo: "A81B6A536A98",
macInRequest: "A81B6A536A98",
expectedToWork: true,
description: "Exact case match should work",
},
{
name: "DeviceInfoUpperRequestLower",
macInDeviceInfo: "A81B6A536A98",
macInRequest: "a81b6a536a98",
expectedToWork: true,
description: "DeviceInfo has uppercase, request has lowercase (should work with normalization)",
},
{
name: "DeviceInfoLowerRequestUpper",
macInDeviceInfo: "a81b6a536a98",
macInRequest: "A81B6A536A98",
expectedToWork: true,
description: "DeviceInfo has lowercase, request has uppercase (should work with normalization)",
},
{
name: "MixedCaseInDeviceInfo",
macInDeviceInfo: "a81B6a536A98",
macInRequest: "A81B6A536A98",
expectedToWork: true,
description: "Mixed case in DeviceInfo vs uppercase request (should work with normalization)",
},
{
name: "WithColonsInDeviceInfo",
macInDeviceInfo: "A8:1B:6A:53:6A:98",
macInRequest: "A81B6A536A98",
expectedToWork: true,
description: "DeviceInfo has colons, request without (should work with normalization)",
},
{
name: "WithDashesInDeviceInfo",
macInDeviceInfo: "A8-1B-6A-53-6A-98",
macInRequest: "A81B6A536A98",
expectedToWork: true,
description: "DeviceInfo has dashes, request without (should work with normalization)",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create separate directory for this test case
testDir := filepath.Join(tmpDir, tc.name)
deviceDir := filepath.Join(testDir, "accounts", accountID, "devices", serialNumber)
if err := os.MkdirAll(deviceDir, 0755); err != nil {
t.Fatalf("failed to create device dir: %v", err)
}
// Create DeviceInfo.xml with specific MAC format
deviceInfoXML := `<?xml version="1.0" encoding="UTF-8"?>
<info deviceID="` + serialNumber + `">
<name>Test Device</name>
<components>
<component>
<componentCategory>SCM</componentCategory>
<softwareVersion>4.8.1</softwareVersion>
<serialNumber>` + serialNumber + `</serialNumber>
</component>
</components>
<networkInfo type="SCM">
<macAddress>` + tc.macInDeviceInfo + `</macAddress>
<ipAddress>192.168.1.100</ipAddress>
</networkInfo>
</info>`
if err := os.WriteFile(filepath.Join(deviceDir, constants.DeviceInfoFile), []byte(deviceInfoXML), 0644); err != nil {
t.Fatalf("failed to write DeviceInfo.xml: %v", err)
}
// Create Presets.xml
presetsXML := `<presets><preset id="1">test</preset></presets>`
if err := os.WriteFile(filepath.Join(deviceDir, constants.PresetsFile), []byte(presetsXML), 0644); err != nil {
t.Fatalf("failed to write Presets.xml: %v", err)
}
// Initialize datastore
ds := NewDataStore(testDir)
if err := ds.Initialize(); err != nil {
t.Fatalf("failed to initialize datastore: %v", err)
}
// Check mapping
ds.idMutex.RLock()
mappedSerial, hasMappingForRequest := ds.deviceMappings[tc.macInRequest]
mappedSerialFromDeviceInfo, hasMappingForDeviceInfo := ds.deviceMappings[tc.macInDeviceInfo]
ds.idMutex.RUnlock()
t.Logf("%s:", tc.description)
t.Logf(" MAC in DeviceInfo.xml: '%s'", tc.macInDeviceInfo)
t.Logf(" MAC in request: '%s'", tc.macInRequest)
t.Logf(" Mapping exists for request MAC: %v", hasMappingForRequest)
t.Logf(" Mapping exists for DeviceInfo MAC: %v", hasMappingForDeviceInfo)
if hasMappingForRequest {
t.Logf(" Request MAC '%s' maps to serial: '%s'", tc.macInRequest, mappedSerial)
}
if hasMappingForDeviceInfo {
t.Logf(" DeviceInfo MAC '%s' maps to serial: '%s'", tc.macInDeviceInfo, mappedSerialFromDeviceInfo)
}
// Try GetPresets
_, err := ds.GetPresets(accountID, tc.macInRequest)
worked := err == nil
if tc.expectedToWork && !worked {
t.Errorf("Expected success but got error: %v", err)
} else if !tc.expectedToWork && worked {
t.Errorf("Expected failure but got success")
} else if worked {
t.Logf(" ✓ Successfully resolved MAC '%s'", tc.macInRequest)
} else {
t.Logf(" ✓ Correctly failed to resolve MAC '%s'", tc.macInRequest)
}
})
}
}
// TestProductionScenarioSimulation simulates the exact issue described
func TestProductionScenarioSimulation(t *testing.T) {
// This test specifically simulates the production scenario where:
// Request: GET /streaming/account/3230304/device/A81B6A536A98/presets
// File exists at: /var/lib/soundtouch-service/accounts/3230304/devices/I6332527703739342000020/Presets.xml
tmpDir, err := os.MkdirTemp("", "production-scenario")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
accountID := "3230304"
serialNumber := "I6332527703739342000020"
requestMAC := "A81B6A536A98"
// Test different MAC address formats that could be in DeviceInfo.xml
possibleMACFormats := []string{
"A81B6A536A98", // Exact match
"a81b6a536a98", // All lowercase
"A81b6a536A98", // Mixed case
"A8:1B:6A:53:6A:98", // With colons
"A8-1B-6A-53-6A-98", // With dashes
"a8:1b:6a:53:6a:98", // Lowercase with colons
"a8-1b-6a-53-6a-98", // Lowercase with dashes
}
t.Logf("Production scenario simulation:")
t.Logf("Request URL: GET /streaming/account/%s/device/%s/presets", accountID, requestMAC)
t.Logf("Expected file location: accounts/%s/devices/%s/Presets.xml", accountID, serialNumber)
t.Logf("")
for i, macFormat := range possibleMACFormats {
t.Run(fmt.Sprintf("MACFormat_%d", i), func(t *testing.T) {
// Create fresh directory for this test
testDir := filepath.Join(tmpDir, fmt.Sprintf("test_%d", i))
deviceDir := filepath.Join(testDir, "accounts", accountID, "devices", serialNumber)
if err := os.MkdirAll(deviceDir, 0755); err != nil {
t.Fatalf("failed to create device dir: %v", err)
}
// Create DeviceInfo.xml with this MAC format
deviceInfoXML := `<?xml version="1.0" encoding="UTF-8"?>
<info deviceID="` + serialNumber + `">
<name>SoundTouch Device</name>
<components>
<component>
<componentCategory>SCM</componentCategory>
<softwareVersion>4.8.1</softwareVersion>
<serialNumber>` + serialNumber + `</serialNumber>
</component>
</components>
<networkInfo type="SCM">
<macAddress>` + macFormat + `</macAddress>
<ipAddress>192.168.1.100</ipAddress>
</networkInfo>
</info>`
if err := os.WriteFile(filepath.Join(deviceDir, constants.DeviceInfoFile), []byte(deviceInfoXML), 0644); err != nil {
t.Fatalf("failed to write DeviceInfo.xml: %v", err)
}
// Create the target file that should be found
presetsXML := `<presets><preset id="1">test</preset></presets>`
if err := os.WriteFile(filepath.Join(deviceDir, constants.PresetsFile), []byte(presetsXML), 0644); err != nil {
t.Fatalf("failed to write Presets.xml: %v", err)
}
// Initialize datastore
ds := NewDataStore(testDir)
if err := ds.Initialize(); err != nil {
t.Fatalf("failed to initialize datastore: %v", err)
}
// Try to access using the request MAC
presets, err := ds.GetPresets(accountID, requestMAC)
if err == nil {
t.Logf("✓ SUCCESS: MAC format '%s' in DeviceInfo allows request with '%s' to work (%d presets found)",
macFormat, requestMAC, len(presets))
} else {
t.Logf("✗ FAILED: MAC format '%s' in DeviceInfo does not allow request with '%s' (error: %v)",
macFormat, requestMAC, err)
}
// Check what actually got mapped
ds.idMutex.RLock()
for mac, serial := range ds.deviceMappings {
t.Logf(" Mapping: '%s' -> '%s'", mac, serial)
}
ds.idMutex.RUnlock()
})
}
}
// TestNormalizationSuggestion tests if we should implement MAC address normalization
func TestNormalizationSuggestion(t *testing.T) {
// This test demonstrates how MAC address normalization could solve the issue
normalizeMAC := func(mac string) string {
// Remove common separators and convert to uppercase
mac = strings.ReplaceAll(mac, ":", "")
mac = strings.ReplaceAll(mac, "-", "")
mac = strings.ToUpper(mac)
return mac
}
testCases := []struct {
original string
normalized string
}{
{"A81B6A536A98", "A81B6A536A98"},
{"a81b6a536a98", "A81B6A536A98"},
{"A8:1B:6A:53:6A:98", "A81B6A536A98"},
{"a8:1b:6a:53:6a:98", "A81B6A536A98"},
{"A8-1B-6A-53-6A-98", "A81B6A536A98"},
{"a8-1b-6a-53-6a-98", "A81B6A536A98"},
{"a81B6a536A98", "A81B6A536A98"},
}
t.Log("MAC Address Normalization Test:")
t.Log("This shows how normalization could solve case/format sensitivity issues")
t.Log("")
allNormalizedSame := true
expectedNormalized := "A81B6A536A98"
for _, tc := range testCases {
normalized := normalizeMAC(tc.original)
matches := normalized == expectedNormalized
if !matches {
allNormalizedSame = false
}
t.Logf("'%s' -> '%s' (matches expected: %v)", tc.original, normalized, matches)
}
if allNormalizedSame {
t.Log("")
t.Log("✓ All MAC address formats normalize to the same value")
t.Log("✓ Implementing normalization would solve case/format sensitivity issues")
} else {
t.Error("✗ Normalization failed to produce consistent results")
}
}
+344 -37
View File
@@ -7,7 +7,9 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
@@ -22,9 +24,26 @@ func exists(path string) bool {
// DataStore represents the device and configuration storage.
type DataStore struct {
DataDir string
eventMutex sync.RWMutex
deviceEvents map[string][]models.DeviceEvent
DataDir string
eventMutex sync.RWMutex
deviceEvents map[string][]models.DeviceEvent
idMutex sync.RWMutex
deviceMappings map[string]string
}
// normalizeMAC normalizes a MAC address to a consistent format
func normalizeMAC(mac string) string {
if mac == "" {
return ""
}
// Remove spaces and common separators, then convert to uppercase
mac = strings.TrimSpace(mac)
mac = strings.ReplaceAll(mac, " ", "")
mac = strings.ReplaceAll(mac, ":", "")
mac = strings.ReplaceAll(mac, "-", "")
mac = strings.ToUpper(mac)
return mac
}
// NewDataStore creates a new DataStore.
@@ -35,24 +54,55 @@ func NewDataStore(dataDir string) *DataStore {
}
return &DataStore{
DataDir: dataDir,
deviceEvents: make(map[string][]models.DeviceEvent),
DataDir: dataDir,
deviceEvents: make(map[string][]models.DeviceEvent),
deviceMappings: make(map[string]string),
}
}
// AccountDir returns the directory path for a specific account.
func (ds *DataStore) AccountDir(account string) string {
return filepath.Join(ds.DataDir, account)
return filepath.Join(ds.DataDir, "accounts", account)
}
// AccountDevicesDir returns the devices directory path for a specific account.
func (ds *DataStore) AccountDevicesDir(account string) string {
return filepath.Join(ds.DataDir, account, constants.DevicesDir)
return filepath.Join(ds.AccountDir(account), constants.DevicesDir)
}
// AccountDeviceDir returns the directory path for a specific device within an account.
func (ds *DataStore) AccountDeviceDir(account, device string) string {
return filepath.Join(ds.AccountDevicesDir(account), device)
// First, check if the device directory exists directly with the given deviceID
// This prioritizes MAC-based deviceIDs over legacy mappings
directPath := filepath.Join(ds.AccountDevicesDir(account), device)
if _, err := os.Stat(directPath); err == nil {
// Directory exists, use the direct deviceID (preferred for MAC-based IDs)
return directPath
}
// If direct path doesn't exist, check device mappings for backward compatibility
ds.idMutex.RLock()
mappedDevice, ok := ds.deviceMappings[device]
if !ok {
// Try with normalized MAC address
normalizedDevice := normalizeMAC(device)
mappedDevice, ok = ds.deviceMappings[normalizedDevice]
}
ds.idMutex.RUnlock()
if ok {
// Use the mapped device only if it exists and the direct path doesn't
mappedPath := filepath.Join(ds.AccountDevicesDir(account), mappedDevice)
if _, err := os.Stat(mappedPath); err == nil {
return mappedPath
}
}
// If neither direct path nor mapping work, return the direct path
// (this allows new devices to be created with MAC-based IDs)
return directPath
}
// GetDeviceInfo retrieves device information for the specified account and device.
@@ -76,9 +126,11 @@ func (ds *DataStore) GetDeviceInfo(account, device string) (*models.ServiceDevic
SerialNumber string `xml:"serialNumber"`
} `xml:"components>component"`
NetworkInfo []struct {
Type string `xml:"type,attr"`
IPAddress string `xml:"ipAddress"`
Type string `xml:"type,attr"`
IPAddress string `xml:"ipAddress"`
MacAddress string `xml:"macAddress"`
} `xml:"networkInfo"`
DiscoveryMethod string `xml:"discoveryMethod"`
}
if err := xml.Unmarshal(data, &info); err != nil {
@@ -86,9 +138,11 @@ func (ds *DataStore) GetDeviceInfo(account, device string) (*models.ServiceDevic
}
deviceInfo := &models.ServiceDeviceInfo{
DeviceID: info.DeviceID,
ProductCode: fmt.Sprintf("%s %s", info.Type, info.ModuleType),
Name: info.Name,
DeviceID: info.DeviceID,
AccountID: account, // Set AccountID from parameter
ProductCode: fmt.Sprintf("%s %s", info.Type, info.ModuleType),
Name: info.Name,
DiscoveryMethod: info.DiscoveryMethod,
}
for _, comp := range info.Components {
@@ -104,6 +158,7 @@ func (ds *DataStore) GetDeviceInfo(account, device string) (*models.ServiceDevic
for _, net := range info.NetworkInfo {
if net.Type == "SCM" {
deviceInfo.IPAddress = net.IPAddress
deviceInfo.MacAddress = net.MacAddress
}
}
@@ -134,6 +189,7 @@ func (ds *DataStore) ListAllDevices() ([]models.ServiceDeviceInfo, error) {
accDevices := ds.listDevicesInAccount(dir, acc.Name())
for i := range accDevices {
info := accDevices[i]
info.AccountID = acc.Name()
key := info.DeviceID
if key == "" {
@@ -153,13 +209,13 @@ func (ds *DataStore) ListAllDevices() ([]models.ServiceDeviceInfo, error) {
func (ds *DataStore) getPossibleDataDirs() []string {
dirs := []string{}
if exists(ds.DataDir) {
dirs = append(dirs, ds.DataDir)
if exists(filepath.Join(ds.DataDir, "accounts")) {
dirs = append(dirs, filepath.Join(ds.DataDir, "accounts"))
}
// Also check soundcork-go/data if it's different and exists
altDir := "soundcork-go/data"
if ds.DataDir != altDir && exists(altDir) {
// Also check st-go/data/accounts if it's different and exists
altDir := "st-go/data/accounts"
if filepath.Join(ds.DataDir, "accounts") != altDir && exists(altDir) {
dirs = append(dirs, altDir)
}
@@ -193,6 +249,9 @@ func (ds *DataStore) listDevicesInAccount(baseDir, accountName string) []models.
}
if err == nil && info != nil {
// Update bidirectional device mappings for resolution
ds.updateDeviceMappings(*info)
devices = append(devices, *info)
}
}
@@ -218,8 +277,9 @@ func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo
SerialNumber string `xml:"serialNumber"`
} `xml:"components>component"`
NetworkInfo []struct {
Type string `xml:"type,attr"`
IPAddress string `xml:"ipAddress"`
Type string `xml:"type,attr"`
IPAddress string `xml:"ipAddress"`
MacAddress string `xml:"macAddress"`
} `xml:"networkInfo"`
DiscoveryMethod string `xml:"discoveryMethod"`
}
@@ -230,12 +290,18 @@ func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo
deviceInfo := &models.ServiceDeviceInfo{
DeviceID: info.DeviceID,
ProductCode: fmt.Sprintf("%s %s", info.Type, info.ModuleType),
ProductCode: info.Type,
Name: info.Name,
DiscoveryMethod: info.DiscoveryMethod,
}
for _, comp := range info.Components {
deviceInfo.Components = append(deviceInfo.Components, models.ServiceComponent{
Category: comp.Category,
SoftwareVersion: comp.SoftwareVersion,
SerialNumber: comp.SerialNumber,
})
switch comp.Category {
case "SCM":
deviceInfo.FirmwareVersion = comp.SoftwareVersion
@@ -248,6 +314,7 @@ func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo
for _, net := range info.NetworkInfo {
if net.Type == "SCM" {
deviceInfo.IPAddress = net.IPAddress
deviceInfo.MacAddress = net.MacAddress
}
}
@@ -392,9 +459,17 @@ func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent,
}
recents := []models.ServiceRecent{}
maxID := 0
for i := range recentsWrap.Recents {
r := &recentsWrap.Recents[i]
if id, err := strconv.Atoi(r.ID); err == nil {
if id > maxID {
maxID = id
}
}
recents = append(recents, models.ServiceRecent{
ServiceContentItem: models.ServiceContentItem{
ID: r.ID,
@@ -411,6 +486,14 @@ func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent,
})
}
// Ensure all recents have unique numeric IDs
for i := range recents {
if _, err := strconv.Atoi(recents[i].ID); err != nil || recents[i].ID == "" {
maxID++
recents[i].ID = strconv.Itoa(maxID)
}
}
return recents, nil
}
@@ -493,8 +576,9 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
}
type NetworkInfoXML struct {
Type string `xml:"type,attr"`
IPAddress string `xml:"ipAddress"`
Type string `xml:"type,attr"`
IPAddress string `xml:"ipAddress"`
MacAddress string `xml:"macAddress"`
}
type InfoXML struct {
@@ -540,8 +624,9 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
},
NetworkInfo: []NetworkInfoXML{
{
Type: "SCM",
IPAddress: info.IPAddress,
Type: "SCM",
IPAddress: info.IPAddress,
MacAddress: info.MacAddress,
},
},
DiscoveryMethod: info.DiscoveryMethod,
@@ -563,6 +648,11 @@ func (ds *DataStore) RemoveDevice(account, device string) error {
return os.RemoveAll(dir)
}
// RemoveDeviceDir is an alias for RemoveDevice for backwards compatibility.
func (ds *DataStore) RemoveDeviceDir(account, device string) error {
return ds.RemoveDevice(account, device)
}
// GetConfiguredSources retrieves all configured sources for the specified account and device.
func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.ConfiguredSource, error) {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
@@ -631,25 +721,102 @@ func (ds *DataStore) SaveConfiguredSources(account, device string, sources []mod
return os.WriteFile(path, append(header, data...), 0644)
}
// Initialize creates the necessary directory structure for the datastore.
// updateDeviceMappings creates bidirectional mappings for device resolution
func (ds *DataStore) updateDeviceMappings(info models.ServiceDeviceInfo) {
ds.idMutex.Lock()
defer ds.idMutex.Unlock()
deviceID := info.DeviceID
macAddress := info.MacAddress
deviceSerial := info.DeviceSerialNumber
// If device is stored with MAC as deviceID and has a serial, create backward mapping
if isMACAddressFormat(deviceID) && deviceSerial != "" && deviceSerial != deviceID {
ds.deviceMappings[deviceSerial] = deviceID
}
// If device is stored with serial as deviceID and has a MAC, create forward mapping
if !isMACAddressFormat(deviceID) && macAddress != "" {
ds.deviceMappings[macAddress] = deviceID
// Also store normalized MAC version
normalizedMAC := normalizeMAC(macAddress)
if normalizedMAC != macAddress {
ds.deviceMappings[normalizedMAC] = deviceID
}
}
}
// UpdateMapping maintains backward compatibility for external callers
func (ds *DataStore) UpdateMapping(mac, serial string) {
if mac == "" || serial == "" {
return
}
ds.idMutex.Lock()
defer ds.idMutex.Unlock()
// In the new system, MAC addresses are preferred as deviceIDs
// So map the serial TO the MAC (reverse of old system)
ds.deviceMappings[serial] = mac
// Also map MAC to serial for any remaining legacy code
ds.deviceMappings[mac] = serial
normalizedMAC := normalizeMAC(mac)
if normalizedMAC != mac {
ds.deviceMappings[normalizedMAC] = serial
}
}
// isMACAddressFormat checks if a string looks like a MAC address
func isMACAddressFormat(s string) bool {
// AABBCCDDEEFF format
if len(s) == 12 {
return isHexOnly(s)
}
// AA:BB:CC:DD:EE:FF or AA-BB-CC-DD-EE-FF format
if len(s) == 17 && (strings.Contains(s, ":") || strings.Contains(s, "-")) {
s = strings.ReplaceAll(s, "-", ":")
parts := strings.Split(s, ":")
if len(parts) != 6 {
return false
}
for _, part := range parts {
if len(part) != 2 || !isHexOnly(part) {
return false
}
}
return true
}
return false
}
func isHexOnly(s string) bool {
for _, r := range s {
if (r < '0' || r > '9') && (r < 'A' || r > 'F') && (r < 'a' || r > 'f') {
return false
}
}
return true
}
// Initialize creates the necessary directory structure for the datastore and populates ID mappings.
func (ds *DataStore) Initialize() error {
// Ensure base data directory exists
if err := os.MkdirAll(ds.DataDir, 0755); err != nil {
return fmt.Errorf("failed to create data directory: %w", err)
}
// Ensure default account exists
defaultDir := ds.AccountDir("default")
if err := os.MkdirAll(defaultDir, 0755); err != nil {
return fmt.Errorf("failed to create default account directory: %w", err)
}
// Scan for devices to populate MAC to Serial mapping
_, err := ds.ListAllDevices()
// Ensure devices subdirectory for default account
if err := os.MkdirAll(ds.AccountDevicesDir("default"), 0755); err != nil {
return fmt.Errorf("failed to create default devices directory: %w", err)
}
return nil
return err
}
// GetETagForPresets returns the ETag (modification time) for the presets file for a specific device.
@@ -706,6 +873,71 @@ func (ds *DataStore) GetETagForAccount(account, device string) int64 {
return maxETag
}
// Settings represents the global service settings.
type Settings struct {
ServerURL string `json:"server_url"`
SoundcorkURL string `json:"soundcork_url"`
HTTPServerURL string `json:"https_server_url,omitempty"`
RedactLogs bool `json:"redact_logs"`
LogBodies bool `json:"log_bodies"`
RecordInteractions bool `json:"record_interactions"`
DiscoveryInterval string `json:"discovery_interval,omitempty"`
DiscoveryEnabled bool `json:"discovery_enabled"`
EnableSoundcorkProxy bool `json:"enable_soundcork_proxy"`
DNSEnabled bool `json:"dns_enabled"`
DNSUpstream []string `json:"dns_upstream,omitempty"`
DNSBindAddr string `json:"dns_bind_addr,omitempty"`
MirrorEnabled bool `json:"mirror_enabled"`
MirrorEndpoints []string `json:"mirror_endpoints,omitempty"`
PreferredSource string `json:"preferred_source,omitempty"`
InternalPaths []string `json:"internal_paths,omitempty"`
Shortcuts map[string]int `json:"shortcuts,omitempty"`
}
// GetSettings retrieves the global service settings.
func (ds *DataStore) GetSettings() (Settings, error) {
if ds == nil || ds.DataDir == "" {
return Settings{}, nil
}
path := filepath.Join(ds.DataDir, "settings.json")
if !exists(path) {
return Settings{}, nil
}
data, err := os.ReadFile(path)
if err != nil {
return Settings{}, err
}
var settings Settings
if err := json.Unmarshal(data, &settings); err != nil {
return Settings{}, err
}
return settings, nil
}
// SaveSettings saves the global service settings.
func (ds *DataStore) SaveSettings(settings Settings) error {
if ds == nil || ds.DataDir == "" {
return nil
}
if err := os.MkdirAll(ds.DataDir, 0755); err != nil {
return fmt.Errorf("failed to create data directory: %w", err)
}
path := filepath.Join(ds.DataDir, "settings.json")
data, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
// SaveUsageStats saves usage statistics to the datastore.
func (ds *DataStore) SaveUsageStats(stats models.UsageStats) error {
dir := filepath.Join(ds.DataDir, "stats", "usage")
@@ -774,3 +1006,78 @@ func (ds *DataStore) GetDeviceEvents(deviceID string) []models.DeviceEvent {
return copiedEvents
}
// DNSDiscoveryEntry represents a persisted DNS discovery.
type DNSDiscoveryEntry struct {
Hostname string `json:"hostname"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
QueryCount int `json:"query_count"`
IsBoseService bool `json:"is_bose_service"`
IsIntercepted bool `json:"is_intercepted"`
RemoteAddr string `json:"remote_addr,omitempty"`
}
// SaveDNSDiscoveries saves DNS discoveries to the datastore.
func (ds *DataStore) SaveDNSDiscoveries(discoveries []DNSDiscoveryEntry) error {
if ds == nil || ds.DataDir == "" {
return nil
}
dir := filepath.Join(ds.DataDir, "dns")
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create dns directory: %w", err)
}
path := filepath.Join(dir, "discoveries.json")
// Sort by last seen descending
sort.Slice(discoveries, func(i, j int) bool {
return discoveries[i].LastSeen.After(discoveries[j].LastSeen)
})
data, err := json.MarshalIndent(discoveries, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
// LoadDNSDiscoveries loads DNS discoveries from the datastore.
func (ds *DataStore) LoadDNSDiscoveries() ([]DNSDiscoveryEntry, error) {
if ds == nil || ds.DataDir == "" {
return []DNSDiscoveryEntry{}, nil
}
path := filepath.Join(ds.DataDir, "dns", "discoveries.json")
if !exists(path) {
return []DNSDiscoveryEntry{}, nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var discoveries []DNSDiscoveryEntry
if err := json.Unmarshal(data, &discoveries); err != nil {
return nil, err
}
return discoveries, nil
}
// ClearDNSDiscoveries removes all DNS discoveries from the datastore.
func (ds *DataStore) ClearDNSDiscoveries() error {
if ds == nil || ds.DataDir == "" {
return nil
}
path := filepath.Join(ds.DataDir, "dns", "discoveries.json")
if !exists(path) {
return nil
}
return os.Remove(path)
}
+58 -12
View File
@@ -9,7 +9,7 @@ import (
)
func TestDataStore(t *testing.T) {
tempDir, err := os.MkdirTemp("", "soundcork-test-*")
tempDir, err := os.MkdirTemp("", "st-test-*")
if err != nil {
t.Fatal(err)
}
@@ -22,8 +22,9 @@ func TestDataStore(t *testing.T) {
// Test Save/Get DeviceInfo
info := &models.ServiceDeviceInfo{
DeviceID: device,
Name: "Test Speaker",
DeviceID: device,
Name: "Test Speaker",
AccountID: account,
}
err = ds.SaveDeviceInfo(account, device, info)
@@ -87,14 +88,14 @@ func TestDataStore(t *testing.T) {
}
// Test path helpers
expectedAccountDir := filepath.Join(tempDir, account)
expectedAccountDir := filepath.Join(tempDir, "accounts", account)
if ds.AccountDir(account) != expectedAccountDir {
t.Errorf("Expected account dir %s, got %s", expectedAccountDir, ds.AccountDir(account))
}
}
func TestListAllDevices_Empty(t *testing.T) {
tempDir, err := os.MkdirTemp("", "soundcork-empty-test-*")
tempDir, err := os.MkdirTemp("", "st-empty-test-*")
if err != nil {
t.Fatal(err)
}
@@ -133,7 +134,7 @@ func TestListAllDevices_Empty(t *testing.T) {
}
func TestListAllDevices(t *testing.T) {
tempDir, err := os.MkdirTemp("", "soundcork-list-test-*")
tempDir, err := os.MkdirTemp("", "st-list-test-*")
if err != nil {
t.Fatal(err)
}
@@ -147,10 +148,11 @@ func TestListAllDevices(t *testing.T) {
info := &models.ServiceDeviceInfo{
DeviceID: deviceID,
Name: "Test Speaker",
IPAddress: "192.168.178.28",
IPAddress: "192.168.1.100",
DeviceSerialNumber: deviceID,
ProductCode: "SoundTouch 10",
FirmwareVersion: "1.2.3",
AccountID: account,
}
err = ds.SaveDeviceInfo(account, deviceID, info)
@@ -173,7 +175,7 @@ func TestListAllDevices(t *testing.T) {
}
func TestListAllDevices_EmptyDeviceID(t *testing.T) {
tempDir, err := os.MkdirTemp("", "soundcork-empty-id-test-*")
tempDir, err := os.MkdirTemp("", "st-empty-id-test-*")
if err != nil {
t.Fatal(err)
}
@@ -185,8 +187,9 @@ func TestListAllDevices_EmptyDeviceID(t *testing.T) {
deviceID := ""
info := &models.ServiceDeviceInfo{
DeviceID: deviceID,
Name: "Empty ID Speaker",
DeviceID: deviceID,
Name: "Empty ID Speaker",
AccountID: account,
}
// Use IP as fallback for device ID if it is empty
@@ -215,7 +218,7 @@ func TestListAllDevices_EmptyDeviceID(t *testing.T) {
}
func TestListAllDevices_MultipleEmptyIDs(t *testing.T) {
tempDir, err := os.MkdirTemp("", "soundcork-multi-empty-test-*")
tempDir, err := os.MkdirTemp("", "st-multi-empty-test-*")
if err != nil {
t.Fatal(err)
}
@@ -230,11 +233,13 @@ func TestListAllDevices_MultipleEmptyIDs(t *testing.T) {
DeviceID: "",
Name: "Speaker 1",
IPAddress: "192.168.1.1",
AccountID: account,
}
info2 := &models.ServiceDeviceInfo{
DeviceID: "",
Name: "Speaker 2",
IPAddress: "192.168.1.2",
AccountID: account,
}
// We use the same logic as in main.go: use IP as fallback for directory name
@@ -259,7 +264,7 @@ func TestListAllDevices_MultipleEmptyIDs(t *testing.T) {
}
func TestListAllDevices_MalformedXML(t *testing.T) {
tempDir, err := os.MkdirTemp("", "soundcork-malformed-test-*")
tempDir, err := os.MkdirTemp("", "st-malformed-test-*")
if err != nil {
t.Fatal(err)
}
@@ -365,3 +370,44 @@ func TestConfiguredSources(t *testing.T) {
t.Error("Expected auto-assigned ID for source with empty ID")
}
}
func TestSettingsPersistence(t *testing.T) {
tempDir, err := os.MkdirTemp("", "settings-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := NewDataStore(tempDir)
settings := Settings{
ServerURL: "http://myserver:8000",
SoundcorkURL: "http://myproxy:8001",
LogBodies: true,
DiscoveryInterval: "10m",
DiscoveryEnabled: true,
}
err = ds.SaveSettings(settings)
if err != nil {
t.Fatalf("SaveSettings failed: %v", err)
}
loaded, err := ds.GetSettings()
if err != nil {
t.Fatalf("GetSettings failed: %v", err)
}
if loaded.ServerURL != settings.ServerURL {
t.Errorf("Expected ServerURL %s, got %s", settings.ServerURL, loaded.ServerURL)
}
if loaded.LogBodies != settings.LogBodies {
t.Errorf("Expected LogBodies %v, got %v", settings.LogBodies, loaded.LogBodies)
}
if loaded.DiscoveryInterval != settings.DiscoveryInterval {
t.Errorf("Expected DiscoveryInterval %s, got %s", settings.DiscoveryInterval, loaded.DiscoveryInterval)
}
if loaded.DiscoveryEnabled != settings.DiscoveryEnabled {
t.Errorf("Expected DiscoveryEnabled %v, got %v", settings.DiscoveryEnabled, loaded.DiscoveryEnabled)
}
}
@@ -0,0 +1,75 @@
package datastore
import (
"os"
"testing"
"time"
)
func TestDNSDiscoveryPersistence(t *testing.T) {
tempDir, err := os.MkdirTemp("", "datastore-dns-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := NewDataStore(tempDir)
now := time.Now().Round(time.Second)
discoveries := []DNSDiscoveryEntry{
{
Hostname: "api.bose.com",
FirstSeen: now.Add(-1 * time.Hour),
LastSeen: now,
QueryCount: 10,
IsBoseService: true,
IsIntercepted: true,
RemoteAddr: "192.168.1.100",
},
{
Hostname: "google.com",
FirstSeen: now.Add(-2 * time.Hour),
LastSeen: now.Add(-1 * time.Hour),
QueryCount: 5,
IsBoseService: false,
IsIntercepted: false,
RemoteAddr: "192.168.1.101",
},
}
// Test Save
err = ds.SaveDNSDiscoveries(discoveries)
if err != nil {
t.Fatalf("SaveDNSDiscoveries failed: %v", err)
}
// Test Load
loaded, err := ds.LoadDNSDiscoveries()
if err != nil {
t.Fatalf("LoadDNSDiscoveries failed: %v", err)
}
if len(loaded) != 2 {
t.Errorf("Expected 2 discoveries, got %d", len(loaded))
}
// Check if sorted by LastSeen (SaveDNSDiscoveries sorts them)
if loaded[0].Hostname != "api.bose.com" {
t.Errorf("Expected api.bose.com to be first, got %s", loaded[0].Hostname)
}
// Test Clear
err = ds.ClearDNSDiscoveries()
if err != nil {
t.Fatalf("ClearDNSDiscoveries failed: %v", err)
}
loadedAfterClear, err := ds.LoadDNSDiscoveries()
if err != nil {
t.Fatalf("LoadDNSDiscoveries after clear failed: %v", err)
}
if len(loadedAfterClear) != 0 {
t.Errorf("Expected 0 discoveries after clear, got %d", len(loadedAfterClear))
}
}
@@ -0,0 +1,235 @@
package datastore
import (
"os"
"path/filepath"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
func TestMacAddressSerialization(t *testing.T) {
tempDir, err := os.MkdirTemp("", "mac-serialization-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := NewDataStore(tempDir)
account := "3230304"
device := "I6332527703739342000020"
macAddress := "A81B6A536A98"
// Create device info with MAC address
info := &models.ServiceDeviceInfo{
DeviceID: device,
Name: "Test SoundTouch",
ProductCode: "SoundTouch 10",
IPAddress: "192.168.1.100",
MacAddress: macAddress,
DeviceSerialNumber: device,
ProductSerialNumber: "PROD123456",
FirmwareVersion: "4.8.1.23456",
DiscoveryMethod: "UPnP",
}
// Save device info
err = ds.SaveDeviceInfo(account, device, info)
if err != nil {
t.Fatalf("SaveDeviceInfo failed: %v", err)
}
// Verify the XML file was created
deviceInfoPath := filepath.Join(ds.AccountDeviceDir(account, device), "DeviceInfo.xml")
if _, err := os.Stat(deviceInfoPath); err != nil {
t.Fatalf("DeviceInfo.xml not created: %v", err)
}
// Read back the device info
loadedInfo, err := ds.GetDeviceInfo(account, device)
if err != nil {
t.Fatalf("GetDeviceInfo failed: %v", err)
}
// Verify MAC address is preserved
if loadedInfo.MacAddress != macAddress {
t.Errorf("MAC address not preserved. Expected: '%s', Got: '%s'", macAddress, loadedInfo.MacAddress)
}
// Verify other fields are also correct
if loadedInfo.DeviceID != device {
t.Errorf("DeviceID mismatch. Expected: %s, Got: %s", device, loadedInfo.DeviceID)
}
if loadedInfo.IPAddress != "192.168.1.100" {
t.Errorf("IPAddress mismatch. Expected: 192.168.1.100, Got: %s", loadedInfo.IPAddress)
}
// Initialize datastore to populate MAC mappings
err = ds.Initialize()
if err != nil {
t.Fatalf("Initialize failed: %v", err)
}
// Test that MAC address mapping works
resolvedPath := ds.AccountDeviceDir(account, macAddress)
expectedPath := ds.AccountDeviceDir(account, device)
if resolvedPath != expectedPath {
t.Errorf("MAC address mapping failed. MAC '%s' resolved to '%s', expected '%s'",
macAddress, resolvedPath, expectedPath)
}
// Test that Sources.xml path resolves correctly via MAC address
// (We don't need to actually read the file, just verify the path resolution works)
macPath := ds.AccountDeviceDir(account, macAddress)
devicePath := ds.AccountDeviceDir(account, device)
if macPath != devicePath {
t.Errorf("MAC address path resolution failed. MAC path: %s, Device path: %s", macPath, devicePath)
}
t.Logf("✅ MAC address serialization working correctly")
t.Logf(" - MAC address '%s' saved to DeviceInfo.xml", macAddress)
t.Logf(" - MAC address '%s' loaded from DeviceInfo.xml", loadedInfo.MacAddress)
t.Logf(" - MAC mapping: '%s' -> '%s'", macAddress, device)
t.Logf(" - Sources.xml accessible via MAC address")
}
func TestMacAddressSerializationEdgeCases(t *testing.T) {
tempDir, err := os.MkdirTemp("", "mac-edge-cases-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := NewDataStore(tempDir)
account := "testaccount"
device := "testdevice"
testCases := []struct {
name string
macAddress string
expected string
}{
{"uppercase", "A81B6A536A98", "A81B6A536A98"},
{"lowercase", "a81b6a536a98", "a81b6a536a98"},
{"with_colons", "A8:1B:6A:53:6A:98", "A8:1B:6A:53:6A:98"},
{"with_dashes", "A8-1B-6A-53-6A-98", "A8-1B-6A-53-6A-98"},
{"empty", "", ""},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
deviceID := device + "_" + tc.name
info := &models.ServiceDeviceInfo{
DeviceID: deviceID,
Name: "Test Device " + tc.name,
ProductCode: "SoundTouch 10",
IPAddress: "192.168.1.100",
MacAddress: tc.macAddress,
DeviceSerialNumber: deviceID,
}
// Save and load
err := ds.SaveDeviceInfo(account, deviceID, info)
if err != nil {
t.Fatalf("SaveDeviceInfo failed for %s: %v", tc.name, err)
}
loadedInfo, err := ds.GetDeviceInfo(account, deviceID)
if err != nil {
t.Fatalf("GetDeviceInfo failed for %s: %v", tc.name, err)
}
if loadedInfo.MacAddress != tc.expected {
t.Errorf("MAC address mismatch for %s. Expected: '%s', Got: '%s'",
tc.name, tc.expected, loadedInfo.MacAddress)
}
})
}
}
func TestExistingDeviceInfoUpdate(t *testing.T) {
tempDir, err := os.MkdirTemp("", "device-update-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := NewDataStore(tempDir)
account := "3230304"
device := "I6332527703739342000020"
// First save without MAC address (simulating old DeviceInfo.xml)
infoWithoutMAC := &models.ServiceDeviceInfo{
DeviceID: device,
Name: "Test SoundTouch",
ProductCode: "SoundTouch 10",
IPAddress: "192.168.1.100",
MacAddress: "", // No MAC address initially
DeviceSerialNumber: device,
}
err = ds.SaveDeviceInfo(account, device, infoWithoutMAC)
if err != nil {
t.Fatalf("Initial SaveDeviceInfo failed: %v", err)
}
// Verify no MAC address initially
loadedInfo1, err := ds.GetDeviceInfo(account, device)
if err != nil {
t.Fatalf("Initial GetDeviceInfo failed: %v", err)
}
if loadedInfo1.MacAddress != "" {
t.Errorf("Expected empty MAC address, got '%s'", loadedInfo1.MacAddress)
}
// Now update with MAC address (simulating discovery update)
macAddress := "A81B6A536A98"
infoWithMAC := &models.ServiceDeviceInfo{
DeviceID: device,
Name: "Test SoundTouch",
ProductCode: "SoundTouch 10",
IPAddress: "192.168.1.100",
MacAddress: macAddress,
DeviceSerialNumber: device,
}
err = ds.SaveDeviceInfo(account, device, infoWithMAC)
if err != nil {
t.Fatalf("Update SaveDeviceInfo failed: %v", err)
}
// Verify MAC address is now present
loadedInfo2, err := ds.GetDeviceInfo(account, device)
if err != nil {
t.Fatalf("Updated GetDeviceInfo failed: %v", err)
}
if loadedInfo2.MacAddress != macAddress {
t.Errorf("MAC address not updated. Expected: '%s', Got: '%s'", macAddress, loadedInfo2.MacAddress)
}
// Initialize to test mapping
err = ds.Initialize()
if err != nil {
t.Fatalf("Initialize failed: %v", err)
}
// Test that MAC mapping now works
resolvedPath := ds.AccountDeviceDir(account, macAddress)
expectedPath := ds.AccountDeviceDir(account, device)
if resolvedPath != expectedPath {
t.Errorf("MAC mapping failed after update. MAC '%s' resolved to '%s', expected '%s'",
macAddress, resolvedPath, expectedPath)
}
t.Logf("✅ DeviceInfo.xml update with MAC address working correctly")
t.Logf(" - Initial: no MAC address")
t.Logf(" - Updated: MAC address '%s' added", macAddress)
t.Logf(" - Mapping: '%s' -> '%s'", macAddress, device)
}
@@ -0,0 +1,368 @@
package datastore
import (
"os"
"path/filepath"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
func TestAccountDeviceDir_MACFirstResolution(t *testing.T) {
tempDir, err := os.MkdirTemp("", "mac-first-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := NewDataStore(tempDir)
accountID := "testaccount"
macAddress := "A81B6A536A98"
serialNumber := "I6332527703739342000020"
t.Run("NewMACBasedDevice", func(t *testing.T) {
// Create a new device with MAC as deviceID
deviceInfo := &models.ServiceDeviceInfo{
DeviceID: macAddress,
AccountID: accountID,
Name: "New MAC Device",
IPAddress: "192.168.1.100",
MacAddress: macAddress,
DeviceSerialNumber: serialNumber,
ProductCode: "SoundTouch 10 sm2",
}
// Save the device
if err := ds.SaveDeviceInfo(accountID, macAddress, deviceInfo); err != nil {
t.Fatalf("Failed to save MAC-based device: %v", err)
}
// Test AccountDeviceDir resolution
resolvedDir := ds.AccountDeviceDir(accountID, macAddress)
expectedDir := filepath.Join(tempDir, "accounts", accountID, "devices", macAddress)
if resolvedDir != expectedDir {
t.Errorf("Expected MAC-based device dir '%s', got '%s'", expectedDir, resolvedDir)
}
// Verify the directory actually exists
if _, err := os.Stat(resolvedDir); os.IsNotExist(err) {
t.Errorf("MAC-based device directory should exist: %s", resolvedDir)
}
t.Logf("✅ MAC-based device correctly resolved to: %s", resolvedDir)
})
t.Run("LegacySerialBasedDevice", func(t *testing.T) {
// Create a legacy device with serial as deviceID (simulating old storage)
legacyInfo := &models.ServiceDeviceInfo{
DeviceID: serialNumber,
AccountID: accountID,
Name: "Legacy Serial Device",
IPAddress: "192.168.1.101",
MacAddress: macAddress,
DeviceSerialNumber: serialNumber,
ProductCode: "SoundTouch 10",
}
// Save the legacy device
if err := ds.SaveDeviceInfo(accountID, serialNumber, legacyInfo); err != nil {
t.Fatalf("Failed to save legacy device: %v", err)
}
// Initialize to populate mappings
if err := ds.Initialize(); err != nil {
t.Fatalf("Failed to initialize datastore: %v", err)
}
// Test resolution by MAC address (should find the legacy device via mapping)
resolvedDir := ds.AccountDeviceDir(accountID, macAddress)
expectedLegacyDir := filepath.Join(tempDir, "accounts", accountID, "devices", serialNumber)
// Since both MAC and serial devices exist, MAC device should take priority
expectedMACDir := filepath.Join(tempDir, "accounts", accountID, "devices", macAddress)
if resolvedDir != expectedMACDir {
t.Errorf("Expected MAC device to take priority. Got '%s', expected '%s'", resolvedDir, expectedMACDir)
}
t.Logf("✅ MAC address resolution correctly prioritized MAC-based device")
// Test resolution by serial number (should find the legacy device directly)
serialResolvedDir := ds.AccountDeviceDir(accountID, serialNumber)
if serialResolvedDir != expectedLegacyDir {
t.Errorf("Expected serial-based device dir '%s', got '%s'", expectedLegacyDir, serialResolvedDir)
}
t.Logf("✅ Serial number correctly resolved to legacy device: %s", serialResolvedDir)
})
t.Run("MACResolutionWithOnlyLegacyDevice", func(t *testing.T) {
// Create a fresh datastore
tempDir2, err := os.MkdirTemp("", "mac-legacy-only-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir2)
ds2 := NewDataStore(tempDir2)
testAccount := "legacyaccount"
testSerial := "LEGACY123456789"
testMAC := "BB:CC:DD:EE:FF:00"
// Create ONLY a legacy device (no MAC-based device)
legacyInfo := &models.ServiceDeviceInfo{
DeviceID: testSerial,
AccountID: testAccount,
Name: "Only Legacy Device",
MacAddress: testMAC,
DeviceSerialNumber: testSerial,
}
if err := ds2.SaveDeviceInfo(testAccount, testSerial, legacyInfo); err != nil {
t.Fatalf("Failed to save legacy-only device: %v", err)
}
// Initialize to populate mappings
if err := ds2.Initialize(); err != nil {
t.Fatalf("Failed to initialize datastore: %v", err)
}
// Test MAC resolution (should find the legacy device via mapping)
resolvedDir := ds2.AccountDeviceDir(testAccount, testMAC)
expectedDir := filepath.Join(tempDir2, "accounts", testAccount, "devices", testSerial)
if resolvedDir != expectedDir {
t.Errorf("MAC '%s' should resolve to legacy device '%s', got '%s'", testMAC, expectedDir, resolvedDir)
}
t.Logf("✅ MAC address correctly resolved to legacy device when no MAC-based device exists")
})
t.Run("NonExistentDevice", func(t *testing.T) {
unknownMAC := "FF:FF:FF:FF:FF:FF"
resolvedDir := ds.AccountDeviceDir(accountID, unknownMAC)
expectedDir := filepath.Join(tempDir, "accounts", accountID, "devices", unknownMAC)
if resolvedDir != expectedDir {
t.Errorf("Non-existent device should resolve to direct path '%s', got '%s'", expectedDir, resolvedDir)
}
t.Logf("✅ Non-existent device correctly resolved to direct MAC path")
})
t.Run("MACNormalization", func(t *testing.T) {
// Test different MAC address formats
macFormats := []string{
"A81B6A536A98", // No separators
"A8:1B:6A:53:6A:98", // Colons
"A8-1B-6A-53-6A-98", // Dashes
"a81b6a536a98", // Lowercase
"a8:1b:6a:53:6a:98", // Lowercase with colons
}
for _, macFormat := range macFormats {
resolvedDir := ds.AccountDeviceDir(accountID, macFormat)
// Should resolve to the MAC-based device we created earlier
expectedDir := filepath.Join(tempDir, "accounts", accountID, "devices", macAddress)
if resolvedDir != expectedDir {
t.Logf("MAC format '%s' resolved to '%s', expected '%s'", macFormat, resolvedDir, expectedDir)
// For now, we'll log this - full normalization might require additional work
}
}
})
t.Run("BackwardCompatibilityMapping", func(t *testing.T) {
// Test that the legacy UpdateMapping method still works
testMAC := "CC:DD:EE:FF:00:11"
testSerial := "COMPAT789"
ds.UpdateMapping(testMAC, testSerial)
// After calling UpdateMapping, the MAC should resolve via the mapping
resolvedDir := ds.AccountDeviceDir(accountID, testMAC)
directPath := filepath.Join(tempDir, "accounts", accountID, "devices", testMAC)
// Since no actual device exists, it should return the direct path
if resolvedDir != directPath {
t.Errorf("UpdateMapping backward compatibility test failed. Got '%s', expected '%s'", resolvedDir, directPath)
}
t.Logf("✅ UpdateMapping backward compatibility maintained")
})
}
func TestDeviceMappings_Bidirectional(t *testing.T) {
tempDir, err := os.MkdirTemp("", "bidirectional-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := NewDataStore(tempDir)
accountID := "testaccount"
t.Run("MACBasedDeviceCreatesSerialMapping", func(t *testing.T) {
macAddress := "11:22:33:44:55:66"
serialNumber := "NEWDEVICE123"
// Create MAC-based device
deviceInfo := &models.ServiceDeviceInfo{
DeviceID: macAddress,
AccountID: accountID,
Name: "MAC First Device",
MacAddress: macAddress,
DeviceSerialNumber: serialNumber,
}
if err := ds.SaveDeviceInfo(accountID, macAddress, deviceInfo); err != nil {
t.Fatalf("Failed to save MAC-based device: %v", err)
}
// Initialize to populate mappings
if err := ds.Initialize(); err != nil {
t.Fatalf("Failed to initialize: %v", err)
}
// Serial should resolve to the MAC-based device
resolvedDir := ds.AccountDeviceDir(accountID, serialNumber)
expectedDir := filepath.Join(tempDir, "accounts", accountID, "devices", macAddress)
if resolvedDir != expectedDir {
t.Errorf("Serial '%s' should resolve to MAC device '%s', got '%s'", serialNumber, expectedDir, resolvedDir)
}
t.Logf("✅ MAC-based device creates correct serial→MAC mapping")
})
t.Run("SerialBasedDeviceCreatesMACMapping", func(t *testing.T) {
macAddress := "77:88:99:AA:BB:CC"
serialNumber := "SERIALDEVICE456"
// Create serial-based device (legacy)
deviceInfo := &models.ServiceDeviceInfo{
DeviceID: serialNumber,
AccountID: accountID,
Name: "Serial First Device",
MacAddress: macAddress,
DeviceSerialNumber: serialNumber,
}
if err := ds.SaveDeviceInfo(accountID, serialNumber, deviceInfo); err != nil {
t.Fatalf("Failed to save serial-based device: %v", err)
}
// Initialize to populate mappings
if err := ds.Initialize(); err != nil {
t.Fatalf("Failed to initialize: %v", err)
}
// MAC should resolve to the serial-based device
resolvedDir := ds.AccountDeviceDir(accountID, macAddress)
expectedDir := filepath.Join(tempDir, "accounts", accountID, "devices", serialNumber)
if resolvedDir != expectedDir {
t.Errorf("MAC '%s' should resolve to serial device '%s', got '%s'", macAddress, expectedDir, resolvedDir)
}
t.Logf("✅ Serial-based device creates correct MAC→serial mapping")
})
}
func TestMACAddressFormatDetection(t *testing.T) {
testCases := []struct {
input string
expected bool
name string
}{
{"A81B6A536A98", true, "12-char hex"},
{"a81b6a536a98", true, "12-char hex lowercase"},
{"A8:1B:6A:53:6A:98", true, "colon-separated"},
{"A8-1B-6A-53-6A-98", true, "dash-separated"},
{"a8:1b:6a:53:6a:98", true, "colon-separated lowercase"},
{"a8-1b-6a-53-6a-98", true, "dash-separated lowercase"},
{"I6332527703739342000020", false, "device serial"},
{"192.168.1.100", false, "IP address"},
{"ABCDEFGHIJKL", false, "12-char non-hex"},
{"A8:1B:6A:53:6A", false, "incomplete MAC"},
{"A8:1B:6A:53:6A:98:01", false, "too long MAC"},
{"", false, "empty string"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := isMACAddressFormat(tc.input)
if result != tc.expected {
t.Errorf("isMACAddressFormat('%s') = %v, expected %v", tc.input, result, tc.expected)
}
})
}
}
func TestAccountDeviceDir_PriorityOrder(t *testing.T) {
tempDir, err := os.MkdirTemp("", "priority-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := NewDataStore(tempDir)
accountID := "prioritytest"
macAddress := "A8:1B:6A:53:6A:98"
serialNumber := "PRIORITY123456789"
// Create both MAC-based and serial-based devices for the same physical device
macDevice := &models.ServiceDeviceInfo{
DeviceID: macAddress,
AccountID: accountID,
Name: "MAC Version",
MacAddress: macAddress,
DeviceSerialNumber: serialNumber,
}
serialDevice := &models.ServiceDeviceInfo{
DeviceID: serialNumber,
AccountID: accountID,
Name: "Serial Version",
MacAddress: macAddress,
DeviceSerialNumber: serialNumber,
}
// Save both devices
if err := ds.SaveDeviceInfo(accountID, macAddress, macDevice); err != nil {
t.Fatalf("Failed to save MAC device: %v", err)
}
if err := ds.SaveDeviceInfo(accountID, serialNumber, serialDevice); err != nil {
t.Fatalf("Failed to save serial device: %v", err)
}
// Initialize mappings
if err := ds.Initialize(); err != nil {
t.Fatalf("Failed to initialize: %v", err)
}
// Test priority: MAC address should resolve to MAC-based device (not serial-based)
resolvedDir := ds.AccountDeviceDir(accountID, macAddress)
expectedMACDir := filepath.Join(tempDir, "accounts", accountID, "devices", macAddress)
if resolvedDir != expectedMACDir {
t.Errorf("MAC address should resolve to MAC-based device directory")
t.Errorf("Expected: %s", expectedMACDir)
t.Errorf("Got: %s", resolvedDir)
}
// Test that serial still resolves to its own device
serialResolvedDir := ds.AccountDeviceDir(accountID, serialNumber)
expectedSerialDir := filepath.Join(tempDir, "accounts", accountID, "devices", serialNumber)
if serialResolvedDir != expectedSerialDir {
t.Errorf("Serial should resolve to serial-based device directory")
t.Errorf("Expected: %s", expectedSerialDir)
t.Errorf("Got: %s", serialResolvedDir)
}
t.Logf("✅ Priority test passed:")
t.Logf(" MAC '%s' → %s", macAddress, resolvedDir)
t.Logf(" Serial '%s' → %s", serialNumber, serialResolvedDir)
}
@@ -0,0 +1,259 @@
package datastore
import (
"os"
"path/filepath"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
)
func TestMacMappingDiagnostic(t *testing.T) {
// Test the exact scenario described in the issue
tmpDir, err := os.MkdirTemp("", "mac-mapping-diagnostic")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
accountID := "3230304"
serialNumber := "I6332527703739342000020"
macAddress := "A81B6A536A98"
// Create the directory structure as it exists in production
deviceDir := filepath.Join(tmpDir, "accounts", accountID, "devices", serialNumber)
if err := os.MkdirAll(deviceDir, 0755); err != nil {
t.Fatalf("failed to create device dir: %v", err)
}
// Create DeviceInfo.xml with the MAC address
deviceInfoXML := `<?xml version="1.0" encoding="UTF-8"?>
<info deviceID="` + serialNumber + `">
<name>SoundTouch Device</name>
<components>
<component>
<componentCategory>SCM</componentCategory>
<softwareVersion>4.8.1</softwareVersion>
<serialNumber>` + serialNumber + `</serialNumber>
</component>
</components>
<networkInfo type="SCM">
<macAddress>` + macAddress + `</macAddress>
<ipAddress>192.168.1.100</ipAddress>
</networkInfo>
</info>`
if err := os.WriteFile(filepath.Join(deviceDir, constants.DeviceInfoFile), []byte(deviceInfoXML), 0644); err != nil {
t.Fatalf("failed to write DeviceInfo.xml: %v", err)
}
// Create Presets.xml to simulate the file that should be found
presetsXML := `<?xml version="1.0" encoding="UTF-8"?>
<presets>
<preset id="1">
<ContentItem source="SPOTIFY" type="station" location="/station/abc123" sourceAccount="spotify_user">
<itemName>My Preset</itemName>
</ContentItem>
</preset>
</presets>`
if err := os.WriteFile(filepath.Join(deviceDir, constants.PresetsFile), []byte(presetsXML), 0644); err != nil {
t.Fatalf("failed to write Presets.xml: %v", err)
}
// Initialize the datastore
ds := NewDataStore(tmpDir)
if err := ds.Initialize(); err != nil {
t.Fatalf("failed to initialize datastore: %v", err)
}
// Test 1: Check if the mapping was populated
t.Run("CheckMappingPopulation", func(t *testing.T) {
ds.idMutex.RLock()
serial, ok := ds.deviceMappings[macAddress]
ds.idMutex.RUnlock()
if !ok {
t.Errorf("MAC address %s not found in mapping", macAddress)
} else if serial != serialNumber {
t.Errorf("MAC address %s mapped to %s, expected %s", macAddress, serial, serialNumber)
} else {
t.Logf("✓ MAC address %s correctly mapped to %s", macAddress, serial)
}
})
// Test 2: Check AccountDeviceDir resolution
t.Run("CheckAccountDeviceDir", func(t *testing.T) {
// Test with MAC address (should resolve to serial)
resolvedDir := ds.AccountDeviceDir(accountID, macAddress)
expectedDir := filepath.Join(tmpDir, "accounts", accountID, "devices", serialNumber)
if resolvedDir != expectedDir {
t.Errorf("AccountDeviceDir with MAC %s resolved to %s, expected %s", macAddress, resolvedDir, expectedDir)
} else {
t.Logf("✓ AccountDeviceDir correctly resolved MAC %s to path %s", macAddress, resolvedDir)
}
// Test with serial number (should work as-is)
resolvedDirSerial := ds.AccountDeviceDir(accountID, serialNumber)
if resolvedDirSerial != expectedDir {
t.Errorf("AccountDeviceDir with serial %s resolved to %s, expected %s", serialNumber, resolvedDirSerial, expectedDir)
} else {
t.Logf("✓ AccountDeviceDir works correctly with serial number %s", serialNumber)
}
})
// Test 3: Check GetPresets functionality with MAC address
t.Run("CheckGetPresetsWithMAC", func(t *testing.T) {
presets, err := ds.GetPresets(accountID, macAddress)
if err != nil {
t.Errorf("GetPresets failed with MAC address %s: %v", macAddress, err)
} else if len(presets) == 0 {
t.Error("GetPresets returned no presets")
} else {
t.Logf("✓ GetPresets successfully returned %d presets using MAC address %s", len(presets), macAddress)
}
})
// Test 4: Check GetPresets functionality with serial number
t.Run("CheckGetPresetsWithSerial", func(t *testing.T) {
presets, err := ds.GetPresets(accountID, serialNumber)
if err != nil {
t.Errorf("GetPresets failed with serial number %s: %v", serialNumber, err)
} else if len(presets) == 0 {
t.Error("GetPresets returned no presets")
} else {
t.Logf("✓ GetPresets successfully returned %d presets using serial number %s", len(presets), serialNumber)
}
})
// Test 5: Check case sensitivity
t.Run("CheckCaseSensitivity", func(t *testing.T) {
lowercaseMAC := "a81b6a536a98"
uppercaseMAC := "A81B6A536A98"
ds.idMutex.RLock()
_, lowercaseOk := ds.deviceMappings[lowercaseMAC]
_, uppercaseOk := ds.deviceMappings[uppercaseMAC]
ds.idMutex.RUnlock()
t.Logf("Lowercase MAC '%s' in mapping: %v", lowercaseMAC, lowercaseOk)
t.Logf("Uppercase MAC '%s' in mapping: %v", uppercaseMAC, uppercaseOk)
// Test GetPresets with different cases
_, errLower := ds.GetPresets(accountID, lowercaseMAC)
_, errUpper := ds.GetPresets(accountID, uppercaseMAC)
t.Logf("GetPresets with lowercase MAC error: %v", errLower)
t.Logf("GetPresets with uppercase MAC error: %v", errUpper)
})
// Test 6: Dump all mappings for debugging
t.Run("DumpMappings", func(t *testing.T) {
ds.idMutex.RLock()
defer ds.idMutex.RUnlock()
t.Logf("Total mappings found: %d", len(ds.deviceMappings))
for mac, serial := range ds.deviceMappings {
t.Logf(" MAC '%s' -> Serial '%s'", mac, serial)
}
})
// Test 7: Check actual file paths
t.Run("CheckFilePaths", func(t *testing.T) {
// Path that should work (with serial number)
correctPath := filepath.Join(tmpDir, "accounts", accountID, "devices", serialNumber, constants.PresetsFile)
if _, err := os.Stat(correctPath); err != nil {
t.Errorf("File not found at correct path %s: %v", correctPath, err)
} else {
t.Logf("✓ File found at correct path: %s", correctPath)
}
// Path that would be wrong (with MAC address)
wrongPath := filepath.Join(tmpDir, "accounts", accountID, "devices", macAddress, constants.PresetsFile)
if _, err := os.Stat(wrongPath); err == nil {
t.Logf("⚠️ File also found at MAC path (unexpected): %s", wrongPath)
} else {
t.Logf("✓ File correctly not found at MAC path: %s", wrongPath)
}
})
}
// TestMacMappingWithDifferentFormats tests various MAC address formats
func TestMacMappingWithDifferentFormats(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "mac-format-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
testCases := []struct {
name string
macInXML string
macInRequest string
shouldWork bool
}{
{"ExactMatch", "A81B6A536A98", "A81B6A536A98", true},
{"LowerCase", "A81B6A536A98", "a81b6a536a98", true}, // Should work with normalization
{"UpperCase", "a81b6a536a98", "A81B6A536A98", true}, // Should work with normalization
{"WithColons", "A8:1B:6A:53:6A:98", "A81B6A536A98", true}, // Should work with normalization
{"WithDashes", "A8-1B-6A-53-6A-98", "A81B6A536A98", true}, // Should work with normalization
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create separate directory for each test case
testDir := filepath.Join(tmpDir, tc.name)
accountID := "12345"
serialNumber := "TEST123456789"
deviceDir := filepath.Join(testDir, "accounts", accountID, "devices", serialNumber)
if err := os.MkdirAll(deviceDir, 0755); err != nil {
t.Fatalf("failed to create device dir: %v", err)
}
// Create DeviceInfo.xml with the specific MAC format
deviceInfoXML := `<?xml version="1.0" encoding="UTF-8"?>
<info deviceID="` + serialNumber + `">
<name>Test Device</name>
<components>
<component>
<componentCategory>SCM</componentCategory>
<softwareVersion>4.8.1</softwareVersion>
<serialNumber>` + serialNumber + `</serialNumber>
</component>
</components>
<networkInfo type="SCM">
<macAddress>` + tc.macInXML + `</macAddress>
<ipAddress>192.168.1.100</ipAddress>
</networkInfo>
</info>`
if err := os.WriteFile(filepath.Join(deviceDir, constants.DeviceInfoFile), []byte(deviceInfoXML), 0644); err != nil {
t.Fatalf("failed to write DeviceInfo.xml: %v", err)
}
// Create Presets.xml
presetsXML := `<presets><preset id="1">test</preset></presets>`
if err := os.WriteFile(filepath.Join(deviceDir, constants.PresetsFile), []byte(presetsXML), 0644); err != nil {
t.Fatalf("failed to write Presets.xml: %v", err)
}
// Initialize datastore
ds := NewDataStore(testDir)
if err := ds.Initialize(); err != nil {
t.Fatalf("failed to initialize datastore: %v", err)
}
// Try to get presets using the request MAC format
_, err := ds.GetPresets(accountID, tc.macInRequest)
if tc.shouldWork && err != nil {
t.Errorf("Expected success but got error: %v", err)
} else if !tc.shouldWork && err == nil {
t.Errorf("Expected failure but got success")
} else if tc.shouldWork {
t.Logf("✓ Successfully resolved MAC '%s' to serial '%s' (normalization worked)", tc.macInRequest, serialNumber)
} else {
t.Logf("✓ Correctly failed to resolve MAC '%s' (XML had '%s')", tc.macInRequest, tc.macInXML)
}
})
}
}
+79
View File
@@ -0,0 +1,79 @@
package datastore
import (
"os"
"path/filepath"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
)
func TestDataStore_MacAddressMapping(t *testing.T) {
if err := os.MkdirAll("testdata/mapping", 0755); err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll("testdata/mapping")
accountID := "12345"
serialNumber := "SERIAL123"
macAddress := "AABBCCDDEEFF"
// Create directory structure
deviceDir := filepath.Join("testdata/mapping", "accounts", accountID, "devices", serialNumber)
if err := os.MkdirAll(deviceDir, 0755); err != nil {
t.Fatalf("failed to create device dir: %v", err)
}
// Create DeviceInfo.xml with MAC address
deviceInfoXML := `<?xml version="1.0" encoding="UTF-8"?>
<info deviceID="` + serialNumber + `">
<name>Test Device</name>
<components>
<component>
<componentCategory>SCM</componentCategory>
<softwareVersion>1.0</softwareVersion>
<serialNumber>` + serialNumber + `</serialNumber>
</component>
</components>
<networkInfo type="SCM">
<macAddress>` + macAddress + `</macAddress>
<ipAddress>192.168.1.10</ipAddress>
</networkInfo>
</info>`
if err := os.WriteFile(filepath.Join(deviceDir, constants.DeviceInfoFile), []byte(deviceInfoXML), 0644); err != nil {
t.Fatalf("failed to write DeviceInfo.xml: %v", err)
}
// Create Presets.xml so we can verify access
presetsXML := `<presets><preset id="1">test</preset></presets>`
if err := os.WriteFile(filepath.Join(deviceDir, constants.PresetsFile), []byte(presetsXML), 0644); err != nil {
t.Fatalf("failed to write Presets.xml: %v", err)
}
ds := NewDataStore("testdata/mapping")
if err := ds.Initialize(); err != nil {
t.Fatalf("failed to initialize datastore: %v", err)
}
// Test mapping resolution in AccountDeviceDir
resolvedDir := ds.AccountDeviceDir(accountID, macAddress)
expectedDir := filepath.Join("testdata/mapping", "accounts", accountID, "devices", serialNumber)
if resolvedDir != expectedDir {
t.Errorf("expected dir %s, got %s", expectedDir, resolvedDir)
}
// Test that we can still use the serial number directly
resolvedDirSerial := ds.AccountDeviceDir(accountID, serialNumber)
if resolvedDirSerial != expectedDir {
t.Errorf("expected dir %s when using serial, got %s", expectedDir, resolvedDirSerial)
}
// Test GetPresets using MAC address
presets, err := ds.GetPresets(accountID, macAddress)
if err != nil {
t.Errorf("GetPresets failed with MAC address: %v", err)
}
if len(presets) == 0 {
t.Error("expected presets to be loaded")
}
}

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