mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
Add Stockholm Mini
This is also a refactoring of our api paths
This commit is contained in:
@@ -76,6 +76,7 @@ The `soundtouch-service` is a local server that emulates Bose's cloud services.
|
||||
- **🔌 Easy Setup**: Activate SSH via USB stick (`remote_services` file)
|
||||
- **🔧 Device Migration**: Seamlessly transition devices to local control
|
||||
- **🌐 Web Management UI**: Easy browser-based setup and management
|
||||
- **🎮 Stockholm Mini**: A minimal reverse-engineered UI for device control (accessible at `/web/stockholm-mini/`)
|
||||
- **💾 Persistent Data**: Store presets, recents, and sources locally
|
||||
- **📝 HTTP Recording**: Persist all interactions as re-playable `.http` files
|
||||
- **🧹 Session Management**: Manage and cleanup recorded interaction sessions
|
||||
|
||||
@@ -680,31 +680,31 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
|
||||
r.Get("/proxy/*", server.HandleProxyRequest)
|
||||
|
||||
r.Route("/devices", func(r chi.Router) {
|
||||
r.Get("/", server.HandleListDiscoveredDevices)
|
||||
r.Post("/", server.HandleAddManualDevice)
|
||||
|
||||
r.Route("/{deviceId}", func(r chi.Router) {
|
||||
r.Delete("/", server.HandleRemoveDevice)
|
||||
r.Get("/events", server.HandleGetDeviceEvents)
|
||||
r.Get("/info", server.HandleGetDeviceInfo)
|
||||
r.Get("/ws", server.HandleDeviceWebSocket)
|
||||
r.Post("/key/{key}", server.HandleDeviceKey)
|
||||
r.Post("/volume/{level}", server.HandleDeviceVolume)
|
||||
r.Post("/reboot", server.HandleRebootDevice)
|
||||
})
|
||||
})
|
||||
|
||||
r.Get("/version", server.HandleGetVersionInfo)
|
||||
|
||||
r.Route("/setup", func(r chi.Router) {
|
||||
r.Get("/devices", server.HandleListDiscoveredDevices)
|
||||
r.Post("/devices", server.HandleAddManualDevice)
|
||||
r.Delete("/devices/{deviceId}", server.HandleRemoveDevice)
|
||||
r.Post("/discover", server.HandleTriggerDiscovery)
|
||||
r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
|
||||
r.Get("/settings", server.HandleGetSettings)
|
||||
r.Post("/settings", server.HandleUpdateSettings)
|
||||
r.Get("/info/{deviceIP}", server.HandleGetDeviceInfo)
|
||||
r.Get("/summary/{deviceIP}", server.HandleGetMigrationSummary)
|
||||
r.Post("/migrate/{deviceIP}", server.HandleMigrateDevice)
|
||||
r.Post("/revert/{deviceIP}", server.HandleRevertMigration)
|
||||
r.Post("/reboot/{deviceIP}", server.HandleRebootDevice)
|
||||
r.Post("/trust-ca/{deviceIP}", server.HandleTrustCACert)
|
||||
r.Post("/ensure-remote-services/{deviceIP}", server.HandleEnsureRemoteServices)
|
||||
r.Post("/remove-remote-services/{deviceIP}", server.HandleRemoveRemoteServices)
|
||||
r.Post("/backup/{deviceIP}", server.HandleBackupConfig)
|
||||
r.Post("/sync/{deviceIP}", server.HandleInitialSync)
|
||||
r.Post("/test-connection/{deviceIP}", server.HandleTestConnection)
|
||||
r.Post("/test-hosts/{deviceIP}", server.HandleTestHostsRedirection)
|
||||
r.Post("/test-dns/{deviceIP}", server.HandleTestDNSRedirection)
|
||||
r.Get("/ca.crt", server.HandleGetCACert)
|
||||
r.Get("/proxy-settings", server.HandleGetProxySettings)
|
||||
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
|
||||
r.Get("/version", server.HandleGetVersionInfo)
|
||||
r.Get("/interaction-stats", server.HandleGetInteractionStats)
|
||||
r.Get("/interactions", server.HandleListInteractions)
|
||||
r.Get("/interaction-content", server.HandleGetInteractionContent)
|
||||
@@ -715,7 +715,19 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Get("/dns-discoveries", server.HandleGetDNSDiscoveries)
|
||||
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
|
||||
|
||||
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
|
||||
r.Route("/devices/{deviceId}", func(r chi.Router) {
|
||||
r.Get("/summary", server.HandleGetMigrationSummary)
|
||||
r.Post("/migrate", server.HandleMigrateDevice)
|
||||
r.Post("/revert", server.HandleRevertMigration)
|
||||
r.Post("/trust-ca", server.HandleTrustCACert)
|
||||
r.Post("/ensure-remote-services", server.HandleEnsureRemoteServices)
|
||||
r.Post("/remove-remote-services", server.HandleRemoveRemoteServices)
|
||||
r.Post("/backup", server.HandleBackupConfig)
|
||||
r.Post("/sync", server.HandleInitialSync)
|
||||
r.Post("/test-connection", server.HandleTestConnection)
|
||||
r.Post("/test-hosts", server.HandleTestHostsRedirection)
|
||||
r.Post("/test-dns", server.HandleTestDNSRedirection)
|
||||
})
|
||||
})
|
||||
|
||||
r.NotFound(server.HandleNotFound)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
* [Upstream URLs](analysis/UPSTREAM-URLS.md)
|
||||
* [Anonymization Summary](analysis/ANONYMIZATION-SUMMARY.md)
|
||||
* [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md)
|
||||
* [Stockholm App Analysis](analysis/stockholm-app-analysis.md)
|
||||
* [Wiki API Comparison](analysis/WIKI-COMPARISON.md)
|
||||
|
||||
## Appendix (Other Documents)
|
||||
|
||||
@@ -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.
|
||||
@@ -207,23 +207,23 @@ Device migration switches your SoundTouch devices from Bose's cloud services to
|
||||
|
||||
```bash
|
||||
# Get migration summary first
|
||||
curl http://localhost:8000/setup/migration-summary/192.168.1.100
|
||||
curl http://localhost:8000/setup/devices/192.168.1.100/summary
|
||||
|
||||
# Perform migration
|
||||
curl -X POST http://localhost:8000/setup/migrate/192.168.1.100
|
||||
curl -X POST http://localhost:8000/setup/devices/192.168.1.100/migrate
|
||||
|
||||
# Verify migration status
|
||||
curl http://localhost:8000/setup/devices
|
||||
curl http://localhost:8000/devices
|
||||
```
|
||||
|
||||
#### Advanced Migration Options
|
||||
|
||||
```bash
|
||||
# Migration with proxy fallback for original services
|
||||
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?proxy_url=http://localhost:8000&marge=original&stats=original"
|
||||
curl -X POST "http://localhost:8000/setup/devices/192.168.1.100/migrate?proxy_url=http://localhost:8000&marge=original&stats=original"
|
||||
|
||||
# Migration with custom target URL
|
||||
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?target_url=https://my-server.com:8000"
|
||||
curl -X POST "http://localhost:8000/setup/devices/192.168.1.100/migrate?target_url=https://my-server.com:8000"
|
||||
```
|
||||
|
||||
### Post-Migration Verification
|
||||
@@ -232,13 +232,13 @@ After migration, verify the device is working correctly:
|
||||
|
||||
```bash
|
||||
# Check device status
|
||||
curl http://localhost:8000/setup/devices
|
||||
curl http://localhost:8000/devices
|
||||
|
||||
# Test preset functionality
|
||||
curl "http://192.168.1.100:8090/presets"
|
||||
|
||||
# Monitor device events (if needed)
|
||||
curl "http://localhost:8000/events/192.168.1.100"
|
||||
curl "http://localhost:8000/devices/08DF1F0BA325/events"
|
||||
```
|
||||
|
||||
#### ResolvConf Migration (DHCP-Aware DNS Redirection)
|
||||
@@ -314,7 +314,7 @@ Even without migrating a device, you can use the DNS server to discover what a d
|
||||
|
||||
### Discovery & Setup
|
||||
|
||||
#### `GET /setup/devices`
|
||||
#### `GET /devices`
|
||||
Lists all discovered SoundTouch devices with their current status.
|
||||
|
||||
**Response:**
|
||||
@@ -335,10 +335,10 @@ Lists all discovered SoundTouch devices with their current status.
|
||||
#### `POST /setup/discover`
|
||||
Triggers immediate network device discovery.
|
||||
|
||||
#### `GET /setup/info/{deviceIP}`
|
||||
#### `GET /devices/{deviceIP}/info`
|
||||
Gets detailed device information and configuration.
|
||||
|
||||
#### `GET /setup/migration-summary/{deviceIP}`
|
||||
#### `GET /setup/devices/{deviceIP}/summary`
|
||||
Analyzes device configuration and provides migration preview.
|
||||
|
||||
**Response:**
|
||||
@@ -355,7 +355,7 @@ Analyzes device configuration and provides migration preview.
|
||||
}
|
||||
```
|
||||
|
||||
#### `POST /setup/migrate/{deviceIP}`
|
||||
#### `POST /setup/devices/{deviceIP}/migrate`
|
||||
Migrates device to use local services.
|
||||
|
||||
**Query Parameters:**
|
||||
@@ -366,6 +366,33 @@ Migrates device to use local services.
|
||||
- `sw_update`: Set to "original" to proxy update requests (optional)
|
||||
- `bmx`: Set to "original" to proxy BMX requests (optional)
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/revert`
|
||||
Reverts device to Bose cloud defaults.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/trust-ca`
|
||||
Injects the AfterTouch root CA into the device's trust store.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/sync`
|
||||
Syncs presets and recents from the device to local storage.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/backup`
|
||||
Creates a backup of the current device configuration.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/ensure-remote-services`
|
||||
Enables persistent SSH/remote services on the device.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/remove-remote-services`
|
||||
Removes persistent SSH/remote services from the device.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/test-connection`
|
||||
Tests HTTPS connection from device to service.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/test-hosts`
|
||||
Tests /etc/hosts redirection on the device.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/test-dns`
|
||||
Tests DNS redirection on the device.
|
||||
|
||||
### BMX Services (Bose Media eXchange)
|
||||
|
||||
#### `GET /bmx/registry/v1/services`
|
||||
@@ -614,13 +641,13 @@ find data/stats/ -name "*.json" -mtime +90 -delete
|
||||
- **Description**: Browser-based guided flow for discovery, data sync, and migration.
|
||||
|
||||
### Setup API
|
||||
- `GET /setup/devices`: List all known (auto-discovered and manual) devices.
|
||||
- `POST /setup/devices`: Manually add a device by IP.
|
||||
- `GET /devices`: List all known (auto-discovered and manual) devices.
|
||||
- `POST /devices`: Manually add a device by IP.
|
||||
- `POST /setup/discover`: Trigger a new network discovery scan.
|
||||
- `GET /setup/discovery-status`: Check if a scan is currently in progress.
|
||||
- `POST /setup/sync/{deviceIP}`: Fetch presets, recents, and sources from a device.
|
||||
- `GET /setup/summary/{deviceIP}`: Get a detailed migration readiness summary.
|
||||
- `POST /setup/migrate/{deviceIP}`: Migrate a device using the specified method (XML/Hosts).
|
||||
- `POST /devices/{deviceIP}/sync`: Fetch presets, recents, and sources from a device.
|
||||
- `GET /devices/{deviceIP}/summary`: Get a detailed migration readiness summary.
|
||||
- `POST /devices/{deviceIP}/migrate`: Migrate a device using the specified method (XML/Hosts).
|
||||
- `GET /setup/ca.crt`: Download the Root CA certificate for manual installation.
|
||||
|
||||
#### `GET /setup/interactions`
|
||||
@@ -767,7 +794,7 @@ soundtouch:
|
||||
name: "Living Room Speaker"
|
||||
|
||||
rest:
|
||||
- resource: "http://localhost:8000/setup/devices"
|
||||
- resource: "http://localhost:8000/devices"
|
||||
scan_interval: 60
|
||||
sensor:
|
||||
- name: "SoundTouch Devices"
|
||||
|
||||
@@ -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,32 +103,103 @@ func TestStaticWeb(t *testing.T) {
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
// 1. Test CSS
|
||||
res, err := http.Get(ts.URL + "/web/css/style.css")
|
||||
// 1. Test Migration UI CSS
|
||||
res, err := http.Get(ts.URL + "/web/migration/style.css")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("CSS: Expected status OK, got %v", res.Status)
|
||||
t.Errorf("Migration CSS: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
|
||||
t.Errorf("CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
|
||||
t.Errorf("Migration CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 2. Test JS
|
||||
res, err = http.Get(ts.URL + "/web/js/script.js")
|
||||
// 2. Test Migration UI JS
|
||||
res, err = http.Get(ts.URL + "/web/migration/script.js")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("JS: Expected status OK, got %v", res.Status)
|
||||
t.Errorf("Migration JS: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "application/javascript") &&
|
||||
!strings.Contains(res.Header.Get("Content-Type"), "text/javascript") {
|
||||
t.Errorf("JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
|
||||
t.Errorf("Migration JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 3. Test Migration UI Index
|
||||
res, err = http.Get(ts.URL + "/web/migration/index.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Migration Index: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "text/html") {
|
||||
t.Errorf("Migration Index: Expected text/html content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 4. Test Stockholm Mini
|
||||
res, err = http.Get(ts.URL + "/web/stockholm-mini/index.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Stockholm Mini: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "text/html") {
|
||||
t.Errorf("Stockholm Mini: Expected text/html content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 5. Test Stockholm Mini CSS
|
||||
res, err = http.Get(ts.URL + "/web/stockholm-mini/style.css")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Stockholm Mini CSS: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
|
||||
t.Errorf("Stockholm Mini CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 6. Test Shared CSS
|
||||
res, err = http.Get(ts.URL + "/web/shared/common.css")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Shared CSS: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
|
||||
t.Errorf("Shared CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 7. Test Shared JS
|
||||
res, err = http.Get(ts.URL + "/web/shared/common.js")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Shared JS: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "application/javascript") &&
|
||||
!strings.Contains(res.Header.Get("Content-Type"), "text/javascript") {
|
||||
t.Errorf("Shared JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,33 +278,17 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// HandleGetDeviceInfo returns live information for a device.
|
||||
func (s *Server) HandleGetDeviceInfo(w http.ResponseWriter, r *http.Request) {
|
||||
deviceIP := chi.URLParam(r, "deviceIP")
|
||||
if deviceIP == "" {
|
||||
http.Error(w, "Device IP is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
info, err := s.sm.GetLiveDeviceInfo(deviceIP)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(info); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleGetMigrationSummary returns a summary of the migration plan for a device.
|
||||
func (s *Server) HandleGetMigrationSummary(w http.ResponseWriter, r *http.Request) {
|
||||
deviceIP := chi.URLParam(r, "deviceIP")
|
||||
if deviceIP == "" {
|
||||
http.Error(w, "Device IP is required", http.StatusBadRequest)
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
http.Error(w, "Device ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -335,12 +319,12 @@ func (s *Server) HandleGetMigrationSummary(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
// HandleMigrateDevice starts the migration process for a device.
|
||||
func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
deviceIP := chi.URLParam(r, "deviceIP")
|
||||
if deviceIP == "" {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil {
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -348,6 +332,12 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
targetURL := r.URL.Query().Get("target_url")
|
||||
proxyURL := r.URL.Query().Get("proxy_url")
|
||||
method := setup.MigrationMethod(r.URL.Query().Get("method"))
|
||||
@@ -383,12 +373,12 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// HandleRevertMigration reverts the migration for a device.
|
||||
func (s *Server) HandleRevertMigration(w http.ResponseWriter, r *http.Request) {
|
||||
deviceIP := chi.URLParam(r, "deviceIP")
|
||||
if deviceIP == "" {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil {
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -396,6 +386,12 @@ func (s *Server) HandleRevertMigration(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
output, err := s.sm.RevertMigration(deviceIP)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -498,12 +494,12 @@ func (s *Server) HandleClearDNSDiscoveries(w http.ResponseWriter, _ *http.Reques
|
||||
|
||||
// HandleTrustCACert injects the local Root CA into the device's shared trust store.
|
||||
func (s *Server) HandleTrustCACert(w http.ResponseWriter, r *http.Request) {
|
||||
deviceIP := chi.URLParam(r, "deviceIP")
|
||||
if deviceIP == "" {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil {
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -511,6 +507,12 @@ func (s *Server) HandleTrustCACert(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
output, err := s.sm.TrustCACert(deviceIP)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -534,12 +536,12 @@ func (s *Server) HandleTrustCACert(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// HandleEnsureRemoteServices ensures that remote services are configured on a device.
|
||||
func (s *Server) HandleEnsureRemoteServices(w http.ResponseWriter, r *http.Request) {
|
||||
deviceIP := chi.URLParam(r, "deviceIP")
|
||||
if deviceIP == "" {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil {
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -547,6 +549,12 @@ func (s *Server) HandleEnsureRemoteServices(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
output, err := s.sm.EnsureRemoteServices(deviceIP)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -570,12 +578,12 @@ func (s *Server) HandleEnsureRemoteServices(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
// HandleRemoveRemoteServices removes remote services configuration from a device.
|
||||
func (s *Server) HandleRemoveRemoteServices(w http.ResponseWriter, r *http.Request) {
|
||||
deviceIP := chi.URLParam(r, "deviceIP")
|
||||
if deviceIP == "" {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil {
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -583,6 +591,12 @@ func (s *Server) HandleRemoveRemoteServices(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
output, err := s.sm.RemoveRemoteServices(deviceIP)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -606,12 +620,12 @@ func (s *Server) HandleRemoveRemoteServices(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
// HandleBackupConfig creates a backup of the device configuration.
|
||||
func (s *Server) HandleBackupConfig(w http.ResponseWriter, r *http.Request) {
|
||||
deviceIP := chi.URLParam(r, "deviceIP")
|
||||
if deviceIP == "" {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil {
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -619,6 +633,12 @@ func (s *Server) HandleBackupConfig(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
output, err := s.sm.BackupConfig(deviceIP)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -731,9 +751,15 @@ func (s *Server) HandleUpdateProxySettings(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
// HandleTestHostsRedirection performs a preliminary check for /etc/hosts redirection.
|
||||
func (s *Server) HandleTestHostsRedirection(w http.ResponseWriter, r *http.Request) {
|
||||
deviceIP := chi.URLParam(r, "deviceIP")
|
||||
if deviceIP == "" {
|
||||
http.Error(w, "Device IP is required", http.StatusBadRequest)
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
http.Error(w, "Device ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -771,9 +797,15 @@ func (s *Server) HandleTestHostsRedirection(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
// HandleTestDNSRedirection performs a check for DNS redirection to the AfterTouch service.
|
||||
func (s *Server) HandleTestDNSRedirection(w http.ResponseWriter, r *http.Request) {
|
||||
deviceIP := chi.URLParam(r, "deviceIP")
|
||||
if deviceIP == "" {
|
||||
http.Error(w, "Device IP is required", http.StatusBadRequest)
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
http.Error(w, "Device ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -811,9 +843,15 @@ func (s *Server) HandleTestDNSRedirection(w http.ResponseWriter, r *http.Request
|
||||
|
||||
// HandleInitialSync fetches presets, recents and sources from the device and saves them to the datastore.
|
||||
func (s *Server) HandleInitialSync(w http.ResponseWriter, r *http.Request) {
|
||||
deviceIP := chi.URLParam(r, "deviceIP")
|
||||
if deviceIP == "" {
|
||||
http.Error(w, "Missing deviceIP", http.StatusBadRequest)
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
http.Error(w, "Device ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -828,12 +866,12 @@ func (s *Server) HandleInitialSync(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// HandleRebootDevice reboots a device.
|
||||
func (s *Server) HandleRebootDevice(w http.ResponseWriter, r *http.Request) {
|
||||
deviceIP := chi.URLParam(r, "deviceIP")
|
||||
if deviceIP == "" {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil {
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -841,6 +879,12 @@ func (s *Server) HandleRebootDevice(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
output, err := s.sm.Reboot(deviceIP)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -864,9 +908,15 @@ func (s *Server) HandleRebootDevice(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// HandleTestConnection performs a connection check from the device to the server.
|
||||
func (s *Server) HandleTestConnection(w http.ResponseWriter, r *http.Request) {
|
||||
deviceIP := chi.URLParam(r, "deviceIP")
|
||||
if deviceIP == "" {
|
||||
http.Error(w, "Device IP is required", http.StatusBadRequest)
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
http.Error(w, "Device ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
@@ -144,6 +145,12 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
return &mockSSH{host: host}
|
||||
}
|
||||
|
||||
// Register device in datastore so lookupIP works
|
||||
_ = ds.SaveDeviceInfo("default", "192.168.1.10", &models.ServiceDeviceInfo{
|
||||
DeviceID: "192.168.1.10",
|
||||
IPAddress: "192.168.1.10",
|
||||
})
|
||||
|
||||
r, server := setupRouter("http://localhost:8001", ds)
|
||||
server.sm = sm // Inject our manager with mock SSH
|
||||
|
||||
@@ -164,8 +171,8 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
t.Errorf("CA: Unexpected content type: %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 2. Test POST /setup/migrate/{deviceIP}?method=hosts
|
||||
res, err = http.Post(ts.URL+"/setup/migrate/192.168.1.10?method=hosts&target_url=http://192.168.1.100:8000", "application/json", nil)
|
||||
// 2. Test POST /setup/devices/{deviceIP}/migrate?method=hosts
|
||||
res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/migrate?method=hosts&target_url=http://192.168.1.100:8000", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -186,8 +193,8 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
t.Errorf("Migrate: Expected output field in response")
|
||||
}
|
||||
|
||||
// 3. Test POST /setup/trust-ca/{deviceIP}
|
||||
res, err = http.Post(ts.URL+"/setup/trust-ca/192.168.1.10", "application/json", nil)
|
||||
// 3. Test POST /setup/devices/{deviceIP}/trust-ca
|
||||
res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/trust-ca", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -207,8 +214,8 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
t.Errorf("TrustCA: Expected output field in response")
|
||||
}
|
||||
|
||||
// 4. Test POST /setup/reboot/{deviceIP}
|
||||
res, err = http.Post(ts.URL+"/setup/reboot/192.168.1.10", "application/json", nil)
|
||||
// 4. Test POST /devices/{deviceIP}/reboot
|
||||
res, err = http.Post(ts.URL+"/devices/192.168.1.10/reboot", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -228,8 +235,8 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
t.Errorf("Reboot: Expected output field in response")
|
||||
}
|
||||
|
||||
// 5. Test POST /setup/remove-remote-services/{deviceIP}
|
||||
res, err = http.Post(ts.URL+"/setup/remove-remote-services/192.168.1.10", "application/json", nil)
|
||||
// 5. Test POST /setup/devices/{deviceIP}/remove-remote-services
|
||||
res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/remove-remote-services", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -261,17 +268,20 @@ func TestRemoveDevice(t *testing.T) {
|
||||
_ = ds.Initialize()
|
||||
|
||||
// Setup a dummy device in the datastore
|
||||
account := "test-account"
|
||||
account := "acc1"
|
||||
deviceID := "TEST-DEVICE-ID"
|
||||
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
|
||||
if err := os.MkdirAll(deviceDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create device dir: %v", err)
|
||||
}
|
||||
|
||||
infoFile := filepath.Join(deviceDir, "DeviceInfo.xml")
|
||||
infoXML := `<?xml version="1.0" encoding="UTF-8" ?><info deviceID="TEST-DEVICE-ID"><name>Test Device</name><type>SoundTouch 10</type></info>`
|
||||
if err := os.WriteFile(infoFile, []byte(infoXML), 0644); err != nil {
|
||||
t.Fatalf("Failed to create device info file: %v", err)
|
||||
// Register device in datastore so HandleRemoveDevice works
|
||||
_ = ds.SaveDeviceInfo(account, deviceID, &models.ServiceDeviceInfo{
|
||||
DeviceID: deviceID,
|
||||
AccountID: account,
|
||||
IPAddress: "192.168.1.100",
|
||||
})
|
||||
|
||||
// Verify directory exists where datastore expects it
|
||||
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
|
||||
if _, err := os.Stat(deviceDir); err != nil {
|
||||
t.Fatalf("Device directory was not created by SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
r, _ := setupRouter("http://localhost:8001", ds)
|
||||
@@ -279,7 +289,7 @@ func TestRemoveDevice(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
// 1. Verify device exists
|
||||
res, err := http.Get(ts.URL + "/setup/devices")
|
||||
res, err := http.Get(ts.URL + "/devices")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -302,7 +312,7 @@ func TestRemoveDevice(t *testing.T) {
|
||||
}
|
||||
|
||||
// 2. Remove device
|
||||
req, err := http.NewRequest(http.MethodDelete, ts.URL+"/setup/devices/"+deviceID, nil)
|
||||
req, err := http.NewRequest(http.MethodDelete, ts.URL+"/devices/"+deviceID, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -317,7 +327,7 @@ func TestRemoveDevice(t *testing.T) {
|
||||
}
|
||||
|
||||
// 3. Verify device is gone
|
||||
res, err = http.Get(ts.URL + "/setup/devices")
|
||||
res, err = http.Get(ts.URL + "/devices")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// HandleGetDeviceInfo returns live information for a device.
|
||||
func (s *Server) HandleGetDeviceInfo(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
http.Error(w, "Device ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
info, err := s.sm.GetLiveDeviceInfo(deviceIP)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Include IP address in both snake_case and camelCase for frontend compatibility
|
||||
type deviceInfoResponse struct {
|
||||
*setup.DeviceInfoXML `json:",inline"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
IPAddressCamel string `json:"ipAddress,omitempty"`
|
||||
}
|
||||
|
||||
resp := deviceInfoResponse{
|
||||
DeviceInfoXML: info,
|
||||
IPAddress: deviceIP,
|
||||
IPAddressCamel: deviceIP,
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDeviceKey sends a key command to a device.
|
||||
func (s *Server) HandleDeviceKey(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
|
||||
key := chi.URLParam(r, "key")
|
||||
if deviceID == "" || key == "" {
|
||||
http.Error(w, "Device ID and Key are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
c := client.NewClientFromHost(deviceIP)
|
||||
|
||||
err = c.SendKey(key)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to send key %s to %s: %v", key, deviceIP, err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Key sent"}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDeviceVolume sets the volume level for a device.
|
||||
func (s *Server) HandleDeviceVolume(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
|
||||
levelStr := chi.URLParam(r, "level")
|
||||
if deviceID == "" || levelStr == "" {
|
||||
http.Error(w, "Device ID and Level are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
level, err := strconv.Atoi(levelStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid volume level", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
c := client.NewClientFromHost(deviceIP)
|
||||
|
||||
err = c.SetVolume(level)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to set volume to %d on %s: %v", level, deviceIP, err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Volume set"}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}()
|
||||
}
|
||||
@@ -79,23 +79,56 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
|
||||
r.Post("/account/{account}/password", server.HandleMargeChangePassword)
|
||||
})
|
||||
|
||||
// Setup Devices for tests
|
||||
r.Route("/devices", func(r chi.Router) {
|
||||
r.Get("/", server.HandleListDiscoveredDevices)
|
||||
r.Post("/", server.HandleAddManualDevice)
|
||||
|
||||
r.Route("/{deviceId}", func(r chi.Router) {
|
||||
r.Delete("/", server.HandleRemoveDevice)
|
||||
r.Get("/events", server.HandleGetDeviceEvents)
|
||||
r.Get("/info", server.HandleGetDeviceInfo)
|
||||
r.Get("/ws", server.HandleDeviceWebSocket)
|
||||
r.Post("/key/{key}", server.HandleDeviceKey)
|
||||
r.Post("/volume/{level}", server.HandleDeviceVolume)
|
||||
r.Post("/reboot", server.HandleRebootDevice)
|
||||
})
|
||||
})
|
||||
|
||||
r.Get("/version", server.HandleGetVersionInfo)
|
||||
|
||||
// Setup Setup for tests
|
||||
r.Route("/setup", func(r chi.Router) {
|
||||
r.Get("/devices", server.HandleListDiscoveredDevices)
|
||||
r.Delete("/devices/{deviceId}", server.HandleRemoveDevice)
|
||||
r.Post("/discover", server.HandleTriggerDiscovery)
|
||||
r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
|
||||
r.Get("/settings", server.HandleGetSettings)
|
||||
r.Post("/settings", server.HandleUpdateSettings)
|
||||
r.Get("/ca.crt", server.HandleGetCACert)
|
||||
r.Get("/proxy-settings", server.HandleGetProxySettings)
|
||||
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
|
||||
r.Post("/ensure-remote-services/{deviceIP}", server.HandleEnsureRemoteServices)
|
||||
r.Post("/remove-remote-services/{deviceIP}", server.HandleRemoveRemoteServices)
|
||||
r.Post("/migrate/{deviceIP}", server.HandleMigrateDevice)
|
||||
r.Post("/revert/{deviceIP}", server.HandleRevertMigration)
|
||||
r.Post("/reboot/{deviceIP}", server.HandleRebootDevice)
|
||||
r.Post("/trust-ca/{deviceIP}", server.HandleTrustCACert)
|
||||
r.Post("/test-connection/{deviceIP}", server.HandleTestConnection)
|
||||
r.Post("/test-hosts/{deviceIP}", server.HandleTestHostsRedirection)
|
||||
r.Get("/ca.crt", server.HandleGetCACert)
|
||||
r.Get("/interaction-stats", server.HandleGetInteractionStats)
|
||||
r.Get("/interactions", server.HandleListInteractions)
|
||||
r.Get("/interaction-content", server.HandleGetInteractionContent)
|
||||
r.Get("/interactions/sessions/{session}/download", server.HandleDownloadSession)
|
||||
r.Delete("/interactions/sessions/{session}", server.HandleDeleteSession)
|
||||
r.Delete("/interactions/sessions", server.HandleCleanupSessions)
|
||||
|
||||
r.Get("/dns-discoveries", server.HandleGetDNSDiscoveries)
|
||||
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
|
||||
|
||||
r.Route("/devices/{deviceId}", func(r chi.Router) {
|
||||
r.Get("/summary", server.HandleGetMigrationSummary)
|
||||
r.Post("/migrate", server.HandleMigrateDevice)
|
||||
r.Post("/revert", server.HandleRevertMigration)
|
||||
r.Post("/trust-ca", server.HandleTrustCACert)
|
||||
r.Post("/ensure-remote-services", server.HandleEnsureRemoteServices)
|
||||
r.Post("/remove-remote-services", server.HandleRemoveRemoteServices)
|
||||
r.Post("/backup", server.HandleBackupConfig)
|
||||
r.Post("/sync", server.HandleInitialSync)
|
||||
r.Post("/test-connection", server.HandleTestConnection)
|
||||
r.Post("/test-hosts", server.HandleTestHostsRedirection)
|
||||
r.Post("/test-dns", server.HandleTestDNSRedirection)
|
||||
})
|
||||
})
|
||||
|
||||
r.NotFound(server.HandleNotFound)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
)
|
||||
@@ -12,7 +13,7 @@ import (
|
||||
// RecordMiddleware returns a middleware that records "self" requests and responses.
|
||||
func (s *Server) RecordMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.recorder == nil || !s.recordEnabled {
|
||||
if s.recorder == nil || !s.recordEnabled || r.Header.Get("Upgrade") == "websocket" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@@ -39,6 +40,10 @@ func (s *Server) RecordMiddleware(next http.Handler) http.Handler {
|
||||
|
||||
// Create a response object for the recorder
|
||||
res := rw.getRecordedResponse(r)
|
||||
if res.StatusCode >= 400 {
|
||||
log.Printf("[DEBUG_LOG] Recording error response: %d %s %s", res.StatusCode, r.Method, r.URL.Path)
|
||||
}
|
||||
|
||||
if res.Body != nil {
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
}
|
||||
@@ -52,8 +57,9 @@ func (s *Server) RecordMiddleware(next http.Handler) http.Handler {
|
||||
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
body *bytes.Buffer
|
||||
statusCode int
|
||||
body *bytes.Buffer
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
func (rw *responseWriter) Header() http.Header {
|
||||
@@ -61,18 +67,28 @@ func (rw *responseWriter) Header() http.Header {
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
if rw.wroteHeader {
|
||||
return
|
||||
}
|
||||
|
||||
rw.statusCode = code
|
||||
rw.wroteHeader = true
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (rw *responseWriter) Write(b []byte) (int, error) {
|
||||
if !rw.wroteHeader {
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
rw.body.Write(b)
|
||||
|
||||
return rw.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
func (rw *responseWriter) getRecordedResponse(r *http.Request) *http.Response {
|
||||
statusCode := rw.statusCode
|
||||
if statusCode == 0 {
|
||||
if !rw.wroteHeader && statusCode == 0 {
|
||||
statusCode = http.StatusOK
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -479,3 +480,23 @@ func (s *Server) findExistingDeviceInfo(d models.DiscoveredDevice) *models.Servi
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// lookupIP resolves a deviceId to its last known device IP.
|
||||
func (s *Server) lookupIP(deviceId string) (string, error) {
|
||||
devices, err := s.ds.ListAllDevices()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for i := range devices {
|
||||
if devices[i].DeviceID == deviceId {
|
||||
if devices[i].IPAddress == "" {
|
||||
return "", fmt.Errorf("no IP known for deviceId %s", deviceId)
|
||||
}
|
||||
|
||||
return devices[i].IPAddress, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("deviceId %s not found", deviceId)
|
||||
}
|
||||
|
||||
@@ -2,472 +2,97 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>AfterTouch (SoundTouch Toolkit)</title>
|
||||
<title>AfterTouch - Select Interface</title>
|
||||
<link rel="icon" href="/media/favicon-braille.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/web/css/style.css">
|
||||
<link rel="stylesheet" href="/web/shared/common.css">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background: #121212;
|
||||
color: #e0e0e0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
max-width: 600px;
|
||||
}
|
||||
h1 { color: #fff; margin-bottom: 30px; }
|
||||
.choices {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
}
|
||||
.choice-card {
|
||||
background: #1e1e1e;
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
width: 200px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: all 0.3s;
|
||||
border: 2px solid transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
box-shadow: 0 10px 20px rgba(0,0,0,0.5);
|
||||
}
|
||||
.choice-card:hover {
|
||||
transform: translateY(-5px);
|
||||
border-color: #00bcd4;
|
||||
background: #252525;
|
||||
}
|
||||
.icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.title {
|
||||
font-weight: bold;
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 10px;
|
||||
color: #00bcd4;
|
||||
}
|
||||
.desc {
|
||||
font-size: 0.9rem;
|
||||
color: #888;
|
||||
}
|
||||
footer {
|
||||
margin-top: 50px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>AfterTouch</h1>
|
||||
<p style="margin-top: -10px; font-style: italic; color: #666;">Bose SoundTouch Toolkit</p>
|
||||
<div class="container">
|
||||
<h1>AfterTouch</h1>
|
||||
<p style="margin-top: -25px; font-style: italic; color: #666; margin-bottom: 30px;">Bose SoundTouch Toolkit</p>
|
||||
<p style="margin-bottom: 40px; color: #aaa;">Select an interface to continue.</p>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab-buttons">
|
||||
<button class="tab-btn active" onclick="openTab(event, 'tab-overview')">Overview</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-settings')">1. Settings</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-devices')">2. Devices</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-sync')">3. Data Sync</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-migration')">4. Migration</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-interactions')">5. Interactions & Events</button>
|
||||
</div>
|
||||
<div class="choices">
|
||||
<a href="/web/stockholm-mini/" class="choice-card">
|
||||
<div class="icon">📻</div>
|
||||
<div class="title">Stockholm Mini</div>
|
||||
<div class="desc">Lightweight device controller and player.</div>
|
||||
</a>
|
||||
|
||||
<!-- Tab 0: Overview -->
|
||||
<div id="tab-overview" class="tab-content active">
|
||||
<h2>Welcome to AfterTouch</h2>
|
||||
<p>This toolkit helps you keep your Bose SoundTouch speakers functional even after the Bose Cloud shutdown in May 2026. It emulates the necessary cloud services locally on your network.</p>
|
||||
|
||||
<h3>Migration Process at a Glance</h3>
|
||||
<div class="info-box prerequisite-box">
|
||||
<strong>🔌 Prerequisite: Enable SSH</strong><br>
|
||||
Migration requires SSH access. To enable it:
|
||||
<ol style="margin-top: 5px; margin-bottom: 5px;">
|
||||
<li>Create an empty file named <code>remote_services</code> on a USB stick.</li>
|
||||
<li>Insert it into the speaker's <strong>SERVICE</strong> port and reboot the speaker.</li>
|
||||
</ol>
|
||||
<strong>Verify connection:</strong>
|
||||
<ul style="margin-top: 5px; margin-bottom: 0; padding-left: 20px;">
|
||||
<li>Use the <strong>Migration</strong> tab to select your device and verify that <em>SSH Connection</em> shows ✅ Success.</li>
|
||||
<li>Or manually: <code>ssh -oHostKeyAlgorithms=+ssh-rsa root@<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>
|
||||
<a href="/web/migration/" class="choice-card">
|
||||
<div class="icon">⚙️</div>
|
||||
<div class="title">Migration</div>
|
||||
<div class="desc">Setup, data sync, and cloud migration toolkit.</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/web/js/script.js"></script>
|
||||
<footer style="margin-top: 50px; padding: 20px; border-top: 1px solid #eee; font-size: 0.8em; color: #888; text-align: center;">
|
||||
<footer>
|
||||
<span id="version-info">AfterTouch</span>
|
||||
</footer>
|
||||
|
||||
<script src="/web/shared/common.js"></script>
|
||||
<script>
|
||||
fetchVersion();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>AfterTouch (SoundTouch Toolkit)</title>
|
||||
<link rel="icon" href="/media/favicon-braille.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="../shared/common.css">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>AfterTouch</h1>
|
||||
<p style="margin-top: -10px; font-style: italic; color: #666;">Bose SoundTouch Toolkit</p>
|
||||
<p style="margin-bottom: 20px;"><a href="/">← 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>
|
||||
+195
-152
@@ -103,8 +103,9 @@ async function updateSettings() {
|
||||
|
||||
async function fetchDevices() {
|
||||
try {
|
||||
const response = await fetch('/setup/devices');
|
||||
const response = await fetch('/devices');
|
||||
const devices = await response.json();
|
||||
window._knownDevices = devices; // Store globally for easy lookup
|
||||
const container = document.getElementById('device-list');
|
||||
const syncSelector = document.getElementById('sync-device-list');
|
||||
const migrationSelector = document.getElementById('migration-device-list');
|
||||
@@ -134,20 +135,20 @@ async function fetchDevices() {
|
||||
<td class="col-fw-serial"><div class="col-firmware">${d.firmware_version || '0.0.0'}</div><div class="col-serial" style="font-size: 0.8em; color: #666;">${d.device_serial_number}</div></td>
|
||||
<td class="col-method">${methodLabel}</td>
|
||||
<td>
|
||||
<button onclick="prepareSync('${d.ip_address}')">Sync Data</button>
|
||||
<button onclick="prepareMigration('${d.ip_address}')">Migrate</button>
|
||||
<button onclick="prepareSync('${d.device_id}')">Sync Data</button>
|
||||
<button onclick="prepareMigration('${d.device_id}')">Migrate</button>
|
||||
<button class="btn-danger" onclick="removeDevice('${d.device_id}', '${d.name}')">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
const optSync = document.createElement('option');
|
||||
optSync.value = d.ip_address;
|
||||
optSync.value = d.device_id;
|
||||
optSync.textContent = `${d.name} (${d.ip_address})`;
|
||||
syncSelector.appendChild(optSync);
|
||||
|
||||
const optMigrate = document.createElement('option');
|
||||
optMigrate.value = d.ip_address;
|
||||
optMigrate.value = d.device_id;
|
||||
optMigrate.textContent = `${d.name} (${d.ip_address})`;
|
||||
migrationSelector.appendChild(optMigrate);
|
||||
|
||||
@@ -166,22 +167,22 @@ async function fetchDevices() {
|
||||
if (eventSelector && currentEventVal) eventSelector.value = currentEventVal;
|
||||
|
||||
// Asynchronously fetch live info for each device
|
||||
devices.forEach(d => updateDeviceInfo(d.ip_address));
|
||||
devices.forEach(d => updateDeviceInfo(d.device_id));
|
||||
}
|
||||
} catch (error) {
|
||||
document.getElementById('device-list').innerHTML = 'Error loading devices: ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
function prepareSync(ip) {
|
||||
document.getElementById('sync-device-list').value = ip;
|
||||
function prepareSync(deviceId) {
|
||||
document.getElementById('sync-device-list').value = deviceId;
|
||||
openTab(null, 'tab-sync');
|
||||
}
|
||||
|
||||
function prepareMigration(ip) {
|
||||
document.getElementById('migration-device-list').value = ip;
|
||||
function prepareMigration(deviceId) {
|
||||
document.getElementById('migration-device-list').value = deviceId;
|
||||
openTab(null, 'tab-migration');
|
||||
showSummary(ip);
|
||||
showSummary(deviceId);
|
||||
}
|
||||
|
||||
function openTab(evt, tabId) {
|
||||
@@ -220,25 +221,48 @@ function openTab(evt, tabId) {
|
||||
}
|
||||
}
|
||||
|
||||
function getDeviceLabel(deviceId) {
|
||||
if (window._knownDevices) {
|
||||
const d = window._knownDevices.find(dev => dev.device_id === deviceId);
|
||||
if (d) {
|
||||
return `${d.name} (${d.ip_address})`;
|
||||
}
|
||||
}
|
||||
// Fallback to searching the UI
|
||||
const rows = document.querySelectorAll('#device-list tr');
|
||||
for (let r of rows) {
|
||||
const deviceIdCol = r.querySelector('.col-deviceid');
|
||||
if (deviceIdCol && deviceIdCol.innerText === deviceId) {
|
||||
const nameEl = r.querySelector('.col-name');
|
||||
const ipEl = r.querySelector('.col-ip');
|
||||
if (nameEl && ipEl) {
|
||||
return `${nameEl.innerText} (${ipEl.innerText})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
async function startSync() {
|
||||
const ip = document.getElementById('sync-device-list').value;
|
||||
if (!ip) {
|
||||
const deviceId = document.getElementById('sync-device-list').value;
|
||||
if (!deviceId) {
|
||||
alert('Please select a device first');
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceLabel = getDeviceLabel(deviceId);
|
||||
const status = document.getElementById('sync-status');
|
||||
const results = document.getElementById('sync-results');
|
||||
const log = document.getElementById('sync-log');
|
||||
|
||||
status.style.display = 'block';
|
||||
status.style.backgroundColor = '#eef';
|
||||
status.textContent = 'Syncing data from ' + ip + '...';
|
||||
status.textContent = 'Syncing data from ' + deviceLabel + '...';
|
||||
results.style.display = 'none';
|
||||
log.innerHTML = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/sync/' + ip, { method: 'POST' });
|
||||
const response = await fetch('/setup/devices/' + deviceId + '/sync', { method: 'POST' });
|
||||
if (response.ok) {
|
||||
status.style.backgroundColor = '#dfd';
|
||||
status.textContent = '✅ Sync completed successfully!';
|
||||
@@ -254,18 +278,6 @@ async function startSync() {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchVersion() {
|
||||
try {
|
||||
const response = await fetch('/setup/version');
|
||||
const data = await response.json();
|
||||
const info = document.getElementById('version-info');
|
||||
if (info && data.version) {
|
||||
info.innerText = `AfterTouch ${data.version} (${data.commit}) - ${data.date}`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch version info', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchInteractionStats() {
|
||||
console.log('Fetching interaction stats...');
|
||||
@@ -605,7 +617,7 @@ async function fetchDeviceEvents(deviceId) {
|
||||
list.innerHTML = '<tr><td colspan="3" style="padding: 20px; text-align: center; color: #666;">Loading events...</td></tr>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/setup/devices/${deviceId}/events`);
|
||||
const response = await fetch(`/devices/${deviceId}/events`);
|
||||
const data = await response.json();
|
||||
const events = data.events;
|
||||
|
||||
@@ -657,7 +669,7 @@ async function addManualDevice() {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/devices', {
|
||||
const response = await fetch('/devices', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ip: ip })
|
||||
@@ -681,7 +693,7 @@ async function removeDevice(deviceId, name) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/setup/devices/${deviceId}`, {
|
||||
const response = await fetch(`/devices/${deviceId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
@@ -725,14 +737,23 @@ async function pollDiscoveryStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateDeviceInfo(ip) {
|
||||
async function updateDeviceInfo(deviceId) {
|
||||
try {
|
||||
const response = await fetch('/setup/info/' + ip);
|
||||
const response = await fetch('/devices/' + deviceId + '/info');
|
||||
if (!response.ok) return;
|
||||
const info = await response.json();
|
||||
|
||||
const rowId = 'device-row-' + ip.replace(/\./g, '-');
|
||||
const row = document.getElementById(rowId);
|
||||
// Find the row by deviceId
|
||||
const rows = document.querySelectorAll('#device-list tr');
|
||||
let row = null;
|
||||
for (let r of rows) {
|
||||
const deviceIdCol = r.querySelector('.col-deviceid');
|
||||
if (deviceIdCol && deviceIdCol.innerText === deviceId) {
|
||||
row = r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (row) {
|
||||
const nameEl = row.querySelector('.col-name');
|
||||
if (nameEl && info.name) nameEl.innerText = info.name;
|
||||
@@ -753,12 +774,12 @@ async function updateDeviceInfo(ip) {
|
||||
if (accountIdEl && info.margeAccountUUID) accountIdEl.innerText = info.margeAccountUUID;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch live info for ' + ip, error);
|
||||
console.warn('Failed to fetch live info for ' + deviceId, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function showSummary(ip) {
|
||||
if (!ip) {
|
||||
async function showSummary(deviceId) {
|
||||
if (!deviceId) {
|
||||
document.getElementById('migration-summary').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
@@ -772,10 +793,11 @@ async function showSummary(ip) {
|
||||
bmx: document.getElementById('opt-bmx').value
|
||||
};
|
||||
|
||||
const deviceLabel = getDeviceLabel(deviceId);
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.style.backgroundColor = '#ffffcc';
|
||||
statusDiv.innerHTML = 'Fetching summary for ' + ip + '...';
|
||||
statusDiv.innerHTML = 'Fetching summary for ' + deviceLabel + '...';
|
||||
|
||||
let query = '?target_url=' + encodeURIComponent(targetUrl) + '&proxy_url=' + encodeURIComponent(proxyUrl);
|
||||
for (let k in opts) {
|
||||
@@ -786,7 +808,7 @@ async function showSummary(ip) {
|
||||
if (outputBox) outputBox.style.display = 'none';
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/summary/' + ip + query);
|
||||
const response = await fetch('/setup/devices/' + deviceId + '/summary' + query);
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText);
|
||||
@@ -794,11 +816,18 @@ async function showSummary(ip) {
|
||||
const summary = await response.json();
|
||||
|
||||
statusDiv.style.display = 'none';
|
||||
document.getElementById('summary-ip').innerText = ip;
|
||||
document.getElementById('summary-ip').innerText = summary.device_id || deviceId;
|
||||
|
||||
// Update table row if it exists
|
||||
const rowId = 'device-row-' + ip.replace(/\./g, '-');
|
||||
const row = document.getElementById(rowId);
|
||||
// Find the row by deviceId
|
||||
const rows = document.querySelectorAll('#device-list tr');
|
||||
let row = null;
|
||||
for (let r of rows) {
|
||||
const deviceIdCol = r.querySelector('.col-deviceid');
|
||||
if (deviceIdCol && deviceIdCol.innerText === deviceId) {
|
||||
row = r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (row) {
|
||||
const nameEl = row.querySelector('.col-name');
|
||||
if (nameEl && summary.device_name) nameEl.innerText = summary.device_name;
|
||||
@@ -860,7 +889,7 @@ async function showSummary(ip) {
|
||||
caTrustStatus.innerText = summary.ca_cert_trusted ? '✅ Yes' : '❌ No';
|
||||
caTrustStatus.style.color = summary.ca_cert_trusted ? 'green' : 'red';
|
||||
document.getElementById('trust-ca-btn').style.display = summary.ca_cert_trusted ? 'none' : 'inline-block';
|
||||
document.getElementById('trust-ca-btn').onclick = () => trustCA(ip);
|
||||
document.getElementById('trust-ca-btn').onclick = () => trustCA(deviceId);
|
||||
} else {
|
||||
remoteStatus.innerText = '❓ Unknown';
|
||||
remoteStatus.style.color = 'gray';
|
||||
@@ -890,51 +919,51 @@ async function showSummary(ip) {
|
||||
testResultDiv.style.display = 'none';
|
||||
testResultDiv.innerText = '';
|
||||
|
||||
document.getElementById('test-connection-explicit-btn').onclick = () => testConnection(ip, true);
|
||||
document.getElementById('test-connection-trusted-btn').onclick = () => testConnection(ip, false);
|
||||
document.getElementById('test-hosts-btn').onclick = () => testHostsRedirection(ip);
|
||||
document.getElementById('test-dns-btn').onclick = () => testDNSRedirection(ip);
|
||||
document.getElementById('test-connection-explicit-btn').onclick = () => testConnection(deviceId, true);
|
||||
document.getElementById('test-connection-trusted-btn').onclick = () => testConnection(deviceId, false);
|
||||
document.getElementById('test-hosts-btn').onclick = () => testHostsRedirection(deviceId);
|
||||
document.getElementById('test-dns-btn').onclick = () => testDNSRedirection(deviceId);
|
||||
|
||||
toggleMigrationMethod();
|
||||
|
||||
const migrateBtn = document.getElementById('confirm-migrate-btn');
|
||||
migrateBtn.onclick = () => migrate(ip);
|
||||
migrateBtn.onclick = () => migrate(deviceId);
|
||||
migrateBtn.disabled = !summary.ssh_success;
|
||||
|
||||
const revertBtn = document.getElementById('revert-migrate-btn');
|
||||
revertBtn.onclick = () => revert(ip);
|
||||
revertBtn.onclick = () => revert(deviceId);
|
||||
revertBtn.disabled = !summary.ssh_success;
|
||||
revertBtn.style.display = summary.original_config ? 'inline-block' : 'none';
|
||||
|
||||
const rebootBtn = document.getElementById('reboot-speaker-btn');
|
||||
rebootBtn.onclick = () => reboot(ip);
|
||||
rebootBtn.onclick = () => reboot(deviceId);
|
||||
rebootBtn.disabled = !summary.ssh_success;
|
||||
rebootBtn.style.border = 'none'; // Reset border if it was set during migration
|
||||
|
||||
const remoteBtn = document.getElementById('ensure-remote-btn');
|
||||
remoteBtn.onclick = () => ensureRemoteServices(ip);
|
||||
remoteBtn.onclick = () => ensureRemoteServices(deviceId);
|
||||
remoteBtn.disabled = !summary.ssh_success;
|
||||
|
||||
const removeRemoteBtn = document.getElementById('remove-remote-btn');
|
||||
removeRemoteBtn.onclick = () => removeRemoteServices(ip);
|
||||
removeRemoteBtn.onclick = () => removeRemoteServices(deviceId);
|
||||
removeRemoteBtn.disabled = !summary.ssh_success || !summary.remote_services_enabled;
|
||||
|
||||
const backupBtn = document.getElementById('backup-config-btn');
|
||||
backupBtn.onclick = () => backupConfig(ip);
|
||||
backupBtn.onclick = () => backupConfig(deviceId);
|
||||
backupBtn.disabled = !summary.ssh_success || !!summary.original_config;
|
||||
|
||||
document.getElementById('migration-summary').style.display = 'block';
|
||||
document.getElementById('migration-summary').scrollIntoView();
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Error fetching summary for ' + ip + ': ' + error;
|
||||
statusDiv.innerHTML = 'Error fetching summary for ' + deviceId + ': ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
function refreshSummary() {
|
||||
const ip = document.getElementById('summary-ip').innerText;
|
||||
if (ip) {
|
||||
showSummary(ip);
|
||||
const deviceId = document.getElementById('summary-ip').innerText;
|
||||
if (deviceId) {
|
||||
showSummary(deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -949,12 +978,13 @@ function showCommandOutput(result) {
|
||||
}
|
||||
}
|
||||
|
||||
async function revert(ip) {
|
||||
if (!ip) {
|
||||
alert('Please enter a valid IP address.');
|
||||
async function revert(deviceId) {
|
||||
if (!deviceId) {
|
||||
alert('Please select a device.');
|
||||
return;
|
||||
}
|
||||
if (!confirm('Are you sure you want to revert ' + ip + ' to Bose cloud defaults?')) {
|
||||
const deviceLabel = getDeviceLabel(deviceId);
|
||||
if (!confirm('Are you sure you want to revert ' + deviceLabel + ' to Bose cloud defaults?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -964,61 +994,63 @@ async function revert(ip) {
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.style.backgroundColor = '#ffffcc';
|
||||
statusDiv.innerHTML = 'Reverting ' + ip + ' to defaults...';
|
||||
statusDiv.innerHTML = 'Reverting ' + deviceLabel + ' to defaults...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/revert/' + ip, { method: 'POST' });
|
||||
const response = await fetch('/setup/devices/' + deviceId + '/revert', { method: 'POST' });
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = '#ccffcc';
|
||||
statusDiv.innerHTML = 'Successfully started revert for ' + ip + '.';
|
||||
statusDiv.innerHTML = 'Successfully started revert for ' + deviceLabel + '.';
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Revert failed for ' + ip + ': ' + (result.message || 'Unknown error');
|
||||
statusDiv.innerHTML = 'Revert failed for ' + deviceLabel + ': ' + (result.message || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Error reverting ' + ip + ': ' + error;
|
||||
statusDiv.innerHTML = 'Error reverting ' + deviceLabel + ': ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
async function reboot(ip) {
|
||||
if (!ip) {
|
||||
alert('Please enter a valid IP address.');
|
||||
async function reboot(deviceId) {
|
||||
if (!deviceId) {
|
||||
alert('Please select a device.');
|
||||
return;
|
||||
}
|
||||
if (!confirm('Are you sure you want to reboot the speaker at ' + ip + '?')) {
|
||||
const deviceLabel = getDeviceLabel(deviceId);
|
||||
if (!confirm('Are you sure you want to reboot the speaker ' + deviceLabel + '?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.style.backgroundColor = '#ffffcc';
|
||||
statusDiv.innerHTML = 'Rebooting ' + ip + '...';
|
||||
statusDiv.innerHTML = 'Rebooting ' + deviceLabel + '...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/reboot/' + ip, { method: 'POST' });
|
||||
const response = await fetch('/devices/' + deviceId + '/reboot', { method: 'POST' });
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = '#ccffcc';
|
||||
statusDiv.innerHTML = 'Successfully started reboot for ' + ip + '.';
|
||||
statusDiv.innerHTML = 'Successfully started reboot for ' + deviceLabel + '.';
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Reboot failed for ' + ip + ': ' + (result.message || 'Unknown error');
|
||||
statusDiv.innerHTML = 'Reboot failed for ' + deviceLabel + ': ' + (result.message || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Error rebooting ' + ip + ': ' + error;
|
||||
statusDiv.innerHTML = 'Error rebooting ' + deviceLabel + ': ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
async function migrate(ip) {
|
||||
if (!ip) {
|
||||
alert('Please enter a valid IP address.');
|
||||
async function migrate(deviceId) {
|
||||
if (!deviceId) {
|
||||
alert('Please select a device.');
|
||||
return;
|
||||
}
|
||||
const deviceLabel = getDeviceLabel(deviceId);
|
||||
const targetUrl = document.getElementById('target-domain').value;
|
||||
const proxyUrl = document.getElementById('soundcork-url').value;
|
||||
const method = document.getElementById('migration-method').value;
|
||||
@@ -1036,7 +1068,7 @@ async function migrate(ip) {
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.style.backgroundColor = '#ffffcc';
|
||||
statusDiv.innerHTML = 'Migrating ' + ip + ' using ' + method + '...';
|
||||
statusDiv.innerHTML = 'Migrating ' + deviceLabel + ' using ' + method + '...';
|
||||
|
||||
let query = '?method=' + encodeURIComponent(method) + '&target_url=' + encodeURIComponent(targetUrl) + '&proxy_url=' + encodeURIComponent(proxyUrl);
|
||||
for (let k in opts) {
|
||||
@@ -1044,62 +1076,99 @@ async function migrate(ip) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/migrate/' + ip + query, { method: 'POST' });
|
||||
const response = await fetch('/setup/devices/' + deviceId + '/migrate' + query, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = '#ccffcc';
|
||||
statusDiv.innerHTML = 'Successfully started migration for ' + ip + '. <strong>Please reboot the device to activate the changes.</strong>';
|
||||
statusDiv.innerHTML = 'Successfully started migration for ' + deviceLabel + '. <strong>Please reboot the device to activate the changes.</strong>';
|
||||
|
||||
// Make reboot button available and prominent
|
||||
const rebootBtn = document.getElementById('reboot-speaker-btn');
|
||||
rebootBtn.style.display = 'inline-block';
|
||||
rebootBtn.disabled = false;
|
||||
rebootBtn.style.border = '2px solid #000';
|
||||
rebootBtn.onclick = () => reboot(deviceId);
|
||||
|
||||
// Re-show summary but with prominence on reboot
|
||||
summaryDiv.style.display = 'block';
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Migration failed for ' + ip + ': ' + (result.message || 'Unknown error');
|
||||
statusDiv.innerHTML = 'Migration failed for ' + deviceLabel + ': ' + (result.message || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Error migrating ' + ip + ': ' + error;
|
||||
statusDiv.innerHTML = 'Error migrating ' + deviceLabel + ': ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
async function trustCA(ip) {
|
||||
if (!ip) {
|
||||
alert('Please enter a valid IP address.');
|
||||
async function trustCA(deviceId) {
|
||||
if (!deviceId) {
|
||||
alert('Please select a device.');
|
||||
return;
|
||||
}
|
||||
const deviceLabel = getDeviceLabel(deviceId);
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.style.backgroundColor = '#ffffcc';
|
||||
statusDiv.innerHTML = 'Injecting Root CA into shared trust store on ' + ip + '...';
|
||||
statusDiv.innerHTML = 'Injecting Root CA into shared trust store on ' + deviceLabel + '...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/trust-ca/' + ip, { method: 'POST' });
|
||||
const response = await fetch('/setup/devices/' + deviceId + '/trust-ca', { method: 'POST' });
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = '#ccffcc';
|
||||
statusDiv.innerHTML = 'Successfully injected Root CA on ' + ip + '.';
|
||||
showSummary(ip); // Refresh to update status
|
||||
statusDiv.innerHTML = 'Successfully injected Root CA on ' + deviceLabel + '.';
|
||||
showSummary(deviceId); // Refresh to update status
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Failed to trust CA on ' + ip + ': ' + (result.message || 'Unknown error');
|
||||
statusDiv.innerHTML = 'Failed to trust CA on ' + deviceLabel + ': ' + (result.message || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Error trusting CA on ' + ip + ': ' + error;
|
||||
statusDiv.innerHTML = 'Error trusting CA on ' + deviceLabel + ': ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureRemoteServices(ip) {
|
||||
if (!ip) {
|
||||
alert('Please enter a valid IP address.');
|
||||
async function ensureRemoteServices(deviceId) {
|
||||
if (!deviceId) {
|
||||
alert('Please select a device.');
|
||||
return;
|
||||
}
|
||||
const deviceLabel = getDeviceLabel(deviceId);
|
||||
const summaryDiv = document.getElementById('migration-summary');
|
||||
summaryDiv.style.display = 'none';
|
||||
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.style.backgroundColor = '#ffffcc';
|
||||
statusDiv.innerHTML = 'Ensuring remote services for ' + deviceLabel + '...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/devices/' + deviceId + '/ensure-remote-services', { method: 'POST' });
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = '#ccffcc';
|
||||
statusDiv.innerHTML = 'Successfully ensured remote services for ' + deviceLabel + '.';
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Failed to ensure remote services for ' + deviceLabel + ': ' + (result.message || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Error ensuring remote services for ' + deviceLabel + ': ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRemoteServices(deviceId) {
|
||||
if (!deviceId) {
|
||||
alert('Please select a device.');
|
||||
return;
|
||||
}
|
||||
const deviceLabel = getDeviceLabel(deviceId);
|
||||
if (!confirm('Are you sure you want to remove remote services from ' + deviceLabel + '?')) {
|
||||
return;
|
||||
}
|
||||
const summaryDiv = document.getElementById('migration-summary');
|
||||
@@ -1108,98 +1177,68 @@ async function ensureRemoteServices(ip) {
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.style.backgroundColor = '#ffffcc';
|
||||
statusDiv.innerHTML = 'Ensuring remote services for ' + ip + '...';
|
||||
statusDiv.innerHTML = 'Removing remote services for ' + deviceLabel + '...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/ensure-remote-services/' + ip, { method: 'POST' });
|
||||
const response = await fetch('/setup/devices/' + deviceId + '/remove-remote-services', { method: 'POST' });
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = '#ccffcc';
|
||||
statusDiv.innerHTML = 'Successfully ensured remote services for ' + ip + '.';
|
||||
statusDiv.innerHTML = 'Successfully removed remote services from ' + deviceLabel + '.';
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Failed to ensure remote services for ' + ip + ': ' + (result.message || 'Unknown error');
|
||||
statusDiv.innerHTML = 'Failed to remove remote services for ' + deviceLabel + ': ' + (result.message || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Error ensuring remote services for ' + ip + ': ' + error;
|
||||
statusDiv.innerHTML = 'Error removing remote services for ' + deviceLabel + ': ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRemoteServices(ip) {
|
||||
if (!ip) {
|
||||
alert('Please enter a valid IP address.');
|
||||
async function backupConfig(deviceId) {
|
||||
if (!deviceId) {
|
||||
alert('Please select a device.');
|
||||
return;
|
||||
}
|
||||
if (!confirm('Are you sure you want to remove remote services from ' + ip + '?')) {
|
||||
return;
|
||||
}
|
||||
const summaryDiv = document.getElementById('migration-summary');
|
||||
summaryDiv.style.display = 'none';
|
||||
|
||||
const deviceLabel = getDeviceLabel(deviceId);
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.style.backgroundColor = '#ffffcc';
|
||||
statusDiv.innerHTML = 'Removing remote services for ' + ip + '...';
|
||||
statusDiv.innerHTML = 'Creating backup for ' + deviceLabel + '...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/remove-remote-services/' + ip, { method: 'POST' });
|
||||
const response = await fetch('/setup/devices/' + deviceId + '/backup', { method: 'POST' });
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = '#ccffcc';
|
||||
statusDiv.innerHTML = 'Successfully removed remote services from ' + ip + '.';
|
||||
statusDiv.innerHTML = 'Successfully created backup for ' + deviceLabel + '.';
|
||||
showSummary(deviceId); // Refresh
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Failed to remove remote services for ' + ip + ': ' + (result.message || 'Unknown error');
|
||||
statusDiv.innerHTML = 'Failed to create backup for ' + deviceLabel + ': ' + (result.message || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Error removing remote services for ' + ip + ': ' + error;
|
||||
statusDiv.innerHTML = 'Error creating backup for ' + deviceLabel + ': ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
async function backupConfig(ip) {
|
||||
if (!ip) {
|
||||
alert('Please enter a valid IP address.');
|
||||
return;
|
||||
}
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.style.backgroundColor = '#ffffcc';
|
||||
statusDiv.innerHTML = 'Creating backup for ' + ip + '...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/backup/' + ip, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = '#ccffcc';
|
||||
statusDiv.innerHTML = 'Successfully created backup for ' + ip + '.';
|
||||
showSummary(ip); // Refresh
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Backup failed for ' + ip + ': ' + (result.message || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Error creating backup for ' + ip + ': ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection(ip, useExplicitCA) {
|
||||
async function testConnection(deviceId, useExplicitCA) {
|
||||
const testUrl = document.getElementById('test-url').innerText;
|
||||
const testResultDiv = document.getElementById('test-result');
|
||||
|
||||
const deviceLabel = getDeviceLabel(deviceId);
|
||||
|
||||
testResultDiv.style.display = 'block';
|
||||
testResultDiv.style.backgroundColor = '#f0f0f0';
|
||||
testResultDiv.style.color = 'black';
|
||||
testResultDiv.innerText = 'Running connection test from ' + ip + '...\n(This may take a few seconds)';
|
||||
testResultDiv.innerText = 'Running connection test from ' + deviceLabel + '...\n(This may take a few seconds)';
|
||||
|
||||
try {
|
||||
const query = `?target_url=${encodeURIComponent(testUrl)}&use_explicit_ca=${useExplicitCA}`;
|
||||
const response = await fetch(`/setup/test-connection/${ip}${query}`, { method: 'POST' });
|
||||
const response = await fetch(`/setup/devices/${deviceId}/test-connection${query}`, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
|
||||
if (result.ok) {
|
||||
@@ -1215,18 +1254,20 @@ async function testConnection(ip, useExplicitCA) {
|
||||
}
|
||||
}
|
||||
|
||||
async function testHostsRedirection(ip) {
|
||||
async function testHostsRedirection(deviceId) {
|
||||
const targetUrl = document.getElementById('target-domain').value;
|
||||
const testResultDiv = document.getElementById('hosts-test-result');
|
||||
|
||||
const deviceLabel = getDeviceLabel(deviceId);
|
||||
|
||||
testResultDiv.style.display = 'block';
|
||||
testResultDiv.style.backgroundColor = '#f0f0f0';
|
||||
testResultDiv.style.color = 'black';
|
||||
testResultDiv.innerText = 'Running hosts redirection test from ' + ip + '...\n(This may take a few seconds)';
|
||||
testResultDiv.innerText = 'Running hosts redirection test from ' + deviceLabel + '...\n(This may take a few seconds)';
|
||||
|
||||
try {
|
||||
const query = `?target_url=${encodeURIComponent(targetUrl)}`;
|
||||
const response = await fetch(`/setup/test-hosts/${ip}${query}`, { method: 'POST' });
|
||||
const response = await fetch(`/setup/devices/${deviceId}/test-hosts${query}`, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
|
||||
if (result.ok) {
|
||||
@@ -1242,18 +1283,20 @@ async function testHostsRedirection(ip) {
|
||||
}
|
||||
}
|
||||
|
||||
async function testDNSRedirection(ip) {
|
||||
async function testDNSRedirection(deviceId) {
|
||||
const targetUrl = document.getElementById('target-domain').value;
|
||||
const testResultDiv = document.getElementById('dns-test-result');
|
||||
|
||||
const deviceLabel = getDeviceLabel(deviceId);
|
||||
|
||||
testResultDiv.style.display = 'block';
|
||||
testResultDiv.style.backgroundColor = '#f0f0f0';
|
||||
testResultDiv.style.color = 'black';
|
||||
testResultDiv.innerText = 'Running DNS redirection test from ' + ip + '...\n(This may take a few seconds)';
|
||||
testResultDiv.innerText = 'Running DNS redirection test from ' + deviceLabel + '...\n(This may take a few seconds)';
|
||||
|
||||
try {
|
||||
const query = `?target_url=${encodeURIComponent(targetUrl)}`;
|
||||
const response = await fetch(`/setup/test-dns/${ip}${query}`, { method: 'POST' });
|
||||
const response = await fetch(`/setup/devices/${deviceId}/test-dns${query}`, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
|
||||
if (result.ok) {
|
||||
@@ -0,0 +1,16 @@
|
||||
footer {
|
||||
margin-top: 50px;
|
||||
padding: 20px;
|
||||
font-size: 0.8em;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#version-info a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#version-info a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
|
||||
@@ -115,6 +116,10 @@ type DeviceInfoXML struct {
|
||||
SoftwareVersion string `xml:"softwareVersion"`
|
||||
SerialNumber string `xml:"serialNumber"`
|
||||
} `xml:"components>component" json:"-"`
|
||||
|
||||
// Enriched fields (not part of device /info XML)
|
||||
NowPlaying *models.NowPlaying `json:"nowPlaying,omitempty"`
|
||||
Volume *models.Volume `json:"volume,omitempty"`
|
||||
}
|
||||
|
||||
// GetLiveDeviceInfo fetches live information from the speaker's :8090/info endpoint.
|
||||
@@ -152,6 +157,16 @@ func (m *Manager) GetLiveDeviceInfo(deviceIP string) (*DeviceInfoXML, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich with live now playing and volume via device API (best-effort)
|
||||
c := client.NewClientFromHost(deviceIP)
|
||||
if vol, err := c.GetVolume(); err == nil {
|
||||
infoXML.Volume = vol
|
||||
}
|
||||
|
||||
if np, err := c.GetNowPlaying(); err == nil {
|
||||
infoXML.NowPlaying = np
|
||||
}
|
||||
|
||||
return &infoXML, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user