diff --git a/README.md b/README.md
index 64b47d7..0889cc7 100644
--- a/README.md
+++ b/README.md
@@ -78,6 +78,7 @@ The `soundtouch-service` is a local server that emulates Bose's cloud services.
- **🔌 Easy Setup**: Activate SSH via USB stick (`remote_services` file)
- **🔧 Device Migration**: Seamlessly transition devices to local control
- **🌐 Web Management UI**: Easy browser-based setup and management
+- **🎮 Stockholm Mini**: A minimal reverse-engineered UI for device control (accessible at `/web/stockholm-mini/`)
- **💾 Persistent Data**: Store presets, recents, and sources locally
- **🔄 Endpoint Mirroring**: Asynchronously mirror local requests to Bose cloud for parity testing
- **⚖️ Parity Logging**: Detect and record discrepancies between local and official Bose responses
diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go
index 52a139e..3dc9ad5 100644
--- a/cmd/soundtouch-service/main.go
+++ b/cmd/soundtouch-service/main.go
@@ -761,10 +761,24 @@ func setupRouter(server *handlers.Server) *chi.Mux {
// Stockholm Mini app
r.Handle("/stockholm-mini/*", http.StripPrefix("/stockholm-mini/", http.FileServer(http.Dir("pkg/service/handlers/web/stockholm-mini"))))
+ r.Route("/devices", func(r chi.Router) {
+ r.Get("/", server.HandleListDiscoveredDevices)
+ r.Post("/", server.HandleAddManualDevice)
+
+ r.Route("/{deviceId}", func(r chi.Router) {
+ r.Delete("/", server.HandleRemoveDevice)
+ r.Get("/events", server.HandleGetDeviceEvents)
+ r.Get("/info", server.HandleGetDeviceInfo)
+ r.Get("/ws", server.HandleDeviceWebSocket)
+ r.Post("/key/{key}", server.HandleDeviceKey)
+ r.Post("/volume/{level}", server.HandleDeviceVolume)
+ r.Post("/reboot", server.HandleRebootDevice)
+ })
+ })
+
+ r.Get("/version", server.HandleGetVersionInfo)
+
r.Route("/setup", func(r chi.Router) {
- r.Get("/devices", server.HandleListDiscoveredDevices)
- r.Post("/devices", server.HandleAddManualDevice)
- r.Delete("/devices/{deviceId}", server.HandleRemoveDevice)
r.Post("/discover", server.HandleTriggerDiscovery)
r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
r.Get("/settings", server.HandleGetSettings)
@@ -785,7 +799,6 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Get("/ca.crt", server.HandleGetCACert)
r.Get("/proxy-settings", server.HandleGetProxySettings)
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
- r.Get("/version", server.HandleGetVersionInfo)
r.Get("/interaction-stats", server.HandleGetInteractionStats)
r.Get("/interactions", server.HandleListInteractions)
r.Get("/interaction-content", server.HandleGetInteractionContent)
@@ -799,7 +812,19 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Get("/dns-discoveries/download", server.HandleDownloadDNSDiscoveries)
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
- r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
+ r.Route("/devices/{deviceId}", func(r chi.Router) {
+ r.Get("/summary", server.HandleGetMigrationSummary)
+ r.Post("/migrate", server.HandleMigrateDevice)
+ r.Post("/revert", server.HandleRevertMigration)
+ r.Post("/trust-ca", server.HandleTrustCACert)
+ r.Post("/ensure-remote-services", server.HandleEnsureRemoteServices)
+ r.Post("/remove-remote-services", server.HandleRemoveRemoteServices)
+ r.Post("/backup", server.HandleBackupConfig)
+ r.Post("/sync", server.HandleInitialSync)
+ r.Post("/test-connection", server.HandleTestConnection)
+ r.Post("/test-hosts", server.HandleTestHostsRedirection)
+ r.Post("/test-dns", server.HandleTestDNSRedirection)
+ })
})
r.NotFound(server.HandleNotFound)
diff --git a/docs/SOUNDTOUCH-SERVICE-ANNOUNCEMENT.md b/docs/SOUNDTOUCH-SERVICE-ANNOUNCEMENT.md
index b5c1885..5103b17 100644
--- a/docs/SOUNDTOUCH-SERVICE-ANNOUNCEMENT.md
+++ b/docs/SOUNDTOUCH-SERVICE-ANNOUNCEMENT.md
@@ -119,7 +119,7 @@ soundtouch-service
```go
// Build custom applications on top of local services
client := &http.Client{}
-resp, _ := client.Get("http://localhost:8000/setup/devices")
+resp, _ := client.Get("http://localhost:8000/devices")
```
### Privacy-Conscious Users
diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md
index b87a25b..7c0bb18 100644
--- a/docs/SUMMARY.md
+++ b/docs/SUMMARY.md
@@ -53,6 +53,7 @@
* [Upstream URLs](analysis/UPSTREAM-URLS.md)
* [Anonymization Summary](analysis/ANONYMIZATION-SUMMARY.md)
* [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md)
+* [Stockholm App Analysis](analysis/stockholm-app-analysis.md)
* [Wiki API Comparison](analysis/WIKI-COMPARISON.md)
* [IoT Config Summary](analysis/IOT-CONFIG-SUMMARY.md)
* [IoT Configuration Analysis](analysis/IOT-CONFIGURATION-ANALYSIS.md)
diff --git a/docs/analysis/stockholm-app-analysis.md b/docs/analysis/stockholm-app-analysis.md
new file mode 100644
index 0000000..aa299c7
--- /dev/null
+++ b/docs/analysis/stockholm-app-analysis.md
@@ -0,0 +1,52 @@
+### Stockholm App Analysis Report
+
+#### 1. Overview
+The Stockholm app is a CEPE MAUI SoundTouch Controller HTML5/JS UI. It is designed to run as a web-based interface for Bose SoundTouch devices, likely served by the device itself or an associated controller.
+
+- **Technology Stack**: HTML5, CSS3, JavaScript (Minified).
+- **Key Libraries**:
+ - **jQuery**: Core DOM manipulation and event handling.
+ - **iScroll**: Used for smooth scrolling in lists and carousels.
+ - **Forge**: Used for cryptographic operations (likely for secure communication or authentication).
+ - **WebSocket Polyfill**: Ensures WebSocket compatibility across environments.
+
+#### 2. Directory Structure
+- `js/`: Core application logic.
+ - `app/`: Main application entry point (`app.js`).
+ - `models/`: Data models for UI components (Presets, Favorites, Onboarding, etc.).
+ - `music_services/`: Implementation of various music services (Amazon, Deezer, Spotify, BMX, etc.).
+ - `views/`: UI view templates and logic.
+ - `utils/`: Utility functions for security, data analytics, and general-purpose tasks.
+- `json/`: Configuration files and static data.
+ - `config.json`: Core application configuration including Base64 encoded Bose API endpoints (e.g., streaming, events, BMX registry).
+ - `sourceFeatures.json`: Capability mapping for different sources.
+- `setup/`: Onboarding and initial device setup logic.
+- `lang/`: Localization files for multi-language support.
+
+#### 3. Communication Architecture
+The app uses several communication channels to interact with the SoundTouch ecosystem:
+
+- **Socket Communication (`socket_comm.js`)**: Real-time updates and low-latency commands via WebSockets.
+- **BMX (`bmx.js` & `js/music_services/bmx/`)**: Interactions with the Bose Music eXperience services. Handles account management, navigation, and API response validation.
+- **Marge (`marge_comm.js`)**: Likely used for interaction with the Marge service (Bose's legacy cloud/proxy service).
+- **Worker-based Architecture**: Many services use Web Workers (`bmx_worker.js`, `spotify_worker.js`) to handle API requests and data processing in the background, keeping the UI responsive.
+
+#### 4. Key Features & Functionality
+- **Multi-Device Management**: Discovering and controlling multiple speakers on the network.
+- **Music Service Integration**: Deep integration with Spotify, Amazon Music, Deezer, and Pandora.
+- **Preset Management**: Browsing and setting presets directly from the UI.
+- **Zone Control**: Creating and managing multi-room groups (Master/Slave configurations).
+- **Onboarding**: A dedicated setup flow for new devices.
+- **Analytics & Data Collection**: Modules like `data_analytics.js` and `dc_server.js` suggest tracking of user interactions.
+
+#### 5. Integration Opportunities for Bose-SoundTouch Project
+Based on the Stockholm app's capabilities, the following features could be enhanced or added to our Go-based `soundtouch-service`:
+
+1. **Enhanced BMX Emulation**: Use insights from `bmx_client.js` and `bmx_navigate_response_generator.js` to improve our local BMX implementation.
+2. **Spotify/Amazon Service Proxies**: Implement the backend logic required to support the same API calls the Stockholm app makes to these services.
+3. **UI parity**: The Stockholm app's view templates (`views/`) can serve as a reference for our Web Management UI.
+4. **WebSocket Support**: Ensure our service provides a robust WebSocket interface similar to what the Stockholm app expects for real-time state synchronization.
+5. **Capability Discovery**: Better utilization of the `sourceFeatures.json` logic to dynamically show/hide features based on the device model and firmware version.
+
+#### 6. Conclusion
+The Stockholm app is a mature, full-featured controller that relies heavily on Bose's proprietary BMX and Marge services. By analyzing its client-side logic, we can better understand the expected API responses and interaction patterns needed to provide a seamless local replacement for the Bose Cloud.
diff --git a/docs/guides/SOUNDTOUCH-SERVICE.md b/docs/guides/SOUNDTOUCH-SERVICE.md
index 1abad6c..3c85d11 100644
--- a/docs/guides/SOUNDTOUCH-SERVICE.md
+++ b/docs/guides/SOUNDTOUCH-SERVICE.md
@@ -212,23 +212,23 @@ Device migration switches your SoundTouch devices from Bose's cloud services to
```bash
# Get migration summary first
-curl http://localhost:8000/setup/migration-summary/192.168.1.100
+curl http://localhost:8000/setup/devices/192.168.1.100/summary
# Perform migration
-curl -X POST http://localhost:8000/setup/migrate/192.168.1.100
+curl -X POST http://localhost:8000/setup/devices/192.168.1.100/migrate
# Verify migration status
-curl http://localhost:8000/setup/devices
+curl http://localhost:8000/devices
```
#### Advanced Migration Options
```bash
# Migration with proxy fallback for original services
-curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?proxy_url=http://localhost:8000&marge=original&stats=original"
+curl -X POST "http://localhost:8000/setup/devices/192.168.1.100/migrate?proxy_url=http://localhost:8000&marge=original&stats=original"
# Migration with custom target URL
-curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?target_url=https://my-server.com:8000"
+curl -X POST "http://localhost:8000/setup/devices/192.168.1.100/migrate?target_url=https://my-server.com:8000"
```
### Post-Migration Verification
@@ -237,13 +237,13 @@ After migration, verify the device is working correctly:
```bash
# Check device status
-curl http://localhost:8000/setup/devices
+curl http://localhost:8000/devices
# Test preset functionality
curl "http://192.168.1.100:8090/presets"
# Monitor device events (if needed)
-curl "http://localhost:8000/events/192.168.1.100"
+curl "http://localhost:8000/devices/08DF1F0BA325/events"
```
#### ResolvConf Migration (DHCP-Aware DNS Redirection)
@@ -347,7 +347,7 @@ Mirrored requests are also recorded in the **Interaction Log** under the categor
### Discovery & Setup
-#### `GET /setup/devices`
+#### `GET /devices`
Lists all discovered SoundTouch devices with their current status.
**Response:**
@@ -368,10 +368,10 @@ Lists all discovered SoundTouch devices with their current status.
#### `POST /setup/discover`
Triggers immediate network device discovery.
-#### `GET /setup/info/{deviceIP}`
+#### `GET /devices/{deviceIP}/info`
Gets detailed device information and configuration.
-#### `GET /setup/migration-summary/{deviceIP}`
+#### `GET /setup/devices/{deviceIP}/summary`
Analyzes device configuration and provides migration preview.
**Response:**
@@ -388,7 +388,7 @@ Analyzes device configuration and provides migration preview.
}
```
-#### `POST /setup/migrate/{deviceIP}`
+#### `POST /setup/devices/{deviceIP}/migrate`
Migrates device to use local services.
**Query Parameters:**
@@ -399,6 +399,33 @@ Migrates device to use local services.
- `sw_update`: Set to "original" to proxy update requests (optional)
- `bmx`: Set to "original" to proxy BMX requests (optional)
+#### `POST /setup/devices/{deviceIP}/revert`
+Reverts device to Bose cloud defaults.
+
+#### `POST /setup/devices/{deviceIP}/trust-ca`
+Injects the AfterTouch root CA into the device's trust store.
+
+#### `POST /setup/devices/{deviceIP}/sync`
+Syncs presets and recents from the device to local storage.
+
+#### `POST /setup/devices/{deviceIP}/backup`
+Creates a backup of the current device configuration.
+
+#### `POST /setup/devices/{deviceIP}/ensure-remote-services`
+Enables persistent SSH/remote services on the device.
+
+#### `POST /setup/devices/{deviceIP}/remove-remote-services`
+Removes persistent SSH/remote services from the device.
+
+#### `POST /setup/devices/{deviceIP}/test-connection`
+Tests HTTPS connection from device to service.
+
+#### `POST /setup/devices/{deviceIP}/test-hosts`
+Tests /etc/hosts redirection on the device.
+
+#### `POST /setup/devices/{deviceIP}/test-dns`
+Tests DNS redirection on the device.
+
### BMX Services (Bose Media eXchange)
#### `GET /bmx/registry/v1/services`
@@ -658,13 +685,13 @@ find data/stats/ -name "*.json" -mtime +90 -delete
- **Description**: Browser-based guided flow for discovery, data sync, and migration.
### Setup API
-- `GET /setup/devices`: List all known (auto-discovered and manual) devices.
-- `POST /setup/devices`: Manually add a device by IP.
+- `GET /devices`: List all known (auto-discovered and manual) devices.
+- `POST /devices`: Manually add a device by IP.
- `POST /setup/discover`: Trigger a new network discovery scan.
- `GET /setup/discovery-status`: Check if a scan is currently in progress.
-- `POST /setup/sync/{deviceIP}`: Fetch presets, recents, and sources from a device.
-- `GET /setup/summary/{deviceIP}`: Get a detailed migration readiness summary.
-- `POST /setup/migrate/{deviceIP}`: Migrate a device using the specified method (XML/Hosts).
+- `POST /devices/{deviceIP}/sync`: Fetch presets, recents, and sources from a device.
+- `GET /devices/{deviceIP}/summary`: Get a detailed migration readiness summary.
+- `POST /devices/{deviceIP}/migrate`: Migrate a device using the specified method (XML/Hosts).
- `GET /setup/ca.crt`: Download the Root CA certificate for manual installation.
#### `GET /setup/interactions`
@@ -811,7 +838,7 @@ soundtouch:
name: "Living Room Speaker"
rest:
- - resource: "http://localhost:8000/setup/devices"
+ - resource: "http://localhost:8000/devices"
scan_interval: 60
sensor:
- name: "SoundTouch Devices"
diff --git a/pkg/service/handlers/handlers_events_test.go b/pkg/service/handlers/handlers_events_test.go
index 033b1c9..ca65ea8 100644
--- a/pkg/service/handlers/handlers_events_test.go
+++ b/pkg/service/handlers/handlers_events_test.go
@@ -18,7 +18,7 @@ func TestEventLog(t *testing.T) {
r := chi.NewRouter()
r.Post("/streaming/stats/usage", s.HandleUsageStats)
- r.Get("/setup/devices/{deviceId}/events", s.HandleGetDeviceEvents)
+ r.Get("/devices/{deviceId}/events", s.HandleGetDeviceEvents)
t.Run("Record and Retrieve Events", func(t *testing.T) {
// 1. Post a usage stat
@@ -36,7 +36,7 @@ func TestEventLog(t *testing.T) {
}
// 2. Retrieve events
- req, _ = http.NewRequest("GET", "/setup/devices/SPEAKER1/events", nil)
+ req, _ = http.NewRequest("GET", "/devices/SPEAKER1/events", nil)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
diff --git a/pkg/service/handlers/handlers_media.go b/pkg/service/handlers/handlers_media.go
index 79ac386..87aac7d 100644
--- a/pkg/service/handlers/handlers_media.go
+++ b/pkg/service/handlers/handlers_media.go
@@ -11,7 +11,7 @@ import (
//go:embed web/index.html
var indexHTML []byte
-//go:embed web/css/* web/js/*
+//go:embed web/migration/* web/stockholm-mini/* web/shared/*
var webFS embed.FS
//go:embed static/media/*
diff --git a/pkg/service/handlers/handlers_media_test.go b/pkg/service/handlers/handlers_media_test.go
index e3c0191..ade5004 100644
--- a/pkg/service/handlers/handlers_media_test.go
+++ b/pkg/service/handlers/handlers_media_test.go
@@ -103,33 +103,104 @@ func TestStaticWeb(t *testing.T) {
ts := httptest.NewServer(r)
defer ts.Close()
- // 1. Test CSS
- res, err := http.Get(ts.URL + "/web/css/style.css")
+ // 1. Test Migration UI CSS
+ res, err := http.Get(ts.URL + "/web/migration/style.css")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
- t.Errorf("CSS: Expected status OK, got %v", res.Status)
+ t.Errorf("Migration CSS: Expected status OK, got %v", res.Status)
}
if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
- t.Errorf("CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
+ t.Errorf("Migration CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
}
- // 2. Test JS
- res, err = http.Get(ts.URL + "/web/js/script.js")
+ // 2. Test Migration UI JS
+ res, err = http.Get(ts.URL + "/web/migration/script.js")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
- t.Errorf("JS: Expected status OK, got %v", res.Status)
+ t.Errorf("Migration JS: Expected status OK, got %v", res.Status)
}
if !strings.Contains(res.Header.Get("Content-Type"), "application/javascript") &&
!strings.Contains(res.Header.Get("Content-Type"), "text/javascript") {
- t.Errorf("JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
+ t.Errorf("Migration JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
+ }
+
+ // 3. Test Migration UI Index
+ res, err = http.Get(ts.URL + "/web/migration/index.html")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ t.Errorf("Migration Index: Expected status OK, got %v", res.Status)
+ }
+ if !strings.Contains(res.Header.Get("Content-Type"), "text/html") {
+ t.Errorf("Migration Index: Expected text/html content type, got %s", res.Header.Get("Content-Type"))
+ }
+
+ // 4. Test Stockholm Mini
+ res, err = http.Get(ts.URL + "/web/stockholm-mini/index.html")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ t.Errorf("Stockholm Mini: Expected status OK, got %v", res.Status)
+ }
+ if !strings.Contains(res.Header.Get("Content-Type"), "text/html") {
+ t.Errorf("Stockholm Mini: Expected text/html content type, got %s", res.Header.Get("Content-Type"))
+ }
+
+ // 5. Test Stockholm Mini CSS
+ res, err = http.Get(ts.URL + "/web/stockholm-mini/style.css")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ t.Errorf("Stockholm Mini CSS: Expected status OK, got %v", res.Status)
+ }
+ if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
+ t.Errorf("Stockholm Mini CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
+ }
+
+ // 6. Test Shared CSS
+ res, err = http.Get(ts.URL + "/web/shared/common.css")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ t.Errorf("Shared CSS: Expected status OK, got %v", res.Status)
+ }
+ if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
+ t.Errorf("Shared CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
+ }
+
+ // 7. Test Shared JS
+ res, err = http.Get(ts.URL + "/web/shared/common.js")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ t.Errorf("Shared JS: Expected status OK, got %v", res.Status)
+ }
+ if !strings.Contains(res.Header.Get("Content-Type"), "application/javascript") &&
+ !strings.Contains(res.Header.Get("Content-Type"), "text/javascript") {
+ t.Errorf("Shared JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
}
// 3. Test diff.min.js
diff --git a/pkg/service/handlers/handlers_setup_test.go b/pkg/service/handlers/handlers_setup_test.go
index 251b1fa..8ace344 100644
--- a/pkg/service/handlers/handlers_setup_test.go
+++ b/pkg/service/handlers/handlers_setup_test.go
@@ -232,8 +232,8 @@ func TestMigrationAndCA(t *testing.T) {
t.Errorf("CA: Unexpected content type: %s", res.Header.Get("Content-Type"))
}
- // 2. Test POST /setup/migrate/{deviceIP}?method=hosts
- res, err = http.Post(ts.URL+"/setup/migrate/192.168.1.10?method=hosts&target_url=http://192.168.1.100:8000", "application/json", nil)
+ // 2. Test POST /setup/devices/{deviceIP}/migrate?method=hosts
+ res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/migrate?method=hosts&target_url=http://192.168.1.100:8000", "application/json", nil)
if err != nil {
t.Fatal(err)
}
@@ -254,8 +254,8 @@ func TestMigrationAndCA(t *testing.T) {
t.Errorf("Migrate: Expected output field in response")
}
- // 3. Test POST /setup/trust-ca/{deviceIP}
- res, err = http.Post(ts.URL+"/setup/trust-ca/192.168.1.10", "application/json", nil)
+ // 3. Test POST /setup/devices/{deviceIP}/trust-ca
+ res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/trust-ca", "application/json", nil)
if err != nil {
t.Fatal(err)
}
@@ -275,8 +275,8 @@ func TestMigrationAndCA(t *testing.T) {
t.Errorf("TrustCA: Expected output field in response")
}
- // 4. Test POST /setup/reboot/{deviceIP}
- res, err = http.Post(ts.URL+"/setup/reboot/192.168.1.10", "application/json", nil)
+ // 4. Test POST /devices/{deviceIP}/reboot
+ res, err = http.Post(ts.URL+"/devices/192.168.1.10/reboot", "application/json", nil)
if err != nil {
t.Fatal(err)
}
@@ -296,8 +296,8 @@ func TestMigrationAndCA(t *testing.T) {
t.Errorf("Reboot: Expected output field in response")
}
- // 5. Test POST /setup/remove-remote-services/{deviceIP}
- res, err = http.Post(ts.URL+"/setup/remove-remote-services/192.168.1.10", "application/json", nil)
+ // 5. Test POST /setup/devices/{deviceIP}/remove-remote-services
+ res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/remove-remote-services", "application/json", nil)
if err != nil {
t.Fatal(err)
}
@@ -329,17 +329,20 @@ func TestRemoveDevice(t *testing.T) {
_ = ds.Initialize()
// Setup a dummy device in the datastore
- account := "test-account"
+ account := "acc1"
deviceID := "TEST-DEVICE-ID"
- deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
- if err := os.MkdirAll(deviceDir, 0755); err != nil {
- t.Fatalf("Failed to create device dir: %v", err)
- }
- infoFile := filepath.Join(deviceDir, "DeviceInfo.xml")
- infoXML := `Test 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)
@@ -347,7 +350,7 @@ func TestRemoveDevice(t *testing.T) {
defer ts.Close()
// 1. Verify device exists
- res, err := http.Get(ts.URL + "/setup/devices")
+ res, err := http.Get(ts.URL + "/devices")
if err != nil {
t.Fatal(err)
}
@@ -370,7 +373,7 @@ func TestRemoveDevice(t *testing.T) {
}
// 2. Remove device
- req, err := http.NewRequest(http.MethodDelete, ts.URL+"/setup/devices/"+deviceID, nil)
+ req, err := http.NewRequest(http.MethodDelete, ts.URL+"/devices/"+deviceID, nil)
if err != nil {
t.Fatal(err)
}
@@ -385,7 +388,7 @@ func TestRemoveDevice(t *testing.T) {
}
// 3. Verify device is gone
- res, err = http.Get(ts.URL + "/setup/devices")
+ res, err = http.Get(ts.URL + "/devices")
if err != nil {
t.Fatal(err)
}
diff --git a/pkg/service/handlers/handlers_websocket.go b/pkg/service/handlers/handlers_websocket.go
new file mode 100644
index 0000000..552e7a1
--- /dev/null
+++ b/pkg/service/handlers/handlers_websocket.go
@@ -0,0 +1,203 @@
+package handlers
+
+import (
+ "encoding/json"
+ "log"
+ "net/http"
+ "time"
+
+ "github.com/gesellix/bose-soundtouch/pkg/client"
+ "github.com/gesellix/bose-soundtouch/pkg/models"
+ "github.com/go-chi/chi/v5"
+ "github.com/gorilla/websocket"
+)
+
+var upgrader = websocket.Upgrader{
+ CheckOrigin: func(_ *http.Request) bool { return true },
+}
+
+const (
+ pongWait = 40 * time.Second
+ pingPeriod = 20 * time.Second // must be less than pongWait
+)
+
+// HandleDeviceWebSocket upgrades the connection and proxies device WebSocket events to the browser.
+func (s *Server) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request) {
+ deviceID := chi.URLParam(r, "deviceId")
+ if deviceID == "" {
+ http.Error(w, "Device ID is required", http.StatusBadRequest)
+ return
+ }
+
+ deviceIP, err := s.lookupIP(deviceID)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusNotFound)
+ return
+ }
+
+ // Upgrade the HTTP connection to a WebSocket for the browser
+ conn, err := upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ return
+ }
+
+ // Create a SoundTouch WebSocket client for the target device
+ c := client.NewClientFromHost(deviceIP)
+ wsClient := c.NewWebSocketClient(client.DefaultWebSocketConfig())
+
+ // Channel-based write pump per Gorilla best practices
+ sendCh := make(chan []byte, 64) // buffer to smooth bursts
+ closeCh := make(chan struct{})
+
+ // Helper to enqueue JSON messages; drop if buffer is full to avoid blocking
+ enqueue := func(v interface{}) {
+ b, err := json.Marshal(v)
+ if err != nil {
+ return
+ }
+
+ select {
+ case sendCh <- b:
+ default:
+ // drop to protect connection under burst
+ }
+ }
+
+ // Reader: we don't expect messages from the browser; just keep the
+ // connection alive by processing control frames and detect close.
+ _ = conn.SetReadDeadline(time.Now().Add(pongWait))
+ conn.SetPongHandler(func(string) error {
+ return conn.SetReadDeadline(time.Now().Add(pongWait))
+ })
+
+ go func() {
+ defer func() {
+ close(closeCh)
+
+ _ = wsClient.Disconnect()
+ _ = conn.Close()
+ }()
+
+ for {
+ mt, _, err := conn.ReadMessage()
+ if err != nil {
+ log.Printf("[WebSocket] Browser connection closed for %s: %v", deviceIP, err)
+ return
+ }
+
+ if mt == websocket.CloseMessage {
+ return
+ }
+ }
+ }()
+
+ // Writer: single writer goroutine handles JSON writes and ping keepalive
+ go func() {
+ pingTicker := time.NewTicker(pingPeriod)
+
+ defer func() {
+ pingTicker.Stop()
+
+ _ = conn.Close()
+ }()
+
+ for {
+ select {
+ case msg, ok := <-sendCh:
+ _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
+ if !ok {
+ _ = conn.WriteMessage(websocket.CloseMessage, []byte{})
+ return
+ }
+
+ if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
+ return
+ }
+ case <-pingTicker.C:
+ _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
+ if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
+ return
+ }
+ case <-closeCh:
+ return
+ }
+ }
+ }()
+
+ // Forward typed events with a simple envelope into the send queue
+ wsClient.SetHandlers(&models.WebSocketEventHandlers{
+ OnNowPlaying: func(e *models.NowPlayingUpdatedEvent) {
+ enqueue(map[string]interface{}{"type": "nowPlayingUpdated", "payload": e})
+ },
+ OnVolumeUpdated: func(e *models.VolumeUpdatedEvent) {
+ enqueue(map[string]interface{}{"type": "volumeUpdated", "payload": e})
+ },
+ OnConnectionState: func(e *models.ConnectionStateUpdatedEvent) {
+ enqueue(map[string]interface{}{"type": "connectionStateUpdated", "payload": e})
+ },
+ OnPresetUpdated: func(e *models.PresetUpdatedEvent) {
+ enqueue(map[string]interface{}{"type": "presetUpdated", "payload": e})
+ },
+ OnZoneUpdated: func(e *models.ZoneUpdatedEvent) {
+ enqueue(map[string]interface{}{"type": "zoneUpdated", "payload": e})
+ },
+ OnBassUpdated: func(e *models.BassUpdatedEvent) {
+ enqueue(map[string]interface{}{"type": "bassUpdated", "payload": e})
+ },
+ OnUnknownEvent: func(event *models.WebSocketEvent) {
+ bytes, _ := json.Marshal(event)
+ enqueue(map[string]interface{}{"type": "unknown", "payload": json.RawMessage(bytes)})
+ },
+ OnSpecialMessage: func(msg *models.SpecialMessage) {
+ enqueue(map[string]interface{}{"type": "special", "payload": msg})
+ },
+ })
+
+ // Add a separate goroutine to monitor the device connection status
+ go func() {
+ wsClient.Wait()
+ log.Printf("[WebSocket] Device %s client terminated", deviceIP)
+
+ _ = conn.Close()
+ }()
+
+ // Connect to the device WebSocket
+ if err := wsClient.Connect(); err != nil {
+ enqueue(map[string]interface{}{"type": "error", "message": err.Error()})
+ return
+ }
+
+ // Optional: send an initial snapshot for convenience
+ go func() {
+ info, err := s.sm.GetLiveDeviceInfo(deviceIP)
+ if err != nil {
+ return
+ }
+
+ // Supplement with volume and now playing
+ c := client.NewClientFromHost(deviceIP)
+ payload := map[string]interface{}{
+ "deviceID": info.DeviceID,
+ "name": info.Name,
+ "type": info.Type,
+ //"maccAddress": info.MaccAddress,
+ "serialNumber": info.SerialNumber,
+ "softwareVersion": info.SoftwareVer,
+ // Provide IP in both styles for frontend robustness
+ "ip_address": deviceIP,
+ "ipAddress": deviceIP,
+ }
+
+ if vol, err := c.GetVolume(); err == nil {
+ payload["volume"] = vol
+ // Also add at top level for flatter frontend parsing
+ payload["actualVolume"] = vol.ActualVolume
+ }
+
+ if np, err := c.GetNowPlaying(); err == nil {
+ payload["nowPlaying"] = np
+ }
+
+ enqueue(map[string]interface{}{"type": "snapshotInfo", "payload": payload})
+ }()
+}
diff --git a/pkg/service/handlers/main_test.go b/pkg/service/handlers/main_test.go
index 2d1cdc2..4ca241e 100644
--- a/pkg/service/handlers/main_test.go
+++ b/pkg/service/handlers/main_test.go
@@ -91,12 +91,31 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
r.Post("/account/{account}/password", server.HandleMargeChangePassword)
})
+ // Setup Devices for tests
+ r.Route("/devices", func(r chi.Router) {
+ r.Get("/", server.HandleListDiscoveredDevices)
+ r.Post("/", server.HandleAddManualDevice)
+
+ r.Route("/{deviceId}", func(r chi.Router) {
+ r.Delete("/", server.HandleRemoveDevice)
+ r.Get("/events", server.HandleGetDeviceEvents)
+ r.Get("/info", server.HandleGetDeviceInfo)
+ r.Get("/ws", server.HandleDeviceWebSocket)
+ r.Post("/key/{key}", server.HandleDeviceKey)
+ r.Post("/volume/{level}", server.HandleDeviceVolume)
+ r.Post("/reboot", server.HandleRebootDevice)
+ })
+ })
+
+ r.Get("/version", server.HandleGetVersionInfo)
+
// Setup Setup for tests
r.Route("/setup", func(r chi.Router) {
- r.Get("/devices", server.HandleListDiscoveredDevices)
- r.Delete("/devices/{deviceId}", server.HandleRemoveDevice)
+ r.Post("/discover", server.HandleTriggerDiscovery)
+ r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
r.Get("/settings", server.HandleGetSettings)
r.Post("/settings", server.HandleUpdateSettings)
+ r.Get("/ca.crt", server.HandleGetCACert)
r.Get("/proxy-settings", server.HandleGetProxySettings)
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
r.Post("/ensure-remote-services/{deviceId}", server.HandleEnsureRemoteServices)
@@ -108,6 +127,30 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
r.Post("/test-connection/{deviceId}", server.HandleTestConnection)
r.Post("/test-hosts/{deviceId}", server.HandleTestHostsRedirection)
r.Get("/ca.crt", server.HandleGetCACert)
+
+ r.Get("/interaction-stats", server.HandleGetInteractionStats)
+ r.Get("/interactions", server.HandleListInteractions)
+ r.Get("/interaction-content", server.HandleGetInteractionContent)
+ r.Get("/interactions/sessions/{session}/download", server.HandleDownloadSession)
+ r.Delete("/interactions/sessions/{session}", server.HandleDeleteSession)
+ r.Delete("/interactions/sessions", server.HandleCleanupSessions)
+
+ r.Get("/dns-discoveries", server.HandleGetDNSDiscoveries)
+ r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
+
+ r.Route("/devices/{deviceId}", func(r chi.Router) {
+ r.Get("/summary", server.HandleGetMigrationSummary)
+ r.Post("/migrate", server.HandleMigrateDevice)
+ r.Post("/revert", server.HandleRevertMigration)
+ r.Post("/trust-ca", server.HandleTrustCACert)
+ r.Post("/ensure-remote-services", server.HandleEnsureRemoteServices)
+ r.Post("/remove-remote-services", server.HandleRemoveRemoteServices)
+ r.Post("/backup", server.HandleBackupConfig)
+ r.Post("/sync", server.HandleInitialSync)
+ r.Post("/test-connection", server.HandleTestConnection)
+ r.Post("/test-hosts", server.HandleTestHostsRedirection)
+ r.Post("/test-dns", server.HandleTestDNSRedirection)
+ })
})
r.NotFound(server.HandleNotFound)
diff --git a/pkg/service/handlers/recorder_middleware.go b/pkg/service/handlers/recorder_middleware.go
index f90747d..de35d55 100644
--- a/pkg/service/handlers/recorder_middleware.go
+++ b/pkg/service/handlers/recorder_middleware.go
@@ -5,6 +5,7 @@ import (
"bytes"
"fmt"
"io"
+ "log"
"net"
"net/http"
)
@@ -12,7 +13,7 @@ import (
// RecordMiddleware returns a middleware that records "self" requests and responses.
func (s *Server) RecordMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if s.recorder == nil || !s.recordEnabled {
+ if s.recorder == nil || !s.recordEnabled || r.Header.Get("Upgrade") == "websocket" {
next.ServeHTTP(w, r)
return
}
@@ -52,6 +53,10 @@ func (s *Server) RecordMiddleware(next http.Handler) http.Handler {
// Create a response object for the recorder
res := rw.getRecordedResponse(r)
+ if res.StatusCode >= 400 {
+ log.Printf("[DEBUG_LOG] Recording error response: %d %s %s", res.StatusCode, r.Method, r.URL.Path)
+ }
+
if res.Body != nil {
defer func() { _ = res.Body.Close() }()
}
@@ -65,8 +70,9 @@ func (s *Server) RecordMiddleware(next http.Handler) http.Handler {
type responseWriter struct {
http.ResponseWriter
- statusCode int
- body *bytes.Buffer
+ statusCode int
+ body *bytes.Buffer
+ wroteHeader bool
}
func (rw *responseWriter) Header() http.Header {
@@ -74,18 +80,28 @@ func (rw *responseWriter) Header() http.Header {
}
func (rw *responseWriter) WriteHeader(code int) {
+ if rw.wroteHeader {
+ return
+ }
+
rw.statusCode = code
+ rw.wroteHeader = true
rw.ResponseWriter.WriteHeader(code)
}
func (rw *responseWriter) Write(b []byte) (int, error) {
+ if !rw.wroteHeader {
+ rw.WriteHeader(http.StatusOK)
+ }
+
rw.body.Write(b)
+
return rw.ResponseWriter.Write(b)
}
func (rw *responseWriter) getRecordedResponse(r *http.Request) *http.Response {
statusCode := rw.statusCode
- if statusCode == 0 {
+ if !rw.wroteHeader && statusCode == 0 {
statusCode = http.StatusOK
}
diff --git a/pkg/service/handlers/web/index.html b/pkg/service/handlers/web/index.html
index 801cc60..ee08a2c 100644
--- a/pkg/service/handlers/web/index.html
+++ b/pkg/service/handlers/web/index.html
@@ -1,1445 +1,98 @@
-
+
-
- AfterTouch (SoundTouch Toolkit)
-
-
-
+
+ AfterTouch - Select Interface
+
+
+
-AfterTouch
-
- Bose SoundTouch Toolkit
-
+
+
AfterTouch
+
Bose SoundTouch Toolkit
+
Select an interface to continue.
-
-
-
- Overview
-
-
- 1. Settings
-
-
- 2. Devices
-
-
- 3. Data Sync
-
-
- 4. Migration
-
-
- 5. Interactions & Events
-
-
- 6. Parity & Mirroring
-
-
+
+
+ 📻
+ 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)
-
-
- Discovery Interval:
-
- Enable Automated Discovery
-
-
-
DNS Discovery:
-
-
- Enable
- DNS Discovery Server
-
-
-
Upstream DNS:
-
-
ⓘ
-
- Optional: comma-separated list of DNS servers
- (e.g.,
1.1.1.1, 8.8.8.8).
- If empty, AfterTouch defaults to the system
- nameservers (e.g. from
-
/etc/resolv.conf).
-
-
-
-
- DNS Bind Address:
-
- (e.g., :53 or 0.0.0.0:53.
- Port 53 is required for actual
- migration)
-
-
-
-
-
Endpoint Mirroring:
-
-
- Enable
- Background Mirroring to Bose Cloud
-
-
-
-
- Prefer Upstream Response for Mirrored Endpoints
-
-
Mirror Endpoints (one per line, supports * wildcards):
-
-
- Note: Mirroring sends matching
- requests (including full headers) to the
- official Bose servers for parity comparison. If
- Redact Sensitive Data is enabled in
- Proxy Settings, credentials will be masked in
- logs and recordings , but full
- headers are always sent to Bose to ensure
- service compatibility.
-
-
-
-
-
-
Spotify Integration:
-
- Checking configuration...
-
-
-
-
Proxy Logging:
-
-
Redact
- Sensitive Headers
-
Log
- Bodies
-
Record
- Interactions(View in 5. Interactions tab)
-
-
Internal Paths (skip recording for these patterns):
-
-
- Requests matching these patterns will be
- excluded from recording. Use one pattern per
- line.
-
-
-
-
-
-
-
-
-
-
- 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 - Redirect via DNS
- Hook)
-
-
-
-
-
-
-
-
-
-
-
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)
-
- Mirror (Bose)
-
-
-
- Since (YYYY-MM-DD HH:mm:ss):
-
-
-
- Apply Filters
-
-
-
-
-
-
-
- #
- Time
- Method
- Path
- Status
- Category
- Event Details
- Action
-
-
-
-
-
- No interactions found.
-
-
-
-
-
-
-
-
-
-
DNS Discoveries
-
-
- Download JSON
-
-
- 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.
-
-
-
-
-
-
-
-
-
-
-
Parity Analysis
-
- Detection of discrepancies between AfterTouch local
- responses and official Bose Cloud responses for mirrored
- endpoints.
-
-
-
-
-
Parity Mismatches
-
-
- Refresh Mismatches
-
-
- Clear All Records
-
-
-
-
-
-
-
-
- Time
- Method
- Path
- Reasons
- Action
-
-
-
-
-
- Loading mismatches...
-
-
-
-
-
-
-
-
-
-
- Mismatch Detail:
-
-
- Close Detail
-
-
-
-
-
-
- ⚠️ Large Payload: Rich diff highlighting is disabled to prevent a browser freeze. Showing raw comparison instead.
- Show Rich Diff Anyway
-
-
-
-
-
-
-
-
-
+
+
diff --git a/pkg/service/handlers/web/js/diff.min.js b/pkg/service/handlers/web/migration/diff.min.js
similarity index 100%
rename from pkg/service/handlers/web/js/diff.min.js
rename to pkg/service/handlers/web/migration/diff.min.js
diff --git a/pkg/service/handlers/web/migration/index.html b/pkg/service/handlers/web/migration/index.html
index 801cc60..6bfa9cd 100644
--- a/pkg/service/handlers/web/migration/index.html
+++ b/pkg/service/handlers/web/migration/index.html
@@ -1,368 +1,146 @@
-
+
-
+
AfterTouch (SoundTouch Toolkit)
-
-
-
+
+
+
AfterTouch
-
- Bose SoundTouch Toolkit
-
+
Bose SoundTouch Toolkit
+
← Back to selection
-
- Overview
-
-
- 1. Settings
-
-
- 2. Devices
-
-
- 3. Data Sync
-
-
- 4. Migration
-
-
- 5. Interactions & Events
-
-
- 6. Parity & Mirroring
-
+ 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.
-
+
This toolkit helps you keep your Bose SoundTouch speakers functional even after the Bose Cloud shutdown in May 2026. It emulates the necessary cloud services locally on your network.
Migration Process at a Glance
-
🔌 Prerequisite: Enable SSH
+
🔌 Prerequisite: Enable SSH
Migration requires SSH access. To enable it:
-
-
- Create an empty file named
- remote_services on a USB stick.
-
-
- Insert it into the speaker's
- SERVICE port and reboot the
- speaker.
-
+
+ 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).
-
+ 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).
+ 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.
+ 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.
+ 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.
+ 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.
+ Verification: After migration and reboot, your speaker will communicate with this toolkit instead of Bose servers.
-
⚠️ Safety First: Before starting any
- migration, please read our
-
Professional
- Migration & Safety Guide . The toolkit automatically creates backups, but understanding the process
- is key to a smooth transition.
+
⚠️ Safety First: Before starting any migration, please read our
+
Professional Migration & Safety Guide .
+ The toolkit automatically creates backups, but understanding the process is key to a smooth transition.
Useful Links
System Settings
-
- Note: These URLs must be
- accessible from your SoundTouch devices .
- Use the IP address of this server on your local network
- (e.g., http://192.168.1.100:8000) rather than
- localhost.
+
+ Note: These URLs must be accessible from your SoundTouch devices .
+ Use the IP address of this server on your local network (e.g., http://192.168.1.100:8000)
+ rather than localhost.
-
+
Target Domain:
-
- (Standard services URL)
+
+ (Standard services URL)
-
+
+ Soundcork URL:
+
+ (Soundcork services URL)
+
+
Discovery Interval:
-
- Enable Automated Discovery
+
+ Enable Automated Discovery
-
-
DNS Discovery:
-
-
- Enable
- DNS Discovery Server
-
-
-
Upstream DNS:
-
-
ⓘ
-
- Optional: comma-separated list of DNS servers
- (e.g.,
1.1.1.1, 8.8.8.8).
- If empty, AfterTouch defaults to the system
- nameservers (e.g. from
-
/etc/resolv.conf).
-
-
-
-
- DNS Bind Address:
-
- (e.g., :53 or 0.0.0.0:53.
- Port 53 is required for actual
- migration)
-
-
-
-
-
Endpoint Mirroring:
-
-
- Enable
- Background Mirroring to Bose Cloud
-
-
-
-
- Prefer Upstream Response for Mirrored Endpoints
-
-
Mirror Endpoints (one per line, supports * wildcards):
-
-
- Note: Mirroring sends matching
- requests (including full headers) to the
- official Bose servers for parity comparison. If
- Redact Sensitive Data is enabled in
- Proxy Settings, credentials will be masked in
- logs and recordings , but full
- headers are always sent to Bose to ensure
- service compatibility.
-
-
-
-
-
-
Spotify Integration:
-
- Checking configuration...
-
-
-
-
Proxy Logging:
-
-
Redact
- Sensitive Headers
-
Log
- Bodies
-
Record
- Interactions(View in 5. Interactions tab)
-
-
Internal Paths (skip recording for these patterns):
-
-
- Requests matching these patterns will be
- excluded from recording. Use one pattern per
- line.
-
-
-
-
-
-
+
Save Settings
-
+
+
+
+
-
-
- Known Devices
- 🔍 Scanning...
-
-
-
+
Known Devices 🔍 Scanning...
Loading devices...
-
@@ -370,11 +148,7 @@
Initial Data Sync
-
- Before migrating, fetch your presets, recents, and
- configured sources from the device to ensure they are
- available locally.
-
+
Before migrating, fetch your presets, recents, and configured sources from the device to ensure they are available locally.
Device:
@@ -383,19 +157,9 @@
Start Sync
-
@@ -404,331 +168,95 @@
Device Migration
Device:
-
+
-- Select a device --
-
+
-
-
- Migration Summary for
-
-
+
+
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
-
-
+
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.
-
+
+
HTTPS Connection Test:
+
Verify the device can reach the server over HTTPS.
+
URL:
-
-
- Test with Explicit CA.crt
-
-
- Test with Shared Trust Store
-
+
+ Test with Explicit CA.crt
+ Test with Shared Trust Store
-
+
-
-
Preliminary /etc/hosts Test:
-
Verify the device's /etc/hosts mechanism before
- full migration.
-
+
+
Preliminary /etc/hosts Test:
+
Verify the device's /etc/hosts mechanism before full migration.
+
Domain: custom-test-api.bose.fake
-
-
- Test Hosts Redirection
-
+
+ Test Hosts Redirection
-
+
-
-
Preliminary DNS Test:
-
Verify the device can resolve domains via the
- AfterTouch DNS server.
-
+
+
Preliminary DNS Test:
+
Verify the device can resolve domains via the AfterTouch DNS server.
+
Domain: aftertouch.test
-
-
- Test DNS Redirection
-
+
+ Test DNS Redirection
-
+
-
-
Migration Method:
-
-
- XML Configuration (Recommended - redirects
- specific services)
-
-
- /etc/hosts + Root CA (Advanced - global
- redirection)
-
-
- /etc/resolv.conf (DHCP-Aware - Redirect via DNS
- Hook)
-
+
+
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
-
+ Service Original URL Implementation
Marge (Streaming)
loading...
-
-
- AfterTouch (Local Service)
-
-
- Upstream (Proxy via local service)
-
+
+ AfterTouch (Local Service)
+ Upstream (Proxy via local service)
@@ -736,16 +264,9 @@
Stats
loading...
-
-
- AfterTouch (Local Service)
-
-
- Upstream (Proxy via local service)
-
+
+ AfterTouch (Local Service)
+ Upstream (Proxy via local service)
@@ -753,16 +274,9 @@
Software Update
loading...
-
-
- AfterTouch (Local Service)
-
-
- Upstream (Proxy via local service)
-
+
+ AfterTouch (Local Service)
+ Upstream (Proxy via local service)
@@ -770,16 +284,9 @@
BMX (Registry)
loading...
-
-
- AfterTouch (Local Service)
-
-
- Upstream (Proxy via local service)
-
+
+ AfterTouch (Local Service)
+ Upstream (Proxy via local service)
@@ -788,132 +295,35 @@
-
-
+
+
-
-
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 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.
+
+ 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
-
+
+ Confirm Migration
+ Revert to Defaults
+ Reboot Speaker
+ Enable Persistent Remote Services
+ Remove Persistent Remote Services
+ Cancel
@@ -921,524 +331,145 @@
Recorded Interactions & Device Events
-
- Analysis of traffic handled by this service (self), proxied
- to Bose (upstream), and internal device events (telemetry).
-
+
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
-
+
+
Total Requests: 0
+
Refresh Stats
+
+ View App/Device Events
-
-
- Cleanup old sessions
-
-
- Keeps only the 10 most recent sessions
-
+
+
Cleanup old sessions
+
Keeps only the 10 most recent sessions
-
-
+
+
-
-
-
-
Browse Recordings
+
+
+
Browse Recordings
-
+
-
-
+
+
-
- #
- Time
- Method
- Path
- Status
- Category
- Event Details
- Action
+
+ #
+ Time
+ Method
+ Path
+ Status
+ Category
+ Action
-
-
- No interactions found.
-
-
+ No interactions found.
-
-
-
DNS Discoveries
-
-
- Download JSON
-
-
- Clear DNS Logs
-
-
+
+
+
DNS Discoveries
+ Clear DNS Logs
-
- Hosts discovered via the AfterTouch DNS server. "Self"
- means the domain was intercepted and redirected to this
- service.
-
-
-
+ 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
+
+ Hostname
+ Last Seen
+ Queries
+ Bose?
+ Category
+ Last Client IP
-
-
- No DNS discoveries found.
-
-
+ No DNS discoveries found.
-
-
-
- Recording Viewer:
-
-
-
- Close
-
+
+
+
Recording Viewer:
+ Close
-
+
-
-
-
App & Device Events
+
+
+
App & Device Events
-- Select Device --
- Close
-
+ Close
-
-
+
+
-
- Time
- Type
- Data
+
+ Time
+ Type
+ Data
-
- Select a device to view
- events.
-
-
+ Select a device to view events.
-
-
-
-
Parity Analysis
-
- Detection of discrepancies between AfterTouch local
- responses and official Bose Cloud responses for mirrored
- endpoints.
-
-
-
-
-
Parity Mismatches
-
-
- Refresh Mismatches
-
-
- Clear All Records
-
-
-
-
-
-
-
-
- Time
- Method
- Path
- Reasons
- Action
-
-
-
-
-
- Loading mismatches...
-
-
-
-
-
-
-
-
-
-
- Mismatch Detail:
-
-
- Close Detail
-
-
-
-
-
-
- ⚠️ Large Payload: Rich diff highlighting is disabled to prevent a browser freeze. Showing raw comparison instead.
- Show Rich Diff Anyway
-
-
-
-
-
-
-
+
+
+
diff --git a/pkg/service/handlers/web/js/script.js b/pkg/service/handlers/web/migration/script.js
similarity index 97%
rename from pkg/service/handlers/web/js/script.js
rename to pkg/service/handlers/web/migration/script.js
index db4a461..b8a87ac 100644
--- a/pkg/service/handlers/web/js/script.js
+++ b/pkg/service/handlers/web/migration/script.js
@@ -251,8 +251,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");
@@ -430,7 +431,7 @@ async function startSync() {
log.innerHTML = "";
try {
- const response = await fetch("/setup/sync/" + encodeURIComponent(deviceId), {method: "POST"},);
+ const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/sync", {method: "POST"},);
if (response.ok) {
status.style.backgroundColor = "#dfd";
status.textContent = "✅ Sync completed successfully for " + display + "!";
@@ -927,7 +928,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;
@@ -1146,7 +1147,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}),
});
@@ -1168,7 +1169,7 @@ async function removeDevice(deviceId, name) {
}
try {
- const response = await fetch(`/setup/devices/${deviceId}`, {
+ const response = await fetch(`/devices/${deviceId}`, {
method: "DELETE",
});
@@ -1214,7 +1215,7 @@ async function pollDiscoveryStatus() {
async function updateDeviceInfo(deviceId, ip) {
try {
- const response = await fetch("/setup/info/" + encodeURIComponent(deviceId));
+ const response = await fetch("/devices/" + encodeURIComponent(deviceId) + "/info");
if (!response.ok) return;
const info = await response.json();
@@ -1274,7 +1275,7 @@ async function showSummary(deviceId) {
}
try {
- const response = await fetch("/setup/summary/" + encodeURIComponent(deviceId) + query,);
+ const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/summary" + query,);
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText);
@@ -1459,7 +1460,7 @@ async function revert(deviceId, ip) {
statusDiv.innerHTML = "Reverting " + display + " to defaults...";
try {
- const response = await fetch("/setup/revert/" + encodeURIComponent(deviceId), {method: "POST"},);
+ const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/revert", {method: "POST"},);
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
@@ -1491,7 +1492,7 @@ async function reboot(deviceId, ip) {
statusDiv.innerHTML = "Rebooting " + display + "...";
try {
- const response = await fetch("/setup/reboot/" + encodeURIComponent(deviceId), {method: "POST"},);
+ const response = await fetch("/devices/" + encodeURIComponent(deviceId) + "/reboot", {method: "POST"},);
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
@@ -1537,7 +1538,7 @@ async function migrate(deviceId, ip) {
}
try {
- const response = await fetch("/setup/migrate/" + encodeURIComponent(deviceId) + query, {method: "POST"},);
+ const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/migrate" + query, {method: "POST"},);
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
@@ -1549,6 +1550,7 @@ async function migrate(deviceId, ip) {
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";
@@ -1574,7 +1576,7 @@ async function trustCA(deviceId, ip) {
statusDiv.innerHTML = "Injecting Root CA into shared trust store on " + display + "...";
try {
- const response = await fetch("/setup/trust-ca/" + encodeURIComponent(deviceId), {method: "POST"},);
+ const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/trust-ca", {method: "POST"},);
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
@@ -1606,7 +1608,7 @@ async function ensureRemoteServices(deviceId, ip) {
statusDiv.innerHTML = "Ensuring remote services for " + display + "...";
try {
- const response = await fetch("/setup/ensure-remote-services/" + encodeURIComponent(deviceId), {method: "POST"},);
+ const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/ensure-remote-services", {method: "POST"},);
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
@@ -1640,7 +1642,7 @@ async function removeRemoteServices(deviceId, ip) {
statusDiv.innerHTML = "Removing remote services for " + display + "...";
try {
- const response = await fetch("/setup/remove-remote-services/" + encodeURIComponent(deviceId), {method: "POST"},);
+ const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/remove-remote-services", {method: "POST"},);
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
@@ -1668,7 +1670,7 @@ async function backupConfig(deviceId, ip) {
statusDiv.innerHTML = "Creating backup for " + display + "...";
try {
- const response = await fetch("/setup/backup/" + encodeURIComponent(deviceId), {method: "POST"},);
+ const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/backup", {method: "POST"},);
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
@@ -1697,7 +1699,7 @@ async function testConnection(deviceId, useExplicitCA) {
try {
const query = `?target_url=${encodeURIComponent(testUrl)}&use_explicit_ca=${useExplicitCA}`;
- const response = await fetch(`/setup/test-connection/${encodeURIComponent(deviceId)}${query}`, {method: "POST"},);
+ const response = await fetch(`/setup/devices/test-connection/${encodeURIComponent(deviceId)}${query}`, {method: "POST"},);
const result = await response.json();
if (result.ok) {
@@ -1725,7 +1727,7 @@ async function testHostsRedirection(deviceId) {
try {
const query = `?target_url=${encodeURIComponent(targetUrl)}`;
- const response = await fetch(`/setup/test-hosts/${encodeURIComponent(deviceId)}${query}`, {method: "POST"},);
+ const response = await fetch(`/setup/devices/${encodeURIComponent(deviceId)}/test-hosts${query}`, {method: "POST"},);
const result = await response.json();
if (result.ok) {
@@ -1753,7 +1755,7 @@ async function testDNSRedirection(deviceId) {
try {
const query = `?target_url=${encodeURIComponent(targetUrl)}`;
- const response = await fetch(`/setup/test-dns/${encodeURIComponent(deviceId)}${query}`, {method: "POST"},);
+ const response = await fetch(`/setup/devices/${encodeURIComponent(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/bose.ttf b/pkg/service/handlers/web/stockholm-mini/bose.ttf
old mode 100644
new mode 100755
diff --git a/pkg/service/setup/setup.go b/pkg/service/setup/setup.go
index 415b56a..58466bf 100644
--- a/pkg/service/setup/setup.go
+++ b/pkg/service/setup/setup.go
@@ -139,6 +139,10 @@ type DeviceInfoXML struct {
} `xml:"networkInfo" json:"networkInfo"`
SoftwareVer string `xml:"-" json:"softwareVersion"`
SerialNumber string `xml:"-" json:"serialNumber"`
+
+ // 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.
@@ -186,6 +190,16 @@ func (m *Manager) parseDeviceInfoXML(reader io.Reader, infoXML *DeviceInfoXML) e
}
}
+ // 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 nil
}