Compare commits
@@ -3,6 +3,7 @@
|
||||
|
||||
# Docker/Service Settings
|
||||
SOUNDTOUCH_HOSTNAME=soundtouch.local
|
||||
SOUNDTOUCH_VERSION=latest
|
||||
|
||||
# Discovery Settings
|
||||
DISCOVERY_TIMEOUT=5s
|
||||
|
||||
@@ -112,7 +112,7 @@ jobs:
|
||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||
output_name="${output_name}.exe"
|
||||
fi
|
||||
go build -o "$output_name" ./cmd/soundtouch-cli
|
||||
go build -trimpath -ldflags="-s -w" -o "$output_name" ./cmd/soundtouch-cli
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -220,7 +220,7 @@ jobs:
|
||||
|
||||
- name: Test CLI build and help
|
||||
run: |
|
||||
go build -o soundtouch-cli ./cmd/soundtouch-cli
|
||||
go build -trimpath -ldflags="-s -w" -o soundtouch-cli ./cmd/soundtouch-cli
|
||||
./soundtouch-cli -help
|
||||
|
||||
- name: Test library imports
|
||||
|
||||
@@ -151,6 +151,7 @@ jobs:
|
||||
rm -f "$OUTPUT_NAME" "$OUTPUT_NAME.sha256" "$OUTPUT_NAME.sha512"
|
||||
|
||||
if ! go build \
|
||||
-trimpath \
|
||||
-ldflags="-s -w" \
|
||||
-o "$OUTPUT_NAME" \
|
||||
"$CMD_PATH"; then
|
||||
|
||||
@@ -58,6 +58,15 @@ vendor/
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Android MITM setup — downloaded/generated artefacts, not committed
|
||||
scripts/android/bose.apk
|
||||
scripts/android/frida-server
|
||||
scripts/android/frida-server.xz
|
||||
scripts/android/frida/
|
||||
scripts/android/frida-venv/
|
||||
scripts/android/captures/
|
||||
scripts/android/mitm/
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.2-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.3-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
|
||||
|
||||
@@ -28,8 +28,8 @@ BACKUP_NAME=soundtouch-backup
|
||||
BACKUP_PATH=./cmd/$(BACKUP_NAME)
|
||||
BUILD_DIR=./build
|
||||
|
||||
# Version info
|
||||
# No ldflags needed - using debug.BuildInfo since Go 1.18
|
||||
# Build flags: strip debug info/DWARF for smaller binaries, remove local paths for reproducibility
|
||||
BUILDFLAGS=-trimpath -ldflags="-s -w"
|
||||
|
||||
all: check build
|
||||
|
||||
@@ -38,78 +38,85 @@ build: build-cli build-service build-web build-examples build-favicon-gen build-
|
||||
build-cli:
|
||||
@echo "Building $(BINARY_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME) $(BINARY_PATH)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) $(BINARY_PATH)
|
||||
|
||||
build-service:
|
||||
@echo "Building $(SERVICE_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME) $(SERVICE_PATH)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME) $(SERVICE_PATH)
|
||||
|
||||
build-web:
|
||||
@echo "Building $(WEB_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(WEB_NAME) $(WEB_PATH)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(WEB_NAME) $(WEB_PATH)
|
||||
|
||||
build-examples:
|
||||
@echo "Building $(EXAMPLE_MDNS_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME) $(EXAMPLE_MDNS_PATH)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME) $(EXAMPLE_MDNS_PATH)
|
||||
@echo "Building $(EXAMPLE_UPNP_NAME)..."
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME) $(EXAMPLE_UPNP_PATH)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME) $(EXAMPLE_UPNP_PATH)
|
||||
@echo "Building $(SCANNER_NAME)..."
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME) $(SCANNER_PATH)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME) $(SCANNER_PATH)
|
||||
|
||||
build-favicon-gen:
|
||||
@echo "Building $(FAVICON_GEN_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(FAVICON_GEN_NAME) $(FAVICON_GEN_PATH)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(FAVICON_GEN_NAME) $(FAVICON_GEN_PATH)
|
||||
|
||||
build-backup:
|
||||
@echo "Building $(BACKUP_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(BACKUP_NAME) $(BACKUP_PATH)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME) $(BACKUP_PATH)
|
||||
|
||||
build-all: build-linux build-darwin build-windows build-examples-all
|
||||
build-all: build-linux build-linux-armv7 build-darwin build-windows build-examples-all
|
||||
|
||||
build-linux:
|
||||
@echo "Building for Linux..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 $(BINARY_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME)-linux-amd64 $(SERVICE_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BACKUP_NAME)-linux-amd64 $(BACKUP_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 $(BINARY_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-linux-amd64 $(SERVICE_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME)-linux-amd64 $(BACKUP_PATH)
|
||||
|
||||
build-linux-armv7:
|
||||
@echo "Building for Linux ARMv7 (CGO_ENABLED=0 for kernel 3.14+ compatibility)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-linux-armv7 $(SERVICE_PATH)
|
||||
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 $(BINARY_PATH)
|
||||
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME)-linux-armv7 $(BACKUP_PATH)
|
||||
|
||||
build-darwin:
|
||||
@echo "Building for macOS..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 $(BINARY_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 $(BINARY_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME)-darwin-amd64 $(SERVICE_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME)-darwin-arm64 $(SERVICE_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BACKUP_NAME)-darwin-amd64 $(BACKUP_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(BACKUP_NAME)-darwin-arm64 $(BACKUP_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 $(BINARY_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 $(BINARY_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-darwin-amd64 $(SERVICE_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-darwin-arm64 $(SERVICE_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME)-darwin-amd64 $(BACKUP_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME)-darwin-arm64 $(BACKUP_PATH)
|
||||
|
||||
build-windows:
|
||||
@echo "Building for Windows..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe $(BINARY_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME)-windows-amd64.exe $(SERVICE_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BACKUP_NAME)-windows-amd64.exe $(BACKUP_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe $(BINARY_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-windows-amd64.exe $(SERVICE_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME)-windows-amd64.exe $(BACKUP_PATH)
|
||||
|
||||
build-examples-all:
|
||||
@echo "Building examples for all platforms..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-linux-amd64 $(EXAMPLE_MDNS_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-darwin-amd64 $(EXAMPLE_MDNS_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-darwin-arm64 $(EXAMPLE_MDNS_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-windows-amd64.exe $(EXAMPLE_MDNS_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-linux-amd64 $(EXAMPLE_UPNP_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-darwin-amd64 $(EXAMPLE_UPNP_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-darwin-arm64 $(EXAMPLE_UPNP_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-windows-amd64.exe $(EXAMPLE_UPNP_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME)-linux-amd64 $(SCANNER_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME)-darwin-amd64 $(SCANNER_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME)-darwin-arm64 $(SCANNER_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME)-windows-amd64.exe $(SCANNER_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-linux-amd64 $(EXAMPLE_MDNS_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-darwin-amd64 $(EXAMPLE_MDNS_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-darwin-arm64 $(EXAMPLE_MDNS_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-windows-amd64.exe $(EXAMPLE_MDNS_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-linux-amd64 $(EXAMPLE_UPNP_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-darwin-amd64 $(EXAMPLE_UPNP_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-darwin-arm64 $(EXAMPLE_UPNP_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-windows-amd64.exe $(EXAMPLE_UPNP_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME)-linux-amd64 $(SCANNER_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME)-darwin-amd64 $(SCANNER_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME)-darwin-arm64 $(SCANNER_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME)-windows-amd64.exe $(SCANNER_PATH)
|
||||
|
||||
test:
|
||||
@echo "Running tests..."
|
||||
@@ -338,6 +345,7 @@ help:
|
||||
@echo " build-favicon-gen - Build the favicon generator"
|
||||
@echo " build-examples - Build only the example programs"
|
||||
@echo " build-all - Build for all platforms"
|
||||
@echo " build-linux-armv7 - Build for Linux ARMv7 (kernel 3.14+ compatible, CGO_ENABLED=0)"
|
||||
@echo " test - Run tests"
|
||||
@echo " test-coverage - Run tests with coverage report"
|
||||
@echo " check - Run fmt, vet, and tests"
|
||||
|
||||
@@ -1,549 +1,127 @@
|
||||
# Bose SoundTouch Toolkit
|
||||
|
||||
A comprehensive solution for controlling and preserving Bose SoundTouch devices, including a Go library, CLI tool, and a local service for cloud emulation.
|
||||
|
||||
[](https://pkg.go.dev/github.com/gesellix/bose-soundtouch)
|
||||
[](https://goreportcard.com/report/github.com/gesellix/bose-soundtouch)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
> **Note**: This is an independent project based on the [official Bose SoundTouch Web API documentation](https://assets.bosecreative.com/m/496577402d128874/original/SoundTouch-Web-API.pdf). Not affiliated with or endorsed by Bose Corporation.
|
||||
> Independent project. Not affiliated with or endorsed by Bose Corporation.
|
||||
|
||||
## Features
|
||||
## Context: Cloud Shutdown
|
||||
|
||||
- ✅ **Complete API Coverage**: All available SoundTouch Web API endpoints implemented
|
||||
- 🎵 **Media Control**: Play, pause, stop, volume, bass, balance, source selection
|
||||
- 🔔 **Smart Notifications**: TTS messages, URL audio content, notification beeps (ST-10)
|
||||
- 🏠 **Multiroom Support**: Create and manage zones across multiple speakers
|
||||
- ⚡ **Real-time Events**: WebSocket connection for live device state monitoring
|
||||
- 🔍 **Device Discovery**: Automatic discovery via UPnP/SSDP and mDNS
|
||||
- 📻 **Content Navigation**: Browse and search TuneIn, Pandora, Spotify, local music
|
||||
- 📻 **Custom Radio**: Play any stream URL via [flexible proxying](docs/guides/CLI-REFERENCE.md#custom-radio-selection-via-soundtouch-service)
|
||||
- 📻 **RadioBrowser**: Access thousands of internet radio stations via [radio-browser.info](docs/reference/radio-browser.md)
|
||||
- 🎙️ **Station Management**: Add and play radio stations without presets
|
||||
- 🖥️ **CLI Tool**: Comprehensive command-line interface
|
||||
- 🌐 **SoundTouch Service**: Emulate Bose cloud services for offline device operation
|
||||
- 🔧 **Service Migration**: Migrate devices to use local services instead of Bose cloud (XML, Hosts, or DNS redirection)
|
||||
- 🔍 **DNS Discovery & Interception**: Dynamic DNS server for intercepting and logging Bose service queries (requires port 53)
|
||||
- 📊 **DNS Discovery Analysis**: Track and deduplicate all device DNS queries to discover hidden hostnames
|
||||
- 📊 **Traffic Analysis**: Proxy and log device communications
|
||||
- 📝 **HTTP Recording**: Persist interactions as re-playable `.http` files
|
||||
- 🔄 **Endpoint Mirroring**: Asynchronously mirror local requests to Bose cloud for parity testing
|
||||
- ⚖️ **Parity Logging**: Detect and record discrepancies between local and official Bose responses
|
||||
- 🧹 **Session Management**: Manage and cleanup recorded interaction sessions
|
||||
- 🔒 **Production Ready**: Extensive testing with real SoundTouch hardware
|
||||
- 🌐 **Cross-Platform**: Windows, macOS, Linux support
|
||||
Bose is shutting down SoundTouch cloud services on **May 6, 2026**. After that, music service browsing, preset sync, and the official SoundTouch app stop working. This toolkit lets you keep your speakers fully functional.
|
||||
|
||||
## Quick Start
|
||||
See the [Survival Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SURVIVAL-GUIDE.html) for the full picture.
|
||||
|
||||
### Installation
|
||||
---
|
||||
|
||||
## Tools
|
||||
|
||||
### soundtouch-service — AfterTouch
|
||||
|
||||
A local server that replaces the Bose cloud ("AfterTouch"). Once your speaker is redirected to it, you have full control without any Bose cloud dependency. The built-in web UI at `http://localhost:8000` handles all setup — no config files needed to get started.
|
||||
|
||||
**Two scenarios:**
|
||||
|
||||
**Before shutdown — migrate your existing setup**
|
||||
While the Bose cloud is still running, use `soundtouch-backup` to save your account data. The local service web UI then helps with the migration so your speaker keeps its presets and credentials.
|
||||
|
||||
**After shutdown or factory reset — start fresh**
|
||||
Create a local account, configure your speakers, and start using them immediately. No Bose infrastructure required.
|
||||
|
||||
**Redirecting your speaker**
|
||||
|
||||
The service needs a stable address on your local network (e.g. `soundtouch.fritz.box` or `soundtouch.local`). The speaker must then be redirected to resolve the Bose cloud hostnames to that address. Two supported methods:
|
||||
|
||||
| Method | How it works | Notes |
|
||||
|--------------|-------------------------------------|--------------------------------------------------------------|
|
||||
| XML redirect | Upload a config XML via the Web API | Surgical; covers only registered endpoints; best for testing |
|
||||
| DNS/DHCP | Serve custom DNS on your network | Covers all devices at once; requires port 53 and TLS |
|
||||
|
||||
The web UI walks you through each method. DNS redirect requires HTTPS — the service manages its own CA certificate and the web UI guides you through trusting it on each speaker.
|
||||
|
||||
> **Note:** A hosts-file method (direct SSH edits to `/etc/hosts`) also exists in the codebase but is deprecated and not exposed in the web UI.
|
||||
|
||||
**Enabling SSH via USB stick**
|
||||
|
||||
Some setup steps require SSH access to the speaker. Enable it once per device: create a file named `remote_services` on a FAT-formatted USB drive (the drive may need its bootable flag set — see [SoundCork issue #172](https://github.com/deborahgu/soundcork/issues/172)), and insert it while the speaker is powered on. After reboot, root SSH is available with no password.
|
||||
|
||||
See [Device Initial Setup](https://gesellix.github.io/Bose-SoundTouch/guides/DEVICE-INITIAL-SETUP.html) and [Migration Guide](https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-GUIDE.html) for step-by-step instructions.
|
||||
|
||||
---
|
||||
|
||||
### soundtouch-backup
|
||||
|
||||
Backs up your Bose cloud account (presets, paired devices, music sources) and each speaker's local state before the shutdown. Run `soundtouch-backup all` to capture everything in one step; it authenticates with the Bose cloud, then polls each paired speaker over the local network.
|
||||
|
||||
See the [soundtouch-backup README](cmd/soundtouch-backup/README.md) for usage.
|
||||
|
||||
---
|
||||
|
||||
### soundtouch-cli
|
||||
|
||||
Command-line control of any SoundTouch device: play/pause/volume, presets, source selection, multiroom zones, device discovery, and more. Works entirely over the local network — no cloud dependency. Well-suited for scripting and home automation.
|
||||
|
||||
See the [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html) for full usage.
|
||||
|
||||
---
|
||||
|
||||
### soundtouch-web
|
||||
|
||||
A standalone web UI for device control — play, pause, volume, preset selection, real-time status — served from a local Go binary. Complements `soundtouch-service` when you want a dedicated device-control interface separate from the setup/admin UI.
|
||||
|
||||
See the [soundtouch-web README](cmd/soundtouch-web/README.md) for usage.
|
||||
|
||||
---
|
||||
|
||||
### Go library
|
||||
|
||||
`pkg/client` provides a Go API for all SoundTouch device endpoints: media control, volume, presets, sources, zones, real-time WebSocket events, and device discovery. Use it to build your own integrations.
|
||||
|
||||
#### Install CLI and Service Tools
|
||||
```bash
|
||||
go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-cli@latest
|
||||
go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-service@latest
|
||||
```
|
||||
|
||||
#### Add Library to Your Project
|
||||
```bash
|
||||
go get github.com/gesellix/bose-soundtouch
|
||||
```
|
||||
|
||||
### CLI Usage
|
||||
See the [API Reference](https://gesellix.github.io/Bose-SoundTouch/reference/API-ENDPOINTS.html) and [pkg.go.dev](https://pkg.go.dev/github.com/gesellix/bose-soundtouch) for documentation.
|
||||
|
||||
Find SoundTouch devices on your network:
|
||||
```bash
|
||||
soundtouch-cli discover devices
|
||||
```
|
||||
|
||||
Control a device (replace `192.168.1.100` with your speaker's IP):
|
||||
```bash
|
||||
# Basic information
|
||||
soundtouch-cli --host 192.168.1.100 info
|
||||
|
||||
# Media controls
|
||||
soundtouch-cli --host 192.168.1.100 play start
|
||||
soundtouch-cli --host 192.168.1.100 volume set --level 50
|
||||
|
||||
# Preset management
|
||||
soundtouch-cli --host 192.168.1.100 preset list
|
||||
```
|
||||
|
||||
For full CLI documentation, see the [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html).
|
||||
|
||||
### SoundTouch Service (Cloud Shutdown Protection)
|
||||
|
||||
The `soundtouch-service` is a local server that emulates Bose's cloud services. This is critical for keeping your speakers functional after the **Bose Cloud Shutdown in May 2026**.
|
||||
|
||||
#### Key Features:
|
||||
- **🏠 Local Emulation**: BMX and Marge service implementation
|
||||
- **🔌 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
|
||||
- **💾 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
|
||||
- **📝 HTTP Recording**: Persist all interactions as re-playable `.http` files
|
||||
- **🧹 Session Management**: Manage and cleanup recorded interaction sessions
|
||||
|
||||
#### Quick Start:
|
||||
```bash
|
||||
# Start the service
|
||||
soundtouch-service
|
||||
```
|
||||
Open `http://localhost:8000` in your browser to manage your devices. Documentation is also available directly through the web interface.
|
||||
|
||||
For a comprehensive guide on transitioning your system, see the [Bose Cloud Shutdown: Survival Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SURVIVAL-GUIDE.html).
|
||||
|
||||
Detailed service configuration and Docker instructions can be found in [SoundTouch Service Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SOUNDTOUCH-SERVICE.html).
|
||||
|
||||
For professional migration tips and safety measures, see the [Migration & Safety Guide](https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-SAFETY.html).
|
||||
|
||||
### Library Usage
|
||||
|
||||
#### Basic Control
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Connect to your SoundTouch device
|
||||
c := client.NewClient(&client.Config{
|
||||
Host: "192.168.1.100",
|
||||
Port: 8090,
|
||||
})
|
||||
|
||||
// Get device information
|
||||
info, err := c.GetDeviceInfo()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("Device: %s\n", info.Name)
|
||||
|
||||
// Control playback
|
||||
err = c.Play()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Set volume
|
||||
err = c.SetVolume(50)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Device Discovery
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Discover SoundTouch devices
|
||||
service := discovery.NewService(5 * time.Second)
|
||||
devices, err := service.DiscoverDevices(context.Background())
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
for _, device := range devices {
|
||||
fmt.Printf("Found: %s at %s:%d\n",
|
||||
device.Name, device.Host, device.Port)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Real-time Events
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func main() {
|
||||
c := client.NewClient(&client.Config{
|
||||
Host: "192.168.1.100",
|
||||
Port: 8090,
|
||||
})
|
||||
|
||||
// Subscribe to device events
|
||||
events, err := c.SubscribeToEvents(context.Background())
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
for event := range events {
|
||||
switch e := event.(type) {
|
||||
case *models.NowPlayingUpdated:
|
||||
fmt.Printf("Now playing: %s by %s\n", e.Track, e.Artist)
|
||||
case *models.VolumeUpdated:
|
||||
fmt.Printf("Volume changed to: %d\n", e.ActualVolume)
|
||||
case *models.ConnectionStateUpdated:
|
||||
fmt.Printf("Connection state: %s\n", e.State)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Preset Management
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func main() {
|
||||
c := client.NewClient(&client.Config{
|
||||
Host: "192.168.1.100",
|
||||
Port: 8090,
|
||||
})
|
||||
|
||||
// Get current presets
|
||||
presets, err := c.GetPresets()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d presets\n", len(presets.Preset))
|
||||
|
||||
// Store currently playing content as preset 1
|
||||
err = c.StoreCurrentAsPreset(1)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Store Spotify playlist as preset 2
|
||||
spotifyContent := &models.ContentItem{
|
||||
Source: "SPOTIFY",
|
||||
Type: "uri",
|
||||
Location: "spotify:playlist:37i9dQZF1DXcBWIGoYBM5M",
|
||||
SourceAccount: "your_username",
|
||||
IsPresetable: true,
|
||||
ItemName: "Today's Top Hits",
|
||||
}
|
||||
err = c.StorePreset(2, spotifyContent)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Store radio station as preset 3
|
||||
radioContent := &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
Type: "stationurl",
|
||||
Location: "/v1/playbook/station/s33828",
|
||||
IsPresetable: true,
|
||||
ItemName: "K-LOVE Radio",
|
||||
}
|
||||
err = c.StorePreset(3, radioContent)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Select preset 1
|
||||
err = c.SelectPreset(1)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Println("Preset management complete!")
|
||||
}
|
||||
```
|
||||
|
||||
#### Multiroom Zones
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func main() {
|
||||
master := client.NewClient(&client.Config{
|
||||
Host: "192.168.1.100", // Master speaker
|
||||
Port: 8090,
|
||||
})
|
||||
|
||||
// Create a multiroom zone
|
||||
zone := &models.Zone{
|
||||
Master: "192.168.1.100",
|
||||
Members: []models.ZoneMember{
|
||||
{IPAddress: "192.168.1.101"}, // Living room
|
||||
{IPAddress: "192.168.1.102"}, // Kitchen
|
||||
},
|
||||
}
|
||||
|
||||
err := master.SetZone(zone)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Println("Multiroom zone created!")
|
||||
}
|
||||
```
|
||||
|
||||
#### Speaker Notifications (ST-10 only)
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
)
|
||||
|
||||
func main() {
|
||||
c := client.NewClient(&client.Config{
|
||||
Host: "192.168.1.100",
|
||||
Port: 8090,
|
||||
})
|
||||
|
||||
// Play Text-to-Speech message (language code "EN", "DE", etc.)
|
||||
err := c.PlayTTS("Welcome home!", "your-app-key", "EN", 70)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Play audio content from URL
|
||||
err = c.PlayURL(
|
||||
"https://example.com/doorbell.mp3",
|
||||
"your-app-key",
|
||||
"Doorbell",
|
||||
"Front Door",
|
||||
"Visitor Alert",
|
||||
80,
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Play notification beep
|
||||
err = c.PlayNotificationBeep()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Println("Notifications sent!")
|
||||
}
|
||||
```
|
||||
|
||||
## Supported Devices
|
||||
|
||||
This library supports all Bose SoundTouch-compatible devices, including:
|
||||
|
||||
- SoundTouch 10, 20, 30 series
|
||||
- SoundTouch Portable
|
||||
- Wave SoundTouch music system
|
||||
- SoundTouch-enabled Bose speakers
|
||||
|
||||
**Tested Hardware**:
|
||||
- ✅ SoundTouch 10
|
||||
- ✅ SoundTouch 20
|
||||
|
||||
## API Coverage
|
||||
|
||||
| Feature | Status | Description |
|
||||
|---------|--------|-------------|
|
||||
| Device Info | ✅ Complete | Device details, name, capabilities |
|
||||
| Media Control | ✅ Complete | Play/pause/stop, track navigation |
|
||||
| Volume & Audio | ✅ Complete | Volume, bass, balance control |
|
||||
| Source Selection | ✅ Complete | Spotify, Bluetooth, AUX, etc. |
|
||||
| Content Navigation | ✅ Complete | Browse music libraries, radio stations |
|
||||
| Station Management | ✅ Complete | Search, add, remove stations |
|
||||
| Preset Management | ✅ Complete | Store, select, remove presets |
|
||||
| Real-time Events | ✅ Complete | WebSocket event streaming |
|
||||
| Multiroom Zones | ✅ Complete | Zone creation and management |
|
||||
| Speaker Notifications | ✅ Complete | TTS, URL audio, beep alerts (ST-10) |
|
||||
| System Settings | ✅ Complete | Clock, display, network info |
|
||||
| Advanced Audio | ✅ Complete | DSP controls, tone controls |
|
||||
|
||||
**API Limitations**: None - all documented SoundTouch Web API functionality is implemented, including endpoints discovered via the comprehensive [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API).
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
- 📖 [Contributing Guide](CONTRIBUTING.md) - How to contribute to the project
|
||||
- 📚 [API Reference](https://gesellix.github.io/Bose-SoundTouch/reference/API-ENDPOINTS.html) - Complete endpoint documentation
|
||||
- 🔧 [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html) - Command-line tool guide
|
||||
- 🌐 [SoundTouch Service Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SOUNDTOUCH-SERVICE.html) - Local service setup and migration
|
||||
- 🎯 [Getting Started](https://gesellix.github.io/Bose-SoundTouch/guides/GETTING-STARTED.html) - Detailed setup and usage
|
||||
- 📻 [Preset Quick Start](https://gesellix.github.io/Bose-SoundTouch/PRESET-QUICKSTART.md) - Favorite content management
|
||||
- 🧭 [Navigation Guide](https://gesellix.github.io/Bose-SoundTouch/NAVIGATION-GUIDE.md) - Content browsing and station management
|
||||
- 📋 [Navigation API Reference](https://gesellix.github.io/Bose-SoundTouch/API-NAVIGATION-REFERENCE.md) - Navigation API documentation
|
||||
- ⚙️ [Advanced Features](https://gesellix.github.io/Bose-SoundTouch/reference/SYSTEM-ENDPOINTS.html) - Advanced functionality
|
||||
- 🏠 [Multiroom Setup](https://gesellix.github.io/Bose-SoundTouch/reference/ZONE-MANAGEMENT.html) - Zone configuration guide
|
||||
- ⚡ [WebSocket Events](https://gesellix.github.io/Bose-SoundTouch/reference/WEBSOCKET-EVENTS.html) - Real-time event handling
|
||||
- 🔔 [Speaker Notifications](https://gesellix.github.io/Bose-SoundTouch/reference/SPEAKER-ENDPOINT.html) - TTS and audio notifications guide
|
||||
- 🔍 [Device Discovery](https://gesellix.github.io/Bose-SoundTouch/reference/DISCOVERY.html) - Discovery configuration
|
||||
- 🛠️ [Troubleshooting](https://gesellix.github.io/Bose-SoundTouch/guides/TROUBLESHOOTING.html) - Common issues and solutions
|
||||
- [Getting Started](https://gesellix.github.io/Bose-SoundTouch/guides/GETTING-STARTED.html)
|
||||
- [Survival Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SURVIVAL-GUIDE.html)
|
||||
- [Migration Guide](https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-GUIDE.html)
|
||||
- [Device Initial Setup](https://gesellix.github.io/Bose-SoundTouch/guides/DEVICE-INITIAL-SETUP.html)
|
||||
- [Migration & Safety Guide](https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-SAFETY.html)
|
||||
- [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html)
|
||||
- [SoundTouch Service Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SOUNDTOUCH-SERVICE.html)
|
||||
- [HTTPS & CA Setup](https://gesellix.github.io/Bose-SoundTouch/guides/HTTPS-SETUP.html)
|
||||
- [API Reference](https://gesellix.github.io/Bose-SoundTouch/reference/API-ENDPOINTS.html)
|
||||
|
||||
## Development
|
||||
---
|
||||
|
||||
### Prerequisites
|
||||
- Go 1.25.6 or later
|
||||
- Optional: SoundTouch device for testing
|
||||
## Related projects
|
||||
|
||||
### Building from Source
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/gesellix/bose-soundtouch.git
|
||||
cd Bose-SoundTouch
|
||||
- **[SoundCork](https://github.com/deborahgu/soundcork)** (Deborah Kaplan et al.) — Python service interception; pioneered the cloud emulation approach this project builds on
|
||||
- **[SoundCork Stockholm App](https://github.com/krahl/soundcork-stockholm-app)** — Companion app for SoundCork
|
||||
- **[SoundTouch Plus](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus)** (Todd Lucas) — Home Assistant integration; extensive undocumented API documentation
|
||||
- **[ÜberBöse API](https://github.com/julius-d/ueberboese-api)** (Julius) — API research and advanced endpoint discovery
|
||||
- **[Bose SoundTouch Hook](https://github.com/CodeFinder2/bose-soundtouch-hook)** (Adrian Böckenkamp) — `LD_PRELOAD` hooking for reverse engineering device internals
|
||||
|
||||
# Install dependencies
|
||||
go mod download
|
||||
|
||||
# Build CLI tool
|
||||
make build
|
||||
|
||||
# Run tests
|
||||
make test
|
||||
|
||||
# Install CLI locally
|
||||
go install ./cmd/soundtouch-cli
|
||||
```
|
||||
|
||||
### Contributing
|
||||
|
||||
We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details on:
|
||||
|
||||
- Setting up your development environment
|
||||
- Coding guidelines and best practices
|
||||
- Testing with real devices
|
||||
- Submitting pull requests
|
||||
|
||||
## Examples
|
||||
|
||||
Check out the [examples/](examples/) directory for more usage patterns:
|
||||
|
||||
- **Basic HTTP Client**: Simple device control
|
||||
- **Preset Management**: Store and manage favorite content
|
||||
- **Navigation & Stations**: Browse content and manage radio stations
|
||||
- **WebSocket Events**: Real-time monitoring
|
||||
- **Device Discovery**: Finding devices on your network
|
||||
- **Multiroom Management**: Zone operations
|
||||
- **Advanced Audio**: DSP and tone controls
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This is an independent project based on the official Bose SoundTouch Web API documentation provided by Bose Corporation. It is not affiliated with, endorsed by, or supported by Bose Corporation. Use at your own risk.
|
||||
|
||||
SoundTouch is a trademark of Bose Corporation.
|
||||
|
||||
## SoundTouch End of Life Notice
|
||||
|
||||
**Important:** Bose has announced that [SoundTouch cloud support will end on May 6, 2026](https://www.bose.com/soundtouch-end-of-life).
|
||||
|
||||
**What will continue to work:**
|
||||
- ✅ Local API control (this library's primary functionality)
|
||||
- ✅ Bluetooth, AirPlay, Spotify Connect, and AUX streaming
|
||||
- ✅ Remote control features (Play, Pause, Skip, Volume)
|
||||
- ✅ Multiroom grouping
|
||||
|
||||
**What will stop working:**
|
||||
- ❌ Cloud-based preset sync between devices and SoundTouch app
|
||||
- ❌ Browsing music services directly from the SoundTouch app
|
||||
- ❌ Cloud-based features and updates
|
||||
|
||||
**What continues to work:**
|
||||
- ✅ Local preset management via this API client (store, select, remove)
|
||||
- ✅ Direct content playback (stations, playlists, etc.)
|
||||
|
||||
This Go library will continue to work as it uses the local Web API for direct device control, which is unaffected by the cloud service discontinuation. The local preset management functionality implemented in this library (discovered through the [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)) provides an alternative to the cloud-based preset features that will be discontinued.
|
||||
|
||||
**Community Alternatives**: See the [Related Projects & Credits](#related-projects--credits) section below for additional tools like SoundCork that provide cloud service alternatives and the SoundTouch Plus project that offers comprehensive Home Assistant integration.
|
||||
|
||||
## Related Projects & Credits
|
||||
|
||||
This project builds upon the excellent work of several community projects:
|
||||
|
||||
### SoundCork 🍾
|
||||
- **Project**: [SoundCork - SoundTouch API Intercept](https://github.com/deborahgu/soundcork)
|
||||
- **Authors**: Deborah Kaplan and contributors
|
||||
- **Our Implementation**: The `soundtouch-service` in this project is heavily inspired by SoundCork's Python implementation. SoundCork pioneered the approach of intercepting and emulating Bose's cloud services, providing the foundation for offline SoundTouch operation.
|
||||
- **Key Contributions**: Service emulation architecture, BMX/Marge endpoint discovery, device migration strategies
|
||||
- **License**: MIT License
|
||||
|
||||
### ÜberBöse API 🎵
|
||||
- **Project**: [ÜberBöse API](https://github.com/julius-d/ueberboese-api)
|
||||
- **Author**: Julius
|
||||
- **Our Implementation**: This project provided valuable insights into advanced SoundTouch API endpoints and helped make our implementation more complete, particularly for content navigation and advanced device features.
|
||||
- **Key Contributions**: Extended API endpoint documentation, advanced feature discovery
|
||||
- **License**: MIT License
|
||||
|
||||
### SoundTouch Plus 🏠
|
||||
- **Project**: [SoundTouch Plus Home Assistant Component](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus)
|
||||
- **Wiki**: [SoundTouch WebServices API Documentation](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)
|
||||
- **Author**: Todd Lucas
|
||||
- **Our Implementation**: The comprehensive API documentation in the SoundTouch Plus Wiki provided invaluable insights into undocumented endpoints beyond the official API, enabling our preset management and content navigation features.
|
||||
- **Key Contributions**: Extensive API endpoint documentation, real-world usage patterns
|
||||
- **License**: MIT License
|
||||
|
||||
### SoundTouch Hook 🪝
|
||||
- **Project**: [Bose SoundTouch Hook](https://github.com/CodeFinder2/bose-soundtouch-hook)
|
||||
- **Author**: Adrian Böckenkamp
|
||||
- **Our Implementation**: This project provides a powerful framework for intercepting and hooking into internal device processes using `LD_PRELOAD`. It was instrumental in verifying internal function calls and understanding how the device validates cloud domains.
|
||||
- **Key Contributions**: Reverse engineering framework, process hooking, cross-compilation toolchain
|
||||
- **License**: GPL-3.0 License
|
||||
|
||||
### Community Ecosystem
|
||||
|
||||
These projects together form a comprehensive ecosystem for SoundTouch device management:
|
||||
|
||||
- **This Project**: Go library + CLI + service for programmatic control and offline operation
|
||||
- **SoundCork**: Python-based service interception and cloud replacement
|
||||
- **SoundTouch Plus**: Home Assistant integration with extensive device support
|
||||
- **ÜberBöse**: API research and advanced endpoint discovery
|
||||
- **SoundTouch Hook**: Advanced reverse engineering and process instrumentation
|
||||
|
||||
We are grateful to these projects and their maintainers for paving the way and providing the foundation that made this comprehensive Go implementation possible. The SoundTouch community's collaborative approach to reverse engineering and documentation has been invaluable.
|
||||
|
||||
### Contributing Back
|
||||
|
||||
If you discover new endpoints, features, or improvements through this library, please consider contributing back to these projects as well. The stronger our community ecosystem becomes, the better we can support SoundTouch devices beyond Bose's official support timeline.
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
- 🐛 **Bug Reports**: [Create an issue](https://github.com/gesellix/bose-soundtouch/issues/new)
|
||||
- 💡 **Feature Requests**: [Start a discussion](https://github.com/gesellix/bose-soundtouch/discussions)
|
||||
- ❓ **Questions**: Check [existing discussions](https://github.com/gesellix/bose-soundtouch/discussions)
|
||||
- 📖 **Documentation**: [Online Documentation](https://gesellix.github.io/Bose-SoundTouch/)
|
||||
- 🔍 **New Discoveries**: [Undocumented Community Features](https://gesellix.github.io/Bose-SoundTouch/UNDOCUMENTED-COMMUNITY-FEATURES.md)
|
||||
- 🌐 **Upstream Analysis**: [Upstream URLs & Domains](https://gesellix.github.io/Bose-SoundTouch/analysis/UPSTREAM-URLS.html)
|
||||
- 🔧 **Redirection Guide**: [Device Redirect Methods](https://gesellix.github.io/Bose-SoundTouch/analysis/DEVICE-REDIRECT-METHODS.html)
|
||||
- 🐣 **Initial Setup**: [Device Initial Setup Variants](https://gesellix.github.io/Bose-SoundTouch/guides/DEVICE-INITIAL-SETUP.html)
|
||||
- 📜 **Logging & Debugging**: [Device Logging Guide](https://gesellix.github.io/Bose-SoundTouch/DEVICE-LOGGING.md)
|
||||
- 🔒 **HTTPS & CA Setup**: [HTTPS & Custom CA Guide](https://gesellix.github.io/Bose-SoundTouch/guides/HTTPS-SETUP.html)
|
||||
- Bug reports: [GitHub Issues](https://github.com/gesellix/bose-soundtouch/issues/new)
|
||||
- Questions & discussions: [GitHub Discussions](https://github.com/gesellix/bose-soundtouch/discussions)
|
||||
|
||||
---
|
||||
|
||||
**Star this project** ⭐ if you find it useful!
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
SoundTouch is a trademark of Bose Corporation.
|
||||
|
||||
@@ -240,6 +240,12 @@ func main() {
|
||||
Value: true,
|
||||
EnvVars: []string{"RECORD_INTERACTIONS"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "discovery-enabled",
|
||||
Usage: "Enable periodic device discovery",
|
||||
Value: true,
|
||||
EnvVars: []string{"DISCOVERY_ENABLED"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "discovery-interval",
|
||||
Usage: "Device discovery interval",
|
||||
@@ -387,7 +393,7 @@ func main() {
|
||||
|
||||
config.domains = getDomains(config.serverURL, config.httpsServerURL, hostname)
|
||||
|
||||
cm := initCertificateManager(config.dataDir)
|
||||
cm := initCertificateManager(config.dataDir, config.hostname)
|
||||
sm := setup.NewManager(config.serverURL, ds, cm)
|
||||
sm.MgmtUsername = config.mgmtUsername
|
||||
sm.MgmtPassword = config.mgmtPassword
|
||||
@@ -395,7 +401,7 @@ func main() {
|
||||
sm.GetDNSRunning = server.GetDNSRunning
|
||||
server.SetHTTPServerURL(config.httpsServerURL)
|
||||
server.SetVersionInfo(version, commit, date, repoURL)
|
||||
server.SetDiscoverySettings(config.discoveryInterval, persisted.DiscoveryEnabled)
|
||||
server.SetDiscoverySettings(config.discoveryInterval, config.discoveryEnabled)
|
||||
server.SetDNSSettings(persisted.DNSEnabled, strings.Join(persisted.DNSUpstream, ","), persisted.DNSBindAddr)
|
||||
server.SetMirrorSettings(persisted.MirrorEnabled, persisted.MirrorEndpoints, persisted.SkipMirrorEndpoints, persisted.PreferredSource)
|
||||
server.SetInternalPaths(persisted.InternalPaths)
|
||||
@@ -460,20 +466,25 @@ func main() {
|
||||
|
||||
initializeDefaultSources(ds)
|
||||
|
||||
tlsConfig, err := cm.GetServerTLSConfig(config.domains)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to setup TLS: %v", err)
|
||||
}
|
||||
|
||||
startDeviceDiscovery(server)
|
||||
|
||||
r := setupRouter(server)
|
||||
|
||||
log.Printf("Go service starting on %s", config.serverURL)
|
||||
|
||||
if tlsConfig != nil {
|
||||
// TLS cert generation can be slow on constrained hardware; run it in the
|
||||
// background so the HTTP server is available immediately.
|
||||
log.Printf("HTTPS setup running in background; %s will be available shortly", config.httpsServerURL)
|
||||
|
||||
go func() {
|
||||
tlsConfig, err := cm.GetServerTLSConfig(config.domains)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to setup TLS: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
startHTTPSServer(config.httpsAddr, r, tlsConfig, config.httpsServerURL)
|
||||
}
|
||||
}()
|
||||
|
||||
return http.ListenAndServe(config.addr, r)
|
||||
},
|
||||
@@ -507,6 +518,7 @@ type serviceConfig struct {
|
||||
bindAddr string
|
||||
addr string
|
||||
dataDir string
|
||||
hostname string
|
||||
serverURL string
|
||||
httpsServerURL string
|
||||
httpsAddr string
|
||||
@@ -520,6 +532,7 @@ type serviceConfig struct {
|
||||
mirrorEndpoints []string
|
||||
skipMirrorEndpoints []string
|
||||
internalPaths []string
|
||||
discoveryEnabled bool
|
||||
discoveryInterval time.Duration
|
||||
domains []string
|
||||
spotifyClientID string
|
||||
@@ -584,6 +597,7 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
dnsUpstream := c.String("dns-upstream")
|
||||
dnsBind := c.String("dns-bind")
|
||||
|
||||
discoveryEnabled := c.Bool("discovery-enabled")
|
||||
discoveryIntervalStr := c.String("discovery-interval")
|
||||
|
||||
discoveryInterval, err := time.ParseDuration(discoveryIntervalStr)
|
||||
@@ -618,6 +632,7 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
bindAddr: bindAddr,
|
||||
addr: addr,
|
||||
dataDir: dataDir,
|
||||
hostname: hostname,
|
||||
serverURL: serverURL,
|
||||
httpsServerURL: httpsServerURL,
|
||||
httpsAddr: httpsAddr,
|
||||
@@ -631,6 +646,7 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
mirrorEndpoints: mirrorEndpoints,
|
||||
skipMirrorEndpoints: skipMirrorEndpoints,
|
||||
internalPaths: internalPaths,
|
||||
discoveryEnabled: discoveryEnabled,
|
||||
discoveryInterval: discoveryInterval,
|
||||
domains: domains,
|
||||
spotifyClientID: spotifyClientID,
|
||||
@@ -668,6 +684,7 @@ func getDomains(serverURL, httpsServerURL, hostname string) []string {
|
||||
"bose.io": true,
|
||||
"bose-prod.apigee.net": true,
|
||||
"bose-test.apigee.net": true,
|
||||
"downloads.bose.com": true,
|
||||
// Local service domains
|
||||
setup.TestDomain: true,
|
||||
hostname: true,
|
||||
@@ -712,6 +729,7 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
|
||||
config.httpsServerURL = persisted.HTTPServerURL
|
||||
}
|
||||
|
||||
config.discoveryEnabled = persisted.DiscoveryEnabled
|
||||
if persisted.DiscoveryInterval != "" {
|
||||
if d, durErr := time.ParseDuration(persisted.DiscoveryInterval); durErr == nil {
|
||||
config.discoveryInterval = d
|
||||
@@ -778,8 +796,8 @@ func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datast
|
||||
RedactLogs: config.redact,
|
||||
LogBodies: config.logBody,
|
||||
RecordInteractions: config.record,
|
||||
DiscoveryEnabled: config.discoveryEnabled,
|
||||
DiscoveryInterval: config.discoveryInterval.String(),
|
||||
DiscoveryEnabled: true,
|
||||
DNSEnabled: config.dnsEnabled,
|
||||
DNSUpstream: strings.Split(config.dnsUpstream, ","),
|
||||
DNSBindAddr: config.dnsBind,
|
||||
@@ -808,8 +826,10 @@ func initDataStore(dataDir string) *datastore.DataStore {
|
||||
return ds
|
||||
}
|
||||
|
||||
func initCertificateManager(dataDir string) *certmanager.CertificateManager {
|
||||
func initCertificateManager(dataDir, hostname string) *certmanager.CertificateManager {
|
||||
cm := certmanager.NewCertificateManager(filepath.Join(dataDir, "certs"))
|
||||
|
||||
cm.CommonName = hostname
|
||||
if err := cm.EnsureCA(); err != nil {
|
||||
log.Printf("Warning: Failed to ensure CA: %v", err)
|
||||
}
|
||||
@@ -847,7 +867,10 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
})
|
||||
|
||||
r.Get("/media/*", server.HandleMedia())
|
||||
r.Get("/bmx-icons/*", server.HandleBmxIcons())
|
||||
r.Get("/ced/*", server.HandleCedStatic())
|
||||
r.Get("/web/*", server.HandleWeb())
|
||||
r.Post("/alexa/certificate", server.HandleAlexaCertificate)
|
||||
r.Get("/docs/*", server.HandleDocs)
|
||||
|
||||
r.Route("/bmx", func(r chi.Router) {
|
||||
@@ -863,9 +886,12 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Get("/v1/navigate", server.HandleTuneInNavigate)
|
||||
r.Get("/v1/navigate/*", server.HandleTuneInNavigate)
|
||||
r.Get("/v1/search", server.HandleTuneInSearch)
|
||||
r.Post("/v1/favorite/{stationID}", server.HandleTuneInFavorite)
|
||||
r.Delete("/v1/favorite/{stationID}", server.HandleTuneInDeleteFavorite)
|
||||
})
|
||||
|
||||
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
|
||||
r.Post("/core02/svc-bmx-adapter-orion/prod/orion/token", server.HandleOrionToken)
|
||||
})
|
||||
|
||||
r.Get("/custom/v1/playback/{encodedURL}", server.HandleCustomPlayback)
|
||||
@@ -932,6 +958,7 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Route("/music", func(r chi.Router) {
|
||||
r.Route("/musicprovider/{providerID}", func(r chi.Router) {
|
||||
r.Post("/is_eligible", server.HandleMusicProviderIsEligible)
|
||||
r.Post("/trial/is_eligible", server.HandleMusicProviderIsEligible)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
CONNECT /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
DELETE /accounts/{account}/devices/{device} handlers.(*Server).HandleMargeRemoveDevice-fm
|
||||
DELETE /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
|
||||
DELETE /bmx/tunein/v1/favorite/{stationID} handlers.(*Server).HandleTuneInDeleteFavorite-fm
|
||||
DELETE /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
DELETE /setup/devices/{deviceId} handlers.(*Server).HandleRemoveDevice-fm
|
||||
DELETE /setup/dns-discoveries handlers.(*Server).HandleClearDNSDiscoveries-fm
|
||||
@@ -19,6 +20,7 @@ GET /accounts/{account}/devices/{device}/presets handlers.(
|
||||
GET /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleMargeRecents-fm
|
||||
GET /accounts/{account}/full handlers.(*Server).HandleMargeAccountFull-fm
|
||||
GET /accounts/{account}/sources handlers.(*Server).HandleMargeAccountSources-fm
|
||||
GET /bmx-icons/* handlers.(*Server).HandleBmxIcons
|
||||
GET /bmx/registry/v1/services handlers.(*Server).HandleBMXRegistry-fm
|
||||
GET /bmx/registry/v1/servicesAvailability handlers.(*Server).HandleBMXServicesAvailability-fm
|
||||
GET /bmx/tunein/v1/navigate handlers.(*Server).HandleTuneInNavigate-fm
|
||||
@@ -27,6 +29,7 @@ GET /bmx/tunein/v1/playback/episode/{podcastID} handlers.(
|
||||
GET /bmx/tunein/v1/playback/episodes/{podcastID} handlers.(*Server).HandleTuneInPodcastInfo-fm
|
||||
GET /bmx/tunein/v1/playback/station/{stationID} handlers.(*Server).HandleTuneInPlayback-fm
|
||||
GET /bmx/tunein/v1/search handlers.(*Server).HandleTuneInSearch-fm
|
||||
GET /ced/* handlers.(*Server).HandleCedStatic
|
||||
GET /custom/v1/playback/{encodedURL} handlers.(*Server).HandleCustomPlayback-fm
|
||||
GET /customer/account/{account} handlers.(*Server).HandleMargeAccountProfile-fm
|
||||
GET /docs/* handlers.(*Server).HandleDocs-fm
|
||||
@@ -91,7 +94,10 @@ POST /accounts/{account}/devices/{device}/presets/{presetNumber} handlers.(
|
||||
POST /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleMargeAddRecent-fm
|
||||
POST /accounts/{account}/group handlers.(*Server).HandleMargeAddGroup-fm
|
||||
POST /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeModifyGroup-fm
|
||||
POST /alexa/certificate handlers.(*Server).HandleAlexaCertificate-fm
|
||||
POST /bmx/core02/svc-bmx-adapter-orion/prod/orion/token handlers.(*Server).HandleOrionToken-fm
|
||||
POST /bmx/orion/v1/playback/station/{data} handlers.(*Server).HandleOrionPlayback-fm
|
||||
POST /bmx/tunein/v1/favorite/{stationID} handlers.(*Server).HandleTuneInFavorite-fm
|
||||
POST /bmx/tunein/v1/report handlers.(*Server).HandleTuneInReport-fm
|
||||
POST /bmx/tunein/v1/token handlers.(*Server).HandleTuneInToken-fm
|
||||
POST /customer/account/{account} handlers.(*Server).HandleMargeUpdateAccountProfile-fm
|
||||
@@ -136,6 +142,7 @@ POST /streaming/account/{account}/group/{groupId} handlers.(
|
||||
POST /streaming/account/{account}/source handlers.(*Server).HandleMargeAddSource-fm
|
||||
POST /streaming/device_setting/account/{account}/device/{device}/device_settings handlers.(*Server).HandleMargeUpdateDeviceSettings-fm
|
||||
POST /streaming/music/musicprovider/{providerID}/is_eligible handlers.(*Server).HandleMusicProviderIsEligible-fm
|
||||
POST /streaming/music/musicprovider/{providerID}/trial/is_eligible handlers.(*Server).HandleMusicProviderIsEligible-fm
|
||||
POST /streaming/stats/error handlers.(*Server).HandleErrorStats-fm
|
||||
POST /streaming/stats/usage handlers.(*Server).HandleUsageStats-fm
|
||||
POST /streaming/support/customersupport handlers.(*Server).HandleMargeCustomerSupport-fm
|
||||
|
||||
@@ -1,648 +0,0 @@
|
||||
// Package handlers contains HTTP handlers for the SoundTouch web UI.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
bmxpkg "github.com/gesellix/bose-soundtouch/pkg/service/bmx"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// WebApp holds the application state and dependencies
|
||||
type WebApp struct {
|
||||
Devices map[string]*webtypes.DeviceConnection
|
||||
Upgrader websocket.Upgrader
|
||||
WSClients map[*websocket.Conn]bool
|
||||
WSMutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewWebApp creates a new WebApp instance for SPA mode
|
||||
func NewWebApp() *WebApp {
|
||||
return &WebApp{
|
||||
Devices: make(map[string]*webtypes.DeviceConnection),
|
||||
WSClients: make(map[*websocket.Conn]bool),
|
||||
Upgrader: websocket.Upgrader{
|
||||
CheckOrigin: func(_ *http.Request) bool { return true },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIDevices returns all devices as JSON
|
||||
func (app *WebApp) HandleAPIDevices(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Return all devices as JSON
|
||||
devices := make(map[string]interface{})
|
||||
for id, device := range app.Devices {
|
||||
devices[id] = map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
"lastSeen": device.LastSeen,
|
||||
}
|
||||
}
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: devices,
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIDevice returns a specific device as JSON
|
||||
func (app *WebApp) HandleAPIDevice(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
if deviceID == "" {
|
||||
app.sendError(w, "Device ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Update device status to get fresh power state
|
||||
app.UpdateDeviceStatus(deviceID, device)
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
},
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIControl handles device control commands
|
||||
func (app *WebApp) HandleAPIControl(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
action := chi.URLParam(r, "action")
|
||||
|
||||
if deviceID == "" {
|
||||
app.sendError(w, "Device ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
app.handleControlAction(w, r, action, device)
|
||||
}
|
||||
|
||||
// handleControlAction processes different control actions
|
||||
func (app *WebApp) handleControlAction(w http.ResponseWriter, r *http.Request, action string, device *webtypes.DeviceConnection) {
|
||||
switch action {
|
||||
case "play":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.Play()
|
||||
app.sendControlResponse(w, err, "Started playback")
|
||||
case "pause":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.Pause()
|
||||
app.sendControlResponse(w, err, "Paused playback")
|
||||
case "stop":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.Stop()
|
||||
app.sendControlResponse(w, err, "Stopped playback")
|
||||
case "next":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.NextTrack()
|
||||
app.sendControlResponse(w, err, "Next track")
|
||||
case "previous":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.PrevTrack()
|
||||
app.sendControlResponse(w, err, "Previous track")
|
||||
case "volume":
|
||||
app.handleVolumeControl(w, r, device)
|
||||
case "mute":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.SendKey(models.KeyMute)
|
||||
app.sendControlResponse(w, err, "Toggled mute")
|
||||
case "preset":
|
||||
app.handlePresetControl(w, r, device)
|
||||
case "bass":
|
||||
app.handleBassControl(w, r, device)
|
||||
case "source":
|
||||
app.handleSourceControl(w, r, device)
|
||||
default:
|
||||
app.sendError(w, "Unknown action", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// handleVolumeControl processes volume control requests
|
||||
func (app *WebApp) handleVolumeControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
|
||||
if r.Method != http.MethodPost {
|
||||
app.sendError(w, "POST required for volume control", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var volumeReq webtypes.VolumeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&volumeReq); err != nil {
|
||||
app.sendError(w, "Invalid volume data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if volumeReq.Level < 0 || volumeReq.Level > 100 {
|
||||
app.sendError(w, "Volume must be between 0 and 100", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.SetVolume(volumeReq.Level)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Volume set to %d", volumeReq.Level))
|
||||
}
|
||||
|
||||
// handlePresetControl processes preset control requests
|
||||
func (app *WebApp) handlePresetControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
|
||||
presetParam := r.URL.Query().Get("id")
|
||||
if presetParam == "" {
|
||||
app.sendError(w, "Preset ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
presetID, err := strconv.Atoi(presetParam)
|
||||
if err != nil {
|
||||
app.sendError(w, "Invalid preset ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = device.Client.SelectPreset(presetID)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Selected preset %d", presetID))
|
||||
}
|
||||
|
||||
// handleBassControl processes bass control requests
|
||||
func (app *WebApp) handleBassControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
|
||||
if r.Method != http.MethodPost {
|
||||
app.sendError(w, "POST required for bass control", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var bassReq webtypes.BassRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&bassReq); err != nil {
|
||||
app.sendError(w, "Invalid bass data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if bassReq.Level < -9 || bassReq.Level > 9 {
|
||||
app.sendError(w, "Bass must be between -9 and 9", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.SetBass(bassReq.Level)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Bass set to %d", bassReq.Level))
|
||||
}
|
||||
|
||||
// handleSourceControl processes source control requests
|
||||
func (app *WebApp) handleSourceControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
|
||||
sourceParam := r.URL.Query().Get("name")
|
||||
if sourceParam == "" {
|
||||
app.sendError(w, "Source name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.SelectSource(sourceParam, "")
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Selected source %s", sourceParam))
|
||||
}
|
||||
|
||||
// sendControlResponse sends a control command response
|
||||
func (app *WebApp) sendControlResponse(w http.ResponseWriter, err error, successMessage string) {
|
||||
if err != nil {
|
||||
app.sendError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]string{"message": successMessage},
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// sendError sends an error response
|
||||
func (app *WebApp) sendError(w http.ResponseWriter, message string, statusCode int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: false,
|
||||
Error: message,
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode error response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDeviceKey handles sending key commands to devices
|
||||
func (app *WebApp) HandleDeviceKey(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
key := chi.URLParam(r, "key")
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
err := device.Client.SendKey(key)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Sent key command: %s", key))
|
||||
}
|
||||
|
||||
// HandleDirectVolumeControl handles direct volume setting via URL parameter
|
||||
func (app *WebApp) HandleDirectVolumeControl(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
|
||||
volumeLevel, err := strconv.Atoi(chi.URLParam(r, "volume"))
|
||||
if err != nil || volumeLevel < 0 || volumeLevel > 100 {
|
||||
app.sendError(w, "Invalid volume level (0-100)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
err = device.Client.SetVolume(volumeLevel)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Volume set to %d", volumeLevel))
|
||||
}
|
||||
|
||||
// HandleDevicePower handles power toggle commands for devices
|
||||
func (app *WebApp) HandleDevicePower(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Send POWER key command to toggle device power
|
||||
err := device.Client.SendKey("POWER")
|
||||
app.sendControlResponse(w, err, "Power toggle command sent")
|
||||
}
|
||||
|
||||
// HandleDevicePowerStatus handles lightweight power status check
|
||||
func (app *WebApp) HandleDevicePowerStatus(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Quick power status check by getting now playing
|
||||
nowPlaying, err := device.Client.GetNowPlaying()
|
||||
if err != nil {
|
||||
app.sendControlResponse(w, err, "Failed to get power status")
|
||||
return
|
||||
}
|
||||
|
||||
isPoweredOn := nowPlaying != nil && nowPlaying.Source != "STANDBY"
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]interface{}{
|
||||
"deviceId": deviceID,
|
||||
"isPoweredOn": isPoweredOn,
|
||||
"source": nowPlaying.Source,
|
||||
},
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastDeviceList sends updated device list to all connected WebSocket clients
|
||||
func (app *WebApp) BroadcastDeviceList() {
|
||||
app.WSMutex.RLock()
|
||||
defer app.WSMutex.RUnlock()
|
||||
|
||||
devices := make(map[string]interface{})
|
||||
for id, device := range app.Devices {
|
||||
devices[id] = map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
"lastSeen": device.LastSeen,
|
||||
}
|
||||
}
|
||||
|
||||
message := webtypes.WebSocketMessage{
|
||||
Type: "devices",
|
||||
Data: devices,
|
||||
}
|
||||
|
||||
// Send to all connected clients
|
||||
var failedClients []*websocket.Conn
|
||||
|
||||
for client := range app.WSClients {
|
||||
if err := client.WriteJSON(message); err != nil {
|
||||
log.Printf("Failed to send device update to WebSocket client: %v", err)
|
||||
// Mark for removal to avoid modifying map during iteration
|
||||
failedClients = append(failedClients, client)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove failed clients
|
||||
for _, client := range failedClients {
|
||||
delete(app.WSClients, client)
|
||||
client.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastDiscoveryStatus sends discovery progress updates to all connected WebSocket clients
|
||||
func (app *WebApp) BroadcastDiscoveryStatus(status string, deviceCount int) {
|
||||
app.WSMutex.RLock()
|
||||
defer app.WSMutex.RUnlock()
|
||||
|
||||
message := webtypes.WebSocketMessage{
|
||||
Type: "discovery_status",
|
||||
Data: map[string]interface{}{
|
||||
"status": status,
|
||||
"deviceCount": deviceCount,
|
||||
},
|
||||
}
|
||||
|
||||
// Send to all connected clients
|
||||
var failedClients []*websocket.Conn
|
||||
|
||||
for client := range app.WSClients {
|
||||
if err := client.WriteJSON(message); err != nil {
|
||||
log.Printf("Failed to send discovery status to WebSocket client: %v", err)
|
||||
// Mark for removal to avoid modifying map during iteration
|
||||
failedClients = append(failedClients, client)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove failed clients
|
||||
for _, client := range failedClients {
|
||||
delete(app.WSClients, client)
|
||||
client.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInSearch handles TuneIn search requests, proxying directly to the bmx package.
|
||||
func (app *WebApp) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("q")
|
||||
if query == "" {
|
||||
app.sendError(w, "query parameter 'q' is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := bmxpkg.TuneInSearch(query)
|
||||
if err != nil {
|
||||
app.sendError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: resp}); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInNavigate handles TuneIn browse/navigate requests, proxying directly to the bmx package.
|
||||
// Supported path suffixes (relative to /api/tunein/navigate):
|
||||
// - (empty) → top-level browse
|
||||
// - /{encodedURI} → browse the given TuneIn URI
|
||||
// - /sub/{n}/{encodedURI} → single subsection
|
||||
// - /profiles/{type}/{id}/{encodedURI} → artist/program profile
|
||||
func (app *WebApp) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request) {
|
||||
wildcard := chi.URLParam(r, "*")
|
||||
|
||||
var (
|
||||
resp interface{}
|
||||
err error
|
||||
)
|
||||
|
||||
if wildcard == "" {
|
||||
resp, err = bmxpkg.TuneInNavigate("", nil)
|
||||
} else {
|
||||
firstSlash := strings.Index(wildcard, "/")
|
||||
if firstSlash == -1 {
|
||||
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
|
||||
} else {
|
||||
pfx := wildcard[:firstSlash]
|
||||
rest := wildcard[firstSlash+1:]
|
||||
|
||||
switch pfx {
|
||||
case "sub":
|
||||
secondSlash := strings.Index(rest, "/")
|
||||
if secondSlash == -1 {
|
||||
resp, err = bmxpkg.TuneInNavigate(rest, nil)
|
||||
} else {
|
||||
n, parseErr := strconv.Atoi(rest[:secondSlash])
|
||||
if parseErr != nil {
|
||||
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
|
||||
} else {
|
||||
resp, err = bmxpkg.TuneInNavigate(rest[secondSlash+1:], &n)
|
||||
}
|
||||
}
|
||||
case "profiles":
|
||||
parts := strings.SplitN(rest, "/", 3)
|
||||
if len(parts) < 3 {
|
||||
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
|
||||
} else {
|
||||
resp, err = bmxpkg.TuneInNavigateProfile(parts[2])
|
||||
}
|
||||
default:
|
||||
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
app.sendError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: resp}); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandlePlayTuneIn plays a TuneIn content item on a specific device via POST /select.
|
||||
func (app *WebApp) HandlePlayTuneIn(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
if deviceID == "" {
|
||||
app.sendError(w, "Device ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, fmt.Sprintf("Device '%s' not found", deviceID), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Location string `json:"location"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
ContainerArt string `json:"containerArt"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
app.sendError(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Location == "" {
|
||||
app.sendError(w, "location is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
itemType := req.Type
|
||||
if itemType == "" {
|
||||
itemType = "stationurl"
|
||||
}
|
||||
|
||||
contentItem := &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
Type: itemType,
|
||||
Location: req.Location,
|
||||
ItemName: req.Name,
|
||||
IsPresetable: true,
|
||||
ContainerArt: req.ContainerArt,
|
||||
}
|
||||
|
||||
if err := device.Client.SelectContentItem(contentItem); err != nil {
|
||||
app.sendError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: map[string]string{"message": "Playing " + req.Name}}); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -2,26 +2,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/handlers"
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/config"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
var staticFS embed.FS
|
||||
|
||||
func main() {
|
||||
app := &cli.App{
|
||||
Name: "soundtouch-web",
|
||||
@@ -49,36 +38,10 @@ func main() {
|
||||
addr = bindAddr + ":" + port
|
||||
}
|
||||
|
||||
// Create web app without templates (SPA mode)
|
||||
webApp := handlers.NewWebApp()
|
||||
webApp := soundtouchweb.New()
|
||||
|
||||
// Initialize discovery service
|
||||
cfg, err := config.LoadFromEnv()
|
||||
if err != nil {
|
||||
log.Printf("Failed to load config: %v, using defaults", err)
|
||||
|
||||
cfg = config.DefaultConfig()
|
||||
}
|
||||
|
||||
cfg.DiscoveryTimeout = 10 * time.Second
|
||||
cfg.CacheEnabled = true
|
||||
|
||||
discoveryService := discovery.NewUnifiedDiscoveryService(cfg)
|
||||
|
||||
// Discover devices on startup
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
webApp.BroadcastDiscoveryStatus("starting", len(webApp.Devices))
|
||||
|
||||
discoverDevices(ctx, webApp, discoveryService)
|
||||
|
||||
webApp.BroadcastDiscoveryStatus("completed", len(webApp.Devices))
|
||||
webApp.BroadcastDeviceList()
|
||||
}()
|
||||
|
||||
r := setupRoutes(webApp, discoveryService)
|
||||
r := chi.NewRouter()
|
||||
webApp.Mount(r)
|
||||
|
||||
log.Printf("SoundTouch Web UI starting on http://%s", addr)
|
||||
|
||||
@@ -90,126 +53,3 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func setupRoutes(app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Static assets (embedded in binary)
|
||||
subFS, _ := fs.Sub(staticFS, "static")
|
||||
r.Get("/static/*", http.StripPrefix("/static", http.FileServer(http.FS(subFS))).ServeHTTP)
|
||||
|
||||
// Serve index.html for SPA routes
|
||||
serveIndex := func(w http.ResponseWriter, _ *http.Request) {
|
||||
data, _ := staticFS.ReadFile("static/index.html")
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// WebSocket endpoint
|
||||
r.Get("/ws", app.HandleWebSocket)
|
||||
|
||||
// API endpoints
|
||||
r.Get("/api/devices", app.HandleAPIDevices)
|
||||
r.Get("/api/device/{id}", app.HandleAPIDevice)
|
||||
r.Post("/api/discover", func(w http.ResponseWriter, r *http.Request) {
|
||||
app.HandleAPIDiscover(w, r)
|
||||
// Trigger discovery
|
||||
//nolint:contextcheck // Context is created within goroutine
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Broadcast discovery start
|
||||
app.BroadcastDiscoveryStatus("starting", len(app.Devices))
|
||||
|
||||
discoverDevices(ctx, app, discoveryService)
|
||||
|
||||
// Broadcast discovery completion and updated device list
|
||||
app.BroadcastDiscoveryStatus("completed", len(app.Devices))
|
||||
app.BroadcastDeviceList()
|
||||
}()
|
||||
})
|
||||
|
||||
// Device control endpoints (GET for most actions, POST for volume/bass)
|
||||
r.Get("/api/control/{id}/{action}", app.HandleAPIControl)
|
||||
r.Post("/api/control/{id}/{action}", app.HandleAPIControl)
|
||||
|
||||
// TuneIn browse, search, and playback
|
||||
r.Get("/api/tunein/search", app.HandleTuneInSearch)
|
||||
r.Get("/api/tunein/navigate", app.HandleTuneInNavigate)
|
||||
r.Get("/api/tunein/navigate/*", app.HandleTuneInNavigate)
|
||||
r.Post("/api/tunein/play/{id}", app.HandlePlayTuneIn)
|
||||
|
||||
// Enhanced device control endpoints
|
||||
r.Post("/api/device-key/{id}/{key}", app.HandleDeviceKey)
|
||||
r.Post("/api/device-volume/{id}/{volume}", app.HandleDirectVolumeControl)
|
||||
r.Post("/api/device-power/{id}", app.HandleDevicePower)
|
||||
r.Get("/api/device-power-status/{id}", app.HandleDevicePowerStatus)
|
||||
r.Get("/api/device-ws/{id}", app.HandleDeviceWebSocket)
|
||||
|
||||
// SPA routes - serve index.html for client-side routing
|
||||
r.Get("/", serveIndex)
|
||||
r.Get("/devices", serveIndex)
|
||||
r.Get("/device/*", serveIndex)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func discoverDevices(ctx context.Context, app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) {
|
||||
log.Println("Starting device discovery...")
|
||||
|
||||
devices, err := discoveryService.DiscoverDevices(ctx)
|
||||
if err != nil {
|
||||
log.Printf("Discovery failed: %v", err)
|
||||
app.BroadcastDiscoveryStatus("failed", len(app.Devices))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Found %d devices", len(devices))
|
||||
|
||||
for _, device := range devices {
|
||||
deviceID := device.Host // Use host as unique ID for now
|
||||
|
||||
// Skip if we already have this device
|
||||
if _, exists := app.Devices[deviceID]; exists {
|
||||
app.Devices[deviceID].LastSeen = time.Now()
|
||||
continue
|
||||
}
|
||||
|
||||
// Create new device connection
|
||||
clientConfig := &client.Config{
|
||||
Host: device.Host,
|
||||
Port: device.Port,
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
soundTouchClient := client.NewClient(clientConfig)
|
||||
|
||||
// Get device info
|
||||
deviceInfo, err := soundTouchClient.GetDeviceInfo()
|
||||
if err != nil {
|
||||
log.Printf("Failed to get device info for %s: %v", device.Host, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create device connection
|
||||
conn := &webtypes.DeviceConnection{
|
||||
Client: soundTouchClient,
|
||||
DeviceInfo: deviceInfo,
|
||||
LastSeen: time.Now(),
|
||||
Status: webtypes.DeviceStatus{
|
||||
IsConnected: false,
|
||||
LastActivity: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
// Initial status fetch asynchronously to avoid blocking discovery
|
||||
go app.UpdateDeviceStatus(deviceID, conn)
|
||||
|
||||
app.Devices[deviceID] = conn
|
||||
|
||||
log.Printf("Added device: %s (%s) at %s", deviceInfo.Name, deviceInfo.Type, device.Host)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/handlers"
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
@@ -55,22 +55,19 @@ func TestSPARouting(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", tt.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Simulate SPA routing handler
|
||||
spaHandler := func(w http.ResponseWriter, r *http.Request) {
|
||||
// If it's an API route, let it pass through
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/static/") || strings.HasPrefix(r.URL.Path, "/ws") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Serve the SPA index.html content (simulated)
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>SoundTouch Control Center</title>
|
||||
<title>SoundTouch Web</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">SPA Content</div>
|
||||
@@ -100,7 +97,7 @@ func TestSPARouting(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAPIEndpoints(t *testing.T) {
|
||||
app := handlers.NewWebApp()
|
||||
app := soundtouchweb.NewWebApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -127,7 +124,7 @@ func TestAPIEndpoints(t *testing.T) {
|
||||
name: "device API with ID",
|
||||
path: "/api/device/test-device",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusNotFound, // Device won't exist in test
|
||||
expectedStatus: http.StatusNotFound,
|
||||
expectedJSON: true,
|
||||
},
|
||||
}
|
||||
@@ -160,7 +157,6 @@ func TestAPIEndpoints(t *testing.T) {
|
||||
t.Errorf("Expected JSON content type, got %s", contentType)
|
||||
}
|
||||
|
||||
// Validate JSON response structure
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Errorf("Invalid JSON response: %v", err)
|
||||
@@ -171,7 +167,7 @@ func TestAPIEndpoints(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAPIResponseFormat(t *testing.T) {
|
||||
app := handlers.NewWebApp()
|
||||
app := soundtouchweb.NewWebApp()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/devices", nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -183,7 +179,6 @@ func TestAPIResponseFormat(t *testing.T) {
|
||||
t.Fatalf("Failed to decode JSON response: %v", err)
|
||||
}
|
||||
|
||||
// Check API response structure
|
||||
if !response.Success {
|
||||
t.Errorf("Expected success=true, got success=%v", response.Success)
|
||||
}
|
||||
@@ -192,7 +187,6 @@ func TestAPIResponseFormat(t *testing.T) {
|
||||
t.Errorf("Expected data field to be present")
|
||||
}
|
||||
|
||||
// Data should be an empty map for no devices
|
||||
dataMap, ok := response.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Errorf("Expected data to be a map, got %T", response.Data)
|
||||
@@ -204,7 +198,7 @@ func TestAPIResponseFormat(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestControlAPIValidation(t *testing.T) {
|
||||
app := handlers.NewWebApp()
|
||||
app := soundtouchweb.NewWebApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -249,7 +243,6 @@ func TestControlAPIValidation(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
// Add a mock device for testing unknown action validation
|
||||
mockDevice := &webtypes.DeviceConnection{
|
||||
Client: nil,
|
||||
DeviceInfo: &models.DeviceInfo{Name: "Test Device"},
|
||||
@@ -278,7 +271,6 @@ func TestControlAPIValidation(t *testing.T) {
|
||||
t.Errorf("Test %s: Expected status %d, got %d. Response: %s", tt.name, tt.expectedStatus, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Validate error response format
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "application/json") {
|
||||
t.Errorf("Expected JSON content type, got %s", contentType)
|
||||
@@ -301,9 +293,8 @@ func TestControlAPIValidation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWebSocketUpgrade(t *testing.T) {
|
||||
app := handlers.NewWebApp()
|
||||
app := soundtouchweb.NewWebApp()
|
||||
|
||||
// Test WebSocket upgrade request
|
||||
req := httptest.NewRequest("GET", "/ws", nil)
|
||||
req.Header.Set("Connection", "upgrade")
|
||||
req.Header.Set("Upgrade", "websocket")
|
||||
@@ -312,16 +303,11 @@ func TestWebSocketUpgrade(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// The actual WebSocket upgrade will fail in test environment,
|
||||
// but we can check that the handler exists and accepts the request
|
||||
app.HandleWebSocket(w, req)
|
||||
|
||||
// In a real test environment, this would fail with a websocket upgrade error
|
||||
// We're just checking the handler doesn't panic and processes the request
|
||||
}
|
||||
|
||||
func TestJSONAPIConsistency(t *testing.T) {
|
||||
app := handlers.NewWebApp()
|
||||
app := soundtouchweb.NewWebApp()
|
||||
|
||||
endpoints := []string{
|
||||
"/api/devices",
|
||||
@@ -344,19 +330,16 @@ func TestJSONAPIConsistency(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// All API endpoints should return JSON
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "application/json") {
|
||||
t.Errorf("Endpoint %s should return JSON, got %s", endpoint, contentType)
|
||||
}
|
||||
|
||||
// All responses should follow APIResponse structure
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Errorf("Endpoint %s returned invalid JSON: %v", endpoint, err)
|
||||
}
|
||||
|
||||
// Response should have either data or error
|
||||
if response.Success && response.Data == nil {
|
||||
t.Errorf("Endpoint %s: success response should have data", endpoint)
|
||||
}
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SoundTouch Control Center</title>
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link href="/static/css/app.css" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="#" onclick="showPage('devices')">
|
||||
<i class="bi bi-speaker"></i>
|
||||
SoundTouch Control
|
||||
</a>
|
||||
<div class="navbar-nav ms-auto">
|
||||
<a
|
||||
class="nav-link"
|
||||
href="#"
|
||||
onclick="showPage('devices')"
|
||||
title="Home"
|
||||
>
|
||||
<i class="bi bi-house"></i>
|
||||
</a>
|
||||
<a
|
||||
class="nav-link tunein-nav-link"
|
||||
href="#"
|
||||
onclick="showPage('tunein')"
|
||||
title="TuneIn Browse"
|
||||
>
|
||||
<img
|
||||
src="/static/img/tunein-mono.svg"
|
||||
alt="TuneIn"
|
||||
class="tunein-nav-icon"
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
class="nav-link"
|
||||
href="#"
|
||||
onclick="discoverDevices()"
|
||||
title="Discover Devices"
|
||||
>
|
||||
<i class="bi bi-search"></i>
|
||||
</a>
|
||||
<button
|
||||
class="theme-toggle nav-link"
|
||||
onclick="toggleTheme()"
|
||||
title="Toggle Dark Mode"
|
||||
>
|
||||
<i id="theme-icon" class="bi bi-moon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-4">
|
||||
<!-- Device List Page -->
|
||||
<div id="devices-page" class="page active">
|
||||
<div
|
||||
class="d-flex justify-content-between align-items-center mb-4"
|
||||
>
|
||||
<h2>Your SoundTouch Devices</h2>
|
||||
<button class="btn btn-primary" onclick="discoverDevices()">
|
||||
<i class="bi bi-search"></i>
|
||||
Discover Devices
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="devices-loading" class="loading-spinner"></div>
|
||||
|
||||
<div id="devices-list" class="row">
|
||||
<!-- Device cards will be inserted here by JavaScript -->
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="no-devices"
|
||||
style="display: none"
|
||||
class="text-center py-5"
|
||||
>
|
||||
<i class="bi bi-speaker display-1 text-muted"></i>
|
||||
<h4 class="mt-3">No Devices Found</h4>
|
||||
<p class="text-muted">
|
||||
Click "Discover Devices" to search for SoundTouch
|
||||
speakers on your network.
|
||||
</p>
|
||||
<button class="btn btn-primary" onclick="discoverDevices()">
|
||||
<i class="bi bi-search"></i>
|
||||
Start Discovery
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TuneIn Browse Page -->
|
||||
<div id="tunein-page" class="page">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2><img src="/static/img/tunein-dark.svg" alt="TuneIn" class="tunein-heading-icon me-2" />TuneIn Browse</h2>
|
||||
</div>
|
||||
|
||||
<div class="tunein-search-bar mb-3">
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="text"
|
||||
id="tunein-search-input"
|
||||
class="form-control"
|
||||
placeholder="Search stations, podcasts..."
|
||||
/>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick="tuneInSearch(document.getElementById('tunein-search-input').value)"
|
||||
>
|
||||
<i class="bi bi-search"></i>
|
||||
Search
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
onclick="tuneInBrowse()"
|
||||
title="Browse top level"
|
||||
>
|
||||
<i class="bi bi-house"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav id="tunein-breadcrumb" class="mb-3" style="display: none">
|
||||
<!-- filled by JavaScript -->
|
||||
</nav>
|
||||
|
||||
<div id="tunein-results">
|
||||
<!-- filled by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Control Page -->
|
||||
<div id="device-page" class="page">
|
||||
<div class="back-button">
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
onclick="showPage('devices')"
|
||||
>
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
Back to Devices
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="device-content">
|
||||
<!-- Device control content will be inserted here by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container text-center">
|
||||
<small>
|
||||
SoundTouch Web Control Interface -
|
||||
<a
|
||||
href="https://github.com/gesellix/Bose-SoundTouch"
|
||||
target="_blank"
|
||||
class="text-decoration-none"
|
||||
>
|
||||
Open Source Project
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Toast container for notifications -->
|
||||
<div class="toast-container"></div>
|
||||
|
||||
<!-- Device picker for TuneIn playback -->
|
||||
<div class="modal fade" id="devicePickerModal" tabindex="-1" aria-labelledby="devicePickerLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header py-2">
|
||||
<h6 class="modal-title" id="devicePickerLabel">
|
||||
<i class="bi bi-speaker me-2"></i>Play on device
|
||||
</h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body p-2" id="devicePickerList">
|
||||
<!-- device buttons filled by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- Application JavaScript -->
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,4 +1,5 @@
|
||||
accounts/
|
||||
backend/
|
||||
certs/
|
||||
default/
|
||||
dns/
|
||||
|
||||
@@ -3,6 +3,8 @@ services:
|
||||
build:
|
||||
context: .
|
||||
target: soundtouch-service
|
||||
networks:
|
||||
- soundtouch-test-net
|
||||
volumes:
|
||||
- ./tests/integration/testdata:/app/data
|
||||
environment:
|
||||
@@ -14,3 +16,31 @@ services:
|
||||
- AMAZON_CLIENT_SECRET=mock-amazon-secret
|
||||
- AMAZON_TOKEN_URL=http://amazon-mock:8080/auth/o2/token
|
||||
- AMAZON_PROFILE_URL=http://amazon-mock:8080/user/profile
|
||||
|
||||
spotify-mock:
|
||||
image: golang:1.26.3-alpine
|
||||
container_name: spotify-mock
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
command: go run ./cmd/mock-spotify/main.go -port 8080
|
||||
ports:
|
||||
- "8081:8080"
|
||||
networks:
|
||||
- soundtouch-test-net
|
||||
|
||||
amazon-mock:
|
||||
image: golang:1.26.3-alpine
|
||||
container_name: amazon-mock
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
command: go run ./cmd/mock-amazon/main.go -port 8080
|
||||
ports:
|
||||
- "8082:8080"
|
||||
networks:
|
||||
- soundtouch-test-net
|
||||
|
||||
networks:
|
||||
soundtouch-test-net:
|
||||
name: soundtouch-test-net
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
soundtouch-service:
|
||||
image: ghcr.io/gesellix/bose-soundtouch:latest
|
||||
image: ghcr.io/gesellix/bose-soundtouch:${SOUNDTOUCH_VERSION:-latest}
|
||||
# build: .
|
||||
container_name: soundtouch-service
|
||||
# Linux only, required for discovery. Swarm requires host network at the task level.
|
||||
@@ -8,8 +8,6 @@ services:
|
||||
ports:
|
||||
- "8000:8000"
|
||||
- "8443:8443"
|
||||
networks:
|
||||
- soundtouch-test-net
|
||||
environment:
|
||||
- PORT=8000
|
||||
- HTTPS_PORT=8443
|
||||
@@ -37,34 +35,6 @@ services:
|
||||
cpus: '0.25'
|
||||
memory: 128M
|
||||
|
||||
spotify-mock:
|
||||
image: golang:1.26.2-alpine
|
||||
container_name: spotify-mock
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
command: go run ./cmd/mock-spotify/main.go -port 8080
|
||||
ports:
|
||||
- "8081:8080"
|
||||
networks:
|
||||
- soundtouch-test-net
|
||||
|
||||
amazon-mock:
|
||||
image: golang:1.26.2-alpine
|
||||
container_name: amazon-mock
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
command: go run ./cmd/mock-amazon/main.go -port 8080
|
||||
ports:
|
||||
- "8082:8080"
|
||||
networks:
|
||||
- soundtouch-test-net
|
||||
|
||||
networks:
|
||||
soundtouch-test-net:
|
||||
name: soundtouch-test-net
|
||||
|
||||
volumes:
|
||||
soundtouch-data:
|
||||
# Named volumes are preferred in Swarm. For multi-node persistence,
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
* [Getting Started](guides/GETTING-STARTED.md)
|
||||
* [SoundTouch Service](guides/SOUNDTOUCH-SERVICE.md)
|
||||
* [Initial Device Setup](guides/DEVICE-INITIAL-SETUP.md)
|
||||
* [Capture Device Pairing Traffic](guides/CAPTURE-DEVICE-PAIRING.md)
|
||||
* [Capture Migration Traffic](guides/CAPTURE-MIGRATION-TRAFFIC.md)
|
||||
* [Device Setup Flow](DEVICE-SETUP.md)
|
||||
* [MAC Address Mapping](guides/MAC-ADDRESS-MAPPING.md)
|
||||
* [HTTPS Setup](guides/HTTPS-SETUP.md)
|
||||
@@ -37,6 +39,7 @@
|
||||
* [System Endpoints](reference/SYSTEM-ENDPOINTS.md)
|
||||
* [Speaker Endpoint](reference/SPEAKER-ENDPOINT.md)
|
||||
* [WebSocket Events](reference/WEBSOCKET-EVENTS.md)
|
||||
* [Device Pairing Flow](reference/DEVICE-PAIRING-FLOW.md)
|
||||
* [Discovery](reference/DISCOVERY.md)
|
||||
* [Zone Management](reference/ZONE-MANAGEMENT.md)
|
||||
* [Preset Management](reference/PRESET-MANAGEMENT.md)
|
||||
|
||||
@@ -2,6 +2,21 @@
|
||||
|
||||
Intercept HTTPS/WebSocket traffic from the Bose SoundTouch Android app using an Android emulator, mitmproxy, and Frida. Tested on Apple Silicon (ARM64) Mac.
|
||||
|
||||
## Automated Setup
|
||||
|
||||
The steps in this document are scripted for reproducibility:
|
||||
|
||||
```bash
|
||||
scripts/android/setup-mitm-avd.sh # one-time: create AVD, install cert & APK, save snapshot
|
||||
scripts/android/start-mitm-session.sh # per-session: restore snapshot, refresh proxy, start frida-server
|
||||
```
|
||||
|
||||
Read on for the full manual walkthrough and the rationale behind each step.
|
||||
|
||||
> **Note:** The manual steps below use `/tmp/` for intermediate files and reflect the original approach. The automated scripts supersede them — use the scripts for day-to-day use and refer here only to understand how things work.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Android Studio installed (for SDK tools and emulator)
|
||||
@@ -9,6 +24,10 @@ Intercept HTTPS/WebSocket traffic from the Bose SoundTouch Android app using an
|
||||
- mitmproxy installed (`pip install mitmproxy` or via your preferred method)
|
||||
- The Bose SoundTouch APK (extracted from a real device, see below)
|
||||
|
||||
> **BLE limitation**: Android emulators do not expose Bluetooth hardware. The Bose app's default setup path (BLE Wi-Fi provisioning) therefore cannot be used to configure a factory-reset speaker from the emulator. Use **AP mode** instead: provision the speaker's Wi-Fi credentials via the Mac command line first (see [DEVICE-INITIAL-SETUP.md § 6](../guides/DEVICE-INITIAL-SETUP.md)), then the app can discover the already-networked speaker via mDNS/SSDP without BLE.
|
||||
|
||||
> **Emulator ↔ local network**: The emulator routes all traffic through the Mac's active network interface. Once the speaker is on the same LAN as the Mac, the emulator can reach it at its normal LAN IP (e.g. `192.168.1.50`) — no extra routing is needed. Use `adb shell ping 192.168.1.50` to confirm reachability.
|
||||
|
||||
Add Android SDK tools to your PATH (add to `~/.zshrc`):
|
||||
|
||||
```bash
|
||||
@@ -89,7 +108,8 @@ adb -s emulator-5554 install bose.apk
|
||||
|
||||
```bash
|
||||
# Start mitmproxy (generates CA cert on first run)
|
||||
mitmweb --port 8080 --mode regular -w bose_traffic.mitm
|
||||
# Use the native macOS app — Docker mitmproxy does not work (NAT blocks emulator traffic)
|
||||
mitmweb --listen-port 8080 --mode regular -w bose_traffic.mitm
|
||||
```
|
||||
|
||||
Extract the CA certificate (without private key):
|
||||
@@ -214,16 +234,19 @@ grep -A3 "CERT_PEM" /tmp/config.js | head -5
|
||||
Make sure mitmweb is running, then:
|
||||
|
||||
```bash
|
||||
/tmp/frida-venv/bin/frida \
|
||||
scripts/android/frida-venv/bin/frida \
|
||||
-U \
|
||||
-f com.bose.soundtouch \
|
||||
-l /tmp/config.js \
|
||||
-l /tmp/android-system-certificate-injection.js \
|
||||
-l /tmp/android-proxy-override.js \
|
||||
-l /tmp/android-certificate-unpinning.js \
|
||||
-l /tmp/android-certificate-unpinning-fallback.js
|
||||
-l scripts/android/frida/config.js \
|
||||
-l scripts/android/frida/native-connect-hook.js \
|
||||
-l scripts/android/frida/android/android-system-certificate-injection.js \
|
||||
-l scripts/android/frida/android/android-proxy-override.js \
|
||||
-l scripts/android/frida/android/android-certificate-unpinning.js \
|
||||
-l scripts/android/frida/android/android-certificate-unpinning-fallback.js
|
||||
```
|
||||
|
||||
> `native-connect-hook.js` is required — the Bose app uses native networking that bypasses Java proxy settings.
|
||||
|
||||
Expected output in the Frida REPL:
|
||||
|
||||
```
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
# Capture Device Pairing Traffic
|
||||
|
||||
Step-by-step runbook for factory-resetting a SoundTouch speaker, pairing it to a Bose cloud account, and capturing every cloud request via mitmproxy. Tested on Apple Silicon Mac.
|
||||
|
||||
**Goal:** obtain a full `.mitm` recording of the account-pairing flow (streaming.bose.com) triggered by the official Android app.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
```
|
||||
Phase 0 Pre-flight checks
|
||||
Phase 1 Factory reset speaker
|
||||
Phase 2 Provision speaker Wi-Fi (AP mode, console)
|
||||
Phase 3 Start mitmproxy + emulator + Frida
|
||||
Phase 4 Pair speaker via Bose app (adb-driven)
|
||||
Phase 5 Save & inspect recording
|
||||
```
|
||||
|
||||
The Android emulator does not have Bluetooth, so the standard BLE setup path is unavailable. Instead:
|
||||
|
||||
1. Provision Wi-Fi directly over the speaker's AP web server (Phase 2).
|
||||
2. Once the speaker is on the LAN, the Bose app discovers it via mDNS — no BLE needed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Pre-flight
|
||||
|
||||
The emulator setup is fully scripted. Run once per machine:
|
||||
|
||||
```bash
|
||||
# Place the Bose APK at scripts/android/bose.apk first (see BOSE-APP-ADB-Emulator.md § 1)
|
||||
# Run mitmweb once to generate the CA: mitmweb --listen-port 8080 (Ctrl-C after it starts)
|
||||
|
||||
scripts/android/setup-mitm-avd.sh
|
||||
```
|
||||
|
||||
This installs the system image, creates an AVD named `bose-mitm`, installs the
|
||||
mitmproxy cert and Bose APK, and saves an emulator snapshot `mitm-ready` — so
|
||||
subsequent sessions never repeat the cert/reboot cycle.
|
||||
|
||||
For subsequent sessions, Phase 3 below is replaced by a single command:
|
||||
|
||||
```bash
|
||||
scripts/android/start-mitm-session.sh
|
||||
```
|
||||
|
||||
Manual steps are only needed if you want to understand the internals; see
|
||||
[BOSE-APP-ADB-Emulator.md](../analysis/BOSE-APP-ADB-Emulator.md) for the
|
||||
full manual walkthrough.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Factory Reset Speaker
|
||||
|
||||
Perform the reset for your model (see
|
||||
[DEVICE-INITIAL-SETUP.md § 5](DEVICE-INITIAL-SETUP.md) for full table):
|
||||
|
||||
| Model | Sequence |
|
||||
|-----------------------------|-------------------------------------------------------------------------|
|
||||
| SoundTouch 10 | Power on; hold **Preset 1** + **Vol −** ~10 s → solid amber Wi-Fi LED |
|
||||
| SoundTouch 20 | Power on; hold **Preset 1** + **Vol −** ~10 s → lights blink L→R, amber |
|
||||
| SoundTouch 20/30 Series III | Hold **Preset 1** + **Preset 6** ~10 s |
|
||||
| SoundTouch 300 | Hold **Vol −** ~15 s until light bar blinks |
|
||||
|
||||
Wait until the white LED sweep / restart animation completes (~30 s). The speaker
|
||||
is now in setup mode and broadcasting its own Wi-Fi AP.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Provision Speaker Wi-Fi (AP Mode)
|
||||
|
||||
### 2.1 Connect Mac to Speaker AP
|
||||
|
||||
```bash
|
||||
# Find the SSID — use System Settings → Wi-Fi or:
|
||||
# sudo wdutil info (macOS Sequoia+, airport command removed)
|
||||
|
||||
# Connect (replace SSID with actual value)
|
||||
SPEAKER_SSID="Bose SoundTouch XXXX"
|
||||
networksetup -setairportnetwork en0 "$SPEAKER_SSID"
|
||||
|
||||
# Confirm: speaker web UI reachable at 192.0.2.1 (client gets 192.0.2.2)
|
||||
curl -s --connect-timeout 5 http://192.0.2.1/ | head -3
|
||||
```
|
||||
|
||||
### 2.2 Push Home Wi-Fi Credentials
|
||||
|
||||
The setup UI at `http://192.0.2.1/` uses the SoundTouch API on **port 8090**. Push credentials directly:
|
||||
|
||||
```bash
|
||||
HOME_SSID="MyHomeNetwork"
|
||||
HOME_PASS="MyPassword"
|
||||
|
||||
# Optional: trigger a site survey first so the speaker finds your SSID
|
||||
curl -s -X POST http://192.0.2.1:8090/performWirelessSiteSurvey \
|
||||
-H 'Content-Type: text/xml' \
|
||||
--data-raw '<PerformWirelessSiteSurvey timeout="5"/>'
|
||||
|
||||
curl -s -X POST http://192.0.2.1:8090/addWirelessProfile \
|
||||
-H 'Content-Type: text/xml' \
|
||||
--data-raw "<AddWirelessProfile><profile ssid=\"${HOME_SSID}\" password=\"${HOME_PASS}\" securityType=\"wpa_or_wpa2\" /></AddWirelessProfile>"
|
||||
```
|
||||
|
||||
Expected response: `<AddWirelessProfileResponse />`
|
||||
|
||||
### 2.3 Reconnect Mac to Home Network
|
||||
|
||||
```bash
|
||||
HOME_SSID="MyHomeNetwork"
|
||||
HOME_PASS="MyPassword"
|
||||
|
||||
networksetup -setairportnetwork en0 "$HOME_SSID" "$HOME_PASS"
|
||||
```
|
||||
|
||||
### 2.4 Wait for Speaker to Join LAN
|
||||
|
||||
```bash
|
||||
# Poll mDNS until the speaker appears (~15-30 s)
|
||||
echo "Waiting for speaker on LAN..."
|
||||
until dns-sd -B _soundtouch._tcp local 2>&1 | grep -m1 "Add"; do sleep 2; done
|
||||
echo "Speaker is online"
|
||||
|
||||
# Resolve its IP
|
||||
dns-sd -L "$(dns-sd -B _soundtouch._tcp local 2>&1 | grep Add | awk '{print $7}')" \
|
||||
_soundtouch._tcp local 2>&1 | grep -o '[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}'
|
||||
```
|
||||
|
||||
Or just scan for open port 8090:
|
||||
|
||||
```bash
|
||||
# Quick scan of your /24 subnet for port 8090
|
||||
SUBNET="192.168.1" # adjust to your local subnet
|
||||
for i in $(seq 1 254); do
|
||||
(ping -c1 -W1 ${SUBNET}.$i &>/dev/null && \
|
||||
nc -z -w1 ${SUBNET}.$i 8090 2>/dev/null && \
|
||||
echo "${SUBNET}.$i") &
|
||||
done
|
||||
wait
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Start mitmproxy, Emulator, Frida
|
||||
|
||||
Run each block in a separate terminal tab.
|
||||
|
||||
### 3.1 Start mitmweb (native macOS app)
|
||||
|
||||
> **Note:** Docker mitmproxy does not work here — its NAT layer prevents the emulator from reaching it. Use the native macOS app instead (download: `https://downloads.mitmproxy.org/12.2.2/mitmproxy-12.2.2-macos-arm64.tar.gz`).
|
||||
|
||||
```bash
|
||||
CAPTURE="bose-pairing-$(date +%Y%m%d-%H%M%S).mitm"
|
||||
/Applications/mitmproxy.app/Contents/MacOS/mitmweb \
|
||||
--web-host 0.0.0.0 --listen-port 8080 --mode regular \
|
||||
--set web_password=bose \
|
||||
-w "scripts/android/captures/${CAPTURE}"
|
||||
# Captures → scripts/android/captures/
|
||||
# Web UI → http://127.0.0.1:8081/?token=bose
|
||||
```
|
||||
|
||||
### 3.2 Start Emulator
|
||||
|
||||
```bash
|
||||
~/Library/Android/sdk/emulator/emulator -avd Pixel_6_API33 -writable-system &
|
||||
echo "Waiting for emulator boot..."
|
||||
adb wait-for-device
|
||||
adb -s emulator-5554 wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done'
|
||||
echo "Emulator ready"
|
||||
```
|
||||
|
||||
Enable root and install certificate (only needed once per emulator session):
|
||||
|
||||
```bash
|
||||
adb -s emulator-5554 root
|
||||
adb -s emulator-5554 shell avbctl disable-verification
|
||||
adb -s emulator-5554 reboot
|
||||
adb -s emulator-5554 wait-for-device
|
||||
adb -s emulator-5554 root
|
||||
|
||||
HASH=$(openssl x509 -inform PEM -subject_hash_old \
|
||||
-in ~/.mitmproxy/mitmproxy-ca-cert.pem | head -1)
|
||||
|
||||
adb -s emulator-5554 push ~/.mitmproxy/mitmproxy-ca-cert.pem /data/local/tmp/mitmproxy.pem
|
||||
adb -s emulator-5554 shell su 0 mkdir -p /data/misc/user/0/cacerts-added
|
||||
adb -s emulator-5554 shell su 0 \
|
||||
cp /data/local/tmp/mitmproxy.pem /data/misc/user/0/cacerts-added/${HASH}.0
|
||||
adb -s emulator-5554 shell su 0 \
|
||||
chmod 644 /data/misc/user/0/cacerts-added/${HASH}.0
|
||||
```
|
||||
|
||||
Set proxy to Mac IP:
|
||||
|
||||
```bash
|
||||
MAC_IP=$(ipconfig getifaddr en0)
|
||||
adb -s emulator-5554 shell settings put global http_proxy "${MAC_IP}:8080"
|
||||
echo "Proxy set to ${MAC_IP}:8080"
|
||||
```
|
||||
|
||||
Confirm emulator can reach the speaker:
|
||||
|
||||
```bash
|
||||
SPEAKER_IP=192.168.1.50 # adjust to your speaker's LAN IP
|
||||
adb -s emulator-5554 shell ping -c 3 "$SPEAKER_IP"
|
||||
```
|
||||
|
||||
### 3.3 Start frida-server
|
||||
|
||||
```bash
|
||||
adb -s emulator-5554 push scripts/android/frida-server /data/local/tmp/frida-server
|
||||
adb -s emulator-5554 shell su 0 chmod 755 /data/local/tmp/frida-server
|
||||
adb -s emulator-5554 shell su 0 "nohup /data/local/tmp/frida-server > /dev/null 2>&1 &"
|
||||
sleep 2
|
||||
echo "frida-server running"
|
||||
```
|
||||
|
||||
### 3.4 Configure config.js
|
||||
|
||||
`start-mitm-session.sh` patches `scripts/android/frida/config.js` automatically with the current Mac IP and mitmproxy cert. No manual step needed.
|
||||
|
||||
### 3.5 Launch App with SSL Unpinning
|
||||
|
||||
```bash
|
||||
scripts/android/frida-venv/bin/frida \
|
||||
-U \
|
||||
-f com.bose.soundtouch \
|
||||
-l scripts/android/frida/config.js \
|
||||
-l scripts/android/frida/native-connect-hook.js \
|
||||
-l scripts/android/frida/android/android-system-certificate-injection.js \
|
||||
-l scripts/android/frida/android/android-proxy-override.js \
|
||||
-l scripts/android/frida/android/android-certificate-unpinning.js \
|
||||
-l scripts/android/frida/android/android-certificate-unpinning-fallback.js
|
||||
```
|
||||
|
||||
> `native-connect-hook.js` is required — the Bose app uses native networking that bypasses Java proxy settings.
|
||||
|
||||
Expected Frida output:
|
||||
|
||||
```
|
||||
== System certificate trust injected ==
|
||||
== Proxy system configuration overridden to <IP>:8080 ==
|
||||
== Proxy configuration overridden to <IP>:8080 ==
|
||||
== Certificate unpinning completed ==
|
||||
== Unpinning fallback auto-patcher installed ==
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Pair Speaker via App
|
||||
|
||||
The Bose app should now be running in the emulator with all traffic going through mitmproxy.
|
||||
|
||||
### 4.1 Inspect UI to Find Interactive Elements
|
||||
|
||||
```bash
|
||||
# Dump current screen
|
||||
adb -s emulator-5554 shell uiautomator dump /sdcard/ui.xml
|
||||
adb -s emulator-5554 pull /sdcard/ui.xml /tmp/ui.xml
|
||||
|
||||
# Helper: list all clickable elements with their text + bounds
|
||||
grep -o 'text="[^"]*" resource-id="[^"]*" \.\.\. clickable="true"[^/]*' /tmp/ui.xml \
|
||||
|| python3 -c "
|
||||
import xml.etree.ElementTree as ET
|
||||
tree = ET.parse('/tmp/ui.xml')
|
||||
for n in tree.iter('node'):
|
||||
if n.get('clickable') == 'true' and n.get('text'):
|
||||
print(n.get('bounds'), n.get('resource-id'), repr(n.get('text')))
|
||||
"
|
||||
```
|
||||
|
||||
### 4.2 Navigate Setup Flow (adb)
|
||||
|
||||
```bash
|
||||
# Take a screenshot at any point to see current state
|
||||
adb -s emulator-5554 shell screencap /sdcard/screen.png
|
||||
adb -s emulator-5554 pull /sdcard/screen.png /tmp/screen.png
|
||||
open /tmp/screen.png
|
||||
|
||||
# Tap by resource-id (find IDs from ui.xml dump)
|
||||
adb -s emulator-5554 shell uiautomator runtest ... # or input tap
|
||||
|
||||
# Tap by screen coordinates
|
||||
adb -s emulator-5554 shell input tap X Y
|
||||
|
||||
# Type into the focused field
|
||||
adb -s emulator-5554 shell input text "your@email.com"
|
||||
|
||||
# Press Enter / Next
|
||||
adb -s emulator-5554 shell input keyevent 66
|
||||
```
|
||||
|
||||
### 4.3 Expected Setup Steps in the App
|
||||
|
||||
Follow the on-screen flow; mitmproxy captures everything automatically.
|
||||
|
||||
1. **Sign in** — enter email + password → triggers `POST /streaming/account/login`
|
||||
2. **Add speaker** — tap "Set Up a New Speaker" or equivalent
|
||||
3. **App discovers speaker** via mDNS on LAN (no BLE required)
|
||||
4. **Wi-Fi already configured** — app skips the Wi-Fi step since speaker is online
|
||||
5. **Name speaker** — type a name → WebSocket `name` message to speaker port 8080
|
||||
6. **Pairing** — app sends `setMargeAccount` WebSocket to speaker → speaker POSTs to `streaming.bose.com/{accountId}/devices`
|
||||
|
||||
All cloud requests (steps 1, 6) will appear in mitmweb at `http://127.0.0.1:8081`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Save & Inspect Recording
|
||||
|
||||
```bash
|
||||
# Stop mitmweb (Ctrl-C in its terminal) — file is already written continuously
|
||||
|
||||
# Inspect offline in the web UI
|
||||
mitmweb -r "$CAPTURE"
|
||||
|
||||
# Filter to streaming.bose.com only
|
||||
mitmdump -r "$CAPTURE" --flow-filter '~u streaming.bose.com' -w bose-cloud-only.mitm
|
||||
|
||||
# Quick text summary
|
||||
mitmdump -r "$CAPTURE" --flow-filter '~u streaming.bose.com' 2>/dev/null \
|
||||
| grep -E "POST|GET" | head -30
|
||||
```
|
||||
|
||||
### Convert to .http files (IntelliJ-compatible)
|
||||
|
||||
Use `scripts/convert_mitm_script.py` to extract each flow as a `.http` file, organized by path:
|
||||
|
||||
```bash
|
||||
NAME=$(basename "$CAPTURE" .mitm)
|
||||
OUT="scripts/android/mitm/${NAME}"
|
||||
|
||||
/Applications/mitmproxy.app/Contents/MacOS/mitmdump \
|
||||
-n -r "$CAPTURE" \
|
||||
-s scripts/convert_mitm_script.py \
|
||||
--set out_dir="${OUT}"
|
||||
```
|
||||
|
||||
Output lands in `scripts/android/mitm/<name>/mirror/` as numbered `.http` files
|
||||
plus `*-websocket/` subdirectories for WebSocket frames. The directory is gitignored.
|
||||
|
||||
---
|
||||
|
||||
## Cleanup
|
||||
|
||||
```bash
|
||||
# Remove proxy from emulator
|
||||
adb -s emulator-5554 shell settings delete global http_proxy
|
||||
|
||||
# Kill emulator
|
||||
adb -s emulator-5554 emu kill
|
||||
```
|
||||
|
||||
Frida artefacts live in `scripts/android/` (gitignored) and persist between sessions — no cleanup needed unless you want to force a fresh setup.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|-------------------------------------|--------------------------------------------------|-----------------------------------------------------------------------------------|
|
||||
| `curl http://192.0.2.1` times out | Mac not on speaker AP | Re-run `networksetup -setairportnetwork`; speaker AP gateway is `192.0.2.1` |
|
||||
| `addWirelessProfile` returns error | Speaker not in AP mode or wrong IP | Confirm speaker AP is active; use `http://192.0.2.1:8090/addWirelessProfile` |
|
||||
| Speaker not found via mDNS | Speaker still on AP (not home LAN yet) | Wait ~30 s, retry; check router DHCP leases |
|
||||
| Emulator can't ping speaker | Different subnet or emulator proxy misconfigured | `adb shell ping` the Mac IP first; check proxy setting |
|
||||
| App shows "No speakers found" | App not detecting mDNS | Ensure emulator is on same `/24` as speaker; disable emulator Wi-Fi and re-enable |
|
||||
| No traffic in mitmweb | Frida not running or cert mismatch | Check Frida output for `== Certificate unpinning ==`; verify issuer in config.js |
|
||||
|
||||
## See Also
|
||||
|
||||
- [BOSE-APP-ADB-Emulator.md](../analysis/BOSE-APP-ADB-Emulator.md) — full MITM + Frida setup
|
||||
- [DEVICE-INITIAL-SETUP.md](DEVICE-INITIAL-SETUP.md) — factory reset sequences + AP mode detail
|
||||
- [DEVICE-SETUP.md](../DEVICE-SETUP.md) — WebSocket and cloud pairing protocol reference
|
||||
|
||||
---
|
||||
|
||||
## Session Trace (2026-05-02, ST10)
|
||||
|
||||
Raw log of the first interactive run. To be cleaned up into the runbook above.
|
||||
|
||||
### Setup
|
||||
- Ran `scripts/android/setup-mitm-avd.sh` — all 9 steps completed:
|
||||
- Steps 1–5 were already done from a prior session (idempotent skips)
|
||||
- Step 6: Docker image built, `frida-server` extracted to `scripts/android/frida-server`
|
||||
- Steps 7–9: Emulator started fresh (`-no-snapshot-load`), AVB disabled, rebooted, cert + APK + frida-server installed, snapshot `mitm-ready` saved
|
||||
- Emulator running at `emulator-5554`
|
||||
|
||||
### Factory Reset (ST10)
|
||||
- Correct sequence confirmed from official Bose guide (`firmware/FirmwareUpdateGuide/`):
|
||||
**Power on → hold Preset 1 + Volume − for 10 s → Wi-Fi LED glows solid amber**
|
||||
- Note: original docs said "Vol− + Mute" — corrected in DEVICE-INITIAL-SETUP.md and this file
|
||||
|
||||
### Pre-reset note
|
||||
- User inserted USB device containing `remote_services` before rebooting — device needs to be on home Wi-Fi before the USB config takes effect
|
||||
|
||||
### Wi-Fi Provisioning (AP mode)
|
||||
- `airport` command not available on this macOS version (removed in recent releases)
|
||||
- Connect Mac to speaker AP via **System Settings → Wi-Fi** (SSID: "Bose SoundTouch XXXX")
|
||||
- Speaker AP gateway confirmed: `192.0.2.1` (client gets `192.0.2.2`), not `192.168.1.1` as previously assumed
|
||||
- `/gabbo_wifi` endpoint was hallucinated — actual endpoint verified from browser network capture (`_/device-reset/wifi-setup.txt`):
|
||||
- Site survey: `POST http://192.0.2.1:8090/performWirelessSiteSurvey` with `<PerformWirelessSiteSurvey timeout="5"/>`
|
||||
- Add profile: `POST http://192.0.2.1:8090/addWirelessProfile` with XML body, `securityType="wpa_or_wpa2"`
|
||||
- Both use the standard SoundTouch API port 8090, same as normal device operation
|
||||
- Response: `<AddWirelessProfileResponse />`
|
||||
- Reconnect Mac to home Wi-Fi; emulator stays running throughout (routes through Mac interface)
|
||||
|
||||
### Wi-Fi Provisioning Result
|
||||
- `POST http://192.0.2.1:8090/addWirelessProfile` succeeded → `<AddWirelessProfileResponse />`
|
||||
- Speaker joined home LAN at `192.168.x.y`
|
||||
- SSH access confirmed: `ssh -oHostKeyAlgorithms=ssh-rsa root@192.168.x.y`
|
||||
- Device name: `Bose SoundTouch XXXXXX`
|
||||
- Network interfaces on device:
|
||||
- `wlan0` → `192.168.x.y` (home LAN)
|
||||
- `wlan1` → `192.0.2.1` (AP mode interface, stays up after provisioning)
|
||||
- `usb0` → `203.0.113.1` (USB gadget — remote_services USB device inserted before reboot)
|
||||
|
||||
### MITM Session Start
|
||||
- `start-mitm-session.sh` run: snapshot restored, proxy set, frida-server started, config.js patched
|
||||
- Issues encountered and fixed:
|
||||
- frida-server start command hung (`adb shell su 0 ... &`) → fixed with `nohup ... > /dev/null 2>&1 &`
|
||||
- Frida SSL scripts download via `curl` from GitHub timed out → moved to Dockerfile (extracted alongside frida-server)
|
||||
- Python heredoc cert injection syntax error (multiline cert in string) → fixed using env vars + single-quoted `'PYEOF'` heredoc
|
||||
- `mitmweb --port` deprecated → `--listen-port`
|
||||
- frida script paths missing `android/` subdirectory → fixed in session script
|
||||
- Docker mitmproxy (`-p 8080:8080`) received no traffic from emulator — Docker NAT layer prevented the emulator reaching it
|
||||
- **Fix: use native macOS mitmproxy app** (`/Users/gesellix/Downloads/mitmproxy.app`) — binds directly to Mac's real network interfaces, traffic flows immediately
|
||||
- Android system traffic visible (connectivity checks to gstatic.com, www.google.com) — TLS failures for system processes expected since only Bose app has Frida cert injection
|
||||
|
||||
### Capture Working
|
||||
- Full flow confirmed working with:
|
||||
- Native mitmproxy app (`~/Downloads/mitmproxy.app`, v12.2.2)
|
||||
- `native-connect-hook.js` added to Frida launch (required for Bose app's native networking)
|
||||
- All 5 Frida scripts loaded: config.js, native-connect-hook.js, android-system-certificate-injection.js, android-proxy-override.js, android-certificate-unpinning.js, android-certificate-unpinning-fallback.js
|
||||
- Bose app traffic visible in mitmweb — pairing flow captured successfully
|
||||
@@ -0,0 +1,378 @@
|
||||
# Capture Speaker Migration Traffic
|
||||
|
||||
Runbook for migrating a SoundTouch speaker to `soundtouch-service` and capturing
|
||||
all traffic (App→Service and Speaker→Service) to identify unimplemented endpoints.
|
||||
|
||||
**Goal:** obtain a complete picture of every cloud request a speaker and the Bose
|
||||
app make after migration, so missing endpoint implementations can be tracked down.
|
||||
|
||||
**Pre-requisites:** the MITM pipeline is already set up and working. See
|
||||
[CAPTURE-DEVICE-PAIRING.md](CAPTURE-DEVICE-PAIRING.md) for the one-time AVD setup
|
||||
and the pairing capture runbook.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
```
|
||||
Step 1 Start soundtouch-service locally (with interaction recording)
|
||||
Step 2 Start a fresh mitmproxy + Frida session (captures App traffic)
|
||||
Step 3 Discover or register the speaker in the service UI
|
||||
Step 4 Migrate the speaker (modifies SoundTouchSdkPrivateCfg.xml via SSH)
|
||||
Step 5 Operate the Bose app — everything now flows through local service
|
||||
Step 6 Inspect captured interactions for unimplemented endpoints
|
||||
Step 7 Revert (optional) / clean up
|
||||
```
|
||||
|
||||
Traffic sources:
|
||||
- **App → Service** — captured by mitmproxy + Frida (same as pairing capture)
|
||||
- **Speaker → Service** — captured by the service's built-in interaction recorder
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Start soundtouch-service
|
||||
|
||||
Build and start the service. Recording is on by default; add `--server-url` so the
|
||||
service knows its own public address (the speaker needs it for redirections).
|
||||
|
||||
```bash
|
||||
# Determine Mac LAN IP first
|
||||
MAC_IP=$(ipconfig getifaddr en0)
|
||||
echo "Mac IP: ${MAC_IP}"
|
||||
|
||||
# Build + run with explicit server-url so the service embeds the correct address
|
||||
make build-service
|
||||
./build/soundtouch-service \
|
||||
--server-url "http://${MAC_IP}:8000" \
|
||||
--record-interactions \
|
||||
--log-bodies
|
||||
```
|
||||
|
||||
Service listens on `:8000` by default. Web UI: `http://localhost:8000`
|
||||
|
||||
> To also enable mirror mode (forward unhandled requests to official Bose servers
|
||||
> for comparison), add: `--mirror-enabled --mirror-endpoints /streaming/`
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Start mitmproxy + Frida (new capture)
|
||||
|
||||
In a separate terminal:
|
||||
|
||||
```bash
|
||||
scripts/android/start-mitm-session.sh
|
||||
```
|
||||
|
||||
The script prints ready-to-run commands for mitmweb and Frida. Run each in its own
|
||||
terminal tab as instructed.
|
||||
|
||||
New capture file lands in `scripts/android/captures/`.
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Discover the Speaker
|
||||
|
||||
Open the service web UI at `http://localhost:8000`.
|
||||
|
||||
The service discovers speakers via mDNS automatically on startup. If the speaker
|
||||
does not appear within ~30 s, add it manually:
|
||||
|
||||
```bash
|
||||
# Via API (replace IP with speaker's current LAN IP)
|
||||
curl -s -X POST http://localhost:8000/setup/devices \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"ip": "192.168.x.y"}'
|
||||
|
||||
# Confirm it's registered
|
||||
curl -s http://localhost:8000/setup/devices | python3 -m json.tool
|
||||
```
|
||||
|
||||
Note the `device_id` from the response — you need it for migration.
|
||||
|
||||
```bash
|
||||
# List all known devices and their IDs
|
||||
curl -s http://localhost:8000/setup/devices | python3 -m json.tool
|
||||
|
||||
# Extract device_id for the speaker by matching its IP
|
||||
DEVICE_ID=$(curl -s http://localhost:8000/setup/devices \
|
||||
| python3 -c "import sys,json; devs=json.load(sys.stdin); \
|
||||
[print(d['device_id']) for d in devs if '35' in d.get('ip_address','')]")
|
||||
echo "Device ID: ${DEVICE_ID}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Migrate the Speaker
|
||||
|
||||
The migration modifies `SoundTouchSdkPrivateCfg.xml` on the speaker via SSH,
|
||||
redirecting `margeServerUrl` (and optionally other service URLs) to the local
|
||||
service.
|
||||
|
||||
### 4.1 Review the Migration Plan
|
||||
|
||||
```bash
|
||||
# Dry-run: see what will be changed
|
||||
curl -s "http://localhost:8000/setup/summary/${DEVICE_ID}" | python3 -m json.tool
|
||||
```
|
||||
|
||||
Key fields to check:
|
||||
- `margeServerUrl` — should become `http://<MAC_IP>:8000/streaming`
|
||||
- `remoteServicesEnabled` — must be `true` for the speaker to make cloud calls
|
||||
- `is_migrated` — `false` before, `true` after
|
||||
|
||||
### 4.2 Run Migration
|
||||
|
||||
```bash
|
||||
MAC_IP=$(ipconfig getifaddr en0)
|
||||
TARGET_URL="http://${MAC_IP}:8000"
|
||||
|
||||
curl -s -X POST \
|
||||
"http://localhost:8000/setup/migrate/${DEVICE_ID}" \
|
||||
-G --data-urlencode "target_url=${TARGET_URL}" \
|
||||
| python3 -m json.tool
|
||||
```
|
||||
|
||||
Expected response: `{"ok": true, "message": "Migration started", "output": "..."}`.
|
||||
The output field contains the SSH transcript of the changes made.
|
||||
|
||||
### 4.3 Reboot the Speaker
|
||||
|
||||
A reboot applies the new config:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "http://localhost:8000/setup/reboot/${DEVICE_ID}"
|
||||
```
|
||||
|
||||
Wait ~30 s for the speaker to come back online. Verify it's back:
|
||||
|
||||
```bash
|
||||
dns-sd -B _soundtouch._tcp local 2>&1 | grep Add
|
||||
# or
|
||||
curl -s http://192.168.x.y:8090/info | head -5
|
||||
```
|
||||
|
||||
### 4.4 Verify Migration
|
||||
|
||||
```bash
|
||||
# Check migration summary again — is_migrated should now be true
|
||||
curl -s "http://localhost:8000/setup/summary/${DEVICE_ID}" \
|
||||
| python3 -c "import sys,json; s=json.load(sys.stdin); print('migrated:', s.get('is_migrated'))"
|
||||
```
|
||||
|
||||
You should also see incoming connections from the speaker in the service logs once
|
||||
it resumes normal operation.
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Operate the Bose App
|
||||
|
||||
With the speaker migrated and Frida running, every app action triggers traffic
|
||||
through the service:
|
||||
|
||||
1. **Sign in** — `POST /streaming/account/login`
|
||||
2. **Speaker shows as linked** — speaker has called the service to register/sync
|
||||
3. **Play music** — BMX registry lookup, playback control
|
||||
4. **Set presets** — `POST /streaming/account/{id}/device/{id}/presets/{n}`
|
||||
5. **Adjust volume, switch source** — direct speaker API (port 8090, not cloud)
|
||||
6. **Check "Now Playing"** — speaker WebSocket events + marge sync
|
||||
|
||||
For each action, both mitmweb and the service's recorder capture the request.
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Inspect Captured Interactions
|
||||
|
||||
### 6.1 Service Interaction Recorder
|
||||
|
||||
The service records all incoming requests to `data/interactions/` (configurable via
|
||||
`--data-dir`). Browse them via:
|
||||
|
||||
```bash
|
||||
# List recorded sessions
|
||||
curl -s http://localhost:8000/setup/interactions | python3 -m json.tool
|
||||
|
||||
# Download a session as HAR
|
||||
curl -s "http://localhost:8000/setup/interactions/sessions/<session>/download" \
|
||||
-o session.har
|
||||
|
||||
# Find 404/500 responses (unimplemented endpoints)
|
||||
curl -s "http://localhost:8000/setup/interaction-content" \
|
||||
| python3 -c "
|
||||
import sys, json
|
||||
for entry in json.load(sys.stdin).get('entries', []):
|
||||
status = entry.get('response', {}).get('status', 0)
|
||||
if status >= 400:
|
||||
print(status, entry.get('request', {}).get('method'), entry.get('request', {}).get('url'))
|
||||
"
|
||||
```
|
||||
|
||||
### 6.2 mitmproxy Recording
|
||||
|
||||
```bash
|
||||
# Inspect app→service traffic offline
|
||||
CAPTURE="scripts/android/captures/<filename>.mitm"
|
||||
mitmweb -r "${CAPTURE}"
|
||||
|
||||
# Filter to local service only
|
||||
mitmdump -r "${CAPTURE}" \
|
||||
--flow-filter "~u ${MAC_IP}:8000" \
|
||||
2>/dev/null | grep -E "POST|GET"
|
||||
|
||||
# Convert to .http files (IntelliJ-compatible, organized by path)
|
||||
NAME=$(basename "${CAPTURE}" .mitm)
|
||||
OUT="scripts/android/mitm/${NAME}"
|
||||
|
||||
/Applications/mitmproxy.app/Contents/MacOS/mitmdump \
|
||||
-n -r "${CAPTURE}" \
|
||||
-s scripts/convert_mitm_script.py \
|
||||
--set out_dir="${OUT}"
|
||||
# Output → scripts/android/mitm/<name>/mirror/
|
||||
```
|
||||
|
||||
### 6.3 Identify Unimplemented Endpoints
|
||||
|
||||
Endpoints the service doesn't handle return `404 Not Found`. Check:
|
||||
|
||||
```bash
|
||||
# From service stats
|
||||
curl -s http://localhost:8000/setup/interaction-stats | python3 -m json.tool
|
||||
|
||||
# List parity mismatches (local vs upstream divergence, if mirror enabled)
|
||||
curl -s http://localhost:8000/setup/parity-mismatches | python3 -m json.tool
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Revert Migration (Optional)
|
||||
|
||||
To restore the speaker to its original config (pointing back to Bose cloud):
|
||||
|
||||
```bash
|
||||
curl -s -X POST "http://localhost:8000/setup/revert/${DEVICE_ID}" | python3 -m json.tool
|
||||
```
|
||||
|
||||
Then reboot the speaker:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "http://localhost:8000/setup/reboot/${DEVICE_ID}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cleanup
|
||||
|
||||
```bash
|
||||
# Stop mitmweb (Ctrl-C in its terminal)
|
||||
# Stop Frida (Ctrl-C in its terminal)
|
||||
# Stop soundtouch-service (Ctrl-C in its terminal)
|
||||
|
||||
# Remove emulator proxy (if not running another session)
|
||||
adb -s emulator-5554 shell settings delete global http_proxy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|-------------------------------------------|--------------------------------------------|---------------------------------------------------------------------------|
|
||||
| Speaker not in service device list | mDNS discovery hasn't fired yet | Trigger manually: `POST /setup/discover` or add via `POST /setup/devices` |
|
||||
| Migration fails with SSH error | Speaker SSH key not trusted | Run `POST /setup/trust-ca/{deviceId}` first, or check SSH connectivity |
|
||||
| Speaker can't reach service after reboot | Firewall blocking port 8000 from LAN | Allow inbound TCP 8000 on Mac firewall |
|
||||
| `is_migrated: false` after migration | Wrong `target_url` or config not written | Check SSH output in migration response; re-run with `--method xml` |
|
||||
| Service logs show no speaker requests | `remote_services` not enabled on speaker | Run `POST /setup/ensure-remote-services/{deviceId}` and reboot |
|
||||
| App shows speaker offline after migration | Speaker config not pointing to correct URL | Check `margeServerUrl` via `GET /setup/summary/{deviceId}` |
|
||||
|
||||
---
|
||||
|
||||
## Session Trace (2026-05-02, ST10)
|
||||
|
||||
Raw log of the first interactive migration run.
|
||||
|
||||
### Service Configuration
|
||||
|
||||
Settings applied in the web UI before migration:
|
||||
|
||||
| Setting | Value |
|
||||
|---------------------|---------------------------------------------------------------------|
|
||||
| Target Domain | `soundtouch.local` (resolvable from speaker to `192.168.x.z`) |
|
||||
| DNS Discovery | enabled |
|
||||
| Upstream DNS | home Wi-Fi gateway |
|
||||
| Mirroring | enabled (for tracing while Bose cloud is still up) |
|
||||
| Mirrored endpoints | `/bmx/*`, `/streaming/*`, `/accounts/*`, `/v1/scmudc/*`, `/oauth/*` |
|
||||
| Proxy logging | enabled, including bodies |
|
||||
| Record interactions | enabled |
|
||||
| Skip recording | `/setup/*`, `/web/*` |
|
||||
|
||||
Settings saved and service restarted.
|
||||
|
||||
### Navigation Flow
|
||||
|
||||
1. **Tab 1 — Settings**: entered all settings above, clicked **Save Settings**, restarted service
|
||||
2. **Tab 2 — Devices**: speaker appeared via mDNS discovery; clicked **Sync Data**
|
||||
3. **Tab 3 — Data Sync**: clicked **Start Sync** to pull account/device data from Bose cloud
|
||||
4. **Tab 2 — Devices**: clicked **Migrate** on the speaker entry
|
||||
5. In the Migrate panel: selected **Migration Method → `/etc/resolv.conf`**
|
||||
6. Ran pre-migration checks (see below)
|
||||
7. Ran migration steps (see below)
|
||||
8. Rebooted speaker
|
||||
9. Paired and configured speaker via the Bose app
|
||||
|
||||
### Pre-Migration Checks
|
||||
|
||||
All tests run from the **Devices → Migrate** panel after selecting the speaker (`192.168.x.y`, SoundTouch 10):
|
||||
|
||||
- **HTTPS test (explicit CA.crt)**: ✅ passed (result not recorded in detail)
|
||||
- **HTTPS test (shared trust store)**: ✅ passed
|
||||
- Speaker connected to `soundtouch.local:443` → `192.168.x.z`
|
||||
- TLS: TLSv1.2 / ECDHE-RSA-AES128-GCM-SHA256, cert issued by `SoundTouch Local Root CA`
|
||||
- CA already in speaker's system trust store (`/etc/pki/tls/certs/ca-bundle.crt`)
|
||||
- **Preliminary DNS Test**: ✅ passed
|
||||
- Raw DNS query for `aftertouch.test` returned `192.168.x.z` via the service DNS at `192.168.x.z:53`
|
||||
- **Planned `/etc/resolv.conf`**:
|
||||
```
|
||||
# Created by Aftertouch/SoundTouch-Service
|
||||
# Priority nameserver for Bose service redirection
|
||||
nameserver 192.168.x.z
|
||||
```
|
||||
|
||||
### Migration Steps
|
||||
|
||||
1. **Enable Persistent Remote Services** → `Successfully ensured remote services for SoundTouch 10 (192.168.x.y)`
|
||||
- Note: `touch /etc/remote_services (with rw): sh: rw: command not found` — safe to ignore, `touch` succeeded
|
||||
2. Reloaded migration view by deselecting and reselecting the speaker in the dropdown
|
||||
3. **Backup Config Now** → `✅ Found .original config at /opt/Bose/etc/SoundTouchSdkPrivateCfg.xml.original`
|
||||
4. **Confirm Migration** → `Successfully started migration for SoundTouch 10 (192.168.x.y). Please reboot the device to activate the changes.`
|
||||
|
||||
Command output:
|
||||
- Off-device backup created ✅
|
||||
- Write access verified ✅
|
||||
- `soundtouch.local` resolved to `192.168.x.z` ✅
|
||||
- `/mnt/nv/soundtouch-service/aftertouch.resolv.conf` uploaded ✅
|
||||
- `rc.local` already contains Aftertouch hook logic ✅
|
||||
- `(rw || mount -o remount,rw /): sh: rw: command not found` — safe to ignore (same shell quirk as above)
|
||||
- `/etc/udhcpc.d/50default` patched and verified ✅
|
||||
- `/opt/Bose/udhcpc.script` patched and verified ✅
|
||||
- CA certificate already trusted, skipping injection ✅
|
||||
|
||||
5. **Reboot Speaker** → speaker came back online after ~30 s
|
||||
|
||||
### Post-Migration
|
||||
|
||||
- Paired speaker to Bose account via app — succeeded ✅
|
||||
- Set presets via app — worked ✅
|
||||
- Mirroring active and functional during session ✅
|
||||
- No visible errors in app behaviour; service logs and interaction recordings not yet reviewed in detail
|
||||
|
||||
### Known Shell Warning (safe to ignore)
|
||||
|
||||
Two commands produced `sh: rw: command not found`. This occurs because the service wraps commands with `(rw || ...)` as a fallback pattern, but the shell on the ST10 interprets `rw` as a bare command rather than a shell variable/flag. The primary command (`touch`, `mount`) still succeeds. This is a known cosmetic issue in the migration output.
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [CAPTURE-DEVICE-PAIRING.md](CAPTURE-DEVICE-PAIRING.md) — MITM setup and pairing capture
|
||||
- [MIGRATION-GUIDE.md](MIGRATION-GUIDE.md) — full migration reference
|
||||
- [SOUNDTOUCH-SERVICE.md](SOUNDTOUCH-SERVICE.md) — service architecture and configuration
|
||||
- [BOSE-APP-ADB-Emulator.md](../analysis/BOSE-APP-ADB-Emulator.md) — Frida + mitmproxy setup
|
||||
@@ -25,13 +25,17 @@ Used by most modern SoundTouch devices (ST-10, ST-20/30 Series III, SoundTouch 3
|
||||
The classic "failover" or "alternate" setup method.
|
||||
|
||||
- **Mechanism**: The device creates its own Wi-Fi network (SSID: `Bose SoundTouch ...` or `Bose Home Speaker ...`).
|
||||
- **IP Address**: Typically `192.168.1.1` or `10.0.0.1` (device-side).
|
||||
- **IP Address**: Typically `192.0.2.1` (device-side, verified on ST10).
|
||||
- **Web Interface**: The device hosts a web server on port 80.
|
||||
- **Process**:
|
||||
1. Connect a PC/Phone to the device's Wi-Fi.
|
||||
2. Open a browser to `http://192.168.1.1`.
|
||||
3. The device serves `setup.html`, which redirects to a setup wizard (`setup/index.html`).
|
||||
4. Use the `gabbo_wifi` form to select a network and enter credentials.
|
||||
2. Open a browser to `http://192.0.2.1`.
|
||||
3. The device serves a Wi-Fi setup form — enter your home network SSID and password and click Submit.
|
||||
4. The device disconnects from AP mode and joins your home network within ~15–30 seconds.
|
||||
|
||||

|
||||
|
||||
For command-line provisioning (without a browser), see §6 below.
|
||||
|
||||
---
|
||||
|
||||
@@ -69,12 +73,96 @@ While the `soundtouch-service` focuses on migrating existing devices, a truly "c
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 5. Factory Reset Button Sequences
|
||||
|
||||
A factory reset wipes Wi-Fi credentials, account pairing, and all presets, returning the device to out-of-box state. The exact sequence varies by hardware generation.
|
||||
|
||||
> Sequences verified against official Bose reset guides in `firmware/FirmwareUpdateGuide/`. Confirm the reset succeeded by watching the status LEDs and by verifying the Wi-Fi indicator glows solid amber (setup mode).
|
||||
|
||||
| Model | Factory Restore Sequence | Confirm |
|
||||
|--------------------------|---------------------------------------------------------------------|------------------------------------|
|
||||
| SoundTouch 10 | Power on; hold **Preset 1** + **Volume −** for 10 s | Wi-Fi indicator glows solid amber |
|
||||
| SoundTouch 20 | Power on; hold **Preset 1** + **Volume −** for 10 s | Lights blink L→R, then solid amber |
|
||||
| SoundTouch 20 Series III | Hold **Preset 1** + **Preset 6** simultaneously for ~10 s | White LED sweep |
|
||||
| SoundTouch 30 Series III | Hold **Preset 1** + **Preset 6** simultaneously for ~10 s | White LED sweep |
|
||||
| SoundTouch 300 | Hold **Volume −** until light bar blinks rapidly (~15 s) | Rapid blink → off → on |
|
||||
| SoundTouch 10 (alt) | Press and hold the back recessed **Reset** pinhole for 10 s | Status LED restarts |
|
||||
| SoundTouch 20 (soft) | Hold **AUX** for 15 s until display goes blank (settings preserved) | Display blanks |
|
||||
|
||||
After factory restore the speaker enters setup mode automatically; no power-cycle is needed.
|
||||
|
||||
---
|
||||
|
||||
## 6. AP Mode Wi-Fi Provisioning via Console
|
||||
|
||||
When BLE is unavailable (e.g. when using an Android emulator), use AP mode to push Wi-Fi credentials from the Mac command line.
|
||||
|
||||
### 6.1 Connect Mac to Speaker AP
|
||||
|
||||
After factory reset the speaker broadcasts an SSID like `Bose SoundTouch XXXX`. Connect the Mac to it:
|
||||
|
||||
```bash
|
||||
# List nearby SSIDs — use System Settings → Wi-Fi (the airport command was removed in macOS Sequoia+)
|
||||
# Connect (replace with actual SSID)
|
||||
networksetup -setairportnetwork en0 "Bose SoundTouch XXXX"
|
||||
```
|
||||
|
||||
The speaker's web UI gateway is at `192.0.2.1` (verified: ST10 assigns `192.0.2.2` to the client via DHCP).
|
||||
|
||||
```bash
|
||||
# Confirm reachability
|
||||
curl -sv http://192.0.2.1/ 2>&1 | head -40
|
||||
```
|
||||
|
||||
### 6.2 Trigger Wi-Fi Site Survey (Optional)
|
||||
|
||||
The setup web UI at `http://192.0.2.1/` uses the SoundTouch API on **port 8090** — the same API as normal device operation. Trigger a network scan first so the speaker finds your SSID:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://192.0.2.1:8090/performWirelessSiteSurvey \
|
||||
-H 'Content-Type: text/xml' \
|
||||
--data-raw '<PerformWirelessSiteSurvey timeout="5"/>'
|
||||
```
|
||||
|
||||
### 6.3 Push Home Wi-Fi Credentials
|
||||
|
||||
```bash
|
||||
HOME_SSID="MyHomeNetwork"
|
||||
HOME_PASS="MyPassword"
|
||||
|
||||
curl -s -X POST http://192.0.2.1:8090/addWirelessProfile \
|
||||
-H 'Content-Type: text/xml' \
|
||||
--data-raw "<AddWirelessProfile><profile ssid=\"${HOME_SSID}\" password=\"${HOME_PASS}\" securityType=\"wpa_or_wpa2\" /></AddWirelessProfile>"
|
||||
```
|
||||
|
||||
Expected response: `<?xml version="1.0" encoding="UTF-8" ?><AddWirelessProfileResponse />`
|
||||
|
||||
The speaker will disconnect from AP mode and join the home network within ~15–30 s.
|
||||
|
||||
### 6.4 Reconnect Mac to Home Network
|
||||
|
||||
```bash
|
||||
networksetup -setairportnetwork en0 "MyHomeNetwork" "MyPassword"
|
||||
```
|
||||
|
||||
Wait ~15 s for the speaker to join the home network, then verify:
|
||||
|
||||
```bash
|
||||
# Discover the speaker's new IP via mDNS
|
||||
dns-sd -B _soundtouch._tcp local &
|
||||
sleep 5 ; kill %1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comparison: Initial Setup vs. Migration
|
||||
|
||||
| Feature | Initial Setup | Migration (soundtouch-service) |
|
||||
| :--- | :--- | :--- |
|
||||
| **Connectivity** | BLE, AP Mode, USB, WAC | Ethernet/Wi-Fi (existing) |
|
||||
| **Credentials** | Required (SSID/Pass) | Not required (uses existing) |
|
||||
| **Access** | Web UI / App protocol | SSH (root) |
|
||||
| **Primary File** | `setup/index.html` | `SoundTouchSdkPrivateCfg.xml` |
|
||||
| **Use Case** | Out-of-the-box / Reset | Redirecting active devices |
|
||||
| Feature | Initial Setup | Migration (soundtouch-service) |
|
||||
|:-----------------|:-----------------------|:-------------------------------|
|
||||
| **Connectivity** | BLE, AP Mode, USB, WAC | Ethernet/Wi-Fi (existing) |
|
||||
| **Credentials** | Required (SSID/Pass) | Not required (uses existing) |
|
||||
| **Access** | Web UI / App protocol | SSH (root) |
|
||||
| **Primary File** | `setup/index.html` | `SoundTouchSdkPrivateCfg.xml` |
|
||||
| **Use Case** | Out-of-the-box / Reset | Redirecting active devices |
|
||||
|
||||
@@ -1,81 +1,64 @@
|
||||
# HTTPS Setup & Custom CA Certificate
|
||||
# HTTPS & Custom CA Certificate
|
||||
|
||||
To use the `/etc/hosts` redirection method safely, SoundTouch devices must communicate over HTTPS. This requires the device to trust the AfterTouch Root CA certificate used by the local service.
|
||||
SoundTouch speakers communicate with cloud services over HTTPS. For the local service to work over HTTPS, speakers must trust the AfterTouch Root CA. The service manages this automatically — it generates a CA on first start and the web UI guides you through installing it on each speaker as part of the migration flow.
|
||||
|
||||
## 1. Automated Migration (Hosts Method)
|
||||
---
|
||||
|
||||
The `soundtouch-service` can automatically configure a device to use the `/etc/hosts` method:
|
||||
## How TLS works in AfterTouch
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8000/setup/migrate/{deviceIP}?method=hosts"
|
||||
The service includes a built-in HTTPS listener (default port `8443`) that presents a certificate covering all Bose cloud hostnames. The certificate is signed by the AfterTouch Root CA, which is generated automatically on first start and stored in `data/certs/`.
|
||||
|
||||
**Domain coverage** — the certificate covers:
|
||||
- Wildcard: `*.api.bose.io`, `*.api.bosecm.com`
|
||||
- Specific: `streaming.bose.com`, `bmx.bose.com`, `stats.bose.com`, `updates.bose.com`, `worldwide.bose.com`, `bose-prod.apigee.net`, `media.bose.io`, `downloads.bose.com`, `voice.api.bose.io`, and more
|
||||
|
||||
> **Note**: The hostname you configure as `HTTPS_SERVER_URL` (e.g. `https://soundtouch.fritz.box:8443`) is also added as a Subject Alternative Name, ensuring valid TLS for direct browser or API access.
|
||||
|
||||
---
|
||||
|
||||
## CA trust installation (via web UI)
|
||||
|
||||
The migration flow in the web UI includes a CA trust step that:
|
||||
1. Uploads the Root CA to the speaker via SSH
|
||||
2. Appends it to the speaker's shared trust store (`/etc/pki/tls/certs/ca-bundle.crt`)
|
||||
3. Verifies connectivity over HTTPS
|
||||
|
||||
This is handled automatically — you don't need to manage CA files manually unless you're doing an advanced or manual setup.
|
||||
|
||||
---
|
||||
|
||||
## Downloading the CA certificate
|
||||
|
||||
You can download the Root CA for manual installation on other devices (phones, PCs, additional speakers):
|
||||
|
||||
```
|
||||
http://<server>:8000/setup/ca.crt
|
||||
```
|
||||
|
||||
This command will:
|
||||
1. Connect to the device via SSH.
|
||||
2. Update `/etc/hosts` to point Bose domains to the service IP.
|
||||
3. Inject the auto-generated AfterTouch Root CA into the device's trust store (`/etc/pki/tls/certs/ca-bundle.crt`).
|
||||
4. Reboot the device.
|
||||
---
|
||||
|
||||
## 2. Managing the Root CA
|
||||
## Binding to port 443
|
||||
|
||||
The AfterTouch service automatically generates a Root CA when it first starts.
|
||||
Speakers expect HTTPS on the default port 443. Since binding to port 443 requires elevated privileges, you have three options:
|
||||
|
||||
- **CA Certificate**: `data/certs/ca.crt`
|
||||
- **CA Private Key**: `data/certs/ca.key`
|
||||
1. **Port forwarding (recommended)**: Run the service on port 8443 and forward port 443 to it using `iptables` or your firewall/router.
|
||||
2. **Capabilities**: Grant the binary permission to bind low ports: `sudo setcap 'cap_net_bind_service=+ep' ./soundtouch-service`
|
||||
3. **Reverse proxy**: Use Nginx or Caddy in front of the service (see below).
|
||||
|
||||
### Downloading the CA Certificate
|
||||
You can download the CA certificate for manual installation on other devices (like your phone or PC) from:
|
||||
`http://<server-ip>:8000/setup/ca.crt`
|
||||
---
|
||||
|
||||
### 3. Built-in HTTPS Support
|
||||
## Reverse proxy (optional)
|
||||
|
||||
The `soundtouch-service` now includes a built-in HTTPS listener. This simplifies the `/etc/hosts` redirection method by automatically presenting the correct certificates for Bose domains.
|
||||
|
||||
- **HTTPS Port**: Configurable via `HTTPS_PORT` environment variable (defaults to `8443`).
|
||||
- **HTTPS Server URL**: Configurable via `HTTPS_SERVER_URL` (e.g., `https://mysoundtouch.local:8443`). If not set, the service attempts to guess it using the system hostname.
|
||||
- **Domain Coverage**: Automatically presents a certificate with comprehensive coverage using wildcard certificates (`*.api.bose.io`, `*.api.bosecm.com`) plus specific domains (`streaming.bose.com`, `updates.bose.com`, `stats.bose.com`, `bmx.bose.com`, `worldwide.bose.com`, `bose-prod.apigee.net`, etc.).
|
||||
- **Wildcard Support**: Uses RFC-compliant wildcard certificates for automatic coverage of all API subdomains, including event analytics endpoints like `events.api.bosecm.com`, `eventsdev.api.bosecm.com`, and future API services.
|
||||
- **TLS Error Logging**: Comprehensive logging of TLS handshake attempts, certificate matching, and connection failures for debugging DNS redirection issues.
|
||||
- **Automatic Setup**: On first start, it generates a server certificate signed by your AfterTouch local Root CA.
|
||||
|
||||
#### TLS Security & Debugging
|
||||
|
||||
The built-in HTTPS listener is configured to use modern and secure TLS settings while maintaining compatibility with SoundTouch devices (which support up to TLS 1.2 with OpenSSL 1.0.2).
|
||||
|
||||
- **Minimum TLS Version**: TLS 1.2
|
||||
- **Preferred Cipher Suites**:
|
||||
- `ECDHE-RSA-AES128-GCM-SHA256`
|
||||
- **TLS Debugging**: Detailed logging of:
|
||||
- Certificate requests by domain (`[TLS] Certificate request for ServerName: events.api.bosecm.com`)
|
||||
- Wildcard certificate matching (`[TLS] ✅ Serving certificate for events.api.bosecm.com (matched *.api.bosecm.com)`)
|
||||
- Handshake failures (`[TLS] ❌ Handshake failed from 192.168.1.50: tls: certificate not found`)
|
||||
- Successful connections (`[TLS] ✅ Successful connection from 192.168.1.50`)
|
||||
- `ECDHE-RSA-AES256-GCM-SHA384`
|
||||
- `ECDHE-RSA-CHACHA20-POLY1305`
|
||||
- `RSA-AES128-GCM-SHA256` (Legacy support)
|
||||
- `RSA-AES256-GCM-SHA384` (Legacy support)
|
||||
|
||||
#### Binding to Port 443
|
||||
SoundTouch devices expect HTTPS on the default port 443. Since binding to port 443 usually requires root privileges, you have two options:
|
||||
|
||||
1. **Port Forwarding (Recommended)**: Run the service on a high port (e.g., 8443) and use `iptables` or your firewall to forward traffic from 443 to 8443.
|
||||
2. **Capabilities**: Grant the binary permission to bind to low ports: `sudo setcap 'cap_net_bind_service=+ep' ./soundtouch-service`.
|
||||
3. **Reverse Proxy**: Use Nginx or Caddy as described below.
|
||||
|
||||
### 4. Reverse Proxy (Optional)
|
||||
|
||||
1. **Generate a certificate** for the Bose domains signed by your Root CA.
|
||||
2. **Configure Nginx** to use this certificate and proxy requests to `soundtouch-service`.
|
||||
If you prefer to use Nginx or another proxy for TLS termination:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name streaming.bose.com bmx.bose.com stats.bose.com updates.bose.com;
|
||||
|
||||
ssl_certificate /path/to/generated-cert.crt;
|
||||
ssl_certificate_key /path/to/generated-cert.key;
|
||||
ssl_certificate /path/to/data/certs/server.crt;
|
||||
ssl_certificate_key /path/to/data/certs/server.key;
|
||||
|
||||
# Secure TLS configuration (matches soundtouch-service defaults)
|
||||
ssl_protocols TLSv1.2;
|
||||
ssl_ciphers 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-CHACHA20-POLY1305:AES128-GCM-SHA256:AES256-GCM-SHA384';
|
||||
|
||||
@@ -87,23 +70,26 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Manual CA Injection (Legacy/Manual)
|
||||
---
|
||||
|
||||
If you prefer to inject the CA certificate manually:
|
||||
## Manual CA injection (advanced)
|
||||
|
||||
1. Copy `ca.crt` to the device:
|
||||
```bash
|
||||
scp data/certs/ca.crt root@{deviceIP}:/tmp/
|
||||
```
|
||||
2. Append it to the trust store on the device:
|
||||
```bash
|
||||
ssh root@{deviceIP} "(rw || mount -o remount,rw /) && cat /tmp/ca.crt >> /etc/pki/tls/certs/ca-bundle.crt"
|
||||
```
|
||||
If you need to inject the CA manually (e.g. without the web UI migration flow):
|
||||
|
||||
## 6. Verifying Connectivity
|
||||
```bash
|
||||
# Copy the CA to the speaker
|
||||
scp data/certs/ca.crt root@<SPEAKER-IP>:/tmp/
|
||||
|
||||
You can verify that your device can correctly reach the `soundtouch-service` over HTTPS using the management web UI.
|
||||
# Make the filesystem writable and append the CA to the trust store
|
||||
ssh root@<SPEAKER-IP> "(rw || mount -o remount,rw /) && cat /tmp/ca.crt >> /etc/pki/tls/certs/ca-bundle.crt"
|
||||
```
|
||||
|
||||
In the **Migration Summary** for a device, you will find an **HTTPS Connection Test** section:
|
||||
- **Test with Explicit CA.crt**: Uploads a temporary copy of the Root CA to the device and uses `curl --cacert` to verify the connection. Use this to verify your HTTPS setup *before* modifying the device's shared trust store.
|
||||
- **Test with Shared Trust Store**: Uses the device's default trust store. Use this to verify that your CA injection was successful and the device now natively trusts your local server.
|
||||
---
|
||||
|
||||
## TLS compatibility
|
||||
|
||||
SoundTouch speakers run OpenSSL 1.0.2, supporting up to TLS 1.2. The service is configured accordingly:
|
||||
|
||||
- **Minimum TLS version**: TLS 1.2
|
||||
- **Preferred cipher suites**: `ECDHE-RSA-AES128-GCM-SHA256`, `ECDHE-RSA-AES256-GCM-SHA384`, `ECDHE-RSA-CHACHA20-POLY1305`
|
||||
- **Legacy support**: `RSA-AES128-GCM-SHA256`, `RSA-AES256-GCM-SHA384`
|
||||
@@ -1,418 +1,193 @@
|
||||
# THIS IS A PLANNED TO BE THE MIGRATION GUIDE
|
||||
# Migration Guide: From Bose Cloud to AfterTouch
|
||||
|
||||
> This migration guide is not finalized, yet.
|
||||
> We're using it as an orientation for the required implementation.
|
||||
This guide walks through the complete process of migrating your SoundTouch speakers from Bose's cloud services to **AfterTouch**, the local replacement provided by `soundtouch-service`. By the end, your speakers will work fully independently of Bose's servers.
|
||||
|
||||
For a shorter overview, see the [Survival Guide](SURVIVAL-GUIDE.md). For safety considerations and rollback options, see the [Migration & Safety Guide](MIGRATION-SAFETY.md).
|
||||
|
||||
---
|
||||
|
||||
# Complete Migration Guide - From Bose Cloud to Local SoundTouch Service
|
||||
## What you need
|
||||
|
||||
## Overview
|
||||
- A machine that is **always on** (Raspberry Pi, NAS, home server, or similar) to run the service
|
||||
- A **USB drive** (FAT-formatted) to enable SSH on each speaker
|
||||
- Your speakers must be on the **same network** as the service host
|
||||
- About **15–30 minutes per speaker**
|
||||
|
||||
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.
|
||||
## Step 1: Install and start the service
|
||||
|
||||
## What You'll Need
|
||||
Choose the option that fits your setup.
|
||||
|
||||
### Hardware Requirements
|
||||
- **Raspberry Pi 4 or similar** (minimum: Raspberry Pi Zero 2W)
|
||||
- **MicroSD card** (16GB or larger)
|
||||
- **USB drive** (for device preparation)
|
||||
- **Network connection** for your Raspberry Pi
|
||||
|
||||
### Before You Start
|
||||
- **List all your SoundTouch devices** and their current locations
|
||||
- **Note your current presets and favorites** (they will be preserved)
|
||||
- **Ensure devices are on the same network** as your future SoundTouch service
|
||||
- **Basic computer skills** (following instructions, using a web browser)
|
||||
|
||||
### Time Estimate
|
||||
- **Setup**: 30-60 minutes for the service installation
|
||||
- **Per Device**: 10-15 minutes for each speaker migration
|
||||
- **Total**: 1-3 hours depending on number of devices
|
||||
|
||||
## Step 1: Install SoundTouch Service
|
||||
|
||||
### Option A: Raspberry Pi Installation (Recommended)
|
||||
|
||||
#### 1.1 Prepare Your Raspberry Pi
|
||||
|
||||
1. **Flash Raspberry Pi OS** to your SD card using Raspberry Pi Imager (see the raspberrypi.com documentation)
|
||||
2. **Enable SSH** during imaging or create an empty `ssh` file on the boot partition
|
||||
3. **Boot your Pi** and connect it to your network
|
||||
4. **Find your Pi's IP address** (check your router or use `ping raspberrypi.local`)
|
||||
|
||||
#### 1.2 Install SoundTouch Service
|
||||
|
||||
Connect to your Pi via SSH and run:
|
||||
### Binary (go install)
|
||||
|
||||
```bash
|
||||
# Download and install
|
||||
curl -sSL https://github.com/gesellix/Bose-SoundTouch/releases/latest/download/install.sh | bash
|
||||
|
||||
# Start the service
|
||||
sudo systemctl enable soundtouch-service
|
||||
sudo systemctl start soundtouch-service
|
||||
go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-service@latest
|
||||
soundtouch-service
|
||||
```
|
||||
|
||||
#### 1.3 Verify Installation
|
||||
The service starts on port 8000. Open `http://localhost:8000` in your browser.
|
||||
|
||||
1. Open your web browser
|
||||
2. Go to `http://[PI_IP_ADDRESS]:8000` (replace with your Pi's IP)
|
||||
3. You should see the **SoundTouch Service Dashboard**
|
||||
### Docker Compose (recommended for home servers and VMs)
|
||||
|
||||

|
||||
*Example: SoundTouch Service main dashboard*
|
||||
The repository ships a `docker-compose.yml` ready for this use case. Clone or download it, copy the example config, then edit `.env` before starting:
|
||||
|
||||
### Option B: Docker Installation
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env:
|
||||
# SOUNDTOUCH_HOSTNAME=192.168.1.100 ← your server's address
|
||||
# SOUNDTOUCH_VERSION=v0.70.0 ← pin to a release tag instead of 'latest'
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
If you prefer Docker, run:
|
||||
`SOUNDTOUCH_HOSTNAME` is the address your speakers will use to reach the service — use a hostname or IP reachable from the speaker, not `localhost`.
|
||||
|
||||
On **Linux** (Debian, Proxmox VE, Raspberry Pi OS, etc.) you can enable host networking for automatic speaker discovery. Uncomment the `network_mode: host` line in `docker-compose.yml` and remove the `ports:` section (they conflict with host networking). Without host networking, add your speakers by IP address in Step 4 instead.
|
||||
|
||||
For local overrides (e.g. switching to `build: .` during development), create a `docker-compose.override.yml` — Docker Compose picks it up automatically and it is not tracked in version control.
|
||||
|
||||
> **Note on `docker-compose.ci.yml`**: this file contains mock services used only for automated integration tests. It is not needed for your own deployment.
|
||||
|
||||
### Docker run (Linux — with host networking for device discovery)
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name soundtouch-service \
|
||||
--restart unless-stopped \
|
||||
-p 8000:8000 \
|
||||
-p 8443:8443 \
|
||||
-v soundtouch-data:/data \
|
||||
gesellix/soundtouch-service:latest
|
||||
--network host \
|
||||
-v $(pwd)/data:/app/data \
|
||||
ghcr.io/gesellix/bose-soundtouch:latest
|
||||
```
|
||||
|
||||
## Step 2: Create Your Account
|
||||
### Docker run (macOS / Windows — manual device IP required)
|
||||
|
||||
### 2.1 Initial Setup
|
||||
|
||||
1. **Open the dashboard** at `http://[SERVICE_IP]:8000`
|
||||
2. Click **"Create New Account"**
|
||||
3. **Fill in your details**:
|
||||
- Account Name: `My Home Audio`
|
||||
- Email: `your@email.com` (optional, for notifications)
|
||||
- Migration Strategy: `Gradual` (recommended)
|
||||
|
||||

|
||||
*Example: Account creation form*
|
||||
|
||||
### 2.2 Account Configuration
|
||||
|
||||
After creation, you'll see your **Account Dashboard**:
|
||||
- **Account ID**: Unique identifier (e.g., `acc_home_audio_001`)
|
||||
- **Status**: `Active - Ready for Migration`
|
||||
- **Device Count**: Initially 0
|
||||
- **Migration Status**: `Prepared`
|
||||
|
||||

|
||||
*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.
|
||||
|
||||
### 3.1 Enable Remote Services
|
||||
|
||||
For each SoundTouch device:
|
||||
|
||||
1. **Prepare a USB drive**:
|
||||
- Format as FAT32
|
||||
- Create an empty file named `remote_services` (no extension)
|
||||
- ~~(Optional) Firmware update/reset.~~ The official Bose SoundTouch USB update website is not available anymore.
|
||||
|
||||
2. **Insert USB drive** into your SoundTouch speaker
|
||||
3. **Power cycle** the device (unplug for 10 seconds, then reconnect)
|
||||
|
||||

|
||||
*Example: USB drive setup for enabling remote services*
|
||||
|
||||
## Step 4: Discover and Register Devices
|
||||
|
||||
### 4.1 Automatic Discovery
|
||||
|
||||
The service automatically scans for SoundTouch devices every 5 minutes. To trigger immediate discovery:
|
||||
|
||||
1. **Dashboard** → **"Devices"** → **"Discover Devices"**
|
||||
2. **Wait 30-60 seconds** for scan completion
|
||||
3. **Review discovered devices** in the list
|
||||
|
||||
### 4.2 Register Devices to Your Account
|
||||
|
||||
For each discovered device:
|
||||
|
||||
1. **Click device name** in the discovery list
|
||||
2. **Verify device information**:
|
||||
- Name: `Living Room Speaker`
|
||||
- Model: `SoundTouch 30`
|
||||
- MAC Address: `A8:1B:6A:53:6A:98`
|
||||
- IP Address: `192.168.1.100`
|
||||
- Status: `Discovered - Ready for Registration`
|
||||
|
||||
3. **Click "Register to Account"**
|
||||
4. **Choose registration type**:
|
||||
- **Fresh Setup**: For new or factory-reset devices
|
||||
- **Migrate from Bose**: For devices with existing Bose account (recommended)
|
||||
|
||||

|
||||
*Example: Device registration dialog with migration options*
|
||||
|
||||
### 4.3 Device Registration Results
|
||||
|
||||
After registration, you'll see:
|
||||
- **Device Status**: `Registered - Active`
|
||||
- **Account Association**: Your account name
|
||||
- **Lifecycle State**: `Active`
|
||||
- **Data Sources**: `Mirror Primary` (initially uses Bose, falls back to local)
|
||||
|
||||
## Step 5: Migrate Individual Devices
|
||||
|
||||
### 5.1 Step 3: Data Sync
|
||||
|
||||
1. **Dashboard** → **"Devices"** → Select your device
|
||||
2. Click **"Data Sync"**
|
||||
3. This fetches configuration (presets, recents, sources) from the speaker to the SoundTouch service.
|
||||
|
||||
### 5.2 Step 4: Migration
|
||||
|
||||
Once data is synced, proceed to the migration tab for the device:
|
||||
|
||||
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.
|
||||
|
||||
#### 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
|
||||
|
||||
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
|
||||
```bash
|
||||
docker run -d \
|
||||
--name soundtouch-service \
|
||||
-p 8000:8000 -p 8443:8443 \
|
||||
-v $(pwd)/data:/app/data \
|
||||
--env SERVER_URL=http://soundtouch.local:8000 \
|
||||
--env HTTPS_SERVER_URL=https://soundtouch.local:8443 \
|
||||
ghcr.io/gesellix/bose-soundtouch:latest
|
||||
```
|
||||
|
||||
## Step 7: Complete Account Migration
|
||||
On macOS/Windows, device discovery via mDNS won't work inside the container — you'll add devices by IP address in Step 4.
|
||||
|
||||
### 7.1 Migrate All Devices
|
||||
|
||||
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`
|
||||
- **Currently Migrating**: `Living Room Speaker, Kitchen Speaker`
|
||||
- **Pending Migration**: `Bedroom Speaker, Office Speaker`
|
||||
- **Estimated Completion**: `3 days remaining`
|
||||
|
||||

|
||||
*Example: Account-wide migration progress*
|
||||
|
||||
### 7.2 Verify Complete Migration
|
||||
|
||||
When all devices are migrated:
|
||||
|
||||
1. **Account Status**: `Active - Fully Migrated`
|
||||
2. **Bose Dependency**: `None`
|
||||
3. **Local Control**: `100%`
|
||||
4. **Device Health**: All devices show `Healthy - Local Only`
|
||||
|
||||

|
||||
*Example: Completed migration dashboard*
|
||||
|
||||
## Step 8: Post-Migration Tasks
|
||||
|
||||
1. **Remove USB stick** from the speaker.
|
||||
2. **Reboot** the device to apply all changes.
|
||||
|
||||
### 8.1 Disable Remote Services (Optional)
|
||||
|
||||
For enhanced security, you can disable SSH on migrated devices. However, keeping it enabled allows for easier future maintenance or reverts.
|
||||
|
||||
### 8.2 Configure Backups
|
||||
|
||||
Set up automatic backups of your device configurations:
|
||||
|
||||
1. **Dashboard** → **"Settings"** → **"Backup"**
|
||||
2. **Enable Automatic Backups**: ✅
|
||||
3. **Backup Schedule**: `Daily at 2 AM`
|
||||
4. **Retention**: `Keep 30 days`
|
||||
5. **Export Location**: `/data/backups` or external storage
|
||||
|
||||

|
||||
*Example: Backup configuration settings*
|
||||
|
||||
### 8.3 Set Up Monitoring Alerts (Optional)
|
||||
|
||||
Configure notifications for important events:
|
||||
|
||||
1. **Dashboard** → **"Settings"** → **"Notifications"**
|
||||
2. **Email Notifications**: Enter your email
|
||||
3. **Alert Types**:
|
||||
- ✅ Device goes offline
|
||||
- ✅ Migration failures
|
||||
- ✅ Service errors
|
||||
- ✅ Daily health summary
|
||||
|
||||
## Troubleshooting Common Issues
|
||||
|
||||
### Device Not Discovered
|
||||
|
||||
**Problem**: Device doesn't appear in discovery scan
|
||||
|
||||
**Solutions**:
|
||||
1. **Check network**: Ensure device and service are on same network
|
||||
2. **Verify USB setup**: Confirm `remote_services` file was processed
|
||||
3. **Power cycle**: Unplug device for 30 seconds, reconnect
|
||||
4. **Manual add**: Dashboard → "Devices" → "Add Manually" with IP address
|
||||
|
||||
### Migration Stuck
|
||||
|
||||
**Problem**: Device stuck in "Migrating" status
|
||||
|
||||
**Solutions**:
|
||||
1. **Check device health**: Dashboard → Device → "Health Status"
|
||||
2. **Review logs**: Dashboard → Device → "View Logs"
|
||||
3. **Restart migration**: Device → "Migration" → "Restart Process"
|
||||
4. **Rollback**: Device → "Migration" → "Rollback to Bose"
|
||||
|
||||
### Presets Not Working
|
||||
|
||||
**Problem**: Saved presets don't work after migration
|
||||
|
||||
**Solutions**:
|
||||
1. **Verify sources**: Check configured sources are still available
|
||||
2. **Re-authenticate**: Re-login to music services (Spotify, etc.)
|
||||
3. **Rebuild presets**: Dashboard → Device → "Presets" → "Rebuild from Backup"
|
||||
|
||||
### Service Unreachable
|
||||
|
||||
**Problem**: Cannot access SoundTouch Service dashboard
|
||||
|
||||
**Solutions**:
|
||||
1. **Check service status**: `sudo systemctl status soundtouch-service`
|
||||
2. **Restart service**: `sudo systemctl restart soundtouch-service`
|
||||
3. **Check network**: Verify Pi is connected and accessible
|
||||
4. **Check ports**: Ensure ports 8000 and 8443 are not blocked
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Multi-Zone Management
|
||||
|
||||
After migration, your multi-zone setups work seamlessly:
|
||||
|
||||
1. **Dashboard** → **"Zones"**
|
||||
2. **Create Zone**: Select primary device and slaves
|
||||
3. **Zone Control**: Play, pause, volume control for entire zone
|
||||
4. **Individual Control**: Override individual speakers in zone
|
||||
|
||||
### Custom Sources
|
||||
|
||||
Add custom streaming sources:
|
||||
|
||||
1. **Dashboard** → **"Sources"** → **"Add Custom"**
|
||||
2. **Configure**:
|
||||
- Name: `Local Radio Station`
|
||||
- Stream URL: `http://stream.example.com:8000`
|
||||
- Image URL: `http://example.com/logo.png`
|
||||
3. **Assign to devices**: Select which devices can access this source
|
||||
|
||||
### API Access
|
||||
|
||||
For developers and advanced users:
|
||||
|
||||
- **REST API**: `http://[SERVICE_IP]:8000/api/v1/`
|
||||
- **Documentation**: `http://[SERVICE_IP]:8000/docs`
|
||||
- **WebSocket Events**: Real-time device status updates
|
||||
- **Export Data**: JSON/XML export of all device configurations
|
||||
|
||||
## Maintenance and Monitoring
|
||||
|
||||
### Daily Monitoring
|
||||
|
||||
Check your **Dashboard Summary**:
|
||||
- **All Devices Online**: ✅ Green indicators
|
||||
- **Response Times**: < 100ms average
|
||||
- **Error Rate**: < 1%
|
||||
- **Storage Usage**: Monitor disk space
|
||||
|
||||
### Weekly Tasks
|
||||
|
||||
1. **Review Health Reports**: Check weekly device health summaries
|
||||
2. **Update Service**: Check for SoundTouch service updates
|
||||
3. **Backup Verification**: Ensure backups are completing successfully
|
||||
4. **Log Review**: Check for any recurring issues or warnings
|
||||
|
||||
### Monthly Tasks
|
||||
|
||||
1. **Full System Backup**: Export complete account and device data
|
||||
2. **Performance Review**: Analyze response times and error patterns
|
||||
3. **Security Update**: Update Raspberry Pi OS and service
|
||||
4. **Capacity Planning**: Monitor storage and consider expansion
|
||||
|
||||
## Getting Help
|
||||
|
||||
### Documentation Resources
|
||||
|
||||
- **Technical Reference**: `/docs/reference/` - Detailed API and configuration docs
|
||||
- **Troubleshooting Guide**: `/docs/guides/TROUBLESHOOTING.md` - Common issues and solutions
|
||||
- **Community Forum**: GitHub Discussions for community support
|
||||
|
||||
### Diagnostic Information
|
||||
|
||||
When seeking help, provide:
|
||||
|
||||
1. **System Information**: Dashboard → "System" → "Download Diagnostic Report"
|
||||
2. **Device Logs**: Dashboard → Device → "Export Logs"
|
||||
3. **Migration History**: Dashboard → "Migration" → "Export Timeline"
|
||||
4. **Current Status**: Screenshot of main dashboard
|
||||
|
||||
### Support Channels
|
||||
|
||||
- **GitHub Issues**: Technical bugs and feature requests
|
||||
- **Community Discussions**: User questions and experiences
|
||||
- **Documentation Updates**: Corrections and improvements
|
||||
See [Raspberry Pi Setup](RASPBERRY-PI.md) and the [SoundTouch Service Guide](SOUNDTOUCH-SERVICE.md) for more deployment options.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
## Step 2: Configure the service URL
|
||||
|
||||
Congratulations! 🎉 You've successfully migrated your SoundTouch speakers to local control. Your devices are now:
|
||||
Open `http://<server>:8000` and go to the **Settings** tab.
|
||||
|
||||
- ✅ **Independent** of Bose cloud services
|
||||
- ✅ **Fully functional** with all original features preserved
|
||||
- ✅ **Enhanced** with better monitoring and control
|
||||
- ✅ **Future-proof** against service shutdowns
|
||||

|
||||
|
||||
**What's Next?**
|
||||
Set the **Target Domain** to the address your speakers can reach — for example `https://soundtouch.fritz.box` or `http://192.168.1.100:8000`. This must be the host's address on your local network, not `localhost`.
|
||||
|
||||
- **Enjoy your music** with enhanced local control
|
||||
- **Monitor your system** through the dashboard
|
||||
- **Share your experience** with the community
|
||||
- **Explore advanced features** as you become more comfortable
|
||||
If you plan to use DNS/DHCP redirect, enable the **DNS Discovery Server** and set the **DNS Bind Address** to `:53`. The upstream DNS should be your router's IP, not the service's own address.
|
||||
|
||||
Your SoundTouch speakers will now continue working indefinitely, regardless of external service availability. Welcome to true audio independence! 🔊
|
||||
> **Tip**: If you change settings and they don't seem to take effect, check `data/settings.json` — settings saved in the UI take precedence over environment variables.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Enable SSH on each speaker
|
||||
|
||||
The migration writes updated configuration to the speaker's filesystem, which requires SSH access. Enable it once per device:
|
||||
|
||||
1. Format a USB drive as FAT (FAT32). Some speakers require the **bootable flag** to be set on the partition — see [SoundCork issue #172](https://github.com/deborahgu/soundcork/issues/172) for details.
|
||||
2. Create an empty file named **`remote_services`** (no extension) in the root of the drive.
|
||||
3. Insert the drive into the speaker's USB port while it is powered on.
|
||||
4. Power-cycle the speaker (unplug the power cable, wait 10 seconds, reconnect).
|
||||
5. After boot, root SSH is available with no password: `ssh -oHostKeyAlgorithms=+ssh-rsa root@<SPEAKER-IP>`
|
||||
|
||||
You only need to do this once per speaker. SSH can remain enabled for future maintenance or be disabled after migration — your choice.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Add and sync your speaker
|
||||
|
||||
### Discover
|
||||
|
||||
The service scans for SoundTouch devices automatically every few minutes. Check the **Devices** tab in the web UI. If your speaker doesn't appear, click **Scan Again** to trigger an immediate scan, or enter the IP address manually and click **Add Device**.
|
||||
|
||||

|
||||
|
||||
### Sync
|
||||
|
||||
Once the speaker appears, click **Sync Data**. This connects to the speaker and pulls its current presets, recently played items, and configured sources into the local service's datastore. It also creates an off-device backup of the speaker's configuration.
|
||||
|
||||

|
||||
|
||||
If the Bose cloud is still running, Sync also fetches your account data from Bose's servers. This is your preservation step — do it before the cloud shuts down.
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Migrate
|
||||
|
||||
Click **Migrate** next to a device on the Devices tab to open the Migration tab. It shows SSH status, CA trust status, and connection test results before letting you apply the redirect.
|
||||
|
||||

|
||||
|
||||
Two redirect methods are available:
|
||||
|
||||
### XML redirect (recommended for first-time / testing)
|
||||
|
||||
Uploads a configuration file to the speaker via the SoundTouch Web API. This changes the application-level service URLs without touching the speaker's network configuration. It's the least invasive option.
|
||||
|
||||
The web UI guides you through:
|
||||
1. Previewing the config change (current vs. planned XML)
|
||||
2. Optionally installing the AfterTouch CA certificate on the speaker (requires SSH; needed for HTTPS)
|
||||
3. Applying the XML redirect
|
||||
4. Verifying the speaker can reach the local service
|
||||
|
||||
### DNS/DHCP redirect (recommended for permanent / all-device setup)
|
||||
|
||||
Configures the speaker to use a custom DNS server that resolves Bose cloud hostnames to the local service. This is the most robust method — it covers all Bose endpoints automatically and survives reboots.
|
||||
|
||||
Requirements:
|
||||
- The AfterTouch DNS server must be running and bound to **port 53** on your network. Enable it in the **Settings** tab (`DNS Discovery` → enabled).
|
||||
- HTTPS is required. The web UI walks you through trusting the CA certificate on the speaker (via SSH).
|
||||
|
||||
The web UI guides you through:
|
||||
1. Verifying the DNS server is running and reachable
|
||||
2. Installing the CA certificate on the speaker
|
||||
3. Configuring the speaker to use the AfterTouch DNS server
|
||||
4. Verifying DNS resolution and HTTPS connectivity
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Reboot and verify
|
||||
|
||||
After migration, **power-cycle the speaker** (unplug and replug). This applies all configuration changes.
|
||||
|
||||
After reboot:
|
||||
- The speaker should appear as **migrated** in the Devices tab
|
||||
- Presets should load and play (served from the local service)
|
||||
- TuneIn browsing should work
|
||||
- Recently played items should appear
|
||||
|
||||
If something doesn't work, check the **Interactions** tab in the web UI for failed requests, and the **Troubleshooting** section in the [SoundTouch Service Guide](SOUNDTOUCH-SERVICE.md).
|
||||
|
||||
---
|
||||
|
||||
## Repeat for each speaker
|
||||
|
||||
Each speaker is migrated independently. You can run multiple migrations in parallel, but migrating one at a time makes it easier to diagnose issues.
|
||||
|
||||
---
|
||||
|
||||
## Rollback
|
||||
|
||||
If you need to undo a migration:
|
||||
|
||||
- **From the web UI**: Use the **Revert** action on the device — this restores the `.original` backup files created on the speaker during migration.
|
||||
- **Via SSH**: The original config is backed up on the speaker with a `.original` suffix. Restore it manually if the UI is unreachable.
|
||||
- **Factory reset**: As a last resort, perform a factory reset (see [Device Initial Setup](DEVICE-INITIAL-SETUP.md) for button sequences). This wipes all configuration and returns the speaker to out-of-box state.
|
||||
|
||||
---
|
||||
|
||||
## Post-migration
|
||||
|
||||
Once all speakers are migrated, the `data/` directory is the source of truth for your presets, recents, and device state. Back it up periodically. The web UI at `http://<server>:8000` is your management interface from this point on.
|
||||
|
||||
For the Bose cloud backup you created in Step 4, keep the `.tar.gz` archive in case you need to restore credentials or presets later.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
### Professional Migration & Safety Guide
|
||||
# Migration & Safety Guide
|
||||
|
||||
Starting a migration on real hardware requires a "Safety First" approach. This guide outlines the safety features implemented in the `soundtouch-service` and provides a checklist for a successful migration.
|
||||
|
||||
@@ -14,8 +14,8 @@ The following features are built into the `soundtouch-service` to ensure stabili
|
||||
|
||||
Before you proceed with the actual migration, follow these steps:
|
||||
|
||||
1. **Enable SSH Access (Prerequisite)**: This toolkit requires SSH access to your speakers, which is not enabled by default.
|
||||
- Create an empty file named `remote_services` on a USB stick.
|
||||
1. **Enable SSH Access (Prerequisite)**: This toolkit requires SSH access to your speakers, which is not enabled by default.
|
||||
- Create a file named `remote_services` on a FAT-formatted USB drive. The drive may need its bootable flag set — see [SoundCork issue #172](https://github.com/deborahgu/soundcork/issues/172) for details.
|
||||
- Insert the USB stick into the SoundTouch speaker's **SERVICE** port.
|
||||
- Reboot the speaker (unplug and replug).
|
||||
- The speaker will now allow SSH connections as `root` with no password.
|
||||
@@ -27,10 +27,11 @@ Before you proceed with the actual migration, follow these steps:
|
||||
4. **Validate SSH Access**: Confirm the device responds to SSH without a password.
|
||||
- In the Web UI **Migration** tab, select your speaker and verify that the "SSH Connection" status shows ✅ Success.
|
||||
- This toolkit automatically handles the necessary SSH parameters (ciphers and key exchanges) required by older Bose firmware.
|
||||
5. **Migration Methods**:
|
||||
- **XML Migration (Default)**: Less invasive, only changes the application config. Best for simple redirection.
|
||||
- **Hosts Migration**: Modifies `/etc/hosts` on the device. Good for system-wide redirection of specific domains.
|
||||
- **ResolvConf Migration**: Points the device to the AfterTouch DNS server. Best for discovering unknown Bose endpoints and dynamic interception. **Note**: This method requires the DNS Discovery Server to be running on port 53. The service includes a pre-flight check to ensure the server is properly bound before allowing this migration.
|
||||
5. **Migration Methods**:
|
||||
- **XML redirect (default)**: Uploads a config file to the speaker via the Web API. Less invasive — only changes the application-level service URLs. Best for testing or single-device migration.
|
||||
- **DNS/DHCP redirect**: Configures the speaker to use a custom DNS server that resolves Bose hostnames to the local service. Best for all-device coverage; requires the AfterTouch DNS server running on port 53. The service includes a pre-flight check before applying this method.
|
||||
|
||||
The web UI walks you through both methods. Both require the CA certificate to be trusted on the speaker for HTTPS to work — the web UI handles this as part of the migration flow.
|
||||
6. **Monitor Logs**: Run the `soundtouch-service` with `DEBUG` or `INFO` logging to see the step-by-step progress of the migration.
|
||||
|
||||
#### 🔄 Rollback Strategy
|
||||
|
||||
@@ -7,7 +7,7 @@ The `soundtouch-service` is a comprehensive local server that emulates Bose's cl
|
||||
The service provides:
|
||||
|
||||
- **🏠 Local Service Emulation**: Complete BMX (Bose Media eXchange) and Marge service implementation
|
||||
- **🔧 Device Migration**: Seamlessly migrate devices from Bose cloud to local services via XML config, `/etc/hosts`, or `/etc/resolv.conf`
|
||||
- **🔧 Device Migration**: Migrate devices from Bose cloud to local services via XML redirect or DNS/DHCP redirect
|
||||
- **🔍 DNS Discovery & Interception**: Built-in DNS server to discover unknown Bose endpoints and selectively intercept cloud traffic
|
||||
- **📊 Traffic Proxying**: Inspect and log all device communications for debugging
|
||||
- **🌐 Web Management UI**: Browser-based interface for device management
|
||||
@@ -169,7 +169,7 @@ The service supports multiple ways to configure its behavior. When multiple sour
|
||||
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
|
||||
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
|
||||
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
|
||||
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for `resolv.conf` migration) | `:53` |
|
||||
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for DNS/DHCP migration) | `:53` |
|
||||
| `MIRROR_ENABLED` | | Enable background mirroring of specific endpoints to Bose cloud | `false` |
|
||||
| `MIRROR_ENDPOINTS` | | Comma-separated list of path patterns to mirror (e.g., `/streaming/account/*/device/*/recent`) | `[]` |
|
||||
| `INTERNAL_PATHS` | `--internal-paths` | Paths for internal requests to exclude from recording (e.g., `/setup/*`, `/web/*`) | `[]` |
|
||||
@@ -247,7 +247,7 @@ curl "http://192.168.1.100:8090/presets"
|
||||
curl "http://localhost:8000/events/192.168.1.100"
|
||||
```
|
||||
|
||||
#### ResolvConf Migration (DHCP-Aware DNS Redirection)
|
||||
#### DNS/DHCP Migration (DHCP-Aware DNS Redirection)
|
||||
|
||||
The most robust and flexible DNS-based migration method. It utilizes the device's persistent `/mnt/nv/rc.local` script to inject a priority DNS hook into the system's DHCP configuration.
|
||||
|
||||
@@ -665,7 +665,7 @@ find data/stats/ -name "*.json" -mtime +90 -delete
|
||||
- `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 /setup/migrate/{deviceIP}`: Migrate a device using the specified method (XML or DNS).
|
||||
- `GET /setup/ca.crt`: Download the Root CA certificate for manual installation.
|
||||
|
||||
#### `GET /setup/interactions`
|
||||
|
||||
@@ -1,85 +1,107 @@
|
||||
### Bose Cloud Shutdown: Survival Guide for SoundTouch
|
||||
# Bose Cloud Shutdown: Survival Guide
|
||||
|
||||
With Bose's announcement of discontinuing cloud support for SoundTouch devices in May 2026, this project provides the necessary tools to keep your speakers fully functional using a local emulation service.
|
||||
Bose is shutting down SoundTouch cloud services on **May 6, 2026**. After that date, the following stop working:
|
||||
|
||||
This guide explains how to set up the `soundtouch-service` to run your devices independently of Bose's servers.
|
||||
- Music service browsing (TuneIn, Spotify connect via app, etc.)
|
||||
- Preset and recently-played sync
|
||||
- The official SoundTouch app
|
||||
- Software update checks
|
||||
|
||||
What **continues to work** regardless:
|
||||
- Local playback controls via `soundtouch-cli`, `soundtouch-web`, or any app that uses the local Web API
|
||||
- Bluetooth, AUX, and AirPlay inputs
|
||||
- Multiroom zones (local, peer-to-peer)
|
||||
|
||||
**AfterTouch** — the `soundtouch-service` — restores everything in the first list by running a local replacement for the Bose cloud on your own network.
|
||||
|
||||
---
|
||||
|
||||
### Supported Use Cases
|
||||
## How it works
|
||||
|
||||
1. **Local Service Emulation**: The service emulates Bose's BMX (Bose Media eXchange) and Marge services, which handle content registries, presets, recents, and software update checks.
|
||||
2. **Traffic Redirection**: Tools are provided to redirect your speakers to this local service instead of `*.bose.com`.
|
||||
3. **Offline Operation**: Once redirected, the speakers function without needing to reach Bose's servers.
|
||||
4. **Preset & Recent Management**: Captures and stores presets and "recently played" items locally.
|
||||
The service emulates the Bose cloud endpoints that speakers call for music service browsing, device registration, preset sync, and update checks. Once a speaker is redirected to point at the local service instead of Bose's servers, it operates independently. The built-in web UI at `http://<server>:8000` handles all setup steps.
|
||||
|
||||
---
|
||||
|
||||
### Setup Steps
|
||||
## Prerequisites
|
||||
|
||||
To set up your SoundTouch system for local-only operation, follow these steps:
|
||||
### 1. A machine that's always on
|
||||
|
||||
#### 1. Install and Start the Service
|
||||
Run the `soundtouch-service` on a machine that is always on (like a Raspberry Pi or a NAS) within your local network.
|
||||
The service must run on a host that's available whenever your speakers are in use — a Raspberry Pi, NAS, home server, or similar. The host needs a stable local address (e.g. `soundtouch.fritz.box` or a fixed IP) reachable from your speakers.
|
||||
|
||||
```bash
|
||||
# Install the service
|
||||
go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-service@latest
|
||||
See [Raspberry Pi Setup](RASPBERRY-PI.md) and the [SoundTouch Service Guide](SOUNDTOUCH-SERVICE.md) for deployment options, including Docker.
|
||||
|
||||
# Start the service (defaults to http://localhost:8000)
|
||||
soundtouch-service
|
||||
```
|
||||
### 2. SSH access on your speakers (for migration)
|
||||
|
||||
#### 2. Access the Management UI
|
||||
Open your web browser and navigate to the service's web interface:
|
||||
`http://<your-server-ip>:8000/`, e.g. `http://localhost:8000/`
|
||||
Redirecting a speaker's service URLs requires writing to its configuration. This is done via SSH. Enable it once per device:
|
||||
|
||||
*Note: The service also supports a `/web/` path for management.*
|
||||
1. Create a file named `remote_services` on a FAT-formatted USB drive. The drive may need its bootable flag set — see [SoundCork issue #172](https://github.com/deborahgu/soundcork/issues/172) for details.
|
||||
2. Insert the drive into the speaker's USB port while it's powered on.
|
||||
3. Power-cycle the speaker (unplug and replug). After boot, root SSH is available with no password.
|
||||
|
||||
#### 3. Enable SSH on Your Speakers
|
||||
To migrate your speakers, the service needs SSH access. You can enable it by:
|
||||
1. Creating an empty file named `remote_services` on a USB stick.
|
||||
2. Inserting the USB stick into the SoundTouch speaker's service port.
|
||||
3. Rebooting the speaker (unplug/replug).
|
||||
|
||||
**Verify SSH Access:**
|
||||
- Confirm the device responds to SSH without a password: `ssh -o HostKeyAlgorithms=+ssh-rsa -o PubkeyAcceptedAlgorithms=+ssh-rsa root@<IP>`
|
||||
- Or use the **Migration** tab in the Web UI to see if the device shows a "✅ Success" status for SSH.
|
||||
Once enabled, you can log in as `root` (no password).
|
||||
|
||||
#### 4. Setup Through the Web UI
|
||||
The web interface handles the entire process in a guided flow. Before proceeding, we strongly recommend reviewing the [Migration & Safety Guide](MIGRATION-SAFETY.md).
|
||||
|
||||
* **Step 1: Settings**: Configure your server's IP or domain. This ensures the speakers know where to find the local services.
|
||||
* **Step 2: Devices**: The service automatically scans for SoundTouch devices on your network. If a device is not found, you can manually add its IP address.
|
||||
* **Step 3: Data Sync**: Select your device and click "Start Sync". This will automatically fetch your presets, recents, and configured sources from the speaker and store them in the local `data/` directory.
|
||||
* **Step 4: Migration**: Choose your redirection method (XML Recommended) and click "Confirm Migration". After the migration, reboot your speaker to apply the changes.
|
||||
|
||||
#### 5. Verify Your Local Data
|
||||
Once migrated, your speaker will use the data captured during the Sync step.
|
||||
* The service stores data in the `data/` directory, organized by device serial number (e.g., `data/default/devices/<SERIAL>/`).
|
||||
* **Automatic Capture**: As you use the device (changing presets, playing new music), the service continues to "learn" and update your local files.
|
||||
You can leave SSH enabled for future maintenance, or disable it once migration is complete.
|
||||
|
||||
---
|
||||
|
||||
### Comparison with other implementations (soundcork)
|
||||
Our implementation (`soundtouch-service`) is largely compatible with the Python-based `soundcork` project but offers several advantages:
|
||||
- **Web UI**: Integrated management interface for discovery and migration.
|
||||
- **Surgical Migration**: Uses XML-based redirection by default, which is less invasive than `/etc/hosts`.
|
||||
- **Automated SSL**: Handles Root CA injection automatically for secure communication.
|
||||
- **Proxy Support**: Can proxy requests to original Bose servers while "learning" your configuration.
|
||||
## Scenario A: Migrate before the shutdown
|
||||
|
||||
Do this while the Bose cloud is still running. Your existing presets and listening history are preserved.
|
||||
|
||||
**Step 1 — Back up your data.**
|
||||
Run `soundtouch-backup all` to save your Bose account data (presets, paired devices, music sources) and each speaker's local state. See the [soundtouch-backup README](../../cmd/soundtouch-backup/README.md) for usage.
|
||||
|
||||
**Step 2 — Start the service and open the web UI** at `http://<server>:8000`.
|
||||
|
||||
**Step 3 — Configure the server URL.**
|
||||
In the Settings tab, set the server URL to the address your speakers can reach (e.g. `http://soundtouch.fritz.box:8000`). If you plan to use DNS/DHCP redirect, also configure the HTTPS server URL.
|
||||
|
||||
**Step 4 — Add your speaker.**
|
||||
The service discovers devices on your network automatically. If a speaker doesn't appear, add it manually by IP address.
|
||||
|
||||
**Step 5 — Sync device data.**
|
||||
Click "Sync" on the device to pull its current presets, recents, and sources from the Bose cloud into the local service's datastore.
|
||||
|
||||
**Step 6 — Migrate.**
|
||||
The web UI offers two redirect methods and walks you through each step:
|
||||
|
||||
| Method | How it works | When to use |
|
||||
|--------------|--------------------------------------------------------|----------------------------------------------------------|
|
||||
| XML redirect | Uploads a config file to the speaker via the Web API | Testing; simpler setup; covers only registered endpoints |
|
||||
| DNS/DHCP | Custom DNS resolves Bose hostnames to the local server | All devices at once; full coverage |
|
||||
|
||||
Both methods require TLS when the speaker uses HTTPS to contact the service. The web UI guides you through installing the service's CA certificate on the speaker (requires SSH).
|
||||
|
||||
**Step 7 — Reboot the speaker.**
|
||||
Power-cycle the speaker to apply the changes. After reboot it contacts the local service instead of Bose's cloud.
|
||||
|
||||
---
|
||||
|
||||
### Alternative: DNS Redirection (No SSH)
|
||||
If you prefer not to modify your speakers via SSH, you can use a local DNS server (like Pi-hole, AdGuard Home, or Unbound) to point the following domains to your local server's IP:
|
||||
## Scenario B: Set up after the shutdown (or after a factory reset)
|
||||
|
||||
* `bmx.bose.com`
|
||||
* `streaming.bose.com`
|
||||
* `updates.bose.com`
|
||||
* `stats.bose.com`
|
||||
* `content.api.bose.io`
|
||||
If the Bose cloud is gone, or you've factory-reset a speaker, there's no existing account to migrate from. You start fresh with a local account.
|
||||
|
||||
*Note: DNS redirection for HTTPS services requires the speakers to trust your local service's SSL certificate. The SSH-based migration handles this automatically by injecting the CA.*
|
||||
**Step 1 — Set up DNS/DHCP redirect first** (recommended).
|
||||
Configure your network's DNS to resolve the Bose cloud hostnames to the local service's address before the speaker tries to register. This way, when the speaker boots and attempts to register, it reaches AfterTouch automatically instead of failing to reach Bose.
|
||||
|
||||
See the [SoundTouch Service Guide](SOUNDTOUCH-SERVICE.md) for the built-in DNS server configuration and the list of hostnames to redirect.
|
||||
|
||||
**Step 2 — Connect the speaker to Wi-Fi.**
|
||||
Use the speaker's built-in AP mode or BLE setup flow. See [Device Initial Setup](DEVICE-INITIAL-SETUP.md) for factory reset button sequences and Wi-Fi provisioning.
|
||||
|
||||
**Step 3 — Start the service and open the web UI** at `http://<server>:8000`.
|
||||
|
||||
**Step 4 — Add the speaker.**
|
||||
After connecting to Wi-Fi, the speaker should appear in the web UI automatically (or add it manually by IP). If DNS redirect is already in place, the speaker is already communicating with AfterTouch.
|
||||
|
||||
**Step 5 — Migrate** (if not already using DNS redirect).
|
||||
If you didn't set up DNS first, use the XML redirect method from the web UI to update the speaker's service URLs. The web UI walks you through the steps including CA certificate setup.
|
||||
|
||||
**Step 6 — Reboot the speaker.**
|
||||
Power-cycle to ensure all changes take effect.
|
||||
|
||||
---
|
||||
|
||||
## After migration
|
||||
|
||||
Once migrated, your speaker uses the local service for music browsing, preset sync, and device registration. The web UI at `http://<server>:8000` is your management interface going forward. Back up the `data/` directory periodically in case you need to restore.
|
||||
|
||||
For the complete step-by-step walkthrough with commands and troubleshooting, see the [Migration Guide](MIGRATION-GUIDE.md). For safety measures and rollback options, see the [Migration & Safety Guide](MIGRATION-SAFETY.md).
|
||||
|
||||
@@ -853,6 +853,85 @@ cat data/accounts/3230304/devices/*/DeviceInfo.xml | grep macAddress
|
||||
|
||||
---
|
||||
|
||||
## 🌐 **Hostname Resolution** {#hostname-resolution}
|
||||
|
||||
### Why the service resolves the hostname from the device
|
||||
|
||||
When you migrate a speaker using the resolv.conf method, the service needs to write a raw IP address into the speaker's network configuration. That IP must be the address the *speaker itself* can reach — which is not necessarily the same address your computer resolves.
|
||||
|
||||
In environments with NAT, split-horizon DNS, or Docker/container networking, `soundtouch.local` (or whatever you set as `SERVER_URL`) may resolve to a different IP depending on who is asking. The service therefore resolves the hostname by running `ping -c 1 <hostname>` over SSH on the speaker and extracting the IP from the output. This is the authoritative result: it is exactly what the speaker would use.
|
||||
|
||||
If that SSH ping fails, migration is aborted. Writing an unresolvable or incorrectly resolved hostname into `aftertouch.resolv.conf` would silently break the speaker's DNS config and prevent it from reaching the service after reboot.
|
||||
|
||||
**The XML migration method is different.** It writes the full URL (e.g. `http://soundtouch.local:8000`) into `SoundTouchSdkPrivateCfg.xml`. The speaker resolves the hostname at connect time, not at migration time. This means migration can proceed even if the hostname is not yet reachable — for example, when the service will be deployed under that hostname but is not running yet. A warning is still shown in the UI so you are aware, but the Confirm Migration button remains enabled.
|
||||
|
||||
### ❌ "Cannot resolve target hostname for migration"
|
||||
|
||||
**Symptoms** (migration log or web UI warning):
|
||||
```
|
||||
cannot resolve target hostname for migration: cannot resolve "soundtouch.local":
|
||||
SSH ping from device failed and service-side DNS lookup also failed
|
||||
```
|
||||
or:
|
||||
```
|
||||
resolved "soundtouch.local" to 192.168.1.100 from service, not from device —
|
||||
result may be wrong if NAT or split-DNS is in use
|
||||
```
|
||||
|
||||
**What this means:**
|
||||
|
||||
The service could not confirm the IP by running `ping` on the speaker via SSH. Either:
|
||||
- the `ping` binary is not available or not in `$PATH` on this firmware, or
|
||||
- the hostname is not resolvable from the speaker's network context.
|
||||
|
||||
**Diagnosis — run manually over SSH:**
|
||||
|
||||
```bash
|
||||
# SSH into the speaker
|
||||
ssh root@<speaker-ip>
|
||||
|
||||
# Try to resolve the service hostname
|
||||
ping -c 1 soundtouch.local
|
||||
# or use the IP directly to verify connectivity
|
||||
ping -c 1 192.168.1.100
|
||||
|
||||
# Check the speaker's current DNS config
|
||||
cat /etc/resolv.conf
|
||||
|
||||
# Check if ping is available
|
||||
which ping
|
||||
busybox ping --help
|
||||
```
|
||||
|
||||
**Solutions:**
|
||||
|
||||
#### 1. Use an IP address as SERVER_URL
|
||||
|
||||
The most reliable fix. If the hostname cannot be resolved from the device, use a raw IP instead. Resolution is skipped entirely when `SERVER_URL` contains an IP.
|
||||
|
||||
```bash
|
||||
# In your .env
|
||||
SERVER_URL=http://192.168.1.100:8000
|
||||
HTTPS_SERVER_URL=https://192.168.1.100:8443
|
||||
```
|
||||
|
||||
HTTPS works correctly with IP addresses — the service certificate includes the IP as a Subject Alternative Name (SAN).
|
||||
|
||||
#### 2. Ensure the hostname resolves on the speaker's network segment
|
||||
|
||||
If you use `soundtouch.local`, verify mDNS is working from another device on the same subnet:
|
||||
|
||||
```bash
|
||||
avahi-resolve -n soundtouch.local # Linux
|
||||
dns-sd -G v4 soundtouch.local # macOS
|
||||
```
|
||||
|
||||
#### 3. Use the XML migration method
|
||||
|
||||
Select the XML method in the migration UI. It writes the full URL and the speaker resolves it at connect time, so hostname resolution is not required during migration. This also allows migrating to a hostname that is not yet live.
|
||||
|
||||
---
|
||||
|
||||
## 🛟 **Getting More Help**
|
||||
|
||||
### Information to Gather
|
||||
|
||||
@@ -1,90 +1,17 @@
|
||||
# Images for Migration Guide
|
||||
# docs/images
|
||||
|
||||
This directory contains images, screenshots, and diagrams referenced in the migration guide and other documentation.
|
||||
Screenshots and diagrams referenced by the documentation.
|
||||
|
||||
## Required Images for Migration Guide
|
||||
## Current screenshots
|
||||
|
||||
The following images need to be created to complete the migration guide:
|
||||
| File | Shows | Used in |
|
||||
|------|-------|---------|
|
||||
| `ui-settings.png` | AfterTouch web UI — Settings tab (Target Domain, DNS Discovery, Mirroring) | Migration Guide |
|
||||
| `ui-devices.png` | AfterTouch web UI — Devices tab (discovered speakers with Sync/Migrate actions) | Migration Guide |
|
||||
| `ui-sync.png` | AfterTouch web UI — Data Sync tab (successful sync result) | Migration Guide |
|
||||
| `ui-migration.png` | AfterTouch web UI — Migration tab (HTTPS test, DNS test, method selector) | Migration Guide |
|
||||
| `speaker-ap-wifi-setup.png` | Speaker AP mode Wi-Fi setup page at `http://192.0.2.1` | Device Initial Setup |
|
||||
|
||||
### Dashboard Screenshots
|
||||
- **dashboard-home.png** - Main SoundTouch Service dashboard homepage
|
||||
- **account-creation.png** - Account creation form with fields filled
|
||||
- **account-dashboard.png** - Fresh account dashboard showing ready state
|
||||
- **device-discovery.png** - Device discovery page showing found speakers
|
||||
- **device-registration.png** - Device registration dialog with options
|
||||
- **migration-setup.png** - Migration configuration dialog
|
||||
- **migration-progress.png** - Migration progress tracker showing phases
|
||||
- **migration-health.png** - Migration health monitoring dashboard
|
||||
- **account-migration.png** - Account-wide migration progress overview
|
||||
- **migration-complete.png** - Completed migration dashboard view
|
||||
- **backup-setup.png** - Backup configuration settings page
|
||||
## Adding new screenshots
|
||||
|
||||
### Setup and Preparation
|
||||
- **usb-remote-services.png** - USB drive setup showing file structure
|
||||
- **raspberry-pi-setup.png** - Raspberry Pi with connected cables (optional)
|
||||
|
||||
### Process Diagrams
|
||||
- **migration-flow-diagram.png** - Flow chart showing migration phases
|
||||
- **network-topology.png** - Network diagram showing Pi, router, speakers
|
||||
- **data-flow-diagram.png** - How data flows between components
|
||||
|
||||
## Image Requirements
|
||||
|
||||
### Technical Specifications
|
||||
- **Format**: PNG preferred for screenshots, SVG for diagrams
|
||||
- **Resolution**: Minimum 1200px width for screenshots
|
||||
- **File Size**: Keep under 500KB when possible for fast loading
|
||||
- **Naming**: Use descriptive kebab-case names as shown above
|
||||
|
||||
### Content Guidelines
|
||||
- **Clean Interface**: Show realistic but clean interface states
|
||||
- **Consistent Styling**: Use consistent colors and styling across images
|
||||
- **Readable Text**: Ensure all text in screenshots is legible
|
||||
- **Example Data**: Use realistic example data (Living Room Speaker, etc.)
|
||||
- **Status Indicators**: Show clear success/error states with appropriate colors
|
||||
|
||||
### Placeholder Content
|
||||
Until real screenshots are available, consider:
|
||||
- **Mockups**: Create simple mockups showing the expected interface
|
||||
- **Wireframes**: Basic wireframes indicating layout and content
|
||||
- **Diagrams**: Technical diagrams can be created immediately
|
||||
- **Text Placeholders**: Use `[Image: Description]` in documentation
|
||||
|
||||
## Creating the Images
|
||||
|
||||
### For Dashboard Screenshots
|
||||
1. Set up the enhanced SoundTouch service
|
||||
2. Create sample account and register devices
|
||||
3. Take screenshots at key points in the migration process
|
||||
4. Edit for clarity (highlight important elements, add annotations)
|
||||
|
||||
### For Diagrams
|
||||
1. Use tools like Lucidchart, draw.io, or similar
|
||||
2. Follow consistent color scheme:
|
||||
- Blue: SoundTouch Service components
|
||||
- Green: Healthy/successful states
|
||||
- Orange: Warning/in-progress states
|
||||
- Red: Error/problematic states
|
||||
- Gray: External/third-party components
|
||||
|
||||
### For Physical Setup
|
||||
1. Take photos of actual hardware setup
|
||||
2. Show USB drive preparation process
|
||||
3. Demonstrate network connections if helpful
|
||||
|
||||
## Alternative Text Requirements
|
||||
|
||||
Each image should have appropriate alt text for accessibility:
|
||||
|
||||
```markdown
|
||||

|
||||
*Caption: Additional context or explanation*
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Consider adding:
|
||||
- **Video Walkthroughs**: Screen recordings of key processes
|
||||
- **Interactive Demos**: Web-based interactive guides
|
||||
- **Troubleshooting Screenshots**: Common error states and solutions
|
||||
- **Mobile Views**: How to access from mobile devices
|
||||
PNG format, 1200 px or wider. Use descriptive kebab-case names. Update this README when adding files.
|
||||
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 544 KiB |
|
After Width: | Height: | Size: 516 KiB |
|
After Width: | Height: | Size: 266 KiB |
@@ -0,0 +1,521 @@
|
||||
# SoundTouch Device WebSocket API — Pairing & Operation Flow
|
||||
|
||||
Reference document derived from mitmproxy captures of the Bose SoundTouch Android app
|
||||
(`bose-pairing-20260502-155542`, `bose-pairing-20260502-165549`).
|
||||
|
||||
> **Why this matters:** With Bose cloud services shutting down on 2026-05-06, the original
|
||||
> app may stop working for pairing and playback control. This document captures the exact
|
||||
> WebSocket message sequences needed to replicate those flows independently.
|
||||
|
||||
---
|
||||
|
||||
## Connection
|
||||
|
||||
All interactions use the SoundTouch WebSocket API on the speaker's local IP, port **8090**
|
||||
(the same port as the REST API). Connect with the `Gabbo` sub-protocol:
|
||||
|
||||
```
|
||||
GET ws://192.168.x.y:8090/
|
||||
Upgrade: websocket
|
||||
Sec-WebSocket-Protocol: Gabbo
|
||||
```
|
||||
|
||||
Upon connection the server immediately sends an identification banner:
|
||||
|
||||
```xml
|
||||
<SoundTouchSdkInfo serverVersion="4" serverBuild="trunk r46330 v4 epdbuild hepdswbld04" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Message Envelope
|
||||
|
||||
All subsequent messages (except `selectLastWiFiSource`, see below) use this envelope:
|
||||
|
||||
**Client → Server request:**
|
||||
```xml
|
||||
<msg>
|
||||
<header deviceID="{device_id}" url="{endpoint}" method="{GET|POST}">
|
||||
<request requestID="{n}">
|
||||
<info type="new"/> <!-- or type="update" -->
|
||||
<!-- optional: <sourceItem source="TUNEIN"/> -->
|
||||
</request>
|
||||
</header>
|
||||
<body>
|
||||
<!-- payload, may be empty -->
|
||||
</body>
|
||||
</msg>
|
||||
```
|
||||
|
||||
**Server → Client response:**
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<msg>
|
||||
<header deviceID="{device_id}" url="{endpoint}" method="{GET|POST}">
|
||||
<request requestID="{n}" msgType="RESPONSE">
|
||||
<info type="new"/>
|
||||
</request>
|
||||
</header>
|
||||
<body>
|
||||
<!-- response payload -->
|
||||
</body>
|
||||
</msg>
|
||||
```
|
||||
|
||||
**Server → Client push (unsolicited):**
|
||||
```xml
|
||||
<updates deviceID="{device_id}">
|
||||
<nowPlayingUpdated>...</nowPlayingUpdated>
|
||||
</updates>
|
||||
```
|
||||
|
||||
`requestID` is a monotonically increasing integer per connection (client-side sequence).
|
||||
`{device_id}` is the speaker's MAC address with colons removed (e.g. `08DF1F0BA325`).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Discovery: Is the Speaker Already Paired?
|
||||
|
||||
```xml
|
||||
<!-- C→S: fetch device info -->
|
||||
<msg><header deviceID="{device_id}" url="info" method="GET">
|
||||
<request requestID="1"><info type="new"/></request>
|
||||
</header></msg>
|
||||
|
||||
<!-- S→C: response -->
|
||||
<info deviceID="{device_id}">
|
||||
<name>SoundTouch 10</name>
|
||||
<type>SoundTouch 10</type>
|
||||
<margeAccountUUID>9569497</margeAccountUUID> <!-- empty = unpaired -->
|
||||
<margeURL>https://streaming.bose.com</margeURL>
|
||||
...
|
||||
</info>
|
||||
```
|
||||
|
||||
- **Empty `margeAccountUUID`** → device is unpaired, proceed to Phase 2
|
||||
- **Populated `margeAccountUUID`** → already paired with that account ID
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Pairing a New Speaker
|
||||
|
||||
### 2.1 Setup State Machine
|
||||
|
||||
The pairing flow uses a setup state machine on the device. States must be sent in order.
|
||||
|
||||
```xml
|
||||
<!-- 1. Start setup -->
|
||||
<msg><header deviceID="{device_id}" url="setup" method="POST">
|
||||
<request requestID="21"></request>
|
||||
</header><body><setupState state="SETUP_START"/></body></msg>
|
||||
|
||||
<!-- 2. Enter identify mode — device flashes/beeps; 300 000 ms timeout -->
|
||||
<msg><header deviceID="{device_id}" url="setup" method="POST">
|
||||
<request requestID="22"></request>
|
||||
</header><body><setupState state="SETUP_IDENTIFY_DEVICE_ENTER" timeout="300000"/></body></msg>
|
||||
|
||||
<!-- Server pushes: -->
|
||||
<updates deviceID="{device_id}">
|
||||
<soundTouchConfigurationUpdated>
|
||||
<soundTouchConfigurationStatus status="SOUNDTOUCH_CONFIGURING"/>
|
||||
</soundTouchConfigurationUpdated>
|
||||
</updates>
|
||||
|
||||
<!-- 3. Set language (3 = German; adjust as needed) -->
|
||||
<msg><header deviceID="{device_id}" url="language" method="POST">
|
||||
<request requestID="23"></request>
|
||||
</header><body><sysLanguage>3</sysLanguage></body></msg>
|
||||
|
||||
<!-- 4. Enter setup (user has confirmed identification) -->
|
||||
<msg><header deviceID="{device_id}" url="setup" method="POST">
|
||||
<request requestID="24"></request>
|
||||
</header><body><setupState state="SETUP_ENTER"/></body></msg>
|
||||
|
||||
<!-- 5. Leave identify mode -->
|
||||
<msg><header deviceID="{device_id}" url="setup" method="POST">
|
||||
<request requestID="25"></request>
|
||||
</header><body><setupState state="SETUP_IDENTIFY_DEVICE_LEAVE"/></body></msg>
|
||||
|
||||
<!-- 6. Set device name -->
|
||||
<msg><header deviceID="{device_id}" url="name" method="POST">
|
||||
<request requestID="26"></request>
|
||||
</header><body><name>My SoundTouch 10</name></body></msg>
|
||||
```
|
||||
|
||||
### 2.2 Account Pairing — The Critical Step
|
||||
|
||||
```xml
|
||||
<!-- C→S: pair device with account -->
|
||||
<msg><header deviceID="{device_id}" url="setMargeAccount" method="POST">
|
||||
<request requestID="27"></request>
|
||||
</header><body>
|
||||
<PairDeviceWithAccount>
|
||||
<accountId>{accountId}</accountId>
|
||||
<userAuthToken>Bearer {token}</userAuthToken>
|
||||
</PairDeviceWithAccount>
|
||||
</body></msg>
|
||||
|
||||
<!-- S→C: device info response with margeAccountUUID now set -->
|
||||
<info deviceID="{device_id}">
|
||||
...
|
||||
<margeAccountUUID>{accountId}</margeAccountUUID>
|
||||
...
|
||||
</info>
|
||||
```
|
||||
|
||||
The server also pushes several `sourcesUpdated` events after successful pairing.
|
||||
|
||||
**`{accountId}`** — the numeric Bose account ID (e.g. `9569497`), obtainable from
|
||||
`GET /streaming/account/login` on soundtouch-service.
|
||||
|
||||
**`{token}`** — a Bearer token issued by Bose authentication (or soundtouch-service).
|
||||
The full token from the captures:
|
||||
```
|
||||
Bearer NtJDRbNtY3hDhm5K8FC2JprRhRQNH3QdZjG6aR4ASwYQg4rvZMY6dPLc3Bm6zvWNciWzCpMWZ/dbITRQoVdClOdssgDO+Nlh4ZJWp2w3tZiGzB8Flho0c+ipXnT/0Yg5
|
||||
```
|
||||
(session-specific; obtain a fresh one from the service's account login flow)
|
||||
|
||||
### 2.3 Finish Setup and Telemetry
|
||||
|
||||
```xml
|
||||
<!-- Leave setup state machine -->
|
||||
<msg><header deviceID="{device_id}" url="setup" method="POST">
|
||||
<request requestID="28"></request>
|
||||
</header><body><setupState state="SETUP_LEAVE"/></body></msg>
|
||||
|
||||
<!-- Trigger device to sync customer support info to Marge cloud -->
|
||||
<msg><header deviceID="{device_id}" url="pushCustomerSupportInfoToMarge" method="GET">
|
||||
<request requestID="29"></request>
|
||||
</header></msg>
|
||||
|
||||
<!-- S→C: -->
|
||||
<status>/pushCustomerSupportInfoToMarge</status>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Unpairing
|
||||
|
||||
```xml
|
||||
<!-- C→S: remove device from account -->
|
||||
<msg><header deviceID="{device_id}" url="setMargeAccount" method="POST">
|
||||
<request requestID="24">
|
||||
<info mainNode="removeDevice" type="new"/>
|
||||
<sourceItem source="SETTINGS" sourceAccount="{device_id}"/>
|
||||
</request>
|
||||
</header><body><UnPairDeviceWithAccount/></body></msg>
|
||||
|
||||
<!-- S→C: response with device info showing empty margeAccountUUID -->
|
||||
<!-- Server also pushes: <updates><infoUpdated/></updates> -->
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — App Initialization (Bulk State Fetch)
|
||||
|
||||
When the app connects to an already-paired device it sends these in rapid parallel sequence:
|
||||
|
||||
```
|
||||
info (GET) — device metadata, check pairing
|
||||
sources (GET) — available input sources
|
||||
presets (GET) — saved presets 1–6
|
||||
swUpdateQuery (POST) — check if update is in progress
|
||||
capabilities (GET) — hardware capabilities, network config
|
||||
bassCapabilities (GET) — bass range and defaults
|
||||
now_playing (GET) — current playback state
|
||||
volume (GET) — current volume
|
||||
getZone (GET) — multi-room zone membership
|
||||
clockDisplay (POST) — set clock timezone/format
|
||||
```
|
||||
|
||||
Then a second wave:
|
||||
|
||||
```
|
||||
swUpdateCheck (POST) — check for new firmware
|
||||
systemtimeout (GET) — power-saving timeout
|
||||
rebroadcastlatencymode (GET) — zone latency mode
|
||||
getGroup (GET) — stereo-pair group
|
||||
language (GET, sourceItem source="settings") — UI language
|
||||
bass (GET) — current bass level
|
||||
serviceAvailability (GET, sourceItem source="add_service" or "settings")
|
||||
webserver/pingRequest (GET) — keepalive
|
||||
pushCustomerSupportInfoToMarge (GET) — telemetry
|
||||
netStats (GET, sourceItem source="settings") — network statistics
|
||||
introspect (POST, sourceItem source="AIRPLAY") — AirPlay2 capabilities
|
||||
```
|
||||
|
||||
`clockDisplay` example with timezone:
|
||||
```xml
|
||||
<clockDisplay>
|
||||
<clockConfig timezoneInfo="Europe/Berlin" timeFormat="TIME_FORMAT_12HOUR_ID"/>
|
||||
</clockDisplay>
|
||||
```
|
||||
|
||||
`serviceAvailability` response lists availability of all service types (PANDORA, AIRPLAY,
|
||||
AMAZON, DEEZER, SPOTIFY, TUNEIN, SIRIUSXM_EVEREST, BLUETOOTH, etc.) with `isAvailable`
|
||||
and optional `reason` attributes.
|
||||
|
||||
---
|
||||
|
||||
## Playback Control
|
||||
|
||||
### Start Playback via `playbackRequest` (preferred — bypasses source checks)
|
||||
|
||||
```xml
|
||||
<msg><header deviceID="{device_id}" url="playbackRequest" method="POST">
|
||||
<request requestID="{n}"><info type="new"/></request>
|
||||
</header><body>
|
||||
<playbackRequest source="TUNEIN" sourceAccount="">
|
||||
<container type="stationurl"
|
||||
location="/v1/playback/station/s25260"
|
||||
isPresetable="true"
|
||||
source="TUNEIN"
|
||||
sourceAccount="">
|
||||
<itemName>1LIVE</itemName>
|
||||
</container>
|
||||
</playbackRequest>
|
||||
</body></msg>
|
||||
|
||||
<!-- S→C response: -->
|
||||
<playbackResponse source="TUNEIN" sourceAccount=""/>
|
||||
|
||||
<!-- S→C pushes: nowPlayingUpdated, recentsUpdated -->
|
||||
```
|
||||
|
||||
For a TuneIn podcast episode, use `type="tracklisturl"` and
|
||||
`location="/v1/playback/episodes/{id}?encoded_name={base64}"`.
|
||||
|
||||
### Select Content via `select` (triggers preset/recents UI highlight)
|
||||
|
||||
```xml
|
||||
<msg><header deviceID="{device_id}" url="select" method="POST">
|
||||
<request requestID="{n}"><info type="new"/></request>
|
||||
</header><body>
|
||||
<ContentItem source="TUNEIN"
|
||||
type="stationurl"
|
||||
location="/v1/playback/station/s25260"
|
||||
sourceAccount="TUNEIN"
|
||||
isPresetable="true">
|
||||
<itemName>1LIVE</itemName>
|
||||
</ContentItem>
|
||||
</body></msg>
|
||||
```
|
||||
|
||||
Note: `select` with a TUNEIN item that the device can't resolve directly may return
|
||||
`error value="1005" name="UNKNOWN_SOURCE_ERROR"`. Use `playbackRequest` instead for
|
||||
reliable playback.
|
||||
|
||||
### Special: Select Last Wi-Fi Source
|
||||
|
||||
A plain-text (non-XML) client message:
|
||||
```
|
||||
selectLastWiFiSource
|
||||
```
|
||||
|
||||
Server responds with plain text:
|
||||
```
|
||||
<?xml version="1.0" encoding="UTF-8" ?><status>/selectLastWiFiSource</status>
|
||||
```
|
||||
|
||||
### Key Presses
|
||||
|
||||
```xml
|
||||
<!-- press -->
|
||||
<msg><header deviceID="{device_id}" url="key" method="POST">
|
||||
<request requestID="{n}"><info mainNode="keyPress" type="new"/><sourceItem source="TUNEIN"/></request>
|
||||
</header><body><key state="press" sender="Gabbo">{KEY}</key></body></msg>
|
||||
|
||||
<!-- release (required for POWER — not for STOP/PAUSE) -->
|
||||
<msg><header deviceID="{device_id}" url="key" method="POST">
|
||||
<request requestID="{n}"><info mainNode="keyRelease" type="new"/><sourceItem source="TUNEIN"/></request>
|
||||
</header><body><key state="release" sender="Gabbo">{KEY}</key></body></msg>
|
||||
```
|
||||
|
||||
Key names observed: `POWER`, `STOP`, `PAUSE`, `ADD_FAVORITE`
|
||||
|
||||
`sender="Gabbo"` is the app identifier string used by all Bose mobile apps.
|
||||
|
||||
### Volume
|
||||
|
||||
```xml
|
||||
<!-- Set volume (0–100) -->
|
||||
<msg><header deviceID="{device_id}" url="volume" method="POST">
|
||||
<request requestID="{n}"><info mainNode="volume" type="new"/><sourceItem source="TUNEIN"/></request>
|
||||
</header><body><volume>30</volume></body></msg>
|
||||
|
||||
<!-- S→C push: -->
|
||||
<updates deviceID="{device_id}">
|
||||
<volumeUpdated>
|
||||
<volume><targetvolume>30</targetvolume><actualvolume>30</actualvolume><muteenabled>false</muteenabled></volume>
|
||||
</volumeUpdated>
|
||||
</updates>
|
||||
```
|
||||
|
||||
### Bass
|
||||
|
||||
```xml
|
||||
<!-- Get -->
|
||||
<msg><header deviceID="{device_id}" url="bass" method="GET">
|
||||
<request requestID="{n}"><info type="new"/></request>
|
||||
</header></msg>
|
||||
|
||||
<!-- Set (range: bassMin to bassMax from bassCapabilities, typically -9 to 0) -->
|
||||
<msg><header deviceID="{device_id}" url="bass" method="POST">
|
||||
<request requestID="{n}"><info mainNode="bassSet" type="new"/><sourceItem source="SETTINGS"/></request>
|
||||
</header><body><bass>-2</bass></body></msg>
|
||||
|
||||
<!-- S→C push: <updates><bassUpdated/></updates> -->
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Browse & Navigate
|
||||
|
||||
```xml
|
||||
<!-- Open recents menu -->
|
||||
<msg><header deviceID="{device_id}" url="navigate" method="POST">
|
||||
<request requestID="{n}"><info mainNode="navigateMenu" type="new"/><sourceItem source="RECENTS"/></request>
|
||||
</header><body><navigate menu="recents"/></body></msg>
|
||||
|
||||
<!-- S→C response: -->
|
||||
<navigateResponse menu="recents">
|
||||
<totalItems>4</totalItems>
|
||||
<items>
|
||||
<item type="stationurl" source="TUNEIN" location="/v1/playback/station/s25260"
|
||||
sourceAccount="TUNEIN" isPresetable="true" id="0">
|
||||
<itemName>1LIVE</itemName>
|
||||
</item>
|
||||
...
|
||||
</items>
|
||||
</navigateResponse>
|
||||
```
|
||||
|
||||
Use `type="update"` on `<info>` for subsequent refresh calls on the same menu.
|
||||
|
||||
---
|
||||
|
||||
## Settings
|
||||
|
||||
### System Timeout (Power-Saving)
|
||||
|
||||
```xml
|
||||
<!-- Read -->
|
||||
<msg><header deviceID="{device_id}" url="systemtimeout" method="GET">
|
||||
<request requestID="{n}"><info type="new"/></request>
|
||||
</header></msg>
|
||||
|
||||
<!-- Write: disable auto power-off -->
|
||||
<msg><header deviceID="{device_id}" url="systemtimeout" method="POST">
|
||||
<request requestID="{n}"><info mainNode="systemtimeout" type="new"/><sourceItem source="SETTINGS"/></request>
|
||||
</header><body><systemtimeout><powersaving_enabled>false</powersaving_enabled></systemtimeout></body></msg>
|
||||
```
|
||||
|
||||
### Clock Display
|
||||
|
||||
```xml
|
||||
<msg><header deviceID="{device_id}" url="clockDisplay" method="POST">
|
||||
<request requestID="{n}"><info mainNode="clockDisplayBypass" type="new"/></request>
|
||||
</header><body>
|
||||
<clockDisplay>
|
||||
<clockConfig timezoneInfo="Europe/Berlin" timeFormat="TIME_FORMAT_12HOUR_ID"/>
|
||||
</clockDisplay>
|
||||
</body></msg>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Keepalive
|
||||
|
||||
The app sends a ping roughly every 30 seconds:
|
||||
|
||||
```xml
|
||||
<!-- C→S -->
|
||||
<msg><header deviceID="{device_id}" url="webserver/pingRequest" method="GET">
|
||||
<request requestID="{n}"><info type="new"/></request>
|
||||
</header></msg>
|
||||
|
||||
<!-- S→C -->
|
||||
<pingRequest pong="true"/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Server Push Events (Unsolicited)
|
||||
|
||||
The server wraps push events in `<updates deviceID="{device_id}">`:
|
||||
|
||||
| Event element | Trigger |
|
||||
|----------------------------------|-----------------------------------------------------------------------------------------------------|
|
||||
| `nowPlayingUpdated` | Source/track changed, playback state changed |
|
||||
| `nowSelectionUpdated` | Preset slot highlighted (UI selection changed) |
|
||||
| `recentsUpdated` | Recents list changed |
|
||||
| `presetsUpdated` | Preset saved or modified |
|
||||
| `volumeUpdated` | Volume changed (any source) |
|
||||
| `bassUpdated` | Bass level changed |
|
||||
| `connectionStateUpdated` | Wi-Fi signal strength changed (`EXCELLENT_SIGNAL`, `GOOD_SIGNAL`, `MARGINAL_SIGNAL`, `POOR_SIGNAL`) |
|
||||
| `soundTouchConfigurationUpdated` | Setup state changed (e.g. `SOUNDTOUCH_CONFIGURING`) |
|
||||
| `infoUpdated` | Device info changed (e.g. after un-pairing) |
|
||||
| `sourcesUpdated` | Available sources list changed |
|
||||
|
||||
Separate push (not inside `<updates>`):
|
||||
```xml
|
||||
<userActivityUpdate deviceID="{device_id}"/>
|
||||
```
|
||||
Sent after any physical or app-initiated user action.
|
||||
|
||||
---
|
||||
|
||||
## Notification (Client → Device Push)
|
||||
|
||||
Used by the app to notify the device of data that has changed on the service side
|
||||
(e.g. after syncing presets from cloud). Header uses `propagate="false"`:
|
||||
|
||||
```xml
|
||||
<msg>
|
||||
<header deviceID="{device_id}" url="notification" method="POST" propagate="false">
|
||||
<request requestID="{n}"><info mainNode="presetsUpdated" type="new"/></request>
|
||||
</header>
|
||||
<body>
|
||||
<updates deviceID="{device_id}"><presetsUpdated/></updates>
|
||||
</body>
|
||||
</msg>
|
||||
|
||||
<!-- S→C response: -->
|
||||
<status>/notification</status>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Pairing Sequence (Minimal)
|
||||
|
||||
To pair a freshly factory-reset speaker to a Bose account (soundtouch-service must be
|
||||
running and authenticated):
|
||||
|
||||
```
|
||||
1. Connect WebSocket to ws://{speakerIP}:8090/
|
||||
2. Receive: <SoundTouchSdkInfo .../>
|
||||
3. GET info → confirm margeAccountUUID is empty
|
||||
4. POST setup SETUP_START
|
||||
5. POST setup SETUP_IDENTIFY_DEVICE_ENTER (timeout=300000)
|
||||
(user physically presses button on speaker to confirm identity)
|
||||
6. POST language <sysLanguage>3</sysLanguage>
|
||||
7. POST setup SETUP_ENTER
|
||||
8. POST setup SETUP_IDENTIFY_DEVICE_LEAVE
|
||||
9. POST name <name>{desired name}</name>
|
||||
10. POST setMargeAccount <PairDeviceWithAccount>
|
||||
<accountId>{accountId}</accountId>
|
||||
<userAuthToken>Bearer {token}</userAuthToken>
|
||||
</PairDeviceWithAccount>
|
||||
→ device responds with info, margeAccountUUID is now set
|
||||
11. POST setup SETUP_LEAVE
|
||||
12. GET pushCustomerSupportInfoToMarge (telemetry, safe to skip)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Source References
|
||||
|
||||
- `bose-pairing-20260502-155542` — Session 1: initial pairing of SoundTouch 10 to account 9569497
|
||||
- `bose-pairing-20260502-165549` — Session 2: re-pairing and full operation (TuneIn, Spotify, presets)
|
||||
- Raw WebSocket files: `scripts/android/mitm/{session}/mirror/{n}-websocket/*.txt`
|
||||
- Companion HTTP upgrade files: `scripts/android/mitm/{session}/mirror/{n}-*.http`
|
||||
@@ -1,8 +1,8 @@
|
||||
module navigation-station-demo
|
||||
|
||||
go 1.26.2
|
||||
go 1.26.3
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.57.0
|
||||
require github.com/gesellix/bose-soundtouch v0.71.2
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
module preset-management-example
|
||||
|
||||
go 1.26.2
|
||||
go 1.26.3
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.57.0
|
||||
require github.com/gesellix/bose-soundtouch v0.71.2
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/gesellix/bose-soundtouch
|
||||
|
||||
go 1.26.2
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
|
||||
@@ -783,7 +783,11 @@ func (c *Client) SelectSource(source, sourceAccount string) error {
|
||||
case "BLUETOOTH":
|
||||
contentItem.ItemName = "Bluetooth"
|
||||
case "AUX":
|
||||
contentItem.ItemName = "AUX Input"
|
||||
contentItem.ItemName = "AUX IN"
|
||||
// The speaker rejects AUX with empty sourceAccount as INVALID_SOURCE.
|
||||
if contentItem.SourceAccount == "" {
|
||||
contentItem.SourceAccount = "AUX"
|
||||
}
|
||||
case "TUNEIN":
|
||||
contentItem.ItemName = "TuneIn"
|
||||
case "PANDORA":
|
||||
@@ -818,7 +822,7 @@ func (c *Client) SelectBluetooth() error {
|
||||
return c.SelectSource("BLUETOOTH", "")
|
||||
}
|
||||
|
||||
// SelectAux is a convenience method to select AUX input
|
||||
// SelectAux is a convenience method to select AUX input.
|
||||
func (c *Client) SelectAux() error {
|
||||
return c.SelectSource("AUX", "")
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestClient_SelectSource(t *testing.T) {
|
||||
{
|
||||
name: "Valid AUX source",
|
||||
source: "AUX",
|
||||
sourceAccount: "",
|
||||
sourceAccount: "AUX",
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
@@ -305,7 +305,7 @@ func TestClient_ConvenienceSourceMethods(t *testing.T) {
|
||||
method: "aux",
|
||||
sourceAccount: "",
|
||||
expectedSource: "AUX",
|
||||
expectedAccount: "",
|
||||
expectedAccount: "AUX",
|
||||
},
|
||||
{
|
||||
name: "SelectTuneIn",
|
||||
@@ -530,7 +530,7 @@ func getExpectedItemName(source string) string {
|
||||
case "BLUETOOTH":
|
||||
return "Bluetooth"
|
||||
case "AUX":
|
||||
return "AUX Input"
|
||||
return "AUX IN"
|
||||
case "TUNEIN":
|
||||
return "TuneIn"
|
||||
case "PANDORA":
|
||||
|
||||
@@ -171,6 +171,7 @@ func (d *DNSDiscovery) shouldIntercept(hostname string) bool {
|
||||
"music.api.bose.com",
|
||||
"bosecm.com",
|
||||
"bose.io",
|
||||
"downloads.bose.com",
|
||||
}
|
||||
|
||||
for _, service := range interceptList {
|
||||
|
||||
@@ -17,7 +17,8 @@ import (
|
||||
|
||||
// CertificateManager handles CA and certificate generation.
|
||||
type CertificateManager struct {
|
||||
CertsDir string
|
||||
CertsDir string
|
||||
CommonName string // CN for generated server certs; defaults to "localhost" if empty
|
||||
}
|
||||
|
||||
// NewCertificateManager creates a new CertificateManager.
|
||||
@@ -137,7 +138,7 @@ func (cm *CertificateManager) GetServerTLSConfig(domains []string) (*tls.Config,
|
||||
|
||||
// GenerateCA generates a new CA certificate and key.
|
||||
func (cm *CertificateManager) GenerateCA() error {
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 4096)
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -255,11 +256,16 @@ func (cm *CertificateManager) GenerateCertificate(domains []string) ([]byte, []b
|
||||
}
|
||||
}
|
||||
|
||||
cn := cm.CommonName
|
||||
if cn == "" {
|
||||
cn = "localhost"
|
||||
}
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"AfterTouch"},
|
||||
CommonName: domains[0],
|
||||
CommonName: cn,
|
||||
},
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
|
||||
@@ -16,6 +16,7 @@ func TestCertificateManager(t *testing.T) {
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
cm := NewCertificateManager(filepath.Join(tempDir, "certs"))
|
||||
cm.CommonName = "test.local"
|
||||
|
||||
// Test CA generation
|
||||
if err := cm.EnsureCA(); err != nil {
|
||||
@@ -67,8 +68,8 @@ func TestCertificateManager(t *testing.T) {
|
||||
t.Fatalf("Failed to parse certificate: %v", err)
|
||||
}
|
||||
|
||||
if cert.Subject.CommonName != domains[0] {
|
||||
t.Errorf("Expected CommonName %s, got %s", domains[0], cert.Subject.CommonName)
|
||||
if cert.Subject.CommonName != cm.CommonName {
|
||||
t.Errorf("Expected CommonName %s, got %s", cm.CommonName, cert.Subject.CommonName)
|
||||
}
|
||||
|
||||
// Check DNS names
|
||||
|
||||
@@ -2038,3 +2038,33 @@ func (ds *DataStore) DeleteGroup(account, groupID string) error {
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// SaveTuneInFavorite records a TuneIn station as favorited by creating a marker file.
|
||||
// File presence indicates the station is a favorite; no content is stored.
|
||||
func (ds *DataStore) SaveTuneInFavorite(stationID string) error {
|
||||
if ds == nil || ds.DataDir == "" || stationID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
dir := ds.safeJoin("tunein", "favorites")
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(ds.safeJoin("tunein", "favorites", stationID), nil, 0644)
|
||||
}
|
||||
|
||||
// DeleteTuneInFavorite removes a previously saved TuneIn favorite marker file.
|
||||
// Returns nil if the station was not favorited.
|
||||
func (ds *DataStore) DeleteTuneInFavorite(stationID string) error {
|
||||
if ds == nil || ds.DataDir == "" || stationID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := os.Remove(ds.safeJoin("tunein", "favorites", stationID))
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// HandleAlexaCertificate handles POST /alexa/certificate.
|
||||
//
|
||||
// The speaker sends a CSR (PEM, URL-form-encoded as "csr") and a JSON "data" field
|
||||
// containing a Bearer token, device MAC address, device type, and AWS region.
|
||||
// The real voice.api.bose.io endpoint forwards the CSR to AWS IoT, which signs it
|
||||
// and returns a device certificate, the account's IoT endpoint URL, and a client ID.
|
||||
// The speaker uses these to establish a persistent MQTT connection to Alexa IoT.
|
||||
//
|
||||
// Full implementation requires an AWS IoT integration:
|
||||
// - Parse the CSR from the form body
|
||||
// - Exchange it via the AWS IoT CreateKeysAndCertificate or RegisterThing API
|
||||
// - Return {"certificatePem": "...", "iot_endpoint": "...", "client_id": "..."}
|
||||
//
|
||||
// Until implemented, Alexa voice control will not work after cloud shutdown.
|
||||
func (s *Server) HandleAlexaCertificate(w http.ResponseWriter, r *http.Request) {
|
||||
device := ""
|
||||
|
||||
if err := r.ParseForm(); err == nil {
|
||||
if data := r.FormValue("data"); data != "" {
|
||||
var d struct {
|
||||
Device string `json:"device"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &d); err == nil {
|
||||
device = d.Device
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[alexa] certificate provisioning not implemented (device=%s); Alexa voice control requires AWS IoT integration", device)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
_, _ = w.Write([]byte(`{"error":"not_implemented","message":"Alexa IoT certificate provisioning requires AWS IoT integration. See voice.api.bose.io /alexa/certificate handler."}`))
|
||||
}
|
||||
@@ -4,12 +4,14 @@ package handlers
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/bmx"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
@@ -148,6 +150,29 @@ func (s *Server) HandleTuneInToken(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// HandleOrionToken returns an anonymous Orion access token.
|
||||
// The token is a base64-encoded JSON serial, matching the pattern used by the real Bose BMX Orion service.
|
||||
func (s *Server) HandleOrionToken(w http.ResponseWriter, _ *http.Request) {
|
||||
token := datastore.GenerateSerialSecret("orion")
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"_embedded": map[string]interface{}{
|
||||
"bmx_account": map[string]string{
|
||||
"displayName": "",
|
||||
"username": "",
|
||||
},
|
||||
},
|
||||
"access_token": token,
|
||||
"refresh_token": token,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleOrionPlayback returns Orion playback information.
|
||||
func (s *Server) HandleOrionPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
@@ -341,3 +366,27 @@ func (s *Server) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInFavorite handles POST /bmx/tunein/v1/favorite/{stationID}.
|
||||
func (s *Server) HandleTuneInFavorite(w http.ResponseWriter, r *http.Request) {
|
||||
stationID := chi.URLParam(r, "stationID")
|
||||
if err := s.ds.SaveTuneInFavorite(stationID); err != nil {
|
||||
log.Printf("Failed to persist TuneIn favorite %s: %v", stationID, err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
}
|
||||
|
||||
// HandleTuneInDeleteFavorite handles DELETE /bmx/tunein/v1/favorite/{stationID}.
|
||||
func (s *Server) HandleTuneInDeleteFavorite(w http.ResponseWriter, r *http.Request) {
|
||||
stationID := chi.URLParam(r, "stationID")
|
||||
if err := s.ds.DeleteTuneInFavorite(stationID); err != nil {
|
||||
log.Printf("Failed to delete TuneIn favorite %s: %v", stationID, err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
}
|
||||
|
||||
@@ -309,7 +309,7 @@ func TestMargeAccountFullExcludesEmptyAmazonSource(t *testing.T) {
|
||||
t.Fatalf("Failed to write first device DeviceInfo.xml: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(firstDir, "Sources.xml"), []byte(`<sources>
|
||||
<source id="10006" type="Audio" createdOn="2026-01-01T00:00:00.000+00:00" updatedOn="2026-01-01T00:00:00.000+00:00" displayName="Amazon Music" secret="" secretType="token" sourceproviderid="20">
|
||||
<source id="10006" type="AMAZON" createdOn="2026-01-01T00:00:00.000+00:00" updatedOn="2026-01-01T00:00:00.000+00:00" displayName="Amazon Music" secret="" secretType="token" sourceproviderid="20">
|
||||
<sourceKey type="AMAZON" account=""/>
|
||||
</source>
|
||||
</sources>`), 0644); err != nil {
|
||||
|
||||
@@ -17,6 +17,9 @@ var webFS embed.FS
|
||||
//go:embed static/media/*
|
||||
var mediaFS embed.FS
|
||||
|
||||
//go:embed static/ced
|
||||
var cedFS embed.FS
|
||||
|
||||
//go:embed static/bmx_services.json
|
||||
var bmxServicesJSON []byte
|
||||
|
||||
@@ -59,3 +62,21 @@ func (s *Server) HandleMedia() http.HandlerFunc {
|
||||
http.StripPrefix("/media", http.FileServer(http.FS(subFS))).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleBmxIcons returns a handler for serving BMX icon assets (media.bose.io /bmx-icons/*).
|
||||
func (s *Server) HandleBmxIcons() http.HandlerFunc {
|
||||
subFS, _ := fs.Sub(mediaFS, "static/media")
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
http.FileServer(http.FS(subFS)).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleCedStatic returns a handler for serving downloads.bose.com CED static files.
|
||||
func (s *Server) HandleCedStatic() http.HandlerFunc {
|
||||
subFS, _ := fs.Sub(cedFS, "static/ced")
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
http.StripPrefix("/ced", http.FileServer(http.FS(subFS))).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -578,7 +578,7 @@ func (s *Server) bridgeAmazonToMarge(accountID string) {
|
||||
}
|
||||
|
||||
for _, acc := range accounts {
|
||||
log.Printf("[Amazon Bridge] Registering Amazon user %s in Marge for account %s", acc.UserID, accountID)
|
||||
log.Printf("[Amazon Bridge] Registering Amazon user %s in Marge for account %s", acc.Email, accountID)
|
||||
|
||||
// Build the AmazonSecret credential envelope expected by the speaker firmware.
|
||||
credMap := map[string]interface{}{
|
||||
@@ -594,7 +594,7 @@ func (s *Server) bridgeAmazonToMarge(accountID string) {
|
||||
continue
|
||||
}
|
||||
|
||||
_, err = marge.AddSource(s.ds, accountID, acc.UserID, strconv.Itoa(constants.AmazonProviderID), string(credJSON), constants.CredentialTypeToken, acc.DisplayName)
|
||||
_, err = marge.AddSource(s.ds, accountID, acc.Email, strconv.Itoa(constants.AmazonProviderID), string(credJSON), constants.CredentialTypeToken, acc.DisplayName)
|
||||
if err != nil {
|
||||
log.Printf("[Amazon Bridge] Failed to register source in Marge: %v", err)
|
||||
continue
|
||||
@@ -623,7 +623,7 @@ func (s *Server) bridgeAmazonToMarge(accountID string) {
|
||||
cfg.Host = d.IPAddress
|
||||
cfg.Timeout = 5 * time.Second
|
||||
c := client.NewClient(cfg)
|
||||
creds := models.NewAmazonOAuthCredentials(acc.UserID, string(credJSON), acc.DisplayName)
|
||||
creds := models.NewAmazonOAuthCredentials(acc.Email, string(credJSON), acc.DisplayName)
|
||||
|
||||
if err := c.SetMusicServiceOAuthAccount(creds); err != nil {
|
||||
log.Printf("[Amazon Bridge] Failed to notify speaker %s via OAuth: %v", d.Name, err)
|
||||
@@ -633,7 +633,7 @@ func (s *Server) bridgeAmazonToMarge(accountID string) {
|
||||
log.Printf("[Amazon Bridge] Sync notification failed for speaker %s: %v", d.Name, err)
|
||||
log.Printf("[Amazon Bridge] Falling back to legacy account creation for speaker %s", d.Name)
|
||||
|
||||
legacyCreds := models.NewAmazonMusicCredentials(acc.UserID, string(credJSON))
|
||||
legacyCreds := models.NewAmazonMusicCredentials(acc.Email, string(credJSON))
|
||||
if err := c.SetMusicServiceAccount(legacyCreds); err != nil {
|
||||
log.Printf("[Amazon Bridge] Legacy fallback failed for speaker %s: %v", d.Name, err)
|
||||
} else {
|
||||
|
||||
@@ -256,11 +256,15 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.Lock()
|
||||
s.serverURL = settings.ServerURL
|
||||
|
||||
s.discoveryEnabled = settings.DiscoveryEnabled
|
||||
if settings.DiscoveryInterval != "" {
|
||||
s.discoveryInterval = interval
|
||||
}
|
||||
|
||||
s.discoveryEnabled = settings.DiscoveryEnabled
|
||||
if s.discoveryInterval == 0 {
|
||||
s.discoveryEnabled = false
|
||||
}
|
||||
|
||||
s.dnsEnabled = settings.DNSEnabled
|
||||
|
||||
// Handle comma-separated upstream DNS servers
|
||||
|
||||
@@ -417,7 +417,7 @@ func (m *mockSSH) Run(command string) (string, error) {
|
||||
m.runCount++
|
||||
if m.runCount > 1 {
|
||||
// Return updated hosts for verification
|
||||
return "127.0.0.1 localhost\n192.168.1.100\tstreaming.bose.com\n192.168.1.100\tupdates.bose.com\n192.168.1.100\tstats.bose.com\n192.168.1.100\tbmx.bose.com\n192.168.1.100\tcontent.api.bose.io\n192.168.1.100\tevents.api.bosecm.com\n192.168.1.100\tbose-prod.apigee.net\n192.168.1.100\tworldwide.bose.com", nil
|
||||
return "127.0.0.1 localhost\n192.168.1.100\tstreaming.bose.com\n192.168.1.100\tupdates.bose.com\n192.168.1.100\tstats.bose.com\n192.168.1.100\tbmx.bose.com\n192.168.1.100\tcontent.api.bose.io\n192.168.1.100\tevents.api.bosecm.com\n192.168.1.100\tbose-prod.apigee.net\n192.168.1.100\tworldwide.bose.com\n192.168.1.100\tmedia.bose.io\n192.168.1.100\tdownloads.bose.com\n192.168.1.100\tvoice.api.bose.io", nil
|
||||
}
|
||||
return "127.0.0.1 localhost", nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<INDEX REVISION="02.11.00">
|
||||
|
||||
<!-- SoundTouch 20 -->
|
||||
<DEVICE ID="0x0923" PRODUCTNAME="SoundTouch 20">
|
||||
<HARDWARE REVISION="00.01.00">
|
||||
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/s/Update.stu">
|
||||
<IMAGE SUBID="0" LENGTH="105879988" CRC="0x2d5a971e" FILENAME="Update_ti_27.0.6.46330.5043500.scm.stu" />
|
||||
</RELEASE>
|
||||
</HARDWARE>
|
||||
</DEVICE>
|
||||
|
||||
<!-- SoundTouch 30 -->
|
||||
<DEVICE ID="0x0924" PRODUCTNAME="SoundTouch 30">
|
||||
<HARDWARE REVISION="00.01.00">
|
||||
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/s/Update.stu">
|
||||
<IMAGE SUBID="0" LENGTH="105879988" CRC="0x2d5a971e" FILENAME="Update_ti_27.0.6.46330.5043500.scm.stu" />
|
||||
</RELEASE>
|
||||
</HARDWARE>
|
||||
</DEVICE>
|
||||
|
||||
<!-- SoundTouch Portable -->
|
||||
<DEVICE ID="0x0925" PRODUCTNAME="SoundTouch Portable">
|
||||
<HARDWARE REVISION="00.01.00">
|
||||
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/s/Update.stu">
|
||||
<IMAGE SUBID="0" LENGTH="105879988" CRC="0x2d5a971e" FILENAME="Update_ti_27.0.6.46330.5043500.scm.stu" />
|
||||
</RELEASE>
|
||||
</HARDWARE>
|
||||
</DEVICE>
|
||||
|
||||
<!-- SoundTouch App HTML5 -->
|
||||
<DEVICE ID="0x0931" PRODUCTNAME="SoundTouch App HTML5">
|
||||
<HARDWARE REVISION="00.01.00">
|
||||
<RELEASE REVISION="27.0.13" HTTPHOST="downloads.bose.com" URLPATH="/ced/soundtouch/mr4_22097fe2/" DOCVERSION="MjAxOC0wMi0xNQ==">
|
||||
<IMAGE SUBID="0" LENGTH="18377810" CRC="0xaa8209b0" FILENAME="Stockholm_27.0.13-4277-8963611.zip" />
|
||||
<NOTES URL="https://downloads.bose.com/ced/soundtouch/mr4_22097fe2/relnotes/releasenotes_##LANG##.xml" />
|
||||
<FEATURE NAME="TRIO" STATUS="OFF" />
|
||||
<FEATURE NAME="ASTREAM" STATUS="OFF" />
|
||||
<FEATURE NAME="RVT" STATUS="ON" />
|
||||
<FEATURE NAME="AD" STATUS="OFF" />
|
||||
</RELEASE>
|
||||
<PROTOCOL REVISION="67">
|
||||
<IMAGE PLATFORM="IOS" URL="https://itunes.apple.com/us/app/soundtouch-controller/id708379313" />
|
||||
<IMAGE PLATFORM="ANDROID" URL="https://play.google.com/store/apps/details?id=com.bose.soundtouch" />
|
||||
<IMAGE PLATFORM="KINDLE" URL="http://www.amazon.com/gp/mas/dl/android?asin=B00R4VJMMU"/>
|
||||
<IMAGE PLATFORM="PC" URL="http://www.bose.com/soundtouch_app_update" />
|
||||
</PROTOCOL>
|
||||
</HARDWARE>
|
||||
</DEVICE>
|
||||
|
||||
<!-- Wave SoundTouch -->
|
||||
<DEVICE ID="0x0932" PRODUCTNAME="Wave SoundTouch">
|
||||
<HARDWARE REVISION="00.01.00">
|
||||
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/n/Update.stu">
|
||||
<IMAGE SUBID="0" LENGTH="112367416" CRC="0x49d88de4" FILENAME="Update_ti_27.0.6.46330.5043500.nelson.scm.stu" />
|
||||
</RELEASE>
|
||||
</HARDWARE>
|
||||
</DEVICE>
|
||||
|
||||
<!-- VideoWave (Developer Keys) -->
|
||||
<DEVICE ID="0x0944" PRODUCTNAME="VideoWave">
|
||||
<HARDWARE REVISION="00.01.00">
|
||||
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2">
|
||||
<IMAGE SUBID="0" LENGTH="215402464" CRC="0xf39b7005" FILENAME="Update_ti_27.0.6.46330.5043500.marconidev.scm.stu" />
|
||||
</RELEASE>
|
||||
</HARDWARE>
|
||||
</DEVICE>
|
||||
|
||||
<!-- Lifestyle (Developer Keys) -->
|
||||
<DEVICE ID="0x0945" PRODUCTNAME="Lifestyle">
|
||||
<HARDWARE REVISION="00.01.00">
|
||||
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2">
|
||||
<IMAGE SUBID="0" LENGTH="215402464" CRC="0xf39b7005" FILENAME="Update_ti_27.0.6.46330.5043500.marconidev.scm.stu" />
|
||||
</RELEASE>
|
||||
</HARDWARE>
|
||||
</DEVICE>
|
||||
|
||||
<!-- SoundTouch Stereo JC -->
|
||||
<DEVICE ID="0x0935" PRODUCTNAME="SoundTouch Stereo JC">
|
||||
<HARDWARE REVISION="00.01.00">
|
||||
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/l/Update.stu">
|
||||
<IMAGE SUBID="0" LENGTH="110991796" CRC="0x01e35713" FILENAME="Update_ti_27.0.6.46330.5043500.lisa.scm.stu" />
|
||||
</RELEASE>
|
||||
</HARDWARE>
|
||||
</DEVICE>
|
||||
|
||||
<!-- SoundTouch SA-4 -->
|
||||
<DEVICE ID="0x0936" PRODUCTNAME="SoundTouch SA-4">
|
||||
<HARDWARE REVISION="00.01.00">
|
||||
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/l/Update.stu">
|
||||
<IMAGE SUBID="0" LENGTH="110991796" CRC="0x01e35713" FILENAME="Update_ti_27.0.6.46330.5043500.lisa.scm.stu" />
|
||||
</RELEASE>
|
||||
</HARDWARE>
|
||||
</DEVICE>
|
||||
|
||||
<!-- Cinemate -->
|
||||
<DEVICE ID="0x0938" PRODUCTNAME="Cinemate">
|
||||
<HARDWARE REVISION="00.01.00">
|
||||
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/t/Update.stu">
|
||||
<IMAGE SUBID="0" LENGTH="114125624" CRC="0x562de6f1" FILENAME="Update_ti_27.0.6.46330.5043500.triode.scm.stu" />
|
||||
</RELEASE>
|
||||
</HARDWARE>
|
||||
</DEVICE>
|
||||
|
||||
<!-- SoundTouch 10 -->
|
||||
<DEVICE ID="0x0939" PRODUCTNAME="SoundTouch 10">
|
||||
<HARDWARE REVISION="00.01.00">
|
||||
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/r/sm2/Update.stu">
|
||||
<IMAGE SUBID="0" LENGTH="95906856" CRC="0xf18fe026" FILENAME="Update_ti_27.0.6.46330.5043500.rhino.sm2.stu" />
|
||||
</RELEASE>
|
||||
</HARDWARE>
|
||||
</DEVICE>
|
||||
|
||||
<!-- SoundTouch SA-5 -->
|
||||
<DEVICE ID="0x093A" PRODUCTNAME="SoundTouch SA-5">
|
||||
<HARDWARE REVISION="00.01.00">
|
||||
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/b/sm2/Update.stu">
|
||||
<IMAGE SUBID="0" LENGTH="100957944" CRC="0x0b99190b" FILENAME="Update_ti_27.0.6.46330.5043500.burns.sm2.stu" />
|
||||
</RELEASE>
|
||||
</HARDWARE>
|
||||
</DEVICE>
|
||||
|
||||
<!-- SoundTouch 20 -->
|
||||
<DEVICE ID="0x093B" PRODUCTNAME="SoundTouch 20">
|
||||
<HARDWARE REVISION="00.01.00">
|
||||
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/s/sm2/Update.stu">
|
||||
<IMAGE SUBID="0" LENGTH="99445800" CRC="0x536e6d6f" FILENAME="Update_ti_27.0.6.46330.5043500.sm2.stu" />
|
||||
</RELEASE>
|
||||
</HARDWARE>
|
||||
</DEVICE>
|
||||
|
||||
<!-- SoundTouch 30 -->
|
||||
<DEVICE ID="0x093C" PRODUCTNAME="SoundTouch 30">
|
||||
<HARDWARE REVISION="00.01.00">
|
||||
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/s/sm2/Update.stu">
|
||||
<IMAGE SUBID="0" LENGTH="99445800" CRC="0x536e6d6f" FILENAME="Update_ti_27.0.6.46330.5043500.sm2.stu" />
|
||||
</RELEASE>
|
||||
</HARDWARE>
|
||||
</DEVICE>
|
||||
|
||||
<!-- Wave SoundTouch -->
|
||||
<DEVICE ID="0x093D" PRODUCTNAME="Wave SoundTouch">
|
||||
<HARDWARE REVISION="00.01.00">
|
||||
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/n/sm2/Update.stu">
|
||||
<IMAGE SUBID="0" LENGTH="100297132" CRC="0x2e2fd417" FILENAME="Update_ti_27.0.6.46330.5043500.nelson.sm2.stu" />
|
||||
</RELEASE>
|
||||
</HARDWARE>
|
||||
</DEVICE>
|
||||
|
||||
<!-- VideoWave (Developer Keys) -->
|
||||
<DEVICE ID="0x0946" PRODUCTNAME="VideoWave">
|
||||
<HARDWARE REVISION="00.01.00">
|
||||
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2">
|
||||
<IMAGE SUBID="0" LENGTH="203977300" CRC="0x1858fa51" FILENAME="Update_ti_27.0.6.46330.5043500.marconidev.sm2.stu" />
|
||||
</RELEASE>
|
||||
</HARDWARE>
|
||||
</DEVICE>
|
||||
|
||||
<!-- Lifestyle (Developer Keys) -->
|
||||
<DEVICE ID="0x0947" PRODUCTNAME="Lifestyle">
|
||||
<HARDWARE REVISION="00.01.00">
|
||||
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2">
|
||||
<IMAGE SUBID="0" LENGTH="203977300" CRC="0x1858fa51" FILENAME="Update_ti_27.0.6.46330.5043500.marconidev.sm2.stu" />
|
||||
</RELEASE>
|
||||
</HARDWARE>
|
||||
</DEVICE>
|
||||
|
||||
<!-- SoundTouch Stereo JC -->
|
||||
<DEVICE ID="0x0940" PRODUCTNAME="SoundTouch Stereo JC">
|
||||
<HARDWARE REVISION="00.01.00">
|
||||
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/l/sm2/Update.stu">
|
||||
<IMAGE SUBID="0" LENGTH="98921512" CRC="0x07521bda" FILENAME="Update_ti_27.0.6.46330.5043500.lisa.sm2.stu" />
|
||||
</RELEASE>
|
||||
</HARDWARE>
|
||||
</DEVICE>
|
||||
|
||||
<!-- SoundTouch SA-4 -->
|
||||
<DEVICE ID="0x0941" PRODUCTNAME="SoundTouch SA-4">
|
||||
<HARDWARE REVISION="00.01.00">
|
||||
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/l/sm2/Update.stu">
|
||||
<IMAGE SUBID="0" LENGTH="98921512" CRC="0x07521bda" FILENAME="Update_ti_27.0.6.46330.5043500.lisa.sm2.stu" />
|
||||
</RELEASE>
|
||||
</HARDWARE>
|
||||
</DEVICE>
|
||||
|
||||
<!-- Cinemate -->
|
||||
<DEVICE ID="0x0942" PRODUCTNAME="Cinemate">
|
||||
<HARDWARE REVISION="00.01.00">
|
||||
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/t/sm2/Update.stu">
|
||||
<IMAGE SUBID="0" LENGTH="102700460" CRC="0xfbcab635" FILENAME="Update_ti_27.0.6.46330.5043500.triode.sm2.stu" />
|
||||
</RELEASE>
|
||||
</HARDWARE>
|
||||
</DEVICE>
|
||||
|
||||
<!-- VideoWave (Production Keys) -->
|
||||
<DEVICE ID="0x0933" PRODUCTNAME="VideoWave">
|
||||
<HARDWARE REVISION="00.01.00">
|
||||
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/m/Update.stu">
|
||||
<IMAGE SUBID="0" LENGTH="215402464" CRC="0xd48b1181" FILENAME="Update_ti_27.0.6.46330.5043500.marconiprod.scm.stu" />
|
||||
</RELEASE>
|
||||
</HARDWARE>
|
||||
</DEVICE>
|
||||
|
||||
<!-- Lifestyle (Production Keys) -->
|
||||
<DEVICE ID="0x0934" PRODUCTNAME="Lifestyle">
|
||||
<HARDWARE REVISION="00.01.00">
|
||||
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/m/Update.stu">
|
||||
<IMAGE SUBID="0" LENGTH="215402464" CRC="0xd48b1181" FILENAME="Update_ti_27.0.6.46330.5043500.marconiprod.scm.stu" />
|
||||
</RELEASE>
|
||||
</HARDWARE>
|
||||
</DEVICE>
|
||||
|
||||
<!-- VideoWave (Production Keys) -->
|
||||
<DEVICE ID="0x093E" PRODUCTNAME="VideoWave">
|
||||
<HARDWARE REVISION="00.01.00">
|
||||
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/m/sm2/Update.stu">
|
||||
<IMAGE SUBID="0" LENGTH="203977300" CRC="0x3f489bd5" FILENAME="Update_ti_27.0.6.46330.5043500.marconiprod.sm2.stu" />
|
||||
</RELEASE>
|
||||
</HARDWARE>
|
||||
</DEVICE>
|
||||
|
||||
<!-- Lifestyle (Production Keys) -->
|
||||
<DEVICE ID="0x093F" PRODUCTNAME="Lifestyle">
|
||||
<HARDWARE REVISION="00.01.00">
|
||||
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/m/sm2/Update.stu">
|
||||
<IMAGE SUBID="0" LENGTH="203977300" CRC="0x3f489bd5" FILENAME="Update_ti_27.0.6.46330.5043500.marconiprod.sm2.stu" />
|
||||
</RELEASE>
|
||||
</HARDWARE>
|
||||
</DEVICE>
|
||||
|
||||
<!-- Lifestyle -->
|
||||
<DEVICE ID="0x094B" PRODUCTNAME="Lifestyle">
|
||||
<HARDWARE REVISION="00.01.00">
|
||||
<RELEASE REVISION="27.0.6.5043530" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/Update.avu">
|
||||
<IMAGE SUBID="0" LENGTH="475186323" CRC="0x523be82b" FILENAME="Update_signed_27.0.6.5043530.avu" />
|
||||
</RELEASE>
|
||||
</HARDWARE>
|
||||
</DEVICE>
|
||||
|
||||
<!-- Lifestyle -->
|
||||
<DEVICE ID="0x0948" PRODUCTNAME="Lifestyle">
|
||||
<HARDWARE REVISION="00.01.00">
|
||||
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2">
|
||||
<IMAGE SUBID="0" LENGTH="105878364" CRC="0xc0b3d401" FILENAME="Update_ti_27.0.6.46330.5043500.bardeen.sm2.stu" />
|
||||
</RELEASE>
|
||||
</HARDWARE>
|
||||
</DEVICE>
|
||||
|
||||
<!-- SoundTouch 300 -->
|
||||
<DEVICE ID="0x0949" PRODUCTNAME="SoundTouch 300">
|
||||
<HARDWARE REVISION="00.01.00">
|
||||
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/g/sm2/Update.stu">
|
||||
<IMAGE SUBID="0" LENGTH="102384736" CRC="0xed21efd3" FILENAME="Update_ti_27.0.6.46330.5043500.ginger.sm2.stu" />
|
||||
</RELEASE>
|
||||
</HARDWARE>
|
||||
</DEVICE>
|
||||
|
||||
<!-- SoundTouch Wireless Link adapter -->
|
||||
<DEVICE ID="0x094A" PRODUCTNAME="SoundTouch Wireless Link adapter">
|
||||
<HARDWARE REVISION="00.01.00">
|
||||
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/s/sm2/Update.stu">
|
||||
<IMAGE SUBID="0" LENGTH="99445800" CRC="0x536e6d6f" FILENAME="Update_ti_27.0.6.46330.5043500.sm2.stu" />
|
||||
</RELEASE>
|
||||
</HARDWARE>
|
||||
</DEVICE>
|
||||
|
||||
<!-- SoundTouch App for Android -->
|
||||
<DEVICE ID="0x000A" PRODUCTNAME="SoundTouch App-A" SUPPORTEDOS="4.4.0">
|
||||
<HARDWARE REVISION="00.01.00">
|
||||
<RELEASE REVISION="27.0.1" HTTPHOST="downloads.bose.com" URLPATH="/ced/soundtouch/mr4_22097fe2/">
|
||||
<IMAGE SUBID="0" LENGTH="23351636" CRC="0x425b2109" FILENAME="SoundTouch-release_27.0.1-3345-bafe54d.apk" />
|
||||
</RELEASE>
|
||||
</HARDWARE>
|
||||
</DEVICE>
|
||||
|
||||
<!-- SoundTouch App for iOS -->
|
||||
<DEVICE ID="0x000B" PRODUCTNAME="SoundTouch App-I" SUPPORTEDOS="8.0.0">
|
||||
<HARDWARE REVISION="00.01.00">
|
||||
<RELEASE REVISION="27.0.1" HTTPHOST="downloads.bose.com" URLPATH="/ced/soundtouch/mr4_22097fe2/">
|
||||
<IMAGE SUBID="0" LENGTH="47413805" CRC="0xe4bf4961" FILENAME="SoundTouch-27.0.1-3498-699a15c.ipa" />
|
||||
</RELEASE>
|
||||
</HARDWARE>
|
||||
</DEVICE>
|
||||
|
||||
<!-- SoundTouch App for Mac (pre OS X 10.9) -->
|
||||
<DEVICE ID="0x000C" PRODUCTNAME="SoundTouch App-M" SUPPORTEDOS="mac_10_8">
|
||||
<HARDWARE REVISION="00.01.00">
|
||||
<RELEASE REVISION="27.0.0" HTTPHOST="downloads.bose.com" URLPATH="/ced/soundtouch/mr4_22097fe2/">
|
||||
<IMAGE SUBID="0" LENGTH="117483653" CRC="0xd6ca482b" FILENAME="SoundTouch-app-installer-27.0.0-3377-1037583.dmg" />
|
||||
</RELEASE>
|
||||
</HARDWARE>
|
||||
</DEVICE>
|
||||
|
||||
<!-- SoundTouch App for Mac (OS X 10.9 & later) -->
|
||||
<DEVICE ID="0x000E" PRODUCTNAME="SoundTouch App-M" SUPPORTEDOS="mac_10_8">
|
||||
<HARDWARE REVISION="00.01.00">
|
||||
<RELEASE REVISION="27.0.0" HTTPHOST="downloads.bose.com" URLPATH="/ced/soundtouch/mr4_22097fe2/">
|
||||
<IMAGE SUBID="0" LENGTH="117483653" CRC="0xd6ca482b" FILENAME="SoundTouch-app-installer-27.0.0-3377-1037583.dmg" />
|
||||
</RELEASE>
|
||||
</HARDWARE>
|
||||
</DEVICE>
|
||||
|
||||
<!-- SoundTouch App for PC -->
|
||||
<DEVICE ID="0x000D" PRODUCTNAME="SoundTouch App-W" SUPPORTEDOS="windows_6_0">
|
||||
<HARDWARE REVISION="00.01.00">
|
||||
<RELEASE REVISION="27.0.0.3377" HTTPHOST="downloads.bose.com" URLPATH="/ced/soundtouch/mr4_22097fe2/">
|
||||
<IMAGE SUBID="0" LENGTH="120307712" CRC="0x3e97da1f" FILENAME="SoundTouch-app-installer-27.0.0.3377.msi" />
|
||||
</RELEASE>
|
||||
</HARDWARE>
|
||||
</DEVICE>
|
||||
|
||||
</INDEX>
|
||||
@@ -0,0 +1,101 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<releases>
|
||||
<release revision="27.0.0">
|
||||
<feature>Fixed bugs and did some general cleaning up under the hood</feature>
|
||||
</release>
|
||||
<release revision="26.0.0">
|
||||
<feature>Fixed bugs and did some general cleaning up under the hood</feature>
|
||||
</release>
|
||||
<release revision="25.0.0">
|
||||
<feature>Airplay2 support arrives for SoundTouch Wireless Link adapter!</feature>
|
||||
<feature>Added improvements to the setup experience</feature>
|
||||
<feature>General bug fixes and improvements</feature>
|
||||
</release>
|
||||
<release revision="24.0.0">
|
||||
<feature>As usual, this release includes bug fixes and performance improvements</feature>
|
||||
</release>
|
||||
<release revision ="23.0.0">
|
||||
<feature platform="IOS">Added lots of under the hood changes to improve the app experience on iOS13</feature>
|
||||
<feature platform="ANDROID">Fixed an issue with Wi-Fi setup on certain Android devices</feature>
|
||||
<feature>Additional bug fixes and performance improvements</feature>
|
||||
</release>
|
||||
<release revision ="22.0.0">
|
||||
<feature>We've added RadioPlayer to our family of music services! Enjoy thousands of regional stations and on demand programs right at your fingertips. Available in select regions</feature>
|
||||
<feature>You can now disable the automatic power save mode in the app for your SoundTouch 10, SoundTouch 20 and SoundTouch 30</feature>
|
||||
<feature>Additional bug fixes & enhancements</feature>
|
||||
</release>
|
||||
<release revision ="21.0.0">
|
||||
<feature>Save more music — you can now save Favorites along with your 6 presets</feature>
|
||||
<feature>We improved Spotify search so you can get grooving faster</feature>
|
||||
<feature>It's now easier to access Recently Played</feature>
|
||||
<feature>Little tweaks here and there to make the app better for you</feature>
|
||||
</release>
|
||||
<release revision="20.0.0">
|
||||
<feature>Say hello to TuneIn (and goodbye to Internet Radio)</feature>
|
||||
<feature>Easily browse through your favorite artists in Spotify</feature>
|
||||
<feature>We made it easier to create an account and reset your password</feature>
|
||||
<feature>General improvements made with our special app-tuning forks</feature>
|
||||
</release>
|
||||
<release revision="19.0.0">
|
||||
<feature>Security related updates to improve application configuration and operation</feature>
|
||||
<feature>Many other bug fixes and enhancements</feature>
|
||||
</release>
|
||||
<release revision="18.0.0">
|
||||
<feature>By updating, you agree to our new Privacy Policy and Terms of Use available at https://worldwide.bose.com/privacypolicy and https://worldwide.bose.com/termsofuse</feature>
|
||||
<feature>Security related updates to improve application configuration and operation</feature>
|
||||
<feature>We tuned the app to include some major performance and stability improvements</feature>
|
||||
</release>
|
||||
<release revision="17.0.0">
|
||||
<feature>Enhanced broadcasting performance of AUX/wired input to other SoundTouch speakers</feature>
|
||||
<feature>Search enabled in Amazon Music</feature>
|
||||
<feature>Many other bug fixes and enhancements</feature>
|
||||
</release>
|
||||
<release revision="16.0.0">
|
||||
<feature><![CDATA[By updating the app, you agree to our new Privacy Policy, available at <a href="https://www.soundtouch.com/privacy">SoundTouch.com/privacy</a>]]></feature>
|
||||
<feature>Broadcasting of AUX/wired input to other SoundTouch speakers</feature>
|
||||
<feature>Volume and grouping enhancements</feature>
|
||||
<feature>New "Just for You" section</feature>
|
||||
<feature>Many other bug fixes and enhancements</feature>
|
||||
</release>
|
||||
<release revision="15.0.0">
|
||||
<feature>As usual, this release includes bug fixes and performance improvements</feature>
|
||||
</release>
|
||||
<release revision="14.0.0">
|
||||
<feature>We made your SoundTouch® experience better, with a completely redesigned app that's easier to use and nicer to look at</feature>
|
||||
<feature>Stereo pairing for SoundTouch® 10 speakers</feature>
|
||||
<feature>QQ Music QPlay streaming support (China only)</feature>
|
||||
<feature platform="IOS">Now you can control your speaker from your Apple Watch® – just open the Watch app and add SoundTouch®</feature>
|
||||
<feature platform="IOS">There’s a new SoundTouch® widget available for iOS – add it in the Today View so you can control your speaker even while your device is locked</feature>
|
||||
<feature>We made speaker selection and volume control more reliable</feature>
|
||||
<feature>The app now shows Pandora’s new logo</feature>
|
||||
<feature>Plus, we fixed some bugs and made things more stable</feature>
|
||||
</release>
|
||||
<release revision="13.0.0">
|
||||
<feature>Updated music library experience with faster navigation, easier search, and more album art</feature>
|
||||
<feature platform="ANDROID">Improved Android Wear support</feature>
|
||||
<feature>Bug fixes</feature>
|
||||
</release>
|
||||
<release revision="12.0.0">
|
||||
<feature>Added Amazon Prime Music (US Only)</feature>
|
||||
<feature platform="ANDROID">Android Wear notifications support</feature>
|
||||
<feature platform="ANDROID">Android lock-screen controls</feature>
|
||||
<feature>SiriusXM® Business Account support</feature>
|
||||
<feature>Over 30 bug fixes</feature>
|
||||
</release>
|
||||
<release revision="7.0.0">
|
||||
<feature>Support for additional music services (where available)</feature>
|
||||
<feature>Search for a track, album, or artist in your stored music library</feature>
|
||||
<feature>Adjust the bass performance of your SoundTouch® system</feature>
|
||||
<feature>Additional bug fixes and performance improvements</feature>
|
||||
</release>
|
||||
<release revision="6.0.0">
|
||||
<feature>Enables wireless setup of SoundTouch® products</feature>
|
||||
<feature>Faster connection to speakers on your network</feature>
|
||||
</release>
|
||||
<release revision="4.0.0">
|
||||
<feature>Enhanced detection of speakers on your network</feature>
|
||||
</release>
|
||||
<release revision="3.0.0">
|
||||
<feature>Improved system discovery</feature>
|
||||
</release>
|
||||
</releases>
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<page id="bose_account_create_1"><item><topic>Why do I need to create an account?</topic><answer><![CDATA[<p>A SoundTouch<sup>®</sup> account is required to keep track of your speakers, presets and music service. In addition, an account helps us better support your speaker and troubleshoot problems.</p> <p>If you are setting up multiple SoundTouch<sup>®</sup> speakers, we strongly recommend that you set them up on the same SoundTouch<sup>®</sup> account. Also, any time you install the SoundTouch<sup>®</sup> app on another mobile device or computer, you are required to sign in using your SoundTouch<sup>®</sup> account info.</p>]]></answer></item><item><topic>Why do I have to provide my email address?</topic><answer><![CDATA[<p>Each SoundTouch<sup>®</sup> account is uniquely identified by an email address. Notifications of software and service updates are sent to this address. When you provide your email address, you can choose (or decline) to receive emails from Bose<sup>®</sup> about new products, events and other announcements. Please read our privacy policy to learn more about how we use your email address by selecting <strong>Settings > About > LEGAL</strong>.</p> <p>If you prefer not to provide your email address, you can set up your speaker anonymously by providing a single-purpose or even non-working email address. In this case, your speaker will continue to receive system software updates, but you will not receive email notifications explaining the new features when these updates are available.</p>]]></answer></item><item><topic>Is there a minimum character or format requirement for the password?</topic><answer><![CDATA[<p>Your password must be at least six characters in length. The password field is case sensitive.</p>]]></answer></item><item><topic>Why should I provide my name and address?</topic><answer><![CDATA[<p>We use your name and address to improve your customer service experience should you need assistance. Please read our privacy policy to understand other ways we may use your name and address by selecting <strong>Settings > About > LEGAL.</strong></p>]]></answer></item></page>
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<page id="gabbo_ios_settings"><item><topic>How do I change my network?</topic><answer><![CDATA[<p>Change your network from the Settings menu on your mobile device.</p><ol><li><p>On your mobile device, tap the <strong>Home</strong> button.</p></li><li><p>Tap <strong>Settings > Wi-Fi</strong>.</p> <p><strong>Note</strong>: By default, the Settings menu is on the Home screen.</p></li><li><p>Select the Wi-Fi<sup>®</sup> network starting with <strong>Bose</strong>.</p></li><li><p>Tap the <strong>Home</strong> button.</p></li><li><p>Tap the SoundTouch<sup>®</sup> icon to return to the SoundTouch<sup>®</sup> app.</p></li></ol>]]></answer></item><item><topic>Why do I need to connect to the "Bose" Wi-Fi<sup>®</sup> network?</topic><answer><![CDATA[<p>Connecting to the "Bose" Wi-Fi<sup>®</sup> network enables your speaker to be set up on your home Wi-Fi<sup>®</sup> network. Connecting to this network is temporary. After you set up the speaker, you reconnect to your home network.</p>]]></answer></item><item><topic>I can't find the "Bose" Wi-Fi<sup>®</sup> network.</topic><answer><![CDATA[<p>Make sure that you're looking for the Wi-Fi<sup>®</sup> network starting with Bose. It may take up to 30 seconds for the network to appear on the list.</p><p>If the network does not appear after 30 seconds, select <strong>I Don't See This Network</strong> to put the system into setup mode and continue with setup.</p>]]></answer></item><item><topic>How do I return to the app from my mobile device's Settings menu?</topic><answer><![CDATA[<ol><li><p>On your mobile device, tap the <strong>Home</strong> button.</p><p>On the Home screen, tap the SoundTouch<sup>®</sup> icon.</p></li></ol>]]></answer></item></page>
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<page id="gabbo_recovery_lisa"><item><topic>The Wi-Fi<sup>®</sup> indicator isn't glowing amber. What do I do now?</topic><answer><![CDATA[<p>If the Wi-Fi<sup>®</sup> indicator is not glowing solid amber, your system is not in setup mode.</p><ol><li><p>Power on your system.</p></li><li><p>Press and hold the <strong>Control</strong> button on the back of the SoundTouch<sup>®</sup> wireless adapter for 1-8 seconds.</p> <p><strong>Note</strong>: If the Wi-Fi<sup>®</sup> indicator does not glow solid amber, press and hold the <strong>Control</strong> button again, making sure to release the button before 8 seconds elapses.</p></li></ol>]]></answer></item><item><topic>Where is the Control button?</topic><answer><![CDATA[<p>The Control button is on the SoundTouch<sup>®</sup> wireless adapter's connector panel.</p>]]></answer></item><item><topic>Where is the Wi-Fi<sup>®</sup> indicator?</topic><answer><![CDATA[<p>The Wi-Fi<sup>®</sup> indicator is on the SoundTouch<sup>®</sup> wireless adapter's connector panel.</p>]]></answer></item></page>
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<page id="gabbo_recovery_nelson"><item><topic>The Wi-Fi<sup>®</sup> indicator isn't glowing amber. What do I do now?</topic><answer><![CDATA[<p>If the Wi-Fi<sup>®</sup> indicator is not glowing solid amber, your system is not in setup mode.</p><ol><li><p>Power on your system.</p></li><li><p>Press and hold the <strong>Control</strong> button on the back of the SoundTouch<sup>®</sup> pedestal for 1-8 seconds.</p> <p>The Wi-Fi<sup>®</sup> indicator glows solid amber.</p> <p>The message <em>SETUP SEE INSTRUCTIONS</em> appears on the display.</p> <p>Your system is now in setup mode.</p></li></ol><p><strong>Note</strong>: If the Wi-Fi<sup>®</sup> indicator does not glow solid amber and the message does not appear on the display, press and hold the <strong>Control</strong> button again, making sure to release the button before 8 seconds elapses.</p>]]></answer></item><item><topic>Where is the Control button?</topic><answer><![CDATA[<p>The Control button is on the SoundTouch<sup>®</sup> pedestal’s connector panel.</p>]]></answer></item><item><topic>Where is the Wi-Fi<sup>®</sup> indicator?</topic><answer><![CDATA[<p>The Wi-Fi<sup>®</sup> indicator is on the SoundTouch<sup>®</sup> pedestal’s connector panel.</p>]]></answer></item></page>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<page id="gabbo_recovery_select_system">
|
||||
<item>
|
||||
<topic>What is the name of my speaker?</topic>
|
||||
<answer>
|
||||
<![CDATA[<p>Check the carton or the owner's guide that shipped with your speaker. You may also find the name of the speaker on a label on the back or bottom of the speaker.</p>]]>
|
||||
</answer></item>
|
||||
<item>
|
||||
<topic>What is the "Bose" Wi-Fi<sup>®</sup> network?</topic>
|
||||
<answer>
|
||||
<![CDATA[<p>Your speaker has its own built-in Wi-Fi network that you use to set up your speaker. You will temporarily connect to this network on the device you are using, set the speaker up on your network and then reconnect to your home Wi-Fi network.</p>]]>
|
||||
</answer>
|
||||
</item>
|
||||
</page>
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<page id="gabbo_recovery_smt"><item><topic>The Wi-Fi<sup>®</sup> indicator isn't glowing amber. What do I do now?</topic><answer><![CDATA[<p>If the Wi-Fi<sup>®</sup> indicator is not glowing solid amber, your speaker is not in setup mode.</p><ol><li><p>Power on your speaker.</p></li><li><p>On the button pad, press and hold the <strong>2</strong> and <strong>Volume -</strong> buttons until the countdown reaches 1 and a message similar to "Setup" appears on the display.</p> <p>The Wi-Fi<sup>®</sup> indicator glows solid amber.</p> <p>Your system is now in Setup mode.</p></li></ol>]]></answer></item><item><topic>Where is the Wi-Fi<sup>®</sup> indicator?</topic><answer><![CDATA[<p>The Wi-Fi<sup>®</sup> indicator is on the front of the speaker.</p>]]></answer></item></page>
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<page id="name_device"><item><topic>Why should I name my speaker?</topic><answer><![CDATA[<p>Naming the speaker makes it easy to recognize in the app when you have multiple speakers. You can use a name that identifies the space where the speaker is placed, for example, such as Living Room.</p>]]></answer></item><item><topic>Can I rename this speaker later?</topic><answer><![CDATA[<p>Yes, you can rename the speaker from the Settings menu. Select <strong>Settings > Speaker Settings</strong>, then select your speaker.</p>]]></answer></item></page>
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<page id="prompt_other_devices"><item><topic>Can I set up another speaker later?</topic><answer><![CDATA[<p>Yes. Select <strong>Settings > Add or Reconnect Speaker</strong>.</p>]]></answer></item><item><topic>How many speakers can I add to my network?</topic><answer><![CDATA[<p>You can add as many speakers to your network as determined by the capacity of your home Wi-Fi<sup>®</sup> network.</p>]]></answer></item></page>
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<page id="setup_done"/>
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<page id="wifi_or_ethernet_ask"><item><topic>How should I connect my speaker to my network?</topic><answer><![CDATA[<p>You can connect your speaker to your network using a Wi-Fi<sup>®</sup> or Ethernet connection. If you select Ethernet, make sure the Ethernet cable from your router can reach your speaker.</p> <p><strong>Note</strong>: If you are setting up a SoundTouch<sup>®</sup> 10 speaker, SoundTouch<sup>®</sup> Wireless Link or SoundTouch<sup>®</sup> Portable Wi-Fi<sup>®</sup> music system, you must select <strong>WI-FI</strong>.</p>]]></answer></item><item><topic>Will there be a difference in performance between a wired and a wireless setup?</topic><answer><![CDATA[<p>Performance varies depending on the number of devices on your network and how the devices are connected. A wireless connection provides more flexibility when placing your system. A wired connection is better when your wireless router's signal is weak or can't be received.</p>]]></answer></item></page>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
@@ -333,6 +333,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px">
|
||||
<strong>Root CA Certificate:</strong>
|
||||
<div style="margin-top: 8px; font-size: 0.9em; color: #555; max-width: 600px">
|
||||
Import this certificate into your OS or browser trust store to
|
||||
trust HTTPS connections to this AfterTouch server from other
|
||||
clients (e.g. curl, Python scripts, browsers).
|
||||
</div>
|
||||
<div style="margin-top: 8px">
|
||||
<a href="/setup/ca.crt" download="soundtouch-ca.crt">
|
||||
<button type="button">Download CA Certificate</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px">
|
||||
<button onclick="updateSettings()">Save Settings</button>
|
||||
<span
|
||||
@@ -531,6 +545,12 @@
|
||||
>
|
||||
Trust CA Now
|
||||
</button>
|
||||
<a
|
||||
href="/setup/ca.crt"
|
||||
download="soundtouch-ca.crt"
|
||||
style="margin-left: 10px; font-size: 0.85em"
|
||||
title="Download CA cert to import into other clients"
|
||||
>Download CA cert</a>
|
||||
</p>
|
||||
|
||||
<div
|
||||
@@ -770,8 +790,11 @@
|
||||
<option value="self">
|
||||
AfterTouch (Local Service)
|
||||
</option>
|
||||
<option value="upstream">
|
||||
Upstream (Proxy via local service)
|
||||
<option value="proxied">
|
||||
Proxied (via local service)
|
||||
</option>
|
||||
<option value="original">
|
||||
Original (keep Bose URL)
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
@@ -787,8 +810,11 @@
|
||||
<option value="self">
|
||||
AfterTouch (Local Service)
|
||||
</option>
|
||||
<option value="upstream">
|
||||
Upstream (Proxy via local service)
|
||||
<option value="proxied">
|
||||
Proxied (via local service)
|
||||
</option>
|
||||
<option value="original">
|
||||
Original (keep Bose URL)
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
@@ -804,8 +830,11 @@
|
||||
<option value="self">
|
||||
AfterTouch (Local Service)
|
||||
</option>
|
||||
<option value="upstream">
|
||||
Upstream (Proxy via local service)
|
||||
<option value="proxied">
|
||||
Proxied (via local service)
|
||||
</option>
|
||||
<option value="original">
|
||||
Original (keep Bose URL)
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
@@ -821,8 +850,11 @@
|
||||
<option value="self">
|
||||
AfterTouch (Local Service)
|
||||
</option>
|
||||
<option value="upstream">
|
||||
Upstream (Proxy via local service)
|
||||
<option value="proxied">
|
||||
Proxied (via local service)
|
||||
</option>
|
||||
<option value="original">
|
||||
Original (keep Bose URL)
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
@@ -842,6 +874,19 @@
|
||||
>Planned Config (AfterTouch)</span
|
||||
>
|
||||
<pre id="planned-config"></pre>
|
||||
<div
|
||||
id="resolve-ip-error"
|
||||
style="display: none; margin-top: 10px; padding: 10px; background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; color: #856404;"
|
||||
>
|
||||
⚠️ <strong>Hostname resolution warning:</strong>
|
||||
<span id="resolve-ip-error-msg"></span>
|
||||
<br/>
|
||||
The planned IP shown above may be incorrect.
|
||||
Migration methods that write IPs to the device
|
||||
(hosts, resolv.conf) will refuse to proceed until
|
||||
the hostname can be resolved from the device itself.
|
||||
<a href="https://github.com/gesellix/bose-soundtouch/blob/main/docs/guides/TROUBLESHOOTING.md#hostname-resolution" target="_blank" style="color: #856404;">Learn more →</a>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="planned-hosts-pane"
|
||||
|
||||
@@ -1816,6 +1816,14 @@ async function showSummary(deviceId) {
|
||||
document.getElementById("planned-hosts").innerText = summary.planned_hosts || "";
|
||||
document.getElementById("planned-resolv").innerText = summary.planned_resolv || "";
|
||||
|
||||
const resolveErrEl = document.getElementById("resolve-ip-error");
|
||||
if (summary.resolve_ip_error) {
|
||||
document.getElementById("resolve-ip-error-msg").innerText = summary.resolve_ip_error;
|
||||
resolveErrEl.style.display = "block";
|
||||
} else {
|
||||
resolveErrEl.style.display = "none";
|
||||
}
|
||||
|
||||
const currentResolvElem = document.getElementById("current-resolv-content");
|
||||
if (currentResolvElem) {
|
||||
currentResolvElem.innerText = summary.current_resolv_conf || "Not available";
|
||||
|
||||
@@ -106,7 +106,11 @@ func ensureTimestamps(s *models.ConfiguredSource) {
|
||||
|
||||
func ensureSourceType(s *models.ConfiguredSource) {
|
||||
if s.Type == "" || (s.SourceKey.Type != "" && s.SourceKey.Type != constants.ProviderAux && s.SourceKey.Type != constants.ProviderBluetooth) {
|
||||
s.Type = "Audio"
|
||||
if s.SourceKey.Type == constants.ProviderAmazon {
|
||||
s.Type = constants.ProviderAmazon
|
||||
} else {
|
||||
s.Type = "Audio"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ type MigrationSummary struct {
|
||||
CurrentResolvConf string `json:"current_resolv_conf,omitempty"`
|
||||
PlannedResolv string `json:"planned_resolv,omitempty"`
|
||||
IsMigrated bool `json:"is_migrated"`
|
||||
ResolveIPError string `json:"resolve_ip_error,omitempty"`
|
||||
MirrorEnabled bool `json:"mirror_enabled"`
|
||||
MirrorEndpoints []string `json:"mirror_endpoints,omitempty"`
|
||||
SkipMirrorEndpoints []string `json:"skip_mirror_endpoints,omitempty"`
|
||||
@@ -257,37 +258,8 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
|
||||
|
||||
summary.PlannedConfig = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" + string(xmlContent)
|
||||
|
||||
// 2b. Initial planned hosts config
|
||||
parsedURL, err := url.Parse(targetURL)
|
||||
if err == nil {
|
||||
hostName := parsedURL.Hostname()
|
||||
if hostName != "" && hostName != "localhost" {
|
||||
client := m.NewSSH(deviceIP)
|
||||
hostIP := m.resolveIP(hostName, client)
|
||||
|
||||
// Predicted aftertouch.resolv.conf
|
||||
summary.PlannedResolv = fmt.Sprintf("# Created by Aftertouch/SoundTouch-Service\n# Priority nameserver for Bose service redirection\nnameserver %s\n", hostIP)
|
||||
|
||||
domains := []string{
|
||||
"streaming.bose.com",
|
||||
"updates.bose.com",
|
||||
"stats.bose.com",
|
||||
"bmx.bose.com",
|
||||
"content.api.bose.io",
|
||||
"events.api.bosecm.com",
|
||||
"bose-prod.apigee.net",
|
||||
"worldwide.bose.com",
|
||||
"music.api.bose.com",
|
||||
}
|
||||
|
||||
var hostsLines []string
|
||||
for _, domain := range domains {
|
||||
hostsLines = append(hostsLines, fmt.Sprintf("%s\t%s", hostIP, domain))
|
||||
}
|
||||
|
||||
summary.PlannedHosts = strings.Join(hostsLines, "\n")
|
||||
}
|
||||
}
|
||||
// 2b. Planned network config (hosts entries, resolv.conf preview, resolve error)
|
||||
m.populatePlannedNetworkConfig(summary, deviceIP, targetURL)
|
||||
|
||||
// 3. Check for remote services files
|
||||
m.checkRemoteServices(summary, deviceIP)
|
||||
@@ -304,18 +276,7 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
|
||||
}
|
||||
|
||||
// 5. Provide HTTPS URL for testing
|
||||
if parsedURL, err := url.Parse(targetURL); err == nil {
|
||||
hostIP := parsedURL.Hostname()
|
||||
if hostIP != "" {
|
||||
// Find HTTPS port from environment or default
|
||||
httpsPort := os.Getenv("HTTPS_PORT")
|
||||
if httpsPort == "" {
|
||||
httpsPort = "8443"
|
||||
}
|
||||
|
||||
summary.ServerHTTPSURL = fmt.Sprintf("https://%s:%s/health", hostIP, httpsPort)
|
||||
}
|
||||
}
|
||||
summary.ServerHTTPSURL = m.buildServerHTTPSURL(targetURL)
|
||||
|
||||
// 6. Check if migrated
|
||||
m.checkIsMigrated(summary, deviceIP)
|
||||
@@ -334,6 +295,67 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func (m *Manager) populatePlannedNetworkConfig(summary *MigrationSummary, deviceIP, targetURL string) {
|
||||
parsedURL, err := url.Parse(targetURL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
hostName := parsedURL.Hostname()
|
||||
if hostName == "" || hostName == "localhost" {
|
||||
return
|
||||
}
|
||||
|
||||
client := m.NewSSH(deviceIP)
|
||||
|
||||
hostIP, resolveErr := m.resolveIP(hostName, client)
|
||||
if resolveErr != nil {
|
||||
summary.ResolveIPError = resolveErr.Error()
|
||||
}
|
||||
|
||||
if hostIP == "" {
|
||||
hostIP = hostName
|
||||
}
|
||||
|
||||
summary.PlannedResolv = fmt.Sprintf("# Created by Aftertouch/SoundTouch-Service\n# Priority nameserver for Bose service redirection\nnameserver %s\n", hostIP)
|
||||
|
||||
domains := []string{
|
||||
"streaming.bose.com",
|
||||
"updates.bose.com",
|
||||
"stats.bose.com",
|
||||
"bmx.bose.com",
|
||||
"content.api.bose.io",
|
||||
"events.api.bosecm.com",
|
||||
"bose-prod.apigee.net",
|
||||
"worldwide.bose.com",
|
||||
"music.api.bose.com",
|
||||
"media.bose.io",
|
||||
"downloads.bose.com",
|
||||
"voice.api.bose.io",
|
||||
}
|
||||
|
||||
hostsLines := make([]string, len(domains))
|
||||
for i, domain := range domains {
|
||||
hostsLines[i] = fmt.Sprintf("%s\t%s", hostIP, domain)
|
||||
}
|
||||
|
||||
summary.PlannedHosts = strings.Join(hostsLines, "\n")
|
||||
}
|
||||
|
||||
func (m *Manager) buildServerHTTPSURL(targetURL string) string {
|
||||
parsedURL, err := url.Parse(targetURL)
|
||||
if err != nil || parsedURL.Hostname() == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
httpsPort := os.Getenv("HTTPS_PORT")
|
||||
if httpsPort == "" {
|
||||
httpsPort = "8443"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("https://%s:%s/health", parsedURL.Hostname(), httpsPort)
|
||||
}
|
||||
|
||||
// checkIsMigrated determines if the device is already migrated to AfterTouch.
|
||||
func (m *Manager) checkIsMigrated(summary *MigrationSummary, deviceIP string) {
|
||||
if !summary.SSHSuccess {
|
||||
@@ -415,7 +437,7 @@ func (m *Manager) isResolvConfMigrated(client SSHClient, summary *MigrationSumma
|
||||
return true
|
||||
}
|
||||
|
||||
resolvedIP := m.resolveIP(targetHost, client)
|
||||
resolvedIP, _ := m.resolveIP(targetHost, client)
|
||||
if resolvedIP != "" && strings.Contains(summary.CurrentResolvConf, resolvedIP) && summary.CACertTrusted {
|
||||
return true
|
||||
}
|
||||
@@ -523,26 +545,35 @@ func (m *Manager) checkCurrentConfig(summary *MigrationSummary, deviceIP string)
|
||||
return "", err
|
||||
}
|
||||
|
||||
// applyProxyOptions modifies planned config based on proxy options
|
||||
// applyProxyOptions modifies planned config based on proxy options.
|
||||
// Each field accepts: "proxied" (route through proxyURL), "original" (keep current value), or unset (use targetURL via plannedCfg default).
|
||||
func (m *Manager) applyProxyOptions(plannedCfg *PrivateCfg, proxyURL string, options map[string]string, currentCfg *PrivateCfg) {
|
||||
if proxyURL == "" || currentCfg == nil {
|
||||
if currentCfg == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if options["marge"] == "upstream" && currentCfg.MargeServerUrl != "" {
|
||||
if options["marge"] == "proxied" && currentCfg.MargeServerUrl != "" {
|
||||
plannedCfg.MargeServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.MargeServerUrl)
|
||||
} else if options["marge"] == "original" && currentCfg.MargeServerUrl != "" {
|
||||
plannedCfg.MargeServerUrl = currentCfg.MargeServerUrl
|
||||
}
|
||||
|
||||
if options["stats"] == "upstream" && currentCfg.StatsServerUrl != "" {
|
||||
if options["stats"] == "proxied" && currentCfg.StatsServerUrl != "" {
|
||||
plannedCfg.StatsServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.StatsServerUrl)
|
||||
} else if options["stats"] == "original" && currentCfg.StatsServerUrl != "" {
|
||||
plannedCfg.StatsServerUrl = currentCfg.StatsServerUrl
|
||||
}
|
||||
|
||||
if options["sw_update"] == "upstream" && currentCfg.SwUpdateUrl != "" {
|
||||
if options["sw_update"] == "proxied" && currentCfg.SwUpdateUrl != "" {
|
||||
plannedCfg.SwUpdateUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.SwUpdateUrl)
|
||||
} else if options["sw_update"] == "original" && currentCfg.SwUpdateUrl != "" {
|
||||
plannedCfg.SwUpdateUrl = currentCfg.SwUpdateUrl
|
||||
}
|
||||
|
||||
if options["bmx"] == "upstream" && currentCfg.BmxRegistryUrl != "" {
|
||||
if options["bmx"] == "proxied" && currentCfg.BmxRegistryUrl != "" {
|
||||
plannedCfg.BmxRegistryUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.BmxRegistryUrl)
|
||||
} else if options["bmx"] == "original" && currentCfg.BmxRegistryUrl != "" {
|
||||
plannedCfg.BmxRegistryUrl = currentCfg.BmxRegistryUrl
|
||||
}
|
||||
}
|
||||
|
||||
@@ -718,7 +749,7 @@ func (m *Manager) migrateViaXML(deviceIP, targetURL, proxyURL string, options ma
|
||||
BmxRegistryUrl: fmt.Sprintf("%s/bmx/registry/v1/services", targetURL),
|
||||
}
|
||||
|
||||
// If we have a proxyURL and can read current config, use it
|
||||
// If we can read current config, apply per-field options
|
||||
if curCfg, curCfgErr := client.Run(fmt.Sprintf("cat %s", SoundTouchSdkPrivateCfgPath)); curCfgErr == nil && curCfg != "" {
|
||||
logs += "Read current configuration\n"
|
||||
|
||||
@@ -753,18 +784,20 @@ func (m *Manager) migrateViaXML(deviceIP, targetURL, proxyURL string, options ma
|
||||
if backupOut, err := client.Run(fmt.Sprintf("[ -f %s.original ]", remotePath)); err != nil {
|
||||
logs += fmt.Sprintf("Backing up original config to %s.original (check: %s)\n", remotePath, backupOut)
|
||||
fmt.Printf("Backing up original config to %s.original\n", remotePath)
|
||||
// Try to copy existing config to .original, ensuring filesystem is writable
|
||||
|
||||
if output, err := client.Run(fmt.Sprintf("%s && cp %s %s.original", rwCmd, remotePath, remotePath)); err != nil {
|
||||
logs += fmt.Sprintf("Warning: failed to cp backup config: %v (output: %s)\n", err, output)
|
||||
fmt.Printf("Warning: failed to cp backup config: %v (output: %s)\n", err, output)
|
||||
// Fallback to manual upload if cp failed (might not have cp?)
|
||||
logs += fmt.Sprintf("cp backup failed: %v (output: %s)\n", err, output)
|
||||
fmt.Printf("cp backup failed: %v (output: %s)\n", err, output)
|
||||
|
||||
if config, err := client.Run(fmt.Sprintf("cat %s", remotePath)); err == nil && config != "" {
|
||||
if err := client.UploadContent([]byte(config), remotePath+".original"); err != nil {
|
||||
logs += "Warning: failed to upload backup config: " + err.Error() + "\n"
|
||||
fmt.Printf("Warning: failed to upload backup config: %v\n", err)
|
||||
} else {
|
||||
logs += "Uploaded backup config via fallback\n"
|
||||
logs += "failed to upload backup config: " + err.Error() + "\n"
|
||||
return logs, fmt.Errorf("cannot create backup of %s before migration: %w", remotePath, err)
|
||||
}
|
||||
|
||||
logs += "Uploaded backup config via fallback\n"
|
||||
} else {
|
||||
return logs, fmt.Errorf("cannot create backup of %s before migration: failed to read original config", remotePath)
|
||||
}
|
||||
} else {
|
||||
logs += "Copied backup config to .original\n"
|
||||
@@ -773,10 +806,7 @@ func (m *Manager) migrateViaXML(deviceIP, targetURL, proxyURL string, options ma
|
||||
logs += "Backup .original already exists\n"
|
||||
}
|
||||
|
||||
// 1. Upload the configuration (rw is handled by calling it before if needed, but UploadContent uses cat > which needs rw)
|
||||
// We'll wrap the upload in a way that EnsureRemoteServices and others might benefit,
|
||||
// but UploadContent is a separate method. We should probably add rw to UploadContent or call it before.
|
||||
// Actually, let's call rw before UploadContent here.
|
||||
// 1. Upload the configuration
|
||||
out, _ = client.Run(rwCmd)
|
||||
|
||||
logs += rwCmd + ": " + out + "\n"
|
||||
@@ -1048,7 +1078,11 @@ func (m *Manager) migrateViaHosts(deviceIP, targetURL string) (string, error) {
|
||||
return "", fmt.Errorf("target URL must contain a valid IP or hostname (got %s)", hostName)
|
||||
}
|
||||
|
||||
hostIP := m.resolveIP(hostName, client)
|
||||
hostIP, err := m.resolveIP(hostName, client)
|
||||
if err != nil {
|
||||
return logs, fmt.Errorf("cannot resolve target hostname for migration: %w", err)
|
||||
}
|
||||
|
||||
logs += fmt.Sprintf("Resolved %s to %s\n", hostName, hostIP)
|
||||
|
||||
// 2. Prepare /etc/hosts entries
|
||||
@@ -1061,6 +1095,9 @@ func (m *Manager) migrateViaHosts(deviceIP, targetURL string) (string, error) {
|
||||
"events.api.bosecm.com",
|
||||
"bose-prod.apigee.net",
|
||||
"worldwide.bose.com",
|
||||
"media.bose.io",
|
||||
"downloads.bose.com",
|
||||
"voice.api.bose.io",
|
||||
}
|
||||
|
||||
hostsContent, err := client.Run("cat /etc/hosts")
|
||||
@@ -1201,7 +1238,11 @@ func (m *Manager) migrateViaResolvConf(deviceIP, targetURL string) (string, erro
|
||||
return "", fmt.Errorf("target URL must contain a valid IP or hostname (got %s)", hostName)
|
||||
}
|
||||
|
||||
hostIP := m.resolveIP(hostName, client)
|
||||
hostIP, err := m.resolveIP(hostName, client)
|
||||
if err != nil {
|
||||
return logs, fmt.Errorf("cannot resolve target hostname for migration: %w", err)
|
||||
}
|
||||
|
||||
logs += fmt.Sprintf("Resolved %s to %s\n", hostName, hostIP)
|
||||
|
||||
// 2. Prepare /mnt/nv/soundtouch-service/aftertouch.resolv.conf content
|
||||
@@ -1871,7 +1912,12 @@ func (m *Manager) parseTargetURLAndResolveIP(targetURL string, client SSHClient)
|
||||
return "", nil, fmt.Errorf("target URL must contain a valid IP or hostname (got %s)", hostName)
|
||||
}
|
||||
|
||||
return m.resolveIP(hostName, client), parsedURL, nil
|
||||
hostIP, err := m.resolveIP(hostName, client)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("cannot resolve target hostname: %w", err)
|
||||
}
|
||||
|
||||
return hostIP, parsedURL, nil
|
||||
}
|
||||
|
||||
func (m *Manager) addTemporaryHostEntry(client SSHClient, deviceIP, testDomain, testEntry, rwCmd string) error {
|
||||
@@ -2018,15 +2064,21 @@ func (m *Manager) TestConnection(deviceIP, targetURL string, useExplicitCA bool)
|
||||
|
||||
// GetResolvedIP returns the resolved IP for a hostname, attempting to resolve it from any connected device first.
|
||||
func (m *Manager) GetResolvedIP(host string) string {
|
||||
return m.resolveIP(host, nil)
|
||||
ip, _ := m.resolveIP(host, nil)
|
||||
return ip
|
||||
}
|
||||
|
||||
func (m *Manager) resolveIP(host string, client SSHClient) string {
|
||||
// resolveIP resolves a hostname to an IP address.
|
||||
// It first tries to resolve from the device via SSH ping (authoritative for migration).
|
||||
// If that fails, it falls back to resolving from the service itself.
|
||||
// An error is returned whenever the SSH ping did not produce the IP, so callers that
|
||||
// write config to the device can abort rather than risk writing an unresolvable hostname.
|
||||
func (m *Manager) resolveIP(host string, client SSHClient) (string, error) {
|
||||
if net.ParseIP(host) != nil {
|
||||
return host
|
||||
return host, nil
|
||||
}
|
||||
|
||||
// 1. Try resolving FROM the device via SSH (best for containers/NAT)
|
||||
// 1. Try resolving FROM the device via SSH (authoritative: gives the IP the device will actually use)
|
||||
if client != nil {
|
||||
// Use ping to resolve hostname on the device.
|
||||
// Busybox ping output usually looks like: PING host (1.2.3.4): 56 data bytes
|
||||
@@ -2040,26 +2092,33 @@ func (m *Manager) resolveIP(host string, client SSHClient) string {
|
||||
ip := output[start+1 : end]
|
||||
if net.ParseIP(ip) != nil {
|
||||
fmt.Printf("Resolved %s to %s from device\n", host, ip)
|
||||
return ip
|
||||
return ip, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback: resolve FROM the service itself
|
||||
// 2. Fallback: resolve FROM the service itself (unreliable for migration — NAT/split-DNS may differ)
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil || len(ips) == 0 {
|
||||
return host // Fallback to host if resolution fails
|
||||
return "", fmt.Errorf("cannot resolve %q: SSH ping from device failed and service-side DNS lookup also failed", host)
|
||||
}
|
||||
|
||||
// Prefer IPv4
|
||||
var resolved string
|
||||
|
||||
for _, ip := range ips {
|
||||
if ip.To4() != nil {
|
||||
return ip.String()
|
||||
resolved = ip.String()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return ips[0].String()
|
||||
if resolved == "" {
|
||||
resolved = ips[0].String()
|
||||
}
|
||||
|
||||
return resolved, fmt.Errorf("resolved %q to %s from service, not from device — result may be wrong if NAT or split-DNS is in use", host, resolved)
|
||||
}
|
||||
|
||||
// SyncDeviceData fetches presets, recents and sources from the device and saves them to the datastore.
|
||||
|
||||
@@ -54,7 +54,7 @@ func TestMigrateViaHosts(t *testing.T) {
|
||||
if command == "cat /etc/hosts" {
|
||||
// Handle both initial read and verification read
|
||||
if len(runCalls) > 2 { // Rough heuristic: verification happens after upload
|
||||
return "192.168.1.100\tstreaming.bose.com\n192.168.1.100\tupdates.bose.com\n192.168.1.100\tstats.bose.com\n192.168.1.100\tbmx.bose.com\n192.168.1.100\tcontent.api.bose.io\n192.168.1.100\tevents.api.bosecm.com\n192.168.1.100\tbose-prod.apigee.net\n192.168.1.100\tworldwide.bose.com", nil
|
||||
return "192.168.1.100\tstreaming.bose.com\n192.168.1.100\tupdates.bose.com\n192.168.1.100\tstats.bose.com\n192.168.1.100\tbmx.bose.com\n192.168.1.100\tcontent.api.bose.io\n192.168.1.100\tevents.api.bosecm.com\n192.168.1.100\tbose-prod.apigee.net\n192.168.1.100\tworldwide.bose.com\n192.168.1.100\tmedia.bose.io\n192.168.1.100\tdownloads.bose.com\n192.168.1.100\tvoice.api.bose.io", nil
|
||||
}
|
||||
return "127.0.0.1 localhost", nil
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func TestMigrateViaHosts_UpdateExisting(t *testing.T) {
|
||||
runCount++
|
||||
if command == "cat /etc/hosts" {
|
||||
if runCount > 1 {
|
||||
return "127.0.0.1 localhost\n192.168.1.100\tstreaming.bose.com\n192.168.1.100\tupdates.bose.com\n192.168.1.100\tstats.bose.com\n192.168.1.100\tbmx.bose.com\n192.168.1.100\tcontent.api.bose.io\n192.168.1.100\tevents.api.bosecm.com\n192.168.1.100\tbose-prod.apigee.net\n192.168.1.100\tworldwide.bose.com", nil
|
||||
return "127.0.0.1 localhost\n192.168.1.100\tstreaming.bose.com\n192.168.1.100\tupdates.bose.com\n192.168.1.100\tstats.bose.com\n192.168.1.100\tbmx.bose.com\n192.168.1.100\tcontent.api.bose.io\n192.168.1.100\tevents.api.bosecm.com\n192.168.1.100\tbose-prod.apigee.net\n192.168.1.100\tworldwide.bose.com\n192.168.1.100\tmedia.bose.io\n192.168.1.100\tdownloads.bose.com\n192.168.1.100\tvoice.api.bose.io", nil
|
||||
}
|
||||
return "127.0.0.1 localhost\n1.2.3.4\tstreaming.bose.com\n1.2.3.4\tupdates.bose.com", nil
|
||||
}
|
||||
@@ -258,9 +258,9 @@ func TestGetMigrationSummary_WithProxyOptions(t *testing.T) {
|
||||
// If SSH fails, ParsedCurrentConfig will be nil.
|
||||
|
||||
options := map[string]string{
|
||||
"marge": "upstream",
|
||||
"marge": "proxied",
|
||||
"stats": "self",
|
||||
"sw_update": "upstream",
|
||||
"sw_update": "proxied",
|
||||
"bmx": "self",
|
||||
}
|
||||
|
||||
@@ -597,17 +597,22 @@ func TestTestHostsRedirection(t *testing.T) {
|
||||
func TestResolveIP(t *testing.T) {
|
||||
m := &Manager{}
|
||||
|
||||
// Test with IP
|
||||
if m.resolveIP("1.2.3.4", nil) != "1.2.3.4" {
|
||||
t.Errorf("Expected 1.2.3.4, got %s", m.resolveIP("1.2.3.4", nil))
|
||||
// IP passthrough: no resolution needed, no error
|
||||
ip, err := m.resolveIP("1.2.3.4", nil)
|
||||
if ip != "1.2.3.4" || err != nil {
|
||||
t.Errorf("Expected 1.2.3.4/nil, got %s/%v", ip, err)
|
||||
}
|
||||
|
||||
// Test with localhost
|
||||
if m.resolveIP("localhost", nil) != "127.0.0.1" && m.resolveIP("localhost", nil) != "::1" {
|
||||
t.Errorf("Expected localhost resolution, got %s", m.resolveIP("localhost", nil))
|
||||
// localhost resolves from service DNS; error expected (no SSH client)
|
||||
ip, err = m.resolveIP("localhost", nil)
|
||||
if ip != "127.0.0.1" && ip != "::1" {
|
||||
t.Errorf("Expected localhost resolution, got %s", ip)
|
||||
}
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for service-side fallback, got nil")
|
||||
}
|
||||
|
||||
// Test with device resolution (mocked)
|
||||
// Device SSH ping succeeds: IP returned, no error
|
||||
mock := &mockSSH{
|
||||
runFunc: func(command string) (string, error) {
|
||||
if strings.Contains(command, "ping -c 1 myhost") {
|
||||
@@ -616,13 +621,18 @@ func TestResolveIP(t *testing.T) {
|
||||
return "", nil
|
||||
},
|
||||
}
|
||||
if m.resolveIP("myhost", mock) != "10.0.0.5" {
|
||||
t.Errorf("Expected 10.0.0.5 from device, got %s", m.resolveIP("myhost", mock))
|
||||
ip, err = m.resolveIP("myhost", mock)
|
||||
if ip != "10.0.0.5" || err != nil {
|
||||
t.Errorf("Expected 10.0.0.5/nil from device, got %s/%v", ip, err)
|
||||
}
|
||||
|
||||
// Test with non-existent host (should fallback to input)
|
||||
if m.resolveIP("non-existent.host.fake", nil) != "non-existent.host.fake" {
|
||||
t.Errorf("Expected fallback to input, got %s", m.resolveIP("non-existent.host.fake", nil))
|
||||
// Non-existent host, no SSH client: both methods fail, error returned
|
||||
ip, err = m.resolveIP("non-existent.host.fake", nil)
|
||||
if ip != "" {
|
||||
t.Errorf("Expected empty IP on failure, got %s", ip)
|
||||
}
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for unresolvable host, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,7 +658,7 @@ func TestMigrateViaHosts_SkipCAIfTrusted(t *testing.T) {
|
||||
if command == "cat /etc/hosts" {
|
||||
// Handle both initial read and verification read
|
||||
if len(runCalls) > 2 { // Rough heuristic: verification happens after upload
|
||||
return "192.168.1.100\tstreaming.bose.com\n192.168.1.100\tupdates.bose.com\n192.168.1.100\tstats.bose.com\n192.168.1.100\tbmx.bose.com\n192.168.1.100\tcontent.api.bose.io\n192.168.1.100\tevents.api.bosecm.com\n192.168.1.100\tbose-prod.apigee.net\n192.168.1.100\tworldwide.bose.com", nil
|
||||
return "192.168.1.100\tstreaming.bose.com\n192.168.1.100\tupdates.bose.com\n192.168.1.100\tstats.bose.com\n192.168.1.100\tbmx.bose.com\n192.168.1.100\tcontent.api.bose.io\n192.168.1.100\tevents.api.bosecm.com\n192.168.1.100\tbose-prod.apigee.net\n192.168.1.100\tworldwide.bose.com\n192.168.1.100\tmedia.bose.io\n192.168.1.100\tdownloads.bose.com\n192.168.1.100\tvoice.api.bose.io", nil
|
||||
}
|
||||
return "127.0.0.1 localhost", nil
|
||||
}
|
||||
@@ -1525,6 +1535,40 @@ func TestCheckIsMigrated(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestCheckCurrentConfig_ReadsOriginalPath verifies that checkCurrentConfig reads
|
||||
// from SoundTouchSdkPrivateCfgPath on an unmigrated device (issue #214 regression test).
|
||||
func TestCheckCurrentConfig_ReadsOriginalPath(t *testing.T) {
|
||||
m := NewManager("http://aftertouch:8000", nil, nil)
|
||||
|
||||
originalCfg := "<SoundTouchSdkPrivateCfg><margeServerUrl>http://streaming.bose.com</margeServerUrl></SoundTouchSdkPrivateCfg>"
|
||||
|
||||
m.NewSSH = func(host string) SSHClient {
|
||||
return &mockSSH{
|
||||
runFunc: func(command string) (string, error) {
|
||||
if strings.HasPrefix(command, "[ -f ") && strings.Contains(command, ".original") {
|
||||
return "", fmt.Errorf("exit status 1")
|
||||
}
|
||||
if command == fmt.Sprintf("cat %s", SoundTouchSdkPrivateCfgPath) {
|
||||
return originalCfg, nil
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
summary := &MigrationSummary{}
|
||||
cfg, err := m.checkCurrentConfig(summary, "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("checkCurrentConfig returned unexpected error: %v", err)
|
||||
}
|
||||
if cfg != originalCfg {
|
||||
t.Errorf("Expected current config to be the original SoundTouchSdkPrivateCfg.xml, got %q", cfg)
|
||||
}
|
||||
if !summary.SSHSuccess {
|
||||
t.Errorf("Expected SSHSuccess to be true when original config is readable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSpeaker_ResolvBlocking(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "setup-test")
|
||||
if err != nil {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Package handlers contains tests for HTTP handlers.
|
||||
package handlers
|
||||
package soundtouchweb
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -10,9 +9,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
/* ── Reset & Base ─────────────────────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #f5f5f5;
|
||||
--surface: #ffffff;
|
||||
--border: #e0e0e0;
|
||||
--text: #1a1a1a;
|
||||
--text-dim: #666;
|
||||
--accent: #000000;
|
||||
--accent-fg: #ffffff;
|
||||
--online: #22c55e;
|
||||
--offline: #9ca3af;
|
||||
--radius: 8px;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,.10), 0 1px 2px rgba(0,0,0,.06);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #111;
|
||||
--surface: #1e1e1e;
|
||||
--border: #333;
|
||||
--text: #f0f0f0;
|
||||
--text-dim: #aaa;
|
||||
--accent: #e0e0e0;
|
||||
--accent-fg:#111;
|
||||
}
|
||||
}
|
||||
|
||||
html, body { height: 100%; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; cursor: pointer; }
|
||||
button { cursor: pointer; font: inherit; border: none; background: none; }
|
||||
ul { list-style: none; }
|
||||
img { display: block; max-width: 100%; }
|
||||
|
||||
/* ── Layout ──────────────────────────────────────────────────────────────── */
|
||||
.app { display: flex; flex-direction: column; min-height: 100vh; }
|
||||
|
||||
/* ── Navbar ──────────────────────────────────────────────────────────────── */
|
||||
.navbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1.25rem;
|
||||
height: 52px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
|
||||
.brand { font-size: 1.1rem; font-weight: 600; letter-spacing: .02em; }
|
||||
|
||||
.nav-links { display: flex; align-items: center; gap: .75rem; }
|
||||
|
||||
.nav-links a, .nav-links .btn-icon {
|
||||
color: var(--accent-fg);
|
||||
opacity: .75;
|
||||
font-size: .9rem;
|
||||
padding: .25rem .5rem;
|
||||
border-radius: 4px;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
|
||||
.nav-links a:hover, .nav-links .btn-icon:hover, .nav-links a.active { opacity: 1; }
|
||||
|
||||
.nav-tunein-icon { height: 18px; display: inline-block; filter: brightness(0) invert(1); opacity: .75; }
|
||||
.nav-links a:hover .nav-tunein-icon, .nav-links a.active .nav-tunein-icon { opacity: 1; }
|
||||
|
||||
/* ── Main content ─────────────────────────────────────────────────────────── */
|
||||
.main-content { flex: 1; padding: 1.5rem 1.25rem; max-width: 960px; width: 100%; margin: 0 auto; }
|
||||
|
||||
/* ── Page header ──────────────────────────────────────────────────────────── */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.page-header h2 { font-size: 1.4rem; font-weight: 600; flex: 1; }
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────────────────────────── */
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
padding: .45rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: .875rem;
|
||||
font-weight: 500;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
.btn-primary:hover { opacity: .85; }
|
||||
|
||||
.btn-secondary {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
padding: .45rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: .875rem;
|
||||
transition: background .15s;
|
||||
}
|
||||
.btn-secondary:hover { background: var(--bg); }
|
||||
|
||||
.btn-icon {
|
||||
color: inherit;
|
||||
font-size: 1.1rem;
|
||||
padding: .25rem .4rem;
|
||||
border-radius: 4px;
|
||||
line-height: 1;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
.btn-icon:hover { opacity: .7; }
|
||||
|
||||
.back-btn {
|
||||
color: var(--text-dim);
|
||||
font-size: .875rem;
|
||||
padding: .3rem .6rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.back-btn:hover { background: var(--bg); }
|
||||
|
||||
/* ── Device grid ─────────────────────────────────────────────────────────── */
|
||||
.device-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.device-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
transition: box-shadow .15s, transform .1s;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.device-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.12); transform: translateY(-1px); }
|
||||
|
||||
.device-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: .25rem; }
|
||||
.device-name { font-weight: 600; font-size: .95rem; }
|
||||
.device-type { font-size: .8rem; color: var(--text-dim); margin-bottom: .5rem; }
|
||||
|
||||
.device-indicator {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.device-indicator.online { background: var(--online); }
|
||||
.device-indicator.offline { background: var(--offline); }
|
||||
|
||||
.now-playing-mini { font-size: .8rem; color: var(--text-dim); margin-top: .25rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.play-status { margin-right: .3rem; }
|
||||
.standby-label { font-size: .8rem; color: var(--text-dim); margin-top: .25rem; }
|
||||
|
||||
/* ── Device detail ───────────────────────────────────────────────────────── */
|
||||
.device-detail { max-width: 560px; }
|
||||
|
||||
/* ── Now playing ─────────────────────────────────────────────────────────── */
|
||||
.now-playing {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
box-shadow: var(--shadow);
|
||||
min-height: 80px;
|
||||
align-items: center;
|
||||
}
|
||||
.now-playing.standby { color: var(--text-dim); font-size: .9rem; }
|
||||
|
||||
.album-art { width: 64px; height: 64px; border-radius: 4px; object-fit: cover; flex-shrink: 0; }
|
||||
|
||||
.track-info { flex: 1; overflow: hidden; }
|
||||
.track-title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.track-artist { font-size: .875rem; color: var(--text-dim); margin-top: .15rem; }
|
||||
.track-album { font-size: .8rem; color: var(--text-dim); }
|
||||
.track-meta { display: flex; align-items: center; gap: .5rem; margin-top: .25rem; }
|
||||
.track-source { font-size: .75rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: .05em; }
|
||||
.buffering-badge { font-size: .7rem; color: var(--text-dim); background: var(--bg); border-radius: 4px; padding: .1rem .35rem; }
|
||||
|
||||
/* ── Transport controls ──────────────────────────────────────────────────── */
|
||||
.controls {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.transport { display: flex; align-items: center; gap: .5rem; margin-bottom: .75rem; }
|
||||
|
||||
.ctrl-btn {
|
||||
font-size: 1.25rem;
|
||||
padding: .4rem .7rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
transition: background .12s;
|
||||
}
|
||||
.ctrl-btn:hover { background: var(--bg); }
|
||||
.ctrl-btn.play-btn { font-size: 1.5rem; padding: .4rem .9rem; }
|
||||
.ctrl-btn.active { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
|
||||
|
||||
.volume-row { display: flex; align-items: center; gap: .75rem; }
|
||||
.volume-icon { font-size: 1rem; }
|
||||
.volume-slider { flex: 1; accent-color: var(--accent); }
|
||||
.volume-value { font-size: .8rem; color: var(--text-dim); min-width: 2.5ch; text-align: right; }
|
||||
|
||||
.bass-row { display: flex; align-items: center; gap: .75rem; margin-top: .5rem; }
|
||||
.bass-label { font-size: .8rem; color: var(--text-dim); width: 2.5ch; }
|
||||
|
||||
/* ── Progress bar ────────────────────────────────────────────────────────── */
|
||||
.progress-row { margin-top: .35rem; display: flex; align-items: center; gap: .5rem; }
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
transition: width .9s linear;
|
||||
}
|
||||
.progress-time { font-size: .7rem; color: var(--text-dim); white-space: nowrap; flex-shrink: 0; }
|
||||
|
||||
/* ── Presets ─────────────────────────────────────────────────────────────── */
|
||||
.presets-section, .sources-section { margin-top: 1.25rem; }
|
||||
.section-title { font-size: .8rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--text-dim); margin-bottom: .6rem; }
|
||||
|
||||
.preset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: .4rem;
|
||||
}
|
||||
|
||||
.preset-slot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: .25rem;
|
||||
padding: .4rem .2rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: background .1s, box-shadow .1s;
|
||||
min-height: 72px;
|
||||
}
|
||||
.preset-slot:hover:not(:disabled) { background: var(--bg); box-shadow: var(--shadow); }
|
||||
.preset-slot:disabled { opacity: .4; cursor: default; }
|
||||
.preset-slot.active { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); }
|
||||
.preset-slot.active .preset-name { color: var(--accent-fg); }
|
||||
|
||||
.preset-art { width: 36px; height: 36px; border-radius: 4px; object-fit: cover; }
|
||||
.preset-source-label { font-size: .6rem; font-weight: 600; text-transform: uppercase; opacity: .6; }
|
||||
.preset-name { font-size: .65rem; text-align: center; line-height: 1.2; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; word-break: break-word; color: var(--text-dim); }
|
||||
.preset-num {
|
||||
position: absolute; top: 2px; right: 4px;
|
||||
font-size: .6rem; font-weight: 700; color: var(--text-dim); opacity: .5;
|
||||
}
|
||||
|
||||
/* ── Sources ─────────────────────────────────────────────────────────────── */
|
||||
.source-list { display: flex; flex-wrap: wrap; gap: .4rem; }
|
||||
|
||||
.source-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .35rem;
|
||||
padding: .35rem .7rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
font-size: .8rem;
|
||||
transition: background .1s, border-color .1s;
|
||||
}
|
||||
.source-btn:hover { background: var(--bg); }
|
||||
.source-btn.active { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); }
|
||||
.source-btn.local { border-style: dashed; }
|
||||
.source-icon { font-size: .9rem; line-height: 1; }
|
||||
.source-name { font-weight: 500; }
|
||||
|
||||
/* ── Zone ────────────────────────────────────────────────────────────────── */
|
||||
.zone-section { margin-top: 1.25rem; }
|
||||
|
||||
.zone-row { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
|
||||
.zone-status-label { font-size: .875rem; color: var(--text-dim); }
|
||||
|
||||
.zone-members { display: flex; flex-direction: column; gap: .3rem; }
|
||||
.zone-member {
|
||||
display: flex; align-items: center; gap: .6rem;
|
||||
padding: .4rem .6rem;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.zone-master-row { background: var(--bg); }
|
||||
|
||||
.zone-badge {
|
||||
font-size: .65rem; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: .05em; padding: .15rem .4rem; border-radius: 3px; flex-shrink: 0;
|
||||
}
|
||||
.zone-badge.master { background: var(--accent); color: var(--accent-fg); }
|
||||
.zone-badge.slave { background: var(--border); color: var(--text-dim); }
|
||||
|
||||
.zone-member-name { flex: 1; font-size: .875rem; }
|
||||
.zone-remove { font-size: .75rem; color: var(--text-dim); padding: .15rem .35rem; }
|
||||
.zone-remove:hover { color: var(--text); }
|
||||
|
||||
.zone-actions { display: flex; gap: .5rem; margin-top: .25rem; flex-wrap: wrap; }
|
||||
.zone-btn { font-size: .8rem; padding: .3rem .7rem; }
|
||||
|
||||
/* ── Recents ─────────────────────────────────────────────────────────────── */
|
||||
.recents-section { margin-top: 1.25rem; }
|
||||
|
||||
.recents-list { display: flex; flex-direction: column; gap: 1px; }
|
||||
|
||||
.recent-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .75rem;
|
||||
width: 100%;
|
||||
padding: .5rem .6rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
text-align: left;
|
||||
color: var(--text);
|
||||
transition: background .1s;
|
||||
}
|
||||
.recent-item:hover { background: var(--bg); }
|
||||
|
||||
.recent-art {
|
||||
width: 40px; height: 40px; border-radius: 4px;
|
||||
object-fit: cover; flex-shrink: 0;
|
||||
}
|
||||
.recent-art-empty {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--bg); font-size: 1.1rem;
|
||||
}
|
||||
.recent-info { flex: 1; overflow: hidden; }
|
||||
.recent-name { display: block; font-size: .875rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.recent-source { display: block; font-size: .75rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: .05em; margin-top: .1rem; }
|
||||
.recent-play { color: var(--text-dim); font-size: .75rem; flex-shrink: 0; opacity: .5; }
|
||||
.recent-item:hover .recent-play { opacity: 1; }
|
||||
|
||||
/* ── TuneIn ──────────────────────────────────────────────────────────────── */
|
||||
.tunein-toolbar { display: flex; gap: .5rem; margin-bottom: 1rem; }
|
||||
.tunein-search-input {
|
||||
flex: 1;
|
||||
padding: .45rem .75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: .875rem;
|
||||
}
|
||||
.tunein-search-input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
|
||||
.breadcrumb { display: flex; align-items: center; gap: .4rem; margin-bottom: .75rem; font-size: .85rem; flex-wrap: wrap; }
|
||||
.breadcrumb-sep { color: var(--text-dim); }
|
||||
.breadcrumb-link { color: var(--text-dim); cursor: pointer; }
|
||||
.breadcrumb-link:hover { text-decoration: underline; }
|
||||
.breadcrumb-current { font-weight: 500; }
|
||||
|
||||
.loading-bar {
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, var(--accent) 0%, transparent 100%);
|
||||
border-radius: 1px;
|
||||
margin-bottom: 1rem;
|
||||
animation: loading 1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes loading { 0%,100% { opacity: .4; } 50% { opacity: 1; } }
|
||||
|
||||
.tunein-list { display: flex; flex-direction: column; gap: 1px; }
|
||||
|
||||
.tunein-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .75rem;
|
||||
padding: .6rem .75rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: background .1s;
|
||||
}
|
||||
.tunein-item:hover { background: var(--bg); }
|
||||
|
||||
.tunein-thumb { width: 40px; height: 40px; border-radius: 4px; object-fit: cover; flex-shrink: 0; }
|
||||
.tunein-item-info { flex: 1; overflow: hidden; }
|
||||
.tunein-item-name { display: block; font-size: .9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tunein-item-desc { display: block; font-size: .75rem; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tunein-item-arrow { color: var(--text-dim); font-size: .9rem; flex-shrink: 0; }
|
||||
|
||||
/* ── Device picker overlay ───────────────────────────────────────────────── */
|
||||
.overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,.45);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.device-picker {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
min-width: 240px;
|
||||
max-width: 320px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,.2);
|
||||
}
|
||||
.picker-title { font-weight: 600; margin-bottom: .25rem; }
|
||||
.picker-item-name { font-size: .875rem; color: var(--text-dim); margin-bottom: 1rem; }
|
||||
.picker-devices { display: flex; flex-direction: column; gap: .5rem; margin-bottom: 1rem; }
|
||||
.picker-device-btn {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: .6rem 1rem;
|
||||
text-align: left;
|
||||
font-size: .9rem;
|
||||
transition: background .1s;
|
||||
}
|
||||
.picker-device-btn:hover { background: var(--border); }
|
||||
.picker-cancel { width: 100%; }
|
||||
.picker-no-devices { font-size: .875rem; color: var(--text-dim); text-align: center; padding: .5rem 0; }
|
||||
|
||||
/* ── Empty state ─────────────────────────────────────────────────────────── */
|
||||
.empty-state { text-align: center; padding: 4rem 1rem; color: var(--text-dim); }
|
||||
.empty-icon { font-size: 3rem; margin-bottom: 1rem; opacity: .4; }
|
||||
.empty-state p { margin-bottom: 1.5rem; }
|
||||
|
||||
/* ── Toast ───────────────────────────────────────────────────────────────── */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
padding: .6rem 1.25rem;
|
||||
border-radius: 999px;
|
||||
font-size: .875rem;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.2);
|
||||
z-index: 200;
|
||||
pointer-events: none;
|
||||
animation: fade-in .2s ease;
|
||||
}
|
||||
@keyframes fade-in { from { opacity: 0; transform: translateX(-50%) translateY(8px); } }
|
||||
|
After Width: | Height: | Size: 859 B |
@@ -0,0 +1,9 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Morse 'S' (drei Punkte) -->
|
||||
<circle cx="8" cy="10" r="3" fill="#0055aa"/>
|
||||
<circle cx="16" cy="10" r="3" fill="#0055aa"/>
|
||||
<circle cx="24" cy="10" r="3" fill="#0055aa"/>
|
||||
|
||||
<!-- Morse 'T' (ein langer Strich) -->
|
||||
<rect x="5" y="18" width="22" height="6" rx="2" fill="#ffcc00"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 381 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SoundTouch Web</title>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"preact": "/static/vendor/preact.module.js",
|
||||
"preact/hooks": "/static/vendor/preact-hooks.module.js",
|
||||
"htm": "/static/vendor/htm.module.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/img/favicon.svg" />
|
||||
<link rel="alternate icon" href="/static/img/favicon.ico" />
|
||||
<link rel="stylesheet" href="/static/css/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,40 @@
|
||||
const JSON_HEADERS = { 'Content-Type': 'application/json' };
|
||||
|
||||
async function req(url, opts = {}) {
|
||||
const r = await fetch(url, opts);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
devices: () => req('/api/devices'),
|
||||
device: (id) => req(`/api/device/${id}`),
|
||||
discover: () => req('/api/discover', { method: 'POST' }),
|
||||
key: (id, key) => req(`/api/device-key/${id}/${key}`, { method: 'POST' }),
|
||||
volume: (id, level) => req(`/api/device-volume/${id}/${level}`, { method: 'POST' }),
|
||||
bass: (id, level) => req(`/api/control/${id}/bass`, {
|
||||
method: 'POST',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ level }),
|
||||
}),
|
||||
power: (id) => req(`/api/device-power/${id}`, { method: 'POST' }),
|
||||
recents: (id) => req(`/api/device-recents/${id}`),
|
||||
zone: (id) => req(`/api/zone/${id}`),
|
||||
zoneAdd: (masterId, slaveId) => req(`/api/zone/${masterId}/add/${slaveId}`, { method: 'POST' }),
|
||||
zoneRemove: (masterId, slaveId) => req(`/api/zone/${masterId}/remove/${slaveId}`, { method: 'POST' }),
|
||||
zoneDissolve: (id) => req(`/api/zone/${id}/dissolve`, { method: 'POST' }),
|
||||
zoneLeave: (id) => req(`/api/zone/${id}/leave`, { method: 'POST' }),
|
||||
play: (id, item) => req(`/api/device-play/${id}`, {
|
||||
method: 'POST',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify(item),
|
||||
}),
|
||||
tuneInBrowse: (path) => req(path ? `/api/tunein/navigate/${path}` : '/api/tunein/navigate'),
|
||||
tuneInSearch: (q) => req(`/api/tunein/search?q=${encodeURIComponent(q)}`),
|
||||
control: (id, action, presetId) => req(`/api/control/${id}/${action}?id=${presetId}`),
|
||||
selectSource: (id, source, account) => req(`/api/control/${id}/source?name=${encodeURIComponent(source)}&account=${encodeURIComponent(account || '')}`),
|
||||
tuneInPlay: (deviceId, item) => req(`/api/tunein/play/${deviceId}`, {
|
||||
method: 'POST',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify(item),
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import { h, render } from 'preact';
|
||||
import { useState, useEffect, useCallback } from 'preact/hooks';
|
||||
import htm from 'htm';
|
||||
import { DeviceList } from './components/DeviceList.js';
|
||||
import { NowPlaying } from './components/NowPlaying.js';
|
||||
import { Controls } from './components/Controls.js';
|
||||
import { Presets } from './components/Presets.js';
|
||||
import { Sources } from './components/Sources.js';
|
||||
import { Zone } from './components/Zone.js';
|
||||
import { Recents } from './components/Recents.js';
|
||||
import { TuneInBrowser } from './components/TuneInBrowser.js';
|
||||
import { api } from './api.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
function DeviceDetail({ deviceId, devices, onBack }) {
|
||||
const device = devices[deviceId];
|
||||
|
||||
if (!device) {
|
||||
return html`
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onClick=${onBack}>← Back</button>
|
||||
</div>
|
||||
<p>Device not found.</p>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="device-detail">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onClick=${onBack}>← Back</button>
|
||||
<h2>${device.info?.Name || deviceId}</h2>
|
||||
<button class="btn-icon" onClick=${() => api.power(deviceId)} title="Power">⏻</button>
|
||||
</div>
|
||||
<${NowPlaying} nowPlaying=${device.status?.nowPlaying} />
|
||||
<${Controls} deviceId=${deviceId} status=${device.status} />
|
||||
<${Presets} deviceId=${deviceId} status=${device.status} />
|
||||
<${Sources} deviceId=${deviceId} status=${device.status} />
|
||||
<${Zone} deviceId=${deviceId} devices=${devices} />
|
||||
<${Recents} deviceId=${deviceId} />
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [devices, setDevices] = useState({});
|
||||
const [page, setPage] = useState('devices');
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
const [toast, setToast] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(`${protocol}//${location.host}/ws`);
|
||||
let reconnectTimer;
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'devices') {
|
||||
setDevices(msg.data || {});
|
||||
} else if (msg.type === 'discovery_status') {
|
||||
if (msg.data?.status === 'completed') {
|
||||
showToast(`Found ${msg.data.deviceCount} device(s)`);
|
||||
}
|
||||
} else if (msg.type === 'status_update' && msg.deviceId) {
|
||||
setDevices(prev => ({
|
||||
...prev,
|
||||
[msg.deviceId]: { ...prev[msg.deviceId], status: msg.data },
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
reconnectTimer = setTimeout(() => location.reload(), 5000);
|
||||
};
|
||||
|
||||
return () => {
|
||||
clearTimeout(reconnectTimer);
|
||||
ws.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
function showToast(msg) {
|
||||
setToast(msg);
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
}
|
||||
|
||||
const navigate = useCallback((p, id = null) => {
|
||||
setPage(p);
|
||||
setSelectedId(id);
|
||||
}, []);
|
||||
|
||||
async function discover() {
|
||||
showToast('Discovering devices…');
|
||||
await api.discover();
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="app">
|
||||
<nav class="navbar">
|
||||
<a class="brand" href="#" onClick=${(e) => { e.preventDefault(); navigate('devices'); }}>
|
||||
SoundTouch
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
<a href="#" class="${page === 'devices' || page === 'device' ? 'active' : ''}"
|
||||
onClick=${(e) => { e.preventDefault(); navigate('devices'); }}>
|
||||
Devices
|
||||
</a>
|
||||
<a href="#" class="${page === 'tunein' ? 'active' : ''}"
|
||||
onClick=${(e) => { e.preventDefault(); navigate('tunein'); }}>
|
||||
<img src="/static/img/tunein-mono.svg" alt="TuneIn" class="nav-tunein-icon" />
|
||||
</a>
|
||||
<button class="btn-icon" onClick=${discover} title="Discover">⟳</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="main-content">
|
||||
${page === 'devices' && html`
|
||||
<${DeviceList}
|
||||
devices=${devices}
|
||||
onSelect=${(id) => navigate('device', id)}
|
||||
onDiscover=${discover}
|
||||
/>
|
||||
`}
|
||||
${page === 'device' && html`
|
||||
<${DeviceDetail}
|
||||
deviceId=${selectedId}
|
||||
devices=${devices}
|
||||
onBack=${() => navigate('devices')}
|
||||
/>
|
||||
`}
|
||||
${page === 'tunein' && html`
|
||||
<${TuneInBrowser} devices=${devices} />
|
||||
`}
|
||||
</main>
|
||||
|
||||
${toast && html`<div class="toast">${toast}</div>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render(html`<${App} />`, document.getElementById('app'));
|
||||
@@ -0,0 +1,80 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import htm from 'htm';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
export function Controls({ deviceId, status }) {
|
||||
const np = status?.nowPlaying;
|
||||
const isPlaying = np?.PlayStatus === 'PLAY_STATE';
|
||||
const actualVolume = status?.volume?.ActualVolume ?? 0;
|
||||
const isMuted = status?.volume?.MuteEnabled ?? false;
|
||||
const shuffle = np?.ShuffleSetting ?? 'SHUFFLE_OFF';
|
||||
const repeat = np?.RepeatSetting ?? 'REPEAT_OFF';
|
||||
const actualBass = status?.bass?.TargetBass ?? 0;
|
||||
const hasBass = status?.bass != null;
|
||||
|
||||
const [localVolume, setLocalVolume] = useState(actualVolume);
|
||||
const [localBass, setLocalBass] = useState(actualBass);
|
||||
|
||||
useEffect(() => { setLocalVolume(actualVolume); }, [actualVolume]);
|
||||
useEffect(() => { setLocalBass(actualBass); }, [actualBass]);
|
||||
|
||||
const send = (key) => api.key(deviceId, key);
|
||||
|
||||
function onVolumeChange(e) {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
setLocalVolume(val);
|
||||
api.volume(deviceId, val);
|
||||
}
|
||||
|
||||
function onBassChange(e) {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
setLocalBass(val);
|
||||
api.bass(deviceId, val);
|
||||
}
|
||||
|
||||
function toggleShuffle() {
|
||||
send(shuffle === 'SHUFFLE_ON' ? 'SHUFFLE_OFF' : 'SHUFFLE_ON');
|
||||
}
|
||||
|
||||
function cycleRepeat() {
|
||||
if (repeat === 'REPEAT_OFF') send('REPEAT_ALL');
|
||||
else if (repeat === 'REPEAT_ALL') send('REPEAT_ONE');
|
||||
else send('REPEAT_OFF');
|
||||
}
|
||||
|
||||
const repeatIcon = repeat === 'REPEAT_ONE' ? '🔂' : '🔁';
|
||||
|
||||
return html`
|
||||
<div class="controls">
|
||||
<div class="transport">
|
||||
<button class="ctrl-btn" onClick=${() => send('PREV_TRACK')} title="Previous">⏮</button>
|
||||
<button class="ctrl-btn play-btn" onClick=${() => send(isPlaying ? 'PAUSE' : 'PLAY')}>
|
||||
${isPlaying ? '⏸' : '▶'}
|
||||
</button>
|
||||
<button class="ctrl-btn" onClick=${() => send('NEXT_TRACK')} title="Next">⏭</button>
|
||||
<button class="ctrl-btn ${isMuted ? 'active' : ''}" onClick=${() => send('MUTE')} title="Mute">
|
||||
${isMuted ? '🔇' : '🔊'}
|
||||
</button>
|
||||
<button class="ctrl-btn ${shuffle === 'SHUFFLE_ON' ? 'active' : ''}" onClick=${toggleShuffle} title="Shuffle">🔀</button>
|
||||
<button class="ctrl-btn ${repeat !== 'REPEAT_OFF' ? 'active' : ''}" onClick=${cycleRepeat} title="Repeat">${repeatIcon}</button>
|
||||
</div>
|
||||
<div class="volume-row">
|
||||
<span class="volume-icon">🔈</span>
|
||||
<input type="range" class="volume-slider" min="0" max="100"
|
||||
value=${localVolume} onInput=${onVolumeChange} />
|
||||
<span class="volume-value">${localVolume}</span>
|
||||
</div>
|
||||
${hasBass && html`
|
||||
<div class="bass-row">
|
||||
<span class="bass-label">Bass</span>
|
||||
<input type="range" class="volume-slider" min="-9" max="9"
|
||||
value=${localBass} onInput=${onBassChange} />
|
||||
<span class="volume-value">${localBass > 0 ? '+' : ''}${localBass}</span>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { h } from 'preact';
|
||||
import htm from 'htm';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
function DeviceCard({ id, device, onSelect }) {
|
||||
const { info, status } = device;
|
||||
const np = status?.nowPlaying;
|
||||
const isPlaying = np?.PlayStatus === 'PLAY_STATE';
|
||||
const isStandby = !np || np.Source === 'STANDBY';
|
||||
|
||||
return html`
|
||||
<div class="device-card" onClick=${() => onSelect(id)}>
|
||||
<div class="device-header">
|
||||
<span class="device-name">${info?.Name || id}</span>
|
||||
<span class="device-indicator ${status?.isConnected ? 'online' : 'offline'}"></span>
|
||||
</div>
|
||||
<div class="device-type">${info?.Type || ''}</div>
|
||||
${!isStandby && html`
|
||||
<div class="now-playing-mini">
|
||||
<span class="play-status">${isPlaying ? '▶' : '⏸'}</span>
|
||||
<span class="track-mini">${np.Track || np.StationName || np.Source}</span>
|
||||
${np.Artist && html`<span class="artist-mini"> — ${np.Artist}</span>`}
|
||||
</div>
|
||||
`}
|
||||
${isStandby && html`<div class="standby-label">Standby</div>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function DeviceList({ devices, onSelect, onDiscover }) {
|
||||
const entries = Object.entries(devices);
|
||||
|
||||
return html`
|
||||
<div class="page-header">
|
||||
<h2>Devices</h2>
|
||||
<button class="btn-secondary" onClick=${onDiscover}>Discover</button>
|
||||
</div>
|
||||
${entries.length === 0
|
||||
? html`
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">◉</div>
|
||||
<p>No devices found on your network.</p>
|
||||
<button class="btn-primary" onClick=${onDiscover}>Start Discovery</button>
|
||||
</div>`
|
||||
: html`
|
||||
<div class="device-grid">
|
||||
${entries.map(([id, device]) => html`
|
||||
<${DeviceCard} key=${id} id=${id} device=${device} onSelect=${onSelect} />
|
||||
`)}
|
||||
</div>`
|
||||
}
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import htm from 'htm';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
function fmt(secs) {
|
||||
if (!secs || secs <= 0) return '0:00';
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = secs % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function NowPlaying({ nowPlaying }) {
|
||||
const [position, setPosition] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const pos = nowPlaying?.Time?.Position ?? 0;
|
||||
setPosition(pos);
|
||||
if (nowPlaying?.PlayStatus !== 'PLAY_STATE') return;
|
||||
const id = setInterval(() => setPosition(p => p + 1), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [nowPlaying?.Time?.Position, nowPlaying?.PlayStatus]);
|
||||
|
||||
if (!nowPlaying || nowPlaying.Source === 'STANDBY') {
|
||||
return html`<div class="now-playing standby">Standby</div>`;
|
||||
}
|
||||
|
||||
const title = nowPlaying.Track || nowPlaying.StationName || nowPlaying.Source;
|
||||
const artURL = nowPlaying.Art?.URL;
|
||||
const isBuffering = nowPlaying.PlayStatus === 'BUFFERING_STATE';
|
||||
const total = nowPlaying.Time?.Total ?? 0;
|
||||
const pct = total > 0 ? Math.min(100, (position / total) * 100) : 0;
|
||||
|
||||
return html`
|
||||
<div class="now-playing">
|
||||
${artURL && html`<img class="album-art" src=${artURL} alt="" />`}
|
||||
<div class="track-info">
|
||||
<div class="track-title">${title}</div>
|
||||
${nowPlaying.Artist && html`<div class="track-artist">${nowPlaying.Artist}</div>`}
|
||||
${nowPlaying.Album && html`<div class="track-album">${nowPlaying.Album}</div>`}
|
||||
<div class="track-meta">
|
||||
<span class="track-source">${nowPlaying.Source}</span>
|
||||
${isBuffering && html`<span class="buffering-badge">Buffering…</span>`}
|
||||
</div>
|
||||
${total > 0 && html`
|
||||
<div class="progress-row">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width:${pct}%"></div>
|
||||
</div>
|
||||
<span class="progress-time">${fmt(position)} / ${fmt(total)}</span>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { h } from 'preact';
|
||||
import htm from 'htm';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
const SOURCE_LABELS = {
|
||||
TUNEIN: 'TuneIn', SPOTIFY: 'Spotify', AMAZON: 'Amazon',
|
||||
PANDORA: 'Pandora', IHEARTRADIO: 'iHeart', DEEZER: 'Deezer',
|
||||
LOCAL_INTERNET_RADIO: 'Internet Radio',
|
||||
};
|
||||
|
||||
function sourceLabel(source) {
|
||||
return SOURCE_LABELS[source] || source;
|
||||
}
|
||||
|
||||
function PresetSlot({ preset, deviceId, active }) {
|
||||
const item = preset?.ContentItem;
|
||||
const isEmpty = !item;
|
||||
const art = item?.ContainerArt;
|
||||
const name = item?.ItemName || `Preset ${preset?.ID ?? ''}`;
|
||||
|
||||
function select() {
|
||||
if (!isEmpty) api.control(deviceId, 'preset', preset.ID);
|
||||
}
|
||||
|
||||
return html`
|
||||
<button
|
||||
class="preset-slot ${isEmpty ? 'empty' : ''} ${active ? 'active' : ''}"
|
||||
onClick=${select}
|
||||
disabled=${isEmpty}
|
||||
title=${isEmpty ? 'Empty' : name}
|
||||
>
|
||||
${art
|
||||
? html`<img class="preset-art" src=${art} alt="" />`
|
||||
: html`<span class="preset-source-label">${isEmpty ? '—' : sourceLabel(item.Source)}</span>`
|
||||
}
|
||||
<span class="preset-name">${isEmpty ? 'Empty' : name}</span>
|
||||
<span class="preset-num">${preset?.ID ?? ''}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
export function Presets({ deviceId, status }) {
|
||||
const presets = status?.presets?.Preset ?? [];
|
||||
const currentSource = status?.nowPlaying?.Source;
|
||||
const currentLocation = status?.nowPlaying?.ContentItem?.Location;
|
||||
|
||||
// Build a map for quick lookup, then render slots 1-6
|
||||
const byId = Object.fromEntries(presets.map(p => [p.ID, p]));
|
||||
const slots = [1, 2, 3, 4, 5, 6].map(id => byId[id] ?? { ID: id, ContentItem: null });
|
||||
|
||||
function isActive(preset) {
|
||||
const item = preset.ContentItem;
|
||||
return item && item.Source === currentSource && item.Location === currentLocation;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="presets-section">
|
||||
<h3 class="section-title">Presets</h3>
|
||||
<div class="preset-grid">
|
||||
${slots.map(preset => html`
|
||||
<${PresetSlot}
|
||||
key=${preset.ID}
|
||||
preset=${preset}
|
||||
deviceId=${deviceId}
|
||||
active=${isActive(preset)}
|
||||
/>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import htm from 'htm';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
const SOURCE_ICONS = {
|
||||
TUNEIN: '📻', SPOTIFY: '🎵', AMAZON: '🎶', PANDORA: '🎸',
|
||||
DEEZER: '🎵', IHEART: '📻', BLUETOOTH: '📶', AUX: '🔌',
|
||||
LOCAL_MUSIC: '💽', STORED_MUSIC: '💽',
|
||||
};
|
||||
|
||||
export function Recents({ deviceId }) {
|
||||
const [items, setItems] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!deviceId) return;
|
||||
api.recents(deviceId).then(resp => {
|
||||
setItems(resp.data?.Items ?? []);
|
||||
}).catch(() => {
|
||||
setItems([]);
|
||||
}).finally(() => setLoading(false));
|
||||
}, [deviceId]);
|
||||
|
||||
if (loading) return html`
|
||||
<div class="recents-section">
|
||||
<div class="section-title">Recents</div>
|
||||
<div class="loading-bar"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
function play(item) {
|
||||
const ci = item.ContentItem;
|
||||
if (!ci?.Location) return;
|
||||
api.play(deviceId, {
|
||||
source: ci.Source,
|
||||
type: ci.Type,
|
||||
location: ci.Location,
|
||||
sourceAccount: ci.SourceAccount,
|
||||
itemName: ci.ItemName,
|
||||
containerArt: ci.ContainerArt,
|
||||
isPresetable: ci.IsPresetable,
|
||||
});
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="recents-section">
|
||||
<div class="section-title">Recents</div>
|
||||
<div class="recents-list">
|
||||
${items.map(item => {
|
||||
const ci = item.ContentItem;
|
||||
if (!ci) return null;
|
||||
const icon = SOURCE_ICONS[ci.Source] ?? '♪';
|
||||
return html`
|
||||
<button class="recent-item" key=${item.ID || item.UTCTime} onClick=${() => play(item)}>
|
||||
${ci.ContainerArt
|
||||
? html`<img class="recent-art" src=${ci.ContainerArt} alt="" />`
|
||||
: html`<div class="recent-art recent-art-empty">${icon}</div>`
|
||||
}
|
||||
<div class="recent-info">
|
||||
<span class="recent-name">${ci.ItemName || ci.Source}</span>
|
||||
<span class="recent-source">${ci.Source}</span>
|
||||
</div>
|
||||
<span class="recent-play">▶</span>
|
||||
</button>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { h } from 'preact';
|
||||
import htm from 'htm';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
const SOURCE_ICONS = {
|
||||
TUNEIN: '📻', SPOTIFY: '🎵', AMAZON: '🛒', PANDORA: '🎶',
|
||||
BLUETOOTH: '📶', AUX: '🔌', OPTICAL: '💡', HDMI: '📺',
|
||||
IHEARTRADIO: '❤️', DEEZER: '🎼', LOCAL_INTERNET_RADIO: '📡',
|
||||
AIRPLAY: '📡', PRODUCT: '🔊',
|
||||
};
|
||||
|
||||
export function Sources({ deviceId, status }) {
|
||||
const items = status?.sources?.SourceItem ?? [];
|
||||
const currentSource = status?.nowPlaying?.Source;
|
||||
const currentAccount = status?.nowPlaying?.SourceAccount;
|
||||
|
||||
const ready = items.filter(s => s.Status === 'READY');
|
||||
if (ready.length === 0) return null;
|
||||
|
||||
function select(src) {
|
||||
api.selectSource(deviceId, src.Source, src.SourceAccount ?? '');
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="sources-section">
|
||||
<h3 class="section-title">Sources</h3>
|
||||
<div class="source-list">
|
||||
${ready.map(src => {
|
||||
const isActive = src.Source === currentSource &&
|
||||
(!src.SourceAccount || src.SourceAccount === currentAccount);
|
||||
return html`
|
||||
<button
|
||||
key=${src.Source + (src.SourceAccount || '')}
|
||||
class="source-btn ${isActive ? 'active' : ''} ${src.IsLocal ? 'local' : ''}"
|
||||
onClick=${() => select(src)}
|
||||
title=${src.Source}
|
||||
>
|
||||
<span class="source-icon">${SOURCE_ICONS[src.Source] || '🔊'}</span>
|
||||
<span class="source-name">${src.DisplayName || src.Source}</span>
|
||||
</button>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import htm from 'htm';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
// BmxNavResponse has shape { bmx_sections: [{ name, items: [{ name, imageUrl, subtitle, _links }] }] }
|
||||
// _links.bmx_navigate.href = "/v1/navigate/{encodedPath}" — strip prefix for API call
|
||||
// _links.bmx_playback.href = station/track URL, type = "stationurl"|"tracklisturl"
|
||||
|
||||
function navPath(item) {
|
||||
const href = item._links?.bmx_navigate?.href;
|
||||
return href ? href.replace(/^\/v1\/navigate\//, '') : null;
|
||||
}
|
||||
|
||||
function playbackInfo(item) {
|
||||
const link = item._links?.bmx_playback;
|
||||
return link ? { location: link.href, type: link.type || 'stationurl' } : null;
|
||||
}
|
||||
|
||||
function flattenSections(data) {
|
||||
if (!data?.bmx_sections) return [];
|
||||
return data.bmx_sections.flatMap(section =>
|
||||
(section.items || []).map(item => ({ ...item, _sectionName: section.name }))
|
||||
);
|
||||
}
|
||||
|
||||
export function TuneInBrowser({ devices }) {
|
||||
const [items, setItems] = useState([]);
|
||||
const [navStack, setNavStack] = useState([{ label: 'TuneIn', path: null }]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pendingPlay, setPendingPlay] = useState(null);
|
||||
|
||||
useEffect(() => { browse(null); }, []);
|
||||
|
||||
async function browse(path) {
|
||||
setLoading(true);
|
||||
const resp = await api.tuneInBrowse(path);
|
||||
setLoading(false);
|
||||
if (resp.success) setItems(flattenSections(resp.data));
|
||||
}
|
||||
|
||||
async function search(q) {
|
||||
if (!q.trim()) return;
|
||||
setLoading(true);
|
||||
const resp = await api.tuneInSearch(q);
|
||||
setLoading(false);
|
||||
if (resp.success) {
|
||||
setNavStack([{ label: 'TuneIn', path: null }, { label: `"${q}"`, path: null }]);
|
||||
setItems(flattenSections(resp.data));
|
||||
}
|
||||
}
|
||||
|
||||
function navigate(item) {
|
||||
const path = navPath(item);
|
||||
const play = playbackInfo(item);
|
||||
|
||||
if (path) {
|
||||
setNavStack(s => [...s, { label: item.name, path }]);
|
||||
browse(path);
|
||||
} else if (play) {
|
||||
setPendingPlay({ ...play, name: item.name, image: item.imageUrl });
|
||||
}
|
||||
}
|
||||
|
||||
function navTo(index) {
|
||||
const stack = navStack.slice(0, index + 1);
|
||||
setNavStack(stack);
|
||||
browse(stack[stack.length - 1].path);
|
||||
}
|
||||
|
||||
async function playOn(deviceId) {
|
||||
await api.tuneInPlay(deviceId, { location: pendingPlay.location, type: pendingPlay.type, name: pendingPlay.name });
|
||||
setPendingPlay(null);
|
||||
}
|
||||
|
||||
const deviceEntries = Object.entries(devices);
|
||||
|
||||
return html`
|
||||
<div class="tunein-browser">
|
||||
<div class="tunein-toolbar">
|
||||
<input
|
||||
type="text"
|
||||
class="tunein-search-input"
|
||||
placeholder="Search stations, podcasts…"
|
||||
value=${searchQuery}
|
||||
onInput=${(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown=${(e) => e.key === 'Enter' && search(searchQuery)}
|
||||
/>
|
||||
<button class="btn-primary" onClick=${() => search(searchQuery)}>Search</button>
|
||||
<button class="btn-secondary" onClick=${() => {
|
||||
setNavStack([{ label: 'TuneIn', path: null }]);
|
||||
setSearchQuery('');
|
||||
browse(null);
|
||||
}}>Browse</button>
|
||||
</div>
|
||||
|
||||
${navStack.length > 1 && html`
|
||||
<nav class="breadcrumb">
|
||||
${navStack.map((entry, i) => html`
|
||||
${i > 0 && html`<span class="breadcrumb-sep">›</span>`}
|
||||
${i < navStack.length - 1
|
||||
? html`<a class="breadcrumb-link" onClick=${() => navTo(i)}>${entry.label}</a>`
|
||||
: html`<span class="breadcrumb-current">${entry.label}</span>`
|
||||
}
|
||||
`)}
|
||||
</nav>
|
||||
`}
|
||||
|
||||
${loading && html`<div class="loading-bar"></div>`}
|
||||
|
||||
<ul class="tunein-list">
|
||||
${items.map((item, i) => {
|
||||
const isNav = !!navPath(item);
|
||||
return html`
|
||||
<li key=${item._links?.self?.href || i} class="tunein-item" onClick=${() => navigate(item)}>
|
||||
${item.imageUrl && html`<img class="tunein-thumb" src=${item.imageUrl} alt="" />`}
|
||||
<div class="tunein-item-info">
|
||||
<span class="tunein-item-name">${item.name}</span>
|
||||
${item.subtitle && html`<span class="tunein-item-desc">${item.subtitle}</span>`}
|
||||
</div>
|
||||
<span class="tunein-item-arrow">${isNav ? '›' : '▶'}</span>
|
||||
</li>
|
||||
`;
|
||||
})}
|
||||
</ul>
|
||||
|
||||
${pendingPlay && html`
|
||||
<div class="overlay" onClick=${() => setPendingPlay(null)}>
|
||||
<div class="device-picker" onClick=${(e) => e.stopPropagation()}>
|
||||
<h3 class="picker-title">Play on device</h3>
|
||||
<p class="picker-item-name">${pendingPlay.name}</p>
|
||||
<div class="picker-devices">
|
||||
${deviceEntries.length === 0 && html`<p class="picker-no-devices">No devices found. Try discovering first.</p>`}
|
||||
${deviceEntries.map(([id, d]) => html`
|
||||
<button class="picker-device-btn" onClick=${() => playOn(id)}>
|
||||
${d.info?.name || id}
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
<button class="btn-secondary picker-cancel" onClick=${() => setPendingPlay(null)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import htm from 'htm';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
export function Zone({ deviceId, devices }) {
|
||||
const [zone, setZone] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
|
||||
function refresh() {
|
||||
api.zone(deviceId).then(resp => {
|
||||
if (resp.success) setZone(resp.data);
|
||||
}).finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => { refresh(); }, [deviceId]);
|
||||
|
||||
async function addDevice(slaveId) {
|
||||
setShowPicker(false);
|
||||
await api.zoneAdd(deviceId, slaveId);
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function removeDevice(slaveId) {
|
||||
await api.zoneRemove(deviceId, slaveId);
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function dissolve() {
|
||||
await api.zoneDissolve(deviceId);
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function leave() {
|
||||
await api.zoneLeave(deviceId);
|
||||
refresh();
|
||||
}
|
||||
|
||||
if (loading) return html`
|
||||
<div class="zone-section">
|
||||
<div class="section-title">Zone</div>
|
||||
<div class="loading-bar"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (!zone) return null;
|
||||
|
||||
// Devices not already in the zone are available to add
|
||||
const zoneIps = new Set([zone.masterIp, ...(zone.members || []).map(m => m.ip)].filter(Boolean));
|
||||
const available = Object.entries(devices || {}).filter(([ip]) => !zoneIps.has(ip));
|
||||
|
||||
const deviceName = (ip) => devices[ip]?.info?.Name ?? ip;
|
||||
|
||||
return html`
|
||||
<div class="zone-section">
|
||||
<div class="section-title">Zone</div>
|
||||
|
||||
${zone.isStandalone && html`
|
||||
<div class="zone-row">
|
||||
<span class="zone-status-label">Standalone</span>
|
||||
${available.length > 0 && html`
|
||||
<button class="btn-secondary zone-btn" onClick=${() => setShowPicker(true)}>+ Group with…</button>
|
||||
`}
|
||||
</div>
|
||||
`}
|
||||
|
||||
${zone.isMaster && html`
|
||||
<div class="zone-members">
|
||||
<div class="zone-member zone-master-row">
|
||||
<span class="zone-badge master">Master</span>
|
||||
<span class="zone-member-name">${deviceName(deviceId)}</span>
|
||||
</div>
|
||||
${(zone.members || []).map(m => html`
|
||||
<div class="zone-member" key=${m.ip}>
|
||||
<span class="zone-badge slave">Member</span>
|
||||
<span class="zone-member-name">${m.name || m.ip}</span>
|
||||
<button class="btn-icon zone-remove" title="Remove from zone"
|
||||
onClick=${() => removeDevice(m.ip)}>✕</button>
|
||||
</div>
|
||||
`)}
|
||||
<div class="zone-actions">
|
||||
${available.length > 0 && html`
|
||||
<button class="btn-secondary zone-btn" onClick=${() => setShowPicker(true)}>+ Add speaker</button>
|
||||
`}
|
||||
<button class="btn-secondary zone-btn" onClick=${dissolve}>Dissolve zone</button>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${zone.isSlave && html`
|
||||
<div class="zone-row">
|
||||
<span class="zone-badge slave">Member</span>
|
||||
<span class="zone-member-name">Zone: ${zone.masterName || zone.masterIp}</span>
|
||||
<button class="btn-secondary zone-btn" onClick=${leave}>Leave zone</button>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${showPicker && html`
|
||||
<div class="overlay" onClick=${() => setShowPicker(false)}>
|
||||
<div class="device-picker" onClick=${e => e.stopPropagation()}>
|
||||
<div class="picker-title">Add to zone</div>
|
||||
<div class="picker-devices">
|
||||
${available.map(([ip, d]) => html`
|
||||
<button class="picker-device-btn" key=${ip} onClick=${() => addDevice(ip)}>
|
||||
${d.info?.Name ?? ip}
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
<button class="btn-secondary picker-cancel" onClick=${() => setShowPicker(false)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
// Package handlers contains WebSocket handlers for real-time communication.
|
||||
package handlers
|
||||
package soundtouchweb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -7,13 +6,13 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// HandleWebSocket handles WebSocket connections for real-time updates
|
||||
// HandleWebSocket handles browser WebSocket connections for real-time updates.
|
||||
func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := app.Upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
@@ -22,19 +21,16 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
defer func() {
|
||||
// Unregister client
|
||||
app.WSMutex.Lock()
|
||||
delete(app.WSClients, conn)
|
||||
app.WSMutex.Unlock()
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
// Register client
|
||||
app.WSMutex.Lock()
|
||||
app.WSClients[conn] = true
|
||||
app.WSMutex.Unlock()
|
||||
|
||||
// Send initial device list
|
||||
devices := make(map[string]interface{})
|
||||
for id, device := range app.Devices {
|
||||
devices[id] = map[string]interface{}{
|
||||
@@ -44,30 +40,20 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
initialMessage := webtypes.WebSocketMessage{
|
||||
Type: "devices",
|
||||
Data: devices,
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(initialMessage); err != nil {
|
||||
if err := conn.WriteJSON(webtypes.WebSocketMessage{Type: "devices", Data: devices}); err != nil {
|
||||
log.Printf("Failed to send initial data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Keep connection alive and send updates
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Set up ping handler to detect client disconnects
|
||||
conn.SetPongHandler(func(string) error {
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
return nil
|
||||
})
|
||||
|
||||
// Set initial read deadline
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
|
||||
// Handle incoming messages in a separate goroutine
|
||||
go func() {
|
||||
defer conn.Close()
|
||||
|
||||
@@ -79,24 +65,19 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}()
|
||||
|
||||
// Main loop for sending periodic updates
|
||||
for range ticker.C {
|
||||
// Send ping to check if client is still connected
|
||||
if err := conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
|
||||
log.Printf("Failed to send ping: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Send periodic status updates
|
||||
for id, device := range app.Devices {
|
||||
if device.Status.IsConnected {
|
||||
statusMessage := webtypes.WebSocketMessage{
|
||||
if err := conn.WriteJSON(webtypes.WebSocketMessage{
|
||||
Type: "status_update",
|
||||
DeviceID: id,
|
||||
Data: device.Status,
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(statusMessage); err != nil {
|
||||
}); err != nil {
|
||||
log.Printf("Failed to send status update: %v", err)
|
||||
return
|
||||
}
|
||||
@@ -105,36 +86,31 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIDiscover triggers device discovery
|
||||
// HandleAPIDiscover acknowledges a discovery request (actual discovery is triggered by Mount).
|
||||
func (app *WebApp) HandleAPIDiscover(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
app.sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Discovery will be triggered by the main app
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
if err := json.NewEncoder(w).Encode(webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]string{"message": "Discovery started"},
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// ConnectDeviceWebSocket establishes a WebSocket connection to a device
|
||||
// ConnectDeviceWebSocket establishes a WebSocket connection to a SoundTouch device.
|
||||
func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.DeviceConnection) {
|
||||
// Skip WebSocket connection if client is not available (e.g., in tests)
|
||||
if conn.Client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
wsClient := conn.Client.NewWebSocketClient(nil)
|
||||
|
||||
// Setup event handlers
|
||||
wsClient.OnNowPlaying(func(event *models.NowPlayingUpdatedEvent) {
|
||||
conn.Status.NowPlaying = &event.NowPlaying
|
||||
conn.Status.LastActivity = time.Now()
|
||||
@@ -155,7 +131,6 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device
|
||||
conn.Status.LastActivity = time.Now()
|
||||
})
|
||||
|
||||
// Connect WebSocket
|
||||
if err := wsClient.Connect(); err != nil {
|
||||
log.Printf("Failed to connect WebSocket for device %s: %v", deviceID, err)
|
||||
return
|
||||
@@ -166,7 +141,6 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device
|
||||
|
||||
log.Printf("WebSocket connected for device %s", deviceID)
|
||||
|
||||
// Wait for disconnection
|
||||
wsClient.Wait()
|
||||
|
||||
conn.Status.IsConnected = false
|
||||
@@ -174,55 +148,46 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device
|
||||
log.Printf("WebSocket disconnected for device %s", deviceID)
|
||||
}
|
||||
|
||||
// UpdateDeviceStatus fetches current status from device
|
||||
// UpdateDeviceStatus fetches current status from a device.
|
||||
func (app *WebApp) UpdateDeviceStatus(_ string, conn *webtypes.DeviceConnection) {
|
||||
// Skip status update if client is not available (e.g., in tests)
|
||||
if conn.Client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
statusUpdated := false
|
||||
|
||||
// Get current now playing
|
||||
if nowPlaying, err := conn.Client.GetNowPlaying(); err == nil {
|
||||
conn.Status.NowPlaying = nowPlaying
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Get current volume
|
||||
if volume, err := conn.Client.GetVolume(); err == nil {
|
||||
conn.Status.Volume = volume
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Get presets
|
||||
if presets, err := conn.Client.GetPresets(); err == nil {
|
||||
conn.Status.Presets = presets
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Update last activity if any status was updated
|
||||
if statusUpdated {
|
||||
conn.Status.LastActivity = time.Now()
|
||||
}
|
||||
// Get sources
|
||||
|
||||
if sources, err := conn.Client.GetSources(); err == nil {
|
||||
conn.Status.Sources = sources
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Get bass (if available)
|
||||
if bass, err := conn.Client.GetBass(); err == nil {
|
||||
conn.Status.Bass = bass
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Mark as connected if we successfully got at least one status
|
||||
conn.Status.IsConnected = statusUpdated
|
||||
conn.Status.LastActivity = time.Now()
|
||||
}
|
||||
|
||||
// HandleDeviceWebSocket handles individual device WebSocket connections for real-time device-specific updates
|
||||
// HandleDeviceWebSocket handles per-device WebSocket connections for real-time device-specific updates.
|
||||
func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
if deviceID == "" {
|
||||
@@ -245,31 +210,21 @@ func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
log.Printf("Device WebSocket connected for %s", deviceID)
|
||||
|
||||
// Send initial device status
|
||||
initialMessage := webtypes.WebSocketMessage{
|
||||
if err := conn.WriteJSON(webtypes.WebSocketMessage{
|
||||
Type: "device_status",
|
||||
DeviceID: deviceID,
|
||||
Data: map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
},
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(initialMessage); err != nil {
|
||||
Data: map[string]interface{}{"info": device.DeviceInfo, "status": device.Status},
|
||||
}); err != nil {
|
||||
log.Printf("Failed to send initial device status: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Set up ping handler to detect client disconnects
|
||||
conn.SetPongHandler(func(string) error {
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
return nil
|
||||
})
|
||||
|
||||
// Set initial read deadline
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
|
||||
// Handle incoming messages in a separate goroutine
|
||||
go func() {
|
||||
defer conn.Close()
|
||||
|
||||
@@ -281,36 +236,26 @@ func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}()
|
||||
|
||||
// Send periodic device status updates
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
// Send ping to check if client is still connected
|
||||
if err := conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
|
||||
log.Printf("Failed to send ping to device WebSocket %s: %v", deviceID, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Send device status update
|
||||
statusMessage := webtypes.WebSocketMessage{
|
||||
if err := conn.WriteJSON(webtypes.WebSocketMessage{
|
||||
Type: "device_status",
|
||||
DeviceID: deviceID,
|
||||
Data: map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
},
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(statusMessage); err != nil {
|
||||
Data: map[string]interface{}{"info": device.DeviceInfo, "status": device.Status},
|
||||
}); err != nil {
|
||||
log.Printf("Failed to send device status update for %s: %v", deviceID, err)
|
||||
return
|
||||
}
|
||||
|
||||
// If device has active WebSocket connection to SoundTouch device,
|
||||
// also send any real-time updates from that connection
|
||||
if device.WebSocket != nil && device.Status.IsConnected {
|
||||
realtimeMessage := webtypes.WebSocketMessage{
|
||||
if err := conn.WriteJSON(webtypes.WebSocketMessage{
|
||||
Type: "device_realtime",
|
||||
DeviceID: deviceID,
|
||||
Data: map[string]interface{}{
|
||||
@@ -318,12 +263,57 @@ func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
"volume": device.Status.Volume,
|
||||
"timestamp": time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(realtimeMessage); err != nil {
|
||||
}); err != nil {
|
||||
log.Printf("Failed to send realtime update for %s: %v", deviceID, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastDeviceList sends the updated device list to all connected browser WebSocket clients.
|
||||
func (app *WebApp) BroadcastDeviceList() {
|
||||
app.WSMutex.RLock()
|
||||
defer app.WSMutex.RUnlock()
|
||||
|
||||
devices := make(map[string]interface{})
|
||||
for id, device := range app.Devices {
|
||||
devices[id] = map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
"lastSeen": device.LastSeen,
|
||||
}
|
||||
}
|
||||
|
||||
app.broadcast(webtypes.WebSocketMessage{Type: "devices", Data: devices})
|
||||
}
|
||||
|
||||
// BroadcastDiscoveryStatus sends discovery progress to all connected browser WebSocket clients.
|
||||
func (app *WebApp) BroadcastDiscoveryStatus(status string, deviceCount int) {
|
||||
app.WSMutex.RLock()
|
||||
defer app.WSMutex.RUnlock()
|
||||
|
||||
app.broadcast(webtypes.WebSocketMessage{
|
||||
Type: "discovery_status",
|
||||
Data: map[string]interface{}{"status": status, "deviceCount": deviceCount},
|
||||
})
|
||||
}
|
||||
|
||||
// broadcast sends a message to all registered WS clients, removing failed ones.
|
||||
// Caller must hold at least a read lock on WSMutex.
|
||||
func (app *WebApp) broadcast(msg webtypes.WebSocketMessage) {
|
||||
var failed []*websocket.Conn
|
||||
|
||||
for client := range app.WSClients {
|
||||
if err := client.WriteJSON(msg); err != nil {
|
||||
log.Printf("Failed to broadcast to WebSocket client: %v", err)
|
||||
|
||||
failed = append(failed, client)
|
||||
}
|
||||
}
|
||||
|
||||
for _, client := range failed {
|
||||
delete(app.WSClients, client)
|
||||
client.Close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
ARG FRIDA_VERSION=17.9.1
|
||||
ARG FRIDA_TOOLS_VERSION=14.8.1
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y android-tools-adb xz-utils curl && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install frida==${FRIDA_VERSION} frida-tools==${FRIDA_TOOLS_VERSION} objection
|
||||
|
||||
RUN curl -L "https://github.com/frida/frida/releases/download/${FRIDA_VERSION}/frida-server-${FRIDA_VERSION}-android-arm64.xz" \
|
||||
-o /tmp/frida-server.xz && \
|
||||
xz -d /tmp/frida-server.xz && \
|
||||
mv /tmp/frida-server /usr/local/bin/frida-server-android && \
|
||||
chmod +x /usr/local/bin/frida-server-android
|
||||
|
||||
ARG FRIDA_SCRIPTS_REPO=https://github.com/httptoolkit/frida-interception-and-unpinning
|
||||
ARG FRIDA_SCRIPTS_REF=main
|
||||
RUN mkdir -p /usr/local/share/frida-scripts && \
|
||||
curl -L "${FRIDA_SCRIPTS_REPO}/archive/refs/heads/${FRIDA_SCRIPTS_REF}.tar.gz" \
|
||||
-o /tmp/frida-scripts.tar.gz && \
|
||||
tar -xzf /tmp/frida-scripts.tar.gz --strip-components=1 \
|
||||
-C /usr/local/share/frida-scripts && \
|
||||
rm /tmp/frida-scripts.tar.gz
|
||||
@@ -0,0 +1,279 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-time setup: install mitmproxy, obtain the Bose APK, create the Android
|
||||
# emulator AVD, and save a ready-to-use snapshot so subsequent sessions skip
|
||||
# the cert/APK install cycle.
|
||||
#
|
||||
# Run once, then use start-mitm-session.sh for day-to-day use.
|
||||
# Tested on: Apple Silicon Mac (arm64)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
ANDROID_DIR="${REPO_ROOT}/scripts/android"
|
||||
|
||||
# ── Config ────────────────────────────────────────────────────────────────────
|
||||
SDK="${ANDROID_HOME:-${HOME}/Library/Android/sdk}"
|
||||
SDKMANAGER="${SDK}/cmdline-tools/latest/bin/sdkmanager"
|
||||
AVDMANAGER="${SDK}/cmdline-tools/latest/bin/avdmanager"
|
||||
EMULATOR="${SDK}/emulator/emulator"
|
||||
ADB="${SDK}/platform-tools/adb"
|
||||
|
||||
SYSTEM_IMAGE="system-images;android-33;google_apis;arm64-v8a"
|
||||
AVD_NAME="bose-mitm"
|
||||
AVD_DEVICE="pixel_6"
|
||||
EMULATOR_SERIAL="emulator-5554"
|
||||
SNAPSHOT_NAME="mitm-ready"
|
||||
|
||||
BOSE_APK="${BOSE_APK:-${ANDROID_DIR}/bose.apk}"
|
||||
MITM_CA="${HOME}/.mitmproxy/mitmproxy-ca-cert.pem"
|
||||
# Keep in sync with scripts/android/Dockerfile ARG defaults
|
||||
MITMPROXY_IMAGE="mitmproxy/mitmproxy:12.2.1"
|
||||
# Native macOS app (recommended for capture sessions — Docker NAT blocks emulator traffic)
|
||||
MITMPROXY_NATIVE_URL="https://downloads.mitmproxy.org/12.2.2/mitmproxy-12.2.2-macos-arm64.tar.gz"
|
||||
MITMPROXY_NATIVE_APP="/Applications/mitmproxy.app"
|
||||
FRIDA_VERSION="17.9.1"
|
||||
FRIDA_SERVER="${ANDROID_DIR}/frida-server"
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
info() { echo " → $*"; }
|
||||
ok() { echo " ✓ $*"; }
|
||||
warn() { echo " ⚠ $*"; }
|
||||
die() { echo " ✗ $*" >&2; exit 1; }
|
||||
|
||||
confirm() {
|
||||
local prompt="$1"
|
||||
local reply
|
||||
read -r -p " ${prompt} [y/N] " reply
|
||||
[[ "${reply}" =~ ^[Yy]$ ]]
|
||||
}
|
||||
|
||||
wait_for_boot() {
|
||||
info "Waiting for emulator to finish booting..."
|
||||
"${ADB}" -s "${EMULATOR_SERIAL}" wait-for-device
|
||||
until "${ADB}" -s "${EMULATOR_SERIAL}" shell getprop sys.boot_completed 2>/dev/null | grep -q "1"; do
|
||||
sleep 2
|
||||
done
|
||||
sleep 3
|
||||
ok "Boot complete"
|
||||
}
|
||||
|
||||
# ── Step 1: Check prerequisites ───────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Step 1/9 Check prerequisites"
|
||||
|
||||
# Docker — used for frida-server build and CA cert generation
|
||||
command -v docker &>/dev/null || die "Docker not found. Install Docker Desktop and try again."
|
||||
docker info &>/dev/null || die "Docker daemon not running. Start Docker Desktop and try again."
|
||||
ok "Docker available"
|
||||
|
||||
# Native mitmproxy app — required for capture sessions (Docker NAT blocks emulator traffic)
|
||||
if [[ -x "${MITMPROXY_NATIVE_APP}/Contents/MacOS/mitmweb" ]]; then
|
||||
ok "mitmproxy native app found at ${MITMPROXY_NATIVE_APP}"
|
||||
else
|
||||
warn "mitmproxy native macOS app not found at ${MITMPROXY_NATIVE_APP}"
|
||||
warn "Download and install it before running capture sessions:"
|
||||
warn " curl -L '${MITMPROXY_NATIVE_URL}' -o /tmp/mitmproxy.tar.gz"
|
||||
warn " tar -xzf /tmp/mitmproxy.tar.gz -C /Applications"
|
||||
fi
|
||||
|
||||
# ── Step 2: Generate mitmproxy CA cert (via Docker) ───────────────────────────
|
||||
echo ""
|
||||
echo "Step 2/9 Generate mitmproxy CA certificate"
|
||||
mkdir -p "${HOME}/.mitmproxy"
|
||||
if [[ -f "${MITM_CA}" ]]; then
|
||||
ok "CA cert already present at ${MITM_CA}"
|
||||
else
|
||||
info "Starting mitmproxy container briefly to generate CA..."
|
||||
MITM_CID=$(docker run -d \
|
||||
-v "${HOME}/.mitmproxy:/home/mitmproxy/.mitmproxy" \
|
||||
"${MITMPROXY_IMAGE}" \
|
||||
mitmdump --listen-host 0.0.0.0 --listen-port 8080)
|
||||
sleep 4
|
||||
docker stop "${MITM_CID}" > /dev/null
|
||||
docker rm "${MITM_CID}" > /dev/null
|
||||
|
||||
if [[ -f "${HOME}/.mitmproxy/mitmproxy-ca.pem" ]]; then
|
||||
openssl x509 -in "${HOME}/.mitmproxy/mitmproxy-ca.pem" -out "${MITM_CA}"
|
||||
ok "CA cert extracted to ${MITM_CA}"
|
||||
else
|
||||
die "CA generation failed — ~/.mitmproxy/mitmproxy-ca.pem not found"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Verify issuer
|
||||
ISSUER=$(openssl x509 -in "${MITM_CA}" -noout -issuer 2>/dev/null)
|
||||
if echo "${ISSUER}" | grep -qi "mitmproxy"; then
|
||||
ok "Cert issuer: ${ISSUER}"
|
||||
else
|
||||
warn "Unexpected cert issuer: ${ISSUER} — verify ${MITM_CA} is the mitmproxy CA"
|
||||
fi
|
||||
|
||||
# ── Step 3: Obtain Bose APK ───────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Step 3/9 Obtain Bose SoundTouch APK"
|
||||
if [[ -f "${BOSE_APK}" ]]; then
|
||||
ok "APK already present at ${BOSE_APK}"
|
||||
else
|
||||
echo ""
|
||||
echo " The Bose APK is needed but not found at ${BOSE_APK}."
|
||||
echo " Two options:"
|
||||
echo " a) Pull from a real Android device connected via USB"
|
||||
echo " b) Download from a URL you provide (e.g. from APKMirror or APKPure)"
|
||||
echo ""
|
||||
echo " APKMirror: https://www.apkmirror.com/apk/bose-corporation/bose-soundtouch/"
|
||||
echo " APKPure: https://apkpure.com/bose-soundtouch/com.bose.soundtouch"
|
||||
echo ""
|
||||
|
||||
PS3=" Choose: "
|
||||
select METHOD in "Pull from connected device (adb)" "Download from URL" "Skip (place APK manually later)"; do
|
||||
case "${REPLY}" in
|
||||
1)
|
||||
info "Listing connected devices..."
|
||||
"${ADB}" devices
|
||||
read -r -p " Enter device serial (leave blank for default): " DEVICE_SERIAL
|
||||
ADB_ARGS=()
|
||||
[[ -n "${DEVICE_SERIAL}" ]] && ADB_ARGS=(-s "${DEVICE_SERIAL}")
|
||||
|
||||
APK_PATH=$("${ADB}" "${ADB_ARGS[@]}" shell pm path com.bose.soundtouch \
|
||||
| tr -d '\r' | sed 's/package://')
|
||||
[[ -n "${APK_PATH}" ]] || die "Bose app not found on device. Is it installed?"
|
||||
|
||||
info "Pulling ${APK_PATH}..."
|
||||
"${ADB}" "${ADB_ARGS[@]}" pull "${APK_PATH}" "${BOSE_APK}"
|
||||
ok "APK saved to ${BOSE_APK}"
|
||||
break
|
||||
;;
|
||||
2)
|
||||
read -r -p " Paste the direct APK download URL: " APK_URL
|
||||
echo ""
|
||||
echo " URL: ${APK_URL}"
|
||||
echo " Destination: ${BOSE_APK}"
|
||||
if confirm "Download from this URL?"; then
|
||||
info "Downloading..."
|
||||
curl -L --progress-bar "${APK_URL}" -o "${BOSE_APK}"
|
||||
ok "APK saved to ${BOSE_APK}"
|
||||
else
|
||||
warn "Download skipped — place APK at ${BOSE_APK} before continuing"
|
||||
fi
|
||||
break
|
||||
;;
|
||||
3)
|
||||
warn "Skipped — place APK at ${BOSE_APK} and re-run this script"
|
||||
break
|
||||
;;
|
||||
*)
|
||||
echo " Please enter 1, 2, or 3"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
|
||||
[[ -f "${BOSE_APK}" ]] || die "APK not found at ${BOSE_APK} — cannot continue"
|
||||
|
||||
# ── Step 4: Install system image ─────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Step 4/9 Install Android system image"
|
||||
if "${SDKMANAGER}" --list_installed 2>/dev/null | grep -q "${SYSTEM_IMAGE}"; then
|
||||
ok "Already installed: ${SYSTEM_IMAGE}"
|
||||
else
|
||||
info "Installing ${SYSTEM_IMAGE} ..."
|
||||
"${SDKMANAGER}" "${SYSTEM_IMAGE}"
|
||||
ok "Installed"
|
||||
fi
|
||||
|
||||
# ── Step 5: Create AVD ────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Step 5/9 Create AVD '${AVD_NAME}'"
|
||||
if "${AVDMANAGER}" list avd 2>/dev/null | grep -q "Name: ${AVD_NAME}"; then
|
||||
ok "AVD '${AVD_NAME}' already exists — skipping creation"
|
||||
else
|
||||
echo no | "${AVDMANAGER}" create avd \
|
||||
-n "${AVD_NAME}" \
|
||||
-k "${SYSTEM_IMAGE}" \
|
||||
-d "${AVD_DEVICE}"
|
||||
ok "Created AVD '${AVD_NAME}'"
|
||||
fi
|
||||
|
||||
# ── Step 6: Build frida image and extract frida-server ────────────────────────
|
||||
echo ""
|
||||
echo "Step 6/9 Frida server v${FRIDA_VERSION} (via Docker)"
|
||||
if [[ -f "${FRIDA_SERVER}" ]]; then
|
||||
ok "Already present at ${FRIDA_SERVER}"
|
||||
else
|
||||
info "Building frida Docker image (FRIDA_VERSION=${FRIDA_VERSION})..."
|
||||
docker build -q \
|
||||
--build-arg "FRIDA_VERSION=${FRIDA_VERSION}" \
|
||||
-t "bose-frida:${FRIDA_VERSION}" \
|
||||
"${ANDROID_DIR}"
|
||||
|
||||
info "Extracting frida-server binary and SSL scripts from image..."
|
||||
CONTAINER_ID=$(docker create "bose-frida:${FRIDA_VERSION}")
|
||||
docker cp "${CONTAINER_ID}:/usr/local/bin/frida-server-android" "${FRIDA_SERVER}"
|
||||
docker cp "${CONTAINER_ID}:/usr/local/share/frida-scripts/." "${ANDROID_DIR}/frida/"
|
||||
docker rm "${CONTAINER_ID}" > /dev/null
|
||||
ok "Extracted to ${FRIDA_SERVER} and ${ANDROID_DIR}/frida/"
|
||||
fi
|
||||
|
||||
# ── Step 7: Start emulator ────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Step 7/9 Start emulator with writable system"
|
||||
if "${ADB}" devices | grep -q "${EMULATOR_SERIAL}"; then
|
||||
info "Emulator already running — will reuse"
|
||||
else
|
||||
"${EMULATOR}" -avd "${AVD_NAME}" -writable-system -no-snapshot-load &
|
||||
EMULATOR_PID=$!
|
||||
info "Emulator PID: ${EMULATOR_PID}"
|
||||
fi
|
||||
wait_for_boot
|
||||
|
||||
# ── Step 8: Root + cert + APK + frida-server ─────────────────────────────────
|
||||
echo ""
|
||||
echo "Step 8/9 Configure emulator (root, cert, APK, frida-server)"
|
||||
|
||||
"${ADB}" -s "${EMULATOR_SERIAL}" root
|
||||
"${ADB}" -s "${EMULATOR_SERIAL}" shell avbctl disable-verification
|
||||
"${ADB}" -s "${EMULATOR_SERIAL}" reboot
|
||||
wait_for_boot
|
||||
"${ADB}" -s "${EMULATOR_SERIAL}" root
|
||||
|
||||
# Install mitmproxy CA cert
|
||||
HASH=$(openssl x509 -inform PEM -subject_hash_old -in "${MITM_CA}" | head -1)
|
||||
"${ADB}" -s "${EMULATOR_SERIAL}" push "${MITM_CA}" /data/local/tmp/mitmproxy.pem
|
||||
"${ADB}" -s "${EMULATOR_SERIAL}" shell su 0 mkdir -p /data/misc/user/0/cacerts-added
|
||||
"${ADB}" -s "${EMULATOR_SERIAL}" shell su 0 \
|
||||
cp /data/local/tmp/mitmproxy.pem "/data/misc/user/0/cacerts-added/${HASH}.0"
|
||||
"${ADB}" -s "${EMULATOR_SERIAL}" shell su 0 \
|
||||
chmod 644 "/data/misc/user/0/cacerts-added/${HASH}.0"
|
||||
ok "Certificate installed (hash: ${HASH})"
|
||||
|
||||
# Install Bose APK
|
||||
if "${ADB}" -s "${EMULATOR_SERIAL}" shell pm list packages 2>/dev/null | grep -q "com.bose.soundtouch"; then
|
||||
ok "Bose app already installed"
|
||||
else
|
||||
"${ADB}" -s "${EMULATOR_SERIAL}" install "${BOSE_APK}"
|
||||
ok "Bose app installed"
|
||||
fi
|
||||
|
||||
# Push frida-server
|
||||
"${ADB}" -s "${EMULATOR_SERIAL}" push "${FRIDA_SERVER}" /data/local/tmp/frida-server
|
||||
"${ADB}" -s "${EMULATOR_SERIAL}" shell su 0 chmod 755 /data/local/tmp/frida-server
|
||||
ok "frida-server pushed"
|
||||
|
||||
# ── Step 9: Save snapshot ─────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Step 9/9 Save snapshot '${SNAPSHOT_NAME}'"
|
||||
"${ADB}" -s "${EMULATOR_SERIAL}" emu avd snapshot save "${SNAPSHOT_NAME}"
|
||||
ok "Snapshot '${SNAPSHOT_NAME}' saved"
|
||||
|
||||
# ── Done ──────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Setup complete."
|
||||
echo ""
|
||||
echo " AVD name : ${AVD_NAME}"
|
||||
echo " Snapshot : ${SNAPSHOT_NAME}"
|
||||
echo ""
|
||||
echo "Start a capture session with:"
|
||||
echo " scripts/android/start-mitm-session.sh"
|
||||
echo ""
|
||||
echo "Shut down the emulator when done:"
|
||||
echo " ${ADB} -s ${EMULATOR_SERIAL} emu kill"
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env bash
|
||||
# Per-session startup: restore the 'mitm-ready' emulator snapshot, refresh the
|
||||
# proxy IP (the Mac's LAN IP can change between sessions), start frida-server,
|
||||
# and print the Frida launch command for the Bose app.
|
||||
#
|
||||
# Prerequisites: run setup-mitm-avd.sh once first.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
ANDROID_DIR="${REPO_ROOT}/scripts/android"
|
||||
|
||||
# ── Config ────────────────────────────────────────────────────────────────────
|
||||
SDK="${ANDROID_HOME:-${HOME}/Library/Android/sdk}"
|
||||
EMULATOR="${SDK}/emulator/emulator"
|
||||
ADB="${SDK}/platform-tools/adb"
|
||||
|
||||
AVD_NAME="bose-mitm"
|
||||
EMULATOR_SERIAL="emulator-5554"
|
||||
SNAPSHOT_NAME="mitm-ready"
|
||||
PROXY_PORT=8080
|
||||
|
||||
FRIDA_SCRIPTS_DIR="${ANDROID_DIR}/frida"
|
||||
FRIDA_VENV="${ANDROID_DIR}/frida-venv"
|
||||
FRIDA_SERVER="${ANDROID_DIR}/frida-server"
|
||||
# Keep in sync with scripts/android/Dockerfile ARG defaults
|
||||
FRIDA_VERSION="17.9.1"
|
||||
FRIDA_TOOLS_VERSION="14.8.1"
|
||||
MITMPROXY_IMAGE="mitmproxy/mitmproxy:12.2.1"
|
||||
# Detect native mitmproxy app location
|
||||
MITMPROXY_NATIVE=""
|
||||
for candidate in "/Applications/mitmproxy.app" "${HOME}/Downloads/mitmproxy.app"; do
|
||||
if [[ -x "${candidate}/Contents/MacOS/mitmweb" ]]; then
|
||||
MITMPROXY_NATIVE="${candidate}/Contents/MacOS/mitmweb"
|
||||
break
|
||||
fi
|
||||
done
|
||||
[[ -n "${MITMPROXY_NATIVE}" ]] || die "mitmproxy native app not found. See setup-mitm-avd.sh for download instructions."
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
info() { echo " → $*"; }
|
||||
ok() { echo " ✓ $*"; }
|
||||
die() { echo " ✗ $*" >&2; exit 1; }
|
||||
|
||||
wait_for_boot() {
|
||||
"${ADB}" -s "${EMULATOR_SERIAL}" wait-for-device
|
||||
until "${ADB}" -s "${EMULATOR_SERIAL}" shell getprop sys.boot_completed 2>/dev/null | grep -q "1"; do
|
||||
sleep 2
|
||||
done
|
||||
sleep 2
|
||||
}
|
||||
|
||||
# ── Mac IP ────────────────────────────────────────────────────────────────────
|
||||
MAC_IP=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null)
|
||||
[[ -n "${MAC_IP}" ]] || die "Could not determine Mac LAN IP. Are you connected to Wi-Fi?"
|
||||
ok "Mac IP: ${MAC_IP}"
|
||||
|
||||
# ── Start emulator from snapshot ──────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Step 1/4 Start emulator from snapshot '${SNAPSHOT_NAME}'"
|
||||
if "${ADB}" devices | grep -q "${EMULATOR_SERIAL}"; then
|
||||
ok "Emulator already running — skipping launch"
|
||||
else
|
||||
"${EMULATOR}" -avd "${AVD_NAME}" -writable-system \
|
||||
-snapshot "${SNAPSHOT_NAME}" &
|
||||
info "Emulator starting (PID $!)..."
|
||||
fi
|
||||
|
||||
wait_for_boot
|
||||
"${ADB}" -s "${EMULATOR_SERIAL}" root
|
||||
ok "Emulator ready"
|
||||
|
||||
# ── Refresh proxy IP ──────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Step 2/4 Set proxy to ${MAC_IP}:${PROXY_PORT}"
|
||||
"${ADB}" -s "${EMULATOR_SERIAL}" shell settings put global http_proxy "${MAC_IP}:${PROXY_PORT}"
|
||||
ok "Proxy configured"
|
||||
|
||||
# ── Start frida-server ────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Step 3/4 Start frida-server"
|
||||
[[ -f "${FRIDA_SERVER}" ]] || die "frida-server not found at ${FRIDA_SERVER}. Run setup-mitm-avd.sh first."
|
||||
"${ADB}" -s "${EMULATOR_SERIAL}" shell su 0 \
|
||||
'pgrep frida-server > /dev/null && echo already_running || (nohup /data/local/tmp/frida-server > /dev/null 2>&1 &)'
|
||||
sleep 2
|
||||
ok "frida-server running"
|
||||
|
||||
# ── Check SSL bypass scripts ──────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Step 4/4 Ensure Frida SSL scripts are present"
|
||||
[[ -f "${FRIDA_SCRIPTS_DIR}/config.js" ]] || \
|
||||
die "Frida SSL scripts not found at ${FRIDA_SCRIPTS_DIR}. Run setup-mitm-avd.sh first."
|
||||
|
||||
# Patch config.js with current MAC IP and mitmproxy cert
|
||||
CERT_PEM=$(openssl x509 -in ~/.mitmproxy/mitmproxy-ca-cert.pem)
|
||||
FRIDA_SCRIPTS_DIR="${FRIDA_SCRIPTS_DIR}" MAC_IP="${MAC_IP}" \
|
||||
PROXY_PORT="${PROXY_PORT}" CERT_PEM="${CERT_PEM}" \
|
||||
python3 - <<'PYEOF'
|
||||
import re, pathlib, os
|
||||
cfg_path = pathlib.Path(os.environ["FRIDA_SCRIPTS_DIR"] + "/config.js")
|
||||
cfg = cfg_path.read_text()
|
||||
cfg = re.sub(r"const PROXY_HOST = '[^']*'", f"const PROXY_HOST = '{os.environ['MAC_IP']}'", cfg)
|
||||
cfg = re.sub(r"const PROXY_PORT = [0-9]+", f"const PROXY_PORT = {os.environ['PROXY_PORT']}", cfg)
|
||||
cfg = re.sub(r"const CERT_PEM = `[^`]*`", f"const CERT_PEM = `{os.environ['CERT_PEM']}`", cfg)
|
||||
cfg_path.write_text(cfg)
|
||||
print(f" ✓ config.js updated (proxy={os.environ['MAC_IP']}:{os.environ['PROXY_PORT']})")
|
||||
PYEOF
|
||||
|
||||
# Ensure frida Python package matches server version
|
||||
if [[ ! -d "${FRIDA_VENV}" ]]; then
|
||||
info "Creating frida venv at ${FRIDA_VENV}..."
|
||||
python3 -m venv "${FRIDA_VENV}"
|
||||
"${FRIDA_VENV}/bin/pip" install -q "frida==${FRIDA_VERSION}" "frida-tools==${FRIDA_TOOLS_VERSION}"
|
||||
fi
|
||||
|
||||
ok "Frida scripts ready at ${FRIDA_SCRIPTS_DIR}"
|
||||
|
||||
# ── Instructions ──────────────────────────────────────────────────────────────
|
||||
CAPTURES_DIR="${ANDROID_DIR}/captures"
|
||||
mkdir -p "${CAPTURES_DIR}"
|
||||
|
||||
cat <<INSTRUCTIONS
|
||||
|
||||
Session ready. In a separate terminal:
|
||||
|
||||
1. Start mitmweb (native macOS app):
|
||||
CAPTURE="bose-pairing-\$(date +%Y%m%d-%H%M%S).mitm"
|
||||
${MITMPROXY_NATIVE} \\
|
||||
--web-host 0.0.0.0 --listen-port ${PROXY_PORT} --mode regular \\
|
||||
--set web_password=bose \\
|
||||
-w "${CAPTURES_DIR}/\${CAPTURE}"
|
||||
Captures → ${CAPTURES_DIR}/
|
||||
Web UI → http://127.0.0.1:8081/?token=bose
|
||||
Note: Docker mitmproxy does not work — its NAT layer blocks emulator traffic.
|
||||
|
||||
2. Launch Bose app with SSL unpinning:
|
||||
${FRIDA_VENV}/bin/frida \\
|
||||
-U \\
|
||||
-f com.bose.soundtouch \\
|
||||
-l ${FRIDA_SCRIPTS_DIR}/config.js \\
|
||||
-l ${FRIDA_SCRIPTS_DIR}/native-connect-hook.js \\
|
||||
-l ${FRIDA_SCRIPTS_DIR}/android/android-system-certificate-injection.js \\
|
||||
-l ${FRIDA_SCRIPTS_DIR}/android/android-proxy-override.js \\
|
||||
-l ${FRIDA_SCRIPTS_DIR}/android/android-certificate-unpinning.js \\
|
||||
-l ${FRIDA_SCRIPTS_DIR}/android/android-certificate-unpinning-fallback.js
|
||||
|
||||
3. Operate the Bose app in the emulator.
|
||||
|
||||
To dump the current UI for adb automation:
|
||||
adb -s ${EMULATOR_SERIAL} shell uiautomator dump /sdcard/ui.xml
|
||||
adb -s ${EMULATOR_SERIAL} pull /sdcard/ui.xml /tmp/ui.xml
|
||||
|
||||
To take a screenshot:
|
||||
adb -s ${EMULATOR_SERIAL} shell screencap /sdcard/screen.png
|
||||
adb -s ${EMULATOR_SERIAL} pull /sdcard/screen.png /tmp/screen.png && open /tmp/screen.png
|
||||
|
||||
INSTRUCTIONS
|
||||
@@ -0,0 +1,119 @@
|
||||
from mitmproxy import http
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
def load(loader):
|
||||
loader.add_option(
|
||||
name="out_dir",
|
||||
typespec=str,
|
||||
default="_/mitm",
|
||||
help="Output directory",
|
||||
)
|
||||
|
||||
def request(flow: http.HTTPFlow):
|
||||
# This is called when a request is received, but in replay mode we process it in 'response' or 'done'
|
||||
pass
|
||||
|
||||
def response(flow: http.HTTPFlow):
|
||||
# This is called when a response is received
|
||||
process_flow(flow)
|
||||
|
||||
def error(flow: http.HTTPFlow):
|
||||
# This is called when an error occurs
|
||||
process_flow(flow)
|
||||
|
||||
def process_flow(flow: http.HTTPFlow):
|
||||
from mitmproxy import ctx
|
||||
out_dir = ctx.options.out_dir
|
||||
|
||||
# Sequence number is not easily available, but we can use a global counter
|
||||
if not hasattr(ctx, "seq"):
|
||||
ctx.seq = 0
|
||||
ctx.seq += 1
|
||||
seq = ctx.seq
|
||||
|
||||
req = flow.request
|
||||
resp = flow.response
|
||||
|
||||
# Normalize path for directory
|
||||
path = req.path
|
||||
if '?' in path:
|
||||
path = path.split('?')[0]
|
||||
|
||||
dir_path = path
|
||||
# Apply replacements as seen in other scripts
|
||||
dir_path = dir_path.replace("9569497", "{accountId}")
|
||||
dir_path = dir_path.replace("A81B6A536A98", "{device_id}")
|
||||
|
||||
# Ensure dir_path doesn't have double slashes and is relative
|
||||
dir_path = dir_path.lstrip('/')
|
||||
|
||||
full_out_dir = os.path.join(out_dir, "mirror", dir_path)
|
||||
os.makedirs(full_out_dir, exist_ok=True)
|
||||
|
||||
# Use timestamp from flow
|
||||
dt = datetime.fromtimestamp(flow.timestamp_start)
|
||||
timestamp = dt.strftime("%Y%m%d-%H%M%S.%f")[:-3]
|
||||
|
||||
method = req.method
|
||||
filename = f"{seq:04d}-{timestamp}-{method}.http"
|
||||
file_path = os.path.join(full_out_dir, filename)
|
||||
|
||||
with open(file_path, "wb") as f:
|
||||
# Request meta
|
||||
f.write(f"### {method} {req.url}\n".encode())
|
||||
f.write(f"{method} {req.path}\n".encode())
|
||||
f.write(f"Host: {req.host}\n".encode())
|
||||
for k, v in req.headers.items():
|
||||
f.write(f"{k}: {v}\n".encode())
|
||||
f.write(b"\n")
|
||||
if req.content:
|
||||
f.write(req.content)
|
||||
f.write(b"\n\n")
|
||||
|
||||
# Response body (optional, but let's include it if it's small or expected)
|
||||
# Check if we should save the response body to a separate file
|
||||
if resp and resp.content:
|
||||
body_filename = filename.replace(".http", ".xml" if "xml" in resp.headers.get("content-type", "") else ".body")
|
||||
body_path = os.path.join(full_out_dir, body_filename)
|
||||
with open(body_path, "wb") as bf:
|
||||
bf.write(resp.content)
|
||||
|
||||
# Response meta
|
||||
f.write(b"> {%\n")
|
||||
if resp:
|
||||
f.write(f" // Response: {resp.status_code} {resp.reason}\n".encode())
|
||||
f.write(b" //\n")
|
||||
f.write(b" // Headers:\n")
|
||||
for k, v in resp.headers.items():
|
||||
f.write(f" // {k}: {v}\n".encode())
|
||||
else:
|
||||
f.write(b" // No response\n")
|
||||
f.write(b"%}\n")
|
||||
|
||||
# print(f"Generated {file_path}")
|
||||
|
||||
# WebSocket messages
|
||||
if flow.websocket:
|
||||
ws_dir = os.path.join(full_out_dir, f"{seq:04d}-websocket")
|
||||
os.makedirs(ws_dir, exist_ok=True)
|
||||
for i, msg in enumerate(flow.websocket.messages):
|
||||
direction = "client" if msg.from_client else "server"
|
||||
content = msg.content
|
||||
# Apply replacements to content as well if it's text
|
||||
if msg.type == 1: # Text
|
||||
content_str = content.decode('utf-8', errors='ignore')
|
||||
content_str = content_str.replace("9569497", "{accountId}")
|
||||
content_str = content_str.replace("A81B6A536A98", "{device_id}")
|
||||
content = content_str.encode('utf-8')
|
||||
|
||||
msg_filename = f"{i:04d}-{direction}.{'bin' if msg.type == 2 else 'txt'}"
|
||||
msg_path = os.path.join(ws_dir, msg_filename)
|
||||
with open(msg_path, "wb") as mf:
|
||||
mf.write(content)
|
||||
|
||||
# Write a small metadata file for the message
|
||||
with open(msg_path + ".meta", "w") as mmf:
|
||||
mmf.write(f"type: {msg.type}\n")
|
||||
mmf.write(f"direction: {direction}\n")
|
||||
mmf.write(f"timestamp: {msg.timestamp}\n")
|
||||