mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-31 14:57:17 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49ec2d58db | ||
|
|
c78664ee59 | ||
|
|
c17ba0e839 | ||
|
|
17f052308f | ||
|
|
2e9f931797 | ||
|
|
41378f720b | ||
|
|
c87a28f3ba | ||
|
|
df18749220 | ||
|
|
b6702cd4b5 | ||
|
|
fc5de2bbc7 | ||
|
|
cf82feca06 | ||
|
|
b8bbc52803 | ||
|
|
a36c2e4629 |
@@ -1,5 +1,8 @@
|
||||
name: CI
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
@@ -14,6 +14,8 @@ jobs:
|
||||
vulnerability-scan:
|
||||
name: Vulnerability Scan
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -53,6 +55,8 @@ jobs:
|
||||
static-analysis:
|
||||
name: Static Security Analysis
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -119,6 +123,8 @@ jobs:
|
||||
dependency-review:
|
||||
name: Dependency Review
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
steps:
|
||||
@@ -137,6 +143,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [vulnerability-scan, static-analysis, codeql-analysis]
|
||||
if: always()
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Security scan summary
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.0-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.1-alpine AS builder
|
||||
|
||||
# Declare automatic platform ARGs to make them available in build stage
|
||||
# See https://docs.docker.com/reference/dockerfile#automatic-platform-args-in-the-global-scope
|
||||
|
||||
@@ -78,6 +78,7 @@ The `soundtouch-service` is a local server that emulates Bose's cloud services.
|
||||
- **🔌 Easy Setup**: Activate SSH via USB stick (`remote_services` file)
|
||||
- **🔧 Device Migration**: Seamlessly transition devices to local control
|
||||
- **🌐 Web Management UI**: Easy browser-based setup and management
|
||||
- **🎮 Stockholm Mini**: A minimal reverse-engineered UI for device control (accessible at `/web/stockholm-mini/`)
|
||||
- **💾 Persistent Data**: Store presets, recents, and sources locally
|
||||
- **🔄 Endpoint Mirroring**: Asynchronously mirror local requests to Bose cloud for parity testing
|
||||
- **⚖️ Parity Logging**: Detect and record discrepancies between local and official Bose responses
|
||||
|
||||
@@ -753,10 +753,32 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
|
||||
r.Get("/proxy/*", server.HandleProxyRequest)
|
||||
|
||||
r.Get("/devices", server.HandleListDiscoveredDevices)
|
||||
r.Get("/devices/{deviceId}/info", server.HandleGetStockholmDeviceInfo)
|
||||
r.Post("/devices/{deviceId}/key/{key}", server.HandleDeviceKey)
|
||||
r.Post("/devices/{deviceId}/volume/{level}", server.HandleDeviceVolume)
|
||||
|
||||
// Stockholm Mini app
|
||||
r.Handle("/stockholm-mini/*", http.StripPrefix("/stockholm-mini/", http.FileServer(http.Dir("pkg/service/handlers/web/stockholm-mini"))))
|
||||
|
||||
r.Route("/devices", func(r chi.Router) {
|
||||
r.Get("/", server.HandleListDiscoveredDevices)
|
||||
r.Post("/", server.HandleAddManualDevice)
|
||||
|
||||
r.Route("/{deviceId}", func(r chi.Router) {
|
||||
r.Delete("/", server.HandleRemoveDevice)
|
||||
r.Get("/events", server.HandleGetDeviceEvents)
|
||||
r.Get("/info", server.HandleGetDeviceInfo)
|
||||
r.Get("/ws", server.HandleDeviceWebSocket)
|
||||
r.Post("/key/{key}", server.HandleDeviceKey)
|
||||
r.Post("/volume/{level}", server.HandleDeviceVolume)
|
||||
r.Post("/reboot", server.HandleRebootDevice)
|
||||
})
|
||||
})
|
||||
|
||||
r.Get("/version", server.HandleGetVersionInfo)
|
||||
|
||||
r.Route("/setup", func(r chi.Router) {
|
||||
r.Get("/devices", server.HandleListDiscoveredDevices)
|
||||
r.Post("/devices", server.HandleAddManualDevice)
|
||||
r.Delete("/devices/{deviceId}", server.HandleRemoveDevice)
|
||||
r.Post("/discover", server.HandleTriggerDiscovery)
|
||||
r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
|
||||
r.Get("/settings", server.HandleGetSettings)
|
||||
@@ -777,7 +799,6 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Get("/ca.crt", server.HandleGetCACert)
|
||||
r.Get("/proxy-settings", server.HandleGetProxySettings)
|
||||
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
|
||||
r.Get("/version", server.HandleGetVersionInfo)
|
||||
r.Get("/interaction-stats", server.HandleGetInteractionStats)
|
||||
r.Get("/interactions", server.HandleListInteractions)
|
||||
r.Get("/interaction-content", server.HandleGetInteractionContent)
|
||||
@@ -791,7 +812,19 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Get("/dns-discoveries/download", server.HandleDownloadDNSDiscoveries)
|
||||
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
|
||||
|
||||
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
|
||||
r.Route("/devices/{deviceId}", func(r chi.Router) {
|
||||
r.Get("/summary", server.HandleGetMigrationSummary)
|
||||
r.Post("/migrate", server.HandleMigrateDevice)
|
||||
r.Post("/revert", server.HandleRevertMigration)
|
||||
r.Post("/trust-ca", server.HandleTrustCACert)
|
||||
r.Post("/ensure-remote-services", server.HandleEnsureRemoteServices)
|
||||
r.Post("/remove-remote-services", server.HandleRemoveRemoteServices)
|
||||
r.Post("/backup", server.HandleBackupConfig)
|
||||
r.Post("/sync", server.HandleInitialSync)
|
||||
r.Post("/test-connection", server.HandleTestConnection)
|
||||
r.Post("/test-hosts", server.HandleTestHostsRedirection)
|
||||
r.Post("/test-dns", server.HandleTestDNSRedirection)
|
||||
})
|
||||
})
|
||||
|
||||
r.NotFound(server.HandleNotFound)
|
||||
|
||||
@@ -119,7 +119,7 @@ soundtouch-service
|
||||
```go
|
||||
// Build custom applications on top of local services
|
||||
client := &http.Client{}
|
||||
resp, _ := client.Get("http://localhost:8000/setup/devices")
|
||||
resp, _ := client.Get("http://localhost:8000/devices")
|
||||
```
|
||||
|
||||
### Privacy-Conscious Users
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
* [Upstream URLs](analysis/UPSTREAM-URLS.md)
|
||||
* [Anonymization Summary](analysis/ANONYMIZATION-SUMMARY.md)
|
||||
* [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md)
|
||||
* [Stockholm App Analysis](analysis/stockholm-app-analysis.md)
|
||||
* [Wiki API Comparison](analysis/WIKI-COMPARISON.md)
|
||||
* [IoT Config Summary](analysis/IOT-CONFIG-SUMMARY.md)
|
||||
* [IoT Configuration Analysis](analysis/IOT-CONFIGURATION-ANALYSIS.md)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
### Stockholm App Analysis Report
|
||||
|
||||
#### 1. Overview
|
||||
The Stockholm app is a CEPE MAUI SoundTouch Controller HTML5/JS UI. It is designed to run as a web-based interface for Bose SoundTouch devices, likely served by the device itself or an associated controller.
|
||||
|
||||
- **Technology Stack**: HTML5, CSS3, JavaScript (Minified).
|
||||
- **Key Libraries**:
|
||||
- **jQuery**: Core DOM manipulation and event handling.
|
||||
- **iScroll**: Used for smooth scrolling in lists and carousels.
|
||||
- **Forge**: Used for cryptographic operations (likely for secure communication or authentication).
|
||||
- **WebSocket Polyfill**: Ensures WebSocket compatibility across environments.
|
||||
|
||||
#### 2. Directory Structure
|
||||
- `js/`: Core application logic.
|
||||
- `app/`: Main application entry point (`app.js`).
|
||||
- `models/`: Data models for UI components (Presets, Favorites, Onboarding, etc.).
|
||||
- `music_services/`: Implementation of various music services (Amazon, Deezer, Spotify, BMX, etc.).
|
||||
- `views/`: UI view templates and logic.
|
||||
- `utils/`: Utility functions for security, data analytics, and general-purpose tasks.
|
||||
- `json/`: Configuration files and static data.
|
||||
- `config.json`: Core application configuration including Base64 encoded Bose API endpoints (e.g., streaming, events, BMX registry).
|
||||
- `sourceFeatures.json`: Capability mapping for different sources.
|
||||
- `setup/`: Onboarding and initial device setup logic.
|
||||
- `lang/`: Localization files for multi-language support.
|
||||
|
||||
#### 3. Communication Architecture
|
||||
The app uses several communication channels to interact with the SoundTouch ecosystem:
|
||||
|
||||
- **Socket Communication (`socket_comm.js`)**: Real-time updates and low-latency commands via WebSockets.
|
||||
- **BMX (`bmx.js` & `js/music_services/bmx/`)**: Interactions with the Bose Music eXperience services. Handles account management, navigation, and API response validation.
|
||||
- **Marge (`marge_comm.js`)**: Likely used for interaction with the Marge service (Bose's legacy cloud/proxy service).
|
||||
- **Worker-based Architecture**: Many services use Web Workers (`bmx_worker.js`, `spotify_worker.js`) to handle API requests and data processing in the background, keeping the UI responsive.
|
||||
|
||||
#### 4. Key Features & Functionality
|
||||
- **Multi-Device Management**: Discovering and controlling multiple speakers on the network.
|
||||
- **Music Service Integration**: Deep integration with Spotify, Amazon Music, Deezer, and Pandora.
|
||||
- **Preset Management**: Browsing and setting presets directly from the UI.
|
||||
- **Zone Control**: Creating and managing multi-room groups (Master/Slave configurations).
|
||||
- **Onboarding**: A dedicated setup flow for new devices.
|
||||
- **Analytics & Data Collection**: Modules like `data_analytics.js` and `dc_server.js` suggest tracking of user interactions.
|
||||
|
||||
#### 5. Integration Opportunities for Bose-SoundTouch Project
|
||||
Based on the Stockholm app's capabilities, the following features could be enhanced or added to our Go-based `soundtouch-service`:
|
||||
|
||||
1. **Enhanced BMX Emulation**: Use insights from `bmx_client.js` and `bmx_navigate_response_generator.js` to improve our local BMX implementation.
|
||||
2. **Spotify/Amazon Service Proxies**: Implement the backend logic required to support the same API calls the Stockholm app makes to these services.
|
||||
3. **UI parity**: The Stockholm app's view templates (`views/`) can serve as a reference for our Web Management UI.
|
||||
4. **WebSocket Support**: Ensure our service provides a robust WebSocket interface similar to what the Stockholm app expects for real-time state synchronization.
|
||||
5. **Capability Discovery**: Better utilization of the `sourceFeatures.json` logic to dynamically show/hide features based on the device model and firmware version.
|
||||
|
||||
#### 6. Conclusion
|
||||
The Stockholm app is a mature, full-featured controller that relies heavily on Bose's proprietary BMX and Marge services. By analyzing its client-side logic, we can better understand the expected API responses and interaction patterns needed to provide a seamless local replacement for the Bose Cloud.
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
This guide will walk you through migrating your Bose SoundTouch speakers from Bose's cloud services to your own local SoundTouch service. By the end of this process, your speakers will be completely independent of Bose's servers while retaining all their functionality.
|
||||
This guide will walk you through migrating your Bose SoundTouch speakers from Bose's cloud services to AfterTouch, your own local SoundTouch service. By the end of this process, your speakers will be completely independent of Bose's servers while retaining all their functionality.
|
||||
|
||||
> **💡 Why Migrate?** Bose announced the shutdown of their SoundTouch cloud services in May 2026. This migration ensures your speakers continue working indefinitely with enhanced local control and monitoring.
|
||||
|
||||
@@ -104,6 +104,20 @@ After creation, you'll see your **Account Dashboard**:
|
||||

|
||||
*Example: Fresh account dashboard ready for device migration*
|
||||
|
||||
### 2.3 Initial Settings
|
||||
|
||||
Once your account is created, configure the global settings:
|
||||
1. **Settings**:
|
||||
- Check **Target Domain**: Ensure it's reachable from the speaker (e.g., `soundtouch.fritz.box`).
|
||||
- **DNS Discovery**: Enable DNS discovery on port `:53`. This is crucial for the DNS hook migration method.
|
||||
2. **Devices**:
|
||||
- Go to the **"Device Discovery"** tab.
|
||||
- Click **"Scan Network"** or manually add a speaker via IP address.
|
||||
- Your devices should appear with **SSH Status**: `Enabled`.
|
||||
|
||||

|
||||
*Example: Discovered devices with remote access enabled*
|
||||
|
||||
## Step 3: Prepare Your Devices
|
||||
|
||||
> **⚠️ Important**: This step temporarily enables SSH access on your speakers. SSH will be automatically disabled after migration unless you choose to keep it enabled.
|
||||
@@ -114,8 +128,8 @@ For each SoundTouch device:
|
||||
|
||||
1. **Prepare a USB drive**:
|
||||
- Format as FAT32
|
||||
- Create a file named `remote_services` (no extension)
|
||||
- Content of file: `enable`
|
||||
- Create an empty file named `remote_services` (no extension)
|
||||
- (Optional) Firmware update/reset, see [Bose SoundTouch USB Update](https://downloads.bose.com/ced/soundtouch/soundtouch_usb/index.html)
|
||||
|
||||
2. **Insert USB drive** into your SoundTouch speaker
|
||||
3. **Power cycle** the device (unplug for 10 seconds, then reconnect)
|
||||
@@ -123,17 +137,6 @@ For each SoundTouch device:
|
||||

|
||||
*Example: USB drive setup for enabling remote services*
|
||||
|
||||
### 3.2 Verify Remote Access
|
||||
|
||||
In your SoundTouch Service dashboard:
|
||||
|
||||
1. Go to **"Device Discovery"** tab
|
||||
2. Click **"Scan Network"**
|
||||
3. Your devices should appear with **SSH Status**: `Enabled`
|
||||
|
||||

|
||||
*Example: Discovered devices with remote access enabled*
|
||||
|
||||
## Step 4: Discover and Register Devices
|
||||
|
||||
### 4.1 Automatic Discovery
|
||||
@@ -174,70 +177,51 @@ After registration, you'll see:
|
||||
|
||||
## Step 5: Migrate Individual Devices
|
||||
|
||||
> **🔄 Migration Strategy**: We recommend "gradual migration" where devices continue using Bose services while building local data, then switch to local-only when ready.
|
||||
|
||||
### 5.1 Start Device Migration
|
||||
### 5.1 Step 3: Data Sync
|
||||
|
||||
1. **Dashboard** → **"Devices"** → Select your device
|
||||
2. **Click "Migrate Device"**
|
||||
3. **Configure migration**:
|
||||
- Migration Method: `Gradual` (recommended)
|
||||
- Preserve Data: ✅ `Yes`
|
||||
- Backup Current State: ✅ `Yes`
|
||||
- Test Period: `7 days`
|
||||
2. Click **"Data Sync"**
|
||||
3. This fetches configuration (presets, recents, sources) from the speaker to the SoundTouch service.
|
||||
|
||||

|
||||
*Example: Migration configuration dialog*
|
||||
### 5.2 Step 4: Migration
|
||||
|
||||
### 5.2 Migration Process
|
||||
Once data is synced, proceed to the migration tab for the device:
|
||||
|
||||
The migration happens in phases:
|
||||
1. **Backup XML**: Create an off-device backup of the current configuration.
|
||||
2. **Enable Persistent Remote Service**: This ensures SSH remains available after reboots.
|
||||
- *Note*: If you see `'rw: command not found'`, you can safely ignore it.
|
||||
3. **CA Certificate Configuration**:
|
||||
- **Test with explicit CA**: Verify the speaker can communicate using the local CA.
|
||||
- **Trust CA now**: Inject the local Root CA into the speaker's trust store.
|
||||
- **Test with shared trust store**: Verify general HTTPS communication.
|
||||
4. **Migration Method**:
|
||||
- Select **"Redirect via DNS hook"**.
|
||||
- **Test DNS Redirection**: Ensure the speaker correctly resolves the service domain.
|
||||
5. **Confirm Migration**: Apply the final changes to the speaker.
|
||||
|
||||
**Phase 1: Data Collection (Days 1-3)**
|
||||
- Device continues using Bose services
|
||||
- Local service mirrors all requests/responses
|
||||
- Builds local database of presets, recents, sources
|
||||
- Status: `Migrating - Data Collection`
|
||||
#### Example Migration Output:
|
||||
```text
|
||||
Successfully created off-device backup of current configuration.
|
||||
Pre-flight: Write access verified.
|
||||
Resolved soundtouch.fritz.box to 192.168.1.100
|
||||
Uploaded /mnt/nv/soundtouch-service/aftertouch.resolv.conf
|
||||
/mnt/nv/rc.local already contains Aftertouch hook logic
|
||||
(rw || mount -o remount,rw /): sh: rw: command not found
|
||||
|
||||
**Phase 2: Testing (Days 4-6)**
|
||||
- Device uses local services for some requests
|
||||
- Falls back to Bose if issues occur
|
||||
- Validates local data completeness
|
||||
- Status: `Migrating - Testing`
|
||||
cp /etc/udhcpc.d/50default /etc/udhcpc.d/50default.original:
|
||||
Applied patch to /etc/udhcpc.d/50default
|
||||
Verified patch on /etc/udhcpc.d/50default
|
||||
cp /opt/Bose/udhcpc.script /opt/Bose/udhcpc.script.original:
|
||||
Applied patch to /opt/Bose/udhcpc.script
|
||||
Verified patch on /opt/Bose/udhcpc.script
|
||||
CA certificate already trusted, skipping injection
|
||||
```
|
||||
|
||||
**Phase 3: Full Local (Day 7+)**
|
||||
- Device uses only local services
|
||||
- Bose services disabled for this device
|
||||
- Full independence achieved
|
||||
- Status: `Active - Local Only`
|
||||
## Step 7: Complete Account Migration
|
||||
|
||||

|
||||
*Example: Migration progress tracking*
|
||||
### 7.1 Migrate All Devices
|
||||
|
||||
### 5.3 Monitor Migration Health
|
||||
|
||||
During migration, monitor:
|
||||
|
||||
**Device Health Dashboard**:
|
||||
- **Connectivity**: Should remain `Online` throughout
|
||||
- **Response Time**: Should be similar or better than before
|
||||
- **Error Rate**: Should remain low (<1%)
|
||||
- **Data Completeness**: Shows percentage of data successfully migrated
|
||||
|
||||

|
||||
*Example: Migration health monitoring*
|
||||
|
||||
**Data Verification**:
|
||||
- **Presets**: Verify all presets work correctly
|
||||
- **Recent History**: Check recent play history is preserved
|
||||
- **Source Configuration**: Ensure all configured sources (Spotify, etc.) work
|
||||
- **Multiroom**: Test zone functionality if applicable
|
||||
|
||||
## Step 6: Complete Account Migration
|
||||
|
||||
### 6.1 Migrate All Devices
|
||||
|
||||
Repeat Step 5 for each of your SoundTouch devices. You can migrate multiple devices simultaneously, but we recommend doing 1-2 at a time to monitor progress.
|
||||
Repeat the migration process for each of your SoundTouch devices. You can migrate multiple devices simultaneously, but we recommend doing 1-2 at a time to monitor progress.
|
||||
|
||||
**Migration Dashboard** shows overall progress:
|
||||
- **Devices Migrated**: `2 of 4 completed`
|
||||
@@ -248,7 +232,7 @@ Repeat Step 5 for each of your SoundTouch devices. You can migrate multiple devi
|
||||

|
||||
*Example: Account-wide migration progress*
|
||||
|
||||
### 6.2 Verify Complete Migration
|
||||
### 7.2 Verify Complete Migration
|
||||
|
||||
When all devices are migrated:
|
||||
|
||||
@@ -260,18 +244,16 @@ When all devices are migrated:
|
||||

|
||||
*Example: Completed migration dashboard*
|
||||
|
||||
## Step 7: Post-Migration Setup
|
||||
## Step 8: Post-Migration Tasks
|
||||
|
||||
### 7.1 Disable Remote Services (Optional)
|
||||
1. **Remove USB stick** from the speaker.
|
||||
2. **Reboot** the device to apply all changes.
|
||||
|
||||
For enhanced security, disable SSH on migrated devices:
|
||||
### 8.1 Disable Remote Services (Optional)
|
||||
|
||||
1. **Dashboard** → **"Device Management"**
|
||||
2. **Select device** → **"Security Settings"**
|
||||
3. **Click "Disable Remote Services"**
|
||||
4. **Confirm** to disable SSH access
|
||||
For enhanced security, you can disable SSH on migrated devices. However, keeping it enabled allows for easier future maintenance or reverts.
|
||||
|
||||
### 7.2 Configure Backups
|
||||
### 8.2 Configure Backups
|
||||
|
||||
Set up automatic backups of your device configurations:
|
||||
|
||||
@@ -284,7 +266,7 @@ Set up automatic backups of your device configurations:
|
||||

|
||||
*Example: Backup configuration settings*
|
||||
|
||||
### 7.3 Set Up Monitoring Alerts (Optional)
|
||||
### 8.3 Set Up Monitoring Alerts (Optional)
|
||||
|
||||
Configure notifications for important events:
|
||||
|
||||
|
||||
@@ -212,23 +212,23 @@ Device migration switches your SoundTouch devices from Bose's cloud services to
|
||||
|
||||
```bash
|
||||
# Get migration summary first
|
||||
curl http://localhost:8000/setup/migration-summary/192.168.1.100
|
||||
curl http://localhost:8000/setup/devices/192.168.1.100/summary
|
||||
|
||||
# Perform migration
|
||||
curl -X POST http://localhost:8000/setup/migrate/192.168.1.100
|
||||
curl -X POST http://localhost:8000/setup/devices/192.168.1.100/migrate
|
||||
|
||||
# Verify migration status
|
||||
curl http://localhost:8000/setup/devices
|
||||
curl http://localhost:8000/devices
|
||||
```
|
||||
|
||||
#### Advanced Migration Options
|
||||
|
||||
```bash
|
||||
# Migration with proxy fallback for original services
|
||||
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?proxy_url=http://localhost:8000&marge=original&stats=original"
|
||||
curl -X POST "http://localhost:8000/setup/devices/192.168.1.100/migrate?proxy_url=http://localhost:8000&marge=original&stats=original"
|
||||
|
||||
# Migration with custom target URL
|
||||
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?target_url=https://my-server.com:8000"
|
||||
curl -X POST "http://localhost:8000/setup/devices/192.168.1.100/migrate?target_url=https://my-server.com:8000"
|
||||
```
|
||||
|
||||
### Post-Migration Verification
|
||||
@@ -237,13 +237,13 @@ After migration, verify the device is working correctly:
|
||||
|
||||
```bash
|
||||
# Check device status
|
||||
curl http://localhost:8000/setup/devices
|
||||
curl http://localhost:8000/devices
|
||||
|
||||
# Test preset functionality
|
||||
curl "http://192.168.1.100:8090/presets"
|
||||
|
||||
# Monitor device events (if needed)
|
||||
curl "http://localhost:8000/events/192.168.1.100"
|
||||
curl "http://localhost:8000/devices/08DF1F0BA325/events"
|
||||
```
|
||||
|
||||
#### ResolvConf Migration (DHCP-Aware DNS Redirection)
|
||||
@@ -347,7 +347,7 @@ Mirrored requests are also recorded in the **Interaction Log** under the categor
|
||||
|
||||
### Discovery & Setup
|
||||
|
||||
#### `GET /setup/devices`
|
||||
#### `GET /devices`
|
||||
Lists all discovered SoundTouch devices with their current status.
|
||||
|
||||
**Response:**
|
||||
@@ -368,10 +368,10 @@ Lists all discovered SoundTouch devices with their current status.
|
||||
#### `POST /setup/discover`
|
||||
Triggers immediate network device discovery.
|
||||
|
||||
#### `GET /setup/info/{deviceIP}`
|
||||
#### `GET /devices/{deviceIP}/info`
|
||||
Gets detailed device information and configuration.
|
||||
|
||||
#### `GET /setup/migration-summary/{deviceIP}`
|
||||
#### `GET /setup/devices/{deviceIP}/summary`
|
||||
Analyzes device configuration and provides migration preview.
|
||||
|
||||
**Response:**
|
||||
@@ -388,7 +388,7 @@ Analyzes device configuration and provides migration preview.
|
||||
}
|
||||
```
|
||||
|
||||
#### `POST /setup/migrate/{deviceIP}`
|
||||
#### `POST /setup/devices/{deviceIP}/migrate`
|
||||
Migrates device to use local services.
|
||||
|
||||
**Query Parameters:**
|
||||
@@ -399,6 +399,33 @@ Migrates device to use local services.
|
||||
- `sw_update`: Set to "original" to proxy update requests (optional)
|
||||
- `bmx`: Set to "original" to proxy BMX requests (optional)
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/revert`
|
||||
Reverts device to Bose cloud defaults.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/trust-ca`
|
||||
Injects the AfterTouch root CA into the device's trust store.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/sync`
|
||||
Syncs presets and recents from the device to local storage.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/backup`
|
||||
Creates a backup of the current device configuration.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/ensure-remote-services`
|
||||
Enables persistent SSH/remote services on the device.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/remove-remote-services`
|
||||
Removes persistent SSH/remote services from the device.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/test-connection`
|
||||
Tests HTTPS connection from device to service.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/test-hosts`
|
||||
Tests /etc/hosts redirection on the device.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/test-dns`
|
||||
Tests DNS redirection on the device.
|
||||
|
||||
### BMX Services (Bose Media eXchange)
|
||||
|
||||
#### `GET /bmx/registry/v1/services`
|
||||
@@ -658,13 +685,13 @@ find data/stats/ -name "*.json" -mtime +90 -delete
|
||||
- **Description**: Browser-based guided flow for discovery, data sync, and migration.
|
||||
|
||||
### Setup API
|
||||
- `GET /setup/devices`: List all known (auto-discovered and manual) devices.
|
||||
- `POST /setup/devices`: Manually add a device by IP.
|
||||
- `GET /devices`: List all known (auto-discovered and manual) devices.
|
||||
- `POST /devices`: Manually add a device by IP.
|
||||
- `POST /setup/discover`: Trigger a new network discovery scan.
|
||||
- `GET /setup/discovery-status`: Check if a scan is currently in progress.
|
||||
- `POST /setup/sync/{deviceIP}`: Fetch presets, recents, and sources from a device.
|
||||
- `GET /setup/summary/{deviceIP}`: Get a detailed migration readiness summary.
|
||||
- `POST /setup/migrate/{deviceIP}`: Migrate a device using the specified method (XML/Hosts).
|
||||
- `POST /devices/{deviceIP}/sync`: Fetch presets, recents, and sources from a device.
|
||||
- `GET /devices/{deviceIP}/summary`: Get a detailed migration readiness summary.
|
||||
- `POST /devices/{deviceIP}/migrate`: Migrate a device using the specified method (XML/Hosts).
|
||||
- `GET /setup/ca.crt`: Download the Root CA certificate for manual installation.
|
||||
|
||||
#### `GET /setup/interactions`
|
||||
@@ -811,7 +838,7 @@ soundtouch:
|
||||
name: "Living Room Speaker"
|
||||
|
||||
rest:
|
||||
- resource: "http://localhost:8000/setup/devices"
|
||||
- resource: "http://localhost:8000/devices"
|
||||
scan_interval: 60
|
||||
sensor:
|
||||
- name: "SoundTouch Devices"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
module navigation-station-demo
|
||||
|
||||
go 1.25.7
|
||||
go 1.26.1
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.0.0
|
||||
require github.com/gesellix/bose-soundtouch v0.43.0
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
module preset-management-example
|
||||
|
||||
go 1.25.7
|
||||
go 1.26.1
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.0.0
|
||||
require github.com/gesellix/bose-soundtouch v0.43.0
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/gesellix/bose-soundtouch
|
||||
|
||||
go 1.25.7
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
@@ -14,7 +14,7 @@ require (
|
||||
|
||||
require (
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
|
||||
@@ -15,8 +15,8 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
|
||||
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
|
||||
@@ -356,6 +356,13 @@ func (ws *WebSocketClient) attemptReconnect(config *WebSocketConfig) {
|
||||
}
|
||||
|
||||
attempt++
|
||||
|
||||
// Check if device is reachable before attempting full WS connection to reduce log noise
|
||||
if err := ws.client.Ping(); err != nil {
|
||||
ws.logger.Printf("Reconnection attempt %d skipped: device unreachable (%v)", attempt, err)
|
||||
continue
|
||||
}
|
||||
|
||||
ws.logger.Printf("Reconnection attempt %d", attempt)
|
||||
|
||||
if err := ws.connectWithConfig(config); err != nil {
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestEventLog(t *testing.T) {
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/streaming/stats/usage", s.HandleUsageStats)
|
||||
r.Get("/setup/devices/{deviceId}/events", s.HandleGetDeviceEvents)
|
||||
r.Get("/devices/{deviceId}/events", s.HandleGetDeviceEvents)
|
||||
|
||||
t.Run("Record and Retrieve Events", func(t *testing.T) {
|
||||
// 1. Post a usage stat
|
||||
@@ -36,7 +36,7 @@ func TestEventLog(t *testing.T) {
|
||||
}
|
||||
|
||||
// 2. Retrieve events
|
||||
req, _ = http.NewRequest("GET", "/setup/devices/SPEAKER1/events", nil)
|
||||
req, _ = http.NewRequest("GET", "/devices/SPEAKER1/events", nil)
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
//go:embed web/index.html
|
||||
var indexHTML []byte
|
||||
|
||||
//go:embed web/css/* web/js/*
|
||||
//go:embed web/migration/* web/stockholm-mini/* web/shared/*
|
||||
var webFS embed.FS
|
||||
|
||||
//go:embed static/media/*
|
||||
|
||||
@@ -103,33 +103,104 @@ func TestStaticWeb(t *testing.T) {
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
// 1. Test CSS
|
||||
res, err := http.Get(ts.URL + "/web/css/style.css")
|
||||
// 1. Test Migration UI CSS
|
||||
res, err := http.Get(ts.URL + "/web/migration/style.css")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("CSS: Expected status OK, got %v", res.Status)
|
||||
t.Errorf("Migration CSS: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
|
||||
t.Errorf("CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
|
||||
t.Errorf("Migration CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 2. Test JS
|
||||
res, err = http.Get(ts.URL + "/web/js/script.js")
|
||||
// 2. Test Migration UI JS
|
||||
res, err = http.Get(ts.URL + "/web/migration/script.js")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("JS: Expected status OK, got %v", res.Status)
|
||||
t.Errorf("Migration JS: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "application/javascript") &&
|
||||
!strings.Contains(res.Header.Get("Content-Type"), "text/javascript") {
|
||||
t.Errorf("JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
|
||||
t.Errorf("Migration JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 3. Test Migration UI Index
|
||||
res, err = http.Get(ts.URL + "/web/migration/index.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Migration Index: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "text/html") {
|
||||
t.Errorf("Migration Index: Expected text/html content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 4. Test Stockholm Mini
|
||||
res, err = http.Get(ts.URL + "/web/stockholm-mini/index.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Stockholm Mini: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "text/html") {
|
||||
t.Errorf("Stockholm Mini: Expected text/html content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 5. Test Stockholm Mini CSS
|
||||
res, err = http.Get(ts.URL + "/web/stockholm-mini/style.css")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Stockholm Mini CSS: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
|
||||
t.Errorf("Stockholm Mini CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 6. Test Shared CSS
|
||||
res, err = http.Get(ts.URL + "/web/shared/common.css")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Shared CSS: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
|
||||
t.Errorf("Shared CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 7. Test Shared JS
|
||||
res, err = http.Get(ts.URL + "/web/shared/common.js")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Shared JS: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "application/javascript") &&
|
||||
!strings.Contains(res.Header.Get("Content-Type"), "text/javascript") {
|
||||
t.Errorf("Shared JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 3. Test diff.min.js
|
||||
|
||||
@@ -55,23 +55,21 @@ func (s *Server) ServeProxy(target *url.URL) http.HandlerFunc {
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(reqBody))
|
||||
}
|
||||
|
||||
rp := httputil.NewSingleHostReverseProxy(target)
|
||||
rp.Transport = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
rp := &httputil.ReverseProxy{
|
||||
Rewrite: func(pr *httputil.ProxyRequest) {
|
||||
pr.SetURL(target)
|
||||
pr.Out.Host = target.Host
|
||||
// If target has a path, we should probably append or replace.
|
||||
// For Bose upstream, it's usually just the domain.
|
||||
if target.Path != "" && target.Path != "/" {
|
||||
pr.Out.URL.Path = target.Path
|
||||
}
|
||||
|
||||
// Update director to set the correct host and path
|
||||
originalDirector := rp.Director
|
||||
rp.Director = func(req *http.Request) {
|
||||
originalDirector(req)
|
||||
req.Host = target.Host
|
||||
// If target has a path, we should probably append or replace.
|
||||
// For Bose upstream, it's usually just the domain.
|
||||
if target.Path != "" && target.Path != "/" {
|
||||
req.URL.Path = target.Path
|
||||
}
|
||||
|
||||
lp.LogRequest(req)
|
||||
lp.LogRequest(pr.Out)
|
||||
},
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
},
|
||||
}
|
||||
|
||||
rp.ModifyResponse = func(res *http.Response) error {
|
||||
|
||||
@@ -232,8 +232,8 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
t.Errorf("CA: Unexpected content type: %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 2. Test POST /setup/migrate/{deviceIP}?method=hosts
|
||||
res, err = http.Post(ts.URL+"/setup/migrate/192.168.1.10?method=hosts&target_url=http://192.168.1.100:8000", "application/json", nil)
|
||||
// 2. Test POST /setup/devices/{deviceIP}/migrate?method=hosts
|
||||
res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/migrate?method=hosts&target_url=http://192.168.1.100:8000", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -254,8 +254,8 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
t.Errorf("Migrate: Expected output field in response")
|
||||
}
|
||||
|
||||
// 3. Test POST /setup/trust-ca/{deviceIP}
|
||||
res, err = http.Post(ts.URL+"/setup/trust-ca/192.168.1.10", "application/json", nil)
|
||||
// 3. Test POST /setup/devices/{deviceIP}/trust-ca
|
||||
res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/trust-ca", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -275,8 +275,8 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
t.Errorf("TrustCA: Expected output field in response")
|
||||
}
|
||||
|
||||
// 4. Test POST /setup/reboot/{deviceIP}
|
||||
res, err = http.Post(ts.URL+"/setup/reboot/192.168.1.10", "application/json", nil)
|
||||
// 4. Test POST /devices/{deviceIP}/reboot
|
||||
res, err = http.Post(ts.URL+"/devices/192.168.1.10/reboot", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -296,8 +296,8 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
t.Errorf("Reboot: Expected output field in response")
|
||||
}
|
||||
|
||||
// 5. Test POST /setup/remove-remote-services/{deviceIP}
|
||||
res, err = http.Post(ts.URL+"/setup/remove-remote-services/192.168.1.10", "application/json", nil)
|
||||
// 5. Test POST /setup/devices/{deviceIP}/remove-remote-services
|
||||
res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/remove-remote-services", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -329,17 +329,20 @@ func TestRemoveDevice(t *testing.T) {
|
||||
_ = ds.Initialize()
|
||||
|
||||
// Setup a dummy device in the datastore
|
||||
account := "test-account"
|
||||
account := "acc1"
|
||||
deviceID := "TEST-DEVICE-ID"
|
||||
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
|
||||
if err := os.MkdirAll(deviceDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create device dir: %v", err)
|
||||
}
|
||||
|
||||
infoFile := filepath.Join(deviceDir, "DeviceInfo.xml")
|
||||
infoXML := `<?xml version="1.0" encoding="UTF-8" ?><info deviceID="TEST-DEVICE-ID"><name>Test Device</name><type>SoundTouch 10</type></info>`
|
||||
if err := os.WriteFile(infoFile, []byte(infoXML), 0644); err != nil {
|
||||
t.Fatalf("Failed to create device info file: %v", err)
|
||||
// Register device in datastore so HandleRemoveDevice works
|
||||
_ = ds.SaveDeviceInfo(account, deviceID, &models.ServiceDeviceInfo{
|
||||
DeviceID: deviceID,
|
||||
AccountID: account,
|
||||
IPAddress: "192.168.1.100",
|
||||
})
|
||||
|
||||
// Verify directory exists where datastore expects it
|
||||
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
|
||||
if _, err := os.Stat(deviceDir); err != nil {
|
||||
t.Fatalf("Device directory was not created by SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
r, _ := setupRouter("http://localhost:8001", ds)
|
||||
@@ -347,7 +350,7 @@ func TestRemoveDevice(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
// 1. Verify device exists
|
||||
res, err := http.Get(ts.URL + "/setup/devices")
|
||||
res, err := http.Get(ts.URL + "/devices")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -370,7 +373,7 @@ func TestRemoveDevice(t *testing.T) {
|
||||
}
|
||||
|
||||
// 2. Remove device
|
||||
req, err := http.NewRequest(http.MethodDelete, ts.URL+"/setup/devices/"+deviceID, nil)
|
||||
req, err := http.NewRequest(http.MethodDelete, ts.URL+"/devices/"+deviceID, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -385,7 +388,7 @@ func TestRemoveDevice(t *testing.T) {
|
||||
}
|
||||
|
||||
// 3. Verify device is gone
|
||||
res, err = http.Get(ts.URL + "/setup/devices")
|
||||
res, err = http.Get(ts.URL + "/devices")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// HandleGetStockholmDeviceInfo returns live information for a device.
|
||||
func (s *Server) HandleGetStockholmDeviceInfo(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
http.Error(w, "Device ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
info, err := s.sm.GetLiveDeviceInfo(deviceIP)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Include IP address in both snake_case and camelCase for frontend compatibility
|
||||
type deviceInfoResponse struct {
|
||||
*setup.DeviceInfoXML `json:",inline"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
IPAddressCamel string `json:"ipAddress,omitempty"`
|
||||
}
|
||||
|
||||
resp := deviceInfoResponse{
|
||||
DeviceInfoXML: info,
|
||||
IPAddress: deviceIP,
|
||||
IPAddressCamel: deviceIP,
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDeviceKey sends a key command to a device.
|
||||
func (s *Server) HandleDeviceKey(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
|
||||
key := chi.URLParam(r, "key")
|
||||
if deviceID == "" || key == "" {
|
||||
http.Error(w, "Device ID and Key are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
c := client.NewClientFromHost(deviceIP)
|
||||
|
||||
err = c.SendKey(key)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to send key %s to %s: %v", key, deviceIP, err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Key sent"}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDeviceVolume sets the volume level for a device.
|
||||
func (s *Server) HandleDeviceVolume(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
|
||||
levelStr := chi.URLParam(r, "level")
|
||||
if deviceID == "" || levelStr == "" {
|
||||
http.Error(w, "Device ID and Level are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
level, err := strconv.Atoi(levelStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid volume level", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
c := client.NewClientFromHost(deviceIP)
|
||||
|
||||
err = c.SetVolume(level)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to set volume to %d on %s: %v", level, deviceIP, err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Volume set"}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(_ *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
const (
|
||||
pongWait = 40 * time.Second
|
||||
pingPeriod = 20 * time.Second // must be less than pongWait
|
||||
)
|
||||
|
||||
// HandleDeviceWebSocket upgrades the connection and proxies device WebSocket events to the browser.
|
||||
func (s *Server) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
http.Error(w, "Device ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Upgrade the HTTP connection to a WebSocket for the browser
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Create a SoundTouch WebSocket client for the target device
|
||||
c := client.NewClientFromHost(deviceIP)
|
||||
wsClient := c.NewWebSocketClient(client.DefaultWebSocketConfig())
|
||||
|
||||
// Channel-based write pump per Gorilla best practices
|
||||
sendCh := make(chan []byte, 64) // buffer to smooth bursts
|
||||
closeCh := make(chan struct{})
|
||||
|
||||
// Helper to enqueue JSON messages; drop if buffer is full to avoid blocking
|
||||
enqueue := func(v interface{}) {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case sendCh <- b:
|
||||
default:
|
||||
// drop to protect connection under burst
|
||||
}
|
||||
}
|
||||
|
||||
// Reader: we don't expect messages from the browser; just keep the
|
||||
// connection alive by processing control frames and detect close.
|
||||
_ = conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
return conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
})
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
close(closeCh)
|
||||
|
||||
_ = wsClient.Disconnect()
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
mt, _, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
log.Printf("[WebSocket] Browser connection closed for %s: %v", deviceIP, err)
|
||||
return
|
||||
}
|
||||
|
||||
if mt == websocket.CloseMessage {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Writer: single writer goroutine handles JSON writes and ping keepalive
|
||||
go func() {
|
||||
pingTicker := time.NewTicker(pingPeriod)
|
||||
|
||||
defer func() {
|
||||
pingTicker.Stop()
|
||||
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case msg, ok := <-sendCh:
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if !ok {
|
||||
_ = conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
|
||||
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
||||
return
|
||||
}
|
||||
case <-pingTicker.C:
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
case <-closeCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Forward typed events with a simple envelope into the send queue
|
||||
wsClient.SetHandlers(&models.WebSocketEventHandlers{
|
||||
OnNowPlaying: func(e *models.NowPlayingUpdatedEvent) {
|
||||
enqueue(map[string]interface{}{"type": "nowPlayingUpdated", "payload": e})
|
||||
},
|
||||
OnVolumeUpdated: func(e *models.VolumeUpdatedEvent) {
|
||||
enqueue(map[string]interface{}{"type": "volumeUpdated", "payload": e})
|
||||
},
|
||||
OnConnectionState: func(e *models.ConnectionStateUpdatedEvent) {
|
||||
enqueue(map[string]interface{}{"type": "connectionStateUpdated", "payload": e})
|
||||
},
|
||||
OnPresetUpdated: func(e *models.PresetUpdatedEvent) {
|
||||
enqueue(map[string]interface{}{"type": "presetUpdated", "payload": e})
|
||||
},
|
||||
OnZoneUpdated: func(e *models.ZoneUpdatedEvent) {
|
||||
enqueue(map[string]interface{}{"type": "zoneUpdated", "payload": e})
|
||||
},
|
||||
OnBassUpdated: func(e *models.BassUpdatedEvent) {
|
||||
enqueue(map[string]interface{}{"type": "bassUpdated", "payload": e})
|
||||
},
|
||||
OnUnknownEvent: func(event *models.WebSocketEvent) {
|
||||
bytes, _ := json.Marshal(event)
|
||||
enqueue(map[string]interface{}{"type": "unknown", "payload": json.RawMessage(bytes)})
|
||||
},
|
||||
OnSpecialMessage: func(msg *models.SpecialMessage) {
|
||||
enqueue(map[string]interface{}{"type": "special", "payload": msg})
|
||||
},
|
||||
})
|
||||
|
||||
// Add a separate goroutine to monitor the device connection status
|
||||
go func() {
|
||||
wsClient.Wait()
|
||||
log.Printf("[WebSocket] Device %s client terminated", deviceIP)
|
||||
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
// Connect to the device WebSocket
|
||||
if err := wsClient.Connect(); err != nil {
|
||||
enqueue(map[string]interface{}{"type": "error", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Optional: send an initial snapshot for convenience
|
||||
go func() {
|
||||
info, err := s.sm.GetLiveDeviceInfo(deviceIP)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Supplement with volume and now playing
|
||||
c := client.NewClientFromHost(deviceIP)
|
||||
payload := map[string]interface{}{
|
||||
"deviceID": info.DeviceID,
|
||||
"name": info.Name,
|
||||
"type": info.Type,
|
||||
//"maccAddress": info.MaccAddress,
|
||||
"serialNumber": info.SerialNumber,
|
||||
"softwareVersion": info.SoftwareVer,
|
||||
// Provide IP in both styles for frontend robustness
|
||||
"ip_address": deviceIP,
|
||||
"ipAddress": deviceIP,
|
||||
}
|
||||
|
||||
if vol, err := c.GetVolume(); err == nil {
|
||||
payload["volume"] = vol
|
||||
// Also add at top level for flatter frontend parsing
|
||||
payload["actualVolume"] = vol.ActualVolume
|
||||
}
|
||||
|
||||
if np, err := c.GetNowPlaying(); err == nil {
|
||||
payload["nowPlaying"] = np
|
||||
}
|
||||
|
||||
enqueue(map[string]interface{}{"type": "snapshotInfo", "payload": payload})
|
||||
}()
|
||||
}
|
||||
@@ -91,12 +91,31 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
|
||||
r.Post("/account/{account}/password", server.HandleMargeChangePassword)
|
||||
})
|
||||
|
||||
// Setup Devices for tests
|
||||
r.Route("/devices", func(r chi.Router) {
|
||||
r.Get("/", server.HandleListDiscoveredDevices)
|
||||
r.Post("/", server.HandleAddManualDevice)
|
||||
|
||||
r.Route("/{deviceId}", func(r chi.Router) {
|
||||
r.Delete("/", server.HandleRemoveDevice)
|
||||
r.Get("/events", server.HandleGetDeviceEvents)
|
||||
r.Get("/info", server.HandleGetDeviceInfo)
|
||||
r.Get("/ws", server.HandleDeviceWebSocket)
|
||||
r.Post("/key/{key}", server.HandleDeviceKey)
|
||||
r.Post("/volume/{level}", server.HandleDeviceVolume)
|
||||
r.Post("/reboot", server.HandleRebootDevice)
|
||||
})
|
||||
})
|
||||
|
||||
r.Get("/version", server.HandleGetVersionInfo)
|
||||
|
||||
// Setup Setup for tests
|
||||
r.Route("/setup", func(r chi.Router) {
|
||||
r.Get("/devices", server.HandleListDiscoveredDevices)
|
||||
r.Delete("/devices/{deviceId}", server.HandleRemoveDevice)
|
||||
r.Post("/discover", server.HandleTriggerDiscovery)
|
||||
r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
|
||||
r.Get("/settings", server.HandleGetSettings)
|
||||
r.Post("/settings", server.HandleUpdateSettings)
|
||||
r.Get("/ca.crt", server.HandleGetCACert)
|
||||
r.Get("/proxy-settings", server.HandleGetProxySettings)
|
||||
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
|
||||
r.Post("/ensure-remote-services/{deviceId}", server.HandleEnsureRemoteServices)
|
||||
@@ -108,6 +127,30 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
|
||||
r.Post("/test-connection/{deviceId}", server.HandleTestConnection)
|
||||
r.Post("/test-hosts/{deviceId}", server.HandleTestHostsRedirection)
|
||||
r.Get("/ca.crt", server.HandleGetCACert)
|
||||
|
||||
r.Get("/interaction-stats", server.HandleGetInteractionStats)
|
||||
r.Get("/interactions", server.HandleListInteractions)
|
||||
r.Get("/interaction-content", server.HandleGetInteractionContent)
|
||||
r.Get("/interactions/sessions/{session}/download", server.HandleDownloadSession)
|
||||
r.Delete("/interactions/sessions/{session}", server.HandleDeleteSession)
|
||||
r.Delete("/interactions/sessions", server.HandleCleanupSessions)
|
||||
|
||||
r.Get("/dns-discoveries", server.HandleGetDNSDiscoveries)
|
||||
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
|
||||
|
||||
r.Route("/devices/{deviceId}", func(r chi.Router) {
|
||||
r.Get("/summary", server.HandleGetMigrationSummary)
|
||||
r.Post("/migrate", server.HandleMigrateDevice)
|
||||
r.Post("/revert", server.HandleRevertMigration)
|
||||
r.Post("/trust-ca", server.HandleTrustCACert)
|
||||
r.Post("/ensure-remote-services", server.HandleEnsureRemoteServices)
|
||||
r.Post("/remove-remote-services", server.HandleRemoveRemoteServices)
|
||||
r.Post("/backup", server.HandleBackupConfig)
|
||||
r.Post("/sync", server.HandleInitialSync)
|
||||
r.Post("/test-connection", server.HandleTestConnection)
|
||||
r.Post("/test-hosts", server.HandleTestHostsRedirection)
|
||||
r.Post("/test-dns", server.HandleTestDNSRedirection)
|
||||
})
|
||||
})
|
||||
|
||||
r.NotFound(server.HandleNotFound)
|
||||
|
||||
@@ -257,17 +257,15 @@ func (s *Server) performMirror(r *http.Request) *mirrorResponseRecorder {
|
||||
}
|
||||
|
||||
// Create a proxy that doesn't write to the original ResponseWriter
|
||||
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||
proxy.Transport = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
|
||||
// Record the mirrored request
|
||||
originalDirector := proxy.Director
|
||||
proxy.Director = func(req *http.Request) {
|
||||
originalDirector(req)
|
||||
req.Host = target.Host
|
||||
req.Header.Set("X-Mirror-Request", "true")
|
||||
proxy := &httputil.ReverseProxy{
|
||||
Rewrite: func(pr *httputil.ProxyRequest) {
|
||||
pr.SetURL(target)
|
||||
pr.Out.Host = target.Host
|
||||
pr.Out.Header.Set("X-Mirror-Request", "true")
|
||||
},
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
},
|
||||
}
|
||||
|
||||
// Capture response for parity check and recording
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
)
|
||||
@@ -12,7 +13,7 @@ import (
|
||||
// RecordMiddleware returns a middleware that records "self" requests and responses.
|
||||
func (s *Server) RecordMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.recorder == nil || !s.recordEnabled {
|
||||
if s.recorder == nil || !s.recordEnabled || r.Header.Get("Upgrade") == "websocket" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@@ -52,6 +53,10 @@ func (s *Server) RecordMiddleware(next http.Handler) http.Handler {
|
||||
|
||||
// Create a response object for the recorder
|
||||
res := rw.getRecordedResponse(r)
|
||||
if res.StatusCode >= 400 {
|
||||
log.Printf("[DEBUG_LOG] Recording error response: %d %s %s", res.StatusCode, r.Method, r.URL.Path)
|
||||
}
|
||||
|
||||
if res.Body != nil {
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
}
|
||||
@@ -65,8 +70,9 @@ func (s *Server) RecordMiddleware(next http.Handler) http.Handler {
|
||||
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
body *bytes.Buffer
|
||||
statusCode int
|
||||
body *bytes.Buffer
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
func (rw *responseWriter) Header() http.Header {
|
||||
@@ -74,18 +80,28 @@ func (rw *responseWriter) Header() http.Header {
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
if rw.wroteHeader {
|
||||
return
|
||||
}
|
||||
|
||||
rw.statusCode = code
|
||||
rw.wroteHeader = true
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (rw *responseWriter) Write(b []byte) (int, error) {
|
||||
if !rw.wroteHeader {
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
rw.body.Write(b)
|
||||
|
||||
return rw.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
func (rw *responseWriter) getRecordedResponse(r *http.Request) *http.Response {
|
||||
statusCode := rw.statusCode
|
||||
if statusCode == 0 {
|
||||
if !rw.wroteHeader && statusCode == 0 {
|
||||
statusCode = http.StatusOK
|
||||
}
|
||||
|
||||
|
||||
@@ -840,3 +840,23 @@ func (s *Server) resolveDeviceIDToIP(deviceID string) (string, error) {
|
||||
|
||||
return "", fmt.Errorf("device not found: %s", deviceID)
|
||||
}
|
||||
|
||||
// lookupIP resolves a deviceId to its last known device IP.
|
||||
func (s *Server) lookupIP(deviceId string) (string, error) {
|
||||
devices, err := s.ds.ListAllDevices()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for i := range devices {
|
||||
if devices[i].DeviceID == deviceId {
|
||||
if devices[i].IPAddress == "" {
|
||||
return "", fmt.Errorf("no IP known for deviceId %s", deviceId)
|
||||
}
|
||||
|
||||
return devices[i].IPAddress, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("deviceId %s not found", deviceId)
|
||||
}
|
||||
|
||||
+86
-1428
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,476 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>AfterTouch (SoundTouch Toolkit)</title>
|
||||
<link rel="icon" href="/media/favicon-braille.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="../shared/common.css">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>AfterTouch</h1>
|
||||
<p style="margin-top: -10px; font-style: italic; color: #666;">Bose SoundTouch Toolkit</p>
|
||||
<p style="margin-bottom: 20px;"><a href="/">← Back to selection</a></p>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab-buttons">
|
||||
<button class="tab-btn active" onclick="openTab(event, 'tab-overview')">Overview</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-settings')">1. Settings</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-devices')">2. Devices</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-sync')">3. Data Sync</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-migration')">4. Migration</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-interactions')">5. Interactions & Events</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab 0: Overview -->
|
||||
<div id="tab-overview" class="tab-content active">
|
||||
<h2>Welcome to AfterTouch</h2>
|
||||
<p>This toolkit helps you keep your Bose SoundTouch speakers functional even after the Bose Cloud shutdown in May 2026. It emulates the necessary cloud services locally on your network.</p>
|
||||
|
||||
<h3>Migration Process at a Glance</h3>
|
||||
<div class="info-box prerequisite-box">
|
||||
<strong>🔌 Prerequisite: Enable SSH</strong><br>
|
||||
Migration requires SSH access. To enable it:
|
||||
<ol style="margin-top: 5px; margin-bottom: 5px;">
|
||||
<li>Create an empty file named <code>remote_services</code> on a USB stick.</li>
|
||||
<li>Insert it into the speaker's <strong>SERVICE</strong> port and reboot the speaker.</li>
|
||||
</ol>
|
||||
<strong>Verify connection:</strong>
|
||||
<ul style="margin-top: 5px; margin-bottom: 0; padding-left: 20px;">
|
||||
<li>Use the <strong>Migration</strong> tab to select your device and verify that <em>SSH Connection</em> shows ✅ Success.</li>
|
||||
<li>Or manually: <code>ssh -oHostKeyAlgorithms=+ssh-rsa root@<SPEAKER-IP></code> (no password).</li>
|
||||
</ul>
|
||||
</div>
|
||||
<ol class="guide-steps">
|
||||
<li>
|
||||
<strong>Settings:</strong> Review the <strong>Settings</strong> tab. Ensure the "Target Domain" and "Proxy Domain" use an IP address or domain name that is <strong>accessible from your speakers</strong> (usually the IP of this server on your local network).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Discovery:</strong> Go to the <strong>Devices</strong> tab to find your speakers on the network.
|
||||
Ensure your speakers are powered on and connected to the same network.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Data Sync:</strong> In the <strong>Data Sync</strong> tab, fetch your current presets, recents, and sources.
|
||||
This step is critical to ensure your local service has all your personalized data before you disconnect from the Bose cloud.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Migration:</strong> In the <strong>Migration</strong> tab, redirect your speaker to this local service.
|
||||
We recommend the <strong>XML Configuration</strong> method as it is surgical and easily reversible.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Verification:</strong> After migration and reboot, your speaker will communicate with this toolkit instead of Bose servers.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div class="info-box safety-box">
|
||||
<strong>⚠️ Safety First:</strong> Before starting any migration, please read our
|
||||
<a href="https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-SAFETY.html" target="_blank">Professional Migration & Safety Guide</a>.
|
||||
The toolkit automatically creates backups, but understanding the process is key to a smooth transition.
|
||||
</div>
|
||||
|
||||
<h3>Useful Links</h3>
|
||||
<ul>
|
||||
<li><a href="https://gesellix.github.io/Bose-SoundTouch/guides/SURVIVAL-GUIDE.html" target="_blank">Cloud Shutdown Survival Guide</a></li>
|
||||
<li><a href="https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html" target="_blank">CLI Reference</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Tab 1: Settings -->
|
||||
<div id="tab-settings" class="tab-content">
|
||||
<h2>System Settings</h2>
|
||||
<p style="font-size: 0.9em; color: #555; margin-bottom: 20px;">
|
||||
<strong>Note:</strong> These URLs must be <strong>accessible from your SoundTouch devices</strong>.
|
||||
Use the IP address of this server on your local network (e.g., <code>http://192.168.1.100:8000</code>)
|
||||
rather than <code>localhost</code>.
|
||||
</p>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label for="target-domain">Target Domain:</label>
|
||||
<input type="text" id="target-domain" placeholder="http://192.168.x.x:8000" style="width: 300px;">
|
||||
<span style="font-size: 0.8em; color: #666;">(Standard services URL)</span>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label for="soundcork-url">Soundcork URL:</label>
|
||||
<input type="text" id="soundcork-url" placeholder="http://192.168.x.x:8001" style="width: 300px;">
|
||||
<span style="font-size: 0.8em; color: #666;">(Soundcork services URL)</span>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label for="discovery-interval">Discovery Interval:</label>
|
||||
<input type="text" id="discovery-interval" placeholder="5m" style="width: 100px;">
|
||||
<label style="margin-left: 15px;"><input type="checkbox" id="discovery-enabled"> Enable Automated Discovery</label>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<button onclick="updateSettings()">Save Settings</button>
|
||||
<span id="settings-status" style="margin-left: 10px; font-size: 0.9em;"></span>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<strong>DNS Discovery:</strong>
|
||||
<div style="margin-top: 5px;">
|
||||
<label style="display: block; margin-bottom: 5px;">
|
||||
<input type="checkbox" id="dns-enabled"> Enable DNS Discovery Server
|
||||
</label>
|
||||
<div style="margin-left: 20px; margin-bottom: 5px;">
|
||||
<label for="dns-upstream">Upstream DNS:</label>
|
||||
<input type="text" id="dns-upstream" placeholder="8.8.8.8" style="width: 150px;">
|
||||
<span style="font-size: 0.8em; color: #666; margin-left: 5px;">(For non-intercepted queries)</span>
|
||||
</div>
|
||||
<div style="margin-left: 20px;">
|
||||
<label for="dns-bind">DNS Bind Address:</label>
|
||||
<input type="text" id="dns-bind" placeholder=":53" style="width: 100px;">
|
||||
<span style="font-size: 0.8em; color: #666; margin-left: 5px;">(e.g., :53 or 0.0.0.0:53. <strong>Port 53</strong> is required for actual migration)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<strong>Proxy Logging:</strong>
|
||||
<div style="margin-top: 5px;">
|
||||
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="proxy-redact" onchange="updateProxySettings()"> Redact Sensitive Headers</label>
|
||||
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="proxy-log-body" onchange="updateProxySettings()"> Log Bodies</label>
|
||||
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="enable-soundcork-proxy" onchange="updateProxySettings()"> Enable Soundcork Proxy (Legacy)</label>
|
||||
<label style="display: block; margin-bottom: 5px;">
|
||||
<input type="checkbox" id="proxy-record" onchange="updateProxySettings()"> Record Interactions
|
||||
<span style="font-size: 0.85em; color: #666; margin-left: 5px;">(View in <strong>5. Interactions</strong> tab)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 2: Devices -->
|
||||
<div id="tab-devices" class="tab-content">
|
||||
<h2>Known Devices <span id="discovery-indicator" style="font-size: 0.5em; vertical-align: middle; display: none;">🔍 Scanning...</span></h2>
|
||||
<div id="device-list">Loading devices...</div>
|
||||
<div style="margin-top: 20px;">
|
||||
<button onclick="triggerDiscovery()">Scan Again</button>
|
||||
<input type="text" id="add-manual-ip" placeholder="Manual IP (e.g. 192.168.1.100)" style="margin-left: 20px; padding: 4px;">
|
||||
<button onclick="addManualDevice()">Add Device</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 3: Data Sync -->
|
||||
<div id="tab-sync" class="tab-content">
|
||||
<h2>Initial Data Sync</h2>
|
||||
<p>Before migrating, fetch your presets, recents, and configured sources from the device to ensure they are available locally.</p>
|
||||
<div class="device-selection">
|
||||
<label for="sync-device-list">Device:</label>
|
||||
<select id="sync-device-list">
|
||||
<option value="">-- Select a device --</option>
|
||||
</select>
|
||||
<button id="sync-now-btn">Start Sync</button>
|
||||
</div>
|
||||
<div id="sync-status" class="status"></div>
|
||||
<div id="sync-results" style="margin-top: 20px; display: none;">
|
||||
<h3>Sync Results</h3>
|
||||
<div id="sync-log" style="font-family: monospace; background: #f4f4f4; padding: 10px; border-radius: 4px; max-height: 300px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 4: Migration -->
|
||||
<div id="tab-migration" class="tab-content">
|
||||
<h2>Device Migration</h2>
|
||||
<div class="device-selection">
|
||||
<label for="migration-device-list">Device:</label>
|
||||
<select id="migration-device-list" onchange="showSummary(this.value)">
|
||||
<option value="">-- Select a device --</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="status" class="status"></div>
|
||||
|
||||
<div id="command-output-box" class="summary-box" style="display: none; background-color: #f0f0f0;">
|
||||
<h3>Command Output</h3>
|
||||
<div id="command-output" style="font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 300px; overflow-y: auto; padding: 10px; border: 1px solid #ccc; background: #fff;"></div>
|
||||
</div>
|
||||
|
||||
<div id="migration-summary" class="summary-box" style="display: none;">
|
||||
<h3>Migration Summary for <span id="summary-ip"></span></h3>
|
||||
<p>Migration Status: <span id="migration-status"></span></p>
|
||||
<p>SSH Connection: <span id="ssh-status"></span></p>
|
||||
<p id="original-config-status" style="display: none;">Backup: ✅ Found .original config at <code>/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml.original</code> <button onclick="toggleOriginalConfig()">Show Original Config</button></p>
|
||||
<p id="no-original-config-status" style="display: none;">Backup: ❌ Not found <button id="backup-config-btn">Backup Config Now</button></p>
|
||||
<p>Remote Services Enabled: <span id="remote-services-status"></span> <span id="remote-services-found" style="font-size: 0.8em; color: #666;"></span></p>
|
||||
<p>AfterTouch Local Root CA Trusted: <span id="ca-trust-status"></span> <button id="trust-ca-btn" style="display: none; background-color: #607D8B; color: white; border: none; padding: 2px 8px; font-size: 0.8em; margin-left: 10px;">Trust CA Now</button></p>
|
||||
|
||||
<div id="connection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #eefbff;">
|
||||
<strong>HTTPS Connection Test:</strong><br>
|
||||
<span style="font-size: 0.85em; color: #555;">Verify the device can reach the server over HTTPS.</span>
|
||||
<div style="margin-top: 10px;">
|
||||
URL: <code id="test-url"></code>
|
||||
</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<button id="test-connection-explicit-btn" style="background-color: #607D8B; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test with Explicit CA.crt</button>
|
||||
<button id="test-connection-trusted-btn" style="background-color: #607D8B; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test with Shared Trust Store</button>
|
||||
</div>
|
||||
<div id="test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
|
||||
<div id="hosts-redirection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #fff4e6; display: none;">
|
||||
<strong>Preliminary /etc/hosts Test:</strong><br>
|
||||
<span style="font-size: 0.85em; color: #555;">Verify the device's /etc/hosts mechanism before full migration.</span>
|
||||
<div style="margin-top: 10px;">
|
||||
Domain: <code>custom-test-api.bose.fake</code>
|
||||
</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<button id="test-hosts-btn" style="background-color: #FF9800; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test Hosts Redirection</button>
|
||||
</div>
|
||||
<div id="hosts-test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
|
||||
<div id="dns-redirection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #e6ffed; display: none;">
|
||||
<strong>Preliminary DNS Test:</strong><br>
|
||||
<span style="font-size: 0.85em; color: #555;">Verify the device can resolve domains via the AfterTouch DNS server.</span>
|
||||
<div style="margin-top: 10px;">
|
||||
Domain: <code>aftertouch.test</code>
|
||||
</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<button id="test-dns-btn" style="background-color: #28a745; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test DNS Redirection</button>
|
||||
</div>
|
||||
<div id="dns-test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
|
||||
<div style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #f9f9f9;">
|
||||
<label for="migration-method"><strong>Migration Method:</strong></label>
|
||||
<select id="migration-method" onchange="toggleMigrationMethod()">
|
||||
<option value="xml">XML Configuration (Recommended - redirects specific services)</option>
|
||||
<option value="hosts">/etc/hosts + Root CA (Advanced - global redirection)</option>
|
||||
<option value="resolv">/etc/resolv.conf (DHCP-Aware - Most flexible)</option>
|
||||
</select>
|
||||
<div id="dns-port-warning" style="margin-top: 5px; color: #d32f2f; font-weight: bold; font-size: 0.9em; display: none;"></div>
|
||||
</div>
|
||||
|
||||
<div id="current-resolv-pane" style="display: none; margin-bottom: 20px;">
|
||||
<span class="config-header">Current /etc/resolv.conf</span>
|
||||
<pre id="current-resolv-content"></pre>
|
||||
</div>
|
||||
|
||||
<div id="original-config-pane" style="display: none; margin-bottom: 20px;">
|
||||
<span class="config-header">Original Config (Backup)</span>
|
||||
<pre id="original-config-content"></pre>
|
||||
</div>
|
||||
|
||||
<div id="service-options" style="margin-bottom: 20px; display: none;">
|
||||
<h4>Service Implementations</h4>
|
||||
<table>
|
||||
<tr><th>Service</th><th>Original URL</th><th>Implementation</th></tr>
|
||||
<tr>
|
||||
<td>Marge (Streaming)</td>
|
||||
<td id="orig-marge">loading...</td>
|
||||
<td>
|
||||
<select id="opt-marge" onchange="refreshSummary()">
|
||||
<option value="self">AfterTouch (Local Service)</option>
|
||||
<option value="upstream">Upstream (Proxy via local service)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Stats</td>
|
||||
<td id="orig-stats">loading...</td>
|
||||
<td>
|
||||
<select id="opt-stats" onchange="refreshSummary()">
|
||||
<option value="self">AfterTouch (Local Service)</option>
|
||||
<option value="upstream">Upstream (Proxy via local service)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Software Update</td>
|
||||
<td id="orig-sw_update">loading...</td>
|
||||
<td>
|
||||
<select id="opt-sw_update" onchange="refreshSummary()">
|
||||
<option value="self">AfterTouch (Local Service)</option>
|
||||
<option value="upstream">Upstream (Proxy via local service)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>BMX (Registry)</td>
|
||||
<td id="orig-bmx">loading...</td>
|
||||
<td>
|
||||
<select id="opt-bmx" onchange="refreshSummary()">
|
||||
<option value="self">AfterTouch (Local Service)</option>
|
||||
<option value="upstream">Upstream (Proxy via local service)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="diff-container">
|
||||
<div id="xml-diff-pane" class="diff-pane">
|
||||
<span class="config-header">Current Config (on Speaker)</span>
|
||||
<pre id="current-config"></pre>
|
||||
</div>
|
||||
<div id="planned-xml-pane" class="diff-pane">
|
||||
<span class="config-header">Planned Config (AfterTouch)</span>
|
||||
<pre id="planned-config"></pre>
|
||||
</div>
|
||||
<div id="planned-hosts-pane" class="diff-pane" style="display: none;">
|
||||
<span class="config-header">Planned /etc/hosts Entries</span>
|
||||
<pre id="planned-hosts"></pre>
|
||||
<div style="margin-top: 10px; font-size: 0.9em; color: #666;">
|
||||
<strong>Note:</strong> This method also injects the AfterTouch Local Root CA into <code>/etc/pki/tls/certs/ca-bundle.crt</code> to enable secure HTTPS communication.
|
||||
</div>
|
||||
</div>
|
||||
<div id="planned-resolv-pane" class="diff-pane" style="display: none;">
|
||||
<span class="config-header">Planned /etc/resolv.conf Hook</span>
|
||||
<pre id="planned-resolv"></pre>
|
||||
<div id="resolv-note" style="margin-top: 10px; font-size: 0.9em; color: #666;">
|
||||
<strong>Note:</strong> This method injects a persistent DNS priority hook into the DHCP logic (<code>/etc/udhcpc.d/50default</code>). It preserves your router's search domain and secondary DNS servers. It also injects the Local Root CA.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 15px;">
|
||||
<button id="confirm-migrate-btn" style="background-color: #4CAF50; color: white; border: none; padding: 10px 20px;">Confirm Migration</button>
|
||||
<button id="revert-migrate-btn" style="background-color: #FF9800; color: white; border: none; padding: 10px 20px; display: none;">Revert to Defaults</button>
|
||||
<button id="reboot-speaker-btn" style="background-color: #607D8B; color: white; border: none; padding: 10px 20px;">Reboot Speaker</button>
|
||||
<button id="ensure-remote-btn" style="background-color: #2196F3; color: white; border: none; padding: 10px 20px;">Enable Persistent Remote Services</button>
|
||||
<button id="remove-remote-btn" style="background-color: #f44336; color: white; border: none; padding: 10px 20px;">Remove Persistent Remote Services</button>
|
||||
<button onclick="document.getElementById('migration-summary').style.display='none'" style="padding: 10px 20px;">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 5: Interactions & Events -->
|
||||
<div id="tab-interactions" class="tab-content">
|
||||
<h2>Recorded Interactions & Device Events</h2>
|
||||
<p>Analysis of traffic handled by this service (self), proxied to Bose (upstream), and internal device events (telemetry).</p>
|
||||
|
||||
<div id="interaction-stats-container" class="summary-box">
|
||||
<div style="display: flex; gap: 20px; align-items: center; margin-bottom: 15px;">
|
||||
<p style="margin: 0;">Total Requests: <strong id="total-requests">0</strong></p>
|
||||
<button onclick="fetchInteractionStats()">Refresh Stats</button>
|
||||
<div style="margin-left: 10px;">
|
||||
<button onclick="showDeviceEvents()">View App/Device Events</button>
|
||||
</div>
|
||||
<div style="margin-left: auto; text-align: right;">
|
||||
<button onclick="cleanupSessions()" class="btn-danger">Cleanup old sessions</button>
|
||||
<div style="font-size: 0.75em; color: #666; margin-top: 3px;">Keeps only the 10 most recent sessions</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 20px;">
|
||||
<div style="flex: 1; border-right: 1px solid #eee; padding-right: 20px;">
|
||||
<h3>By Service</h3>
|
||||
<ul id="stats-by-service" class="stats-list"></ul>
|
||||
</div>
|
||||
<div style="flex: 2;">
|
||||
<h3>Sessions</h3>
|
||||
<div id="stats-by-session-container" style="max-height: 200px; overflow-y: auto; border: 1px solid #eee; padding: 5px; border-radius: 4px;">
|
||||
<ul id="stats-by-session" class="stats-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="browse-recordings" class="summary-box" style="margin-top: 20px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
||||
<h3 style="margin: 0;">Browse Recordings</h3>
|
||||
</div>
|
||||
<div style="margin-bottom: 15px; display: flex; gap: 15px; align-items: center; background: #f9f9f9; padding: 10px; border-radius: 4px;">
|
||||
<div>
|
||||
<label for="filter-session">Session:</label>
|
||||
<select id="filter-session" onchange="fetchInteractions()">
|
||||
<option value="">All Sessions</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="filter-category">Category:</label>
|
||||
<select id="filter-category" onchange="fetchInteractions()">
|
||||
<option value="">All Categories</option>
|
||||
<option value="self">Self (Emulated)</option>
|
||||
<option value="upstream">Upstream (Bose)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="filter-since">Since (YYYY-MM-DD HH:mm:ss):</label>
|
||||
<input type="text" id="filter-since" placeholder="e.g. 2026-02-15 15:00:00" size="25" onchange="fetchInteractions()">
|
||||
</div>
|
||||
<button onclick="fetchInteractions()">Apply Filters</button>
|
||||
</div>
|
||||
|
||||
<div id="interactions-list-container" style="max-height: 400px; overflow-y: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="text-align: left; border-bottom: 2px solid #eee;">
|
||||
<th style="padding: 8px;">#</th>
|
||||
<th style="padding: 8px;">Time</th>
|
||||
<th style="padding: 8px;">Method</th>
|
||||
<th style="padding: 8px;">Path</th>
|
||||
<th style="padding: 8px;">Status</th>
|
||||
<th style="padding: 8px;">Category</th>
|
||||
<th style="padding: 8px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="interactions-list">
|
||||
<tr><td colspan="7" style="padding: 20px; text-align: center; color: #666;">No interactions found.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="dns-discoveries" class="summary-box" style="margin-top: 20px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
||||
<h3 style="margin: 0;">DNS Discoveries</h3>
|
||||
<button onclick="clearDNSDiscoveries()" class="btn-danger">Clear DNS Logs</button>
|
||||
</div>
|
||||
<p style="font-size: 0.85em; color: #666;">Hosts discovered via the AfterTouch DNS server. "Self" means the domain was intercepted and redirected to this service.</p>
|
||||
<div id="dns-discoveries-list-container" style="max-height: 400px; overflow-y: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="text-align: left; border-bottom: 2px solid #eee;">
|
||||
<th style="padding: 8px;">Hostname</th>
|
||||
<th style="padding: 8px;">Last Seen</th>
|
||||
<th style="padding: 8px; text-align: center;">Queries</th>
|
||||
<th style="padding: 8px; text-align: center;">Bose?</th>
|
||||
<th style="padding: 8px;">Category</th>
|
||||
<th style="padding: 8px;">Last Client IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dns-discoveries-list">
|
||||
<tr><td colspan="6" style="padding: 20px; text-align: center; color: #666;">No DNS discoveries found.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="interaction-viewer" class="summary-box" style="margin-top: 20px; display: none; background: #2b2b2b; color: #a9b7c6;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||||
<h3 style="margin: 0; color: #fff;">Recording Viewer: <span id="viewer-filename" style="font-weight: normal; font-size: 0.8em;"></span></h3>
|
||||
<button onclick="document.getElementById('interaction-viewer').style.display='none'" style="background: #444; color: #fff; border: 1px solid #666;">Close</button>
|
||||
</div>
|
||||
<pre id="interaction-content" style="white-space: pre-wrap; font-family: 'Courier New', Courier, monospace; font-size: 0.9em; margin: 0; padding: 10px; overflow-x: auto; max-height: 600px;"></pre>
|
||||
</div>
|
||||
|
||||
<!-- Device Events Overlay -->
|
||||
<div id="device-events-overlay" class="summary-box" style="margin-top: 20px; display: none; background: #fdfdfd; border: 1px solid #ddd;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||||
<h3 style="margin: 0;">App & Device Events</h3>
|
||||
<div>
|
||||
<select id="event-device-selector" onchange="fetchDeviceEvents(this.value)">
|
||||
<option value="">-- Select Device --</option>
|
||||
</select>
|
||||
<button onclick="document.getElementById('device-events-overlay').style.display='none'" style="margin-left: 10px;">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="events-list-container" style="max-height: 400px; overflow-y: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="text-align: left; border-bottom: 2px solid #eee;">
|
||||
<th style="padding: 8px;">Time</th>
|
||||
<th style="padding: 8px;">Type</th>
|
||||
<th style="padding: 8px;">Data</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="events-list">
|
||||
<tr><td colspan="3" style="padding: 20px; text-align: center; color: #666;">Select a device to view events.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="../shared/common.js"></script>
|
||||
<script src="script.js"></script>
|
||||
<footer style="margin-top: 50px;">
|
||||
<span id="version-info">AfterTouch</span>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
+56
-40
@@ -251,8 +251,9 @@ async function updateSettings() {
|
||||
|
||||
async function fetchDevices() {
|
||||
try {
|
||||
const response = await fetch("/setup/devices");
|
||||
const response = await fetch("/devices");
|
||||
const devices = await response.json();
|
||||
window._knownDevices = devices; // Store globally for easy lookup
|
||||
const container = document.getElementById("device-list");
|
||||
const syncSelector = document.getElementById("sync-device-list");
|
||||
const migrationSelector = document.getElementById("migration-device-list");
|
||||
@@ -430,7 +431,7 @@ async function startSync() {
|
||||
log.innerHTML = "";
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/sync/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||||
const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/sync", {method: "POST"},);
|
||||
if (response.ok) {
|
||||
status.style.backgroundColor = "#dfd";
|
||||
status.textContent = "✅ Sync completed successfully for " + display + "!";
|
||||
@@ -927,7 +928,7 @@ async function fetchDeviceEvents(deviceId) {
|
||||
list.innerHTML = '<tr><td colspan="3" style="padding: 20px; text-align: center; color: #666;">Loading events...</td></tr>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/setup/devices/${deviceId}/events`);
|
||||
const response = await fetch(`/devices/${deviceId}/events`);
|
||||
const data = await response.json();
|
||||
const events = data.events;
|
||||
|
||||
@@ -1008,7 +1009,7 @@ async function clearParityMismatches() {
|
||||
}
|
||||
}
|
||||
|
||||
function viewParityMismatch(m) {
|
||||
function viewParityMismatch(m, forceRichDiff = false) {
|
||||
document.getElementById("diff-path-display").innerText = m.method + " " + m.path;
|
||||
const reasonsList = document.getElementById("diff-reasons-list");
|
||||
reasonsList.innerHTML = "";
|
||||
@@ -1027,32 +1028,46 @@ function viewParityMismatch(m) {
|
||||
const localBody = formatBody(m.local.body, localCT);
|
||||
const upstreamBody = formatBody(m.upstream.body, upstreamCT);
|
||||
|
||||
if (typeof Diff !== 'undefined') {
|
||||
const diff = Diff.diffChars(localBody, upstreamBody);
|
||||
const localEl = document.getElementById("diff-local-body");
|
||||
const upstreamEl = document.getElementById("diff-upstream-body");
|
||||
const diffSizeThreshold = 50000; // 50KB
|
||||
const isLarge = localBody.length > diffSizeThreshold || upstreamBody.length > diffSizeThreshold;
|
||||
const warningEl = document.getElementById("diff-size-warning");
|
||||
|
||||
localEl.innerHTML = "";
|
||||
upstreamEl.innerHTML = "";
|
||||
if (isLarge && !forceRichDiff) {
|
||||
warningEl.style.display = "block";
|
||||
const forceBtn = document.getElementById("force-rich-diff-btn");
|
||||
forceBtn.onclick = () => viewParityMismatch(m, true);
|
||||
|
||||
diff.forEach((part) => {
|
||||
const span = document.createElement('span');
|
||||
if (part.added) {
|
||||
span.className = 'diff-added';
|
||||
span.innerText = part.value;
|
||||
upstreamEl.appendChild(span);
|
||||
} else if (part.removed) {
|
||||
span.className = 'diff-removed';
|
||||
span.innerText = part.value;
|
||||
localEl.appendChild(span);
|
||||
} else {
|
||||
localEl.appendChild(document.createTextNode(part.value));
|
||||
upstreamEl.appendChild(document.createTextNode(part.value));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
document.getElementById("diff-local-body").innerText = localBody;
|
||||
document.getElementById("diff-upstream-body").innerText = upstreamBody;
|
||||
} else {
|
||||
warningEl.style.display = "none";
|
||||
if (typeof Diff !== 'undefined') {
|
||||
const diff = Diff.diffChars(localBody, upstreamBody);
|
||||
const localEl = document.getElementById("diff-local-body");
|
||||
const upstreamEl = document.getElementById("diff-upstream-body");
|
||||
|
||||
localEl.innerHTML = "";
|
||||
upstreamEl.innerHTML = "";
|
||||
|
||||
diff.forEach((part) => {
|
||||
const span = document.createElement('span');
|
||||
if (part.added) {
|
||||
span.className = 'diff-added';
|
||||
span.innerText = part.value;
|
||||
upstreamEl.appendChild(span);
|
||||
} else if (part.removed) {
|
||||
span.className = 'diff-removed';
|
||||
span.innerText = part.value;
|
||||
localEl.appendChild(span);
|
||||
} else {
|
||||
localEl.appendChild(document.createTextNode(part.value));
|
||||
upstreamEl.appendChild(document.createTextNode(part.value));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
document.getElementById("diff-local-body").innerText = localBody;
|
||||
document.getElementById("diff-upstream-body").innerText = upstreamBody;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("parity-diff-view").style.display = "block";
|
||||
@@ -1132,7 +1147,7 @@ async function addManualDevice() {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/devices", {
|
||||
const response = await fetch("/devices", {
|
||||
method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ip: ip}),
|
||||
});
|
||||
|
||||
@@ -1154,7 +1169,7 @@ async function removeDevice(deviceId, name) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/setup/devices/${deviceId}`, {
|
||||
const response = await fetch(`/devices/${deviceId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
@@ -1200,7 +1215,7 @@ async function pollDiscoveryStatus() {
|
||||
|
||||
async function updateDeviceInfo(deviceId, ip) {
|
||||
try {
|
||||
const response = await fetch("/setup/info/" + encodeURIComponent(deviceId));
|
||||
const response = await fetch("/devices/" + encodeURIComponent(deviceId) + "/info");
|
||||
if (!response.ok) return;
|
||||
const info = await response.json();
|
||||
|
||||
@@ -1260,7 +1275,7 @@ async function showSummary(deviceId) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/summary/" + encodeURIComponent(deviceId) + query,);
|
||||
const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/summary" + query,);
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText);
|
||||
@@ -1445,7 +1460,7 @@ async function revert(deviceId, ip) {
|
||||
statusDiv.innerHTML = "Reverting " + display + " to defaults...";
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/revert/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||||
const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/revert", {method: "POST"},);
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
@@ -1477,7 +1492,7 @@ async function reboot(deviceId, ip) {
|
||||
statusDiv.innerHTML = "Rebooting " + display + "...";
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/reboot/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||||
const response = await fetch("/devices/" + encodeURIComponent(deviceId) + "/reboot", {method: "POST"},);
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
@@ -1523,7 +1538,7 @@ async function migrate(deviceId, ip) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/migrate/" + encodeURIComponent(deviceId) + query, {method: "POST"},);
|
||||
const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/migrate" + query, {method: "POST"},);
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
@@ -1535,6 +1550,7 @@ async function migrate(deviceId, ip) {
|
||||
rebootBtn.style.display = "inline-block";
|
||||
rebootBtn.disabled = false;
|
||||
rebootBtn.style.border = "2px solid #000";
|
||||
rebootBtn.onclick = () => reboot(deviceId);
|
||||
|
||||
// Re-show summary but with prominence on reboot
|
||||
summaryDiv.style.display = "block";
|
||||
@@ -1560,7 +1576,7 @@ async function trustCA(deviceId, ip) {
|
||||
statusDiv.innerHTML = "Injecting Root CA into shared trust store on " + display + "...";
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/trust-ca/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||||
const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/trust-ca", {method: "POST"},);
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
@@ -1592,7 +1608,7 @@ async function ensureRemoteServices(deviceId, ip) {
|
||||
statusDiv.innerHTML = "Ensuring remote services for " + display + "...";
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/ensure-remote-services/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||||
const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/ensure-remote-services", {method: "POST"},);
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
@@ -1626,7 +1642,7 @@ async function removeRemoteServices(deviceId, ip) {
|
||||
statusDiv.innerHTML = "Removing remote services for " + display + "...";
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/remove-remote-services/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||||
const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/remove-remote-services", {method: "POST"},);
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
@@ -1654,7 +1670,7 @@ async function backupConfig(deviceId, ip) {
|
||||
statusDiv.innerHTML = "Creating backup for " + display + "...";
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/backup/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||||
const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/backup", {method: "POST"},);
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
@@ -1683,7 +1699,7 @@ async function testConnection(deviceId, useExplicitCA) {
|
||||
|
||||
try {
|
||||
const query = `?target_url=${encodeURIComponent(testUrl)}&use_explicit_ca=${useExplicitCA}`;
|
||||
const response = await fetch(`/setup/test-connection/${encodeURIComponent(deviceId)}${query}`, {method: "POST"},);
|
||||
const response = await fetch(`/setup/devices/test-connection/${encodeURIComponent(deviceId)}${query}`, {method: "POST"},);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.ok) {
|
||||
@@ -1711,7 +1727,7 @@ async function testHostsRedirection(deviceId) {
|
||||
|
||||
try {
|
||||
const query = `?target_url=${encodeURIComponent(targetUrl)}`;
|
||||
const response = await fetch(`/setup/test-hosts/${encodeURIComponent(deviceId)}${query}`, {method: "POST"},);
|
||||
const response = await fetch(`/setup/devices/${encodeURIComponent(deviceId)}/test-hosts${query}`, {method: "POST"},);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.ok) {
|
||||
@@ -1739,7 +1755,7 @@ async function testDNSRedirection(deviceId) {
|
||||
|
||||
try {
|
||||
const query = `?target_url=${encodeURIComponent(targetUrl)}`;
|
||||
const response = await fetch(`/setup/test-dns/${encodeURIComponent(deviceId)}${query}`, {method: "POST"},);
|
||||
const response = await fetch(`/setup/devices/${encodeURIComponent(deviceId)}/test-dns${query}`, {method: "POST"},);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.ok) {
|
||||
@@ -0,0 +1,16 @@
|
||||
footer {
|
||||
margin-top: 50px;
|
||||
padding: 20px;
|
||||
font-size: 0.8em;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#version-info a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#version-info a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
async function fetchVersion() {
|
||||
try {
|
||||
const response = await fetch('/version');
|
||||
const data = await response.json();
|
||||
const info = document.getElementById('version-info');
|
||||
if (info && data.version) {
|
||||
const version = data.version;
|
||||
const commit = data.commit;
|
||||
const isDirty = version.includes('dirty');
|
||||
const releaseUrl = isDirty
|
||||
? 'https://github.com/gesellix/Bose-SoundTouch/releases'
|
||||
: `https://github.com/gesellix/Bose-SoundTouch/releases/tag/v${version}`;
|
||||
const commitUrl = `https://github.com/gesellix/Bose-SoundTouch/commit/${commit}`;
|
||||
const projectUrl = 'https://gesellix.github.io/Bose-SoundTouch/';
|
||||
|
||||
info.innerHTML = `<a href="${projectUrl}" target="_blank" style="color: inherit; text-decoration: none;">AfterTouch</a> ` +
|
||||
`<a href="${releaseUrl}" target="_blank" style="color: inherit;">${version}</a> ` +
|
||||
`(<a href="${commitUrl}" target="_blank" style="color: inherit;">${commit}</a>) - ${data.date}`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch version info', error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
async function fetchDevices() {
|
||||
try {
|
||||
const response = await fetch('/devices');
|
||||
const devices = await response.json();
|
||||
const container = document.getElementById('device-list');
|
||||
const seen = new Set();
|
||||
|
||||
if (devices.length === 0) {
|
||||
container.innerHTML = '<p>No devices found. Ensure they are on the same network.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
devices.forEach(device => {
|
||||
seen.add(device.device_id);
|
||||
const existing = document.getElementById(`device-${device.device_id}`);
|
||||
if (existing) {
|
||||
// Update product code/IP if changed, but keep title if we already have a better name
|
||||
const title = existing.querySelector('.device-title');
|
||||
if (title && (!title.textContent || title.textContent === 'Unknown Device' || title.textContent.startsWith('SoundTouch-'))) {
|
||||
title.textContent = device.name || 'Unknown Device';
|
||||
}
|
||||
const subtitle = existing.querySelector('.device-subtitle span');
|
||||
if (subtitle) {
|
||||
const currentSubtitle = subtitle.textContent || '';
|
||||
const parts = currentSubtitle.split(' | ');
|
||||
const currentType = parts.length > 1 ? parts[1].trim() : '';
|
||||
const newType = device.product_code || 'Unknown';
|
||||
|
||||
// Don't downgrade type if we already have a specific one
|
||||
const isGeneric = !currentType || currentType === 'Unknown' || currentType === 'N/A';
|
||||
const displayType = isGeneric ? newType : currentType;
|
||||
subtitle.textContent = `${device.ip_address} | ${displayType}`;
|
||||
}
|
||||
const details = existing.querySelector(`#details-${device.device_id}`);
|
||||
if (details) {
|
||||
const idField = details.querySelector('p:nth-child(1) code');
|
||||
if (idField) {
|
||||
const currentId = idField.textContent;
|
||||
// Don't overwrite with serial if we have a real deviceID (usually hex)
|
||||
if (!currentId || currentId === 'N/A' || currentId === device.device_serial_number) {
|
||||
idField.textContent = device.device_id || 'N/A';
|
||||
}
|
||||
}
|
||||
const firmwareField = details.querySelector('p:nth-child(2) code');
|
||||
if (firmwareField) {
|
||||
const cur = firmwareField.textContent;
|
||||
if (!cur || cur === 'N/A' || cur === '0.0.0') {
|
||||
firmwareField.textContent = device.firmware_version || 'N/A';
|
||||
}
|
||||
}
|
||||
const serialField = details.querySelector('p:nth-child(3) code');
|
||||
if (serialField && (!serialField.textContent || serialField.textContent === 'N/A')) {
|
||||
serialField.textContent = device.device_serial_number || 'N/A';
|
||||
}
|
||||
}
|
||||
// Ensure WS is open
|
||||
openDeviceWebSocket(device.device_id);
|
||||
return;
|
||||
}
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'device-card';
|
||||
card.id = `device-${device.device_id}`;
|
||||
card.innerHTML = `
|
||||
<div class="device-info">
|
||||
<div class="device-header">
|
||||
<div>
|
||||
<div class="device-title-row">
|
||||
<h2 class="device-title">${device.name || 'Unknown Device'}</h2>
|
||||
<button class="info-toggle" title="More info" onclick="toggleDetails('${device.device_id}')">i</button>
|
||||
</div>
|
||||
<p class="device-subtitle">
|
||||
<span>${device.ip_address} | ${device.product_code}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button class="power-icon" title="Power" aria-label="Power" onclick="control('${device.device_id}', 'POWER')"></button>
|
||||
</div>
|
||||
<div class="device-details" id="details-${device.device_id}">
|
||||
<p>ID: <code>${device.device_id}</code></p>
|
||||
<p>Firmware: <code>${device.firmware_version || 'N/A'}</code></p>
|
||||
<p>Serial: <code>${device.device_serial_number || 'N/A'}</code></p>
|
||||
<p>Discovery: <code>${device.discovery_method || 'N/A'}</code></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="now-playing" id="np-${device.device_id}">
|
||||
<p><em>Loading playback status...</em></p>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button class="primary" onclick="control('${device.device_id}', 'PLAY')">Play</button>
|
||||
<button class="primary" onclick="control('${device.device_id}', 'PAUSE')">Pause</button>
|
||||
<button onclick="control('${device.device_id}', 'PREV_TRACK')">Prev</button>
|
||||
<button onclick="control('${device.device_id}', 'NEXT_TRACK')">Next</button>
|
||||
</div>
|
||||
<div class="volume-container">
|
||||
<span>Vol:</span>
|
||||
<input id="vol-${device.device_id}" type="range" min="0" max="100"
|
||||
oninput="onVolumeInput('${device.device_id}', this)"
|
||||
onmousedown="startAdjust('${device.device_id}')" ontouchstart="startAdjust('${device.device_id}')"
|
||||
onmouseup="endAdjust('${device.device_id}')" ontouchend="endAdjust('${device.device_id}')">
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(card);
|
||||
updateNowPlaying(device.device_id);
|
||||
updateVolume(device.device_id);
|
||||
openDeviceWebSocket(device.device_id);
|
||||
});
|
||||
|
||||
// Remove cards for devices that no longer exist
|
||||
Array.from(container.children).forEach(child => {
|
||||
const id = child.id?.replace('device-', '');
|
||||
if (id && !seen.has(id)) {
|
||||
container.removeChild(child);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch devices', error);
|
||||
document.getElementById('device-list').innerHTML = '<p>Error loading devices.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
async function updateNowPlaying(deviceId) {
|
||||
try {
|
||||
const response = await fetch(`/devices/${deviceId}/info`);
|
||||
if (!response.ok) return;
|
||||
const info = await response.json();
|
||||
|
||||
// Update device name and type if available (live info is more accurate than discovery)
|
||||
const title = document.querySelector(`#device-${deviceId} .device-title`);
|
||||
if (title && info.name) {
|
||||
title.textContent = info.name;
|
||||
}
|
||||
const subtitle = document.querySelector(`#device-${deviceId} .device-subtitle span`);
|
||||
if (subtitle && info.type) {
|
||||
subtitle.textContent = `${info.ipAddress || info.ip_address || 'N/A'} | ${info.type}`;
|
||||
}
|
||||
|
||||
// Update firmware version if available
|
||||
const details = document.getElementById(`details-${deviceId}`);
|
||||
if (details) {
|
||||
if (info.deviceID) {
|
||||
const idField = details.querySelector('p:nth-child(1) code');
|
||||
if (idField) idField.textContent = info.deviceID;
|
||||
}
|
||||
if (info.softwareVersion) {
|
||||
const firmwareField = details.querySelector('p:nth-child(2) code');
|
||||
if (firmwareField) firmwareField.textContent = info.softwareVersion;
|
||||
}
|
||||
if (info.serialNumber) {
|
||||
const serialField = details.querySelector('p:nth-child(3) code');
|
||||
if (serialField) serialField.textContent = info.serialNumber;
|
||||
}
|
||||
}
|
||||
|
||||
const npContainer = document.getElementById(`np-${deviceId}`);
|
||||
if (npContainer && info.nowPlaying) {
|
||||
const np = info.nowPlaying;
|
||||
const source = np.source || np.Source;
|
||||
const powerIcon = document.querySelector(`#device-${deviceId} .power-icon`);
|
||||
if (powerIcon) {
|
||||
if (source === 'STANDBY') {
|
||||
powerIcon.classList.add('off');
|
||||
powerIcon.classList.remove('on');
|
||||
} else {
|
||||
powerIcon.classList.remove('off');
|
||||
powerIcon.classList.add('on');
|
||||
}
|
||||
}
|
||||
if (source === 'STANDBY') {
|
||||
npContainer.innerHTML = '<div class="now-playing-info"><p><em>Standby</em></p></div>';
|
||||
} else {
|
||||
const track = np.track || np.Track || np.stationName || np.StationName || 'Unknown Track';
|
||||
const artist = np.artist || np.Artist || 'Unknown Artist';
|
||||
const album = np.album || np.Album || 'Unknown Album';
|
||||
const art = np.Art || np.art || {};
|
||||
const artStatus = art.ArtImageStatus || art.artImageStatus;
|
||||
const artUrl = artStatus === 'IMAGE_PRESENT' ? (art.URL || art.url || '') : '';
|
||||
|
||||
npContainer.innerHTML = `
|
||||
<img class="album-art" src="${artUrl}" alt="Artwork">
|
||||
<div class="now-playing-info">
|
||||
<strong>${track}</strong><br>
|
||||
${artist} - ${album}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch now playing for ' + deviceId, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateVolume(deviceId) {
|
||||
try {
|
||||
const response = await fetch(`/devices/${deviceId}/info`);
|
||||
if (!response.ok) return;
|
||||
const info = await response.json();
|
||||
const slider = document.getElementById(`vol-${deviceId}`);
|
||||
if (slider && info.volume && typeof info.volume.actualvolume === 'number' && !adjusting[deviceId]) {
|
||||
slider.value = String(info.volume.actualvolume);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch volume for ' + deviceId, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function control(deviceId, key) {
|
||||
let deviceName = deviceId;
|
||||
const title = document.querySelector(`#device-${deviceId} .device-title`);
|
||||
if (title && title.textContent) {
|
||||
deviceName = title.textContent;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/devices/${encodeURIComponent(deviceId)}/key/${encodeURIComponent(key)}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Control failed', error);
|
||||
alert(`Failed to send ${key} to ${deviceName}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function setVolume(deviceId, level) {
|
||||
let deviceName = deviceId;
|
||||
const title = document.querySelector(`#device-${deviceId} .device-title`);
|
||||
if (title && title.textContent) {
|
||||
deviceName = title.textContent;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/devices/${encodeURIComponent(deviceId)}/volume/${encodeURIComponent(level)}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Set volume failed', error);
|
||||
alert(`Failed to set volume on ${deviceName} to ${level}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Volume interaction helpers to avoid UI jumping while dragging
|
||||
const adjusting = {};
|
||||
const volumeTimers = {};
|
||||
|
||||
function startAdjust(deviceId) {
|
||||
adjusting[deviceId] = true;
|
||||
}
|
||||
|
||||
function endAdjust(deviceId) {
|
||||
// Small delay to let the device send back its volume update
|
||||
setTimeout(() => { adjusting[deviceId] = false; }, 300);
|
||||
}
|
||||
|
||||
function onVolumeInput(deviceId, el) {
|
||||
startAdjust(deviceId);
|
||||
const level = el.value;
|
||||
// Debounce network calls per device
|
||||
if (volumeTimers[deviceId]) {
|
||||
clearTimeout(volumeTimers[deviceId]);
|
||||
}
|
||||
volumeTimers[deviceId] = setTimeout(() => {
|
||||
setVolume(deviceId, level);
|
||||
endAdjust(deviceId);
|
||||
}, 150);
|
||||
}
|
||||
|
||||
let deviceSockets = {};
|
||||
|
||||
function openDeviceWebSocket(deviceId) {
|
||||
const key = `${deviceId}`;
|
||||
try {
|
||||
const existing = deviceSockets[key];
|
||||
if (existing) {
|
||||
// Reuse an already healthy connection instead of tearing it down every refresh
|
||||
if (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING) {
|
||||
return;
|
||||
}
|
||||
try { existing.close(); } catch (_) {}
|
||||
}
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const wsUrl = `${proto}://${location.host}/devices/${encodeURIComponent(deviceId)}/ws`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
deviceSockets[key] = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
// console.log('WS connected for', deviceId);
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
const type = msg.type;
|
||||
const payload = msg.payload || {};
|
||||
if (type === 'nowPlayingUpdated') {
|
||||
const e = payload;
|
||||
const np = e.NowPlaying || e.nowPlaying || {};
|
||||
const source = np.source || np.Source;
|
||||
|
||||
// Also try to update name/type if they are present in the event (sometimes events carry device info)
|
||||
const title = document.querySelector(`#device-${deviceId} .device-title`);
|
||||
if (title && e.name) {
|
||||
title.textContent = e.name;
|
||||
}
|
||||
const subtitle = document.querySelector(`#device-${deviceId} .device-subtitle span`);
|
||||
if (subtitle && e.type) {
|
||||
subtitle.textContent = `${e.ipAddress || e.ip_address || 'N/A'} | ${e.type}`;
|
||||
}
|
||||
|
||||
const powerIcon = document.querySelector(`#device-${deviceId} .power-icon`);
|
||||
if (powerIcon) {
|
||||
if (source === 'STANDBY') {
|
||||
powerIcon.classList.add('off');
|
||||
powerIcon.classList.remove('on');
|
||||
} else {
|
||||
powerIcon.classList.remove('off');
|
||||
powerIcon.classList.add('on');
|
||||
}
|
||||
}
|
||||
const npContainer = document.getElementById(`np-${deviceId}`);
|
||||
if (npContainer) {
|
||||
if (source === 'STANDBY') {
|
||||
npContainer.innerHTML = '<div class="now-playing-info"><p><em>Standby</em></p></div>';
|
||||
} else {
|
||||
const track = np.track || np.Track || np.stationName || np.StationName || 'Unknown Track';
|
||||
const artist = np.artist || np.Artist || 'Unknown Artist';
|
||||
const album = np.album || np.Album || 'Unknown Album';
|
||||
const art = np.Art || np.art || {};
|
||||
const artStatus = art.ArtImageStatus || art.artImageStatus;
|
||||
const artUrl = artStatus === 'IMAGE_PRESENT' ? (art.URL || art.url || '') : '';
|
||||
|
||||
npContainer.innerHTML = `
|
||||
<img class="album-art" src="${artUrl}" alt="Artwork">
|
||||
<div class="now-playing-info">
|
||||
<strong>${track}</strong><br>
|
||||
${artist} - ${album}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
} else if (type === 'volumeUpdated') {
|
||||
const e = payload;
|
||||
const vol = (e.Volume && (typeof e.Volume.actualvolume === 'number' ? e.Volume.actualvolume : (typeof e.Volume.actual === 'number' ? e.Volume.actual : e.Volume.target))) ||
|
||||
(e.volume && (typeof e.volume.actualvolume === 'number' ? e.volume.actualvolume : (typeof e.volume.actual === 'number' ? e.volume.actual : e.volume.target)));
|
||||
const slider = document.getElementById(`vol-${deviceId}`);
|
||||
if (slider && typeof vol === 'number' && !adjusting[deviceId]) {
|
||||
slider.value = String(vol);
|
||||
}
|
||||
} else if (type === 'snapshotInfo') {
|
||||
const info = payload || {};
|
||||
|
||||
// Update name and type from snapshot
|
||||
const title = document.querySelector(`#device-${deviceId} .device-title`);
|
||||
if (title && info.name) {
|
||||
title.textContent = info.name;
|
||||
}
|
||||
const subtitle = document.querySelector(`#device-${deviceId} .device-subtitle span`);
|
||||
if (subtitle && info.type) {
|
||||
subtitle.textContent = `${info.ipAddress || info.ip_address || 'N/A'} | ${info.type}`;
|
||||
}
|
||||
|
||||
// Update firmware and ID from snapshot if available
|
||||
const details = document.getElementById(`details-${deviceId}`);
|
||||
if (details) {
|
||||
if (info.deviceID) {
|
||||
const idField = details.querySelector('p:nth-child(1) code');
|
||||
if (idField) idField.textContent = info.deviceID;
|
||||
}
|
||||
if (info.softwareVersion) {
|
||||
const firmwareField = details.querySelector('p:nth-child(2) code');
|
||||
if (firmwareField) firmwareField.textContent = info.softwareVersion;
|
||||
}
|
||||
if (info.serialNumber) {
|
||||
const serialField = details.querySelector('p:nth-child(3) code');
|
||||
if (serialField) serialField.textContent = info.serialNumber;
|
||||
}
|
||||
}
|
||||
|
||||
if (info.nowPlaying) {
|
||||
const np = info.nowPlaying;
|
||||
const source = np.source || np.Source;
|
||||
const powerIcon = document.querySelector(`#device-${deviceId} .power-icon`);
|
||||
if (powerIcon) {
|
||||
if (source === 'STANDBY') {
|
||||
powerIcon.classList.add('off');
|
||||
powerIcon.classList.remove('on');
|
||||
} else {
|
||||
powerIcon.classList.remove('off');
|
||||
powerIcon.classList.add('on');
|
||||
}
|
||||
}
|
||||
const npContainer = document.getElementById(`np-${deviceId}`);
|
||||
if (npContainer) {
|
||||
if (source === 'STANDBY') {
|
||||
npContainer.innerHTML = '<div class="now-playing-info"><p><em>Standby</em></p></div>';
|
||||
} else {
|
||||
const track = np.track || np.Track || np.stationName || np.StationName || 'Unknown Track';
|
||||
const artist = np.artist || np.Artist || 'Unknown Artist';
|
||||
const album = np.album || np.Album || 'Unknown Album';
|
||||
const art = np.Art || np.art || {};
|
||||
const artStatus = art.ArtImageStatus || art.artImageStatus;
|
||||
const artUrl = artStatus === 'IMAGE_PRESENT' ? (art.URL || art.url || '') : '';
|
||||
|
||||
npContainer.innerHTML = `
|
||||
<img class="album-art" src="${artUrl}" alt="Artwork">
|
||||
<div class="now-playing-info">
|
||||
<strong>${track}</strong><br>
|
||||
${artist} - ${album}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
const vol = info.actualVolume || (info.volume && (typeof info.volume.actualvolume === 'number' ? info.volume.actualvolume : (typeof info.volume.actual === 'number' ? info.volume.actual : null)));
|
||||
const slider = document.getElementById(`vol-${deviceId}`);
|
||||
if (slider && typeof vol === 'number' && !adjusting[deviceId]) slider.value = String(vol);
|
||||
}
|
||||
} catch (err) {
|
||||
// console.warn('Bad WS message', err);
|
||||
}
|
||||
};
|
||||
ws.onerror = () => {
|
||||
// console.warn('WS error for', ip);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
// Try to reconnect after a delay
|
||||
setTimeout(() => {
|
||||
if (deviceSockets[key] === ws) {
|
||||
delete deviceSockets[key];
|
||||
}
|
||||
openDeviceWebSocket(deviceId);
|
||||
}, 3000);
|
||||
};
|
||||
} catch (e) {
|
||||
// console.warn('Failed to open WS for', deviceId, e);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDetails(deviceId) {
|
||||
const el = document.getElementById(`details-${deviceId}`);
|
||||
if (el) {
|
||||
el.classList.toggle('visible');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
fetchDevices();
|
||||
fetchVersion();
|
||||
setInterval(fetchDevices, 30000);
|
||||
});
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Stockholm Mini - Reverse Engineered</title>
|
||||
<link rel="stylesheet" href="../shared/common.css">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Stockholm Mini</h1>
|
||||
<p style="margin-top: -10px; font-style: italic; color: #666; font-size: 0.9rem;">A minimal reverse-engineered SoundTouch controller.</p>
|
||||
<p style="margin-bottom: 20px;"><a href="/" style="color: #00bcd4; text-decoration: none; font-size: 0.9rem;">← Back to selection</a></p>
|
||||
|
||||
<div id="device-list"></div>
|
||||
</div>
|
||||
|
||||
<footer style="margin-top: 50px;">
|
||||
<span id="version-info">AfterTouch</span>
|
||||
</footer>
|
||||
|
||||
<script src="../shared/common.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,40 @@
|
||||
@font-face {
|
||||
font-family: 'bose';
|
||||
src: url('bose.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: #121212; color: #e0e0e0; margin: 0; padding: 20px; }
|
||||
.container { max-width: 800px; margin: 0 auto; }
|
||||
h1 { color: #fff; border-bottom: 1px solid #333; padding-bottom: 10px; }
|
||||
.device-card { background: #1e1e1e; border-radius: 8px; padding: 20px; margin-bottom: 20px; box-shadow: 0 4px 6px rgba(0,0,0,0.3); }
|
||||
.device-info h2 { margin-top: 0; color: #00bcd4; margin-bottom: 0; }
|
||||
.device-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.device-title-row { display: flex; align-items: center; gap: 10px; margin-bottom: 4px; }
|
||||
.device-title { margin: 0; font-size: 1.5rem; line-height: 1.2; }
|
||||
.device-subtitle { color: #888; font-size: 0.85rem; margin: 0; display: flex; align-items: center; }
|
||||
.info-toggle { background: none; color: #555; padding: 0; width: 1.15rem; height: 1.15rem; display: inline-flex; align-items: center; justify-content: center; border: 1px solid #444; border-radius: 50%; font-size: 0.7rem; font-style: italic; cursor: pointer; line-height: 1; transition: all 0.2s; flex-shrink: 0; }
|
||||
.info-toggle:hover { color: #aaa; border-color: #666; background: #2a2a2a; }
|
||||
.device-details { display: none; margin-top: 10px; font-size: 0.8rem; background: #252525; padding: 10px; border-radius: 4px; color: #aaa; border-left: 2px solid #00bcd4; }
|
||||
.device-details.visible { display: block; }
|
||||
.device-details p { margin: 4px 0; }
|
||||
.device-details code { color: #ccc; }
|
||||
.controls { display: flex; gap: 10px; margin-top: 20px; }
|
||||
button { background: #333; color: #fff; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; transition: background 0.2s; }
|
||||
button:hover { background: #444; }
|
||||
button.primary { background: #00bcd4; color: #000; font-weight: bold; }
|
||||
button.primary:hover { background: #00acc1; }
|
||||
.power-icon { font-family: bose, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 1.25rem; line-height: 1; height: 2.25rem; width: 2.25rem; padding: 0; display: inline-flex; align-items: center; justify-content: center; background: #2a2a2a; border-radius: 50%; color: #00bcd4; border: 1px solid #00bcd4; }
|
||||
.power-icon:hover { background: #3a3a3a; }
|
||||
.power-icon.off { color: #666; border-color: #444; background: #1a1a1a; }
|
||||
.power-icon.on { background: #00bcd4; color: #000; border-color: #00bcd4; }
|
||||
.power-icon.on:hover { background: #00acc1; }
|
||||
.status-badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.8em; background: #333; margin-left: 10px; }
|
||||
.now-playing { margin-top: 20px; padding-top: 20px; border-top: 1px solid #333; display: flex; gap: 15px; align-items: center; min-height: 80px; }
|
||||
.now-playing-info { flex-grow: 1; }
|
||||
.album-art { width: 80px; height: 80px; border-radius: 4px; background: #2a2a2a; flex-shrink: 0; object-fit: cover; box-shadow: 0 2px 4px rgba(0,0,0,0.5); }
|
||||
.album-art[src=""] { display: none; }
|
||||
.volume-container { margin-top: 15px; display: flex; align-items: center; gap: 10px; }
|
||||
input[type=range] { flex-grow: 1; }
|
||||
#device-list:empty::after { content: "Searching for devices..."; color: #666; font-style: italic; }
|
||||
@@ -139,6 +139,10 @@ type DeviceInfoXML struct {
|
||||
} `xml:"networkInfo" json:"networkInfo"`
|
||||
SoftwareVer string `xml:"-" json:"softwareVersion"`
|
||||
SerialNumber string `xml:"-" json:"serialNumber"`
|
||||
|
||||
// Enriched fields (not part of device /info XML)
|
||||
NowPlaying *models.NowPlaying `json:"nowPlaying,omitempty"`
|
||||
Volume *models.Volume `json:"volume,omitempty"`
|
||||
}
|
||||
|
||||
// GetLiveDeviceInfo fetches live information from the speaker's :8090/info endpoint.
|
||||
@@ -186,6 +190,16 @@ func (m *Manager) parseDeviceInfoXML(reader io.Reader, infoXML *DeviceInfoXML) e
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich with live now playing and volume via device API (best-effort)
|
||||
//c := client.NewClientFromHost(deviceIP)
|
||||
//if vol, err := c.GetVolume(); err == nil {
|
||||
// infoXML.Volume = vol
|
||||
//}
|
||||
//
|
||||
//if np, err := c.GetNowPlaying(); err == nil {
|
||||
// infoXML.NowPlaying = np
|
||||
//}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user