Add Stockholm Mini

This is also a refactoring of our api paths
This commit is contained in:
Tobias Gesellchen
2026-02-21 00:49:18 +01:00
parent ec8bbb2f86
commit 1288a619f7
28 changed files with 2096 additions and 749 deletions
+1
View File
@@ -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
+30 -18
View File
@@ -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)
+1 -1
View File
@@ -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
+1
View File
@@ -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)
+52
View File
@@ -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.
+44 -17
View File
@@ -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"
+7
View File
@@ -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 {
+2 -2
View File
@@ -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)
+1 -1
View File
@@ -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/*
+79 -8
View File
@@ -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"))
}
}
+108 -58
View File
@@ -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
}
+30 -20
View File
@@ -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 := `<?xml version="1.0" encoding="UTF-8" ?><info deviceID="TEST-DEVICE-ID"><name>Test Device</name><type>SoundTouch 10</type></info>`
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)
}
+124
View File
@@ -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)
}
}
+203
View File
@@ -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})
}()
}
+44 -11
View File
@@ -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)
+20 -4
View File
@@ -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
}
+21
View File
@@ -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)
}
+82 -457
View File
@@ -2,472 +2,97 @@
<html>
<head>
<meta charset="UTF-8">
<title>AfterTouch (SoundTouch Toolkit)</title>
<title>AfterTouch - Select Interface</title>
<link rel="icon" href="/media/favicon-braille.svg" type="image/svg+xml">
<link rel="stylesheet" href="/web/css/style.css">
<link rel="stylesheet" href="/web/shared/common.css">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: #121212;
color: #e0e0e0;
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
}
.container {
text-align: center;
max-width: 600px;
}
h1 { color: #fff; margin-bottom: 30px; }
.choices {
display: flex;
gap: 20px;
justify-content: center;
}
.choice-card {
background: #1e1e1e;
border-radius: 12px;
padding: 30px;
width: 200px;
text-decoration: none;
color: inherit;
transition: all 0.3s;
border: 2px solid transparent;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 10px 20px rgba(0,0,0,0.5);
}
.choice-card:hover {
transform: translateY(-5px);
border-color: #00bcd4;
background: #252525;
}
.icon {
font-size: 3rem;
margin-bottom: 15px;
}
.title {
font-weight: bold;
font-size: 1.2rem;
margin-bottom: 10px;
color: #00bcd4;
}
.desc {
font-size: 0.9rem;
color: #888;
}
footer {
margin-top: 50px;
}
</style>
</head>
<body>
<h1>AfterTouch</h1>
<p style="margin-top: -10px; font-style: italic; color: #666;">Bose SoundTouch Toolkit</p>
<div class="container">
<h1>AfterTouch</h1>
<p style="margin-top: -25px; font-style: italic; color: #666; margin-bottom: 30px;">Bose SoundTouch Toolkit</p>
<p style="margin-bottom: 40px; color: #aaa;">Select an interface to continue.</p>
<div class="tabs">
<div class="tab-buttons">
<button class="tab-btn active" onclick="openTab(event, 'tab-overview')">Overview</button>
<button class="tab-btn" onclick="openTab(event, 'tab-settings')">1. Settings</button>
<button class="tab-btn" onclick="openTab(event, 'tab-devices')">2. Devices</button>
<button class="tab-btn" onclick="openTab(event, 'tab-sync')">3. Data Sync</button>
<button class="tab-btn" onclick="openTab(event, 'tab-migration')">4. Migration</button>
<button class="tab-btn" onclick="openTab(event, 'tab-interactions')">5. Interactions & Events</button>
</div>
<div class="choices">
<a href="/web/stockholm-mini/" class="choice-card">
<div class="icon">📻</div>
<div class="title">Stockholm Mini</div>
<div class="desc">Lightweight device controller and player.</div>
</a>
<!-- Tab 0: Overview -->
<div id="tab-overview" class="tab-content active">
<h2>Welcome to AfterTouch</h2>
<p>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.</p>
<h3>Migration Process at a Glance</h3>
<div class="info-box prerequisite-box">
<strong>🔌 Prerequisite: Enable SSH</strong><br>
Migration requires SSH access. To enable it:
<ol style="margin-top: 5px; margin-bottom: 5px;">
<li>Create an empty file named <code>remote_services</code> on a USB stick.</li>
<li>Insert it into the speaker's <strong>SERVICE</strong> port and reboot the speaker.</li>
</ol>
<strong>Verify connection:</strong>
<ul style="margin-top: 5px; margin-bottom: 0; padding-left: 20px;">
<li>Use the <strong>Migration</strong> tab to select your device and verify that <em>SSH Connection</em> shows ✅ Success.</li>
<li>Or manually: <code>ssh -oHostKeyAlgorithms=+ssh-rsa root@&lt;SPEAKER-IP&gt;</code> (no password).</li>
</ul>
</div>
<ol class="guide-steps">
<li>
<strong>Settings:</strong> Review the <strong>Settings</strong> tab. Ensure the "Target Domain" and "Proxy Domain" use an IP address or domain name that is <strong>accessible from your speakers</strong> (usually the IP of this server on your local network).
</li>
<li>
<strong>Discovery:</strong> Go to the <strong>Devices</strong> tab to find your speakers on the network.
Ensure your speakers are powered on and connected to the same network.
</li>
<li>
<strong>Data Sync:</strong> In the <strong>Data Sync</strong> 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.
</li>
<li>
<strong>Migration:</strong> In the <strong>Migration</strong> tab, redirect your speaker to this local service.
We recommend the <strong>XML Configuration</strong> method as it is surgical and easily reversible.
</li>
<li>
<strong>Verification:</strong> After migration and reboot, your speaker will communicate with this toolkit instead of Bose servers.
</li>
</ol>
<div class="info-box safety-box">
<strong>⚠️ Safety First:</strong> Before starting any migration, please read our
<a href="https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-SAFETY.html" target="_blank">Professional Migration & Safety Guide</a>.
The toolkit automatically creates backups, but understanding the process is key to a smooth transition.
</div>
<h3>Useful Links</h3>
<ul>
<li><a href="https://gesellix.github.io/Bose-SoundTouch/guides/SURVIVAL-GUIDE.html" target="_blank">Cloud Shutdown Survival Guide</a></li>
<li><a href="https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html" target="_blank">CLI Reference</a></li>
</ul>
</div>
<!-- Tab 1: Settings -->
<div id="tab-settings" class="tab-content">
<h2>System Settings</h2>
<p style="font-size: 0.9em; color: #555; margin-bottom: 20px;">
<strong>Note:</strong> These URLs must be <strong>accessible from your SoundTouch devices</strong>.
Use the IP address of this server on your local network (e.g., <code>http://192.168.1.100:8000</code>)
rather than <code>localhost</code>.
</p>
<div style="margin-bottom: 20px;">
<label for="target-domain">Target Domain:</label>
<input type="text" id="target-domain" placeholder="http://192.168.x.x:8000" style="width: 300px;">
<span style="font-size: 0.8em; color: #666;">(Standard services URL)</span>
</div>
<div style="margin-bottom: 20px;">
<label for="soundcork-url">Soundcork URL:</label>
<input type="text" id="soundcork-url" placeholder="http://192.168.x.x:8001" style="width: 300px;">
<span style="font-size: 0.8em; color: #666;">(Soundcork services URL)</span>
</div>
<div style="margin-bottom: 20px;">
<label for="discovery-interval">Discovery Interval:</label>
<input type="text" id="discovery-interval" placeholder="5m" style="width: 100px;">
<label style="margin-left: 15px;"><input type="checkbox" id="discovery-enabled"> Enable Automated Discovery</label>
</div>
<div style="margin-bottom: 20px;">
<button onclick="updateSettings()">Save Settings</button>
<span id="settings-status" style="margin-left: 10px; font-size: 0.9em;"></span>
</div>
<div style="margin-bottom: 20px;">
<strong>DNS Discovery:</strong>
<div style="margin-top: 5px;">
<label style="display: block; margin-bottom: 5px;">
<input type="checkbox" id="dns-enabled"> Enable DNS Discovery Server
</label>
<div style="margin-left: 20px; margin-bottom: 5px;">
<label for="dns-upstream">Upstream DNS:</label>
<input type="text" id="dns-upstream" placeholder="8.8.8.8" style="width: 150px;">
<span style="font-size: 0.8em; color: #666; margin-left: 5px;">(For non-intercepted queries)</span>
</div>
<div style="margin-left: 20px;">
<label for="dns-bind">DNS Bind Address:</label>
<input type="text" id="dns-bind" placeholder=":53" style="width: 100px;">
<span style="font-size: 0.8em; color: #666; margin-left: 5px;">(e.g., :53 or 0.0.0.0:53. <strong>Port 53</strong> is required for actual migration)</span>
</div>
</div>
</div>
<div style="margin-bottom: 20px;">
<strong>Proxy Logging:</strong>
<div style="margin-top: 5px;">
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="proxy-redact" onchange="updateProxySettings()"> Redact Sensitive Headers</label>
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="proxy-log-body" onchange="updateProxySettings()"> Log Bodies</label>
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="enable-soundcork-proxy" onchange="updateProxySettings()"> Enable Soundcork Proxy (Legacy)</label>
<label style="display: block; margin-bottom: 5px;">
<input type="checkbox" id="proxy-record" onchange="updateProxySettings()"> Record Interactions
<span style="font-size: 0.85em; color: #666; margin-left: 5px;">(View in <strong>5. Interactions</strong> tab)</span>
</label>
</div>
</div>
</div>
<!-- Tab 2: Devices -->
<div id="tab-devices" class="tab-content">
<h2>Known Devices <span id="discovery-indicator" style="font-size: 0.5em; vertical-align: middle; display: none;">🔍 Scanning...</span></h2>
<div id="device-list">Loading devices...</div>
<div style="margin-top: 20px;">
<button onclick="triggerDiscovery()">Scan Again</button>
<input type="text" id="add-manual-ip" placeholder="Manual IP (e.g. 192.168.1.100)" style="margin-left: 20px; padding: 4px;">
<button onclick="addManualDevice()">Add Device</button>
</div>
</div>
<!-- Tab 3: Data Sync -->
<div id="tab-sync" class="tab-content">
<h2>Initial Data Sync</h2>
<p>Before migrating, fetch your presets, recents, and configured sources from the device to ensure they are available locally.</p>
<div class="device-selection">
<label for="sync-device-list">Device:</label>
<select id="sync-device-list">
<option value="">-- Select a device --</option>
</select>
<button id="sync-now-btn">Start Sync</button>
</div>
<div id="sync-status" class="status"></div>
<div id="sync-results" style="margin-top: 20px; display: none;">
<h3>Sync Results</h3>
<div id="sync-log" style="font-family: monospace; background: #f4f4f4; padding: 10px; border-radius: 4px; max-height: 300px; overflow-y: auto;"></div>
</div>
</div>
<!-- Tab 4: Migration -->
<div id="tab-migration" class="tab-content">
<h2>Device Migration</h2>
<div class="device-selection">
<label for="migration-device-list">Device:</label>
<select id="migration-device-list" onchange="showSummary(this.value)">
<option value="">-- Select a device --</option>
</select>
</div>
<div id="status" class="status"></div>
<div id="command-output-box" class="summary-box" style="display: none; background-color: #f0f0f0;">
<h3>Command Output</h3>
<div id="command-output" style="font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 300px; overflow-y: auto; padding: 10px; border: 1px solid #ccc; background: #fff;"></div>
</div>
<div id="migration-summary" class="summary-box" style="display: none;">
<h3>Migration Summary for <span id="summary-ip"></span></h3>
<p>Migration Status: <span id="migration-status"></span></p>
<p>SSH Connection: <span id="ssh-status"></span></p>
<p id="original-config-status" style="display: none;">Backup: ✅ Found .original config at <code>/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml.original</code> <button onclick="toggleOriginalConfig()">Show Original Config</button></p>
<p id="no-original-config-status" style="display: none;">Backup: ❌ Not found <button id="backup-config-btn">Backup Config Now</button></p>
<p>Remote Services Enabled: <span id="remote-services-status"></span> <span id="remote-services-found" style="font-size: 0.8em; color: #666;"></span></p>
<p>AfterTouch Local Root CA Trusted: <span id="ca-trust-status"></span> <button id="trust-ca-btn" style="display: none; background-color: #607D8B; color: white; border: none; padding: 2px 8px; font-size: 0.8em; margin-left: 10px;">Trust CA Now</button></p>
<div id="connection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #eefbff;">
<strong>HTTPS Connection Test:</strong><br>
<span style="font-size: 0.85em; color: #555;">Verify the device can reach the server over HTTPS.</span>
<div style="margin-top: 10px;">
URL: <code id="test-url"></code>
</div>
<div style="margin-top: 10px;">
<button id="test-connection-explicit-btn" style="background-color: #607D8B; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test with Explicit CA.crt</button>
<button id="test-connection-trusted-btn" style="background-color: #607D8B; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test with Shared Trust Store</button>
</div>
<div id="test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
</div>
<div id="hosts-redirection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #fff4e6; display: none;">
<strong>Preliminary /etc/hosts Test:</strong><br>
<span style="font-size: 0.85em; color: #555;">Verify the device's /etc/hosts mechanism before full migration.</span>
<div style="margin-top: 10px;">
Domain: <code>custom-test-api.bose.fake</code>
</div>
<div style="margin-top: 10px;">
<button id="test-hosts-btn" style="background-color: #FF9800; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test Hosts Redirection</button>
</div>
<div id="hosts-test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
</div>
<div id="dns-redirection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #e6ffed; display: none;">
<strong>Preliminary DNS Test:</strong><br>
<span style="font-size: 0.85em; color: #555;">Verify the device can resolve domains via the AfterTouch DNS server.</span>
<div style="margin-top: 10px;">
Domain: <code>aftertouch.test</code>
</div>
<div style="margin-top: 10px;">
<button id="test-dns-btn" style="background-color: #28a745; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test DNS Redirection</button>
</div>
<div id="dns-test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
</div>
<div style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #f9f9f9;">
<label for="migration-method"><strong>Migration Method:</strong></label>
<select id="migration-method" onchange="toggleMigrationMethod()">
<option value="xml">XML Configuration (Recommended - redirects specific services)</option>
<option value="hosts">/etc/hosts + Root CA (Advanced - global redirection)</option>
<option value="resolv">/etc/resolv.conf (DHCP-Aware - Most flexible)</option>
</select>
<div id="dns-port-warning" style="margin-top: 5px; color: #d32f2f; font-weight: bold; font-size: 0.9em; display: none;"></div>
</div>
<div id="current-resolv-pane" style="display: none; margin-bottom: 20px;">
<span class="config-header">Current /etc/resolv.conf</span>
<pre id="current-resolv-content"></pre>
</div>
<div id="original-config-pane" style="display: none; margin-bottom: 20px;">
<span class="config-header">Original Config (Backup)</span>
<pre id="original-config-content"></pre>
</div>
<div id="service-options" style="margin-bottom: 20px; display: none;">
<h4>Service Implementations</h4>
<table>
<tr><th>Service</th><th>Original URL</th><th>Implementation</th></tr>
<tr>
<td>Marge (Streaming)</td>
<td id="orig-marge">loading...</td>
<td>
<select id="opt-marge" onchange="refreshSummary()">
<option value="self">AfterTouch (Local Service)</option>
<option value="upstream">Upstream (Proxy via local service)</option>
</select>
</td>
</tr>
<tr>
<td>Stats</td>
<td id="orig-stats">loading...</td>
<td>
<select id="opt-stats" onchange="refreshSummary()">
<option value="self">AfterTouch (Local Service)</option>
<option value="upstream">Upstream (Proxy via local service)</option>
</select>
</td>
</tr>
<tr>
<td>Software Update</td>
<td id="orig-sw_update">loading...</td>
<td>
<select id="opt-sw_update" onchange="refreshSummary()">
<option value="self">AfterTouch (Local Service)</option>
<option value="upstream">Upstream (Proxy via local service)</option>
</select>
</td>
</tr>
<tr>
<td>BMX (Registry)</td>
<td id="orig-bmx">loading...</td>
<td>
<select id="opt-bmx" onchange="refreshSummary()">
<option value="self">AfterTouch (Local Service)</option>
<option value="upstream">Upstream (Proxy via local service)</option>
</select>
</td>
</tr>
</table>
</div>
<div class="diff-container">
<div id="xml-diff-pane" class="diff-pane">
<span class="config-header">Current Config (on Speaker)</span>
<pre id="current-config"></pre>
</div>
<div id="planned-xml-pane" class="diff-pane">
<span class="config-header">Planned Config (AfterTouch)</span>
<pre id="planned-config"></pre>
</div>
<div id="planned-hosts-pane" class="diff-pane" style="display: none;">
<span class="config-header">Planned /etc/hosts Entries</span>
<pre id="planned-hosts"></pre>
<div style="margin-top: 10px; font-size: 0.9em; color: #666;">
<strong>Note:</strong> This method also injects the AfterTouch Local Root CA into <code>/etc/pki/tls/certs/ca-bundle.crt</code> to enable secure HTTPS communication.
</div>
</div>
<div id="planned-resolv-pane" class="diff-pane" style="display: none;">
<span class="config-header">Planned /etc/resolv.conf Hook</span>
<pre id="planned-resolv"></pre>
<div id="resolv-note" style="margin-top: 10px; font-size: 0.9em; color: #666;">
<strong>Note:</strong> This method injects a persistent DNS priority hook into the DHCP logic (<code>/etc/udhcpc.d/50default</code>). It preserves your router's search domain and secondary DNS servers. It also injects the Local Root CA.
</div>
</div>
</div>
<div style="margin-top: 15px;">
<button id="confirm-migrate-btn" style="background-color: #4CAF50; color: white; border: none; padding: 10px 20px;">Confirm Migration</button>
<button id="revert-migrate-btn" style="background-color: #FF9800; color: white; border: none; padding: 10px 20px; display: none;">Revert to Defaults</button>
<button id="reboot-speaker-btn" style="background-color: #607D8B; color: white; border: none; padding: 10px 20px;">Reboot Speaker</button>
<button id="ensure-remote-btn" style="background-color: #2196F3; color: white; border: none; padding: 10px 20px;">Enable Persistent Remote Services</button>
<button id="remove-remote-btn" style="background-color: #f44336; color: white; border: none; padding: 10px 20px;">Remove Persistent Remote Services</button>
<button onclick="document.getElementById('migration-summary').style.display='none'" style="padding: 10px 20px;">Cancel</button>
</div>
</div>
</div>
<!-- Tab 5: Interactions & Events -->
<div id="tab-interactions" class="tab-content">
<h2>Recorded Interactions & Device Events</h2>
<p>Analysis of traffic handled by this service (self), proxied to Bose (upstream), and internal device events (telemetry).</p>
<div id="interaction-stats-container" class="summary-box">
<div style="display: flex; gap: 20px; align-items: center; margin-bottom: 15px;">
<p style="margin: 0;">Total Requests: <strong id="total-requests">0</strong></p>
<button onclick="fetchInteractionStats()">Refresh Stats</button>
<div style="margin-left: 10px;">
<button onclick="showDeviceEvents()">View App/Device Events</button>
</div>
<div style="margin-left: auto; text-align: right;">
<button onclick="cleanupSessions()" class="btn-danger">Cleanup old sessions</button>
<div style="font-size: 0.75em; color: #666; margin-top: 3px;">Keeps only the 10 most recent sessions</div>
</div>
</div>
<div style="display: flex; gap: 20px;">
<div style="flex: 1; border-right: 1px solid #eee; padding-right: 20px;">
<h3>By Service</h3>
<ul id="stats-by-service" class="stats-list"></ul>
</div>
<div style="flex: 2;">
<h3>Sessions</h3>
<div id="stats-by-session-container" style="max-height: 200px; overflow-y: auto; border: 1px solid #eee; padding: 5px; border-radius: 4px;">
<ul id="stats-by-session" class="stats-list"></ul>
</div>
</div>
</div>
</div>
<div id="browse-recordings" class="summary-box" style="margin-top: 20px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h3 style="margin: 0;">Browse Recordings</h3>
</div>
<div style="margin-bottom: 15px; display: flex; gap: 15px; align-items: center; background: #f9f9f9; padding: 10px; border-radius: 4px;">
<div>
<label for="filter-session">Session:</label>
<select id="filter-session" onchange="fetchInteractions()">
<option value="">All Sessions</option>
</select>
</div>
<div>
<label for="filter-category">Category:</label>
<select id="filter-category" onchange="fetchInteractions()">
<option value="">All Categories</option>
<option value="self">Self (Emulated)</option>
<option value="upstream">Upstream (Bose)</option>
</select>
</div>
<div>
<label for="filter-since">Since (YYYY-MM-DD HH:mm:ss):</label>
<input type="text" id="filter-since" placeholder="e.g. 2026-02-15 15:00:00" size="25" onchange="fetchInteractions()">
</div>
<button onclick="fetchInteractions()">Apply Filters</button>
</div>
<div id="interactions-list-container" style="max-height: 400px; overflow-y: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="text-align: left; border-bottom: 2px solid #eee;">
<th style="padding: 8px;">#</th>
<th style="padding: 8px;">Time</th>
<th style="padding: 8px;">Method</th>
<th style="padding: 8px;">Path</th>
<th style="padding: 8px;">Status</th>
<th style="padding: 8px;">Category</th>
<th style="padding: 8px;">Action</th>
</tr>
</thead>
<tbody id="interactions-list">
<tr><td colspan="7" style="padding: 20px; text-align: center; color: #666;">No interactions found.</td></tr>
</tbody>
</table>
</div>
</div>
<div id="dns-discoveries" class="summary-box" style="margin-top: 20px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h3 style="margin: 0;">DNS Discoveries</h3>
<button onclick="clearDNSDiscoveries()" class="btn-danger">Clear DNS Logs</button>
</div>
<p style="font-size: 0.85em; color: #666;">Hosts discovered via the AfterTouch DNS server. "Self" means the domain was intercepted and redirected to this service.</p>
<div id="dns-discoveries-list-container" style="max-height: 400px; overflow-y: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="text-align: left; border-bottom: 2px solid #eee;">
<th style="padding: 8px;">Hostname</th>
<th style="padding: 8px;">Last Seen</th>
<th style="padding: 8px; text-align: center;">Queries</th>
<th style="padding: 8px; text-align: center;">Bose?</th>
<th style="padding: 8px;">Category</th>
<th style="padding: 8px;">Last Client IP</th>
</tr>
</thead>
<tbody id="dns-discoveries-list">
<tr><td colspan="6" style="padding: 20px; text-align: center; color: #666;">No DNS discoveries found.</td></tr>
</tbody>
</table>
</div>
</div>
<div id="interaction-viewer" class="summary-box" style="margin-top: 20px; display: none; background: #2b2b2b; color: #a9b7c6;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<h3 style="margin: 0; color: #fff;">Recording Viewer: <span id="viewer-filename" style="font-weight: normal; font-size: 0.8em;"></span></h3>
<button onclick="document.getElementById('interaction-viewer').style.display='none'" style="background: #444; color: #fff; border: 1px solid #666;">Close</button>
</div>
<pre id="interaction-content" style="white-space: pre-wrap; font-family: 'Courier New', Courier, monospace; font-size: 0.9em; margin: 0; padding: 10px; overflow-x: auto; max-height: 600px;"></pre>
</div>
<!-- Device Events Overlay -->
<div id="device-events-overlay" class="summary-box" style="margin-top: 20px; display: none; background: #fdfdfd; border: 1px solid #ddd;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<h3 style="margin: 0;">App & Device Events</h3>
<div>
<select id="event-device-selector" onchange="fetchDeviceEvents(this.value)">
<option value="">-- Select Device --</option>
</select>
<button onclick="document.getElementById('device-events-overlay').style.display='none'" style="margin-left: 10px;">Close</button>
</div>
</div>
<div id="events-list-container" style="max-height: 400px; overflow-y: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="text-align: left; border-bottom: 2px solid #eee;">
<th style="padding: 8px;">Time</th>
<th style="padding: 8px;">Type</th>
<th style="padding: 8px;">Data</th>
</tr>
</thead>
<tbody id="events-list">
<tr><td colspan="3" style="padding: 20px; text-align: center; color: #666;">Select a device to view events.</td></tr>
</tbody>
</table>
</div>
</div>
<a href="/web/migration/" class="choice-card">
<div class="icon">⚙️</div>
<div class="title">Migration</div>
<div class="desc">Setup, data sync, and cloud migration toolkit.</div>
</a>
</div>
</div>
<script src="/web/js/script.js"></script>
<footer style="margin-top: 50px; padding: 20px; border-top: 1px solid #eee; font-size: 0.8em; color: #888; text-align: center;">
<footer>
<span id="version-info">AfterTouch</span>
</footer>
<script src="/web/shared/common.js"></script>
<script>
fetchVersion();
</script>
</body>
</html>
@@ -0,0 +1,476 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>AfterTouch (SoundTouch Toolkit)</title>
<link rel="icon" href="/media/favicon-braille.svg" type="image/svg+xml">
<link rel="stylesheet" href="../shared/common.css">
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>AfterTouch</h1>
<p style="margin-top: -10px; font-style: italic; color: #666;">Bose SoundTouch Toolkit</p>
<p style="margin-bottom: 20px;"><a href="/">&larr; Back to selection</a></p>
<div class="tabs">
<div class="tab-buttons">
<button class="tab-btn active" onclick="openTab(event, 'tab-overview')">Overview</button>
<button class="tab-btn" onclick="openTab(event, 'tab-settings')">1. Settings</button>
<button class="tab-btn" onclick="openTab(event, 'tab-devices')">2. Devices</button>
<button class="tab-btn" onclick="openTab(event, 'tab-sync')">3. Data Sync</button>
<button class="tab-btn" onclick="openTab(event, 'tab-migration')">4. Migration</button>
<button class="tab-btn" onclick="openTab(event, 'tab-interactions')">5. Interactions & Events</button>
</div>
<!-- Tab 0: Overview -->
<div id="tab-overview" class="tab-content active">
<h2>Welcome to AfterTouch</h2>
<p>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.</p>
<h3>Migration Process at a Glance</h3>
<div class="info-box prerequisite-box">
<strong>🔌 Prerequisite: Enable SSH</strong><br>
Migration requires SSH access. To enable it:
<ol style="margin-top: 5px; margin-bottom: 5px;">
<li>Create an empty file named <code>remote_services</code> on a USB stick.</li>
<li>Insert it into the speaker's <strong>SERVICE</strong> port and reboot the speaker.</li>
</ol>
<strong>Verify connection:</strong>
<ul style="margin-top: 5px; margin-bottom: 0; padding-left: 20px;">
<li>Use the <strong>Migration</strong> tab to select your device and verify that <em>SSH Connection</em> shows ✅ Success.</li>
<li>Or manually: <code>ssh -oHostKeyAlgorithms=+ssh-rsa root@&lt;SPEAKER-IP&gt;</code> (no password).</li>
</ul>
</div>
<ol class="guide-steps">
<li>
<strong>Settings:</strong> Review the <strong>Settings</strong> tab. Ensure the "Target Domain" and "Proxy Domain" use an IP address or domain name that is <strong>accessible from your speakers</strong> (usually the IP of this server on your local network).
</li>
<li>
<strong>Discovery:</strong> Go to the <strong>Devices</strong> tab to find your speakers on the network.
Ensure your speakers are powered on and connected to the same network.
</li>
<li>
<strong>Data Sync:</strong> In the <strong>Data Sync</strong> 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.
</li>
<li>
<strong>Migration:</strong> In the <strong>Migration</strong> tab, redirect your speaker to this local service.
We recommend the <strong>XML Configuration</strong> method as it is surgical and easily reversible.
</li>
<li>
<strong>Verification:</strong> After migration and reboot, your speaker will communicate with this toolkit instead of Bose servers.
</li>
</ol>
<div class="info-box safety-box">
<strong>⚠️ Safety First:</strong> Before starting any migration, please read our
<a href="https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-SAFETY.html" target="_blank">Professional Migration & Safety Guide</a>.
The toolkit automatically creates backups, but understanding the process is key to a smooth transition.
</div>
<h3>Useful Links</h3>
<ul>
<li><a href="https://gesellix.github.io/Bose-SoundTouch/guides/SURVIVAL-GUIDE.html" target="_blank">Cloud Shutdown Survival Guide</a></li>
<li><a href="https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html" target="_blank">CLI Reference</a></li>
</ul>
</div>
<!-- Tab 1: Settings -->
<div id="tab-settings" class="tab-content">
<h2>System Settings</h2>
<p style="font-size: 0.9em; color: #555; margin-bottom: 20px;">
<strong>Note:</strong> These URLs must be <strong>accessible from your SoundTouch devices</strong>.
Use the IP address of this server on your local network (e.g., <code>http://192.168.1.100:8000</code>)
rather than <code>localhost</code>.
</p>
<div style="margin-bottom: 20px;">
<label for="target-domain">Target Domain:</label>
<input type="text" id="target-domain" placeholder="http://192.168.x.x:8000" style="width: 300px;">
<span style="font-size: 0.8em; color: #666;">(Standard services URL)</span>
</div>
<div style="margin-bottom: 20px;">
<label for="soundcork-url">Soundcork URL:</label>
<input type="text" id="soundcork-url" placeholder="http://192.168.x.x:8001" style="width: 300px;">
<span style="font-size: 0.8em; color: #666;">(Soundcork services URL)</span>
</div>
<div style="margin-bottom: 20px;">
<label for="discovery-interval">Discovery Interval:</label>
<input type="text" id="discovery-interval" placeholder="5m" style="width: 100px;">
<label style="margin-left: 15px;"><input type="checkbox" id="discovery-enabled"> Enable Automated Discovery</label>
</div>
<div style="margin-bottom: 20px;">
<button onclick="updateSettings()">Save Settings</button>
<span id="settings-status" style="margin-left: 10px; font-size: 0.9em;"></span>
</div>
<div style="margin-bottom: 20px;">
<strong>DNS Discovery:</strong>
<div style="margin-top: 5px;">
<label style="display: block; margin-bottom: 5px;">
<input type="checkbox" id="dns-enabled"> Enable DNS Discovery Server
</label>
<div style="margin-left: 20px; margin-bottom: 5px;">
<label for="dns-upstream">Upstream DNS:</label>
<input type="text" id="dns-upstream" placeholder="8.8.8.8" style="width: 150px;">
<span style="font-size: 0.8em; color: #666; margin-left: 5px;">(For non-intercepted queries)</span>
</div>
<div style="margin-left: 20px;">
<label for="dns-bind">DNS Bind Address:</label>
<input type="text" id="dns-bind" placeholder=":53" style="width: 100px;">
<span style="font-size: 0.8em; color: #666; margin-left: 5px;">(e.g., :53 or 0.0.0.0:53. <strong>Port 53</strong> is required for actual migration)</span>
</div>
</div>
</div>
<div style="margin-bottom: 20px;">
<strong>Proxy Logging:</strong>
<div style="margin-top: 5px;">
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="proxy-redact" onchange="updateProxySettings()"> Redact Sensitive Headers</label>
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="proxy-log-body" onchange="updateProxySettings()"> Log Bodies</label>
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="enable-soundcork-proxy" onchange="updateProxySettings()"> Enable Soundcork Proxy (Legacy)</label>
<label style="display: block; margin-bottom: 5px;">
<input type="checkbox" id="proxy-record" onchange="updateProxySettings()"> Record Interactions
<span style="font-size: 0.85em; color: #666; margin-left: 5px;">(View in <strong>5. Interactions</strong> tab)</span>
</label>
</div>
</div>
</div>
<!-- Tab 2: Devices -->
<div id="tab-devices" class="tab-content">
<h2>Known Devices <span id="discovery-indicator" style="font-size: 0.5em; vertical-align: middle; display: none;">🔍 Scanning...</span></h2>
<div id="device-list">Loading devices...</div>
<div style="margin-top: 20px;">
<button onclick="triggerDiscovery()">Scan Again</button>
<input type="text" id="add-manual-ip" placeholder="Manual IP (e.g. 192.168.1.100)" style="margin-left: 20px; padding: 4px;">
<button onclick="addManualDevice()">Add Device</button>
</div>
</div>
<!-- Tab 3: Data Sync -->
<div id="tab-sync" class="tab-content">
<h2>Initial Data Sync</h2>
<p>Before migrating, fetch your presets, recents, and configured sources from the device to ensure they are available locally.</p>
<div class="device-selection">
<label for="sync-device-list">Device:</label>
<select id="sync-device-list">
<option value="">-- Select a device --</option>
</select>
<button id="sync-now-btn">Start Sync</button>
</div>
<div id="sync-status" class="status"></div>
<div id="sync-results" style="margin-top: 20px; display: none;">
<h3>Sync Results</h3>
<div id="sync-log" style="font-family: monospace; background: #f4f4f4; padding: 10px; border-radius: 4px; max-height: 300px; overflow-y: auto;"></div>
</div>
</div>
<!-- Tab 4: Migration -->
<div id="tab-migration" class="tab-content">
<h2>Device Migration</h2>
<div class="device-selection">
<label for="migration-device-list">Device:</label>
<select id="migration-device-list" onchange="showSummary(this.value)">
<option value="">-- Select a device --</option>
</select>
</div>
<div id="status" class="status"></div>
<div id="command-output-box" class="summary-box" style="display: none; background-color: #f0f0f0;">
<h3>Command Output</h3>
<div id="command-output" style="font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 300px; overflow-y: auto; padding: 10px; border: 1px solid #ccc; background: #fff;"></div>
</div>
<div id="migration-summary" class="summary-box" style="display: none;">
<h3>Migration Summary for <span id="summary-ip"></span></h3>
<p>Migration Status: <span id="migration-status"></span></p>
<p>SSH Connection: <span id="ssh-status"></span></p>
<p id="original-config-status" style="display: none;">Backup: ✅ Found .original config at <code>/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml.original</code> <button onclick="toggleOriginalConfig()">Show Original Config</button></p>
<p id="no-original-config-status" style="display: none;">Backup: ❌ Not found <button id="backup-config-btn">Backup Config Now</button></p>
<p>Remote Services Enabled: <span id="remote-services-status"></span> <span id="remote-services-found" style="font-size: 0.8em; color: #666;"></span></p>
<p>AfterTouch Local Root CA Trusted: <span id="ca-trust-status"></span> <button id="trust-ca-btn" style="display: none; background-color: #607D8B; color: white; border: none; padding: 2px 8px; font-size: 0.8em; margin-left: 10px;">Trust CA Now</button></p>
<div id="connection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #eefbff;">
<strong>HTTPS Connection Test:</strong><br>
<span style="font-size: 0.85em; color: #555;">Verify the device can reach the server over HTTPS.</span>
<div style="margin-top: 10px;">
URL: <code id="test-url"></code>
</div>
<div style="margin-top: 10px;">
<button id="test-connection-explicit-btn" style="background-color: #607D8B; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test with Explicit CA.crt</button>
<button id="test-connection-trusted-btn" style="background-color: #607D8B; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test with Shared Trust Store</button>
</div>
<div id="test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
</div>
<div id="hosts-redirection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #fff4e6; display: none;">
<strong>Preliminary /etc/hosts Test:</strong><br>
<span style="font-size: 0.85em; color: #555;">Verify the device's /etc/hosts mechanism before full migration.</span>
<div style="margin-top: 10px;">
Domain: <code>custom-test-api.bose.fake</code>
</div>
<div style="margin-top: 10px;">
<button id="test-hosts-btn" style="background-color: #FF9800; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test Hosts Redirection</button>
</div>
<div id="hosts-test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
</div>
<div id="dns-redirection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #e6ffed; display: none;">
<strong>Preliminary DNS Test:</strong><br>
<span style="font-size: 0.85em; color: #555;">Verify the device can resolve domains via the AfterTouch DNS server.</span>
<div style="margin-top: 10px;">
Domain: <code>aftertouch.test</code>
</div>
<div style="margin-top: 10px;">
<button id="test-dns-btn" style="background-color: #28a745; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test DNS Redirection</button>
</div>
<div id="dns-test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
</div>
<div style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #f9f9f9;">
<label for="migration-method"><strong>Migration Method:</strong></label>
<select id="migration-method" onchange="toggleMigrationMethod()">
<option value="xml">XML Configuration (Recommended - redirects specific services)</option>
<option value="hosts">/etc/hosts + Root CA (Advanced - global redirection)</option>
<option value="resolv">/etc/resolv.conf (DHCP-Aware - Most flexible)</option>
</select>
<div id="dns-port-warning" style="margin-top: 5px; color: #d32f2f; font-weight: bold; font-size: 0.9em; display: none;"></div>
</div>
<div id="current-resolv-pane" style="display: none; margin-bottom: 20px;">
<span class="config-header">Current /etc/resolv.conf</span>
<pre id="current-resolv-content"></pre>
</div>
<div id="original-config-pane" style="display: none; margin-bottom: 20px;">
<span class="config-header">Original Config (Backup)</span>
<pre id="original-config-content"></pre>
</div>
<div id="service-options" style="margin-bottom: 20px; display: none;">
<h4>Service Implementations</h4>
<table>
<tr><th>Service</th><th>Original URL</th><th>Implementation</th></tr>
<tr>
<td>Marge (Streaming)</td>
<td id="orig-marge">loading...</td>
<td>
<select id="opt-marge" onchange="refreshSummary()">
<option value="self">AfterTouch (Local Service)</option>
<option value="upstream">Upstream (Proxy via local service)</option>
</select>
</td>
</tr>
<tr>
<td>Stats</td>
<td id="orig-stats">loading...</td>
<td>
<select id="opt-stats" onchange="refreshSummary()">
<option value="self">AfterTouch (Local Service)</option>
<option value="upstream">Upstream (Proxy via local service)</option>
</select>
</td>
</tr>
<tr>
<td>Software Update</td>
<td id="orig-sw_update">loading...</td>
<td>
<select id="opt-sw_update" onchange="refreshSummary()">
<option value="self">AfterTouch (Local Service)</option>
<option value="upstream">Upstream (Proxy via local service)</option>
</select>
</td>
</tr>
<tr>
<td>BMX (Registry)</td>
<td id="orig-bmx">loading...</td>
<td>
<select id="opt-bmx" onchange="refreshSummary()">
<option value="self">AfterTouch (Local Service)</option>
<option value="upstream">Upstream (Proxy via local service)</option>
</select>
</td>
</tr>
</table>
</div>
<div class="diff-container">
<div id="xml-diff-pane" class="diff-pane">
<span class="config-header">Current Config (on Speaker)</span>
<pre id="current-config"></pre>
</div>
<div id="planned-xml-pane" class="diff-pane">
<span class="config-header">Planned Config (AfterTouch)</span>
<pre id="planned-config"></pre>
</div>
<div id="planned-hosts-pane" class="diff-pane" style="display: none;">
<span class="config-header">Planned /etc/hosts Entries</span>
<pre id="planned-hosts"></pre>
<div style="margin-top: 10px; font-size: 0.9em; color: #666;">
<strong>Note:</strong> This method also injects the AfterTouch Local Root CA into <code>/etc/pki/tls/certs/ca-bundle.crt</code> to enable secure HTTPS communication.
</div>
</div>
<div id="planned-resolv-pane" class="diff-pane" style="display: none;">
<span class="config-header">Planned /etc/resolv.conf Hook</span>
<pre id="planned-resolv"></pre>
<div id="resolv-note" style="margin-top: 10px; font-size: 0.9em; color: #666;">
<strong>Note:</strong> This method injects a persistent DNS priority hook into the DHCP logic (<code>/etc/udhcpc.d/50default</code>). It preserves your router's search domain and secondary DNS servers. It also injects the Local Root CA.
</div>
</div>
</div>
<div style="margin-top: 15px;">
<button id="confirm-migrate-btn" style="background-color: #4CAF50; color: white; border: none; padding: 10px 20px;">Confirm Migration</button>
<button id="revert-migrate-btn" style="background-color: #FF9800; color: white; border: none; padding: 10px 20px; display: none;">Revert to Defaults</button>
<button id="reboot-speaker-btn" style="background-color: #607D8B; color: white; border: none; padding: 10px 20px;">Reboot Speaker</button>
<button id="ensure-remote-btn" style="background-color: #2196F3; color: white; border: none; padding: 10px 20px;">Enable Persistent Remote Services</button>
<button id="remove-remote-btn" style="background-color: #f44336; color: white; border: none; padding: 10px 20px;">Remove Persistent Remote Services</button>
<button onclick="document.getElementById('migration-summary').style.display='none'" style="padding: 10px 20px;">Cancel</button>
</div>
</div>
</div>
<!-- Tab 5: Interactions & Events -->
<div id="tab-interactions" class="tab-content">
<h2>Recorded Interactions & Device Events</h2>
<p>Analysis of traffic handled by this service (self), proxied to Bose (upstream), and internal device events (telemetry).</p>
<div id="interaction-stats-container" class="summary-box">
<div style="display: flex; gap: 20px; align-items: center; margin-bottom: 15px;">
<p style="margin: 0;">Total Requests: <strong id="total-requests">0</strong></p>
<button onclick="fetchInteractionStats()">Refresh Stats</button>
<div style="margin-left: 10px;">
<button onclick="showDeviceEvents()">View App/Device Events</button>
</div>
<div style="margin-left: auto; text-align: right;">
<button onclick="cleanupSessions()" class="btn-danger">Cleanup old sessions</button>
<div style="font-size: 0.75em; color: #666; margin-top: 3px;">Keeps only the 10 most recent sessions</div>
</div>
</div>
<div style="display: flex; gap: 20px;">
<div style="flex: 1; border-right: 1px solid #eee; padding-right: 20px;">
<h3>By Service</h3>
<ul id="stats-by-service" class="stats-list"></ul>
</div>
<div style="flex: 2;">
<h3>Sessions</h3>
<div id="stats-by-session-container" style="max-height: 200px; overflow-y: auto; border: 1px solid #eee; padding: 5px; border-radius: 4px;">
<ul id="stats-by-session" class="stats-list"></ul>
</div>
</div>
</div>
</div>
<div id="browse-recordings" class="summary-box" style="margin-top: 20px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h3 style="margin: 0;">Browse Recordings</h3>
</div>
<div style="margin-bottom: 15px; display: flex; gap: 15px; align-items: center; background: #f9f9f9; padding: 10px; border-radius: 4px;">
<div>
<label for="filter-session">Session:</label>
<select id="filter-session" onchange="fetchInteractions()">
<option value="">All Sessions</option>
</select>
</div>
<div>
<label for="filter-category">Category:</label>
<select id="filter-category" onchange="fetchInteractions()">
<option value="">All Categories</option>
<option value="self">Self (Emulated)</option>
<option value="upstream">Upstream (Bose)</option>
</select>
</div>
<div>
<label for="filter-since">Since (YYYY-MM-DD HH:mm:ss):</label>
<input type="text" id="filter-since" placeholder="e.g. 2026-02-15 15:00:00" size="25" onchange="fetchInteractions()">
</div>
<button onclick="fetchInteractions()">Apply Filters</button>
</div>
<div id="interactions-list-container" style="max-height: 400px; overflow-y: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="text-align: left; border-bottom: 2px solid #eee;">
<th style="padding: 8px;">#</th>
<th style="padding: 8px;">Time</th>
<th style="padding: 8px;">Method</th>
<th style="padding: 8px;">Path</th>
<th style="padding: 8px;">Status</th>
<th style="padding: 8px;">Category</th>
<th style="padding: 8px;">Action</th>
</tr>
</thead>
<tbody id="interactions-list">
<tr><td colspan="7" style="padding: 20px; text-align: center; color: #666;">No interactions found.</td></tr>
</tbody>
</table>
</div>
</div>
<div id="dns-discoveries" class="summary-box" style="margin-top: 20px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h3 style="margin: 0;">DNS Discoveries</h3>
<button onclick="clearDNSDiscoveries()" class="btn-danger">Clear DNS Logs</button>
</div>
<p style="font-size: 0.85em; color: #666;">Hosts discovered via the AfterTouch DNS server. "Self" means the domain was intercepted and redirected to this service.</p>
<div id="dns-discoveries-list-container" style="max-height: 400px; overflow-y: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="text-align: left; border-bottom: 2px solid #eee;">
<th style="padding: 8px;">Hostname</th>
<th style="padding: 8px;">Last Seen</th>
<th style="padding: 8px; text-align: center;">Queries</th>
<th style="padding: 8px; text-align: center;">Bose?</th>
<th style="padding: 8px;">Category</th>
<th style="padding: 8px;">Last Client IP</th>
</tr>
</thead>
<tbody id="dns-discoveries-list">
<tr><td colspan="6" style="padding: 20px; text-align: center; color: #666;">No DNS discoveries found.</td></tr>
</tbody>
</table>
</div>
</div>
<div id="interaction-viewer" class="summary-box" style="margin-top: 20px; display: none; background: #2b2b2b; color: #a9b7c6;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<h3 style="margin: 0; color: #fff;">Recording Viewer: <span id="viewer-filename" style="font-weight: normal; font-size: 0.8em;"></span></h3>
<button onclick="document.getElementById('interaction-viewer').style.display='none'" style="background: #444; color: #fff; border: 1px solid #666;">Close</button>
</div>
<pre id="interaction-content" style="white-space: pre-wrap; font-family: 'Courier New', Courier, monospace; font-size: 0.9em; margin: 0; padding: 10px; overflow-x: auto; max-height: 600px;"></pre>
</div>
<!-- Device Events Overlay -->
<div id="device-events-overlay" class="summary-box" style="margin-top: 20px; display: none; background: #fdfdfd; border: 1px solid #ddd;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<h3 style="margin: 0;">App & Device Events</h3>
<div>
<select id="event-device-selector" onchange="fetchDeviceEvents(this.value)">
<option value="">-- Select Device --</option>
</select>
<button onclick="document.getElementById('device-events-overlay').style.display='none'" style="margin-left: 10px;">Close</button>
</div>
</div>
<div id="events-list-container" style="max-height: 400px; overflow-y: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="text-align: left; border-bottom: 2px solid #eee;">
<th style="padding: 8px;">Time</th>
<th style="padding: 8px;">Type</th>
<th style="padding: 8px;">Data</th>
</tr>
</thead>
<tbody id="events-list">
<tr><td colspan="3" style="padding: 20px; text-align: center; color: #666;">Select a device to view events.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script src="../shared/common.js"></script>
<script src="script.js"></script>
<footer style="margin-top: 50px;">
<span id="version-info">AfterTouch</span>
</footer>
</body>
</html>
@@ -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() {
<td class="col-fw-serial"><div class="col-firmware">${d.firmware_version || '0.0.0'}</div><div class="col-serial" style="font-size: 0.8em; color: #666;">${d.device_serial_number}</div></td>
<td class="col-method">${methodLabel}</td>
<td>
<button onclick="prepareSync('${d.ip_address}')">Sync Data</button>
<button onclick="prepareMigration('${d.ip_address}')">Migrate</button>
<button onclick="prepareSync('${d.device_id}')">Sync Data</button>
<button onclick="prepareMigration('${d.device_id}')">Migrate</button>
<button class="btn-danger" onclick="removeDevice('${d.device_id}', '${d.name}')">Remove</button>
</td>
</tr>
`;
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 = '<tr><td colspan="3" style="padding: 20px; text-align: center; color: #666;">Loading events...</td></tr>';
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 + '. <strong>Please reboot the device to activate the changes.</strong>';
statusDiv.innerHTML = 'Successfully started migration for ' + deviceLabel + '. <strong>Please reboot the device to activate the changes.</strong>';
// 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) {
@@ -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;
}
+23
View File
@@ -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 = `<a href="${projectUrl}" target="_blank" style="color: inherit; text-decoration: none;">AfterTouch</a> ` +
`<a href="${releaseUrl}" target="_blank" style="color: inherit;">${version}</a> ` +
`(<a href="${commitUrl}" target="_blank" style="color: inherit;">${commit}</a>) - ${data.date}`;
}
} catch (error) {
console.error('Failed to fetch version info', error);
}
}
@@ -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 = '<p>No devices found. Ensure they are on the same network.</p>';
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 = `
<div class="device-info">
<div class="device-header">
<div>
<div class="device-title-row">
<h2 class="device-title">${device.name || 'Unknown Device'}</h2>
<button class="info-toggle" title="More info" onclick="toggleDetails('${device.device_id}')">i</button>
</div>
<p class="device-subtitle">
<span>${device.ip_address} | ${device.product_code}</span>
</p>
</div>
<button class="power-icon" title="Power" aria-label="Power" onclick="control('${device.device_id}', 'POWER')">&#xE17E;</button>
</div>
<div class="device-details" id="details-${device.device_id}">
<p>ID: <code>${device.device_id}</code></p>
<p>Firmware: <code>${device.firmware_version || 'N/A'}</code></p>
<p>Serial: <code>${device.device_serial_number || 'N/A'}</code></p>
<p>Discovery: <code>${device.discovery_method || 'N/A'}</code></p>
</div>
</div>
<div class="now-playing" id="np-${device.device_id}">
<p><em>Loading playback status...</em></p>
</div>
<div class="controls">
<button class="primary" onclick="control('${device.device_id}', 'PLAY')">Play</button>
<button class="primary" onclick="control('${device.device_id}', 'PAUSE')">Pause</button>
<button onclick="control('${device.device_id}', 'PREV_TRACK')">Prev</button>
<button onclick="control('${device.device_id}', 'NEXT_TRACK')">Next</button>
</div>
<div class="volume-container">
<span>Vol:</span>
<input id="vol-${device.device_id}" type="range" min="0" max="100"
oninput="onVolumeInput('${device.device_id}', this)"
onmousedown="startAdjust('${device.device_id}')" ontouchstart="startAdjust('${device.device_id}')"
onmouseup="endAdjust('${device.device_id}')" ontouchend="endAdjust('${device.device_id}')">
</div>
`;
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 = '<p>Error loading devices.</p>';
}
}
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 = '<div class="now-playing-info"><p><em>Standby</em></p></div>';
} 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 = `
<img class="album-art" src="${artUrl}" alt="Artwork">
<div class="now-playing-info">
<strong>${track}</strong><br>
${artist} - ${album}
</div>
`;
}
}
} 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 = '<div class="now-playing-info"><p><em>Standby</em></p></div>';
} 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 = `
<img class="album-art" src="${artUrl}" alt="Artwork">
<div class="now-playing-info">
<strong>${track}</strong><br>
${artist} - ${album}
</div>
`;
}
}
} 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 = '<div class="now-playing-info"><p><em>Standby</em></p></div>';
} 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 = `
<img class="album-art" src="${artUrl}" alt="Artwork">
<div class="now-playing-info">
<strong>${track}</strong><br>
${artist} - ${album}
</div>
`;
}
}
}
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);
});
Binary file not shown.
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Stockholm Mini - Reverse Engineered</title>
<link rel="stylesheet" href="../shared/common.css">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>Stockholm Mini</h1>
<p style="margin-top: -10px; font-style: italic; color: #666; font-size: 0.9rem;">A minimal reverse-engineered SoundTouch controller.</p>
<p style="margin-bottom: 20px;"><a href="/" style="color: #00bcd4; text-decoration: none; font-size: 0.9rem;">&larr; Back to selection</a></p>
<div id="device-list"></div>
</div>
<footer style="margin-top: 50px;">
<span id="version-info">AfterTouch</span>
</footer>
<script src="../shared/common.js"></script>
<script src="app.js"></script>
</body>
</html>
@@ -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; }
+15
View File
@@ -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
}