From 49ec2d58db7be38316ad2e7ea296336f1ab01375 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sat, 7 Mar 2026 19:50:51 +0100 Subject: [PATCH] wip --- README.md | 1 + cmd/soundtouch-service/main.go | 35 +- docs/SOUNDTOUCH-SERVICE-ANNOUNCEMENT.md | 2 +- docs/SUMMARY.md | 1 + docs/analysis/stockholm-app-analysis.md | 52 + docs/guides/SOUNDTOUCH-SERVICE.md | 61 +- pkg/service/handlers/handlers_events_test.go | 4 +- pkg/service/handlers/handlers_media.go | 2 +- pkg/service/handlers/handlers_media_test.go | 87 +- pkg/service/handlers/handlers_setup_test.go | 43 +- pkg/service/handlers/handlers_websocket.go | 203 +++ pkg/service/handlers/main_test.go | 47 +- pkg/service/handlers/recorder_middleware.go | 24 +- pkg/service/handlers/web/index.html | 1519 +---------------- .../web/{js => migration}/diff.min.js | 0 pkg/service/handlers/web/migration/index.html | 1425 +++------------- .../handlers/web/{js => migration}/script.js | 36 +- .../handlers/web/{css => migration}/style.css | 0 pkg/service/handlers/web/shared/common.css | 16 + pkg/service/handlers/web/shared/common.js | 23 + .../handlers/web/stockholm-mini/bose.ttf | Bin pkg/service/setup/setup.go | 14 + 22 files changed, 888 insertions(+), 2707 deletions(-) create mode 100644 docs/analysis/stockholm-app-analysis.md create mode 100644 pkg/service/handlers/handlers_websocket.go rename pkg/service/handlers/web/{js => migration}/diff.min.js (100%) rename pkg/service/handlers/web/{js => migration}/script.js (97%) rename pkg/service/handlers/web/{css => migration}/style.css (100%) create mode 100644 pkg/service/handlers/web/shared/common.css create mode 100644 pkg/service/handlers/web/shared/common.js mode change 100644 => 100755 pkg/service/handlers/web/stockholm-mini/bose.ttf diff --git a/README.md b/README.md index 64b47d7..0889cc7 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ The `soundtouch-service` is a local server that emulates Bose's cloud services. - **🔌 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 +- **🎮 Stockholm Mini**: A minimal reverse-engineered UI for device control (accessible at `/web/stockholm-mini/`) - **💾 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 diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 52a139e..3dc9ad5 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -761,10 +761,24 @@ func setupRouter(server *handlers.Server) *chi.Mux { // Stockholm Mini app r.Handle("/stockholm-mini/*", http.StripPrefix("/stockholm-mini/", http.FileServer(http.Dir("pkg/service/handlers/web/stockholm-mini")))) + r.Route("/devices", func(r chi.Router) { + r.Get("/", server.HandleListDiscoveredDevices) + r.Post("/", server.HandleAddManualDevice) + + r.Route("/{deviceId}", func(r chi.Router) { + r.Delete("/", server.HandleRemoveDevice) + r.Get("/events", server.HandleGetDeviceEvents) + r.Get("/info", server.HandleGetDeviceInfo) + r.Get("/ws", server.HandleDeviceWebSocket) + r.Post("/key/{key}", server.HandleDeviceKey) + r.Post("/volume/{level}", server.HandleDeviceVolume) + r.Post("/reboot", server.HandleRebootDevice) + }) + }) + + r.Get("/version", server.HandleGetVersionInfo) + 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) @@ -785,7 +799,6 @@ func setupRouter(server *handlers.Server) *chi.Mux { 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) @@ -799,7 +812,19 @@ func setupRouter(server *handlers.Server) *chi.Mux { r.Get("/dns-discoveries/download", server.HandleDownloadDNSDiscoveries) r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries) - r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents) + r.Route("/devices/{deviceId}", func(r chi.Router) { + r.Get("/summary", server.HandleGetMigrationSummary) + r.Post("/migrate", server.HandleMigrateDevice) + r.Post("/revert", server.HandleRevertMigration) + r.Post("/trust-ca", server.HandleTrustCACert) + r.Post("/ensure-remote-services", server.HandleEnsureRemoteServices) + r.Post("/remove-remote-services", server.HandleRemoveRemoteServices) + r.Post("/backup", server.HandleBackupConfig) + r.Post("/sync", server.HandleInitialSync) + r.Post("/test-connection", server.HandleTestConnection) + r.Post("/test-hosts", server.HandleTestHostsRedirection) + r.Post("/test-dns", server.HandleTestDNSRedirection) + }) }) r.NotFound(server.HandleNotFound) diff --git a/docs/SOUNDTOUCH-SERVICE-ANNOUNCEMENT.md b/docs/SOUNDTOUCH-SERVICE-ANNOUNCEMENT.md index b5c1885..5103b17 100644 --- a/docs/SOUNDTOUCH-SERVICE-ANNOUNCEMENT.md +++ b/docs/SOUNDTOUCH-SERVICE-ANNOUNCEMENT.md @@ -119,7 +119,7 @@ soundtouch-service ```go // Build custom applications on top of local services client := &http.Client{} -resp, _ := client.Get("http://localhost:8000/setup/devices") +resp, _ := client.Get("http://localhost:8000/devices") ``` ### Privacy-Conscious Users diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index b87a25b..7c0bb18 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -53,6 +53,7 @@ * [Upstream URLs](analysis/UPSTREAM-URLS.md) * [Anonymization Summary](analysis/ANONYMIZATION-SUMMARY.md) * [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md) +* [Stockholm App Analysis](analysis/stockholm-app-analysis.md) * [Wiki API Comparison](analysis/WIKI-COMPARISON.md) * [IoT Config Summary](analysis/IOT-CONFIG-SUMMARY.md) * [IoT Configuration Analysis](analysis/IOT-CONFIGURATION-ANALYSIS.md) diff --git a/docs/analysis/stockholm-app-analysis.md b/docs/analysis/stockholm-app-analysis.md new file mode 100644 index 0000000..aa299c7 --- /dev/null +++ b/docs/analysis/stockholm-app-analysis.md @@ -0,0 +1,52 @@ +### Stockholm App Analysis Report + +#### 1. Overview +The Stockholm app is a CEPE MAUI SoundTouch Controller HTML5/JS UI. It is designed to run as a web-based interface for Bose SoundTouch devices, likely served by the device itself or an associated controller. + +- **Technology Stack**: HTML5, CSS3, JavaScript (Minified). +- **Key Libraries**: + - **jQuery**: Core DOM manipulation and event handling. + - **iScroll**: Used for smooth scrolling in lists and carousels. + - **Forge**: Used for cryptographic operations (likely for secure communication or authentication). + - **WebSocket Polyfill**: Ensures WebSocket compatibility across environments. + +#### 2. Directory Structure +- `js/`: Core application logic. + - `app/`: Main application entry point (`app.js`). + - `models/`: Data models for UI components (Presets, Favorites, Onboarding, etc.). + - `music_services/`: Implementation of various music services (Amazon, Deezer, Spotify, BMX, etc.). + - `views/`: UI view templates and logic. + - `utils/`: Utility functions for security, data analytics, and general-purpose tasks. +- `json/`: Configuration files and static data. + - `config.json`: Core application configuration including Base64 encoded Bose API endpoints (e.g., streaming, events, BMX registry). + - `sourceFeatures.json`: Capability mapping for different sources. +- `setup/`: Onboarding and initial device setup logic. +- `lang/`: Localization files for multi-language support. + +#### 3. Communication Architecture +The app uses several communication channels to interact with the SoundTouch ecosystem: + +- **Socket Communication (`socket_comm.js`)**: Real-time updates and low-latency commands via WebSockets. +- **BMX (`bmx.js` & `js/music_services/bmx/`)**: Interactions with the Bose Music eXperience services. Handles account management, navigation, and API response validation. +- **Marge (`marge_comm.js`)**: Likely used for interaction with the Marge service (Bose's legacy cloud/proxy service). +- **Worker-based Architecture**: Many services use Web Workers (`bmx_worker.js`, `spotify_worker.js`) to handle API requests and data processing in the background, keeping the UI responsive. + +#### 4. Key Features & Functionality +- **Multi-Device Management**: Discovering and controlling multiple speakers on the network. +- **Music Service Integration**: Deep integration with Spotify, Amazon Music, Deezer, and Pandora. +- **Preset Management**: Browsing and setting presets directly from the UI. +- **Zone Control**: Creating and managing multi-room groups (Master/Slave configurations). +- **Onboarding**: A dedicated setup flow for new devices. +- **Analytics & Data Collection**: Modules like `data_analytics.js` and `dc_server.js` suggest tracking of user interactions. + +#### 5. Integration Opportunities for Bose-SoundTouch Project +Based on the Stockholm app's capabilities, the following features could be enhanced or added to our Go-based `soundtouch-service`: + +1. **Enhanced BMX Emulation**: Use insights from `bmx_client.js` and `bmx_navigate_response_generator.js` to improve our local BMX implementation. +2. **Spotify/Amazon Service Proxies**: Implement the backend logic required to support the same API calls the Stockholm app makes to these services. +3. **UI parity**: The Stockholm app's view templates (`views/`) can serve as a reference for our Web Management UI. +4. **WebSocket Support**: Ensure our service provides a robust WebSocket interface similar to what the Stockholm app expects for real-time state synchronization. +5. **Capability Discovery**: Better utilization of the `sourceFeatures.json` logic to dynamically show/hide features based on the device model and firmware version. + +#### 6. Conclusion +The Stockholm app is a mature, full-featured controller that relies heavily on Bose's proprietary BMX and Marge services. By analyzing its client-side logic, we can better understand the expected API responses and interaction patterns needed to provide a seamless local replacement for the Bose Cloud. diff --git a/docs/guides/SOUNDTOUCH-SERVICE.md b/docs/guides/SOUNDTOUCH-SERVICE.md index 1abad6c..3c85d11 100644 --- a/docs/guides/SOUNDTOUCH-SERVICE.md +++ b/docs/guides/SOUNDTOUCH-SERVICE.md @@ -212,23 +212,23 @@ Device migration switches your SoundTouch devices from Bose's cloud services to ```bash # Get migration summary first -curl http://localhost:8000/setup/migration-summary/192.168.1.100 +curl http://localhost:8000/setup/devices/192.168.1.100/summary # Perform migration -curl -X POST http://localhost:8000/setup/migrate/192.168.1.100 +curl -X POST http://localhost:8000/setup/devices/192.168.1.100/migrate # Verify migration status -curl http://localhost:8000/setup/devices +curl http://localhost:8000/devices ``` #### Advanced Migration Options ```bash # Migration with proxy fallback for original services -curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?proxy_url=http://localhost:8000&marge=original&stats=original" +curl -X POST "http://localhost:8000/setup/devices/192.168.1.100/migrate?proxy_url=http://localhost:8000&marge=original&stats=original" # Migration with custom target URL -curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?target_url=https://my-server.com:8000" +curl -X POST "http://localhost:8000/setup/devices/192.168.1.100/migrate?target_url=https://my-server.com:8000" ``` ### Post-Migration Verification @@ -237,13 +237,13 @@ After migration, verify the device is working correctly: ```bash # Check device status -curl http://localhost:8000/setup/devices +curl http://localhost:8000/devices # Test preset functionality curl "http://192.168.1.100:8090/presets" # Monitor device events (if needed) -curl "http://localhost:8000/events/192.168.1.100" +curl "http://localhost:8000/devices/08DF1F0BA325/events" ``` #### ResolvConf Migration (DHCP-Aware DNS Redirection) @@ -347,7 +347,7 @@ Mirrored requests are also recorded in the **Interaction Log** under the categor ### Discovery & Setup -#### `GET /setup/devices` +#### `GET /devices` Lists all discovered SoundTouch devices with their current status. **Response:** @@ -368,10 +368,10 @@ Lists all discovered SoundTouch devices with their current status. #### `POST /setup/discover` Triggers immediate network device discovery. -#### `GET /setup/info/{deviceIP}` +#### `GET /devices/{deviceIP}/info` Gets detailed device information and configuration. -#### `GET /setup/migration-summary/{deviceIP}` +#### `GET /setup/devices/{deviceIP}/summary` Analyzes device configuration and provides migration preview. **Response:** @@ -388,7 +388,7 @@ Analyzes device configuration and provides migration preview. } ``` -#### `POST /setup/migrate/{deviceIP}` +#### `POST /setup/devices/{deviceIP}/migrate` Migrates device to use local services. **Query Parameters:** @@ -399,6 +399,33 @@ Migrates device to use local services. - `sw_update`: Set to "original" to proxy update requests (optional) - `bmx`: Set to "original" to proxy BMX requests (optional) +#### `POST /setup/devices/{deviceIP}/revert` +Reverts device to Bose cloud defaults. + +#### `POST /setup/devices/{deviceIP}/trust-ca` +Injects the AfterTouch root CA into the device's trust store. + +#### `POST /setup/devices/{deviceIP}/sync` +Syncs presets and recents from the device to local storage. + +#### `POST /setup/devices/{deviceIP}/backup` +Creates a backup of the current device configuration. + +#### `POST /setup/devices/{deviceIP}/ensure-remote-services` +Enables persistent SSH/remote services on the device. + +#### `POST /setup/devices/{deviceIP}/remove-remote-services` +Removes persistent SSH/remote services from the device. + +#### `POST /setup/devices/{deviceIP}/test-connection` +Tests HTTPS connection from device to service. + +#### `POST /setup/devices/{deviceIP}/test-hosts` +Tests /etc/hosts redirection on the device. + +#### `POST /setup/devices/{deviceIP}/test-dns` +Tests DNS redirection on the device. + ### BMX Services (Bose Media eXchange) #### `GET /bmx/registry/v1/services` @@ -658,13 +685,13 @@ find data/stats/ -name "*.json" -mtime +90 -delete - **Description**: Browser-based guided flow for discovery, data sync, and migration. ### Setup API -- `GET /setup/devices`: List all known (auto-discovered and manual) devices. -- `POST /setup/devices`: Manually add a device by IP. +- `GET /devices`: List all known (auto-discovered and manual) devices. +- `POST /devices`: Manually add a device by IP. - `POST /setup/discover`: Trigger a new network discovery scan. - `GET /setup/discovery-status`: Check if a scan is currently in progress. -- `POST /setup/sync/{deviceIP}`: Fetch presets, recents, and sources from a device. -- `GET /setup/summary/{deviceIP}`: Get a detailed migration readiness summary. -- `POST /setup/migrate/{deviceIP}`: Migrate a device using the specified method (XML/Hosts). +- `POST /devices/{deviceIP}/sync`: Fetch presets, recents, and sources from a device. +- `GET /devices/{deviceIP}/summary`: Get a detailed migration readiness summary. +- `POST /devices/{deviceIP}/migrate`: Migrate a device using the specified method (XML/Hosts). - `GET /setup/ca.crt`: Download the Root CA certificate for manual installation. #### `GET /setup/interactions` @@ -811,7 +838,7 @@ soundtouch: name: "Living Room Speaker" rest: - - resource: "http://localhost:8000/setup/devices" + - resource: "http://localhost:8000/devices" scan_interval: 60 sensor: - name: "SoundTouch Devices" diff --git a/pkg/service/handlers/handlers_events_test.go b/pkg/service/handlers/handlers_events_test.go index 033b1c9..ca65ea8 100644 --- a/pkg/service/handlers/handlers_events_test.go +++ b/pkg/service/handlers/handlers_events_test.go @@ -18,7 +18,7 @@ func TestEventLog(t *testing.T) { r := chi.NewRouter() r.Post("/streaming/stats/usage", s.HandleUsageStats) - r.Get("/setup/devices/{deviceId}/events", s.HandleGetDeviceEvents) + r.Get("/devices/{deviceId}/events", s.HandleGetDeviceEvents) t.Run("Record and Retrieve Events", func(t *testing.T) { // 1. Post a usage stat @@ -36,7 +36,7 @@ func TestEventLog(t *testing.T) { } // 2. Retrieve events - req, _ = http.NewRequest("GET", "/setup/devices/SPEAKER1/events", nil) + req, _ = http.NewRequest("GET", "/devices/SPEAKER1/events", nil) w = httptest.NewRecorder() r.ServeHTTP(w, req) diff --git a/pkg/service/handlers/handlers_media.go b/pkg/service/handlers/handlers_media.go index 79ac386..87aac7d 100644 --- a/pkg/service/handlers/handlers_media.go +++ b/pkg/service/handlers/handlers_media.go @@ -11,7 +11,7 @@ import ( //go:embed web/index.html var indexHTML []byte -//go:embed web/css/* web/js/* +//go:embed web/migration/* web/stockholm-mini/* web/shared/* var webFS embed.FS //go:embed static/media/* diff --git a/pkg/service/handlers/handlers_media_test.go b/pkg/service/handlers/handlers_media_test.go index e3c0191..ade5004 100644 --- a/pkg/service/handlers/handlers_media_test.go +++ b/pkg/service/handlers/handlers_media_test.go @@ -103,33 +103,104 @@ func TestStaticWeb(t *testing.T) { ts := httptest.NewServer(r) defer ts.Close() - // 1. Test CSS - res, err := http.Get(ts.URL + "/web/css/style.css") + // 1. Test Migration UI CSS + res, err := http.Get(ts.URL + "/web/migration/style.css") if err != nil { t.Fatal(err) } defer res.Body.Close() if res.StatusCode != http.StatusOK { - t.Errorf("CSS: Expected status OK, got %v", res.Status) + t.Errorf("Migration CSS: Expected status OK, got %v", res.Status) } if !strings.Contains(res.Header.Get("Content-Type"), "text/css") { - t.Errorf("CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type")) + t.Errorf("Migration CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type")) } - // 2. Test JS - res, err = http.Get(ts.URL + "/web/js/script.js") + // 2. Test Migration UI JS + res, err = http.Get(ts.URL + "/web/migration/script.js") if err != nil { t.Fatal(err) } defer res.Body.Close() if res.StatusCode != http.StatusOK { - t.Errorf("JS: Expected status OK, got %v", res.Status) + t.Errorf("Migration JS: Expected status OK, got %v", res.Status) } if !strings.Contains(res.Header.Get("Content-Type"), "application/javascript") && !strings.Contains(res.Header.Get("Content-Type"), "text/javascript") { - t.Errorf("JS: Expected javascript content type, got %s", res.Header.Get("Content-Type")) + t.Errorf("Migration JS: Expected javascript content type, got %s", res.Header.Get("Content-Type")) + } + + // 3. Test Migration UI Index + res, err = http.Get(ts.URL + "/web/migration/index.html") + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + t.Errorf("Migration Index: Expected status OK, got %v", res.Status) + } + if !strings.Contains(res.Header.Get("Content-Type"), "text/html") { + t.Errorf("Migration Index: Expected text/html content type, got %s", res.Header.Get("Content-Type")) + } + + // 4. Test Stockholm Mini + res, err = http.Get(ts.URL + "/web/stockholm-mini/index.html") + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + t.Errorf("Stockholm Mini: Expected status OK, got %v", res.Status) + } + if !strings.Contains(res.Header.Get("Content-Type"), "text/html") { + t.Errorf("Stockholm Mini: Expected text/html content type, got %s", res.Header.Get("Content-Type")) + } + + // 5. Test Stockholm Mini CSS + res, err = http.Get(ts.URL + "/web/stockholm-mini/style.css") + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + t.Errorf("Stockholm Mini CSS: Expected status OK, got %v", res.Status) + } + if !strings.Contains(res.Header.Get("Content-Type"), "text/css") { + t.Errorf("Stockholm Mini CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type")) + } + + // 6. Test Shared CSS + res, err = http.Get(ts.URL + "/web/shared/common.css") + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + t.Errorf("Shared CSS: Expected status OK, got %v", res.Status) + } + if !strings.Contains(res.Header.Get("Content-Type"), "text/css") { + t.Errorf("Shared CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type")) + } + + // 7. Test Shared JS + res, err = http.Get(ts.URL + "/web/shared/common.js") + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + t.Errorf("Shared JS: Expected status OK, got %v", res.Status) + } + if !strings.Contains(res.Header.Get("Content-Type"), "application/javascript") && + !strings.Contains(res.Header.Get("Content-Type"), "text/javascript") { + t.Errorf("Shared JS: Expected javascript content type, got %s", res.Header.Get("Content-Type")) } // 3. Test diff.min.js diff --git a/pkg/service/handlers/handlers_setup_test.go b/pkg/service/handlers/handlers_setup_test.go index 251b1fa..8ace344 100644 --- a/pkg/service/handlers/handlers_setup_test.go +++ b/pkg/service/handlers/handlers_setup_test.go @@ -232,8 +232,8 @@ func TestMigrationAndCA(t *testing.T) { t.Errorf("CA: Unexpected content type: %s", res.Header.Get("Content-Type")) } - // 2. Test POST /setup/migrate/{deviceIP}?method=hosts - res, err = http.Post(ts.URL+"/setup/migrate/192.168.1.10?method=hosts&target_url=http://192.168.1.100:8000", "application/json", nil) + // 2. Test POST /setup/devices/{deviceIP}/migrate?method=hosts + res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/migrate?method=hosts&target_url=http://192.168.1.100:8000", "application/json", nil) if err != nil { t.Fatal(err) } @@ -254,8 +254,8 @@ func TestMigrationAndCA(t *testing.T) { t.Errorf("Migrate: Expected output field in response") } - // 3. Test POST /setup/trust-ca/{deviceIP} - res, err = http.Post(ts.URL+"/setup/trust-ca/192.168.1.10", "application/json", nil) + // 3. Test POST /setup/devices/{deviceIP}/trust-ca + res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/trust-ca", "application/json", nil) if err != nil { t.Fatal(err) } @@ -275,8 +275,8 @@ func TestMigrationAndCA(t *testing.T) { t.Errorf("TrustCA: Expected output field in response") } - // 4. Test POST /setup/reboot/{deviceIP} - res, err = http.Post(ts.URL+"/setup/reboot/192.168.1.10", "application/json", nil) + // 4. Test POST /devices/{deviceIP}/reboot + res, err = http.Post(ts.URL+"/devices/192.168.1.10/reboot", "application/json", nil) if err != nil { t.Fatal(err) } @@ -296,8 +296,8 @@ func TestMigrationAndCA(t *testing.T) { t.Errorf("Reboot: Expected output field in response") } - // 5. Test POST /setup/remove-remote-services/{deviceIP} - res, err = http.Post(ts.URL+"/setup/remove-remote-services/192.168.1.10", "application/json", nil) + // 5. Test POST /setup/devices/{deviceIP}/remove-remote-services + res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/remove-remote-services", "application/json", nil) if err != nil { t.Fatal(err) } @@ -329,17 +329,20 @@ func TestRemoveDevice(t *testing.T) { _ = ds.Initialize() // Setup a dummy device in the datastore - account := "test-account" + account := "acc1" deviceID := "TEST-DEVICE-ID" - deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID) - if err := os.MkdirAll(deviceDir, 0755); err != nil { - t.Fatalf("Failed to create device dir: %v", err) - } - infoFile := filepath.Join(deviceDir, "DeviceInfo.xml") - infoXML := `Test DeviceSoundTouch 10` - if err := os.WriteFile(infoFile, []byte(infoXML), 0644); err != nil { - t.Fatalf("Failed to create device info file: %v", err) + // Register device in datastore so HandleRemoveDevice works + _ = ds.SaveDeviceInfo(account, deviceID, &models.ServiceDeviceInfo{ + DeviceID: deviceID, + AccountID: account, + IPAddress: "192.168.1.100", + }) + + // Verify directory exists where datastore expects it + deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID) + if _, err := os.Stat(deviceDir); err != nil { + t.Fatalf("Device directory was not created by SaveDeviceInfo: %v", err) } r, _ := setupRouter("http://localhost:8001", ds) @@ -347,7 +350,7 @@ func TestRemoveDevice(t *testing.T) { defer ts.Close() // 1. Verify device exists - res, err := http.Get(ts.URL + "/setup/devices") + res, err := http.Get(ts.URL + "/devices") if err != nil { t.Fatal(err) } @@ -370,7 +373,7 @@ func TestRemoveDevice(t *testing.T) { } // 2. Remove device - req, err := http.NewRequest(http.MethodDelete, ts.URL+"/setup/devices/"+deviceID, nil) + req, err := http.NewRequest(http.MethodDelete, ts.URL+"/devices/"+deviceID, nil) if err != nil { t.Fatal(err) } @@ -385,7 +388,7 @@ func TestRemoveDevice(t *testing.T) { } // 3. Verify device is gone - res, err = http.Get(ts.URL + "/setup/devices") + res, err = http.Get(ts.URL + "/devices") if err != nil { t.Fatal(err) } diff --git a/pkg/service/handlers/handlers_websocket.go b/pkg/service/handlers/handlers_websocket.go new file mode 100644 index 0000000..552e7a1 --- /dev/null +++ b/pkg/service/handlers/handlers_websocket.go @@ -0,0 +1,203 @@ +package handlers + +import ( + "encoding/json" + "log" + "net/http" + "time" + + "github.com/gesellix/bose-soundtouch/pkg/client" + "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/go-chi/chi/v5" + "github.com/gorilla/websocket" +) + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(_ *http.Request) bool { return true }, +} + +const ( + pongWait = 40 * time.Second + pingPeriod = 20 * time.Second // must be less than pongWait +) + +// HandleDeviceWebSocket upgrades the connection and proxies device WebSocket events to the browser. +func (s *Server) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request) { + deviceID := chi.URLParam(r, "deviceId") + if deviceID == "" { + http.Error(w, "Device ID is required", http.StatusBadRequest) + return + } + + deviceIP, err := s.lookupIP(deviceID) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + // Upgrade the HTTP connection to a WebSocket for the browser + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + + // Create a SoundTouch WebSocket client for the target device + c := client.NewClientFromHost(deviceIP) + wsClient := c.NewWebSocketClient(client.DefaultWebSocketConfig()) + + // Channel-based write pump per Gorilla best practices + sendCh := make(chan []byte, 64) // buffer to smooth bursts + closeCh := make(chan struct{}) + + // Helper to enqueue JSON messages; drop if buffer is full to avoid blocking + enqueue := func(v interface{}) { + b, err := json.Marshal(v) + if err != nil { + return + } + + select { + case sendCh <- b: + default: + // drop to protect connection under burst + } + } + + // Reader: we don't expect messages from the browser; just keep the + // connection alive by processing control frames and detect close. + _ = conn.SetReadDeadline(time.Now().Add(pongWait)) + conn.SetPongHandler(func(string) error { + return conn.SetReadDeadline(time.Now().Add(pongWait)) + }) + + go func() { + defer func() { + close(closeCh) + + _ = wsClient.Disconnect() + _ = conn.Close() + }() + + for { + mt, _, err := conn.ReadMessage() + if err != nil { + log.Printf("[WebSocket] Browser connection closed for %s: %v", deviceIP, err) + return + } + + if mt == websocket.CloseMessage { + return + } + } + }() + + // Writer: single writer goroutine handles JSON writes and ping keepalive + go func() { + pingTicker := time.NewTicker(pingPeriod) + + defer func() { + pingTicker.Stop() + + _ = conn.Close() + }() + + for { + select { + case msg, ok := <-sendCh: + _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if !ok { + _ = conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + + if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil { + return + } + case <-pingTicker.C: + _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + case <-closeCh: + return + } + } + }() + + // Forward typed events with a simple envelope into the send queue + wsClient.SetHandlers(&models.WebSocketEventHandlers{ + OnNowPlaying: func(e *models.NowPlayingUpdatedEvent) { + enqueue(map[string]interface{}{"type": "nowPlayingUpdated", "payload": e}) + }, + OnVolumeUpdated: func(e *models.VolumeUpdatedEvent) { + enqueue(map[string]interface{}{"type": "volumeUpdated", "payload": e}) + }, + OnConnectionState: func(e *models.ConnectionStateUpdatedEvent) { + enqueue(map[string]interface{}{"type": "connectionStateUpdated", "payload": e}) + }, + OnPresetUpdated: func(e *models.PresetUpdatedEvent) { + enqueue(map[string]interface{}{"type": "presetUpdated", "payload": e}) + }, + OnZoneUpdated: func(e *models.ZoneUpdatedEvent) { + enqueue(map[string]interface{}{"type": "zoneUpdated", "payload": e}) + }, + OnBassUpdated: func(e *models.BassUpdatedEvent) { + enqueue(map[string]interface{}{"type": "bassUpdated", "payload": e}) + }, + OnUnknownEvent: func(event *models.WebSocketEvent) { + bytes, _ := json.Marshal(event) + enqueue(map[string]interface{}{"type": "unknown", "payload": json.RawMessage(bytes)}) + }, + OnSpecialMessage: func(msg *models.SpecialMessage) { + enqueue(map[string]interface{}{"type": "special", "payload": msg}) + }, + }) + + // Add a separate goroutine to monitor the device connection status + go func() { + wsClient.Wait() + log.Printf("[WebSocket] Device %s client terminated", deviceIP) + + _ = conn.Close() + }() + + // Connect to the device WebSocket + if err := wsClient.Connect(); err != nil { + enqueue(map[string]interface{}{"type": "error", "message": err.Error()}) + return + } + + // Optional: send an initial snapshot for convenience + go func() { + info, err := s.sm.GetLiveDeviceInfo(deviceIP) + if err != nil { + return + } + + // Supplement with volume and now playing + c := client.NewClientFromHost(deviceIP) + payload := map[string]interface{}{ + "deviceID": info.DeviceID, + "name": info.Name, + "type": info.Type, + //"maccAddress": info.MaccAddress, + "serialNumber": info.SerialNumber, + "softwareVersion": info.SoftwareVer, + // Provide IP in both styles for frontend robustness + "ip_address": deviceIP, + "ipAddress": deviceIP, + } + + if vol, err := c.GetVolume(); err == nil { + payload["volume"] = vol + // Also add at top level for flatter frontend parsing + payload["actualVolume"] = vol.ActualVolume + } + + if np, err := c.GetNowPlaying(); err == nil { + payload["nowPlaying"] = np + } + + enqueue(map[string]interface{}{"type": "snapshotInfo", "payload": payload}) + }() +} diff --git a/pkg/service/handlers/main_test.go b/pkg/service/handlers/main_test.go index 2d1cdc2..4ca241e 100644 --- a/pkg/service/handlers/main_test.go +++ b/pkg/service/handlers/main_test.go @@ -91,12 +91,31 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server) r.Post("/account/{account}/password", server.HandleMargeChangePassword) }) + // Setup Devices for tests + r.Route("/devices", func(r chi.Router) { + r.Get("/", server.HandleListDiscoveredDevices) + r.Post("/", server.HandleAddManualDevice) + + r.Route("/{deviceId}", func(r chi.Router) { + r.Delete("/", server.HandleRemoveDevice) + r.Get("/events", server.HandleGetDeviceEvents) + r.Get("/info", server.HandleGetDeviceInfo) + r.Get("/ws", server.HandleDeviceWebSocket) + r.Post("/key/{key}", server.HandleDeviceKey) + r.Post("/volume/{level}", server.HandleDeviceVolume) + r.Post("/reboot", server.HandleRebootDevice) + }) + }) + + r.Get("/version", server.HandleGetVersionInfo) + // Setup Setup for tests r.Route("/setup", func(r chi.Router) { - r.Get("/devices", server.HandleListDiscoveredDevices) - r.Delete("/devices/{deviceId}", server.HandleRemoveDevice) + r.Post("/discover", server.HandleTriggerDiscovery) + r.Get("/discovery-status", server.HandleGetDiscoveryStatus) r.Get("/settings", server.HandleGetSettings) r.Post("/settings", server.HandleUpdateSettings) + r.Get("/ca.crt", server.HandleGetCACert) r.Get("/proxy-settings", server.HandleGetProxySettings) r.Post("/proxy-settings", server.HandleUpdateProxySettings) r.Post("/ensure-remote-services/{deviceId}", server.HandleEnsureRemoteServices) @@ -108,6 +127,30 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server) r.Post("/test-connection/{deviceId}", server.HandleTestConnection) r.Post("/test-hosts/{deviceId}", server.HandleTestHostsRedirection) r.Get("/ca.crt", server.HandleGetCACert) + + r.Get("/interaction-stats", server.HandleGetInteractionStats) + r.Get("/interactions", server.HandleListInteractions) + r.Get("/interaction-content", server.HandleGetInteractionContent) + 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.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries) + + r.Route("/devices/{deviceId}", func(r chi.Router) { + r.Get("/summary", server.HandleGetMigrationSummary) + r.Post("/migrate", server.HandleMigrateDevice) + r.Post("/revert", server.HandleRevertMigration) + r.Post("/trust-ca", server.HandleTrustCACert) + r.Post("/ensure-remote-services", server.HandleEnsureRemoteServices) + r.Post("/remove-remote-services", server.HandleRemoveRemoteServices) + r.Post("/backup", server.HandleBackupConfig) + r.Post("/sync", server.HandleInitialSync) + r.Post("/test-connection", server.HandleTestConnection) + r.Post("/test-hosts", server.HandleTestHostsRedirection) + r.Post("/test-dns", server.HandleTestDNSRedirection) + }) }) r.NotFound(server.HandleNotFound) diff --git a/pkg/service/handlers/recorder_middleware.go b/pkg/service/handlers/recorder_middleware.go index f90747d..de35d55 100644 --- a/pkg/service/handlers/recorder_middleware.go +++ b/pkg/service/handlers/recorder_middleware.go @@ -5,6 +5,7 @@ import ( "bytes" "fmt" "io" + "log" "net" "net/http" ) @@ -12,7 +13,7 @@ import ( // RecordMiddleware returns a middleware that records "self" requests and responses. func (s *Server) RecordMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if s.recorder == nil || !s.recordEnabled { + if s.recorder == nil || !s.recordEnabled || r.Header.Get("Upgrade") == "websocket" { next.ServeHTTP(w, r) return } @@ -52,6 +53,10 @@ func (s *Server) RecordMiddleware(next http.Handler) http.Handler { // Create a response object for the recorder res := rw.getRecordedResponse(r) + if res.StatusCode >= 400 { + log.Printf("[DEBUG_LOG] Recording error response: %d %s %s", res.StatusCode, r.Method, r.URL.Path) + } + if res.Body != nil { defer func() { _ = res.Body.Close() }() } @@ -65,8 +70,9 @@ func (s *Server) RecordMiddleware(next http.Handler) http.Handler { type responseWriter struct { http.ResponseWriter - statusCode int - body *bytes.Buffer + statusCode int + body *bytes.Buffer + wroteHeader bool } func (rw *responseWriter) Header() http.Header { @@ -74,18 +80,28 @@ func (rw *responseWriter) Header() http.Header { } func (rw *responseWriter) WriteHeader(code int) { + if rw.wroteHeader { + return + } + rw.statusCode = code + rw.wroteHeader = true rw.ResponseWriter.WriteHeader(code) } func (rw *responseWriter) Write(b []byte) (int, error) { + if !rw.wroteHeader { + rw.WriteHeader(http.StatusOK) + } + rw.body.Write(b) + return rw.ResponseWriter.Write(b) } func (rw *responseWriter) getRecordedResponse(r *http.Request) *http.Response { statusCode := rw.statusCode - if statusCode == 0 { + if !rw.wroteHeader && statusCode == 0 { statusCode = http.StatusOK } diff --git a/pkg/service/handlers/web/index.html b/pkg/service/handlers/web/index.html index 801cc60..ee08a2c 100644 --- a/pkg/service/handlers/web/index.html +++ b/pkg/service/handlers/web/index.html @@ -1,1445 +1,98 @@ - + - - AfterTouch (SoundTouch Toolkit) - - - + + AfterTouch - Select Interface + + + -

AfterTouch

-

- Bose SoundTouch Toolkit -

+
+

AfterTouch

+

Bose SoundTouch Toolkit

+

Select an interface to continue.

-
-
- - - - - - - -
+
+ +
📻
+
Stockholm Mini
+
Lightweight device controller and player.
+
- -
-

Welcome to AfterTouch

-

- This toolkit helps you keep your Bose SoundTouch speakers - functional even after the Bose Cloud shutdown in May 2026. - It emulates the necessary cloud services locally on your - network. -

- -

Migration Process at a Glance

-
- 🔌 Prerequisite: Enable SSH
- Migration requires SSH access. To enable it: -
    -
  1. - Create an empty file named - remote_services on a USB stick. -
  2. -
  3. - Insert it into the speaker's - SERVICE port and reboot the - speaker. -
  4. -
- Verify connection: -
    -
  • - Use the Migration tab to select - your device and verify that - SSH Connection shows ✅ Success. -
  • -
  • - Or manually: - ssh -oHostKeyAlgorithms=+ssh-rsa - root@<SPEAKER-IP> - (no password). -
  • -
-
-
    -
  1. - Settings: Review the - Settings tab. Ensure the "Target - Domain" and "Proxy Domain" use an IP address or domain - name that is - accessible from your speakers (usually - the IP of this server on your local network). -
  2. -
  3. - Discovery: Go to the - Devices tab to find your speakers on - the network. Ensure your speakers are powered on and - connected to the same network. -
  4. -
  5. - Data Sync: In the - Data Sync tab, fetch your current - presets, recents, and sources. This step is critical to - ensure your local service has all your personalized data - before you disconnect from the Bose cloud. -
  6. -
  7. - Migration: In the - Migration tab, redirect your speaker to - this local service. We recommend the - XML Configuration method as it is - surgical and easily reversible. -
  8. -
  9. - Verification: After migration and - reboot, your speaker will communicate with this toolkit - instead of Bose servers. -
  10. -
- -
- ⚠️ Safety First: Before starting any - migration, please read our - Professional - Migration & Safety Guide. The toolkit automatically creates backups, but understanding the process - is key to a smooth transition. -
- -

Useful Links

- -
- - -
-

System Settings

-

- Note: These URLs must be - accessible from your SoundTouch devices. - Use the IP address of this server on your local network - (e.g., http://192.168.1.100:8000) rather than - localhost. -

-
- - - (Standard services URL) -
-
- - - -
-
- DNS Discovery: -
- -
- - - -
- Optional: comma-separated list of DNS servers - (e.g., 1.1.1.1, 8.8.8.8).
- If empty, AfterTouch defaults to the system - nameservers (e.g. from - /etc/resolv.conf).
-
-
-
-
- - - (e.g., :53 or 0.0.0.0:53. - Port 53 is required for actual - migration) -
-
-
-
- Endpoint Mirroring: -
- -
- -
- -
- Note: Mirroring sends matching - requests (including full headers) to the - official Bose servers for parity comparison. If - Redact Sensitive Data is enabled in - Proxy Settings, credentials will be masked in - logs and recordings, but full - headers are always sent to Bose to ensure - service compatibility. -
-
-
-
-
- Spotify Integration: -
- Checking configuration... -
-
-
- Proxy Logging: -
- - - -
-
- -
- Requests matching these patterns will be - excluded from recording. Use one pattern per - line. -
-
-
-
- -
- -
-
-

- Known Devices - 🔍 Scanning... -

-
- Spotify: - Not Linked - -
-
-
Loading devices...
-
- - - -
-
+
+ AfterTouch +
- -
-

Initial Data Sync

-

- Before migrating, fetch your presets, recents, and - configured sources from the device to ensure they are - available locally. -

-
- - - -
-
- -
- - -
-

Device Migration

-
- - -
- -
- - - - -
- - -
-

Recorded Interactions & Device Events

-

- Analysis of traffic handled by this service (self), proxied - to Bose (upstream), and internal device events (telemetry). -

- -
-
-

- Total Requests: - 0 -

- -
- -
-
- -
- Keeps only the 10 most recent sessions -
-
-
-
-
-

By Service

-
    -
    -
    -

    Sessions

    -
    -
      -
      -
      -
      -
      - -
      -
      -

      Browse Recordings

      -
      -
      -
      - - -
      -
      - - -
      -
      - - -
      - -
      - -
      - - - - - - - - - - - - - - - - - - -
      #TimeMethodPathStatusCategoryEvent DetailsAction
      - No interactions found. -
      -
      -
      - -
      -
      -

      DNS Discoveries

      -
      - - -
      -
      -

      - Hosts discovered via the AfterTouch DNS server. "Self" - means the domain was intercepted and redirected to this - service. -

      -
      - - - - - - - - - - - - - - - - -
      HostnameLast Seen - Queries - - Bose? - CategoryLast Client IP
      - No DNS discoveries found. -
      -
      -
      - -
      -
      -

      - Recording Viewer: - -

      - -
      -
      
      -        
      - - -
      -
      -

      App & Device Events

      -
      - - -
      -
      -
      - - - - - - - - - - - - - -
      TimeTypeData
      Select a device to view - events. -
      -
      -
      -
      - - -
      -

      Parity Analysis

      -

      - Detection of discrepancies between AfterTouch local - responses and official Bose Cloud responses for mirrored - endpoints. -

      - -
      -
      -

      Parity Mismatches

      -
      - - -
      -
      - -
      - - - - - - - - - - - - - - - -
      TimeMethodPathReasonsAction
      - Loading mismatches... -
      -
      -
      - - -
      -
      - - -
      - AfterTouch -
      + + diff --git a/pkg/service/handlers/web/js/diff.min.js b/pkg/service/handlers/web/migration/diff.min.js similarity index 100% rename from pkg/service/handlers/web/js/diff.min.js rename to pkg/service/handlers/web/migration/diff.min.js diff --git a/pkg/service/handlers/web/migration/index.html b/pkg/service/handlers/web/migration/index.html index 801cc60..6bfa9cd 100644 --- a/pkg/service/handlers/web/migration/index.html +++ b/pkg/service/handlers/web/migration/index.html @@ -1,368 +1,146 @@ - + - + AfterTouch (SoundTouch Toolkit) - - - + + +

      AfterTouch

      -

      - Bose SoundTouch Toolkit -

      +

      Bose SoundTouch Toolkit

      +

      ← Back to selection

      - - - - - - - + + + + + +

      Welcome to AfterTouch

      -

      - This toolkit helps you keep your Bose SoundTouch speakers - functional even after the Bose Cloud shutdown in May 2026. - It emulates the necessary cloud services locally on your - network. -

      +

      This toolkit helps you keep your Bose SoundTouch speakers functional even after the Bose Cloud shutdown in May 2026. It emulates the necessary cloud services locally on your network.

      Migration Process at a Glance

      - 🔌 Prerequisite: Enable SSH
      + 🔌 Prerequisite: Enable SSH
      Migration requires SSH access. To enable it: -
        -
      1. - Create an empty file named - remote_services on a USB stick. -
      2. -
      3. - Insert it into the speaker's - SERVICE port and reboot the - speaker. -
      4. +
          +
        1. Create an empty file named remote_services on a USB stick.
        2. +
        3. Insert it into the speaker's SERVICE port and reboot the speaker.
        Verify connection:
          -
        • - Use the Migration tab to select - your device and verify that - SSH Connection shows ✅ Success. -
        • -
        • - Or manually: - ssh -oHostKeyAlgorithms=+ssh-rsa - root@<SPEAKER-IP> - (no password). -
        • +
        • Use the Migration tab to select your device and verify that SSH Connection shows ✅ Success.
        • +
        • Or manually: ssh -oHostKeyAlgorithms=+ssh-rsa root@<SPEAKER-IP> (no password).
      1. - Settings: Review the - Settings tab. Ensure the "Target - Domain" and "Proxy Domain" use an IP address or domain - name that is - accessible from your speakers (usually - the IP of this server on your local network). + Settings: Review the Settings tab. Ensure the "Target Domain" and "Proxy Domain" use an IP address or domain name that is accessible from your speakers (usually the IP of this server on your local network).
      2. - Discovery: Go to the - Devices tab to find your speakers on - the network. Ensure your speakers are powered on and - connected to the same network. + Discovery: Go to the Devices tab to find your speakers on the network. + Ensure your speakers are powered on and connected to the same network.
      3. - Data Sync: In the - Data Sync tab, fetch your current - presets, recents, and sources. This step is critical to - ensure your local service has all your personalized data - before you disconnect from the Bose cloud. + Data Sync: In the Data Sync tab, fetch your current presets, recents, and sources. + This step is critical to ensure your local service has all your personalized data before you disconnect from the Bose cloud.
      4. - Migration: In the - Migration tab, redirect your speaker to - this local service. We recommend the - XML Configuration method as it is - surgical and easily reversible. + Migration: In the Migration tab, redirect your speaker to this local service. + We recommend the XML Configuration method as it is surgical and easily reversible.
      5. - Verification: After migration and - reboot, your speaker will communicate with this toolkit - instead of Bose servers. + Verification: After migration and reboot, your speaker will communicate with this toolkit instead of Bose servers.
      - ⚠️ Safety First: Before starting any - migration, please read our - Professional - Migration & Safety Guide. The toolkit automatically creates backups, but understanding the process - is key to a smooth transition. + ⚠️ Safety First: Before starting any migration, please read our + Professional Migration & Safety Guide. + The toolkit automatically creates backups, but understanding the process is key to a smooth transition.

      Useful Links

      System Settings

      -

      - Note: These URLs must be - accessible from your SoundTouch devices. - Use the IP address of this server on your local network - (e.g., http://192.168.1.100:8000) rather than - localhost. +

      + Note: These URLs must be accessible from your SoundTouch devices. + Use the IP address of this server on your local network (e.g., http://192.168.1.100:8000) + rather than localhost.

      -
      +
      - - (Standard services URL) + + (Standard services URL)
      -
      +
      + + + (Soundcork services URL) +
      +
      - - + +
      -
      - DNS Discovery: -
      - -
      - - - -
      - Optional: comma-separated list of DNS servers - (e.g., 1.1.1.1, 8.8.8.8).
      - If empty, AfterTouch defaults to the system - nameservers (e.g. from - /etc/resolv.conf).
      -
      -
      -
      -
      - - - (e.g., :53 or 0.0.0.0:53. - Port 53 is required for actual - migration) -
      -
      -
      -
      - Endpoint Mirroring: -
      - -
      - -
      - -
      - Note: Mirroring sends matching - requests (including full headers) to the - official Bose servers for parity comparison. If - Redact Sensitive Data is enabled in - Proxy Settings, credentials will be masked in - logs and recordings, but full - headers are always sent to Bose to ensure - service compatibility. -
      -
      -
      -
      -
      - Spotify Integration: -
      - Checking configuration... -
      -
      -
      - Proxy Logging: -
      - - - -
      -
      - -
      - Requests matching these patterns will be - excluded from recording. Use one pattern per - line. -
      -
      -
      -
      - -
      +
      - + +
      +
      + DNS Discovery: +
      + +
      + + + (For non-intercepted queries) +
      +
      + + + (e.g., :53 or 0.0.0.0:53. Port 53 is required for actual migration) +
      +
      +
      +
      + Proxy Logging: +
      + + + + +
      -
      -

      - Known Devices - 🔍 Scanning... -

      -
      - Spotify: - Not Linked - -
      -
      +

      Known Devices

      Loading devices...
      -
      +
      - +
      @@ -370,11 +148,7 @@

      Initial Data Sync

      -

      - Before migrating, fetch your presets, recents, and - configured sources from the device to ensure they are - available locally. -

      +

      Before migrating, fetch your presets, recents, and configured sources from the device to ensure they are available locally.

      +
      -