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 Device SoundTouch 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.
-
-
- Overview
- 1. Settings
- 2. Devices
- 3. Data Sync
- 4. Migration
- 5. Interactions & Events
-
+
+
+ ๐ป
+ 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:
-
- Create an empty file named remote_services on a USB stick.
- 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).
-
-
-
-
- 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).
-
-
- 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.
-
-
- 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.
-
-
- 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.
-
-
- 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.
-
-
-
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.
-
-
- Target Domain:
-
- (Standard services URL)
-
-
- Soundcork URL:
-
- (Soundcork services URL)
-
-
- Discovery Interval:
-
- Enable Automated Discovery
-
-
- Save Settings
-
-
-
-
-
-
-
-
-
Known Devices ๐ Scanning...
-
Loading devices...
-
- Scan Again
-
- Add Device
-
-
-
-
-
-
Initial Data Sync
-
Before migrating, fetch your presets, recents, and configured sources from the device to ensure they are available locally.
-
- Device:
-
- -- Select a device --
-
- Start Sync
-
-
-
-
-
-
-
-
Device Migration
-
- Device:
-
- -- Select a device --
-
-
-
-
-
-
-
-
-
Migration Summary for
-
Migration Status:
-
SSH Connection:
-
Backup: โ
Found .original config at /opt/Bose/etc/SoundTouchSdkPrivateCfg.xml.original Show Original Config
-
Backup: โ Not found Backup Config Now
-
Remote Services Enabled:
-
AfterTouch Local Root CA Trusted: Trust CA Now
-
-
-
HTTPS Connection Test:
-
Verify the device can reach the server over HTTPS.
-
- URL:
-
-
- Test with Explicit CA.crt
- Test with Shared Trust Store
-
-
-
-
-
-
Preliminary /etc/hosts Test:
-
Verify the device's /etc/hosts mechanism before full migration.
-
- Domain: custom-test-api.bose.fake
-
-
- Test Hosts Redirection
-
-
-
-
-
-
Preliminary DNS Test:
-
Verify the device can resolve domains via the AfterTouch DNS server.
-
- Domain: aftertouch.test
-
-
- Test DNS Redirection
-
-
-
-
-
-
Migration Method:
-
- XML Configuration (Recommended - redirects specific services)
- /etc/hosts + Root CA (Advanced - global redirection)
- /etc/resolv.conf (DHCP-Aware - Most flexible)
-
-
-
-
-
-
-
-
-
-
Service Implementations
-
- Service Original URL Implementation
-
- Marge (Streaming)
- loading...
-
-
- AfterTouch (Local Service)
- Upstream (Proxy via local service)
-
-
-
-
- Stats
- loading...
-
-
- AfterTouch (Local Service)
- Upstream (Proxy via local service)
-
-
-
-
- Software Update
- loading...
-
-
- AfterTouch (Local Service)
- Upstream (Proxy via local service)
-
-
-
-
- BMX (Registry)
- loading...
-
-
- AfterTouch (Local Service)
- Upstream (Proxy via local service)
-
-
-
-
-
-
-
-
-
-
-
-
-
- Note: This method also injects the AfterTouch Local Root CA into /etc/pki/tls/certs/ca-bundle.crt to enable secure HTTPS communication.
-
-
-
-
-
-
- Note: This method injects a persistent DNS priority hook into the DHCP logic (/etc/udhcpc.d/50default). It preserves your router's search domain and secondary DNS servers. It also injects the Local Root CA.
-
-
-
-
- Confirm Migration
- Revert to Defaults
- Reboot Speaker
- Enable Persistent Remote Services
- Remove Persistent Remote Services
- Cancel
-
-
-
-
-
-
-
Recorded Interactions & Device Events
-
Analysis of traffic handled by this service (self), proxied to Bose (upstream), and internal device events (telemetry).
-
-
-
-
Total Requests: 0
-
Refresh Stats
-
- View App/Device Events
-
-
-
Cleanup old sessions
-
Keeps only the 10 most recent sessions
-
-
-
-
-
-
-
-
Browse Recordings
-
-
-
- Session:
-
- All Sessions
-
-
-
- Category:
-
- All Categories
- Self (Emulated)
- Upstream (Bose)
-
-
-
- Since (YYYY-MM-DD HH:mm:ss):
-
-
-
Apply Filters
-
-
-
-
-
-
- #
- Time
- Method
- Path
- Status
- Category
- Action
-
-
-
- No interactions found.
-
-
-
-
-
-
-
-
DNS Discoveries
- Clear DNS Logs
-
-
Hosts discovered via the AfterTouch DNS server. "Self" means the domain was intercepted and redirected to this service.
-
-
-
-
- Hostname
- Last Seen
- Queries
- Bose?
- Category
- Last Client IP
-
-
-
- No DNS discoveries found.
-
-
-
-
-
-
-
-
Recording Viewer:
- Close
-
-
-
-
-
-
-
-
App & Device Events
-
-
- -- Select Device --
-
- Close
-
-
-
-
-
-
- Time
- Type
- Data
-
-
-
- Select a device to view events.
-
-
-
-
+
+ โ๏ธ
+ Migration
+ Setup, data sync, and cloud migration toolkit.
+
-
-
+
+
+
+
diff --git a/pkg/service/handlers/web/migration/index.html b/pkg/service/handlers/web/migration/index.html
new file mode 100644
index 0000000..668ed34
--- /dev/null
+++ b/pkg/service/handlers/web/migration/index.html
@@ -0,0 +1,476 @@
+
+
+
+
+ AfterTouch (SoundTouch Toolkit)
+
+
+
+
+
+ AfterTouch
+ Bose SoundTouch Toolkit
+ ← Back to selection
+
+
+
+ Overview
+ 1. Settings
+ 2. Devices
+ 3. Data Sync
+ 4. Migration
+ 5. Interactions & Events
+
+
+
+
+
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:
+
+ Create an empty file named remote_services on a USB stick.
+ 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).
+
+
+
+
+ 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).
+
+
+ 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.
+
+
+ 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.
+
+
+ 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.
+
+
+ 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.
+
+
+
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.
+
+
+ Target Domain:
+
+ (Standard services URL)
+
+
+ Soundcork URL:
+
+ (Soundcork services URL)
+
+
+ Discovery Interval:
+
+ Enable Automated Discovery
+
+
+ Save Settings
+
+
+
+
+
+
+
+
+
Known Devices ๐ Scanning...
+
Loading devices...
+
+ Scan Again
+
+ Add Device
+
+
+
+
+
+
Initial Data Sync
+
Before migrating, fetch your presets, recents, and configured sources from the device to ensure they are available locally.
+
+ Device:
+
+ -- Select a device --
+
+ Start Sync
+
+
+
+
+
+
+
+
Device Migration
+
+ Device:
+
+ -- Select a device --
+
+
+
+
+
+
+
+
+
Migration Summary for
+
Migration Status:
+
SSH Connection:
+
Backup: โ
Found .original config at /opt/Bose/etc/SoundTouchSdkPrivateCfg.xml.original Show Original Config
+
Backup: โ Not found Backup Config Now
+
Remote Services Enabled:
+
AfterTouch Local Root CA Trusted: Trust CA Now
+
+
+
HTTPS Connection Test:
+
Verify the device can reach the server over HTTPS.
+
+ URL:
+
+
+ Test with Explicit CA.crt
+ Test with Shared Trust Store
+
+
+
+
+
+
Preliminary /etc/hosts Test:
+
Verify the device's /etc/hosts mechanism before full migration.
+
+ Domain: custom-test-api.bose.fake
+
+
+ Test Hosts Redirection
+
+
+
+
+
+
Preliminary DNS Test:
+
Verify the device can resolve domains via the AfterTouch DNS server.
+
+ Domain: aftertouch.test
+
+
+ Test DNS Redirection
+
+
+
+
+
+
Migration Method:
+
+ XML Configuration (Recommended - redirects specific services)
+ /etc/hosts + Root CA (Advanced - global redirection)
+ /etc/resolv.conf (DHCP-Aware - Most flexible)
+
+
+
+
+
+
+
+
+
+
Service Implementations
+
+ Service Original URL Implementation
+
+ Marge (Streaming)
+ loading...
+
+
+ AfterTouch (Local Service)
+ Upstream (Proxy via local service)
+
+
+
+
+ Stats
+ loading...
+
+
+ AfterTouch (Local Service)
+ Upstream (Proxy via local service)
+
+
+
+
+ Software Update
+ loading...
+
+
+ AfterTouch (Local Service)
+ Upstream (Proxy via local service)
+
+
+
+
+ BMX (Registry)
+ loading...
+
+
+ AfterTouch (Local Service)
+ Upstream (Proxy via local service)
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Note: This method also injects the AfterTouch Local Root CA into /etc/pki/tls/certs/ca-bundle.crt to enable secure HTTPS communication.
+
+
+
+
+
+
+ Note: This method injects a persistent DNS priority hook into the DHCP logic (/etc/udhcpc.d/50default). It preserves your router's search domain and secondary DNS servers. It also injects the Local Root CA.
+
+
+
+
+ Confirm Migration
+ Revert to Defaults
+ Reboot Speaker
+ Enable Persistent Remote Services
+ Remove Persistent Remote Services
+ Cancel
+
+
+
+
+
+
+
Recorded Interactions & Device Events
+
Analysis of traffic handled by this service (self), proxied to Bose (upstream), and internal device events (telemetry).
+
+
+
+
Total Requests: 0
+
Refresh Stats
+
+ View App/Device Events
+
+
+
Cleanup old sessions
+
Keeps only the 10 most recent sessions
+
+
+
+
+
+
+
+
Browse Recordings
+
+
+
+ Session:
+
+ All Sessions
+
+
+
+ Category:
+
+ All Categories
+ Self (Emulated)
+ Upstream (Bose)
+
+
+
+ Since (YYYY-MM-DD HH:mm:ss):
+
+
+
Apply Filters
+
+
+
+
+
+
+ #
+ Time
+ Method
+ Path
+ Status
+ Category
+ Action
+
+
+
+ No interactions found.
+
+
+
+
+
+
+
+
DNS Discoveries
+ Clear DNS Logs
+
+
Hosts discovered via the AfterTouch DNS server. "Self" means the domain was intercepted and redirected to this service.
+
+
+
+
+ Hostname
+ Last Seen
+ Queries
+ Bose?
+ Category
+ Last Client IP
+
+
+
+ No DNS discoveries found.
+
+
+
+
+
+
+
+
Recording Viewer:
+ Close
+
+
+
+
+
+
+
+
App & Device Events
+
+
+ -- Select Device --
+
+ Close
+
+
+
+
+
+
+ Time
+ Type
+ Data
+
+
+
+ Select a device to view events.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pkg/service/handlers/web/js/script.js b/pkg/service/handlers/web/migration/script.js
similarity index 85%
rename from pkg/service/handlers/web/js/script.js
rename to pkg/service/handlers/web/migration/script.js
index 6a58861..fd8f6df 100644
--- a/pkg/service/handlers/web/js/script.js
+++ b/pkg/service/handlers/web/migration/script.js
@@ -103,8 +103,9 @@ async function updateSettings() {
async function fetchDevices() {
try {
- const response = await fetch('/setup/devices');
+ const response = await fetch('/devices');
const devices = await response.json();
+ window._knownDevices = devices; // Store globally for easy lookup
const container = document.getElementById('device-list');
const syncSelector = document.getElementById('sync-device-list');
const migrationSelector = document.getElementById('migration-device-list');
@@ -134,20 +135,20 @@ async function fetchDevices() {
${d.firmware_version || '0.0.0'}
${d.device_serial_number}
${methodLabel}
- Sync Data
- Migrate
+ Sync Data
+ Migrate
Remove
`;
const optSync = document.createElement('option');
- optSync.value = d.ip_address;
+ optSync.value = d.device_id;
optSync.textContent = `${d.name} (${d.ip_address})`;
syncSelector.appendChild(optSync);
const optMigrate = document.createElement('option');
- optMigrate.value = d.ip_address;
+ optMigrate.value = d.device_id;
optMigrate.textContent = `${d.name} (${d.ip_address})`;
migrationSelector.appendChild(optMigrate);
@@ -166,22 +167,22 @@ async function fetchDevices() {
if (eventSelector && currentEventVal) eventSelector.value = currentEventVal;
// Asynchronously fetch live info for each device
- devices.forEach(d => updateDeviceInfo(d.ip_address));
+ devices.forEach(d => updateDeviceInfo(d.device_id));
}
} catch (error) {
document.getElementById('device-list').innerHTML = 'Error loading devices: ' + error;
}
}
-function prepareSync(ip) {
- document.getElementById('sync-device-list').value = ip;
+function prepareSync(deviceId) {
+ document.getElementById('sync-device-list').value = deviceId;
openTab(null, 'tab-sync');
}
-function prepareMigration(ip) {
- document.getElementById('migration-device-list').value = ip;
+function prepareMigration(deviceId) {
+ document.getElementById('migration-device-list').value = deviceId;
openTab(null, 'tab-migration');
- showSummary(ip);
+ showSummary(deviceId);
}
function openTab(evt, tabId) {
@@ -220,25 +221,48 @@ function openTab(evt, tabId) {
}
}
+function getDeviceLabel(deviceId) {
+ if (window._knownDevices) {
+ const d = window._knownDevices.find(dev => dev.device_id === deviceId);
+ if (d) {
+ return `${d.name} (${d.ip_address})`;
+ }
+ }
+ // Fallback to searching the UI
+ const rows = document.querySelectorAll('#device-list tr');
+ for (let r of rows) {
+ const deviceIdCol = r.querySelector('.col-deviceid');
+ if (deviceIdCol && deviceIdCol.innerText === deviceId) {
+ const nameEl = r.querySelector('.col-name');
+ const ipEl = r.querySelector('.col-ip');
+ if (nameEl && ipEl) {
+ return `${nameEl.innerText} (${ipEl.innerText})`;
+ }
+ }
+ }
+ return deviceId;
+}
+
async function startSync() {
- const ip = document.getElementById('sync-device-list').value;
- if (!ip) {
+ const deviceId = document.getElementById('sync-device-list').value;
+ if (!deviceId) {
alert('Please select a device first');
return;
}
+ const deviceLabel = getDeviceLabel(deviceId);
const status = document.getElementById('sync-status');
const results = document.getElementById('sync-results');
const log = document.getElementById('sync-log');
status.style.display = 'block';
status.style.backgroundColor = '#eef';
- status.textContent = 'Syncing data from ' + ip + '...';
+ status.textContent = 'Syncing data from ' + deviceLabel + '...';
results.style.display = 'none';
log.innerHTML = '';
try {
- const response = await fetch('/setup/sync/' + ip, { method: 'POST' });
+ const response = await fetch('/setup/devices/' + deviceId + '/sync', { method: 'POST' });
if (response.ok) {
status.style.backgroundColor = '#dfd';
status.textContent = 'โ
Sync completed successfully!';
@@ -254,18 +278,6 @@ async function startSync() {
}
}
-async function fetchVersion() {
- try {
- const response = await fetch('/setup/version');
- const data = await response.json();
- const info = document.getElementById('version-info');
- if (info && data.version) {
- info.innerText = `AfterTouch ${data.version} (${data.commit}) - ${data.date}`;
- }
- } catch (error) {
- console.error('Failed to fetch version info', error);
- }
-}
async function fetchInteractionStats() {
console.log('Fetching interaction stats...');
@@ -605,7 +617,7 @@ async function fetchDeviceEvents(deviceId) {
list.innerHTML = 'Loading events... ';
try {
- const response = await fetch(`/setup/devices/${deviceId}/events`);
+ const response = await fetch(`/devices/${deviceId}/events`);
const data = await response.json();
const events = data.events;
@@ -657,7 +669,7 @@ async function addManualDevice() {
}
try {
- const response = await fetch('/setup/devices', {
+ const response = await fetch('/devices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ip: ip })
@@ -681,7 +693,7 @@ async function removeDevice(deviceId, name) {
}
try {
- const response = await fetch(`/setup/devices/${deviceId}`, {
+ const response = await fetch(`/devices/${deviceId}`, {
method: 'DELETE'
});
@@ -725,14 +737,23 @@ async function pollDiscoveryStatus() {
}
}
-async function updateDeviceInfo(ip) {
+async function updateDeviceInfo(deviceId) {
try {
- const response = await fetch('/setup/info/' + ip);
+ const response = await fetch('/devices/' + deviceId + '/info');
if (!response.ok) return;
const info = await response.json();
- const rowId = 'device-row-' + ip.replace(/\./g, '-');
- const row = document.getElementById(rowId);
+ // Find the row by deviceId
+ const rows = document.querySelectorAll('#device-list tr');
+ let row = null;
+ for (let r of rows) {
+ const deviceIdCol = r.querySelector('.col-deviceid');
+ if (deviceIdCol && deviceIdCol.innerText === deviceId) {
+ row = r;
+ break;
+ }
+ }
+
if (row) {
const nameEl = row.querySelector('.col-name');
if (nameEl && info.name) nameEl.innerText = info.name;
@@ -753,12 +774,12 @@ async function updateDeviceInfo(ip) {
if (accountIdEl && info.margeAccountUUID) accountIdEl.innerText = info.margeAccountUUID;
}
} catch (error) {
- console.warn('Failed to fetch live info for ' + ip, error);
+ console.warn('Failed to fetch live info for ' + deviceId, error);
}
}
-async function showSummary(ip) {
- if (!ip) {
+async function showSummary(deviceId) {
+ if (!deviceId) {
document.getElementById('migration-summary').style.display = 'none';
return;
}
@@ -772,10 +793,11 @@ async function showSummary(ip) {
bmx: document.getElementById('opt-bmx').value
};
+ const deviceLabel = getDeviceLabel(deviceId);
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
- statusDiv.innerHTML = 'Fetching summary for ' + ip + '...';
+ statusDiv.innerHTML = 'Fetching summary for ' + deviceLabel + '...';
let query = '?target_url=' + encodeURIComponent(targetUrl) + '&proxy_url=' + encodeURIComponent(proxyUrl);
for (let k in opts) {
@@ -786,7 +808,7 @@ async function showSummary(ip) {
if (outputBox) outputBox.style.display = 'none';
try {
- const response = await fetch('/setup/summary/' + ip + query);
+ const response = await fetch('/setup/devices/' + deviceId + '/summary' + query);
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText);
@@ -794,11 +816,18 @@ async function showSummary(ip) {
const summary = await response.json();
statusDiv.style.display = 'none';
- document.getElementById('summary-ip').innerText = ip;
+ document.getElementById('summary-ip').innerText = summary.device_id || deviceId;
- // Update table row if it exists
- const rowId = 'device-row-' + ip.replace(/\./g, '-');
- const row = document.getElementById(rowId);
+ // Find the row by deviceId
+ const rows = document.querySelectorAll('#device-list tr');
+ let row = null;
+ for (let r of rows) {
+ const deviceIdCol = r.querySelector('.col-deviceid');
+ if (deviceIdCol && deviceIdCol.innerText === deviceId) {
+ row = r;
+ break;
+ }
+ }
if (row) {
const nameEl = row.querySelector('.col-name');
if (nameEl && summary.device_name) nameEl.innerText = summary.device_name;
@@ -860,7 +889,7 @@ async function showSummary(ip) {
caTrustStatus.innerText = summary.ca_cert_trusted ? 'โ
Yes' : 'โ No';
caTrustStatus.style.color = summary.ca_cert_trusted ? 'green' : 'red';
document.getElementById('trust-ca-btn').style.display = summary.ca_cert_trusted ? 'none' : 'inline-block';
- document.getElementById('trust-ca-btn').onclick = () => trustCA(ip);
+ document.getElementById('trust-ca-btn').onclick = () => trustCA(deviceId);
} else {
remoteStatus.innerText = 'โ Unknown';
remoteStatus.style.color = 'gray';
@@ -890,51 +919,51 @@ async function showSummary(ip) {
testResultDiv.style.display = 'none';
testResultDiv.innerText = '';
- document.getElementById('test-connection-explicit-btn').onclick = () => testConnection(ip, true);
- document.getElementById('test-connection-trusted-btn').onclick = () => testConnection(ip, false);
- document.getElementById('test-hosts-btn').onclick = () => testHostsRedirection(ip);
- document.getElementById('test-dns-btn').onclick = () => testDNSRedirection(ip);
+ document.getElementById('test-connection-explicit-btn').onclick = () => testConnection(deviceId, true);
+ document.getElementById('test-connection-trusted-btn').onclick = () => testConnection(deviceId, false);
+ document.getElementById('test-hosts-btn').onclick = () => testHostsRedirection(deviceId);
+ document.getElementById('test-dns-btn').onclick = () => testDNSRedirection(deviceId);
toggleMigrationMethod();
const migrateBtn = document.getElementById('confirm-migrate-btn');
- migrateBtn.onclick = () => migrate(ip);
+ migrateBtn.onclick = () => migrate(deviceId);
migrateBtn.disabled = !summary.ssh_success;
const revertBtn = document.getElementById('revert-migrate-btn');
- revertBtn.onclick = () => revert(ip);
+ revertBtn.onclick = () => revert(deviceId);
revertBtn.disabled = !summary.ssh_success;
revertBtn.style.display = summary.original_config ? 'inline-block' : 'none';
const rebootBtn = document.getElementById('reboot-speaker-btn');
- rebootBtn.onclick = () => reboot(ip);
+ rebootBtn.onclick = () => reboot(deviceId);
rebootBtn.disabled = !summary.ssh_success;
rebootBtn.style.border = 'none'; // Reset border if it was set during migration
const remoteBtn = document.getElementById('ensure-remote-btn');
- remoteBtn.onclick = () => ensureRemoteServices(ip);
+ remoteBtn.onclick = () => ensureRemoteServices(deviceId);
remoteBtn.disabled = !summary.ssh_success;
const removeRemoteBtn = document.getElementById('remove-remote-btn');
- removeRemoteBtn.onclick = () => removeRemoteServices(ip);
+ removeRemoteBtn.onclick = () => removeRemoteServices(deviceId);
removeRemoteBtn.disabled = !summary.ssh_success || !summary.remote_services_enabled;
const backupBtn = document.getElementById('backup-config-btn');
- backupBtn.onclick = () => backupConfig(ip);
+ backupBtn.onclick = () => backupConfig(deviceId);
backupBtn.disabled = !summary.ssh_success || !!summary.original_config;
document.getElementById('migration-summary').style.display = 'block';
document.getElementById('migration-summary').scrollIntoView();
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
- statusDiv.innerHTML = 'Error fetching summary for ' + ip + ': ' + error;
+ statusDiv.innerHTML = 'Error fetching summary for ' + deviceId + ': ' + error;
}
}
function refreshSummary() {
- const ip = document.getElementById('summary-ip').innerText;
- if (ip) {
- showSummary(ip);
+ const deviceId = document.getElementById('summary-ip').innerText;
+ if (deviceId) {
+ showSummary(deviceId);
}
}
@@ -949,12 +978,13 @@ function showCommandOutput(result) {
}
}
-async function revert(ip) {
- if (!ip) {
- alert('Please enter a valid IP address.');
+async function revert(deviceId) {
+ if (!deviceId) {
+ alert('Please select a device.');
return;
}
- if (!confirm('Are you sure you want to revert ' + ip + ' to Bose cloud defaults?')) {
+ const deviceLabel = getDeviceLabel(deviceId);
+ if (!confirm('Are you sure you want to revert ' + deviceLabel + ' to Bose cloud defaults?')) {
return;
}
@@ -964,61 +994,63 @@ async function revert(ip) {
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
- statusDiv.innerHTML = 'Reverting ' + ip + ' to defaults...';
+ statusDiv.innerHTML = 'Reverting ' + deviceLabel + ' to defaults...';
try {
- const response = await fetch('/setup/revert/' + ip, { method: 'POST' });
+ const response = await fetch('/setup/devices/' + deviceId + '/revert', { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
- statusDiv.innerHTML = 'Successfully started revert for ' + ip + '.';
+ statusDiv.innerHTML = 'Successfully started revert for ' + deviceLabel + '.';
} else {
statusDiv.style.backgroundColor = '#ffcccc';
- statusDiv.innerHTML = 'Revert failed for ' + ip + ': ' + (result.message || 'Unknown error');
+ statusDiv.innerHTML = 'Revert failed for ' + deviceLabel + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
- statusDiv.innerHTML = 'Error reverting ' + ip + ': ' + error;
+ statusDiv.innerHTML = 'Error reverting ' + deviceLabel + ': ' + error;
}
}
-async function reboot(ip) {
- if (!ip) {
- alert('Please enter a valid IP address.');
+async function reboot(deviceId) {
+ if (!deviceId) {
+ alert('Please select a device.');
return;
}
- if (!confirm('Are you sure you want to reboot the speaker at ' + ip + '?')) {
+ const deviceLabel = getDeviceLabel(deviceId);
+ if (!confirm('Are you sure you want to reboot the speaker ' + deviceLabel + '?')) {
return;
}
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
- statusDiv.innerHTML = 'Rebooting ' + ip + '...';
+ statusDiv.innerHTML = 'Rebooting ' + deviceLabel + '...';
try {
- const response = await fetch('/setup/reboot/' + ip, { method: 'POST' });
+ const response = await fetch('/devices/' + deviceId + '/reboot', { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
- statusDiv.innerHTML = 'Successfully started reboot for ' + ip + '.';
+ statusDiv.innerHTML = 'Successfully started reboot for ' + deviceLabel + '.';
} else {
statusDiv.style.backgroundColor = '#ffcccc';
- statusDiv.innerHTML = 'Reboot failed for ' + ip + ': ' + (result.message || 'Unknown error');
+ statusDiv.innerHTML = 'Reboot failed for ' + deviceLabel + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
- statusDiv.innerHTML = 'Error rebooting ' + ip + ': ' + error;
+ statusDiv.innerHTML = 'Error rebooting ' + deviceLabel + ': ' + error;
}
}
-async function migrate(ip) {
- if (!ip) {
- alert('Please enter a valid IP address.');
+async function migrate(deviceId) {
+ if (!deviceId) {
+ alert('Please select a device.');
return;
}
+ const deviceLabel = getDeviceLabel(deviceId);
const targetUrl = document.getElementById('target-domain').value;
const proxyUrl = document.getElementById('soundcork-url').value;
const method = document.getElementById('migration-method').value;
@@ -1036,7 +1068,7 @@ async function migrate(ip) {
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
- statusDiv.innerHTML = 'Migrating ' + ip + ' using ' + method + '...';
+ statusDiv.innerHTML = 'Migrating ' + deviceLabel + ' using ' + method + '...';
let query = '?method=' + encodeURIComponent(method) + '&target_url=' + encodeURIComponent(targetUrl) + '&proxy_url=' + encodeURIComponent(proxyUrl);
for (let k in opts) {
@@ -1044,62 +1076,99 @@ async function migrate(ip) {
}
try {
- const response = await fetch('/setup/migrate/' + ip + query, { method: 'POST' });
+ const response = await fetch('/setup/devices/' + deviceId + '/migrate' + query, { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
- statusDiv.innerHTML = 'Successfully started migration for ' + ip + '. Please reboot the device to activate the changes. ';
+ statusDiv.innerHTML = 'Successfully started migration for ' + deviceLabel + '. Please reboot the device to activate the changes. ';
// Make reboot button available and prominent
const rebootBtn = document.getElementById('reboot-speaker-btn');
rebootBtn.style.display = 'inline-block';
rebootBtn.disabled = false;
rebootBtn.style.border = '2px solid #000';
+ rebootBtn.onclick = () => reboot(deviceId);
// Re-show summary but with prominence on reboot
summaryDiv.style.display = 'block';
} else {
statusDiv.style.backgroundColor = '#ffcccc';
- statusDiv.innerHTML = 'Migration failed for ' + ip + ': ' + (result.message || 'Unknown error');
+ statusDiv.innerHTML = 'Migration failed for ' + deviceLabel + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
- statusDiv.innerHTML = 'Error migrating ' + ip + ': ' + error;
+ statusDiv.innerHTML = 'Error migrating ' + deviceLabel + ': ' + error;
}
}
-async function trustCA(ip) {
- if (!ip) {
- alert('Please enter a valid IP address.');
+async function trustCA(deviceId) {
+ if (!deviceId) {
+ alert('Please select a device.');
return;
}
+ const deviceLabel = getDeviceLabel(deviceId);
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
- statusDiv.innerHTML = 'Injecting Root CA into shared trust store on ' + ip + '...';
+ statusDiv.innerHTML = 'Injecting Root CA into shared trust store on ' + deviceLabel + '...';
try {
- const response = await fetch('/setup/trust-ca/' + ip, { method: 'POST' });
+ const response = await fetch('/setup/devices/' + deviceId + '/trust-ca', { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
- statusDiv.innerHTML = 'Successfully injected Root CA on ' + ip + '.';
- showSummary(ip); // Refresh to update status
+ statusDiv.innerHTML = 'Successfully injected Root CA on ' + deviceLabel + '.';
+ showSummary(deviceId); // Refresh to update status
} else {
statusDiv.style.backgroundColor = '#ffcccc';
- statusDiv.innerHTML = 'Failed to trust CA on ' + ip + ': ' + (result.message || 'Unknown error');
+ statusDiv.innerHTML = 'Failed to trust CA on ' + deviceLabel + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
- statusDiv.innerHTML = 'Error trusting CA on ' + ip + ': ' + error;
+ statusDiv.innerHTML = 'Error trusting CA on ' + deviceLabel + ': ' + error;
}
}
-async function ensureRemoteServices(ip) {
- if (!ip) {
- alert('Please enter a valid IP address.');
+async function ensureRemoteServices(deviceId) {
+ if (!deviceId) {
+ alert('Please select a device.');
+ return;
+ }
+ const deviceLabel = getDeviceLabel(deviceId);
+ const summaryDiv = document.getElementById('migration-summary');
+ summaryDiv.style.display = 'none';
+
+ const statusDiv = document.getElementById('status');
+ statusDiv.style.display = 'block';
+ statusDiv.style.backgroundColor = '#ffffcc';
+ statusDiv.innerHTML = 'Ensuring remote services for ' + deviceLabel + '...';
+
+ try {
+ const response = await fetch('/setup/devices/' + deviceId + '/ensure-remote-services', { method: 'POST' });
+ const result = await response.json();
+ showCommandOutput(result);
+ if (result.ok) {
+ statusDiv.style.backgroundColor = '#ccffcc';
+ statusDiv.innerHTML = 'Successfully ensured remote services for ' + deviceLabel + '.';
+ } else {
+ statusDiv.style.backgroundColor = '#ffcccc';
+ statusDiv.innerHTML = 'Failed to ensure remote services for ' + deviceLabel + ': ' + (result.message || 'Unknown error');
+ }
+ } catch (error) {
+ statusDiv.style.backgroundColor = '#ffcccc';
+ statusDiv.innerHTML = 'Error ensuring remote services for ' + deviceLabel + ': ' + error;
+ }
+}
+
+async function removeRemoteServices(deviceId) {
+ if (!deviceId) {
+ alert('Please select a device.');
+ return;
+ }
+ const deviceLabel = getDeviceLabel(deviceId);
+ if (!confirm('Are you sure you want to remove remote services from ' + deviceLabel + '?')) {
return;
}
const summaryDiv = document.getElementById('migration-summary');
@@ -1108,98 +1177,68 @@ async function ensureRemoteServices(ip) {
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
- statusDiv.innerHTML = 'Ensuring remote services for ' + ip + '...';
+ statusDiv.innerHTML = 'Removing remote services for ' + deviceLabel + '...';
try {
- const response = await fetch('/setup/ensure-remote-services/' + ip, { method: 'POST' });
+ const response = await fetch('/setup/devices/' + deviceId + '/remove-remote-services', { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
- statusDiv.innerHTML = 'Successfully ensured remote services for ' + ip + '.';
+ statusDiv.innerHTML = 'Successfully removed remote services from ' + deviceLabel + '.';
} else {
statusDiv.style.backgroundColor = '#ffcccc';
- statusDiv.innerHTML = 'Failed to ensure remote services for ' + ip + ': ' + (result.message || 'Unknown error');
+ statusDiv.innerHTML = 'Failed to remove remote services for ' + deviceLabel + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
- statusDiv.innerHTML = 'Error ensuring remote services for ' + ip + ': ' + error;
+ statusDiv.innerHTML = 'Error removing remote services for ' + deviceLabel + ': ' + error;
}
}
-async function removeRemoteServices(ip) {
- if (!ip) {
- alert('Please enter a valid IP address.');
+async function backupConfig(deviceId) {
+ if (!deviceId) {
+ alert('Please select a device.');
return;
}
- if (!confirm('Are you sure you want to remove remote services from ' + ip + '?')) {
- return;
- }
- const summaryDiv = document.getElementById('migration-summary');
- summaryDiv.style.display = 'none';
-
+ const deviceLabel = getDeviceLabel(deviceId);
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
- statusDiv.innerHTML = 'Removing remote services for ' + ip + '...';
+ statusDiv.innerHTML = 'Creating backup for ' + deviceLabel + '...';
try {
- const response = await fetch('/setup/remove-remote-services/' + ip, { method: 'POST' });
+ const response = await fetch('/setup/devices/' + deviceId + '/backup', { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
- statusDiv.innerHTML = 'Successfully removed remote services from ' + ip + '.';
+ statusDiv.innerHTML = 'Successfully created backup for ' + deviceLabel + '.';
+ showSummary(deviceId); // Refresh
} else {
statusDiv.style.backgroundColor = '#ffcccc';
- statusDiv.innerHTML = 'Failed to remove remote services for ' + ip + ': ' + (result.message || 'Unknown error');
+ statusDiv.innerHTML = 'Failed to create backup for ' + deviceLabel + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
- statusDiv.innerHTML = 'Error removing remote services for ' + ip + ': ' + error;
+ statusDiv.innerHTML = 'Error creating backup for ' + deviceLabel + ': ' + error;
}
}
-async function backupConfig(ip) {
- if (!ip) {
- alert('Please enter a valid IP address.');
- return;
- }
- const statusDiv = document.getElementById('status');
- statusDiv.style.display = 'block';
- statusDiv.style.backgroundColor = '#ffffcc';
- statusDiv.innerHTML = 'Creating backup for ' + ip + '...';
-
- try {
- const response = await fetch('/setup/backup/' + ip, { method: 'POST' });
- const result = await response.json();
- showCommandOutput(result);
- if (result.ok) {
- statusDiv.style.backgroundColor = '#ccffcc';
- statusDiv.innerHTML = 'Successfully created backup for ' + ip + '.';
- showSummary(ip); // Refresh
- } else {
- statusDiv.style.backgroundColor = '#ffcccc';
- statusDiv.innerHTML = 'Backup failed for ' + ip + ': ' + (result.message || 'Unknown error');
- }
- } catch (error) {
- statusDiv.style.backgroundColor = '#ffcccc';
- statusDiv.innerHTML = 'Error creating backup for ' + ip + ': ' + error;
- }
-}
-
-async function testConnection(ip, useExplicitCA) {
+async function testConnection(deviceId, useExplicitCA) {
const testUrl = document.getElementById('test-url').innerText;
const testResultDiv = document.getElementById('test-result');
+ const deviceLabel = getDeviceLabel(deviceId);
+
testResultDiv.style.display = 'block';
testResultDiv.style.backgroundColor = '#f0f0f0';
testResultDiv.style.color = 'black';
- testResultDiv.innerText = 'Running connection test from ' + ip + '...\n(This may take a few seconds)';
+ testResultDiv.innerText = 'Running connection test from ' + deviceLabel + '...\n(This may take a few seconds)';
try {
const query = `?target_url=${encodeURIComponent(testUrl)}&use_explicit_ca=${useExplicitCA}`;
- const response = await fetch(`/setup/test-connection/${ip}${query}`, { method: 'POST' });
+ const response = await fetch(`/setup/devices/${deviceId}/test-connection${query}`, { method: 'POST' });
const result = await response.json();
if (result.ok) {
@@ -1215,18 +1254,20 @@ async function testConnection(ip, useExplicitCA) {
}
}
-async function testHostsRedirection(ip) {
+async function testHostsRedirection(deviceId) {
const targetUrl = document.getElementById('target-domain').value;
const testResultDiv = document.getElementById('hosts-test-result');
+ const deviceLabel = getDeviceLabel(deviceId);
+
testResultDiv.style.display = 'block';
testResultDiv.style.backgroundColor = '#f0f0f0';
testResultDiv.style.color = 'black';
- testResultDiv.innerText = 'Running hosts redirection test from ' + ip + '...\n(This may take a few seconds)';
+ testResultDiv.innerText = 'Running hosts redirection test from ' + deviceLabel + '...\n(This may take a few seconds)';
try {
const query = `?target_url=${encodeURIComponent(targetUrl)}`;
- const response = await fetch(`/setup/test-hosts/${ip}${query}`, { method: 'POST' });
+ const response = await fetch(`/setup/devices/${deviceId}/test-hosts${query}`, { method: 'POST' });
const result = await response.json();
if (result.ok) {
@@ -1242,18 +1283,20 @@ async function testHostsRedirection(ip) {
}
}
-async function testDNSRedirection(ip) {
+async function testDNSRedirection(deviceId) {
const targetUrl = document.getElementById('target-domain').value;
const testResultDiv = document.getElementById('dns-test-result');
+ const deviceLabel = getDeviceLabel(deviceId);
+
testResultDiv.style.display = 'block';
testResultDiv.style.backgroundColor = '#f0f0f0';
testResultDiv.style.color = 'black';
- testResultDiv.innerText = 'Running DNS redirection test from ' + ip + '...\n(This may take a few seconds)';
+ testResultDiv.innerText = 'Running DNS redirection test from ' + deviceLabel + '...\n(This may take a few seconds)';
try {
const query = `?target_url=${encodeURIComponent(targetUrl)}`;
- const response = await fetch(`/setup/test-dns/${ip}${query}`, { method: 'POST' });
+ const response = await fetch(`/setup/devices/${deviceId}/test-dns${query}`, { method: 'POST' });
const result = await response.json();
if (result.ok) {
diff --git a/pkg/service/handlers/web/css/style.css b/pkg/service/handlers/web/migration/style.css
similarity index 100%
rename from pkg/service/handlers/web/css/style.css
rename to pkg/service/handlers/web/migration/style.css
diff --git a/pkg/service/handlers/web/shared/common.css b/pkg/service/handlers/web/shared/common.css
new file mode 100644
index 0000000..960beb7
--- /dev/null
+++ b/pkg/service/handlers/web/shared/common.css
@@ -0,0 +1,16 @@
+footer {
+ margin-top: 50px;
+ padding: 20px;
+ font-size: 0.8em;
+ color: #666;
+ text-align: center;
+}
+
+#version-info a {
+ color: inherit;
+ text-decoration: none;
+}
+
+#version-info a:hover {
+ text-decoration: underline;
+}
diff --git a/pkg/service/handlers/web/shared/common.js b/pkg/service/handlers/web/shared/common.js
new file mode 100644
index 0000000..926b65e
--- /dev/null
+++ b/pkg/service/handlers/web/shared/common.js
@@ -0,0 +1,23 @@
+async function fetchVersion() {
+ try {
+ const response = await fetch('/version');
+ const data = await response.json();
+ const info = document.getElementById('version-info');
+ if (info && data.version) {
+ const version = data.version;
+ const commit = data.commit;
+ const isDirty = version.includes('dirty');
+ const releaseUrl = isDirty
+ ? 'https://github.com/gesellix/Bose-SoundTouch/releases'
+ : `https://github.com/gesellix/Bose-SoundTouch/releases/tag/v${version}`;
+ const commitUrl = `https://github.com/gesellix/Bose-SoundTouch/commit/${commit}`;
+ const projectUrl = 'https://gesellix.github.io/Bose-SoundTouch/';
+
+ info.innerHTML = `AfterTouch ` +
+ `${version} ` +
+ `(${commit} ) - ${data.date}`;
+ }
+ } catch (error) {
+ console.error('Failed to fetch version info', error);
+ }
+}
diff --git a/pkg/service/handlers/web/stockholm-mini/app.js b/pkg/service/handlers/web/stockholm-mini/app.js
new file mode 100644
index 0000000..e8d1197
--- /dev/null
+++ b/pkg/service/handlers/web/stockholm-mini/app.js
@@ -0,0 +1,456 @@
+async function fetchDevices() {
+ try {
+ const response = await fetch('/devices');
+ const devices = await response.json();
+ const container = document.getElementById('device-list');
+ const seen = new Set();
+
+ if (devices.length === 0) {
+ container.innerHTML = 'No devices found. Ensure they are on the same network.
';
+ return;
+ }
+
+ devices.forEach(device => {
+ seen.add(device.device_id);
+ const existing = document.getElementById(`device-${device.device_id}`);
+ if (existing) {
+ // Update product code/IP if changed, but keep title if we already have a better name
+ const title = existing.querySelector('.device-title');
+ if (title && (!title.textContent || title.textContent === 'Unknown Device' || title.textContent.startsWith('SoundTouch-'))) {
+ title.textContent = device.name || 'Unknown Device';
+ }
+ const subtitle = existing.querySelector('.device-subtitle span');
+ if (subtitle) {
+ const currentSubtitle = subtitle.textContent || '';
+ const parts = currentSubtitle.split(' | ');
+ const currentType = parts.length > 1 ? parts[1].trim() : '';
+ const newType = device.product_code || 'Unknown';
+
+ // Don't downgrade type if we already have a specific one
+ const isGeneric = !currentType || currentType === 'Unknown' || currentType === 'N/A';
+ const displayType = isGeneric ? newType : currentType;
+ subtitle.textContent = `${device.ip_address} | ${displayType}`;
+ }
+ const details = existing.querySelector(`#details-${device.device_id}`);
+ if (details) {
+ const idField = details.querySelector('p:nth-child(1) code');
+ if (idField) {
+ const currentId = idField.textContent;
+ // Don't overwrite with serial if we have a real deviceID (usually hex)
+ if (!currentId || currentId === 'N/A' || currentId === device.device_serial_number) {
+ idField.textContent = device.device_id || 'N/A';
+ }
+ }
+ const firmwareField = details.querySelector('p:nth-child(2) code');
+ if (firmwareField) {
+ const cur = firmwareField.textContent;
+ if (!cur || cur === 'N/A' || cur === '0.0.0') {
+ firmwareField.textContent = device.firmware_version || 'N/A';
+ }
+ }
+ const serialField = details.querySelector('p:nth-child(3) code');
+ if (serialField && (!serialField.textContent || serialField.textContent === 'N/A')) {
+ serialField.textContent = device.device_serial_number || 'N/A';
+ }
+ }
+ // Ensure WS is open
+ openDeviceWebSocket(device.device_id);
+ return;
+ }
+
+ const card = document.createElement('div');
+ card.className = 'device-card';
+ card.id = `device-${device.device_id}`;
+ card.innerHTML = `
+
+
+
+
ID: ${device.device_id}
+
Firmware: ${device.firmware_version || 'N/A'}
+
Serial: ${device.device_serial_number || 'N/A'}
+
Discovery: ${device.discovery_method || 'N/A'}
+
+
+
+
Loading playback status...
+
+
+ Play
+ Pause
+ Prev
+ Next
+
+
+ Vol:
+
+
+ `;
+ container.appendChild(card);
+ updateNowPlaying(device.device_id);
+ updateVolume(device.device_id);
+ openDeviceWebSocket(device.device_id);
+ });
+
+ // Remove cards for devices that no longer exist
+ Array.from(container.children).forEach(child => {
+ const id = child.id?.replace('device-', '');
+ if (id && !seen.has(id)) {
+ container.removeChild(child);
+ }
+ });
+ } catch (error) {
+ console.error('Failed to fetch devices', error);
+ document.getElementById('device-list').innerHTML = 'Error loading devices.
';
+ }
+}
+
+async function updateNowPlaying(deviceId) {
+ try {
+ const response = await fetch(`/devices/${deviceId}/info`);
+ if (!response.ok) return;
+ const info = await response.json();
+
+ // Update device name and type if available (live info is more accurate than discovery)
+ const title = document.querySelector(`#device-${deviceId} .device-title`);
+ if (title && info.name) {
+ title.textContent = info.name;
+ }
+ const subtitle = document.querySelector(`#device-${deviceId} .device-subtitle span`);
+ if (subtitle && info.type) {
+ subtitle.textContent = `${info.ipAddress || info.ip_address || 'N/A'} | ${info.type}`;
+ }
+
+ // Update firmware version if available
+ const details = document.getElementById(`details-${deviceId}`);
+ if (details) {
+ if (info.deviceID) {
+ const idField = details.querySelector('p:nth-child(1) code');
+ if (idField) idField.textContent = info.deviceID;
+ }
+ if (info.softwareVersion) {
+ const firmwareField = details.querySelector('p:nth-child(2) code');
+ if (firmwareField) firmwareField.textContent = info.softwareVersion;
+ }
+ if (info.serialNumber) {
+ const serialField = details.querySelector('p:nth-child(3) code');
+ if (serialField) serialField.textContent = info.serialNumber;
+ }
+ }
+
+ const npContainer = document.getElementById(`np-${deviceId}`);
+ if (npContainer && info.nowPlaying) {
+ const np = info.nowPlaying;
+ const source = np.source || np.Source;
+ const powerIcon = document.querySelector(`#device-${deviceId} .power-icon`);
+ if (powerIcon) {
+ if (source === 'STANDBY') {
+ powerIcon.classList.add('off');
+ powerIcon.classList.remove('on');
+ } else {
+ powerIcon.classList.remove('off');
+ powerIcon.classList.add('on');
+ }
+ }
+ if (source === 'STANDBY') {
+ npContainer.innerHTML = '';
+ } else {
+ const track = np.track || np.Track || np.stationName || np.StationName || 'Unknown Track';
+ const artist = np.artist || np.Artist || 'Unknown Artist';
+ const album = np.album || np.Album || 'Unknown Album';
+ const art = np.Art || np.art || {};
+ const artStatus = art.ArtImageStatus || art.artImageStatus;
+ const artUrl = artStatus === 'IMAGE_PRESENT' ? (art.URL || art.url || '') : '';
+
+ npContainer.innerHTML = `
+
+
+ ${track}
+ ${artist} - ${album}
+
+ `;
+ }
+ }
+ } catch (error) {
+ console.warn('Failed to fetch now playing for ' + deviceId, error);
+ }
+}
+
+async function updateVolume(deviceId) {
+ try {
+ const response = await fetch(`/devices/${deviceId}/info`);
+ if (!response.ok) return;
+ const info = await response.json();
+ const slider = document.getElementById(`vol-${deviceId}`);
+ if (slider && info.volume && typeof info.volume.actualvolume === 'number' && !adjusting[deviceId]) {
+ slider.value = String(info.volume.actualvolume);
+ }
+ } catch (error) {
+ console.warn('Failed to fetch volume for ' + deviceId, error);
+ }
+}
+
+async function control(deviceId, key) {
+ let deviceName = deviceId;
+ const title = document.querySelector(`#device-${deviceId} .device-title`);
+ if (title && title.textContent) {
+ deviceName = title.textContent;
+ }
+
+ try {
+ const res = await fetch(`/devices/${encodeURIComponent(deviceId)}/key/${encodeURIComponent(key)}`, {
+ method: 'POST'
+ });
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(text || `HTTP ${res.status}`);
+ }
+ } catch (error) {
+ console.error('Control failed', error);
+ alert(`Failed to send ${key} to ${deviceName}: ${error.message}`);
+ }
+}
+
+async function setVolume(deviceId, level) {
+ let deviceName = deviceId;
+ const title = document.querySelector(`#device-${deviceId} .device-title`);
+ if (title && title.textContent) {
+ deviceName = title.textContent;
+ }
+
+ try {
+ const res = await fetch(`/devices/${encodeURIComponent(deviceId)}/volume/${encodeURIComponent(level)}`, {
+ method: 'POST'
+ });
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(text || `HTTP ${res.status}`);
+ }
+ } catch (error) {
+ console.error('Set volume failed', error);
+ alert(`Failed to set volume on ${deviceName} to ${level}: ${error.message}`);
+ }
+}
+
+// Volume interaction helpers to avoid UI jumping while dragging
+const adjusting = {};
+const volumeTimers = {};
+
+function startAdjust(deviceId) {
+ adjusting[deviceId] = true;
+}
+
+function endAdjust(deviceId) {
+ // Small delay to let the device send back its volume update
+ setTimeout(() => { adjusting[deviceId] = false; }, 300);
+}
+
+function onVolumeInput(deviceId, el) {
+ startAdjust(deviceId);
+ const level = el.value;
+ // Debounce network calls per device
+ if (volumeTimers[deviceId]) {
+ clearTimeout(volumeTimers[deviceId]);
+ }
+ volumeTimers[deviceId] = setTimeout(() => {
+ setVolume(deviceId, level);
+ endAdjust(deviceId);
+ }, 150);
+}
+
+let deviceSockets = {};
+
+function openDeviceWebSocket(deviceId) {
+ const key = `${deviceId}`;
+ try {
+ const existing = deviceSockets[key];
+ if (existing) {
+ // Reuse an already healthy connection instead of tearing it down every refresh
+ if (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING) {
+ return;
+ }
+ try { existing.close(); } catch (_) {}
+ }
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws';
+ const wsUrl = `${proto}://${location.host}/devices/${encodeURIComponent(deviceId)}/ws`;
+ const ws = new WebSocket(wsUrl);
+ deviceSockets[key] = ws;
+
+ ws.onopen = () => {
+ // console.log('WS connected for', deviceId);
+ };
+ ws.onmessage = (ev) => {
+ try {
+ const msg = JSON.parse(ev.data);
+ const type = msg.type;
+ const payload = msg.payload || {};
+ if (type === 'nowPlayingUpdated') {
+ const e = payload;
+ const np = e.NowPlaying || e.nowPlaying || {};
+ const source = np.source || np.Source;
+
+ // Also try to update name/type if they are present in the event (sometimes events carry device info)
+ const title = document.querySelector(`#device-${deviceId} .device-title`);
+ if (title && e.name) {
+ title.textContent = e.name;
+ }
+ const subtitle = document.querySelector(`#device-${deviceId} .device-subtitle span`);
+ if (subtitle && e.type) {
+ subtitle.textContent = `${e.ipAddress || e.ip_address || 'N/A'} | ${e.type}`;
+ }
+
+ const powerIcon = document.querySelector(`#device-${deviceId} .power-icon`);
+ if (powerIcon) {
+ if (source === 'STANDBY') {
+ powerIcon.classList.add('off');
+ powerIcon.classList.remove('on');
+ } else {
+ powerIcon.classList.remove('off');
+ powerIcon.classList.add('on');
+ }
+ }
+ const npContainer = document.getElementById(`np-${deviceId}`);
+ if (npContainer) {
+ if (source === 'STANDBY') {
+ npContainer.innerHTML = '';
+ } else {
+ const track = np.track || np.Track || np.stationName || np.StationName || 'Unknown Track';
+ const artist = np.artist || np.Artist || 'Unknown Artist';
+ const album = np.album || np.Album || 'Unknown Album';
+ const art = np.Art || np.art || {};
+ const artStatus = art.ArtImageStatus || art.artImageStatus;
+ const artUrl = artStatus === 'IMAGE_PRESENT' ? (art.URL || art.url || '') : '';
+
+ npContainer.innerHTML = `
+
+
+ ${track}
+ ${artist} - ${album}
+
+ `;
+ }
+ }
+ } else if (type === 'volumeUpdated') {
+ const e = payload;
+ const vol = (e.Volume && (typeof e.Volume.actualvolume === 'number' ? e.Volume.actualvolume : (typeof e.Volume.actual === 'number' ? e.Volume.actual : e.Volume.target))) ||
+ (e.volume && (typeof e.volume.actualvolume === 'number' ? e.volume.actualvolume : (typeof e.volume.actual === 'number' ? e.volume.actual : e.volume.target)));
+ const slider = document.getElementById(`vol-${deviceId}`);
+ if (slider && typeof vol === 'number' && !adjusting[deviceId]) {
+ slider.value = String(vol);
+ }
+ } else if (type === 'snapshotInfo') {
+ const info = payload || {};
+
+ // Update name and type from snapshot
+ const title = document.querySelector(`#device-${deviceId} .device-title`);
+ if (title && info.name) {
+ title.textContent = info.name;
+ }
+ const subtitle = document.querySelector(`#device-${deviceId} .device-subtitle span`);
+ if (subtitle && info.type) {
+ subtitle.textContent = `${info.ipAddress || info.ip_address || 'N/A'} | ${info.type}`;
+ }
+
+ // Update firmware and ID from snapshot if available
+ const details = document.getElementById(`details-${deviceId}`);
+ if (details) {
+ if (info.deviceID) {
+ const idField = details.querySelector('p:nth-child(1) code');
+ if (idField) idField.textContent = info.deviceID;
+ }
+ if (info.softwareVersion) {
+ const firmwareField = details.querySelector('p:nth-child(2) code');
+ if (firmwareField) firmwareField.textContent = info.softwareVersion;
+ }
+ if (info.serialNumber) {
+ const serialField = details.querySelector('p:nth-child(3) code');
+ if (serialField) serialField.textContent = info.serialNumber;
+ }
+ }
+
+ if (info.nowPlaying) {
+ const np = info.nowPlaying;
+ const source = np.source || np.Source;
+ const powerIcon = document.querySelector(`#device-${deviceId} .power-icon`);
+ if (powerIcon) {
+ if (source === 'STANDBY') {
+ powerIcon.classList.add('off');
+ powerIcon.classList.remove('on');
+ } else {
+ powerIcon.classList.remove('off');
+ powerIcon.classList.add('on');
+ }
+ }
+ const npContainer = document.getElementById(`np-${deviceId}`);
+ if (npContainer) {
+ if (source === 'STANDBY') {
+ npContainer.innerHTML = '';
+ } else {
+ const track = np.track || np.Track || np.stationName || np.StationName || 'Unknown Track';
+ const artist = np.artist || np.Artist || 'Unknown Artist';
+ const album = np.album || np.Album || 'Unknown Album';
+ const art = np.Art || np.art || {};
+ const artStatus = art.ArtImageStatus || art.artImageStatus;
+ const artUrl = artStatus === 'IMAGE_PRESENT' ? (art.URL || art.url || '') : '';
+
+ npContainer.innerHTML = `
+
+
+ ${track}
+ ${artist} - ${album}
+
+ `;
+ }
+ }
+ }
+ const vol = info.actualVolume || (info.volume && (typeof info.volume.actualvolume === 'number' ? info.volume.actualvolume : (typeof info.volume.actual === 'number' ? info.volume.actual : null)));
+ const slider = document.getElementById(`vol-${deviceId}`);
+ if (slider && typeof vol === 'number' && !adjusting[deviceId]) slider.value = String(vol);
+ }
+ } catch (err) {
+ // console.warn('Bad WS message', err);
+ }
+ };
+ ws.onerror = () => {
+ // console.warn('WS error for', ip);
+ };
+ ws.onclose = () => {
+ // Try to reconnect after a delay
+ setTimeout(() => {
+ if (deviceSockets[key] === ws) {
+ delete deviceSockets[key];
+ }
+ openDeviceWebSocket(deviceId);
+ }, 3000);
+ };
+ } catch (e) {
+ // console.warn('Failed to open WS for', deviceId, e);
+ }
+}
+
+function toggleDetails(deviceId) {
+ const el = document.getElementById(`details-${deviceId}`);
+ if (el) {
+ el.classList.toggle('visible');
+ }
+}
+
+
+document.addEventListener('DOMContentLoaded', () => {
+ fetchDevices();
+ fetchVersion();
+ setInterval(fetchDevices, 30000);
+});
diff --git a/pkg/service/handlers/web/stockholm-mini/bose.ttf b/pkg/service/handlers/web/stockholm-mini/bose.ttf
new file mode 100755
index 0000000..efabc4c
Binary files /dev/null and b/pkg/service/handlers/web/stockholm-mini/bose.ttf differ
diff --git a/pkg/service/handlers/web/stockholm-mini/index.html b/pkg/service/handlers/web/stockholm-mini/index.html
new file mode 100644
index 0000000..34d1018
--- /dev/null
+++ b/pkg/service/handlers/web/stockholm-mini/index.html
@@ -0,0 +1,25 @@
+
+
+
+
+ Stockholm Mini - Reverse Engineered
+
+
+
+
+
+
Stockholm Mini
+
A minimal reverse-engineered SoundTouch controller.
+
← Back to selection
+
+
+
+
+
+
+
+
+
+
diff --git a/pkg/service/handlers/web/stockholm-mini/style.css b/pkg/service/handlers/web/stockholm-mini/style.css
new file mode 100644
index 0000000..67d0598
--- /dev/null
+++ b/pkg/service/handlers/web/stockholm-mini/style.css
@@ -0,0 +1,40 @@
+@font-face {
+ font-family: 'bose';
+ src: url('bose.ttf') format('truetype');
+ font-weight: normal;
+ font-style: normal;
+ font-display: swap;
+}
+body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: #121212; color: #e0e0e0; margin: 0; padding: 20px; }
+.container { max-width: 800px; margin: 0 auto; }
+h1 { color: #fff; border-bottom: 1px solid #333; padding-bottom: 10px; }
+.device-card { background: #1e1e1e; border-radius: 8px; padding: 20px; margin-bottom: 20px; box-shadow: 0 4px 6px rgba(0,0,0,0.3); }
+.device-info h2 { margin-top: 0; color: #00bcd4; margin-bottom: 0; }
+.device-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
+.device-title-row { display: flex; align-items: center; gap: 10px; margin-bottom: 4px; }
+.device-title { margin: 0; font-size: 1.5rem; line-height: 1.2; }
+.device-subtitle { color: #888; font-size: 0.85rem; margin: 0; display: flex; align-items: center; }
+.info-toggle { background: none; color: #555; padding: 0; width: 1.15rem; height: 1.15rem; display: inline-flex; align-items: center; justify-content: center; border: 1px solid #444; border-radius: 50%; font-size: 0.7rem; font-style: italic; cursor: pointer; line-height: 1; transition: all 0.2s; flex-shrink: 0; }
+.info-toggle:hover { color: #aaa; border-color: #666; background: #2a2a2a; }
+.device-details { display: none; margin-top: 10px; font-size: 0.8rem; background: #252525; padding: 10px; border-radius: 4px; color: #aaa; border-left: 2px solid #00bcd4; }
+.device-details.visible { display: block; }
+.device-details p { margin: 4px 0; }
+.device-details code { color: #ccc; }
+.controls { display: flex; gap: 10px; margin-top: 20px; }
+button { background: #333; color: #fff; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; transition: background 0.2s; }
+button:hover { background: #444; }
+button.primary { background: #00bcd4; color: #000; font-weight: bold; }
+button.primary:hover { background: #00acc1; }
+.power-icon { font-family: bose, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 1.25rem; line-height: 1; height: 2.25rem; width: 2.25rem; padding: 0; display: inline-flex; align-items: center; justify-content: center; background: #2a2a2a; border-radius: 50%; color: #00bcd4; border: 1px solid #00bcd4; }
+.power-icon:hover { background: #3a3a3a; }
+.power-icon.off { color: #666; border-color: #444; background: #1a1a1a; }
+.power-icon.on { background: #00bcd4; color: #000; border-color: #00bcd4; }
+.power-icon.on:hover { background: #00acc1; }
+.status-badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.8em; background: #333; margin-left: 10px; }
+.now-playing { margin-top: 20px; padding-top: 20px; border-top: 1px solid #333; display: flex; gap: 15px; align-items: center; min-height: 80px; }
+.now-playing-info { flex-grow: 1; }
+.album-art { width: 80px; height: 80px; border-radius: 4px; background: #2a2a2a; flex-shrink: 0; object-fit: cover; box-shadow: 0 2px 4px rgba(0,0,0,0.5); }
+.album-art[src=""] { display: none; }
+.volume-container { margin-top: 15px; display: flex; align-items: center; gap: 10px; }
+input[type=range] { flex-grow: 1; }
+#device-list:empty::after { content: "Searching for devices..."; color: #666; font-style: italic; }
diff --git a/pkg/service/setup/setup.go b/pkg/service/setup/setup.go
index 04a580b..0ad8b6a 100644
--- a/pkg/service/setup/setup.go
+++ b/pkg/service/setup/setup.go
@@ -12,6 +12,7 @@ import (
"strconv"
"strings"
+ "github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
@@ -115,6 +116,10 @@ type DeviceInfoXML struct {
SoftwareVersion string `xml:"softwareVersion"`
SerialNumber string `xml:"serialNumber"`
} `xml:"components>component" json:"-"`
+
+ // Enriched fields (not part of device /info XML)
+ NowPlaying *models.NowPlaying `json:"nowPlaying,omitempty"`
+ Volume *models.Volume `json:"volume,omitempty"`
}
// GetLiveDeviceInfo fetches live information from the speaker's :8090/info endpoint.
@@ -152,6 +157,16 @@ func (m *Manager) GetLiveDeviceInfo(deviceIP string) (*DeviceInfoXML, error) {
}
}
+ // Enrich with live now playing and volume via device API (best-effort)
+ c := client.NewClientFromHost(deviceIP)
+ if vol, err := c.GetVolume(); err == nil {
+ infoXML.Volume = vol
+ }
+
+ if np, err := c.GetNowPlaying(); err == nil {
+ infoXML.NowPlaying = np
+ }
+
return &infoXML, nil
}