diff --git a/README.md b/README.md index 099da5d..913a789 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,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 - **๐Ÿ“ HTTP Recording**: Persist all interactions as re-playable `.http` files - **๐Ÿงน Session Management**: Manage and cleanup recorded interaction sessions diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index e50cecd..a48905a 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -680,31 +680,31 @@ func setupRouter(server *handlers.Server) *chi.Mux { r.Get("/proxy/*", server.HandleProxyRequest) + 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) r.Post("/settings", server.HandleUpdateSettings) - 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("/test-dns/{deviceIP}", 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) @@ -715,7 +715,19 @@ func setupRouter(server *handlers.Server) *chi.Mux { r.Get("/dns-discoveries", server.HandleGetDNSDiscoveries) 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 8d368de..079681e 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -44,6 +44,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) ## Appendix (Other Documents) 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 eabaafe..264416e 100644 --- a/docs/guides/SOUNDTOUCH-SERVICE.md +++ b/docs/guides/SOUNDTOUCH-SERVICE.md @@ -207,23 +207,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 @@ -232,13 +232,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) @@ -314,7 +314,7 @@ Even without migrating a device, you can use the DNS server to discover what a d ### Discovery & Setup -#### `GET /setup/devices` +#### `GET /devices` Lists all discovered SoundTouch devices with their current status. **Response:** @@ -335,10 +335,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:** @@ -355,7 +355,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:** @@ -366,6 +366,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` @@ -614,13 +641,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` @@ -767,7 +794,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/client/websocket.go b/pkg/client/websocket.go index 160c00d..8dea033 100644 --- a/pkg/client/websocket.go +++ b/pkg/client/websocket.go @@ -356,6 +356,13 @@ func (ws *WebSocketClient) attemptReconnect(config *WebSocketConfig) { } attempt++ + + // Check if device is reachable before attempting full WS connection to reduce log noise + if err := ws.client.Ping(); err != nil { + ws.logger.Printf("Reconnection attempt %d skipped: device unreachable (%v)", attempt, err) + continue + } + ws.logger.Printf("Reconnection attempt %d", attempt) if err := ws.connectWithConfig(config); err != nil { diff --git a/pkg/service/handlers/handlers_events_test.go b/pkg/service/handlers/handlers_events_test.go index cc2ae94..1eae2cb 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 efb24dc..216dae8 100644 --- a/pkg/service/handlers/handlers_media_test.go +++ b/pkg/service/handlers/handlers_media_test.go @@ -103,32 +103,103 @@ 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")) } } diff --git a/pkg/service/handlers/handlers_setup.go b/pkg/service/handlers/handlers_setup.go index 8cdd7c3..8aa7ff1 100644 --- a/pkg/service/handlers/handlers_setup.go +++ b/pkg/service/handlers/handlers_setup.go @@ -278,33 +278,17 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) { } } -// HandleGetDeviceInfo returns live information for a device. -func (s *Server) HandleGetDeviceInfo(w http.ResponseWriter, r *http.Request) { - deviceIP := chi.URLParam(r, "deviceIP") - if deviceIP == "" { - http.Error(w, "Device IP is required", http.StatusBadRequest) - return - } - - info, err := s.sm.GetLiveDeviceInfo(deviceIP) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - - if err := json.NewEncoder(w).Encode(info); err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - return - } -} - // HandleGetMigrationSummary returns a summary of the migration plan for a device. func (s *Server) HandleGetMigrationSummary(w http.ResponseWriter, r *http.Request) { - deviceIP := chi.URLParam(r, "deviceIP") - if deviceIP == "" { - http.Error(w, "Device IP is required", http.StatusBadRequest) + 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 } @@ -335,12 +319,12 @@ func (s *Server) HandleGetMigrationSummary(w http.ResponseWriter, r *http.Reques // HandleMigrateDevice starts the migration process for a device. func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) { - deviceIP := chi.URLParam(r, "deviceIP") - if deviceIP == "" { + deviceID := chi.URLParam(r, "deviceId") + if deviceID == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil { + if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) return } @@ -348,6 +332,12 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) { return } + deviceIP, err := s.lookupIP(deviceID) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + targetURL := r.URL.Query().Get("target_url") proxyURL := r.URL.Query().Get("proxy_url") method := setup.MigrationMethod(r.URL.Query().Get("method")) @@ -383,12 +373,12 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) { // HandleRevertMigration reverts the migration for a device. func (s *Server) HandleRevertMigration(w http.ResponseWriter, r *http.Request) { - deviceIP := chi.URLParam(r, "deviceIP") - if deviceIP == "" { + deviceID := chi.URLParam(r, "deviceId") + if deviceID == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil { + if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) return } @@ -396,6 +386,12 @@ func (s *Server) HandleRevertMigration(w http.ResponseWriter, r *http.Request) { return } + deviceIP, err := s.lookupIP(deviceID) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + output, err := s.sm.RevertMigration(deviceIP) if err != nil { w.Header().Set("Content-Type", "application/json") @@ -498,12 +494,12 @@ func (s *Server) HandleClearDNSDiscoveries(w http.ResponseWriter, _ *http.Reques // HandleTrustCACert injects the local Root CA into the device's shared trust store. func (s *Server) HandleTrustCACert(w http.ResponseWriter, r *http.Request) { - deviceIP := chi.URLParam(r, "deviceIP") - if deviceIP == "" { + deviceID := chi.URLParam(r, "deviceId") + if deviceID == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil { + if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) return } @@ -511,6 +507,12 @@ func (s *Server) HandleTrustCACert(w http.ResponseWriter, r *http.Request) { return } + deviceIP, err := s.lookupIP(deviceID) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + output, err := s.sm.TrustCACert(deviceIP) if err != nil { w.Header().Set("Content-Type", "application/json") @@ -534,12 +536,12 @@ func (s *Server) HandleTrustCACert(w http.ResponseWriter, r *http.Request) { // HandleEnsureRemoteServices ensures that remote services are configured on a device. func (s *Server) HandleEnsureRemoteServices(w http.ResponseWriter, r *http.Request) { - deviceIP := chi.URLParam(r, "deviceIP") - if deviceIP == "" { + deviceID := chi.URLParam(r, "deviceId") + if deviceID == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil { + if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) return } @@ -547,6 +549,12 @@ func (s *Server) HandleEnsureRemoteServices(w http.ResponseWriter, r *http.Reque return } + deviceIP, err := s.lookupIP(deviceID) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + output, err := s.sm.EnsureRemoteServices(deviceIP) if err != nil { w.Header().Set("Content-Type", "application/json") @@ -570,12 +578,12 @@ func (s *Server) HandleEnsureRemoteServices(w http.ResponseWriter, r *http.Reque // HandleRemoveRemoteServices removes remote services configuration from a device. func (s *Server) HandleRemoveRemoteServices(w http.ResponseWriter, r *http.Request) { - deviceIP := chi.URLParam(r, "deviceIP") - if deviceIP == "" { + deviceID := chi.URLParam(r, "deviceId") + if deviceID == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil { + if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) return } @@ -583,6 +591,12 @@ func (s *Server) HandleRemoveRemoteServices(w http.ResponseWriter, r *http.Reque return } + deviceIP, err := s.lookupIP(deviceID) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + output, err := s.sm.RemoveRemoteServices(deviceIP) if err != nil { w.Header().Set("Content-Type", "application/json") @@ -606,12 +620,12 @@ func (s *Server) HandleRemoveRemoteServices(w http.ResponseWriter, r *http.Reque // HandleBackupConfig creates a backup of the device configuration. func (s *Server) HandleBackupConfig(w http.ResponseWriter, r *http.Request) { - deviceIP := chi.URLParam(r, "deviceIP") - if deviceIP == "" { + deviceID := chi.URLParam(r, "deviceId") + if deviceID == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil { + if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) return } @@ -619,6 +633,12 @@ func (s *Server) HandleBackupConfig(w http.ResponseWriter, r *http.Request) { return } + deviceIP, err := s.lookupIP(deviceID) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + output, err := s.sm.BackupConfig(deviceIP) if err != nil { w.Header().Set("Content-Type", "application/json") @@ -731,9 +751,15 @@ func (s *Server) HandleUpdateProxySettings(w http.ResponseWriter, r *http.Reques // HandleTestHostsRedirection performs a preliminary check for /etc/hosts redirection. func (s *Server) HandleTestHostsRedirection(w http.ResponseWriter, r *http.Request) { - deviceIP := chi.URLParam(r, "deviceIP") - if deviceIP == "" { - http.Error(w, "Device IP is required", http.StatusBadRequest) + 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 } @@ -771,9 +797,15 @@ func (s *Server) HandleTestHostsRedirection(w http.ResponseWriter, r *http.Reque // HandleTestDNSRedirection performs a check for DNS redirection to the AfterTouch service. func (s *Server) HandleTestDNSRedirection(w http.ResponseWriter, r *http.Request) { - deviceIP := chi.URLParam(r, "deviceIP") - if deviceIP == "" { - http.Error(w, "Device IP is required", http.StatusBadRequest) + 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 } @@ -811,9 +843,15 @@ func (s *Server) HandleTestDNSRedirection(w http.ResponseWriter, r *http.Request // HandleInitialSync fetches presets, recents and sources from the device and saves them to the datastore. func (s *Server) HandleInitialSync(w http.ResponseWriter, r *http.Request) { - deviceIP := chi.URLParam(r, "deviceIP") - if deviceIP == "" { - http.Error(w, "Missing deviceIP", http.StatusBadRequest) + 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 } @@ -828,12 +866,12 @@ func (s *Server) HandleInitialSync(w http.ResponseWriter, r *http.Request) { // HandleRebootDevice reboots a device. func (s *Server) HandleRebootDevice(w http.ResponseWriter, r *http.Request) { - deviceIP := chi.URLParam(r, "deviceIP") - if deviceIP == "" { + deviceID := chi.URLParam(r, "deviceId") + if deviceID == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil { + if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) return } @@ -841,6 +879,12 @@ func (s *Server) HandleRebootDevice(w http.ResponseWriter, r *http.Request) { return } + deviceIP, err := s.lookupIP(deviceID) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + output, err := s.sm.Reboot(deviceIP) if err != nil { w.Header().Set("Content-Type", "application/json") @@ -864,9 +908,15 @@ func (s *Server) HandleRebootDevice(w http.ResponseWriter, r *http.Request) { // HandleTestConnection performs a connection check from the device to the server. func (s *Server) HandleTestConnection(w http.ResponseWriter, r *http.Request) { - deviceIP := chi.URLParam(r, "deviceIP") - if deviceIP == "" { - http.Error(w, "Device IP is required", http.StatusBadRequest) + 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 } diff --git a/pkg/service/handlers/handlers_setup_test.go b/pkg/service/handlers/handlers_setup_test.go index 0199d73..f6e58c3 100644 --- a/pkg/service/handlers/handlers_setup_test.go +++ b/pkg/service/handlers/handlers_setup_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "github.com/gesellix/bose-soundtouch/pkg/models" "github.com/gesellix/bose-soundtouch/pkg/service/certmanager" "github.com/gesellix/bose-soundtouch/pkg/service/datastore" "github.com/gesellix/bose-soundtouch/pkg/service/setup" @@ -144,6 +145,12 @@ func TestMigrationAndCA(t *testing.T) { return &mockSSH{host: host} } + // Register device in datastore so lookupIP works + _ = ds.SaveDeviceInfo("default", "192.168.1.10", &models.ServiceDeviceInfo{ + DeviceID: "192.168.1.10", + IPAddress: "192.168.1.10", + }) + r, server := setupRouter("http://localhost:8001", ds) server.sm = sm // Inject our manager with mock SSH @@ -164,8 +171,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) } @@ -186,8 +193,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) } @@ -207,8 +214,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) } @@ -228,8 +235,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) } @@ -261,17 +268,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) @@ -279,7 +289,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) } @@ -302,7 +312,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) } @@ -317,7 +327,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_stockholm.go b/pkg/service/handlers/handlers_stockholm.go new file mode 100644 index 0000000..e42bf6c --- /dev/null +++ b/pkg/service/handlers/handlers_stockholm.go @@ -0,0 +1,124 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "strconv" + + "github.com/gesellix/bose-soundtouch/pkg/client" + "github.com/gesellix/bose-soundtouch/pkg/service/setup" + "github.com/go-chi/chi/v5" +) + +// HandleGetDeviceInfo returns live information for a device. +func (s *Server) HandleGetDeviceInfo(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 + } + + info, err := s.sm.GetLiveDeviceInfo(deviceIP) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + + // Include IP address in both snake_case and camelCase for frontend compatibility + type deviceInfoResponse struct { + *setup.DeviceInfoXML `json:",inline"` + IPAddress string `json:"ip_address"` + IPAddressCamel string `json:"ipAddress,omitempty"` + } + + resp := deviceInfoResponse{ + DeviceInfoXML: info, + IPAddress: deviceIP, + IPAddressCamel: deviceIP, + } + + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } +} + +// HandleDeviceKey sends a key command to a device. +func (s *Server) HandleDeviceKey(w http.ResponseWriter, r *http.Request) { + deviceID := chi.URLParam(r, "deviceId") + + key := chi.URLParam(r, "key") + if deviceID == "" || key == "" { + http.Error(w, "Device ID and Key are required", http.StatusBadRequest) + return + } + + deviceIP, err := s.lookupIP(deviceID) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + c := client.NewClientFromHost(deviceIP) + + err = c.SendKey(key) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to send key %s to %s: %v", key, deviceIP, err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + + if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Key sent"}); err != nil { + log.Printf("Failed to encode JSON response: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// HandleDeviceVolume sets the volume level for a device. +func (s *Server) HandleDeviceVolume(w http.ResponseWriter, r *http.Request) { + deviceID := chi.URLParam(r, "deviceId") + + levelStr := chi.URLParam(r, "level") + if deviceID == "" || levelStr == "" { + http.Error(w, "Device ID and Level are required", http.StatusBadRequest) + return + } + + deviceIP, err := s.lookupIP(deviceID) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + level, err := strconv.Atoi(levelStr) + if err != nil { + http.Error(w, "Invalid volume level", http.StatusBadRequest) + return + } + + c := client.NewClientFromHost(deviceIP) + + err = c.SetVolume(level) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to set volume to %d on %s: %v", level, deviceIP, err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + + if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Volume set"}); err != nil { + log.Printf("Failed to encode JSON response: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} diff --git a/pkg/service/handlers/handlers_websocket.go b/pkg/service/handlers/handlers_websocket.go new file mode 100644 index 0000000..bb75754 --- /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 f010ad3..c1f1ad9 100644 --- a/pkg/service/handlers/main_test.go +++ b/pkg/service/handlers/main_test.go @@ -79,23 +79,56 @@ 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/{deviceIP}", server.HandleEnsureRemoteServices) - r.Post("/remove-remote-services/{deviceIP}", server.HandleRemoveRemoteServices) - 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("/test-connection/{deviceIP}", server.HandleTestConnection) - r.Post("/test-hosts/{deviceIP}", 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 10101f8..f58e7c4 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 } @@ -39,6 +40,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() }() } @@ -52,8 +57,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 { @@ -61,18 +67,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/server.go b/pkg/service/handlers/server.go index 1c28d2e..13580dc 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "fmt" "log" "net/http" "net/url" @@ -479,3 +480,23 @@ func (s *Server) findExistingDeviceInfo(d models.DiscoveredDevice) *models.Servi return nil } + +// lookupIP resolves a deviceId to its last known device IP. +func (s *Server) lookupIP(deviceId string) (string, error) { + devices, err := s.ds.ListAllDevices() + if err != nil { + return "", err + } + + for i := range devices { + if devices[i].DeviceID == deviceId { + if devices[i].IPAddress == "" { + return "", fmt.Errorf("no IP known for deviceId %s", deviceId) + } + + return devices[i].IPAddress, nil + } + } + + return "", fmt.Errorf("deviceId %s not found", deviceId) +} diff --git a/pkg/service/handlers/web/index.html b/pkg/service/handlers/web/index.html index 4c43ffc..8826d7b 100644 --- a/pkg/service/handlers/web/index.html +++ b/pkg/service/handlers/web/index.html @@ -2,472 +2,97 @@ - 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) -
-
- - - (Soundcork services URL) -
-
- - - -
-
- - -
-
- 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

-
Loading devices...
-
- - - -
-
- - -
-

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

      -
      -
      -
      - - -
      -
      - - -
      -
      - - -
      - -
      - -
      - - - - - - - - - - - - - - - -
      #TimeMethodPathStatusCategoryAction
      No interactions found.
      -
      -
      - -
      -
      -

      DNS Discoveries

      - -
      -

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

      -
      - - - - - - - - - - - - - - -
      HostnameLast SeenQueriesBose?CategoryLast Client IP
      No DNS discoveries found.
      -
      -
      - - - - - + +
      โš™๏ธ
      +
      Migration
      +
      Setup, data sync, and cloud migration toolkit.
      +
      - -