mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49ec2d58db | ||
|
|
c78664ee59 | ||
|
|
c17ba0e839 | ||
|
|
17f052308f |
@@ -78,6 +78,7 @@ The `soundtouch-service` is a local server that emulates Bose's cloud services.
|
||||
- **🔌 Easy Setup**: Activate SSH via USB stick (`remote_services` file)
|
||||
- **🔧 Device Migration**: Seamlessly transition devices to local control
|
||||
- **🌐 Web Management UI**: Easy browser-based setup and management
|
||||
- **🎮 Stockholm Mini**: A minimal reverse-engineered UI for device control (accessible at `/web/stockholm-mini/`)
|
||||
- **💾 Persistent Data**: Store presets, recents, and sources locally
|
||||
- **🔄 Endpoint Mirroring**: Asynchronously mirror local requests to Bose cloud for parity testing
|
||||
- **⚖️ Parity Logging**: Detect and record discrepancies between local and official Bose responses
|
||||
|
||||
@@ -753,10 +753,32 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
|
||||
r.Get("/proxy/*", server.HandleProxyRequest)
|
||||
|
||||
r.Get("/devices", server.HandleListDiscoveredDevices)
|
||||
r.Get("/devices/{deviceId}/info", server.HandleGetStockholmDeviceInfo)
|
||||
r.Post("/devices/{deviceId}/key/{key}", server.HandleDeviceKey)
|
||||
r.Post("/devices/{deviceId}/volume/{level}", server.HandleDeviceVolume)
|
||||
|
||||
// Stockholm Mini app
|
||||
r.Handle("/stockholm-mini/*", http.StripPrefix("/stockholm-mini/", http.FileServer(http.Dir("pkg/service/handlers/web/stockholm-mini"))))
|
||||
|
||||
r.Route("/devices", func(r chi.Router) {
|
||||
r.Get("/", server.HandleListDiscoveredDevices)
|
||||
r.Post("/", server.HandleAddManualDevice)
|
||||
|
||||
r.Route("/{deviceId}", func(r chi.Router) {
|
||||
r.Delete("/", server.HandleRemoveDevice)
|
||||
r.Get("/events", server.HandleGetDeviceEvents)
|
||||
r.Get("/info", server.HandleGetDeviceInfo)
|
||||
r.Get("/ws", server.HandleDeviceWebSocket)
|
||||
r.Post("/key/{key}", server.HandleDeviceKey)
|
||||
r.Post("/volume/{level}", server.HandleDeviceVolume)
|
||||
r.Post("/reboot", server.HandleRebootDevice)
|
||||
})
|
||||
})
|
||||
|
||||
r.Get("/version", server.HandleGetVersionInfo)
|
||||
|
||||
r.Route("/setup", func(r chi.Router) {
|
||||
r.Get("/devices", server.HandleListDiscoveredDevices)
|
||||
r.Post("/devices", server.HandleAddManualDevice)
|
||||
r.Delete("/devices/{deviceId}", server.HandleRemoveDevice)
|
||||
r.Post("/discover", server.HandleTriggerDiscovery)
|
||||
r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
|
||||
r.Get("/settings", server.HandleGetSettings)
|
||||
@@ -777,7 +799,6 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Get("/ca.crt", server.HandleGetCACert)
|
||||
r.Get("/proxy-settings", server.HandleGetProxySettings)
|
||||
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
|
||||
r.Get("/version", server.HandleGetVersionInfo)
|
||||
r.Get("/interaction-stats", server.HandleGetInteractionStats)
|
||||
r.Get("/interactions", server.HandleListInteractions)
|
||||
r.Get("/interaction-content", server.HandleGetInteractionContent)
|
||||
@@ -791,7 +812,19 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Get("/dns-discoveries/download", server.HandleDownloadDNSDiscoveries)
|
||||
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
|
||||
|
||||
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
|
||||
r.Route("/devices/{deviceId}", func(r chi.Router) {
|
||||
r.Get("/summary", server.HandleGetMigrationSummary)
|
||||
r.Post("/migrate", server.HandleMigrateDevice)
|
||||
r.Post("/revert", server.HandleRevertMigration)
|
||||
r.Post("/trust-ca", server.HandleTrustCACert)
|
||||
r.Post("/ensure-remote-services", server.HandleEnsureRemoteServices)
|
||||
r.Post("/remove-remote-services", server.HandleRemoveRemoteServices)
|
||||
r.Post("/backup", server.HandleBackupConfig)
|
||||
r.Post("/sync", server.HandleInitialSync)
|
||||
r.Post("/test-connection", server.HandleTestConnection)
|
||||
r.Post("/test-hosts", server.HandleTestHostsRedirection)
|
||||
r.Post("/test-dns", server.HandleTestDNSRedirection)
|
||||
})
|
||||
})
|
||||
|
||||
r.NotFound(server.HandleNotFound)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
* [Upstream URLs](analysis/UPSTREAM-URLS.md)
|
||||
* [Anonymization Summary](analysis/ANONYMIZATION-SUMMARY.md)
|
||||
* [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md)
|
||||
* [Stockholm App Analysis](analysis/stockholm-app-analysis.md)
|
||||
* [Wiki API Comparison](analysis/WIKI-COMPARISON.md)
|
||||
* [IoT Config Summary](analysis/IOT-CONFIG-SUMMARY.md)
|
||||
* [IoT Configuration Analysis](analysis/IOT-CONFIGURATION-ANALYSIS.md)
|
||||
|
||||
@@ -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.
|
||||
@@ -212,23 +212,23 @@ Device migration switches your SoundTouch devices from Bose's cloud services to
|
||||
|
||||
```bash
|
||||
# Get migration summary first
|
||||
curl http://localhost:8000/setup/migration-summary/192.168.1.100
|
||||
curl http://localhost:8000/setup/devices/192.168.1.100/summary
|
||||
|
||||
# Perform migration
|
||||
curl -X POST http://localhost:8000/setup/migrate/192.168.1.100
|
||||
curl -X POST http://localhost:8000/setup/devices/192.168.1.100/migrate
|
||||
|
||||
# Verify migration status
|
||||
curl http://localhost:8000/setup/devices
|
||||
curl http://localhost:8000/devices
|
||||
```
|
||||
|
||||
#### Advanced Migration Options
|
||||
|
||||
```bash
|
||||
# Migration with proxy fallback for original services
|
||||
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?proxy_url=http://localhost:8000&marge=original&stats=original"
|
||||
curl -X POST "http://localhost:8000/setup/devices/192.168.1.100/migrate?proxy_url=http://localhost:8000&marge=original&stats=original"
|
||||
|
||||
# Migration with custom target URL
|
||||
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?target_url=https://my-server.com:8000"
|
||||
curl -X POST "http://localhost:8000/setup/devices/192.168.1.100/migrate?target_url=https://my-server.com:8000"
|
||||
```
|
||||
|
||||
### Post-Migration Verification
|
||||
@@ -237,13 +237,13 @@ After migration, verify the device is working correctly:
|
||||
|
||||
```bash
|
||||
# Check device status
|
||||
curl http://localhost:8000/setup/devices
|
||||
curl http://localhost:8000/devices
|
||||
|
||||
# Test preset functionality
|
||||
curl "http://192.168.1.100:8090/presets"
|
||||
|
||||
# Monitor device events (if needed)
|
||||
curl "http://localhost:8000/events/192.168.1.100"
|
||||
curl "http://localhost:8000/devices/08DF1F0BA325/events"
|
||||
```
|
||||
|
||||
#### ResolvConf Migration (DHCP-Aware DNS Redirection)
|
||||
@@ -347,7 +347,7 @@ Mirrored requests are also recorded in the **Interaction Log** under the categor
|
||||
|
||||
### Discovery & Setup
|
||||
|
||||
#### `GET /setup/devices`
|
||||
#### `GET /devices`
|
||||
Lists all discovered SoundTouch devices with their current status.
|
||||
|
||||
**Response:**
|
||||
@@ -368,10 +368,10 @@ Lists all discovered SoundTouch devices with their current status.
|
||||
#### `POST /setup/discover`
|
||||
Triggers immediate network device discovery.
|
||||
|
||||
#### `GET /setup/info/{deviceIP}`
|
||||
#### `GET /devices/{deviceIP}/info`
|
||||
Gets detailed device information and configuration.
|
||||
|
||||
#### `GET /setup/migration-summary/{deviceIP}`
|
||||
#### `GET /setup/devices/{deviceIP}/summary`
|
||||
Analyzes device configuration and provides migration preview.
|
||||
|
||||
**Response:**
|
||||
@@ -388,7 +388,7 @@ Analyzes device configuration and provides migration preview.
|
||||
}
|
||||
```
|
||||
|
||||
#### `POST /setup/migrate/{deviceIP}`
|
||||
#### `POST /setup/devices/{deviceIP}/migrate`
|
||||
Migrates device to use local services.
|
||||
|
||||
**Query Parameters:**
|
||||
@@ -399,6 +399,33 @@ Migrates device to use local services.
|
||||
- `sw_update`: Set to "original" to proxy update requests (optional)
|
||||
- `bmx`: Set to "original" to proxy BMX requests (optional)
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/revert`
|
||||
Reverts device to Bose cloud defaults.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/trust-ca`
|
||||
Injects the AfterTouch root CA into the device's trust store.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/sync`
|
||||
Syncs presets and recents from the device to local storage.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/backup`
|
||||
Creates a backup of the current device configuration.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/ensure-remote-services`
|
||||
Enables persistent SSH/remote services on the device.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/remove-remote-services`
|
||||
Removes persistent SSH/remote services from the device.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/test-connection`
|
||||
Tests HTTPS connection from device to service.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/test-hosts`
|
||||
Tests /etc/hosts redirection on the device.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/test-dns`
|
||||
Tests DNS redirection on the device.
|
||||
|
||||
### BMX Services (Bose Media eXchange)
|
||||
|
||||
#### `GET /bmx/registry/v1/services`
|
||||
@@ -658,13 +685,13 @@ find data/stats/ -name "*.json" -mtime +90 -delete
|
||||
- **Description**: Browser-based guided flow for discovery, data sync, and migration.
|
||||
|
||||
### Setup API
|
||||
- `GET /setup/devices`: List all known (auto-discovered and manual) devices.
|
||||
- `POST /setup/devices`: Manually add a device by IP.
|
||||
- `GET /devices`: List all known (auto-discovered and manual) devices.
|
||||
- `POST /devices`: Manually add a device by IP.
|
||||
- `POST /setup/discover`: Trigger a new network discovery scan.
|
||||
- `GET /setup/discovery-status`: Check if a scan is currently in progress.
|
||||
- `POST /setup/sync/{deviceIP}`: Fetch presets, recents, and sources from a device.
|
||||
- `GET /setup/summary/{deviceIP}`: Get a detailed migration readiness summary.
|
||||
- `POST /setup/migrate/{deviceIP}`: Migrate a device using the specified method (XML/Hosts).
|
||||
- `POST /devices/{deviceIP}/sync`: Fetch presets, recents, and sources from a device.
|
||||
- `GET /devices/{deviceIP}/summary`: Get a detailed migration readiness summary.
|
||||
- `POST /devices/{deviceIP}/migrate`: Migrate a device using the specified method (XML/Hosts).
|
||||
- `GET /setup/ca.crt`: Download the Root CA certificate for manual installation.
|
||||
|
||||
#### `GET /setup/interactions`
|
||||
@@ -811,7 +838,7 @@ soundtouch:
|
||||
name: "Living Room Speaker"
|
||||
|
||||
rest:
|
||||
- resource: "http://localhost:8000/setup/devices"
|
||||
- resource: "http://localhost:8000/devices"
|
||||
scan_interval: 60
|
||||
sensor:
|
||||
- name: "SoundTouch Devices"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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/*
|
||||
|
||||
@@ -103,33 +103,104 @@ func TestStaticWeb(t *testing.T) {
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
// 1. Test CSS
|
||||
res, err := http.Get(ts.URL + "/web/css/style.css")
|
||||
// 1. Test Migration UI CSS
|
||||
res, err := http.Get(ts.URL + "/web/migration/style.css")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("CSS: Expected status OK, got %v", res.Status)
|
||||
t.Errorf("Migration CSS: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
|
||||
t.Errorf("CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
|
||||
t.Errorf("Migration CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 2. Test JS
|
||||
res, err = http.Get(ts.URL + "/web/js/script.js")
|
||||
// 2. Test Migration UI JS
|
||||
res, err = http.Get(ts.URL + "/web/migration/script.js")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("JS: Expected status OK, got %v", res.Status)
|
||||
t.Errorf("Migration JS: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "application/javascript") &&
|
||||
!strings.Contains(res.Header.Get("Content-Type"), "text/javascript") {
|
||||
t.Errorf("JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
|
||||
t.Errorf("Migration JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 3. Test Migration UI Index
|
||||
res, err = http.Get(ts.URL + "/web/migration/index.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Migration Index: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "text/html") {
|
||||
t.Errorf("Migration Index: Expected text/html content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 4. Test Stockholm Mini
|
||||
res, err = http.Get(ts.URL + "/web/stockholm-mini/index.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Stockholm Mini: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "text/html") {
|
||||
t.Errorf("Stockholm Mini: Expected text/html content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 5. Test Stockholm Mini CSS
|
||||
res, err = http.Get(ts.URL + "/web/stockholm-mini/style.css")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Stockholm Mini CSS: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
|
||||
t.Errorf("Stockholm Mini CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 6. Test Shared CSS
|
||||
res, err = http.Get(ts.URL + "/web/shared/common.css")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Shared CSS: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
|
||||
t.Errorf("Shared CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 7. Test Shared JS
|
||||
res, err = http.Get(ts.URL + "/web/shared/common.js")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Shared JS: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "application/javascript") &&
|
||||
!strings.Contains(res.Header.Get("Content-Type"), "text/javascript") {
|
||||
t.Errorf("Shared JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 3. Test diff.min.js
|
||||
|
||||
@@ -232,8 +232,8 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
t.Errorf("CA: Unexpected content type: %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 2. Test POST /setup/migrate/{deviceIP}?method=hosts
|
||||
res, err = http.Post(ts.URL+"/setup/migrate/192.168.1.10?method=hosts&target_url=http://192.168.1.100:8000", "application/json", nil)
|
||||
// 2. Test POST /setup/devices/{deviceIP}/migrate?method=hosts
|
||||
res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/migrate?method=hosts&target_url=http://192.168.1.100:8000", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -254,8 +254,8 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
t.Errorf("Migrate: Expected output field in response")
|
||||
}
|
||||
|
||||
// 3. Test POST /setup/trust-ca/{deviceIP}
|
||||
res, err = http.Post(ts.URL+"/setup/trust-ca/192.168.1.10", "application/json", nil)
|
||||
// 3. Test POST /setup/devices/{deviceIP}/trust-ca
|
||||
res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/trust-ca", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -275,8 +275,8 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
t.Errorf("TrustCA: Expected output field in response")
|
||||
}
|
||||
|
||||
// 4. Test POST /setup/reboot/{deviceIP}
|
||||
res, err = http.Post(ts.URL+"/setup/reboot/192.168.1.10", "application/json", nil)
|
||||
// 4. Test POST /devices/{deviceIP}/reboot
|
||||
res, err = http.Post(ts.URL+"/devices/192.168.1.10/reboot", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -296,8 +296,8 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
t.Errorf("Reboot: Expected output field in response")
|
||||
}
|
||||
|
||||
// 5. Test POST /setup/remove-remote-services/{deviceIP}
|
||||
res, err = http.Post(ts.URL+"/setup/remove-remote-services/192.168.1.10", "application/json", nil)
|
||||
// 5. Test POST /setup/devices/{deviceIP}/remove-remote-services
|
||||
res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/remove-remote-services", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -329,17 +329,20 @@ func TestRemoveDevice(t *testing.T) {
|
||||
_ = ds.Initialize()
|
||||
|
||||
// Setup a dummy device in the datastore
|
||||
account := "test-account"
|
||||
account := "acc1"
|
||||
deviceID := "TEST-DEVICE-ID"
|
||||
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
|
||||
if err := os.MkdirAll(deviceDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create device dir: %v", err)
|
||||
}
|
||||
|
||||
infoFile := filepath.Join(deviceDir, "DeviceInfo.xml")
|
||||
infoXML := `<?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)
|
||||
@@ -347,7 +350,7 @@ func TestRemoveDevice(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
// 1. Verify device exists
|
||||
res, err := http.Get(ts.URL + "/setup/devices")
|
||||
res, err := http.Get(ts.URL + "/devices")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -370,7 +373,7 @@ func TestRemoveDevice(t *testing.T) {
|
||||
}
|
||||
|
||||
// 2. Remove device
|
||||
req, err := http.NewRequest(http.MethodDelete, ts.URL+"/setup/devices/"+deviceID, nil)
|
||||
req, err := http.NewRequest(http.MethodDelete, ts.URL+"/devices/"+deviceID, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -385,7 +388,7 @@ func TestRemoveDevice(t *testing.T) {
|
||||
}
|
||||
|
||||
// 3. Verify device is gone
|
||||
res, err = http.Get(ts.URL + "/setup/devices")
|
||||
res, err = http.Get(ts.URL + "/devices")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
// HandleGetStockholmDeviceInfo returns live information for a device.
|
||||
func (s *Server) HandleGetStockholmDeviceInfo(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)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}()
|
||||
}
|
||||
@@ -91,12 +91,31 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
|
||||
r.Post("/account/{account}/password", server.HandleMargeChangePassword)
|
||||
})
|
||||
|
||||
// Setup Devices for tests
|
||||
r.Route("/devices", func(r chi.Router) {
|
||||
r.Get("/", server.HandleListDiscoveredDevices)
|
||||
r.Post("/", server.HandleAddManualDevice)
|
||||
|
||||
r.Route("/{deviceId}", func(r chi.Router) {
|
||||
r.Delete("/", server.HandleRemoveDevice)
|
||||
r.Get("/events", server.HandleGetDeviceEvents)
|
||||
r.Get("/info", server.HandleGetDeviceInfo)
|
||||
r.Get("/ws", server.HandleDeviceWebSocket)
|
||||
r.Post("/key/{key}", server.HandleDeviceKey)
|
||||
r.Post("/volume/{level}", server.HandleDeviceVolume)
|
||||
r.Post("/reboot", server.HandleRebootDevice)
|
||||
})
|
||||
})
|
||||
|
||||
r.Get("/version", server.HandleGetVersionInfo)
|
||||
|
||||
// Setup Setup for tests
|
||||
r.Route("/setup", func(r chi.Router) {
|
||||
r.Get("/devices", server.HandleListDiscoveredDevices)
|
||||
r.Delete("/devices/{deviceId}", server.HandleRemoveDevice)
|
||||
r.Post("/discover", server.HandleTriggerDiscovery)
|
||||
r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
|
||||
r.Get("/settings", server.HandleGetSettings)
|
||||
r.Post("/settings", server.HandleUpdateSettings)
|
||||
r.Get("/ca.crt", server.HandleGetCACert)
|
||||
r.Get("/proxy-settings", server.HandleGetProxySettings)
|
||||
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
|
||||
r.Post("/ensure-remote-services/{deviceId}", server.HandleEnsureRemoteServices)
|
||||
@@ -108,6 +127,30 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
|
||||
r.Post("/test-connection/{deviceId}", server.HandleTestConnection)
|
||||
r.Post("/test-hosts/{deviceId}", server.HandleTestHostsRedirection)
|
||||
r.Get("/ca.crt", server.HandleGetCACert)
|
||||
|
||||
r.Get("/interaction-stats", server.HandleGetInteractionStats)
|
||||
r.Get("/interactions", server.HandleListInteractions)
|
||||
r.Get("/interaction-content", server.HandleGetInteractionContent)
|
||||
r.Get("/interactions/sessions/{session}/download", server.HandleDownloadSession)
|
||||
r.Delete("/interactions/sessions/{session}", server.HandleDeleteSession)
|
||||
r.Delete("/interactions/sessions", server.HandleCleanupSessions)
|
||||
|
||||
r.Get("/dns-discoveries", server.HandleGetDNSDiscoveries)
|
||||
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
|
||||
|
||||
r.Route("/devices/{deviceId}", func(r chi.Router) {
|
||||
r.Get("/summary", server.HandleGetMigrationSummary)
|
||||
r.Post("/migrate", server.HandleMigrateDevice)
|
||||
r.Post("/revert", server.HandleRevertMigration)
|
||||
r.Post("/trust-ca", server.HandleTrustCACert)
|
||||
r.Post("/ensure-remote-services", server.HandleEnsureRemoteServices)
|
||||
r.Post("/remove-remote-services", server.HandleRemoveRemoteServices)
|
||||
r.Post("/backup", server.HandleBackupConfig)
|
||||
r.Post("/sync", server.HandleInitialSync)
|
||||
r.Post("/test-connection", server.HandleTestConnection)
|
||||
r.Post("/test-hosts", server.HandleTestHostsRedirection)
|
||||
r.Post("/test-dns", server.HandleTestDNSRedirection)
|
||||
})
|
||||
})
|
||||
|
||||
r.NotFound(server.HandleNotFound)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
)
|
||||
@@ -12,7 +13,7 @@ import (
|
||||
// RecordMiddleware returns a middleware that records "self" requests and responses.
|
||||
func (s *Server) RecordMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.recorder == nil || !s.recordEnabled {
|
||||
if s.recorder == nil || !s.recordEnabled || r.Header.Get("Upgrade") == "websocket" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@@ -52,6 +53,10 @@ func (s *Server) RecordMiddleware(next http.Handler) http.Handler {
|
||||
|
||||
// Create a response object for the recorder
|
||||
res := rw.getRecordedResponse(r)
|
||||
if res.StatusCode >= 400 {
|
||||
log.Printf("[DEBUG_LOG] Recording error response: %d %s %s", res.StatusCode, r.Method, r.URL.Path)
|
||||
}
|
||||
|
||||
if res.Body != nil {
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
}
|
||||
@@ -65,8 +70,9 @@ func (s *Server) RecordMiddleware(next http.Handler) http.Handler {
|
||||
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
body *bytes.Buffer
|
||||
statusCode int
|
||||
body *bytes.Buffer
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
func (rw *responseWriter) Header() http.Header {
|
||||
@@ -74,18 +80,28 @@ func (rw *responseWriter) Header() http.Header {
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
if rw.wroteHeader {
|
||||
return
|
||||
}
|
||||
|
||||
rw.statusCode = code
|
||||
rw.wroteHeader = true
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (rw *responseWriter) Write(b []byte) (int, error) {
|
||||
if !rw.wroteHeader {
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
rw.body.Write(b)
|
||||
|
||||
return rw.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
func (rw *responseWriter) getRecordedResponse(r *http.Request) *http.Response {
|
||||
statusCode := rw.statusCode
|
||||
if statusCode == 0 {
|
||||
if !rw.wroteHeader && statusCode == 0 {
|
||||
statusCode = http.StatusOK
|
||||
}
|
||||
|
||||
|
||||
@@ -840,3 +840,23 @@ func (s *Server) resolveDeviceIDToIP(deviceID string) (string, error) {
|
||||
|
||||
return "", fmt.Errorf("device not found: %s", deviceID)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
+86
-1433
File diff suppressed because it is too large
Load Diff
@@ -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="/">← 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@<SPEAKER-IP></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>
|
||||
+19
-17
@@ -251,8 +251,9 @@ async function updateSettings() {
|
||||
|
||||
async function fetchDevices() {
|
||||
try {
|
||||
const response = await fetch("/setup/devices");
|
||||
const response = await fetch("/devices");
|
||||
const devices = await response.json();
|
||||
window._knownDevices = devices; // Store globally for easy lookup
|
||||
const container = document.getElementById("device-list");
|
||||
const syncSelector = document.getElementById("sync-device-list");
|
||||
const migrationSelector = document.getElementById("migration-device-list");
|
||||
@@ -430,7 +431,7 @@ async function startSync() {
|
||||
log.innerHTML = "";
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/sync/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||||
const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/sync", {method: "POST"},);
|
||||
if (response.ok) {
|
||||
status.style.backgroundColor = "#dfd";
|
||||
status.textContent = "✅ Sync completed successfully for " + display + "!";
|
||||
@@ -927,7 +928,7 @@ async function fetchDeviceEvents(deviceId) {
|
||||
list.innerHTML = '<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;
|
||||
|
||||
@@ -1146,7 +1147,7 @@ async function addManualDevice() {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/devices", {
|
||||
const response = await fetch("/devices", {
|
||||
method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ip: ip}),
|
||||
});
|
||||
|
||||
@@ -1168,7 +1169,7 @@ async function removeDevice(deviceId, name) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/setup/devices/${deviceId}`, {
|
||||
const response = await fetch(`/devices/${deviceId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
@@ -1214,7 +1215,7 @@ async function pollDiscoveryStatus() {
|
||||
|
||||
async function updateDeviceInfo(deviceId, ip) {
|
||||
try {
|
||||
const response = await fetch("/setup/info/" + encodeURIComponent(deviceId));
|
||||
const response = await fetch("/devices/" + encodeURIComponent(deviceId) + "/info");
|
||||
if (!response.ok) return;
|
||||
const info = await response.json();
|
||||
|
||||
@@ -1274,7 +1275,7 @@ async function showSummary(deviceId) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/summary/" + encodeURIComponent(deviceId) + query,);
|
||||
const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/summary" + query,);
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText);
|
||||
@@ -1459,7 +1460,7 @@ async function revert(deviceId, ip) {
|
||||
statusDiv.innerHTML = "Reverting " + display + " to defaults...";
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/revert/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||||
const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/revert", {method: "POST"},);
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
@@ -1491,7 +1492,7 @@ async function reboot(deviceId, ip) {
|
||||
statusDiv.innerHTML = "Rebooting " + display + "...";
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/reboot/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||||
const response = await fetch("/devices/" + encodeURIComponent(deviceId) + "/reboot", {method: "POST"},);
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
@@ -1537,7 +1538,7 @@ async function migrate(deviceId, ip) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/migrate/" + encodeURIComponent(deviceId) + query, {method: "POST"},);
|
||||
const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/migrate" + query, {method: "POST"},);
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
@@ -1549,6 +1550,7 @@ async function migrate(deviceId, ip) {
|
||||
rebootBtn.style.display = "inline-block";
|
||||
rebootBtn.disabled = false;
|
||||
rebootBtn.style.border = "2px solid #000";
|
||||
rebootBtn.onclick = () => reboot(deviceId);
|
||||
|
||||
// Re-show summary but with prominence on reboot
|
||||
summaryDiv.style.display = "block";
|
||||
@@ -1574,7 +1576,7 @@ async function trustCA(deviceId, ip) {
|
||||
statusDiv.innerHTML = "Injecting Root CA into shared trust store on " + display + "...";
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/trust-ca/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||||
const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/trust-ca", {method: "POST"},);
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
@@ -1606,7 +1608,7 @@ async function ensureRemoteServices(deviceId, ip) {
|
||||
statusDiv.innerHTML = "Ensuring remote services for " + display + "...";
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/ensure-remote-services/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||||
const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/ensure-remote-services", {method: "POST"},);
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
@@ -1640,7 +1642,7 @@ async function removeRemoteServices(deviceId, ip) {
|
||||
statusDiv.innerHTML = "Removing remote services for " + display + "...";
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/remove-remote-services/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||||
const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/remove-remote-services", {method: "POST"},);
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
@@ -1668,7 +1670,7 @@ async function backupConfig(deviceId, ip) {
|
||||
statusDiv.innerHTML = "Creating backup for " + display + "...";
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/backup/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||||
const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/backup", {method: "POST"},);
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
@@ -1697,7 +1699,7 @@ async function testConnection(deviceId, useExplicitCA) {
|
||||
|
||||
try {
|
||||
const query = `?target_url=${encodeURIComponent(testUrl)}&use_explicit_ca=${useExplicitCA}`;
|
||||
const response = await fetch(`/setup/test-connection/${encodeURIComponent(deviceId)}${query}`, {method: "POST"},);
|
||||
const response = await fetch(`/setup/devices/test-connection/${encodeURIComponent(deviceId)}${query}`, {method: "POST"},);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.ok) {
|
||||
@@ -1725,7 +1727,7 @@ async function testHostsRedirection(deviceId) {
|
||||
|
||||
try {
|
||||
const query = `?target_url=${encodeURIComponent(targetUrl)}`;
|
||||
const response = await fetch(`/setup/test-hosts/${encodeURIComponent(deviceId)}${query}`, {method: "POST"},);
|
||||
const response = await fetch(`/setup/devices/${encodeURIComponent(deviceId)}/test-hosts${query}`, {method: "POST"},);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.ok) {
|
||||
@@ -1753,7 +1755,7 @@ async function testDNSRedirection(deviceId) {
|
||||
|
||||
try {
|
||||
const query = `?target_url=${encodeURIComponent(targetUrl)}`;
|
||||
const response = await fetch(`/setup/test-dns/${encodeURIComponent(deviceId)}${query}`, {method: "POST"},);
|
||||
const response = await fetch(`/setup/devices/${encodeURIComponent(deviceId)}/test-dns${query}`, {method: "POST"},);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.ok) {
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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')"></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);
|
||||
});
|
||||
BIN
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;">← 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; }
|
||||
@@ -139,6 +139,10 @@ type DeviceInfoXML struct {
|
||||
} `xml:"networkInfo" json:"networkInfo"`
|
||||
SoftwareVer string `xml:"-" json:"softwareVersion"`
|
||||
SerialNumber string `xml:"-" json:"serialNumber"`
|
||||
|
||||
// Enriched fields (not part of device /info XML)
|
||||
NowPlaying *models.NowPlaying `json:"nowPlaying,omitempty"`
|
||||
Volume *models.Volume `json:"volume,omitempty"`
|
||||
}
|
||||
|
||||
// GetLiveDeviceInfo fetches live information from the speaker's :8090/info endpoint.
|
||||
@@ -186,6 +190,16 @@ func (m *Manager) parseDeviceInfoXML(reader io.Reader, infoXML *DeviceInfoXML) e
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich with live now playing and volume via device API (best-effort)
|
||||
//c := client.NewClientFromHost(deviceIP)
|
||||
//if vol, err := c.GetVolume(); err == nil {
|
||||
// infoXML.Volume = vol
|
||||
//}
|
||||
//
|
||||
//if np, err := c.GetNowPlaying(); err == nil {
|
||||
// infoXML.NowPlaying = np
|
||||
//}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user