mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e52d17290c |
@@ -1,5 +0,0 @@
|
||||
# Files intentionally not linked in docs/SUMMARY.md.
|
||||
# Paths are relative to the docs/ directory.
|
||||
# Lines starting with # and blank lines are ignored.
|
||||
|
||||
#analysis/bose-soundtouch-community-tools.md
|
||||
+8
-27
@@ -3,25 +3,6 @@
|
||||
|
||||
# Docker/Service Settings
|
||||
SOUNDTOUCH_HOSTNAME=soundtouch.local
|
||||
SOUNDTOUCH_VERSION=latest
|
||||
|
||||
# Stockholm frontend (used by make prepare-stockholm and by the Go service at startup)
|
||||
# BACKEND_URL is the base URL your speakers and browser can reach the service at.
|
||||
# Corresponds to SERVER_URL in the Go service.
|
||||
# BACKEND_URL=http://soundtouch.local:8000
|
||||
#
|
||||
# STREAMING_URL is used for streaming.bose.com rewrites (defaults to BACKEND_URL).
|
||||
# Set to $(BACKEND_URL)/marge only when routing through a soundcork backend.
|
||||
# STREAMING_URL=http://soundtouch.local:8000
|
||||
#
|
||||
# AUTH_SERVICE_URL is written into config.json as the auth endpoint (defaults to BACKEND_URL).
|
||||
# A trailing slash is added automatically; the JS appends paths like "oauth/account/..." directly.
|
||||
# AUTH_SERVICE_URL=http://soundtouch.local:8000
|
||||
#
|
||||
# STOCKHOLM_BASE_PATH mounts the Stockholm UI under a URL prefix, freeing / for the management UI.
|
||||
# The bridge API (/api/native/*, /api/http-proxy) remains at root regardless of this setting.
|
||||
# Defaults to /stockholm. Set to empty to serve at root.
|
||||
# STOCKHOLM_BASE_PATH=/stockholm
|
||||
|
||||
# Discovery Settings
|
||||
DISCOVERY_TIMEOUT=5s
|
||||
@@ -43,23 +24,23 @@ CACHE_TTL=30s
|
||||
|
||||
# Examples:
|
||||
# Single device with default port:
|
||||
# PREFERRED_DEVICES="192.0.2.100"
|
||||
# PREFERRED_DEVICES="192.168.1.100"
|
||||
|
||||
# Single device with custom name:
|
||||
# PREFERRED_DEVICES="Living Room@192.0.2.100"
|
||||
# PREFERRED_DEVICES="Living Room@192.168.1.100"
|
||||
|
||||
# Single device with custom port:
|
||||
# PREFERRED_DEVICES="192.0.2.100:8091"
|
||||
# PREFERRED_DEVICES="192.168.1.100:8091"
|
||||
|
||||
# Multiple devices with mixed configurations:
|
||||
PREFERRED_DEVICES="Living Room@192.0.2.100:8090;Kitchen@192.0.2.101;192.0.2.102:8091"
|
||||
PREFERRED_DEVICES="Living Room@192.168.1.100:8090;Kitchen@192.168.1.101;192.168.1.102:8091"
|
||||
|
||||
# Example — replace with your speakers' names and IPs:
|
||||
# PREFERRED_DEVICES="Living Room SoundTouch@192.0.2.10;Kitchen SoundTouch@192.0.2.11"
|
||||
# Real example based on your devices:
|
||||
# PREFERRED_DEVICES="Sound Machinechen@192.168.178.35;A Sound Machine@192.168.178.28"
|
||||
|
||||
# Alternative format examples:
|
||||
# PREFERRED_DEVICES="192.0.2.10;192.0.2.11"
|
||||
# PREFERRED_DEVICES="SoundTouch 10@192.0.2.10;SoundTouch 20@192.0.2.11"
|
||||
# PREFERRED_DEVICES="192.168.178.35;192.168.178.28"
|
||||
# PREFERRED_DEVICES="SoundTouch 10@192.168.178.35;SoundTouch 20@192.168.178.28"
|
||||
|
||||
# Spotify Integration
|
||||
# Create an app at https://developer.spotify.com/dashboard
|
||||
|
||||
@@ -30,7 +30,7 @@ A clear and concise description of what you expected to happen.
|
||||
**Command/Code that failed**
|
||||
```bash
|
||||
# If using CLI tool, provide the exact command
|
||||
soundtouch-cli --host 192.0.2.100 info get
|
||||
soundtouch-cli --host 192.168.1.100 info get
|
||||
|
||||
# If using Go library, provide minimal code example
|
||||
```
|
||||
|
||||
@@ -163,7 +163,7 @@ body:
|
||||
label: Network Configuration
|
||||
description: Details about your network setup (if relevant to the issue)
|
||||
placeholder: |
|
||||
- Device IP: 192.0.2.100
|
||||
- Device IP: 192.168.1.100
|
||||
- Network type: WiFi/Ethernet
|
||||
- Router model:
|
||||
- Any firewalls or network restrictions:
|
||||
|
||||
@@ -69,8 +69,8 @@ List any features that don't work or behave unexpectedly:
|
||||
**Testing Commands Used**
|
||||
```bash
|
||||
# List the specific commands you used for testing
|
||||
soundtouch-cli --host 192.0.2.100 info get
|
||||
soundtouch-cli --host 192.0.2.100 play start
|
||||
soundtouch-cli --host 192.168.1.100 info get
|
||||
soundtouch-cli --host 192.168.1.100 play start
|
||||
# ... etc
|
||||
```
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ client.NewFeature(parameters)
|
||||
|
||||
```bash
|
||||
# CLI example
|
||||
soundtouch-cli --host 192.0.2.100 new-feature --param value
|
||||
soundtouch-cli --host 192.168.1.100 new-feature --param value
|
||||
```
|
||||
|
||||
**Priority**
|
||||
|
||||
@@ -131,7 +131,7 @@ body:
|
||||
render: go
|
||||
placeholder: |
|
||||
// Example of how you envision using this feature
|
||||
client := soundtouch.New("192.0.2.100", 8090)
|
||||
client := soundtouch.New("192.168.1.100", 8090)
|
||||
|
||||
// Your desired API call
|
||||
result, err := client.NewFeature(options)
|
||||
|
||||
@@ -98,25 +98,3 @@ updates:
|
||||
- "dependencies"
|
||||
- "docker"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
# npm dependency updates
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "thursday"
|
||||
time: "09:00"
|
||||
timezone: "UTC"
|
||||
open-pull-requests-limit: 3
|
||||
reviewers:
|
||||
- "gesellix"
|
||||
assignees:
|
||||
- "gesellix"
|
||||
commit-message:
|
||||
prefix: "deps"
|
||||
include: "scope"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "npm"
|
||||
- "frontend"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"retryOn429": true,
|
||||
"retryCount": 3,
|
||||
"fallbackRetryDelay": "30s",
|
||||
"aliveStatusCodes": [200, 202, 206],
|
||||
"aliveStatusCodes": [200, 206],
|
||||
"ignorePatterns": [
|
||||
{
|
||||
"pattern": "^http://localhost"
|
||||
@@ -28,21 +28,6 @@
|
||||
},
|
||||
{
|
||||
"pattern": "^\\.\\./images/(dashboard-home|account-creation|account-dashboard|usb-remote-services|device-discovery|device-registration|account-migration|migration-setup|migration-progress|migration-health|migration-complete|backup-setup)\\.png$"
|
||||
},
|
||||
{
|
||||
"pattern": "https://www.contributor-covenant.org/version/2/0/code_of_conduct.html"
|
||||
},
|
||||
{
|
||||
"pattern": "https://www.apkmirror.com/apk/bose-corporation/bose-soundtouch/"
|
||||
},
|
||||
{
|
||||
"pattern": "https://apkpure.com/bose-soundtouch/com.bose.soundtouch"
|
||||
},
|
||||
{
|
||||
"pattern": "^https://bose\\.fandom\\.com/"
|
||||
},
|
||||
{
|
||||
"pattern": "^https://www\\.reddit\\.com/"
|
||||
}
|
||||
],
|
||||
"replacementPatterns": [
|
||||
|
||||
@@ -50,7 +50,7 @@ Please check the type of change your PR introduces:
|
||||
|
||||
**Device(s) tested with:**
|
||||
- Device model: [e.g. SoundTouch 10]
|
||||
- Device IP: [e.g. 192.0.2.100]
|
||||
- Device IP: [e.g. 192.168.1.100]
|
||||
- Test results: [brief description]
|
||||
|
||||
### Test Commands
|
||||
@@ -58,7 +58,7 @@ Please check the type of change your PR introduces:
|
||||
# Commands used to test this change
|
||||
make test
|
||||
go test ./pkg/client -v -run TestNewFeature
|
||||
soundtouch-cli --host 192.0.2.100 new-command
|
||||
soundtouch-cli --host 192.168.1.100 new-command
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
+48
-175
@@ -17,15 +17,15 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
@@ -34,9 +34,6 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Install libpcap
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
@@ -46,14 +43,8 @@ jobs:
|
||||
- name: Run tests
|
||||
run: go test -v -race -coverprofile=coverage.out ./...
|
||||
|
||||
- name: Build service
|
||||
run: make build-service
|
||||
|
||||
- name: Run HTTP client integration tests
|
||||
run: make test-http-client
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
file: ./coverage.out
|
||||
flags: unittests
|
||||
@@ -66,18 +57,15 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Install libpcap
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Run golangci-lint
|
||||
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: latest
|
||||
args: --timeout=5m
|
||||
@@ -86,76 +74,39 @@ jobs:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
- goos: linux
|
||||
goarch: arm
|
||||
goarm: 7
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
goos: [linux, darwin, windows]
|
||||
goarch: [amd64, arm64]
|
||||
exclude:
|
||||
# Windows ARM64 builds are experimental
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
- goos: freebsd
|
||||
goarch: amd64
|
||||
goarch: arm64
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.mod') }}-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Build binaries
|
||||
- name: Build CLI
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
GOARM: ${{ matrix.goarm }}
|
||||
CGO_ENABLED: 0
|
||||
run: |
|
||||
ARCH_SUFFIX="${{ matrix.goos }}-${{ matrix.goarch }}"
|
||||
if [[ -n "${{ matrix.goarm }}" ]]; then
|
||||
ARCH_SUFFIX="${ARCH_SUFFIX}v${{ matrix.goarm }}"
|
||||
output_name="soundtouch-cli-${{ matrix.goos }}-${{ matrix.goarch }}"
|
||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||
output_name="${output_name}.exe"
|
||||
fi
|
||||
|
||||
EXT=""
|
||||
if [[ "${{ matrix.goos }}" == "windows" ]]; then
|
||||
EXT=".exe"
|
||||
fi
|
||||
|
||||
mkdir -p build
|
||||
|
||||
for binary in soundtouch-cli soundtouch-service soundtouch-web soundtouch-backup; do
|
||||
OUTPUT="build/${binary}-${ARCH_SUFFIX}${EXT}"
|
||||
echo "Building $OUTPUT"
|
||||
go build -trimpath -ldflags="-s -w" -o "$OUTPUT" "./cmd/$binary"
|
||||
done
|
||||
|
||||
ls -la build/
|
||||
go build -o "$output_name" ./cmd/soundtouch-cli
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm }}
|
||||
path: build/
|
||||
name: soundtouch-cli-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: soundtouch-cli-*
|
||||
|
||||
security:
|
||||
name: Basic Security Check
|
||||
@@ -163,16 +114,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Install libpcap
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Run basic vulnerability check
|
||||
run: |
|
||||
go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
@@ -190,12 +138,14 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Check documentation links
|
||||
run: |
|
||||
npm install -g markdown-link-check
|
||||
find . -name "*.md" -not -path "./tests/*" -not -path "./node_modules/*" -print0 | xargs -0 -n1 markdown-link-check -q -v -c .github/markdown-link-check.json
|
||||
uses: gaurav-nelson/github-action-markdown-link-check@v1
|
||||
with:
|
||||
use-quiet-mode: "yes"
|
||||
use-verbose-mode: "yes"
|
||||
config-file: ".github/markdown-link-check.json"
|
||||
|
||||
- name: Warn on pending images
|
||||
run: |
|
||||
@@ -248,16 +198,16 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Test CLI build and help
|
||||
run: |
|
||||
go build -trimpath -ldflags="-s -w" -o soundtouch-cli ./cmd/soundtouch-cli
|
||||
go build -o soundtouch-cli ./cmd/soundtouch-cli
|
||||
./soundtouch-cli -help
|
||||
|
||||
- name: Test library imports
|
||||
@@ -275,7 +225,7 @@ jobs:
|
||||
|
||||
func main() {
|
||||
// Test basic client creation
|
||||
c := client.NewClientFromHost("192.0.2.100")
|
||||
c := client.NewClientFromHost("192.168.1.100")
|
||||
fmt.Printf("Client created for %s\n", c.BaseURL())
|
||||
|
||||
// Test models can be imported
|
||||
@@ -303,116 +253,39 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Determine push eligibility
|
||||
id: push-check
|
||||
run: |
|
||||
# Push on main, and on same-repo PRs (forks can't push to GHCR via GITHUB_TOKEN).
|
||||
SHOULD_PUSH="false"
|
||||
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/main" ]]; then
|
||||
SHOULD_PUSH="true"
|
||||
elif [[ "${{ github.event_name }}" == "pull_request" && \
|
||||
"${{ github.event.pull_request.head.repo.full_name }}" == "${{ github.repository }}" ]]; then
|
||||
SHOULD_PUSH="true"
|
||||
fi
|
||||
echo "should-push=$SHOULD_PUSH" >> "$GITHUB_OUTPUT"
|
||||
echo "Will push: $SHOULD_PUSH"
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: steps.push-check.outputs.should-push == 'true'
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for soundtouch-service
|
||||
id: meta-service
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=ref,event=pr,prefix=preview-pr-
|
||||
type=sha,prefix=preview-sha-,format=short,enable=${{ github.event_name == 'pull_request' }}
|
||||
type=ref,event=branch,prefix=preview-branch-,enable=${{ github.event_name == 'push' && github.ref != 'refs/heads/main' }}
|
||||
type=ref,event=pr
|
||||
|
||||
- name: Build and push soundtouch-service Docker image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-service
|
||||
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
|
||||
push: ${{ steps.push-check.outputs.should-push == 'true' }}
|
||||
tags: ${{ steps.meta-service.outputs.tags }}
|
||||
labels: ${{ steps.meta-service.outputs.labels }}
|
||||
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Extract metadata (tags, labels) for soundtouch-web
|
||||
id: meta-web
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}-web
|
||||
tags: |
|
||||
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=ref,event=pr,prefix=preview-pr-
|
||||
type=sha,prefix=preview-sha-,format=short,enable=${{ github.event_name == 'pull_request' }}
|
||||
type=ref,event=branch,prefix=preview-branch-,enable=${{ github.event_name == 'push' && github.ref != 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push soundtouch-web Docker image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-web
|
||||
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
|
||||
push: ${{ steps.push-check.outputs.should-push == 'true' }}
|
||||
tags: ${{ steps.meta-web.outputs.tags }}
|
||||
labels: ${{ steps.meta-web.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Summarize published images
|
||||
if: steps.push-check.outputs.should-push == 'true'
|
||||
env:
|
||||
SERVICE_TAGS: ${{ steps.meta-service.outputs.tags }}
|
||||
WEB_TAGS: ${{ steps.meta-web.outputs.tags }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
{
|
||||
echo "## 🐳 Published Docker Images"
|
||||
echo ""
|
||||
if [[ "$EVENT_NAME" == "pull_request" ]]; then
|
||||
echo "**Preview** images for PR #${PR_NUMBER}. These are not release builds."
|
||||
elif [[ "$REF_NAME" == "main" ]]; then
|
||||
echo "**Edge** images from \`main\`."
|
||||
else
|
||||
echo "**Preview** images from branch \`${REF_NAME}\`. These are not release builds."
|
||||
fi
|
||||
echo ""
|
||||
echo "### soundtouch-service"
|
||||
echo ""
|
||||
echo '```bash'
|
||||
while IFS= read -r tag; do
|
||||
[[ -n "$tag" ]] && echo "docker pull $tag"
|
||||
done <<< "$SERVICE_TAGS"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### soundtouch-web"
|
||||
echo ""
|
||||
echo '```bash'
|
||||
while IFS= read -r tag; do
|
||||
[[ -n "$tag" ]] && echo "docker pull $tag"
|
||||
done <<< "$WEB_TAGS"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
notify:
|
||||
name: Notify Status
|
||||
runs-on: ubuntu-latest
|
||||
@@ -447,7 +320,7 @@ jobs:
|
||||
|
||||
- name: Update commit status
|
||||
if: always()
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
try {
|
||||
|
||||
@@ -20,18 +20,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v6
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
||||
uses: actions/configure-pages@v5
|
||||
- name: Build with Jekyll
|
||||
uses: actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697 # v1.0.13
|
||||
uses: actions/jekyll-build-pages@v1
|
||||
with:
|
||||
source: 'docs/'
|
||||
destination: '_site'
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: '_site'
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
|
||||
uses: actions/deploy-pages@v4
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -64,13 +64,10 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: ${{ env.GO_VERSION_FILE }}
|
||||
|
||||
- name: Install libpcap
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Run tests before release
|
||||
run: |
|
||||
echo "Running final tests before release..."
|
||||
@@ -102,15 +99,15 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: ${{ env.GO_VERSION_FILE }}
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
@@ -151,7 +148,6 @@ jobs:
|
||||
rm -f "$OUTPUT_NAME" "$OUTPUT_NAME.sha256" "$OUTPUT_NAME.sha512"
|
||||
|
||||
if ! go build \
|
||||
-trimpath \
|
||||
-ldflags="-s -w" \
|
||||
-o "$OUTPUT_NAME" \
|
||||
"$CMD_PATH"; then
|
||||
@@ -169,20 +165,12 @@ jobs:
|
||||
|
||||
# Build Service
|
||||
build_binary "soundtouch-service" "./cmd/soundtouch-service"
|
||||
|
||||
# Build Web
|
||||
build_binary "soundtouch-web" "./cmd/soundtouch-web"
|
||||
|
||||
# Build Backup
|
||||
build_binary "soundtouch-backup" "./cmd/soundtouch-backup"
|
||||
id: build
|
||||
|
||||
- name: Generate individual checksums
|
||||
run: |
|
||||
CLI_NAME="${{ steps.build.outputs.soundtouch-cli }}"
|
||||
SVC_NAME="${{ steps.build.outputs.soundtouch-service }}"
|
||||
WEB_NAME="${{ steps.build.outputs.soundtouch-web }}"
|
||||
BCK_NAME="${{ steps.build.outputs.soundtouch-backup }}"
|
||||
|
||||
# Use atomic operations to avoid conflicts
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
@@ -198,22 +186,18 @@ jobs:
|
||||
|
||||
generate_checksums "$CLI_NAME"
|
||||
generate_checksums "$SVC_NAME"
|
||||
generate_checksums "$WEB_NAME"
|
||||
generate_checksums "$BCK_NAME"
|
||||
|
||||
# Cleanup
|
||||
rm -rf "$TEMP_DIR"
|
||||
echo "✅ Checksums generated successfully"
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm }}
|
||||
path: |
|
||||
build/soundtouch-cli-v*
|
||||
build/soundtouch-service-v*
|
||||
build/soundtouch-web-v*
|
||||
build/soundtouch-backup-v*
|
||||
retention-days: 1
|
||||
|
||||
checksums:
|
||||
@@ -223,7 +207,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download binary artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: binaries-*
|
||||
path: ./binaries
|
||||
@@ -240,7 +224,7 @@ jobs:
|
||||
mkdir -p release-files
|
||||
|
||||
# Move all files from subdirectories to the collection directory
|
||||
find . -mindepth 2 -type f \( -name "soundtouch-cli-*" -o -name "soundtouch-service-*" -o -name "soundtouch-web-*" -o -name "soundtouch-backup-*" \) -exec mv {} release-files/ \;
|
||||
find . -mindepth 2 -type f \( -name "soundtouch-cli-*" -o -name "soundtouch-service-*" \) -exec mv {} release-files/ \;
|
||||
|
||||
# Remove empty directories
|
||||
find . -type d -empty -delete
|
||||
@@ -255,14 +239,14 @@ jobs:
|
||||
# Generate combined checksums (exclude individual .sha256/.sha512 files)
|
||||
if ls soundtouch-* 1> /dev/null 2>&1; then
|
||||
# Only checksum the actual binaries, not the .sha256/.sha512 files
|
||||
ls soundtouch-cli-* soundtouch-service-* soundtouch-web-* soundtouch-backup-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha256sum > checksums.sha256
|
||||
ls soundtouch-cli-* soundtouch-service-* soundtouch-web-* soundtouch-backup-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha512sum > checksums.sha512
|
||||
ls soundtouch-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha256sum > checksums.sha256
|
||||
ls soundtouch-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha512sum > checksums.sha512
|
||||
|
||||
echo "📋 Generated combined checksums:"
|
||||
cat checksums.sha256
|
||||
|
||||
# Verify all expected files are present (binaries only, not checksum files)
|
||||
EXPECTED_COUNT=28 # 7 platforms * 4 binaries
|
||||
EXPECTED_COUNT=14 # 7 platforms * 2 binaries
|
||||
ACTUAL_COUNT=$(ls soundtouch-* | grep -v '\.sha256$' | grep -v '\.sha512$' | wc -l)
|
||||
|
||||
if [[ $ACTUAL_COUNT -ne $EXPECTED_COUNT ]]; then
|
||||
@@ -280,7 +264,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload checksums
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: checksums
|
||||
path: |
|
||||
@@ -291,7 +275,7 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload all release assets
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-assets
|
||||
path: binaries/release-files/
|
||||
@@ -305,12 +289,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download release assets
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: release-assets
|
||||
path: ./release-assets
|
||||
@@ -358,7 +342,7 @@ jobs:
|
||||
|
||||
func main() {
|
||||
// Create client
|
||||
c := client.New("192.0.2.100", 8090)
|
||||
c := client.New("192.168.1.100", 8090)
|
||||
|
||||
// Get device info
|
||||
info, err := c.GetInfo()
|
||||
@@ -393,18 +377,6 @@ jobs:
|
||||
./soundtouch-service
|
||||
\`\`\`
|
||||
|
||||
### SoundTouch Web
|
||||
\`\`\`bash
|
||||
# Start the web app
|
||||
./soundtouch-web
|
||||
\`\`\`
|
||||
|
||||
### SoundTouch Backup
|
||||
\`\`\`bash
|
||||
# Back up cloud account and all paired speakers in one go
|
||||
./soundtouch-backup all
|
||||
\`\`\`
|
||||
|
||||
## 🧪 Tested Hardware
|
||||
|
||||
- Bose SoundTouch 10
|
||||
@@ -423,7 +395,7 @@ jobs:
|
||||
- Windows (amd64)
|
||||
- FreeBSD (amd64)
|
||||
|
||||
`soundtouch-cli`, `soundtouch-service`, `soundtouch-web`, and `soundtouch-backup` are included.
|
||||
Both `soundtouch-cli` and `soundtouch-service` are included.
|
||||
|
||||
## 🔐 Checksums
|
||||
|
||||
@@ -468,7 +440,7 @@ jobs:
|
||||
echo "release_notes_file=release_notes.md" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.event.inputs.tag }}
|
||||
name: "Bose SoundTouch Go Library ${{ github.event.inputs.tag }}"
|
||||
@@ -478,8 +450,6 @@ jobs:
|
||||
files: |
|
||||
release-assets/soundtouch-cli-v*
|
||||
release-assets/soundtouch-service-v*
|
||||
release-assets/soundtouch-web-v*
|
||||
release-assets/soundtouch-backup-v*
|
||||
release-assets/checksums.sha256
|
||||
release-assets/checksums.sha512
|
||||
fail_on_unmatched_files: true
|
||||
@@ -494,20 +464,18 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download release assets
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: release-assets
|
||||
path: ./release-assets
|
||||
|
||||
- name: Upload additional assets to existing release
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.event.release.tag_name }}
|
||||
files: |
|
||||
release-assets/soundtouch-cli-v*
|
||||
release-assets/soundtouch-service-v*
|
||||
release-assets/soundtouch-web-v*
|
||||
release-assets/soundtouch-backup-v*
|
||||
release-assets/checksums.sha256
|
||||
release-assets/checksums.sha512
|
||||
fail_on_unmatched_files: true
|
||||
@@ -521,21 +489,21 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for soundtouch-service
|
||||
id: meta-service
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
@@ -543,37 +511,14 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}},value=v${{ needs.validate.outputs.version }}
|
||||
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
|
||||
|
||||
- name: Build and push soundtouch-service Docker image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-service
|
||||
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
|
||||
push: true
|
||||
tags: ${{ steps.meta-service.outputs.tags }}
|
||||
labels: ${{ steps.meta-service.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Extract metadata (tags, labels) for soundtouch-web
|
||||
id: meta-web
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}-web
|
||||
tags: |
|
||||
type=semver,pattern={{version}},value=v${{ needs.validate.outputs.version }}
|
||||
type=semver,pattern={{major}}.{{minor}},value=v${{ needs.validate.outputs.version }}
|
||||
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
|
||||
|
||||
- name: Build and push soundtouch-web Docker image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-web
|
||||
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
|
||||
push: true
|
||||
tags: ${{ steps.meta-web.outputs.tags }}
|
||||
labels: ${{ steps.meta-web.outputs.labels }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -587,7 +532,7 @@ jobs:
|
||||
- name: Notify success
|
||||
run: |
|
||||
echo "🎉 Release ${{ needs.validate.outputs.version }} completed successfully!"
|
||||
echo "📦 Binaries built for 7 platforms (CLI, Service, Web, and Backup)"
|
||||
echo "📦 Binaries built for 7 platforms (CLI and Service)"
|
||||
echo "🐳 Docker image published to ghcr.io"
|
||||
echo "🔐 Checksums generated and verified"
|
||||
echo "📋 Release notes automatically generated"
|
||||
|
||||
@@ -19,16 +19,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Install libpcap
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Install security scanning tools
|
||||
run: |
|
||||
go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
@@ -48,7 +45,7 @@ jobs:
|
||||
|
||||
- name: Upload vulnerability scan results
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: vulnerability-scan-results
|
||||
path: |
|
||||
@@ -63,16 +60,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Install libpcap
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Install static analysis tools
|
||||
run: |
|
||||
go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||
@@ -84,7 +78,7 @@ jobs:
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Run Semgrep security analysis
|
||||
uses: semgrep/semgrep-action@713efdd345f3035192eaa63f56867b88e63e4e5d # v1 (no v1.x.y semver tag exists)
|
||||
uses: semgrep/semgrep-action@v1
|
||||
with:
|
||||
config: >-
|
||||
p/security-audit
|
||||
@@ -95,7 +89,7 @@ jobs:
|
||||
|
||||
- name: Upload Semgrep SARIF results
|
||||
if: always()
|
||||
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: semgrep.sarif
|
||||
continue-on-error: true
|
||||
@@ -110,22 +104,19 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install libpcap
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: go
|
||||
config-file: ./.github/codeql-config.yml
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:go"
|
||||
|
||||
@@ -138,10 +129,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Dependency Review
|
||||
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
|
||||
uses: actions/dependency-review-action@v4
|
||||
with:
|
||||
fail-on-severity: moderate
|
||||
allow-ghsas: GHSA-xxxx-xxxx-xxxx # Add specific allowlisted advisories if needed
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
name: Update Static Dependencies
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update-deps:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.actor == 'dependabot[bot]' || github.event_name == 'workflow_dispatch'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Update static dependencies
|
||||
run: make update-static-deps
|
||||
|
||||
- name: Check for changes
|
||||
id: git-check
|
||||
run: |
|
||||
git status --short pkg/service/soundtouchweb/static/lib/
|
||||
if [ -n "$(git status --short pkg/service/soundtouchweb/static/lib/)" ]; then
|
||||
echo "changed=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "changed=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Commit and push changes
|
||||
if: steps.git-check.outputs.changed == 'true'
|
||||
run: |
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git add pkg/service/soundtouchweb/static/lib/
|
||||
git commit -m "chore: sync static dependencies with package.json"
|
||||
git push
|
||||
-37
@@ -12,18 +12,14 @@ dist/
|
||||
#example-upnp
|
||||
|
||||
# Root-level binary executables (exclude built binaries in root)
|
||||
/soundtouch-backup
|
||||
/soundtouch-cli
|
||||
/soundtouch-service
|
||||
/soundtouch-web
|
||||
/dummy-speaker
|
||||
/example-mdns
|
||||
/example-upnp
|
||||
/example-unified
|
||||
/mdns-scanner
|
||||
/websocket-demo
|
||||
/main
|
||||
/screenshots
|
||||
|
||||
# Environment configuration
|
||||
.env
|
||||
@@ -43,13 +39,10 @@ go.work.sum
|
||||
|
||||
# Dependency directories
|
||||
vendor/
|
||||
node_modules/
|
||||
|
||||
# IDE and editor files
|
||||
.vscode/
|
||||
.idea/
|
||||
.claude/
|
||||
.junie/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
@@ -63,15 +56,6 @@ node_modules/
|
||||
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
|
||||
@@ -101,24 +85,3 @@ pids
|
||||
|
||||
# dotenv environment variables file (but keep .env.example)
|
||||
!.env.example
|
||||
|
||||
# Stockholm frontend — generated by `make prepare-stockholm`, not committed
|
||||
stockholm/
|
||||
|
||||
!pkg/service/stockholm/
|
||||
|
||||
# Stockholm source zip — large binary, place manually at stockholm_zip/stockholm.zip
|
||||
stockholm_zip/*.zip
|
||||
|
||||
# Local working-tree notes — running pickup-here log (NEXT) + archive of
|
||||
# resolved items (DONE). Both are session-local scratch, not project docs.
|
||||
NEXT.md
|
||||
DONE.md
|
||||
|
||||
# Plan/tracking note for the Health-tab debug-utility programme.
|
||||
# Living document; commit history of the checks themselves is the
|
||||
# source of truth for what shipped.
|
||||
SERVICE-HEALTH.md
|
||||
|
||||
# Diagnostic encryption keys — private key stays local with the maintainer
|
||||
keys/private/
|
||||
|
||||
@@ -78,13 +78,6 @@ linters:
|
||||
linters:
|
||||
- errcheck
|
||||
|
||||
# Carry-over from cmd/soundtouch-web/handlers relocation: same code,
|
||||
# same waiver. Tighten in a follow-up if/when the package is reviewed.
|
||||
- path: pkg/service/soundtouchweb/.*\.go
|
||||
text: "Error return value of.*is not checked"
|
||||
linters:
|
||||
- errcheck
|
||||
|
||||
settings:
|
||||
errcheck:
|
||||
check-type-assertions: true
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Entry point for any Claude Code (or human) session working on this
|
||||
repository. Read it before touching code.
|
||||
|
||||
## What this project is
|
||||
|
||||
Go library and toolset for controlling Bose SoundTouch speakers via
|
||||
the local network API, plus a local cloud-service emulator. Bose
|
||||
discontinued the SoundTouch cloud — this project keeps existing
|
||||
speakers usable without it.
|
||||
|
||||
**Module:** `github.com/gesellix/bose-soundtouch`
|
||||
|
||||
Key binaries:
|
||||
|
||||
- `soundtouch-cli` — command-line control of one or more speakers
|
||||
(status, play, presets, groups, migration, …).
|
||||
- `soundtouch-service` — replacement for `streaming.bose.com`
|
||||
and the `bmx` services, default port `8000`.
|
||||
- `soundtouch-web` — Web UI for Radio browsing and device control.
|
||||
- `soundtouch-backup` — Helper for on-device backup and restore.
|
||||
|
||||
Per-session pickup notes live in two local files at the repo root (they are `.gitignore`d and only exist if created during a session):
|
||||
|
||||
- `NEXT.md` — current "pick up here" log of open items.
|
||||
- `DONE.md` — archive of recently resolved items.
|
||||
|
||||
## How a new session should start
|
||||
|
||||
1. Read this file.
|
||||
2. Read `NEXT.md` if it's present — that's where running context lives.
|
||||
3. Skim `README.md` for the user-facing pitch.
|
||||
4. Skim `docs/` for the area you're touching. Long-form notes
|
||||
(analysis, guides, troubleshooting) live there, not in the code.
|
||||
5. Run `make check` once to confirm the local environment compiles,
|
||||
vets, and tests cleanly.
|
||||
|
||||
## Build, test, run
|
||||
|
||||
```bash
|
||||
# Build
|
||||
make build # All binaries
|
||||
make build-cli # Just CLI
|
||||
make build-service # Just service
|
||||
make build-web # Just web UI
|
||||
make build-all # Cross-platform builds (Linux, macOS, Windows)
|
||||
make install # Install to $GOPATH/bin
|
||||
|
||||
# Quality
|
||||
make test # Unit tests
|
||||
make test-coverage # Coverage reports
|
||||
make check # fmt + vet + test
|
||||
make lint # golangci-lint
|
||||
make update-static-deps # Update frontend libraries (preact, htm) from node_modules
|
||||
|
||||
# Automation
|
||||
A GitHub Action automatically runs `make update-static-deps` on Dependabot PRs that modify `package.json` to keep the vendored `.js` files in sync. Note: This requires `npm` to be installed.
|
||||
|
||||
# Development
|
||||
make dev-service # Run local service on port 8000
|
||||
make dev-discover # Discover devices on the LAN
|
||||
make dev-info HOST=<ip> # Get device info
|
||||
|
||||
# Docker
|
||||
make docker-build
|
||||
make docker-run-host
|
||||
```
|
||||
|
||||
**Pre-push quality gate:** `make lint` (golangci-lint) must be clean
|
||||
before `git push`. CI runs it on every PR; running it locally first
|
||||
saves a round-trip. `make check` covers `lint` is its own target —
|
||||
combine as needed.
|
||||
|
||||
## Integration tests
|
||||
|
||||
The `.http` integration tests under `tests/integration/http-client/`
|
||||
run via `make test-http-client`, which spins up the service plus
|
||||
support mocks (`spotify-mock`, `amazon-mock`) using
|
||||
`docker-compose.yml` + `docker-compose.ci.yml`, executes the suite
|
||||
through the JetBrains HTTP client image, then tears the stack down.
|
||||
Requires Docker.
|
||||
|
||||
The compose CI override mounts `tests/integration/testdata/` into the
|
||||
service container as its persistent data dir. That directory is
|
||||
listed in `tests/.gitignore` — it's local developer state, not source.
|
||||
|
||||
**Treat the testdata dir as debug evidence, not disposable scratch.**
|
||||
When a fixture or schema change makes the old state stale (e.g.
|
||||
post-anonymisation, the previous run's IPs no longer match the
|
||||
assertions), don't `rm -rf` it — archive it:
|
||||
|
||||
```bash
|
||||
make test-http-client-rotate # renames testdata/ → testdata_<timestamp>/
|
||||
make test-http-client # fresh run on a clean slate
|
||||
```
|
||||
|
||||
The rotate target is non-destructive (it moves, never deletes) and
|
||||
opt-in (no other target invokes it). Old archives stay around for
|
||||
retrospective diffing whenever something goes sideways.
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
cmd/
|
||||
soundtouch-cli/ # CLI tool for device control
|
||||
soundtouch-service/ # Local cloud service emulator
|
||||
soundtouch-web/ # Web UI (TuneIn browser, device control)
|
||||
soundtouch-backup/ # On-device backup helper
|
||||
example-*/ # Usage examples
|
||||
pkg/
|
||||
client/ # HTTP + WebSocket client for the SoundTouch Web API
|
||||
models/ # XML/JSON data structures
|
||||
discovery/ # Device discovery (mDNS + UPnP, unified interface)
|
||||
config/ # Configuration management
|
||||
service/
|
||||
bmx/ # Bose Media eXchange service emulation
|
||||
marge/ # Device-management service emulation
|
||||
handlers/ # HTTP request handlers (pkg/service/handlers/)
|
||||
proxy/ # HTTP proxy with request recording
|
||||
datastore/ # Persistent device data storage
|
||||
certmanager/ # TLS certificate management
|
||||
setup/ # Device migration and configuration
|
||||
spotify/ # Spotify integration
|
||||
stockholm/ # Optional Stockholm frontend bridge
|
||||
soundtouchweb/ # SoundTouch Web UI service logic
|
||||
examples/ # Feature demonstration programs
|
||||
docs/ # Long-form analysis, guides, troubleshooting
|
||||
.junie/ # Communication-style guidelines (see below)
|
||||
```
|
||||
|
||||
## Key technologies
|
||||
|
||||
- **Go 1.26.3+**
|
||||
- **chi v5** — HTTP router
|
||||
- **gorilla/websocket** — WebSocket for real-time events
|
||||
- **hashicorp/mdns** — mDNS device discovery
|
||||
- **miekg/dns** — DNS operations and a custom DNS server
|
||||
- **urfave/cli/v2** — CLI framework
|
||||
|
||||
## Architecture notes
|
||||
|
||||
- `pkg/client` is the core library for device API calls (HTTP + WebSocket).
|
||||
- `pkg/service` is the local cloud replacement; routes wire to the
|
||||
handlers in `pkg/service/handlers/` via chi middleware.
|
||||
- Discovery supports both mDNS and UPnP/SSDP behind a unified interface.
|
||||
- The SoundTouch Web API uses XML on the wire; internal service-to-service
|
||||
messages use JSON.
|
||||
- Tests cover unit, integration, parity (local vs. official Bose API
|
||||
recordings), and regression. Reproducer tests should be refactored
|
||||
into permanent regression or documentation tests rather than deleted.
|
||||
|
||||
## Load-bearing gotchas
|
||||
|
||||
### `ETag` header literal must stay capitalised
|
||||
|
||||
Bose speakers emit the response header with exact capitalisation
|
||||
`ETag`. Go's `http.Header.Set` canonicalises to `Etag` (lowercase `t`).
|
||||
Real speakers parse strictly — `Etag` is rejected. The codebase
|
||||
deliberately bypasses the canonicalisation path; do **not** rewrite
|
||||
the string literal `"ETag"` to `"Etag"` anywhere in `pkg/service/handlers/`
|
||||
or in tests.
|
||||
|
||||
The contrast is encoded in two named constants in
|
||||
`pkg/service/handlers/handlers_etag_test.go`:
|
||||
|
||||
```go
|
||||
const normalizedEtag = "Etag" // what http.Header.Set produces
|
||||
const caseSensitiveETag = "ETag" // what the speaker actually expects
|
||||
```
|
||||
|
||||
Linter suppressions on the canonical-header check live alongside the
|
||||
test code. Static-analysis warnings about `"ETag"` are expected;
|
||||
don't "fix" them.
|
||||
|
||||
### Destructive git or filesystem actions need explicit confirmation
|
||||
|
||||
`git reset --hard`, `git checkout` that would overwrite local changes,
|
||||
`git clean -fd`, `rm -rf` on non-build paths, `git stash drop` — all
|
||||
should be proposed in writing with their consequences before running,
|
||||
unless the user has already authorised that specific action in this
|
||||
session. Prefer reversible alternatives (`git stash` over
|
||||
`git reset --hard`).
|
||||
|
||||
## What never goes into this repo
|
||||
|
||||
This repository is public. The following must never be committed:
|
||||
|
||||
- **Real LAN IPs** of personal networks. Use RFC-5737 documentation
|
||||
ranges in examples and fixtures: `192.0.2.0/24`, `198.51.100.0/24`,
|
||||
`203.0.113.0/24`.
|
||||
- **Real MAC addresses** or speaker device IDs from anyone's actual
|
||||
hardware. Use `AA:BB:CC:DD:EE:FF` or `DEVICEID01` style placeholders.
|
||||
- **Bose account IDs**, serial numbers, or tokens belonging to anyone
|
||||
other than the committer's own test devices — and even those should
|
||||
be sanitised before publication when feasible.
|
||||
- **Bose firmware binaries, NAND dumps, or decompiled Bose code.**
|
||||
- **Wi-Fi SSIDs or credentials**, captured or otherwise.
|
||||
- **Network captures, traces, or logs** that include data from
|
||||
accounts or devices other than your own test hardware.
|
||||
- **Personal identifiers**: real names of speakers ("LivingRoom",
|
||||
custom device names), private email addresses, household member
|
||||
names visible in source IDs.
|
||||
|
||||
If you spot any of the above already in the tree, treat it as a
|
||||
sanitisation task: stop, flag it to the maintainer, propose a
|
||||
remediation commit before continuing.
|
||||
|
||||
## Disclaimers
|
||||
|
||||
"SoundTouch" and "Bose" are registered trademarks of Bose Corporation.
|
||||
This project is an unofficial, community-built effort, not affiliated
|
||||
with, endorsed by, or authorised by Bose.
|
||||
|
||||
## Communication style
|
||||
|
||||
When working with a human user in this repo:
|
||||
|
||||
- **Prioritise direct answers** to the question being asked, even when
|
||||
it sits outside the current task or project context. Don't divert
|
||||
back to whatever you were doing when the user asks something else.
|
||||
- **Don't substitute assumptions for real information.** When something
|
||||
is unclear, ask or check, rather than guessing and proceeding.
|
||||
|
||||
These principles also apply to other AI assistants pointed at this
|
||||
repo. Tool-specific config dirs (e.g. `.junie/`, `.claude/`) should
|
||||
defer to this file as the source of truth instead of carrying their
|
||||
own copies.
|
||||
+6
-26
@@ -2,17 +2,6 @@
|
||||
|
||||
Thank you for your interest in contributing to the Bose SoundTouch API Client! This project aims to provide a comprehensive, reliable, and well-tested Go library for controlling Bose SoundTouch devices.
|
||||
|
||||
## Ways to Contribute
|
||||
|
||||
All contributions are welcome — large or small:
|
||||
|
||||
- **Code suggestions** — bug fixes, new features, refactoring, performance improvements.
|
||||
- **Documentation updates** — README, guides, examples, troubleshooting notes, inline doc comments.
|
||||
- **Bug fixes** — even just a clear reproducer in an issue is a real contribution.
|
||||
- **Donations** — if the project kept a speaker (or several) of yours alive past the Bose cloud shutdown and you want to give back, [GitHub Sponsors](https://github.com/sponsors/gesellix) is open. No expectation; everything in this repo stays MIT regardless.
|
||||
|
||||
By submitting a code or documentation contribution you agree to license it under MIT. The detailed guides below cover the mechanics.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
@@ -26,7 +15,6 @@ By submitting a code or documentation contribution you agree to license it under
|
||||
- [Reporting Issues](#reporting-issues)
|
||||
- [Device Testing](#device-testing)
|
||||
- [Community](#community)
|
||||
- [Support the Project](#support-the-project)
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
@@ -157,7 +145,7 @@ golangci-lint run --fix
|
||||
go install ./cmd/soundtouch-cli
|
||||
|
||||
# Run integration tests (requires real device)
|
||||
make test-integration HOST=192.0.2.100
|
||||
make test-integration HOST=192.168.1.100
|
||||
```
|
||||
|
||||
### Environment Setup
|
||||
@@ -166,7 +154,7 @@ For development with real devices, create a `.env` file:
|
||||
|
||||
```env
|
||||
# Optional: Pre-configured device for testing
|
||||
SOUNDTOUCH_HOST=192.0.2.100
|
||||
SOUNDTOUCH_HOST=192.168.1.100
|
||||
SOUNDTOUCH_PORT=8090
|
||||
|
||||
# Optional: Enable debug logging
|
||||
@@ -340,7 +328,7 @@ When possible, test with real SoundTouch devices:
|
||||
|
||||
```bash
|
||||
# Set device IP for integration tests
|
||||
export SOUNDTOUCH_HOST=192.0.2.100
|
||||
export SOUNDTOUCH_HOST=192.168.1.100
|
||||
go test -tags integration ./pkg/client/
|
||||
```
|
||||
|
||||
@@ -361,7 +349,7 @@ go test -tags integration ./pkg/client/
|
||||
// Basic usage:
|
||||
//
|
||||
// client := client.NewClient(&client.Config{
|
||||
// Host: "192.0.2.100",
|
||||
// Host: "192.168.1.100",
|
||||
// Port: 8090,
|
||||
// })
|
||||
//
|
||||
@@ -410,8 +398,8 @@ If you have access to other SoundTouch models:
|
||||
|
||||
2. **Test basic functionality**:
|
||||
```bash
|
||||
./soundtouch-cli -h 192.0.2.100 info get
|
||||
./soundtouch-cli -h 192.0.2.100 now-playing get
|
||||
./soundtouch-cli -h 192.168.1.100 info get
|
||||
./soundtouch-cli -h 192.168.1.100 now-playing get
|
||||
```
|
||||
|
||||
3. **Report compatibility** in your PR or issue
|
||||
@@ -477,14 +465,6 @@ Contributors will be:
|
||||
- **Mentioned in release notes** for significant contributions
|
||||
- **Credited in documentation** where appropriate
|
||||
|
||||
## Support the Project
|
||||
|
||||
If you want to support the maintenance effort beyond code:
|
||||
|
||||
[](https://github.com/sponsors/gesellix)
|
||||
|
||||
Sponsorship is entirely optional. Code, docs, and bug reports remain the most useful contributions for the project itself.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Go Documentation](https://golang.org/doc/)
|
||||
|
||||
+9
-25
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.3-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.1-alpine AS builder
|
||||
|
||||
# Declare automatic platform ARGs to make them available in build stage
|
||||
# See https://docs.docker.com/reference/dockerfile#automatic-platform-args-in-the-global-scope
|
||||
@@ -24,47 +24,31 @@ RUN if [ "${TARGETARCH}" = "arm" ] && [ -n "${TARGETVARIANT}" ]; then \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /soundtouch-service ./cmd/soundtouch-service; \
|
||||
fi
|
||||
|
||||
# Build the soundtouch-web
|
||||
RUN if [ "${TARGETARCH}" = "arm" ] && [ -n "${TARGETVARIANT}" ]; then \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} GOARM=${TARGETVARIANT#v} go build -o /soundtouch-web ./cmd/soundtouch-web; \
|
||||
else \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /soundtouch-web ./cmd/soundtouch-web; \
|
||||
fi
|
||||
|
||||
# soundtouch-service image
|
||||
FROM alpine:3.23 AS soundtouch-service
|
||||
# Final stage
|
||||
FROM alpine:3.23
|
||||
|
||||
# Install necessary runtime dependencies
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the binary from the builder stage
|
||||
COPY --from=builder /soundtouch-service /app/soundtouch-service
|
||||
|
||||
# Verify the binary works on the target platform
|
||||
RUN /app/soundtouch-service version || echo "Binary verification complete"
|
||||
|
||||
# Create data directory for persistence
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Set environment variables with defaults
|
||||
ENV PORT=8000
|
||||
ENV DATA_DIR=/app/data
|
||||
ENV LOG_PROXY_BODY=false
|
||||
ENV REDACT_PROXY_LOGS=true
|
||||
|
||||
# Expose the service port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run the service
|
||||
ENTRYPOINT ["/app/soundtouch-service"]
|
||||
|
||||
# soundtouch-web image
|
||||
FROM alpine:3.23 AS soundtouch-web
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /soundtouch-web /app/soundtouch-web
|
||||
|
||||
ENV PORT=8080
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["/app/soundtouch-web"]
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
# Dockerfile.stockholm — builds the Stockholm frontend preparation image.
|
||||
#
|
||||
# This image clones krahl/soundcork-stockholm-app, installs the required tools
|
||||
# (prettier, patch, unzip, jq), and is used exclusively to run the entrypoint
|
||||
# preparation step that extracts and patches the Stockholm frontend.
|
||||
#
|
||||
# Java is NOT included — we stop before `exec java`.
|
||||
#
|
||||
# Usage (see Makefile targets build-stockholm-image / prepare-stockholm):
|
||||
#
|
||||
# docker build --build-arg STOCKHOLM_APP_REF=main \
|
||||
# -f Dockerfile.stockholm -t soundcork-stockholm-app .
|
||||
#
|
||||
# docker run --rm \
|
||||
# -v "$PWD/stockholm_zip:/app/stockholm_zip:ro" \
|
||||
# -v "$PWD/stockholm:/app/stockholm" \
|
||||
# --entrypoint bash soundcork-stockholm-app \
|
||||
# -c 'awk "/^exec java/{exit} {print}" /app/docker-entrypoint.sh | bash'
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
ARG STOCKHOLM_APP_REF=main
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
git \
|
||||
jq \
|
||||
unzip \
|
||||
nodejs \
|
||||
npm \
|
||||
patch && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN npm install -g prettier@3.8.3 && npm cache clean --force
|
||||
|
||||
RUN git clone --depth 1 --branch "${STOCKHOLM_APP_REF}" \
|
||||
https://github.com/krahl/soundcork-stockholm-app /app
|
||||
|
||||
WORKDIR /app
|
||||
@@ -1,7 +1,4 @@
|
||||
.PHONY: all build build-cli test test-coverage test-http-client test-http-client-rotate check fmt vet lint clean dev help screenshots build-stockholm-image prepare-stockholm update-static-deps
|
||||
|
||||
# Load .env if present (simple KEY=VALUE format, no shell quoting)
|
||||
-include .env
|
||||
.PHONY: all build build-cli test test-coverage check fmt vet lint clean dev help
|
||||
|
||||
# Go parameters
|
||||
GOCMD=go
|
||||
@@ -17,125 +14,77 @@ BINARY_NAME=soundtouch-cli
|
||||
BINARY_PATH=./cmd/$(BINARY_NAME)
|
||||
SERVICE_NAME=soundtouch-service
|
||||
SERVICE_PATH=./cmd/$(SERVICE_NAME)
|
||||
WEB_NAME=soundtouch-web
|
||||
WEB_PATH=./cmd/$(WEB_NAME)
|
||||
EXAMPLE_MDNS_NAME=example-mdns
|
||||
EXAMPLE_MDNS_PATH=./cmd/$(EXAMPLE_MDNS_NAME)
|
||||
EXAMPLE_UPNP_NAME=example-upnp
|
||||
EXAMPLE_UPNP_PATH=./cmd/$(EXAMPLE_UPNP_NAME)
|
||||
SCANNER_NAME=mdns-scanner
|
||||
SCANNER_PATH=./cmd/$(SCANNER_NAME)
|
||||
FAVICON_GEN_NAME=favicon-gen
|
||||
FAVICON_GEN_PATH=./cmd/$(FAVICON_GEN_NAME)
|
||||
BACKUP_NAME=soundtouch-backup
|
||||
BACKUP_PATH=./cmd/$(BACKUP_NAME)
|
||||
BUILD_DIR=./build
|
||||
|
||||
# Build flags: strip debug info/DWARF for smaller binaries, remove local paths for reproducibility
|
||||
BUILDFLAGS=-trimpath -ldflags="-s -w"
|
||||
|
||||
# Stockholm frontend preparation (see Dockerfile.stockholm and docs/stockholm-port-guide.md)
|
||||
# STOCKHOLM_APP_REF can be overridden to pin a specific commit: make build-stockholm-image STOCKHOLM_APP_REF=<sha>
|
||||
STOCKHOLM_IMAGE ?= soundcork-stockholm-app
|
||||
STOCKHOLM_APP_REF ?= main
|
||||
STOCKHOLM_ZIP_DIR ?= $(CURDIR)/stockholm_zip
|
||||
STOCKHOLM_DIR ?= $(CURDIR)/stockholm
|
||||
# URLs baked into stockholm/json/config.json during prepare-stockholm.
|
||||
# The Go service rewrites these again at startup using SERVER_URL / MARGE_URL,
|
||||
# so these only matter for static-file-only deployments or when pre-baking is desired.
|
||||
# Default to localhost:8000 (matches the Go service default).
|
||||
BACKEND_URL ?= http://localhost:8000
|
||||
# STREAMING_URL defaults to BACKEND_URL (no /marge suffix — set to $(BACKEND_URL)/marge for soundcork).
|
||||
STREAMING_URL ?= $(BACKEND_URL)
|
||||
# AUTH_SERVICE_URL defaults to BACKEND_URL; override to point at a different auth endpoint.
|
||||
AUTH_SERVICE_URL ?= $(BACKEND_URL)
|
||||
# Version info
|
||||
# No ldflags needed - using debug.BuildInfo since Go 1.18
|
||||
|
||||
all: check build
|
||||
|
||||
build: build-cli build-service build-web build-examples build-favicon-gen build-backup
|
||||
build: build-cli build-service build-examples
|
||||
|
||||
build-cli:
|
||||
@echo "Building $(BINARY_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) $(BINARY_PATH)
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME) $(BINARY_PATH)
|
||||
|
||||
build-service:
|
||||
@echo "Building $(SERVICE_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME) $(SERVICE_PATH)
|
||||
|
||||
build-web:
|
||||
@echo "Building $(WEB_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(WEB_NAME) $(WEB_PATH)
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME) $(SERVICE_PATH)
|
||||
|
||||
build-examples:
|
||||
@echo "Building $(EXAMPLE_MDNS_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME) $(EXAMPLE_MDNS_PATH)
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME) $(EXAMPLE_MDNS_PATH)
|
||||
@echo "Building $(EXAMPLE_UPNP_NAME)..."
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME) $(EXAMPLE_UPNP_PATH)
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME) $(EXAMPLE_UPNP_PATH)
|
||||
@echo "Building $(SCANNER_NAME)..."
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME) $(SCANNER_PATH)
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME) $(SCANNER_PATH)
|
||||
|
||||
build-favicon-gen:
|
||||
@echo "Building $(FAVICON_GEN_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(FAVICON_GEN_NAME) $(FAVICON_GEN_PATH)
|
||||
|
||||
build-backup:
|
||||
@echo "Building $(BACKUP_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME) $(BACKUP_PATH)
|
||||
|
||||
build-all: build-linux build-linux-armv7 build-darwin build-windows build-examples-all
|
||||
build-all: build-linux build-darwin build-windows build-examples-all
|
||||
|
||||
build-linux:
|
||||
@echo "Building for Linux..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
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)
|
||||
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)
|
||||
|
||||
build-darwin:
|
||||
@echo "Building for macOS..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
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)
|
||||
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)
|
||||
|
||||
build-windows:
|
||||
@echo "Building for Windows..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
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)
|
||||
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)
|
||||
|
||||
build-examples-all:
|
||||
@echo "Building examples for all platforms..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
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)
|
||||
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)
|
||||
|
||||
test:
|
||||
@echo "Running tests..."
|
||||
@@ -147,72 +96,7 @@ test-coverage:
|
||||
$(GOCMD) tool cover -html=coverage.out -o coverage.html
|
||||
@echo "Coverage report generated: coverage.html"
|
||||
|
||||
check: fmt vet test test-http-client
|
||||
|
||||
# Archive any existing tests/integration/testdata/ to a timestamped sibling
|
||||
# so the next `make test-http-client` starts from a clean slate. Keeps the
|
||||
# old state around for retrospective debugging — never destructive.
|
||||
# Run BEFORE test-http-client when fixtures or schemas have changed and
|
||||
# stale state would otherwise be reused via the compose volume mount.
|
||||
test-http-client-rotate:
|
||||
@if [ -d tests/integration/testdata ]; then \
|
||||
archive=tests/integration/testdata_$$(date +%Y%m%d-%H%M%S); \
|
||||
mv tests/integration/testdata "$$archive"; \
|
||||
echo "Archived existing testdata to $$archive"; \
|
||||
else \
|
||||
echo "No tests/integration/testdata/ to archive — already fresh."; \
|
||||
fi
|
||||
|
||||
test-http-client:
|
||||
@echo "Starting services with docker compose..."
|
||||
@docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d --build
|
||||
@echo "Waiting for services to start..."
|
||||
@sleep 10
|
||||
@echo "Running .http tests..."
|
||||
@docker run --rm --network soundtouch-test-net \
|
||||
-v "$(PWD)/tests/integration/http-client:/workdir" \
|
||||
jetbrains/intellij-http-client:2026.1 \
|
||||
--env-file /workdir/http-client.env.json \
|
||||
--env ci \
|
||||
/workdir/spotify_registration.http \
|
||||
/workdir/amazon_registration.http \
|
||||
/workdir/create_account.http \
|
||||
/workdir/register_device.http \
|
||||
/workdir/spotify_full_flow.http \
|
||||
/workdir/customer_support.http \
|
||||
/workdir/power_on.http \
|
||||
/workdir/get_bmx_services.http \
|
||||
/workdir/get_sourceproviders.http \
|
||||
/workdir/get_software_update.http \
|
||||
/workdir/get_soundtouch_updates.http \
|
||||
/workdir/get_streaming_token.http \
|
||||
/workdir/post_oauth_token.http \
|
||||
/workdir/post_oauth_token_amazon.http \
|
||||
/workdir/get_provider_settings.http \
|
||||
/workdir/tunein_playback_station.http \
|
||||
/workdir/set_preset_6.http \
|
||||
/workdir/get_presets.http \
|
||||
/workdir/delete_preset_6.http \
|
||||
/workdir/set_preset_5.http \
|
||||
/workdir/post_recent.http \
|
||||
/workdir/get_recents.http \
|
||||
/workdir/get_account_presets.http \
|
||||
/workdir/get_account_devices.http \
|
||||
/workdir/get_account_sources.http \
|
||||
/workdir/get_api_versions.http \
|
||||
/workdir/post_musicprovider_is_eligible.http \
|
||||
/workdir/get_full_account.http \
|
||||
/workdir/create_group.http \
|
||||
/workdir/get_group.http \
|
||||
/workdir/rename_device.http \
|
||||
/workdir/unregister_device.http \
|
||||
--report; \
|
||||
EXIT_CODE=$$?; \
|
||||
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs soundtouch-service; \
|
||||
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs spotify-mock; \
|
||||
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs amazon-mock; \
|
||||
docker compose -f docker-compose.yml -f docker-compose.ci.yml down; \
|
||||
exit $$EXIT_CODE
|
||||
check: fmt vet test
|
||||
|
||||
fmt:
|
||||
@echo "Formatting code..."
|
||||
@@ -247,18 +131,6 @@ dev-service-proxy: build-service
|
||||
fi
|
||||
PYTHON_BACKEND_URL=$(PROXY_URL) $(BUILD_DIR)/$(SERVICE_NAME)
|
||||
|
||||
# Run the service with the Stockholm frontend enabled. Requires that
|
||||
# `make prepare-stockholm` has been run at least once (the check below
|
||||
# avoids re-running the Docker container on every dev launch).
|
||||
dev-service-stockholm: build-service
|
||||
@if [ ! -f "$(STOCKHOLM_DIR)/index.html" ]; then \
|
||||
echo "Error: Stockholm not prepared at $(STOCKHOLM_DIR)."; \
|
||||
echo "Run 'make prepare-stockholm' first (needs stockholm_zip/stockholm.zip)."; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "Starting development service with Stockholm enabled from $(STOCKHOLM_DIR)..."
|
||||
STOCKHOLM_DIR=$(STOCKHOLM_DIR) $(BUILD_DIR)/$(SERVICE_NAME)
|
||||
|
||||
dev-discover: build-cli
|
||||
@echo "Running device discovery..."
|
||||
$(BUILD_DIR)/$(BINARY_NAME) -discover
|
||||
@@ -266,7 +138,7 @@ dev-discover: build-cli
|
||||
dev-info: build-cli
|
||||
@echo "Getting device info (requires -host flag)..."
|
||||
@if [ -z "$(HOST)" ]; then \
|
||||
echo "Usage: make dev-info HOST=192.0.2.10"; \
|
||||
echo "Usage: make dev-info HOST=192.168.1.10"; \
|
||||
exit 1; \
|
||||
fi
|
||||
$(BUILD_DIR)/$(BINARY_NAME) -host $(HOST) -info
|
||||
@@ -315,48 +187,10 @@ dev-scan-http: build-examples
|
||||
@echo "Scanning for HTTP mDNS services..."
|
||||
$(BUILD_DIR)/$(SCANNER_NAME) -service _http._tcp -v
|
||||
|
||||
dev-web: build-web
|
||||
@echo "Starting web UI (default port 8080)..."
|
||||
cd cmd/soundtouch-web && ../../$(BUILD_DIR)/$(WEB_NAME)
|
||||
|
||||
dev-web-port: build-web
|
||||
@echo "Starting web UI on custom port..."
|
||||
@if [ -z "$(PORT)" ]; then \
|
||||
echo "Usage: make dev-web-port PORT=8888"; \
|
||||
exit 1; \
|
||||
fi
|
||||
cd cmd/soundtouch-web && ../../$(BUILD_DIR)/$(WEB_NAME) -port $(PORT)
|
||||
|
||||
dev-backup: build-backup
|
||||
@echo "Running backup tool..."
|
||||
$(BUILD_DIR)/$(BACKUP_NAME) --help
|
||||
|
||||
dev-backup-cloud: build-backup
|
||||
@echo "Running cloud backup..."
|
||||
$(BUILD_DIR)/$(BACKUP_NAME) cloud
|
||||
|
||||
dev-backup-local: build-backup
|
||||
@echo "Running local backup (auto-discover)..."
|
||||
$(BUILD_DIR)/$(BACKUP_NAME) local --discover
|
||||
|
||||
dev-web-host: build-web
|
||||
@echo "Starting web UI with specific host..."
|
||||
@if [ -z "$(HOST)" ]; then \
|
||||
echo "Usage: make dev-web-host HOST=192.0.2.10"; \
|
||||
exit 1; \
|
||||
fi
|
||||
cd cmd/soundtouch-web && ../../$(BUILD_DIR)/$(WEB_NAME) -host $(HOST)
|
||||
|
||||
install: build-cli build-service build-web build-backup
|
||||
install: build-cli build-service
|
||||
@echo "Installing binaries to $(GOPATH)/bin..."
|
||||
cp $(BUILD_DIR)/$(BINARY_NAME) $(GOPATH)/bin/
|
||||
cp $(BUILD_DIR)/$(SERVICE_NAME) $(GOPATH)/bin/
|
||||
cp $(BUILD_DIR)/$(WEB_NAME) $(GOPATH)/bin/
|
||||
cp $(BUILD_DIR)/$(BACKUP_NAME) $(GOPATH)/bin/
|
||||
|
||||
update-static-deps:
|
||||
@echo "Updating static frontend dependencies..."
|
||||
@./scripts/update-static-deps.sh
|
||||
|
||||
clean:
|
||||
@echo "Cleaning..."
|
||||
@@ -376,70 +210,7 @@ release: clean check build-all
|
||||
|
||||
docker-build:
|
||||
@echo "Building Docker image..."
|
||||
docker build --target soundtouch-service -t soundtouch-service .
|
||||
|
||||
# Stockholm frontend preparation.
|
||||
# Requires: Docker, internet access (clones github.com/krahl/soundcork-stockholm-app).
|
||||
# No pre-built image is published; the image must be built locally before running prepare-stockholm.
|
||||
build-stockholm-image:
|
||||
@echo "Building Stockholm preparation image (clones upstream, installs prettier/patch)..."
|
||||
docker build \
|
||||
--build-arg STOCKHOLM_APP_REF=$(STOCKHOLM_APP_REF) \
|
||||
-f Dockerfile.stockholm \
|
||||
-t $(STOCKHOLM_IMAGE) \
|
||||
.
|
||||
|
||||
# Extracts and patches the Stockholm frontend using the upstream container image.
|
||||
# Requires: build-stockholm-image to have been run, and stockholm_zip/stockholm.zip to be present.
|
||||
# The resulting stockholm/ directory is used by the soundtouch-service at runtime.
|
||||
prepare-stockholm:
|
||||
@mkdir -p "$(STOCKHOLM_DIR)"
|
||||
@[ -f "$(STOCKHOLM_ZIP_DIR)/stockholm.zip" ] || { \
|
||||
echo "Error: $(STOCKHOLM_ZIP_DIR)/stockholm.zip not found."; \
|
||||
echo "Download the Stockholm zip and place it at stockholm_zip/stockholm.zip first."; \
|
||||
exit 1; }
|
||||
docker run --rm \
|
||||
-e BACKEND_URL=$(BACKEND_URL) \
|
||||
-e STREAMING_URL=$(STREAMING_URL) \
|
||||
-e AUTH_SERVICE_URL=$(AUTH_SERVICE_URL) \
|
||||
-v "$(STOCKHOLM_ZIP_DIR):/app/stockholm_zip:ro" \
|
||||
-v "$(STOCKHOLM_DIR):/app/stockholm" \
|
||||
--entrypoint bash \
|
||||
$(STOCKHOLM_IMAGE) \
|
||||
-c 'awk "/^exec java/{exit} {print}" /app/docker-entrypoint.sh | bash'
|
||||
@# Patch update-urls.sh: replace the hardcoded ${BACKEND_URL}/marge with
|
||||
@# ${STREAMING_URL:-${BACKEND_URL}} so the streaming URL is configurable and
|
||||
@# defaults to BACKEND_URL (no /marge suffix) rather than the soundcork convention.
|
||||
@script="$(STOCKHOLM_DIR)/json/update-urls.sh"; \
|
||||
awk '{ gsub(/\$$\{BACKEND_URL\}\/marge/, "$${STREAMING_URL:-$${BACKEND_URL}}"); print }' \
|
||||
"$$script" > "$$script.tmp" && mv "$$script.tmp" "$$script"
|
||||
@# Restore config.json from the backup that update-urls.sh created.
|
||||
@# The Go service rewrites URLs at startup via RewriteConfigURLs, so we start
|
||||
@# from the original Bose URLs rather than whatever update-urls.sh produced.
|
||||
@[ ! -f "$(STOCKHOLM_DIR)/json/backup.json" ] || \
|
||||
cp "$(STOCKHOLM_DIR)/json/backup.json" "$(STOCKHOLM_DIR)/json/config.json"
|
||||
@# Patch browse.js: guard against empty browse-path array so that
|
||||
@# funcObj.browse.getPath() returning undefined does not throw when the user
|
||||
@# has not browsed yet (causes "Now playing error: topLevel" console spam and
|
||||
@# aborts the now-playing update handler).
|
||||
@sed -i.bak \
|
||||
-e 's/: (l()\.topLevel/: ((l() || {}).topLevel/' \
|
||||
-e 's/var a = l()\.topLevel,/var a = (l() || {}).topLevel,/' \
|
||||
-e 's/E() === 0 || funcObj\.browse\.getPath()\.topLevel/E() === 0 || (funcObj.browse.getPath() || {}).topLevel/' \
|
||||
"$(STOCKHOLM_DIR)/js/browse.js" && \
|
||||
rm -f "$(STOCKHOLM_DIR)/js/browse.js.bak"
|
||||
@# Patch bridge JS: replace hardcoded /api/* paths with __stockholmBase-prefixed
|
||||
@# versions so the bridge works when Stockholm is mounted under a base path.
|
||||
@# browser_http_proxy.js declares the proxy URL as a top-level constant;
|
||||
@# without patching it, requests from a /stockholm/* page hit /api/http-proxy
|
||||
@# directly and 404 because the proxy is mounted under the base path.
|
||||
@# Also fix resolveWebviewUrl to include the base path when resolving relative URLs.
|
||||
@python3 scripts/patch-stockholm-bridge.py \
|
||||
"$(STOCKHOLM_DIR)/js/browser_http_proxy.js" \
|
||||
"$(STOCKHOLM_DIR)/js/browser_native_bridge.js" \
|
||||
"$(STOCKHOLM_DIR)/js/app_comm.js" \
|
||||
"$(STOCKHOLM_DIR)/setup/js/app_comm.js"
|
||||
@echo "Stockholm frontend prepared at $(STOCKHOLM_DIR)"
|
||||
docker build -t soundtouch-service .
|
||||
|
||||
docker-run-host:
|
||||
@echo "Running Docker container..."
|
||||
@@ -450,24 +221,15 @@ docker-run-ports:
|
||||
@echo "Running Docker container with port mapping (discovery will be manual)..."
|
||||
docker run --rm -it -p 8000:8000 -v $$(pwd)/data:/app/data soundtouch-service
|
||||
|
||||
screenshots:
|
||||
@echo "Capturing documentation screenshots..."
|
||||
@bash scripts/screenshots/run.sh
|
||||
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo " build - Build the CLI tool, service, and examples"
|
||||
@echo " build-cli - Build only the CLI tool"
|
||||
@echo " build-service - Build only the service"
|
||||
@echo " build-backup - Build only the backup tool"
|
||||
@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 " test-http-client - Run .http integration tests via Docker Compose"
|
||||
@echo " test-http-client-rotate - Archive tests/integration/testdata/ before a fresh run (non-destructive)"
|
||||
@echo " check - Run fmt, vet, and tests"
|
||||
@echo " fmt - Format code"
|
||||
@echo " vet - Run go vet"
|
||||
@@ -476,8 +238,6 @@ help:
|
||||
@echo " dev - Build and show CLI help"
|
||||
@echo " dev-service - Build and run service locally"
|
||||
@echo " dev-service-proxy - Build and run service with proxy (PROXY_URL=url required)"
|
||||
@echo " dev-service-stockholm - Build and run service with Stockholm frontend (requires prior 'make prepare-stockholm')"
|
||||
@echo " screenshots - Capture documentation screenshots (headless Chrome via chromedp)"
|
||||
@echo " dev-discover - Build and run device discovery"
|
||||
@echo " dev-info - Build and get device info (HOST=ip required)"
|
||||
@echo " dev-mdns - Build and run mDNS discovery example"
|
||||
@@ -489,28 +249,19 @@ help:
|
||||
@echo " dev-scan-all - Scan all mDNS services on network"
|
||||
@echo " dev-scan-soundtouch - Scan specifically for SoundTouch mDNS services"
|
||||
@echo " dev-scan-http - Scan for HTTP mDNS services"
|
||||
@echo " dev-backup - Build and show backup tool help"
|
||||
@echo " dev-backup-cloud - Build and run cloud backup (prompts for credentials)"
|
||||
@echo " dev-backup-local - Build and run local backup (auto-discover speakers)"
|
||||
@echo " dev-web - Build and run web UI (default port 8080)"
|
||||
@echo " dev-web-port - Build and run web UI on custom port (PORT=8888)"
|
||||
@echo " dev-web-host - Build and run web UI with specific device (HOST=ip)"
|
||||
@echo " install - Install binaries to GOPATH/bin"
|
||||
@echo " clean - Clean build artifacts"
|
||||
@echo " release - Create release binaries"
|
||||
@echo " docker-build - Build Docker image"
|
||||
@echo " docker-run-host - Run container with host networking (Linux discovery)"
|
||||
@echo " docker-run-ports - Run container with port mapping (macOS/Windows/No discovery)"
|
||||
@echo " build-stockholm-image - Build Stockholm prep image (requires Docker + internet)"
|
||||
@echo " prepare-stockholm - Extract and patch Stockholm frontend (requires build-stockholm-image"
|
||||
@echo " and stockholm_zip/stockholm.zip; see docs/stockholm-port-guide.md)"
|
||||
@echo " help - Show this help message"
|
||||
@echo ""
|
||||
@echo "Examples:"
|
||||
@echo " make dev-service"
|
||||
@echo " make dev-service-proxy PROXY_URL=http://192.0.2.50:8001"
|
||||
@echo " make dev-service-proxy PROXY_URL=http://192.168.1.50:8001"
|
||||
@echo " make dev-discover"
|
||||
@echo " make dev-info HOST=192.0.2.10"
|
||||
@echo " make dev-info HOST=192.168.1.10"
|
||||
@echo " make dev-mdns"
|
||||
@echo " make dev-mdns-verbose"
|
||||
@echo " make dev-mdns-timeout TIMEOUT=10s"
|
||||
@@ -519,8 +270,5 @@ help:
|
||||
@echo " make dev-upnp-timeout TIMEOUT=10s"
|
||||
@echo " make dev-scan-all"
|
||||
@echo " make dev-scan-soundtouch"
|
||||
@echo " make dev-web"
|
||||
@echo " make dev-web-port PORT=8888"
|
||||
@echo " make dev-web-host HOST=192.0.2.10"
|
||||
@echo " make test"
|
||||
@echo " make build-all"
|
||||
|
||||
@@ -1,159 +1,549 @@
|
||||
# <img src="media/favicon-braille.svg" width="32" height="32" valign="middle"> AfterTouch
|
||||
<p style="margin-top: -10px; font-style: italic; color: #666;">Bose SoundTouch Toolkit</p>
|
||||
# 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)
|
||||
|
||||
> Independent project. **Not affiliated with, endorsed by, sponsored
|
||||
> by, or otherwise connected to Bose Corporation.** See
|
||||
> [Disclaimer](#disclaimer) for the full statement.
|
||||
> **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.
|
||||
|
||||
## Context: Cloud Shutdown
|
||||
## Features
|
||||
|
||||
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.
|
||||
- ✅ **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
|
||||
|
||||
See the [Survival Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SURVIVAL-GUIDE.html) for the full picture.
|
||||
## Quick Start
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
If you don't want to run a server for this - no problem. The service is small enough to run on the SoundTouch itself. See the [On-Device Installer](./scripts/on-device-install/README.md) for instructions.
|
||||
|
||||
**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.
|
||||
### Installation
|
||||
|
||||
#### 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
|
||||
```
|
||||
|
||||
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.
|
||||
### CLI Usage
|
||||
|
||||
---
|
||||
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
|
||||
|
||||
- [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)
|
||||
- 📖 [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
|
||||
|
||||
---
|
||||
## Development
|
||||
|
||||
## Related projects
|
||||
### Prerequisites
|
||||
- Go 1.25.6 or later
|
||||
- Optional: SoundTouch device for testing
|
||||
|
||||
- **[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
|
||||
### Building from Source
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/gesellix/bose-soundtouch.git
|
||||
cd Bose-SoundTouch
|
||||
|
||||
---
|
||||
# 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](#related-projects) 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: [GitHub Issues](https://github.com/gesellix/bose-soundtouch/issues/new)
|
||||
- Questions & discussions: [GitHub Discussions](https://github.com/gesellix/bose-soundtouch/discussions)
|
||||
- 🐛 **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)
|
||||
|
||||
---
|
||||
|
||||
**Star this project** ⭐ if you find it useful!
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and pull requests welcome — code, documentation, bug reports, and feature ideas all land in the same place. By submitting a contribution you agree to license it under MIT. For significant changes please open an issue first to discuss the approach. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide.
|
||||
|
||||
## Support the project
|
||||
|
||||
If this toolkit kept a speaker (or several) of yours alive past the Bose cloud shutdown and you want to give back, [GitHub Sponsors](https://github.com/sponsors/gesellix) is open. No expectation — everything in this repo stays MIT regardless.
|
||||
|
||||
[](https://github.com/sponsors/gesellix)
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This is an independent open-source project. **Bose** and **SoundTouch**
|
||||
are registered trademarks of Bose Corporation in the United States and
|
||||
other countries. This project is **not affiliated with, endorsed by,
|
||||
sponsored by, or otherwise connected to** Bose Corporation.
|
||||
|
||||
The toolkit exists solely to restore functionality of Bose SoundTouch
|
||||
speakers after the official cloud service shutdown on May 6, 2026.
|
||||
Reverse engineering for the sole purpose of interoperability is
|
||||
permitted under [EU Directive 2009/24/EC, Article 6](https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32009L0024)
|
||||
("Decompilation"), and comparable provisions in other jurisdictions.
|
||||
|
||||
The optional Stockholm frontend integration (`STOCKHOLM_DIR`) requires
|
||||
the user to supply the Stockholm web-app sources themselves; no Bose
|
||||
code is redistributed in this repository.
|
||||
|
||||
The software is provided AS IS, without warranty. Use at your own risk.
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
// Command dummy-speaker runs an HTTP-only fake SoundTouch speaker and
|
||||
// optionally registers it with a running soundtouch-service so the web UI
|
||||
// has a device to display.
|
||||
//
|
||||
// Intended for documentation screenshots and local UI smoke checks. Do not
|
||||
// use against a real network — the fixture payload is synthetic and would
|
||||
// confuse other tooling that expects live device data.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// dummy-speaker --port 8090 --register http://localhost:8000
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/testing/fakespeaker"
|
||||
)
|
||||
|
||||
func main() {
|
||||
listen := flag.String("listen", "127.0.0.1:8090", "bind address for the fake speaker's HTTP API")
|
||||
telnetListen := flag.String("telnet-listen", "127.0.0.1:17000", "bind address for the fake speaker's telnet diagnostic shell (empty to disable)")
|
||||
register := flag.String("register", "", "service base URL (e.g. http://localhost:8000) to self-register with via POST /setup/devices")
|
||||
registerAs := flag.String("register-as", "", "address to send to /setup/devices (defaults to --listen)")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
s, err := fakespeaker.Start(fakespeaker.Config{
|
||||
HTTPListen: *listen,
|
||||
TelnetListen: *telnetListen,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("start fake speaker: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("fake speaker HTTP listening on http://%s", s.HTTPAddr())
|
||||
|
||||
if addr := s.TelnetAddr(); addr != "" {
|
||||
log.Printf("fake speaker telnet listening on tcp://%s", addr)
|
||||
}
|
||||
|
||||
if *register != "" {
|
||||
target := *registerAs
|
||||
if target == "" {
|
||||
target = s.HTTPAddr()
|
||||
}
|
||||
|
||||
if err := registerWithService(*register, target); err != nil {
|
||||
log.Printf("self-register failed: %v (continuing anyway)", err)
|
||||
} else {
|
||||
log.Printf("registered %s with service at %s", target, *register)
|
||||
}
|
||||
}
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sig
|
||||
|
||||
log.Printf("shutting down")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := s.Stop(ctx); err != nil {
|
||||
log.Printf("stop: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func registerWithService(serviceURL, deviceAddr string) error {
|
||||
body, err := json.Marshal(map[string]string{"ip": deviceAddr})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, serviceURL+"/setup/devices", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("service responded %s", resp.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
// Package main provides a utility to generate PNG and ICO favicons from SVG source files.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/srwiley/oksvg"
|
||||
"github.com/srwiley/rasterx"
|
||||
)
|
||||
|
||||
func main() {
|
||||
mediaDir := "pkg/service/handlers/web/img"
|
||||
files := []string{"favicon-braille", "favicon-morse"}
|
||||
|
||||
for _, name := range files {
|
||||
svgPath := filepath.Join(mediaDir, name+".svg")
|
||||
pngPath := filepath.Join(mediaDir, name+".png")
|
||||
icoPath := filepath.Join(mediaDir, name+".ico")
|
||||
|
||||
fmt.Printf("Processing %s...\n", name)
|
||||
|
||||
// 1. Render SVG to PNG
|
||||
img, err := renderSVG(svgPath, 32, 32)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to render %s: %v", svgPath, err)
|
||||
}
|
||||
|
||||
f, err := os.Create(pngPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create %s: %v", pngPath, err)
|
||||
}
|
||||
|
||||
if err := png.Encode(f, img); err != nil {
|
||||
f.Close()
|
||||
log.Fatalf("Failed to encode PNG %s: %v", pngPath, err)
|
||||
}
|
||||
|
||||
f.Close()
|
||||
fmt.Printf("Created %s\n", pngPath)
|
||||
|
||||
// 2. Create ICO (containing multiple sizes)
|
||||
sizes := []int{16, 32, 48}
|
||||
|
||||
var images []image.Image
|
||||
|
||||
for _, s := range sizes {
|
||||
m, err := renderSVG(svgPath, s, s)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to render %s at size %d: %v", svgPath, s, err)
|
||||
}
|
||||
|
||||
images = append(images, m)
|
||||
}
|
||||
|
||||
if err := writeICO(icoPath, images); err != nil {
|
||||
log.Fatalf("Failed to write ICO %s: %v", icoPath, err)
|
||||
}
|
||||
|
||||
fmt.Printf("Created %s\n", icoPath)
|
||||
}
|
||||
}
|
||||
|
||||
func renderSVG(path string, w, h int) (image.Image, error) {
|
||||
in, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
icon, err := oksvg.ReadIconStream(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
icon.SetTarget(0, 0, float64(w), float64(h))
|
||||
rgba := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
gv := rasterx.NewScannerGV(w, h, rgba, rgba.Bounds())
|
||||
dasher := rasterx.NewDasher(w, h, gv)
|
||||
icon.Draw(dasher, 1.0)
|
||||
|
||||
return rgba, nil
|
||||
}
|
||||
|
||||
// Simple ICO encoder that wraps PNGs
|
||||
func writeICO(path string, images []image.Image) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
bw := bufio.NewWriter(f)
|
||||
defer bw.Flush()
|
||||
|
||||
// ICONDIR header
|
||||
// Reserved (2), Type (2), Count (2)
|
||||
binary.Write(bw, binary.LittleEndian, uint16(0))
|
||||
binary.Write(bw, binary.LittleEndian, uint16(1)) // 1 = ICO
|
||||
binary.Write(bw, binary.LittleEndian, uint16(len(images)))
|
||||
|
||||
var pngData [][]byte
|
||||
|
||||
for _, img := range images {
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pngData = append(pngData, buf.Bytes())
|
||||
}
|
||||
|
||||
offset := uint32(6 + len(images)*16)
|
||||
for i, img := range images {
|
||||
b := img.Bounds()
|
||||
|
||||
width := uint8(b.Dx())
|
||||
if b.Dx() >= 256 {
|
||||
width = 0
|
||||
}
|
||||
|
||||
height := uint8(b.Dy())
|
||||
if b.Dy() >= 256 {
|
||||
height = 0
|
||||
}
|
||||
|
||||
// ICONDIRENTRY
|
||||
bw.WriteByte(width)
|
||||
bw.WriteByte(height)
|
||||
bw.WriteByte(0) // Color count
|
||||
bw.WriteByte(0) // Reserved
|
||||
binary.Write(bw, binary.LittleEndian, uint16(1)) // Planes (1)
|
||||
binary.Write(bw, binary.LittleEndian, uint16(32)) // Bits per pixel (32)
|
||||
binary.Write(bw, binary.LittleEndian, uint32(len(pngData[i])))
|
||||
binary.Write(bw, binary.LittleEndian, offset)
|
||||
|
||||
offset += uint32(len(pngData[i]))
|
||||
}
|
||||
|
||||
for _, data := range pngData {
|
||||
bw.Write(data)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
// Package main provides a mock Amazon LWA server for testing purposes.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/testutils/amazon"
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := flag.Int("port", 8080, "Port to listen on")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
log.Printf("Starting mock Amazon LWA server on port %d", *port)
|
||||
|
||||
if err := http.ListenAndServe(fmt.Sprintf(":%d", *port), amazon.NewAmazonHandler()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
// Package main provides a mock Spotify server for testing purposes.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/testutils/spotify"
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := flag.Int("port", 8080, "Port to listen on")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
log.Printf("Starting mock Spotify server on port %d", *port)
|
||||
|
||||
if err := http.ListenAndServe(fmt.Sprintf(":%d", *port), spotify.NewSpotifyHandler()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
# soundtouch-backup
|
||||
|
||||
A standalone tool for backing up Bose SoundTouch data — both your **cloud account** (presets, devices, sources) and the **local filesystem** of each speaker — before the Bose cloud services shut down on May 6, 2026.
|
||||
|
||||
## Overview
|
||||
|
||||
| Subcommand | What it backs up |
|
||||
|------------|----------------------------------------------------------------------------------------------------|
|
||||
| `all` | Cloud account **and** all paired speakers in one step — the recommended starting point |
|
||||
| `cloud` | Bose account profile, paired devices, cloud presets, music service sources |
|
||||
| `local` | Speaker HTTP API data (presets, sources, volume, …) and optionally device filesystem files via SSH |
|
||||
|
||||
Output is a single `.tar.gz` archive (or `.zip`) with a dated root directory.
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
make build-backup
|
||||
# binary: ./build/soundtouch-backup
|
||||
```
|
||||
|
||||
Or install alongside the other tools:
|
||||
|
||||
```bash
|
||||
make install
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Combined backup (recommended)
|
||||
|
||||
The `all` command is the simplest way to capture everything: it authenticates with the Bose cloud, backs up your account data, then reads the IP addresses from `devices.xml` and backs up each reachable speaker over HTTP.
|
||||
|
||||
```bash
|
||||
# Interactive — prompts for email and password
|
||||
soundtouch-backup all
|
||||
|
||||
# Non-interactive
|
||||
soundtouch-backup all --email you@example.com --password secret
|
||||
|
||||
# Include SSH filesystem backup for each speaker
|
||||
soundtouch-backup all --ssh
|
||||
|
||||
# Environment variables
|
||||
BOSE_EMAIL=you@example.com BOSE_PASSWORD=secret soundtouch-backup all --ssh
|
||||
```
|
||||
|
||||
**Flags**
|
||||
|
||||
| Flag | Short | Default | Description |
|
||||
|--------------|--------|---------------------------------------|--------------------------------------------------------|
|
||||
| `--email` | `-e` | — | Bose account email (`$BOSE_EMAIL`) |
|
||||
| `--password` | `--pw` | — | Bose account password (`$BOSE_PASSWORD`) |
|
||||
| `--ssh` | | on | Also capture filesystem files via SSH for each speaker |
|
||||
| `--output` | `-o` | `soundtouch-backup-YYYY-MM-DD.tar.gz` | Output archive path |
|
||||
| `--format` | | `tar.gz` | Archive format: `tar.gz` or `zip` |
|
||||
|
||||
Speakers that are offline or unreachable at the time of backup are skipped with a `✗` warning; the cloud data is still saved.
|
||||
|
||||
---
|
||||
|
||||
### Cloud backup
|
||||
|
||||
Backs up data from your Bose account at `streaming.bose.com`. Credentials are prompted interactively if not supplied as flags.
|
||||
|
||||
```bash
|
||||
# Interactive — prompts for email, masked password input
|
||||
soundtouch-backup cloud
|
||||
|
||||
# Non-interactive
|
||||
soundtouch-backup cloud --email you@example.com --password secret
|
||||
|
||||
# Environment variables (avoids secrets in shell history)
|
||||
BOSE_EMAIL=you@example.com BOSE_PASSWORD=secret soundtouch-backup cloud
|
||||
|
||||
# Zip output
|
||||
soundtouch-backup cloud --format zip --output my-bose-cloud.zip
|
||||
```
|
||||
|
||||
**Flags**
|
||||
|
||||
| Flag | Short | Default | Description |
|
||||
|--------------|--------|---------------------------------------|---------------------------------------------------|
|
||||
| `--email` | `-e` | — | Bose account email (`$BOSE_EMAIL`) |
|
||||
| `--password` | `--pw` | — | Bose account password (`$BOSE_PASSWORD`) |
|
||||
| `--output` | `-o` | `soundtouch-backup-YYYY-MM-DD.tar.gz` | Output archive path (`$SOUNDTOUCH_BACKUP_OUTPUT`) |
|
||||
| `--format` | | `tar.gz` | Archive format: `tar.gz` or `zip` |
|
||||
|
||||
**What gets fetched**
|
||||
|
||||
| File in archive | Source endpoint |
|
||||
|--------------------------|---------------------------------------------------------------------------------|
|
||||
| `cloud/emailaddress.xml` | `GET /streaming/account/{id}/emailaddress` |
|
||||
| `cloud/devices.xml` | `GET /streaming/account/{id}/devices` |
|
||||
| `cloud/sources.xml` | `GET /streaming/account/{id}/sources` |
|
||||
| `cloud/presets.xml` | `GET /streaming/account/{id}/presets/all` |
|
||||
| `cloud/full.xml` | `GET /streaming/account/{id}/full` (may overlap with the above; skipped if 4xx) |
|
||||
|
||||
---
|
||||
|
||||
### Local backup
|
||||
|
||||
Backs up each speaker over its HTTP API on port 8090. With `--ssh`, also captures key filesystem files via SSH.
|
||||
|
||||
```bash
|
||||
# Auto-discover all speakers on the local network
|
||||
soundtouch-backup local
|
||||
|
||||
# Specific speaker
|
||||
soundtouch-backup local --host 192.0.2.11
|
||||
|
||||
# Multiple speakers
|
||||
soundtouch-backup local --host 192.0.2.11 --host 192.0.2.10
|
||||
|
||||
# Include SSH filesystem backup
|
||||
soundtouch-backup local --ssh
|
||||
|
||||
# Longer discovery window on busy networks
|
||||
soundtouch-backup local --discover-timeout 10s
|
||||
```
|
||||
|
||||
**Flags**
|
||||
|
||||
| Flag | Short | Default | Description |
|
||||
|----------------------|-------|---------------------------------------|--------------------------------------------------|
|
||||
| `--host` | `-H` | — | Speaker host/IP, repeatable (`$SOUNDTOUCH_HOST`) |
|
||||
| `--port` | `-p` | `8090` | Speaker HTTP port (`$SOUNDTOUCH_PORT`) |
|
||||
| `--discover` | `-d` | auto | Force mDNS/UPnP discovery |
|
||||
| `--discover-timeout` | | `5s` | Discovery timeout |
|
||||
| `--ssh` | | on | Also capture filesystem files via SSH |
|
||||
| `--output` | `-o` | `soundtouch-backup-YYYY-MM-DD.tar.gz` | Output archive path |
|
||||
| `--format` | | `tar.gz` | Archive format: `tar.gz` or `zip` |
|
||||
|
||||
**What gets fetched via HTTP**
|
||||
|
||||
| File | Device endpoint |
|
||||
|---------------------|-----------------|
|
||||
| `info.xml` | `/info` |
|
||||
| `name.xml` | `/name` |
|
||||
| `presets.xml` | `/presets` |
|
||||
| `sources.xml` | `/sources` |
|
||||
| `now_playing.xml` | `/now_playing` |
|
||||
| `volume.xml` | `/volume` |
|
||||
| `bass.xml` | `/bass` |
|
||||
| `balance.xml` | `/balance` |
|
||||
| `capabilities.xml` | `/capabilities` |
|
||||
| `network_info.xml` | `/networkInfo` |
|
||||
| `clock_display.xml` | `/clockDisplay` |
|
||||
| `zone.xml` | `/getZone` |
|
||||
|
||||
Endpoints that return HTTP 4xx (not supported on the device model) are silently skipped.
|
||||
|
||||
**What gets fetched via SSH** (`--ssh`)
|
||||
|
||||
SSH connects as `root@<host>:22` with an empty password, which is the default for SoundTouch firmware.
|
||||
|
||||
Individual files:
|
||||
|
||||
| Remote path | Notes |
|
||||
|---------------------------|--------------------------------------------|
|
||||
| `/etc/hosts` | DNS redirect state |
|
||||
| `/etc/resolv.conf` | DNS resolver configuration |
|
||||
| `/etc/remote_services` | Service registration (post-migration only) |
|
||||
| `/mnt/nv/remote_services` | Alternative location for remote services |
|
||||
|
||||
Directories (all regular files recursively):
|
||||
|
||||
| Remote path | Contents |
|
||||
|----------------------------------|----------------------------------------------------------------------------|
|
||||
| `/opt/Bose/etc/` | Full Bose configuration directory, including `SoundTouchSdkPrivateCfg.xml` |
|
||||
| `/mnt/nv/BoseApp-Persistence/1/` | Persisted app state |
|
||||
|
||||
Missing files and directories are silently skipped with a `⚠` warning.
|
||||
|
||||
---
|
||||
|
||||
## Archive structure
|
||||
|
||||
Both subcommands write into a single dated archive:
|
||||
|
||||
```
|
||||
soundtouch-backup-2026-05-02/
|
||||
├── cloud/
|
||||
│ ├── emailaddress.xml
|
||||
│ ├── devices.xml
|
||||
│ ├── sources.xml
|
||||
│ └── presets.xml
|
||||
└── local/
|
||||
├── A_Sound_Machine/
|
||||
│ ├── info.xml
|
||||
│ ├── presets.xml
|
||||
│ ├── sources.xml
|
||||
│ ├── volume.xml
|
||||
│ ├── …
|
||||
│ └── ssh/
|
||||
│ ├── etc/
|
||||
│ │ ├── hosts
|
||||
│ │ └── resolv.conf
|
||||
│ ├── opt/Bose/etc/
|
||||
│ │ └── SoundTouchSdkPrivateCfg.xml
|
||||
│ └── mnt/nv/BoseApp-Persistence/1/
|
||||
└── Sound_Machinechen/
|
||||
└── …
|
||||
```
|
||||
|
||||
Running `cloud` and `local` separately produces two archives. To combine them, use the same `--output` path for both invocations — each adds its own subdirectory so they won't collide (`.tar.gz` does not support appending; use `--format zip` if you need a single archive from two runs, or just keep them separate).
|
||||
|
||||
## See also
|
||||
|
||||
- [Cloud Shutdown Survival Guide](../../docs/guides/SURVIVAL-GUIDE.md) — full migration context
|
||||
- [`soundtouch-cli`](../soundtouch-cli/) — live device control
|
||||
- [`soundtouch-service`](../soundtouch-service/) — local cloud replacement
|
||||
@@ -1,119 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func allCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "all",
|
||||
Usage: "Back up cloud account then all paired speakers in one go",
|
||||
Description: "Authenticates with the Bose cloud, backs up account data, then reads" +
|
||||
" the device IP addresses from the cloud device list and backs up each reachable" +
|
||||
" speaker over HTTP (and optionally SSH).",
|
||||
Flags: append(outputFlags,
|
||||
&cli.StringFlag{
|
||||
Name: "email",
|
||||
Aliases: []string{"e"},
|
||||
Usage: "Bose account email",
|
||||
EnvVars: []string{"BOSE_EMAIL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "password",
|
||||
Aliases: []string{"pw"},
|
||||
Usage: "Bose account password",
|
||||
EnvVars: []string{"BOSE_PASSWORD"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "ssh",
|
||||
Usage: "Also back up device filesystem files via SSH (root@host:22, no password required)",
|
||||
Value: true,
|
||||
},
|
||||
),
|
||||
Action: runAllBackup,
|
||||
}
|
||||
}
|
||||
|
||||
func runAllBackup(c *cli.Context) error {
|
||||
doSSH := c.Bool("ssh")
|
||||
output := resolveOutputPath(c.String("output"), c.String("format"))
|
||||
format := c.String("format")
|
||||
|
||||
// 1. Cloud backup
|
||||
client, err := setupCloudClient(c.String("email"), c.String("password"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
root := archiveRoot()
|
||||
files := collectCloudFiles(client, root)
|
||||
|
||||
if len(files) == 0 {
|
||||
return fmt.Errorf("no cloud data fetched")
|
||||
}
|
||||
|
||||
// 2. Resolve speakers from devices.xml, then back each one up
|
||||
devicesData := files[root+"/cloud/devices.xml"]
|
||||
if devicesData == nil {
|
||||
printWarn("devices.xml not available — skipping local backup")
|
||||
} else {
|
||||
targets := parseDevicesXML(devicesData)
|
||||
if len(targets) == 0 {
|
||||
printWarn("no device IP addresses found in devices.xml")
|
||||
} else {
|
||||
fmt.Printf("Found %d device(s) in cloud account, attempting local backup...\n", len(targets))
|
||||
}
|
||||
|
||||
hc := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
for k, v := range collectLocalFiles(hc, targets, root, doSSH) {
|
||||
files[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeArchive(output, format, files); err != nil {
|
||||
return fmt.Errorf("writing archive: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Archive written: %s (%d files)\n", output, len(files))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type xmlDevice struct {
|
||||
Name string `xml:"name"`
|
||||
IPAddress string `xml:"ipaddress"`
|
||||
}
|
||||
|
||||
type xmlDevices struct {
|
||||
XMLName xml.Name `xml:"devices"`
|
||||
Devices []xmlDevice `xml:"device"`
|
||||
}
|
||||
|
||||
// parseDevicesXML extracts speaker targets from a devices.xml cloud response.
|
||||
func parseDevicesXML(data []byte) []speakerTarget {
|
||||
var d xmlDevices
|
||||
|
||||
if err := xml.Unmarshal(data, &d); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var targets []speakerTarget
|
||||
|
||||
for _, dev := range d.Devices {
|
||||
if dev.IPAddress == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Pass name as a hint for error messages; backupSpeakerHTTP re-fetches
|
||||
// from /info to get the current name and include info.xml in the archive.
|
||||
targets = append(targets, speakerTarget{host: dev.IPAddress, port: 8090, name: dev.Name})
|
||||
}
|
||||
|
||||
return targets
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
streamingBase = "https://streaming.bose.com"
|
||||
streamingCT = "application/vnd.bose.streaming-v1.1+xml"
|
||||
stockholmVer = "27.0.13-4277+8963611.epdbuild.develop.hepdswbld04.2025-10-02T13:17:00"
|
||||
nativeFrameVer = "27.0.2 -3353+4ae7c78.epdbuild.HEAD.ssgbld02.2023-10-12T15:10Z"
|
||||
protocolVer = "67"
|
||||
appGUID = "b94dedd1-a61b-492b-b86b-2bc32c9261f4"
|
||||
appUserAgent = "Mozilla/5.0 (Linux; Android 13; Android SDK built for arm64 Build/TE1A.220922.034; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/101.0.4951.61 Mobile Safari/537.36 Manufacturer/unknown DeviceModel/Android-SDK-built-for-arm64 SOUNDTOUCH_MOBILE_APP/" + appGUID
|
||||
)
|
||||
|
||||
func cloudCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "cloud",
|
||||
Usage: "Back up your Bose SoundTouch cloud account (devices, presets, sources)",
|
||||
Flags: append(outputFlags,
|
||||
&cli.StringFlag{
|
||||
Name: "email",
|
||||
Aliases: []string{"e"},
|
||||
Usage: "Bose account email",
|
||||
EnvVars: []string{"BOSE_EMAIL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "password",
|
||||
Aliases: []string{"pw"},
|
||||
Usage: "Bose account password",
|
||||
EnvVars: []string{"BOSE_PASSWORD"},
|
||||
},
|
||||
),
|
||||
Action: runCloudBackup,
|
||||
}
|
||||
}
|
||||
|
||||
func runCloudBackup(c *cli.Context) error {
|
||||
output := resolveOutputPath(c.String("output"), c.String("format"))
|
||||
format := c.String("format")
|
||||
|
||||
client, err := setupCloudClient(c.String("email"), c.String("password"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
root := archiveRoot()
|
||||
files := collectCloudFiles(client, root)
|
||||
|
||||
if len(files) == 0 {
|
||||
return fmt.Errorf("no data fetched")
|
||||
}
|
||||
|
||||
if err := writeArchive(output, format, files); err != nil {
|
||||
return fmt.Errorf("writing archive: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Archive written: %s (%d files)\n", output, len(files))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setupCloudClient prompts for missing credentials, then authenticates with the Bose cloud.
|
||||
func setupCloudClient(email, password string) (*cloudClient, error) {
|
||||
if email == "" || password == "" {
|
||||
var err error
|
||||
|
||||
email, password, err = promptCredentials(email)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("credentials: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if email == "" || password == "" {
|
||||
return nil, fmt.Errorf("email and password are required")
|
||||
}
|
||||
|
||||
fmt.Printf("Authenticating as %s...\n", email)
|
||||
|
||||
client, err := loginToCloud(email, password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("authentication failed: %w", err)
|
||||
}
|
||||
|
||||
printOK(fmt.Sprintf("Authenticated (account ID: %s)", client.accountID))
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// collectCloudFiles fetches all cloud account data and returns a files map ready for
|
||||
// archiving. Keys are prefixed with root (e.g. "soundtouch-backup-2026-05-02/cloud/").
|
||||
func collectCloudFiles(client *cloudClient, root string) map[string][]byte {
|
||||
type cloudEndpoint struct {
|
||||
label string
|
||||
filename string
|
||||
fetch func(*cloudClient) ([]byte, error)
|
||||
}
|
||||
|
||||
endpoints := []cloudEndpoint{
|
||||
{"email address", "emailaddress.xml", fetchEmailAddress},
|
||||
{"devices", "devices.xml", fetchDevices},
|
||||
{"sources", "sources.xml", fetchSources},
|
||||
{"presets", "presets.xml", fetchPresets},
|
||||
{"full account", "full.xml", fetchFull},
|
||||
}
|
||||
|
||||
files := make(map[string][]byte)
|
||||
|
||||
for _, ep := range endpoints {
|
||||
data, err := ep.fetch(client)
|
||||
if err != nil {
|
||||
printFail(fmt.Sprintf("%s: %v", ep.label, err))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
files[root+"/cloud/"+ep.filename] = data
|
||||
printOK(fmt.Sprintf("%s (%d bytes)", ep.label, len(data)))
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
type cloudClient struct {
|
||||
http *http.Client
|
||||
accountID string
|
||||
token string
|
||||
}
|
||||
|
||||
type loginXML struct {
|
||||
XMLName xml.Name `xml:"login"`
|
||||
Username string `xml:"username"`
|
||||
Password string `xml:"password"`
|
||||
}
|
||||
|
||||
var accountIDRe = regexp.MustCompile(`<account\s+id="([^"]+)"`)
|
||||
|
||||
func loginToCloud(email, password string) (*cloudClient, error) {
|
||||
loginBody, err := xml.Marshal(loginXML{Username: email, Password: password})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body := []byte(`<?xml version="1.0" encoding="UTF-8"?>`)
|
||||
body = append(body, loginBody...)
|
||||
|
||||
req, err := http.NewRequest("POST", streamingBase+"/streaming/account/login", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
setStreamingHeaders(req, "")
|
||||
|
||||
hc := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
token := resp.Header.Get("credentials")
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("no credentials in response — check your email and password")
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m := accountIDRe.FindSubmatch(data)
|
||||
if len(m) < 2 {
|
||||
return nil, fmt.Errorf("could not extract account ID from login response")
|
||||
}
|
||||
|
||||
return &cloudClient{http: hc, accountID: string(m[1]), token: token}, nil
|
||||
}
|
||||
|
||||
func setStreamingHeaders(req *http.Request, token string) {
|
||||
req.Header.Set("content-type", streamingCT)
|
||||
req.Header.Set("accept", streamingCT)
|
||||
req.Header.Set("clienttype", "SOUNDTOUCH_MOBILE_APP")
|
||||
req.Header.Set("version_stockholmversion", stockholmVer)
|
||||
req.Header.Set("version_nativeframeversion", nativeFrameVer)
|
||||
req.Header.Set("version_protocolversion", protocolVer)
|
||||
req.Header.Set("user-agent", appUserAgent)
|
||||
req.Header.Set("guid", appGUID)
|
||||
req.Header.Set("x-requested-with", "com.bose.soundtouch")
|
||||
req.Header.Set("pragma", "no-cache")
|
||||
req.Header.Set("cache-control", "no-cache")
|
||||
|
||||
if token != "" {
|
||||
req.Header.Set("authorization", token)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *cloudClient) get(path string) ([]byte, error) {
|
||||
url := fmt.Sprintf("%s%s?_=%d", streamingBase, path, time.Now().UnixMilli())
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
setStreamingHeaders(req, c.token)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
|
||||
}
|
||||
|
||||
func fetchEmailAddress(c *cloudClient) ([]byte, error) {
|
||||
return c.get("/streaming/account/" + c.accountID + "/emailaddress")
|
||||
}
|
||||
|
||||
func fetchDevices(c *cloudClient) ([]byte, error) {
|
||||
return c.get("/streaming/account/" + c.accountID + "/devices")
|
||||
}
|
||||
|
||||
func fetchSources(c *cloudClient) ([]byte, error) {
|
||||
return c.get("/streaming/account/" + c.accountID + "/sources")
|
||||
}
|
||||
|
||||
func fetchPresets(c *cloudClient) ([]byte, error) {
|
||||
return c.get("/streaming/account/" + c.accountID + "/presets/all")
|
||||
}
|
||||
|
||||
func fetchFull(c *cloudClient) ([]byte, error) {
|
||||
return c.get("/streaming/account/" + c.accountID + "/full")
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/config"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/ssh"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
var localEndpoints = []struct {
|
||||
path string
|
||||
file string
|
||||
}{
|
||||
{"/info", "info.xml"},
|
||||
{"/name", "name.xml"},
|
||||
{"/presets", "presets.xml"},
|
||||
{"/sources", "sources.xml"},
|
||||
{"/now_playing", "now_playing.xml"},
|
||||
{"/volume", "volume.xml"},
|
||||
{"/bass", "bass.xml"},
|
||||
{"/balance", "balance.xml"},
|
||||
{"/capabilities", "capabilities.xml"},
|
||||
{"/networkInfo", "network_info.xml"},
|
||||
{"/clockDisplay", "clock_display.xml"},
|
||||
{"/getZone", "zone.xml"},
|
||||
}
|
||||
|
||||
// sshFiles lists individual device filesystem paths captured via SSH.
|
||||
// Paths that may not exist on all devices are silently skipped.
|
||||
var sshFiles = []string{
|
||||
"/etc/hosts",
|
||||
"/etc/resolv.conf",
|
||||
"/etc/remote_services",
|
||||
"/mnt/nv/remote_services",
|
||||
}
|
||||
|
||||
// sshDirs lists device directories whose contents are recursively captured via SSH.
|
||||
var sshDirs = []string{
|
||||
"/opt/Bose/etc",
|
||||
"/mnt/nv/BoseApp-Persistence/1",
|
||||
}
|
||||
|
||||
func localCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "local",
|
||||
Usage: "Back up one or more SoundTouch speakers on your local network",
|
||||
Flags: append(outputFlags,
|
||||
&cli.StringSliceFlag{
|
||||
Name: "host",
|
||||
Aliases: []string{"H"},
|
||||
Usage: "Speaker host/IP (repeatable for multiple speakers)",
|
||||
EnvVars: []string{"SOUNDTOUCH_HOST"},
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "port",
|
||||
Aliases: []string{"p"},
|
||||
Usage: "Speaker HTTP port",
|
||||
Value: 8090,
|
||||
EnvVars: []string{"SOUNDTOUCH_PORT"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "discover",
|
||||
Aliases: []string{"d"},
|
||||
Usage: "Auto-discover speakers on the local network",
|
||||
},
|
||||
&cli.DurationFlag{
|
||||
Name: "discover-timeout",
|
||||
Usage: "Discovery timeout",
|
||||
Value: 5 * time.Second,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "ssh",
|
||||
Usage: "Also back up device filesystem files via SSH (root@host:22, no password required)",
|
||||
Value: true,
|
||||
},
|
||||
),
|
||||
Action: runLocalBackup,
|
||||
}
|
||||
}
|
||||
|
||||
type speakerTarget struct {
|
||||
host string
|
||||
port int
|
||||
name string
|
||||
}
|
||||
|
||||
func runLocalBackup(c *cli.Context) error {
|
||||
hosts := c.StringSlice("host")
|
||||
port := c.Int("port")
|
||||
doDiscover := c.Bool("discover") || len(hosts) == 0
|
||||
discoverTimeout := c.Duration("discover-timeout")
|
||||
doSSH := c.Bool("ssh")
|
||||
output := resolveOutputPath(c.String("output"), c.String("format"))
|
||||
format := c.String("format")
|
||||
|
||||
var targets []speakerTarget
|
||||
|
||||
if doDiscover {
|
||||
fmt.Printf("Discovering speakers (timeout: %s)...\n", discoverTimeout)
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Context, discoverTimeout)
|
||||
defer cancel()
|
||||
|
||||
cfg, _ := config.LoadFromEnv()
|
||||
svc := discovery.NewUnifiedDiscoveryService(cfg)
|
||||
|
||||
found, discErr := svc.DiscoverDevices(ctx)
|
||||
if discErr != nil {
|
||||
printWarn(fmt.Sprintf("Discovery failed: %v", discErr))
|
||||
}
|
||||
|
||||
for _, d := range found {
|
||||
targets = append(targets, speakerTarget{host: d.Host, port: d.Port, name: d.Name})
|
||||
printOK(fmt.Sprintf("Found: %s (%s:%d)", d.Name, d.Host, d.Port))
|
||||
}
|
||||
}
|
||||
|
||||
for _, h := range hosts {
|
||||
targets = append(targets, speakerTarget{host: h, port: port})
|
||||
}
|
||||
|
||||
if len(targets) == 0 {
|
||||
return fmt.Errorf("no speakers found — use --host <ip> or --discover")
|
||||
}
|
||||
|
||||
hc := &http.Client{Timeout: 10 * time.Second}
|
||||
root := archiveRoot()
|
||||
files := collectLocalFiles(hc, targets, root, doSSH)
|
||||
|
||||
if len(files) == 0 {
|
||||
return fmt.Errorf("no data collected")
|
||||
}
|
||||
|
||||
if err := writeArchive(output, format, files); err != nil {
|
||||
return fmt.Errorf("writing archive: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Archive written: %s (%d files)\n", output, len(files))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// collectLocalFiles backs up all targets over HTTP (and optionally SSH) and returns
|
||||
// a files map ready for archiving. Keys are prefixed with root.
|
||||
func collectLocalFiles(hc *http.Client, targets []speakerTarget, root string, doSSH bool) map[string][]byte {
|
||||
files := make(map[string][]byte)
|
||||
|
||||
for _, t := range targets {
|
||||
name, entries, err := backupSpeakerHTTP(hc, t)
|
||||
if err != nil {
|
||||
printFail(fmt.Sprintf("%s:%d — %v", t.host, t.port, err))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
dir := root + "/local/" + sanitizeName(name) + "/"
|
||||
|
||||
for filename, data := range entries {
|
||||
files[dir+filename] = data
|
||||
}
|
||||
|
||||
printOK(fmt.Sprintf("%s: %d files via HTTP", name, len(entries)))
|
||||
|
||||
if doSSH {
|
||||
sshEntries := backupSpeakerSSH(t.host, name)
|
||||
|
||||
for filename, data := range sshEntries {
|
||||
files[dir+filename] = data
|
||||
}
|
||||
|
||||
if len(sshEntries) > 0 {
|
||||
printOK(fmt.Sprintf("%s: %d files via SSH", name, len(sshEntries)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
func backupSpeakerHTTP(hc *http.Client, t speakerTarget) (name string, files map[string][]byte, err error) {
|
||||
base := fmt.Sprintf("http://%s:%d", t.host, t.port)
|
||||
files = make(map[string][]byte)
|
||||
name = t.name
|
||||
infoFetched := false
|
||||
|
||||
if name == "" {
|
||||
data, ferr := fetchRaw(hc, base+"/info")
|
||||
if ferr != nil {
|
||||
return "", nil, fmt.Errorf("cannot reach %s: %w", base, ferr)
|
||||
}
|
||||
|
||||
files["info.xml"] = data
|
||||
infoFetched = true
|
||||
|
||||
if extracted := xmlFirst(data, "name"); extracted != "" {
|
||||
name = extracted
|
||||
} else {
|
||||
name = t.host
|
||||
}
|
||||
}
|
||||
|
||||
for _, ep := range localEndpoints {
|
||||
if ep.path == "/info" && infoFetched {
|
||||
continue
|
||||
}
|
||||
|
||||
data, ferr := fetchRaw(hc, base+ep.path)
|
||||
if ferr != nil {
|
||||
printWarn(fmt.Sprintf("%s: skipped %s (%v)", name, ep.file, ferr))
|
||||
continue
|
||||
}
|
||||
|
||||
files[ep.file] = data
|
||||
}
|
||||
|
||||
return name, files, nil
|
||||
}
|
||||
|
||||
// backupSpeakerSSH connects to the device via SSH and reads the key filesystem paths.
|
||||
// Files that don't exist on the device are silently skipped.
|
||||
// Returned map keys are relative paths within the device backup directory (e.g. "ssh/etc/hosts").
|
||||
func backupSpeakerSSH(host, deviceName string) map[string][]byte {
|
||||
client := ssh.NewClient(host)
|
||||
files := make(map[string][]byte)
|
||||
|
||||
for _, remotePath := range sshFiles {
|
||||
data, err := client.ReadFile(remotePath)
|
||||
if err != nil {
|
||||
// Most missing files are expected (e.g. /etc/remote_services only exists post-migration)
|
||||
printWarn(fmt.Sprintf("%s: SSH skipped %s (%v)", deviceName, remotePath, err))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
printWarn(fmt.Sprintf("%s: SSH empty file %s", deviceName, remotePath))
|
||||
}
|
||||
|
||||
files["ssh"+remotePath] = data
|
||||
}
|
||||
|
||||
for _, remoteDir := range sshDirs {
|
||||
dirFiles, err := client.ReadDir(remoteDir)
|
||||
if err != nil {
|
||||
printWarn(fmt.Sprintf("%s: SSH skipped dir %s (%v)", deviceName, remoteDir, err))
|
||||
continue
|
||||
}
|
||||
|
||||
for path, data := range dirFiles {
|
||||
files["ssh"+path] = data
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
func fetchRaw(hc *http.Client, url string) ([]byte, error) {
|
||||
resp, err := hc.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
|
||||
}
|
||||
|
||||
func xmlFirst(data []byte, field string) string {
|
||||
re := regexp.MustCompile(`<` + regexp.QuoteMeta(field) + `[^>]*>([^<]+)</` + regexp.QuoteMeta(field) + `>`)
|
||||
|
||||
m := re.FindSubmatch(data)
|
||||
if len(m) >= 2 {
|
||||
return strings.TrimSpace(string(m[1]))
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
const (
|
||||
FormatTarGz = "tar.gz"
|
||||
FormatZip = "zip"
|
||||
)
|
||||
|
||||
var outputFlags = []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "output",
|
||||
Aliases: []string{"o"},
|
||||
Usage: "Output archive file (default: soundtouch-backup-YYYY-MM-DD.tar.gz)",
|
||||
EnvVars: []string{"SOUNDTOUCH_BACKUP_OUTPUT"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "format",
|
||||
Usage: "Archive format: tar.gz or zip",
|
||||
Value: FormatTarGz,
|
||||
},
|
||||
}
|
||||
|
||||
func resolveOutputPath(output, format string) string {
|
||||
date := time.Now().Format("2006-01-02")
|
||||
|
||||
ext := ".tar.gz"
|
||||
if format == FormatZip {
|
||||
ext = ".zip"
|
||||
}
|
||||
|
||||
filename := "soundtouch-backup-" + date + ext
|
||||
|
||||
if output == "" {
|
||||
return filename
|
||||
}
|
||||
|
||||
if info, err := os.Stat(output); err == nil && info.IsDir() {
|
||||
return output + string(os.PathSeparator) + filename
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
func archiveRoot() string {
|
||||
return "soundtouch-backup-" + time.Now().Format("2006-01-02")
|
||||
}
|
||||
|
||||
func writeArchive(outputPath, format string, files map[string][]byte) error {
|
||||
if format == FormatZip {
|
||||
return writeZip(outputPath, files)
|
||||
}
|
||||
|
||||
return writeTarGz(outputPath, files)
|
||||
}
|
||||
|
||||
func writeTarGz(outputPath string, files map[string][]byte) error {
|
||||
f, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
gz := gzip.NewWriter(f)
|
||||
defer gz.Close()
|
||||
|
||||
tw := tar.NewWriter(gz)
|
||||
defer tw.Close()
|
||||
|
||||
now := time.Now()
|
||||
for name, data := range files {
|
||||
hdr := &tar.Header{
|
||||
Name: name,
|
||||
Mode: 0644,
|
||||
Size: int64(len(data)),
|
||||
ModTime: now,
|
||||
Typeflag: tar.TypeReg,
|
||||
}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
return fmt.Errorf("tar header %s: %w", name, err)
|
||||
}
|
||||
|
||||
if _, err := tw.Write(data); err != nil {
|
||||
return fmt.Errorf("tar write %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeZip(outputPath string, files map[string][]byte) error {
|
||||
f, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
zw := zip.NewWriter(f)
|
||||
defer zw.Close()
|
||||
|
||||
for name, data := range files {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("zip entry %s: %w", name, err)
|
||||
}
|
||||
|
||||
if _, err := w.Write(data); err != nil {
|
||||
return fmt.Errorf("zip write %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func promptCredentials(emailHint string) (email, password string, err error) {
|
||||
r := bufio.NewReader(os.Stdin)
|
||||
|
||||
if emailHint != "" {
|
||||
email = emailHint
|
||||
} else {
|
||||
fmt.Print("Bose account email: ")
|
||||
|
||||
email, err = r.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
email = strings.TrimSpace(email)
|
||||
}
|
||||
|
||||
fmt.Print("Password: ")
|
||||
|
||||
raw, termErr := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
|
||||
fmt.Println()
|
||||
|
||||
if termErr != nil {
|
||||
err = fmt.Errorf("reading password: %w (tip: use --password flag or BOSE_PASSWORD env var)", termErr)
|
||||
return
|
||||
}
|
||||
|
||||
password = string(raw)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func sanitizeName(name string) string {
|
||||
r := strings.NewReplacer(
|
||||
"/", "_", "\\", "_", ":", "_",
|
||||
"*", "_", "?", "_", "\"", "_",
|
||||
"<", "_", ">", "_", "|", "_",
|
||||
" ", "_",
|
||||
)
|
||||
|
||||
return r.Replace(name)
|
||||
}
|
||||
|
||||
func printOK(msg string) { fmt.Printf(" ✓ %s\n", msg) }
|
||||
func printFail(msg string) { fmt.Printf(" ✗ %s\n", msg) }
|
||||
func printWarn(msg string) { fmt.Printf(" ⚠ %s\n", msg) }
|
||||
@@ -1,37 +0,0 @@
|
||||
// Package main implements the soundtouch-backup tool for backing up Bose SoundTouch
|
||||
// cloud account data and local speaker filesystem files.
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
func init() {
|
||||
if info, ok := debug.ReadBuildInfo(); ok {
|
||||
if info.Main.Version != "" && info.Main.Version != "(devel)" {
|
||||
version = info.Main.Version
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
app := &cli.App{
|
||||
Name: "soundtouch-backup",
|
||||
Usage: "Back up Bose SoundTouch account and speaker data",
|
||||
Version: version,
|
||||
Commands: []*cli.Command{
|
||||
allCommand(),
|
||||
cloudCommand(),
|
||||
localCommand(),
|
||||
},
|
||||
}
|
||||
if err := app.Run(os.Args); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -652,73 +652,6 @@ func listMusicServiceAccounts(c *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// pairDevice triggers the Stockholm registration flow via WebSocket
|
||||
func pairDevice(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
accountID := c.String("id")
|
||||
token := c.String("token")
|
||||
|
||||
PrintDeviceHeader("Pairing device with Marge account", clientConfig.Host, clientConfig.Port)
|
||||
fmt.Printf(" Account ID: %s\n", accountID)
|
||||
|
||||
// We need a WebSocket client for this
|
||||
ws := client.NewWebSocketClient(nil)
|
||||
|
||||
err = ws.Connect()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to device WebSocket: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = ws.Disconnect() }()
|
||||
|
||||
err = ws.PairWithAccount(accountID, token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send pairing request: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Pairing request sent successfully")
|
||||
fmt.Println("💡 The device will now register itself with the cloud service.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// unpairDevice triggers the Stockholm unregistration flow via WebSocket
|
||||
func unpairDevice(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Unpairing device from Marge account", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
// We need a WebSocket client for this
|
||||
ws := client.NewWebSocketClient(nil)
|
||||
|
||||
err = ws.Connect()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to device WebSocket: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = ws.Disconnect() }()
|
||||
|
||||
err = ws.UnPairFromAccount()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send unpairing request: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Unpairing request sent successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getServiceDisplayName returns a user-friendly display name for a service
|
||||
func getServiceDisplayName(source string) string {
|
||||
switch source {
|
||||
|
||||
@@ -157,33 +157,6 @@ func setClockTimeNow(c *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// setClockDisplayTimezone POSTs only the timezoneInfo attribute,
|
||||
// leaving format/brightness untouched. Useful after a clock now to
|
||||
// make the speaker's logs and front-panel display tick in local time
|
||||
// instead of UTC.
|
||||
func setClockDisplayTimezone(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
tz := c.String("tz")
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Setting clock timezone to %s", tz), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
request := models.NewClockDisplayRequest().SetTimeZone(tz)
|
||||
if err := client.SetClockDisplay(request); err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to set timezone: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess(fmt.Sprintf("Timezone set to %s", tz))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getClockDisplay retrieves the current clock display settings
|
||||
func getClockDisplay(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
@@ -23,12 +23,6 @@ func eventSubscribe(c *cli.Context) error {
|
||||
filterStr := c.String("filter")
|
||||
filters := parseEventFilters(filterStr)
|
||||
|
||||
debugMode, err := parseDebugMode(c.String("debug"))
|
||||
if err != nil {
|
||||
PrintError(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse duration
|
||||
duration := c.Duration("duration")
|
||||
verbose := c.Bool("verbose")
|
||||
@@ -66,10 +60,6 @@ func eventSubscribe(c *cli.Context) error {
|
||||
// Set up event handlers
|
||||
setupEventHandlers(wsClient, filters, verbose)
|
||||
|
||||
if debugMode != debugOff {
|
||||
installDebugHook(wsClient, debugMode)
|
||||
}
|
||||
|
||||
// Connect to WebSocket
|
||||
fmt.Println("🔌 Connecting to WebSocket...")
|
||||
|
||||
@@ -137,78 +127,11 @@ func eventSubscribe(c *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// debugMode controls when the WebSocket subscribe loop prints raw frames
|
||||
// to stderr. "off" disables debug output entirely (the production default
|
||||
// when --debug is unset).
|
||||
type debugMode int
|
||||
|
||||
const (
|
||||
debugOff debugMode = iota
|
||||
debugAll
|
||||
debugUnknown
|
||||
debugErrors
|
||||
)
|
||||
|
||||
func parseDebugMode(s string) (debugMode, error) {
|
||||
switch strings.TrimSpace(s) {
|
||||
case "":
|
||||
return debugOff, nil
|
||||
case "all":
|
||||
return debugAll, nil
|
||||
case "unknown":
|
||||
return debugUnknown, nil
|
||||
case "errors":
|
||||
return debugErrors, nil
|
||||
default:
|
||||
return debugOff, fmt.Errorf("invalid --debug value %q (want one of: all, unknown, errors)", s)
|
||||
}
|
||||
}
|
||||
|
||||
// installDebugHook wires an OnRawMessage handler that prints the raw
|
||||
// frame to stderr based on the chosen mode. Stays out of stdout so
|
||||
// debug output can be filtered/grep'd independently of normal events.
|
||||
func installDebugHook(ws *client.WebSocketClient, mode debugMode) {
|
||||
ws.OnRawMessage(func(data []byte, parseErr error) {
|
||||
switch mode {
|
||||
case debugAll:
|
||||
printRawFrame(data, parseErr, "all")
|
||||
case debugErrors:
|
||||
if parseErr != nil {
|
||||
printRawFrame(data, parseErr, "errors")
|
||||
}
|
||||
case debugUnknown:
|
||||
// "Unknown" = parsed successfully but no known event types
|
||||
// matched. Parse errors also qualify, since they're frames
|
||||
// the client couldn't interpret either.
|
||||
if parseErr != nil {
|
||||
printRawFrame(data, parseErr, "unknown:parse-error")
|
||||
return
|
||||
}
|
||||
|
||||
ev, err := models.ParseWebSocketEvent(data)
|
||||
if err != nil || len(ev.GetEventTypes()) == 0 {
|
||||
printRawFrame(data, err, "unknown")
|
||||
}
|
||||
case debugOff:
|
||||
// nothing
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func printRawFrame(data []byte, parseErr error, tag string) {
|
||||
prefix := "[ws-debug:" + tag + "]"
|
||||
if parseErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s parse-error: %v\n", prefix, parseErr)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%s %s\n", prefix, string(data))
|
||||
}
|
||||
|
||||
// parseEventFilters validates and parses the filter string
|
||||
func parseEventFilters(eventFilter string) map[string]bool {
|
||||
validFilters := map[string]bool{
|
||||
"nowPlaying": true, "volume": true, "connection": true,
|
||||
"preset": true, "zone": true, "group": true, "bass": true,
|
||||
"preset": true, "zone": true, "bass": true,
|
||||
"sdkInfo": true, "userActivity": true,
|
||||
}
|
||||
|
||||
@@ -294,13 +217,6 @@ func setupEventHandlers(wsClient *client.WebSocketClient, filters map[string]boo
|
||||
})
|
||||
}
|
||||
|
||||
// Stereo-pair (group) events — ST-10 only
|
||||
if filters == nil || filters["group"] {
|
||||
wsClient.OnGroupUpdated(func(event *models.GroupUpdatedEvent) {
|
||||
handleGroupEvent(event)
|
||||
})
|
||||
}
|
||||
|
||||
// Bass events
|
||||
if filters == nil || filters["bass"] {
|
||||
wsClient.OnBassUpdated(func(event *models.BassUpdatedEvent) {
|
||||
@@ -413,11 +329,9 @@ func handlePresetEvent(event *models.PresetUpdatedEvent, verbose bool) {
|
||||
for _, preset := range presets.Preset {
|
||||
fmt.Printf(" 📻 Preset %d:", preset.ID)
|
||||
|
||||
// IsEmpty catches both <preset/> and INVALID_SOURCE
|
||||
// placeholders; using the nil-safe helpers below means the
|
||||
// inner Printf never dereferences a nil ContentItem.
|
||||
if !preset.IsEmpty() {
|
||||
fmt.Printf(" %s (%s)", preset.GetDisplayName(), preset.GetSource())
|
||||
if preset.ContentItem != nil {
|
||||
fmt.Printf(" %s", preset.ContentItem.ItemName)
|
||||
fmt.Printf(" (%s)", preset.ContentItem.Source)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
@@ -444,34 +358,6 @@ func handleZoneEvent(event *models.ZoneUpdatedEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
func handleGroupEvent(event *models.GroupUpdatedEvent) {
|
||||
group := &event.Group
|
||||
fmt.Printf("\n🎧 Stereo-Pair Update [%s]:\n", event.DeviceID)
|
||||
|
||||
if group.IsEmpty() {
|
||||
fmt.Println(" ⛓️💥 Pair dissolved (no group configured)")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf(" 🆔 ID: %s\n", group.ID)
|
||||
fmt.Printf(" 📛 Name: %s\n", group.Name)
|
||||
fmt.Printf(" 👑 Master: %s\n", group.MasterDeviceID)
|
||||
|
||||
if group.Status != "" {
|
||||
fmt.Printf(" ✅ Status: %s\n", group.Status)
|
||||
}
|
||||
|
||||
for _, r := range group.Roles.Roles {
|
||||
fmt.Printf(" %-5s %s", r.Role, r.DeviceID)
|
||||
|
||||
if r.IPAddress != "" {
|
||||
fmt.Printf(" (IP: %s)", r.IPAddress)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
func handleBassEvent(event *models.BassUpdatedEvent) {
|
||||
bass := &event.Bass
|
||||
fmt.Printf("\n🎵 Bass Update [%s]:\n", event.DeviceID)
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/speaker"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// getGroupStatus retrieves and prints the device's current stereo-pair state.
|
||||
func getGroupStatus(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader("Getting group information", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
group, err := client.GetGroup()
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to get group: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if group.IsEmpty() {
|
||||
fmt.Println("Device is not in a stereo pair")
|
||||
return nil
|
||||
}
|
||||
|
||||
printGroup(group)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createGroup forms a stereo pair by POSTing /addGroup to both speakers in
|
||||
// parallel. LEFT is the master. Addressing each speaker directly (instead of
|
||||
// only the master and letting it propagate via marge) sidesteps the
|
||||
// inter-device round-trip that surfaced as client timeouts in #252.
|
||||
func createGroup(c *cli.Context) error {
|
||||
leftIP := c.String("left")
|
||||
rightIP := c.String("right")
|
||||
name := c.String("name")
|
||||
|
||||
if net.ParseIP(leftIP) == nil {
|
||||
PrintError(fmt.Sprintf("Invalid left IP address: %s", leftIP))
|
||||
return fmt.Errorf("invalid left IP: %s", leftIP)
|
||||
}
|
||||
|
||||
if net.ParseIP(rightIP) == nil {
|
||||
PrintError(fmt.Sprintf("Invalid right IP address: %s", rightIP))
|
||||
return fmt.Errorf("invalid right IP: %s", rightIP)
|
||||
}
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Creating stereo pair: LEFT=%s RIGHT=%s", leftIP, rightIP), leftIP, speaker.HTTPPort)
|
||||
|
||||
leftInfo, err := fetchDeviceInfo(c, leftIP)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to read LEFT device info: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
rightInfo, err := fetchDeviceInfo(c, rightIP)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to read RIGHT device info: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("%s + %s", leftInfo.Name, rightInfo.Name)
|
||||
}
|
||||
|
||||
req := &models.Group{
|
||||
Name: name,
|
||||
MasterDeviceID: leftInfo.DeviceID,
|
||||
Roles: models.GroupRoles{
|
||||
Roles: []models.GroupRole{
|
||||
{DeviceID: leftInfo.DeviceID, Role: "LEFT", IPAddress: leftIP},
|
||||
{DeviceID: rightInfo.DeviceID, Role: "RIGHT", IPAddress: rightIP},
|
||||
},
|
||||
},
|
||||
// SenderIPAddress is intentionally omitted on the base request.
|
||||
// propagateAddGroup adds it to the slave's copy only — see comment there.
|
||||
}
|
||||
|
||||
leftClient, err := clientForHost(c, leftIP)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client for LEFT: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
rightClient, err := clientForHost(c, rightIP)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client for RIGHT: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
leftOut, rightOut := propagateAddGroup(leftClient, rightClient, leftIP, rightIP, req)
|
||||
|
||||
if leftOut.err != nil {
|
||||
PrintError(fmt.Sprintf("LEFT (%s) /addGroup failed: %v", leftIP, leftOut.err))
|
||||
}
|
||||
|
||||
if rightOut.err != nil {
|
||||
PrintError(fmt.Sprintf("RIGHT (%s) /addGroup failed: %v", rightIP, rightOut.err))
|
||||
}
|
||||
|
||||
if leftOut.err != nil || rightOut.err != nil {
|
||||
if (leftOut.err == nil) != (rightOut.err == nil) {
|
||||
succeeded := leftIP
|
||||
if leftOut.err != nil {
|
||||
succeeded = rightIP
|
||||
}
|
||||
|
||||
PrintError(fmt.Sprintf("Partial group state on %s — clean up with `soundtouch-cli --host %s group remove`", succeeded, succeeded))
|
||||
}
|
||||
|
||||
return fmt.Errorf("/addGroup propagation failed")
|
||||
}
|
||||
|
||||
// The LEFT (master) response carries the assigned group ID; use it for display.
|
||||
PrintSuccess(fmt.Sprintf("Stereo pair created (id=%s)", leftOut.group.ID))
|
||||
printGroup(leftOut.group)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addGroupOutcome is the per-speaker result of a parallel /addGroup call.
|
||||
type addGroupOutcome struct {
|
||||
host string
|
||||
group *models.Group
|
||||
err error
|
||||
}
|
||||
|
||||
// propagateAddGroup POSTs /addGroup to both speakers concurrently and returns
|
||||
// the (LEFT, RIGHT) outcomes. A non-GROUP_OK Status in the response is
|
||||
// reported as an error so callers don't have to re-inspect the body.
|
||||
//
|
||||
// The two POSTs carry different payloads: the master (LEFT) receives the base
|
||||
// request with no senderIPAddress so its state machine forms the group as the
|
||||
// master, while the slave (RIGHT) receives a copy with senderIPAddress set to
|
||||
// the master's IP so its state machine joins as the slave. Sending the same
|
||||
// payload to both makes both speakers think they're the slave — they enter
|
||||
// AddingSlave, wait for a master that never confirms, time out after 5 s, and
|
||||
// revert (issue #252).
|
||||
func propagateAddGroup(left, right *client.Client, leftIP, rightIP string, req *models.Group) (addGroupOutcome, addGroupOutcome) {
|
||||
masterReq := *req
|
||||
masterReq.SenderIPAddress = ""
|
||||
|
||||
slaveReq := *req
|
||||
slaveReq.SenderIPAddress = leftIP
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
leftOut, rightOut addGroupOutcome
|
||||
)
|
||||
|
||||
wg.Add(2)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
leftOut = postAddGroup(left, leftIP, &masterReq)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
rightOut = postAddGroup(right, rightIP, &slaveReq)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return leftOut, rightOut
|
||||
}
|
||||
|
||||
func postAddGroup(cli *client.Client, host string, req *models.Group) addGroupOutcome {
|
||||
out := addGroupOutcome{host: host}
|
||||
|
||||
g, err := cli.AddGroup(req)
|
||||
if err != nil {
|
||||
out.err = err
|
||||
return out
|
||||
}
|
||||
|
||||
out.group = g
|
||||
|
||||
if g != nil && g.Status != "" && g.Status != "GROUP_OK" {
|
||||
out.err = fmt.Errorf("device returned status %q (want GROUP_OK)", g.Status)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// renameGroup updates the name of the existing stereo pair. The device
|
||||
// requires the full structure on every update, so we fetch the current
|
||||
// state first.
|
||||
func renameGroup(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
newName := c.String("name")
|
||||
|
||||
if newName == "" {
|
||||
PrintError("--name is required")
|
||||
return fmt.Errorf("name is required")
|
||||
}
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Renaming stereo pair to %q", newName), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
stClient, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
current, err := stClient.GetGroup()
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to read current group: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if current.IsEmpty() {
|
||||
PrintError("Device is not in a stereo pair — nothing to rename")
|
||||
return fmt.Errorf("no group configured")
|
||||
}
|
||||
|
||||
// Status is read-only on the device side; don't echo it back.
|
||||
current.Status = ""
|
||||
current.Name = newName
|
||||
|
||||
result, err := stClient.UpdateGroup(current)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to rename group: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess(fmt.Sprintf("Stereo pair renamed to %q", result.Name))
|
||||
printGroup(result)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeGroup tears down the device's stereo pair.
|
||||
func removeGroup(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader("Removing stereo pair", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
stClient, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if err := stClient.RemoveGroup(); err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to remove group: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess("Stereo pair removed")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchDeviceInfo builds a one-off client for the given IP and reads /info.
|
||||
// Reused for both halves of a `create` invocation so the caller doesn't have
|
||||
// to babysit two host/port pairs.
|
||||
func fetchDeviceInfo(c *cli.Context, host string) (*models.DeviceInfo, error) {
|
||||
stClient, err := clientForHost(c, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return stClient.GetDeviceInfo()
|
||||
}
|
||||
|
||||
// clientForHost mirrors CreateSoundTouchClient but overrides the host so we
|
||||
// can talk to a speaker other than the one named in --host.
|
||||
func clientForHost(c *cli.Context, host string) (*client.Client, error) {
|
||||
cfg, err := loadConfig(c.Duration("timeout"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
return client.NewClient(&client.Config{
|
||||
Host: host,
|
||||
Port: speaker.HTTPPort,
|
||||
Timeout: cfg.HTTPTimeout,
|
||||
UserAgent: cfg.UserAgent,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func printGroup(g *models.Group) {
|
||||
fmt.Println("Stereo Pair Configuration:")
|
||||
fmt.Printf(" ID: %s\n", g.ID)
|
||||
fmt.Printf(" Name: %s\n", g.Name)
|
||||
fmt.Printf(" Master: %s\n", g.MasterDeviceID)
|
||||
|
||||
if g.Status != "" {
|
||||
fmt.Printf(" Status: %s\n", g.Status)
|
||||
}
|
||||
|
||||
for _, r := range g.Roles.Roles {
|
||||
fmt.Printf(" %-5s %s", r.Role, r.DeviceID)
|
||||
|
||||
if r.IPAddress != "" {
|
||||
fmt.Printf(" (IP: %s)", r.IPAddress)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
// happyAddGroupServer fakes a speaker's /addGroup that echoes the request
|
||||
// with an assigned ID and GROUP_OK status, matching real hardware behaviour.
|
||||
func happyAddGroupServer(t *testing.T, assignedID string) (*httptest.Server, *[]string) {
|
||||
t.Helper()
|
||||
|
||||
bodies := make([]string, 0)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/addGroup" || r.Method != http.MethodPost {
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
bodies = append(bodies, string(body))
|
||||
|
||||
var got models.Group
|
||||
if err := xml.Unmarshal(body, &got); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
|
||||
got.ID = assignedID
|
||||
got.Status = "GROUP_OK"
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
|
||||
enc, _ := xml.Marshal(&got)
|
||||
_, _ = w.Write(enc)
|
||||
}))
|
||||
|
||||
return srv, &bodies
|
||||
}
|
||||
|
||||
func newTestGroupClient(serverURL string) *client.Client {
|
||||
return client.NewClientFromHost(serverURL)
|
||||
}
|
||||
|
||||
func sampleGroupRequest(leftIP, rightIP string) *models.Group {
|
||||
return &models.Group{
|
||||
Name: "Living Room",
|
||||
MasterDeviceID: "9070658C9D4A",
|
||||
Roles: models.GroupRoles{
|
||||
Roles: []models.GroupRole{
|
||||
{DeviceID: "9070658C9D4A", Role: "LEFT", IPAddress: leftIP},
|
||||
{DeviceID: "F45EAB3115DA", Role: "RIGHT", IPAddress: rightIP},
|
||||
},
|
||||
},
|
||||
// senderIPAddress is intentionally not set here; propagateAddGroup
|
||||
// adds it to the slave's copy only.
|
||||
}
|
||||
}
|
||||
|
||||
func TestPropagateAddGroup_BothSucceed(t *testing.T) {
|
||||
leftSrv, leftBodies := happyAddGroupServer(t, "9999999")
|
||||
defer leftSrv.Close()
|
||||
|
||||
rightSrv, rightBodies := happyAddGroupServer(t, "9999999")
|
||||
defer rightSrv.Close()
|
||||
|
||||
leftClient := newTestGroupClient(leftSrv.URL)
|
||||
rightClient := newTestGroupClient(rightSrv.URL)
|
||||
|
||||
req := sampleGroupRequest("192.0.2.131", "192.0.2.134")
|
||||
|
||||
leftOut, rightOut := propagateAddGroup(leftClient, rightClient, "192.0.2.131", "192.0.2.134", req)
|
||||
|
||||
if leftOut.err != nil {
|
||||
t.Errorf("LEFT err = %v, want nil", leftOut.err)
|
||||
}
|
||||
|
||||
if rightOut.err != nil {
|
||||
t.Errorf("RIGHT err = %v, want nil", rightOut.err)
|
||||
}
|
||||
|
||||
if leftOut.group == nil || leftOut.group.ID != "9999999" || leftOut.group.Status != "GROUP_OK" {
|
||||
t.Errorf("LEFT group = %+v, want id=9999999 status=GROUP_OK", leftOut.group)
|
||||
}
|
||||
|
||||
if rightOut.group == nil || rightOut.group.Status != "GROUP_OK" {
|
||||
t.Errorf("RIGHT group = %+v, want status=GROUP_OK", rightOut.group)
|
||||
}
|
||||
|
||||
// Both speakers must have received the roles, but only the slave's payload
|
||||
// carries senderIPAddress — see propagateAddGroup for the why.
|
||||
for label, bodies := range map[string]*[]string{"LEFT": leftBodies, "RIGHT": rightBodies} {
|
||||
if len(*bodies) != 1 {
|
||||
t.Fatalf("%s: expected exactly one POST, got %d", label, len(*bodies))
|
||||
}
|
||||
|
||||
body := (*bodies)[0]
|
||||
for _, want := range []string{"<role>LEFT</role>", "<role>RIGHT</role>"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("%s body missing %q\nbody:\n%s", label, want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
leftBody := (*leftBodies)[0]
|
||||
if strings.Contains(leftBody, "<senderIPAddress>") {
|
||||
t.Errorf("LEFT (master) body must NOT carry <senderIPAddress>, otherwise the master flips into slave mode (issue #252)\nbody:\n%s", leftBody)
|
||||
}
|
||||
|
||||
rightBody := (*rightBodies)[0]
|
||||
if !strings.Contains(rightBody, "<senderIPAddress>192.0.2.131</senderIPAddress>") {
|
||||
t.Errorf("RIGHT (slave) body must carry <senderIPAddress>192.0.2.131</senderIPAddress>\nbody:\n%s", rightBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPropagateAddGroup_RightFails(t *testing.T) {
|
||||
leftSrv, _ := happyAddGroupServer(t, "9999999")
|
||||
defer leftSrv.Close()
|
||||
|
||||
rightSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
}))
|
||||
defer rightSrv.Close()
|
||||
|
||||
leftClient := newTestGroupClient(leftSrv.URL)
|
||||
rightClient := newTestGroupClient(rightSrv.URL)
|
||||
|
||||
req := sampleGroupRequest("192.0.2.131", "192.0.2.134")
|
||||
|
||||
leftOut, rightOut := propagateAddGroup(leftClient, rightClient, "192.0.2.131", "192.0.2.134", req)
|
||||
|
||||
if leftOut.err != nil {
|
||||
t.Errorf("LEFT err = %v, want nil", leftOut.err)
|
||||
}
|
||||
|
||||
if rightOut.err == nil {
|
||||
t.Error("RIGHT err = nil, want non-nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostAddGroup_StatusOtherThanGroupOKIsError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(`<group><status>GROUP_NOT_READY</status></group>`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
out := postAddGroup(newTestGroupClient(srv.URL), "test", sampleGroupRequest("1.1.1.1", "2.2.2.2"))
|
||||
|
||||
if out.err == nil {
|
||||
t.Fatal("expected error for non-GROUP_OK status")
|
||||
}
|
||||
|
||||
if !strings.Contains(out.err.Error(), "GROUP_NOT_READY") {
|
||||
t.Errorf("error %q does not mention returned status", out.err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostAddGroup_EmptyStatusIsAccepted(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(`<group id="42"><name>n</name></group>`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
out := postAddGroup(newTestGroupClient(srv.URL), "test", sampleGroupRequest("1.1.1.1", "2.2.2.2"))
|
||||
|
||||
if out.err != nil {
|
||||
t.Errorf("err = %v, want nil for empty status (some firmware omits it)", out.err)
|
||||
}
|
||||
|
||||
if out.group == nil || out.group.ID != "42" {
|
||||
t.Errorf("group = %+v, want id=42", out.group)
|
||||
}
|
||||
}
|
||||
@@ -177,36 +177,23 @@ func getPresets(c *cli.Context) error {
|
||||
|
||||
fmt.Printf("Device Presets:\n")
|
||||
|
||||
// Filter out placeholder presets the firmware emits for unconfigured
|
||||
// slots (issue #308): self-closing <preset/> after factory reset,
|
||||
// or <ContentItem source="INVALID_SOURCE"/> on healthy devices.
|
||||
// IsEmpty covers both shapes; accessing fields like ContentItem.Source
|
||||
// directly on the first shape panics.
|
||||
configured := make([]models.Preset, 0, len(presets.Preset))
|
||||
|
||||
for _, p := range presets.Preset {
|
||||
if !p.IsEmpty() {
|
||||
configured = append(configured, p)
|
||||
}
|
||||
}
|
||||
|
||||
if len(configured) == 0 {
|
||||
if len(presets.Preset) == 0 {
|
||||
fmt.Printf(" No presets configured\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf(" Configured Presets:\n")
|
||||
|
||||
for _, preset := range configured {
|
||||
for _, preset := range presets.Preset {
|
||||
fmt.Printf(" %d. %s\n", preset.ID, preset.GetDisplayName())
|
||||
fmt.Printf(" Source: %s\n", preset.GetSource())
|
||||
fmt.Printf(" Source: %s\n", preset.ContentItem.Source)
|
||||
|
||||
if account := preset.GetSourceAccount(); account != "" && account != preset.GetSource() {
|
||||
fmt.Printf(" Account: %s\n", account)
|
||||
if preset.ContentItem.SourceAccount != "" && preset.ContentItem.SourceAccount != preset.ContentItem.Source {
|
||||
fmt.Printf(" Account: %s\n", preset.ContentItem.SourceAccount)
|
||||
}
|
||||
|
||||
if location := preset.GetLocation(); location != "" {
|
||||
fmt.Printf(" Location: %s\n", location)
|
||||
if preset.ContentItem.Location != "" {
|
||||
fmt.Printf(" Location: %s\n", preset.ContentItem.Location)
|
||||
}
|
||||
|
||||
// Show preset creation time if available
|
||||
|
||||
@@ -17,7 +17,7 @@ func TestIntrospectCommands(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "introspect service with source flag",
|
||||
args: []string{"soundtouch-cli", "--host", "192.0.2.100", "source", "introspect", "--source", "SPOTIFY"},
|
||||
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "source", "introspect", "--source", "SPOTIFY"},
|
||||
expectedOutput: []string{
|
||||
"Getting introspect data for SPOTIFY",
|
||||
"=== SPOTIFY Service Introspect Data ===",
|
||||
@@ -47,7 +47,7 @@ func TestIntrospectCommands(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "introspect spotify convenience command",
|
||||
args: []string{"soundtouch-cli", "--host", "192.0.2.100", "source", "introspect-spotify"},
|
||||
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "source", "introspect-spotify"},
|
||||
expectedOutput: []string{
|
||||
"Getting Spotify introspect data",
|
||||
"=== Spotify Service Introspect Data ===",
|
||||
@@ -60,7 +60,7 @@ func TestIntrospectCommands(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "introspect with account parameter",
|
||||
args: []string{"soundtouch-cli", "--host", "192.0.2.100", "source", "introspect", "--source", "SPOTIFY", "--account", "my_spotify_account"},
|
||||
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "source", "introspect", "--source", "SPOTIFY", "--account", "my_spotify_account"},
|
||||
expectedOutput: []string{
|
||||
"Getting introspect data for SPOTIFY",
|
||||
"Source Account: my_spotify_account",
|
||||
@@ -68,7 +68,7 @@ func TestIntrospectCommands(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "introspect missing source flag",
|
||||
args: []string{"soundtouch-cli", "--host", "192.0.2.100", "source", "introspect"},
|
||||
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "source", "introspect"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -17,7 +17,7 @@ func TestRecentsCommands(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "recents list command",
|
||||
args: []string{"soundtouch-cli", "--host", "192.0.2.100", "recents", "list"},
|
||||
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "recents", "list"},
|
||||
expectedOutput: []string{
|
||||
"Getting recently played content",
|
||||
"Recent Items Summary:",
|
||||
@@ -26,7 +26,7 @@ func TestRecentsCommands(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "recents filter by source",
|
||||
args: []string{"soundtouch-cli", "--host", "192.0.2.100", "recents", "filter", "--source", "SPOTIFY"},
|
||||
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "recents", "filter", "--source", "SPOTIFY"},
|
||||
expectedOutput: []string{
|
||||
"Getting filtered recent content",
|
||||
"filtered by source: SPOTIFY",
|
||||
@@ -34,7 +34,7 @@ func TestRecentsCommands(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "recents latest command",
|
||||
args: []string{"soundtouch-cli", "--host", "192.0.2.100", "recents", "latest"},
|
||||
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "recents", "latest"},
|
||||
expectedOutput: []string{
|
||||
"Getting most recent item",
|
||||
"Most Recent Item:",
|
||||
@@ -42,7 +42,7 @@ func TestRecentsCommands(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "recents stats command",
|
||||
args: []string{"soundtouch-cli", "--host", "192.0.2.100", "recents", "stats"},
|
||||
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "recents", "stats"},
|
||||
expectedOutput: []string{
|
||||
"Getting recent items statistics",
|
||||
"Recent Items Statistics",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,310 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
)
|
||||
|
||||
// captureStdout runs fn and returns whatever it wrote to os.Stdout.
|
||||
// renderSourceTable prints directly via fmt.Print* — this lets us assert
|
||||
// on its output without restructuring the renderer to take an io.Writer.
|
||||
func captureStdout(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
|
||||
orig := os.Stdout
|
||||
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("pipe: %v", err)
|
||||
}
|
||||
|
||||
os.Stdout = w
|
||||
|
||||
done := make(chan struct{})
|
||||
buf := &bytes.Buffer{}
|
||||
|
||||
go func() {
|
||||
_, _ = io.Copy(buf, r)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
fn()
|
||||
_ = w.Close()
|
||||
|
||||
os.Stdout = orig
|
||||
<-done
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestRenderSourceTable_AlignsColumnsAndDedupsDisplayName(t *testing.T) {
|
||||
items := []models.SourceItem{
|
||||
// displayName != account → kept as "AUX (AUX IN)"
|
||||
{Source: "AUX", SourceAccount: "AUX", DisplayName: "AUX IN", Status: "READY", IsLocal: true, MultiroomAllowed: true},
|
||||
// displayName == account → dropped (would otherwise duplicate the next column)
|
||||
{Source: "AMAZON", SourceAccount: "amzn1.account.AFKTQOUNVZL7ODQCF4STPAAMVMPA", DisplayName: "amzn1.account.AFKTQOUNVZL7ODQCF4STPAAMVMPA", Status: "READY", MultiroomAllowed: true},
|
||||
// No displayName at all, no account
|
||||
{Source: "BLUETOOTH", Status: "UNAVAILABLE", IsLocal: true, MultiroomAllowed: true},
|
||||
// Long source name, no catalog entry → provider#?
|
||||
{Source: "STORED_MUSIC_MEDIA_RENDERER", SourceAccount: "StoredMusicUserName", DisplayName: "StoredMusicUserName", Status: "UNAVAILABLE", MultiroomAllowed: true},
|
||||
}
|
||||
|
||||
out := captureStdout(t, func() { renderSourceTable(items) })
|
||||
|
||||
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
|
||||
if len(lines) != 4 {
|
||||
t.Fatalf("got %d output lines, want 4:\n%s", len(lines), out)
|
||||
}
|
||||
|
||||
// (1) AUX keeps "(AUX IN)" because it differs from both source and account.
|
||||
if !strings.Contains(lines[0], "AUX (AUX IN)") {
|
||||
t.Errorf("AUX line should keep displayName parenthesis: %q", lines[0])
|
||||
}
|
||||
|
||||
// (2) AMAZON drops "(amzn1…)" because displayName equals sourceAccount.
|
||||
if strings.Contains(lines[1], "(amzn1.account") {
|
||||
t.Errorf("AMAZON line should drop displayName when it duplicates account: %q", lines[1])
|
||||
}
|
||||
|
||||
// (3) provider#? for the uncatalogued source.
|
||||
if !strings.Contains(lines[3], "provider#?") {
|
||||
t.Errorf("uncatalogued source should be tagged provider#?: %q", lines[3])
|
||||
}
|
||||
|
||||
// (4) Column starts must align across all rows — find the column index
|
||||
// where "status=" appears in each line; they should all match.
|
||||
statusCols := make([]int, len(lines))
|
||||
for i, l := range lines {
|
||||
statusCols[i] = strings.Index(l, "status=")
|
||||
if statusCols[i] < 0 {
|
||||
t.Fatalf("line %d missing status= column: %q", i, l)
|
||||
}
|
||||
}
|
||||
|
||||
for i := 1; i < len(statusCols); i++ {
|
||||
if statusCols[i] != statusCols[0] {
|
||||
t.Errorf("status= column misaligned: line 0 at col %d, line %d at col %d\n%s",
|
||||
statusCols[0], i, statusCols[i], out)
|
||||
}
|
||||
}
|
||||
|
||||
// (5) account= column should likewise align across all rows.
|
||||
accountCols := make([]int, len(lines))
|
||||
for i, l := range lines {
|
||||
accountCols[i] = strings.Index(l, "account=")
|
||||
if accountCols[i] < 0 {
|
||||
t.Fatalf("line %d missing account= column: %q", i, l)
|
||||
}
|
||||
}
|
||||
|
||||
for i := 1; i < len(accountCols); i++ {
|
||||
if accountCols[i] != accountCols[0] {
|
||||
t.Errorf("account= column misaligned: line 0 at col %d, line %d at col %d\n%s",
|
||||
accountCols[0], i, accountCols[i], out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSourceTable_EmptyShowsNonePlaceholder(t *testing.T) {
|
||||
out := captureStdout(t, func() { renderSourceTable(nil) })
|
||||
if !strings.Contains(out, "(none)") {
|
||||
t.Errorf("expected (none) placeholder for empty list, got: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendMigrationMethod_PrefersTelnet(t *testing.T) {
|
||||
method, reason := recommendMigrationMethod("http://aftertouch.local:8000", &setup.MigrationSummary{
|
||||
TelnetReachable: true,
|
||||
SSHSuccess: true,
|
||||
})
|
||||
|
||||
if method != setup.MigrationMethodTelnet {
|
||||
t.Errorf("method = %q, want telnet (simplest path when telnet works)", method)
|
||||
}
|
||||
|
||||
if !strings.Contains(reason, "Telnet") {
|
||||
t.Errorf("reason should mention Telnet: %q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendMigrationMethod_HTTPSAddsCaveatToTelnet(t *testing.T) {
|
||||
_, reason := recommendMigrationMethod("https://aftertouch.local:8443", &setup.MigrationSummary{
|
||||
TelnetReachable: true,
|
||||
})
|
||||
|
||||
if !strings.Contains(reason, "install-ca") {
|
||||
t.Errorf("HTTPS service URL should flag the CA-install caveat in the reason: %q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendMigrationMethod_FallsBackToResolvWhenTelnetDown(t *testing.T) {
|
||||
method, _ := recommendMigrationMethod("http://aftertouch.local:8000", &setup.MigrationSummary{
|
||||
TelnetReachable: false,
|
||||
SSHSuccess: true,
|
||||
})
|
||||
|
||||
if method != setup.MigrationMethodResolvConf {
|
||||
t.Errorf("method = %q, want resolv (DNS redirect via SSH)", method)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendMigrationMethod_EmptyWhenNoTransport(t *testing.T) {
|
||||
method, _ := recommendMigrationMethod("http://aftertouch.local:8000", &setup.MigrationSummary{
|
||||
TelnetReachable: false,
|
||||
SSHSuccess: false,
|
||||
})
|
||||
|
||||
if method != "" {
|
||||
t.Errorf("method = %q, want empty when no transport works", method)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanSteps_NoOpWhenAlreadyMigratedAndPaired(t *testing.T) {
|
||||
summary := &setup.MigrationSummary{IsMigrated: true, IsPaired: true, TelnetMigrated: true}
|
||||
inspect := &setup.InspectReport{Info: &setup.DeviceInfoXML{DeviceID: "AABBCCDDEEFF"}}
|
||||
|
||||
steps := buildPlanSteps("192.0.2.42", "http://aftertouch.local:8000", "", true, false, inspect, summary)
|
||||
|
||||
if len(steps) != 0 {
|
||||
t.Errorf("expected no steps for fully-set-up device, got %d:\n%v", len(steps), steps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanSteps_RecommendsPairWhenMigratedButUnpaired(t *testing.T) {
|
||||
summary := &setup.MigrationSummary{IsMigrated: true, IsPaired: false, TelnetMigrated: true, TelnetReachable: true}
|
||||
inspect := &setup.InspectReport{Info: &setup.DeviceInfoXML{DeviceID: "AABBCCDDEEFF"}}
|
||||
|
||||
steps := buildPlanSteps("192.0.2.42", "http://aftertouch.local:8000", "", true, false, inspect, summary)
|
||||
|
||||
if len(steps) != 1 {
|
||||
t.Fatalf("expected exactly the pair step, got %d:\n%v", len(steps), steps)
|
||||
}
|
||||
|
||||
if !strings.Contains(steps[0].cmd, "setup pair") {
|
||||
t.Errorf("expected pair command, got %q", steps[0].cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanSteps_MigrateRebootThenPairWhenFresh(t *testing.T) {
|
||||
summary := &setup.MigrationSummary{TelnetReachable: true, SSHSuccess: false, IsPaired: false}
|
||||
inspect := &setup.InspectReport{Info: &setup.DeviceInfoXML{DeviceID: "AABBCCDDEEFF"}}
|
||||
|
||||
steps := buildPlanSteps("192.0.2.42", "http://aftertouch.local:8000", "", true, false, inspect, summary)
|
||||
|
||||
// migrate → reboot → pair. The reboot step exists because envswitch's
|
||||
// parallel-persistence layer only fully wins on the next boot, and we
|
||||
// want the new URLs locked in before pairing posts to the speaker.
|
||||
if len(steps) != 3 {
|
||||
t.Fatalf("expected migrate+reboot+pair, got %d steps:\n%v", len(steps), steps)
|
||||
}
|
||||
|
||||
if !strings.Contains(steps[0].cmd, "setup migrate") || !strings.Contains(steps[0].cmd, "method=telnet") {
|
||||
t.Errorf("step 1 should be telnet migrate, got %q", steps[0].cmd)
|
||||
}
|
||||
|
||||
if !strings.Contains(steps[1].cmd, "setup reboot") {
|
||||
t.Errorf("step 2 should be reboot, got %q", steps[1].cmd)
|
||||
}
|
||||
|
||||
if !strings.Contains(steps[2].cmd, "setup pair") {
|
||||
t.Errorf("step 3 should be pair, got %q", steps[2].cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanSteps_DNSMethodPrependsCAInstall(t *testing.T) {
|
||||
// Telnet down, SSH up, CA not yet trusted → plan must install-ca
|
||||
// before applying the resolv migration.
|
||||
summary := &setup.MigrationSummary{
|
||||
TelnetReachable: false,
|
||||
SSHSuccess: true,
|
||||
CACertTrusted: false,
|
||||
IsPaired: false,
|
||||
}
|
||||
inspect := &setup.InspectReport{Info: &setup.DeviceInfoXML{DeviceID: "X"}}
|
||||
|
||||
steps := buildPlanSteps("192.0.2.42", "http://aftertouch.local:8000", "", false, false, inspect, summary)
|
||||
|
||||
if len(steps) < 2 {
|
||||
t.Fatalf("expected at least install-ca + migrate, got %d steps:\n%v", len(steps), steps)
|
||||
}
|
||||
|
||||
if !strings.Contains(steps[0].cmd, "install-ca") {
|
||||
t.Errorf("install-ca should come first when DNS method is chosen and CA is not trusted, got %q", steps[0].cmd)
|
||||
}
|
||||
|
||||
if !strings.Contains(steps[1].cmd, "method=resolv") {
|
||||
t.Errorf("step 2 should be resolv migrate, got %q", steps[1].cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanSteps_ResetModeIncludesManualNetworkSwitches(t *testing.T) {
|
||||
inspect := &setup.InspectReport{
|
||||
Info: &setup.DeviceInfoXML{DeviceID: "506583DE4803"},
|
||||
Network: &models.NetworkInformation{
|
||||
Interfaces: models.NetworkInterfaces{
|
||||
Interfaces: []models.NetworkInterface{
|
||||
{Type: "WIFI_INTERFACE", SSID: "MyHomeNetwork"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
summary := &setup.MigrationSummary{IsMigrated: true, IsPaired: true} // doesn't matter in reset mode
|
||||
|
||||
steps := buildPlanSteps("192.0.2.42", "http://aftertouch.local:8000", "", true, true, inspect, summary)
|
||||
|
||||
// Expected sequence in --reset mode:
|
||||
// factory-reset, manual AP switch, wait-ap, wifi-push, manual home switch,
|
||||
// wait-online, migrate, pair (8 steps).
|
||||
if len(steps) < 7 {
|
||||
t.Fatalf("expected at least 7 steps in --reset mode, got %d:\n%v", len(steps), steps)
|
||||
}
|
||||
|
||||
manualCount := 0
|
||||
for _, s := range steps {
|
||||
if s.manual {
|
||||
manualCount++
|
||||
}
|
||||
}
|
||||
|
||||
if manualCount < 2 {
|
||||
t.Errorf("expected at least 2 manual steps for the Wi-Fi switches, got %d", manualCount)
|
||||
}
|
||||
|
||||
if !strings.Contains(steps[0].cmd, "factory-reset") {
|
||||
t.Errorf("step 1 must be factory-reset, got %q", steps[0].cmd)
|
||||
}
|
||||
|
||||
// wifi-push step should default to the inspected SSID
|
||||
foundWiFi := false
|
||||
|
||||
for _, s := range steps {
|
||||
if strings.Contains(s.cmd, "wifi-push") && strings.Contains(s.cmd, "MyHomeNetwork") {
|
||||
foundWiFi = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !foundWiFi {
|
||||
t.Errorf("expected wifi-push step to default to inspected SSID 'MyHomeNetwork'")
|
||||
}
|
||||
|
||||
// wait-online --match should use the deviceID suffix
|
||||
foundMatch := false
|
||||
|
||||
for _, s := range steps {
|
||||
if strings.Contains(s.cmd, "wait-online") && strings.Contains(s.cmd, "--match=DE4803") {
|
||||
foundMatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !foundMatch {
|
||||
t.Errorf("expected wait-online step to use --match=DE4803 from deviceID suffix")
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
// Package main — `soundtouch-cli source tunein` subcommand.
|
||||
//
|
||||
// Convenience shortcut for the verbose `source content --source TUNEIN
|
||||
// --type … --location …` pattern. Picks the right Type + location template
|
||||
// from the TuneIn guide-ID prefix, optionally fetches name + artwork from
|
||||
// TuneIn's describe endpoint, then calls the same SelectContentItem path
|
||||
// the generic `source content` command uses.
|
||||
//
|
||||
// Implements #226.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/bmx"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// tuneInKind captures the three guide-ID shapes the SoundTouch firmware
|
||||
// distinguishes; each picks a different Bose `/v1/playback/...` location
|
||||
// template and a different ContentItem Type.
|
||||
type tuneInKind struct {
|
||||
flag string // CLI flag name (`station`, `episode`, `program`)
|
||||
prefix string // single-letter guide-ID prefix (`s`, `e`, `p`)
|
||||
location string // printf template, %s = guide ID
|
||||
itemType string // ContentItem.Type the speaker expects
|
||||
humanName string // user-facing kind label for log lines
|
||||
}
|
||||
|
||||
var tuneInKinds = []tuneInKind{
|
||||
{flag: "station", prefix: "s", location: "/v1/playback/station/%s", itemType: "stationurl", humanName: "live station"},
|
||||
{flag: "episode", prefix: "e", location: "/v1/playback/episode/%s", itemType: "stationurl", humanName: "podcast episode"},
|
||||
{flag: "program", prefix: "p", location: "/v1/playback/episodes/%s", itemType: "tracklisturl", humanName: "podcast program"},
|
||||
}
|
||||
|
||||
// resolveTuneInKind picks a kind from the CLI flags. Exactly one of
|
||||
// --station / --episode / --program must be set, OR --id with a prefix we
|
||||
// recognise. Returns the kind plus the bare guide ID.
|
||||
func resolveTuneInKind(c *cli.Context) (*tuneInKind, string, error) {
|
||||
// Explicit kind flags take precedence over --id.
|
||||
var picked *tuneInKind
|
||||
|
||||
var id string
|
||||
|
||||
for i, k := range tuneInKinds {
|
||||
v := c.String(k.flag)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if picked != nil {
|
||||
return nil, "", fmt.Errorf("only one of --station, --episode, --program may be set")
|
||||
}
|
||||
|
||||
picked = &tuneInKinds[i]
|
||||
id = v
|
||||
}
|
||||
|
||||
if picked != nil {
|
||||
return picked, strings.TrimSpace(id), nil
|
||||
}
|
||||
|
||||
// Fall back to --id with prefix auto-detect.
|
||||
raw := strings.TrimSpace(c.String("id"))
|
||||
if raw == "" {
|
||||
return nil, "", fmt.Errorf("one of --station, --episode, --program, or --id is required")
|
||||
}
|
||||
|
||||
if raw == "" {
|
||||
return nil, "", fmt.Errorf("--id is empty")
|
||||
}
|
||||
|
||||
for i, k := range tuneInKinds {
|
||||
if strings.HasPrefix(raw, k.prefix) {
|
||||
return &tuneInKinds[i], raw, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, "", fmt.Errorf("--id %q has no recognised TuneIn prefix; use --station/--episode/--program explicitly", raw)
|
||||
}
|
||||
|
||||
// playTuneIn is the action wired into `soundtouch-cli source tunein`.
|
||||
func playTuneIn(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
kind, id, err := resolveTuneInKind(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
name := c.String("name")
|
||||
artwork := c.String("artwork")
|
||||
|
||||
// Optional metadata enrichment — only fetch if the user hasn't already
|
||||
// supplied both, and they haven't asked us to skip it.
|
||||
if !c.Bool("no-lookup") && (name == "" || artwork == "") {
|
||||
fetchedName, fetchedLogo, lookupErr := bmx.TuneInDescribeMeta(id)
|
||||
if lookupErr != nil {
|
||||
// Non-fatal: the speaker can resolve the title itself; just
|
||||
// note the failure so an operator sees what went wrong.
|
||||
fmt.Printf(" Note: TuneIn describe lookup failed (%v); proceeding without enrichment.\n", lookupErr)
|
||||
} else {
|
||||
if name == "" {
|
||||
name = fetchedName
|
||||
}
|
||||
|
||||
if artwork == "" {
|
||||
artwork = fetchedLogo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
// Fall back to a sensible non-empty default so the speaker's
|
||||
// now-playing UI doesn't show a blank source label.
|
||||
name = "TuneIn"
|
||||
}
|
||||
|
||||
contentItem := &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
Type: kind.itemType,
|
||||
Location: fmt.Sprintf(kind.location, id),
|
||||
ItemName: name,
|
||||
ContainerArt: artwork,
|
||||
IsPresetable: true,
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Playing TuneIn "+kind.humanName, clientConfig.Host, clientConfig.Port)
|
||||
fmt.Printf(" ID: %s\n", id)
|
||||
fmt.Printf(" Location: %s\n", contentItem.Location)
|
||||
fmt.Printf(" Type: %s\n", contentItem.Type)
|
||||
fmt.Printf(" Name: %s\n", contentItem.ItemName)
|
||||
|
||||
if contentItem.ContainerArt != "" {
|
||||
fmt.Printf(" Artwork: %s\n", contentItem.ContainerArt)
|
||||
}
|
||||
|
||||
if err := client.SelectContentItem(contentItem); err != nil {
|
||||
return fmt.Errorf("failed to select TuneIn content: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("TuneIn content selected")
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// newCtx wires a *cli.Context with the kind-selection flags the resolver
|
||||
// reads, plus whatever values the test wants set. Empty-string values are
|
||||
// the default (flag not provided).
|
||||
func newCtx(t *testing.T, kv map[string]string) *cli.Context {
|
||||
t.Helper()
|
||||
|
||||
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
||||
for _, name := range []string{"station", "episode", "program", "id"} {
|
||||
fs.String(name, "", "")
|
||||
}
|
||||
|
||||
for k, v := range kv {
|
||||
if err := fs.Set(k, v); err != nil {
|
||||
t.Fatalf("fs.Set(%q, %q): %v", k, v, err)
|
||||
}
|
||||
}
|
||||
|
||||
return cli.NewContext(nil, fs, nil)
|
||||
}
|
||||
|
||||
func TestResolveTuneInKind_Station(t *testing.T) {
|
||||
c := newCtx(t, map[string]string{"station": "s14991"})
|
||||
|
||||
k, id, err := resolveTuneInKind(c)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if k.flag != "station" || k.itemType != "stationurl" {
|
||||
t.Errorf("wrong kind: %+v", k)
|
||||
}
|
||||
|
||||
if id != "s14991" {
|
||||
t.Errorf("wrong id: %q", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTuneInKind_Episode(t *testing.T) {
|
||||
c := newCtx(t, map[string]string{"episode": "e789012"})
|
||||
|
||||
k, id, err := resolveTuneInKind(c)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if k.flag != "episode" || k.itemType != "stationurl" {
|
||||
t.Errorf("wrong kind: %+v", k)
|
||||
}
|
||||
|
||||
if id != "e789012" {
|
||||
t.Errorf("wrong id: %q", id)
|
||||
}
|
||||
|
||||
if !strings.Contains(k.location, "/v1/playback/episode/") {
|
||||
t.Errorf("wrong location template: %q", k.location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTuneInKind_Program(t *testing.T) {
|
||||
c := newCtx(t, map[string]string{"program": "p123456"})
|
||||
|
||||
k, id, err := resolveTuneInKind(c)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if k.flag != "program" || k.itemType != "tracklisturl" {
|
||||
t.Errorf("wrong kind: %+v", k)
|
||||
}
|
||||
|
||||
if id != "p123456" {
|
||||
t.Errorf("wrong id: %q", id)
|
||||
}
|
||||
|
||||
if !strings.Contains(k.location, "/v1/playback/episodes/") {
|
||||
t.Errorf("wrong location template: %q", k.location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTuneInKind_IDPrefixAutoDetect(t *testing.T) {
|
||||
cases := []struct {
|
||||
id string
|
||||
wantFlag string
|
||||
}{
|
||||
{"s14991", "station"},
|
||||
{"e789012", "episode"},
|
||||
{"p123456", "program"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.id, func(t *testing.T) {
|
||||
c := newCtx(t, map[string]string{"id": tc.id})
|
||||
|
||||
k, id, err := resolveTuneInKind(c)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if k.flag != tc.wantFlag {
|
||||
t.Errorf("auto-detect picked %q; want %q", k.flag, tc.wantFlag)
|
||||
}
|
||||
|
||||
if id != tc.id {
|
||||
t.Errorf("id round-tripped wrong: got %q want %q", id, tc.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTuneInKind_NoFlags(t *testing.T) {
|
||||
c := newCtx(t, nil)
|
||||
|
||||
_, _, err := resolveTuneInKind(c)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no flags are set")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "required") {
|
||||
t.Errorf("error message should mention required flag: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTuneInKind_ConflictingFlags(t *testing.T) {
|
||||
c := newCtx(t, map[string]string{"station": "s14991", "episode": "e789012"})
|
||||
|
||||
_, _, err := resolveTuneInKind(c)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when conflicting flags are set")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "only one of") {
|
||||
t.Errorf("error message should mention exclusivity: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTuneInKind_UnknownPrefix(t *testing.T) {
|
||||
c := newCtx(t, map[string]string{"id": "x999"})
|
||||
|
||||
_, _, err := resolveTuneInKind(c)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown ID prefix")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "no recognised TuneIn prefix") {
|
||||
t.Errorf("error message should explain prefix mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -196,7 +196,7 @@ var httpClient = &http.Client{
|
||||
}
|
||||
|
||||
func fetchTuneInMetadata(url string) (*Metadata, error) {
|
||||
if !strings.Contains(url, "tunein.com/radio/") && !strings.Contains(url, "127.0.0.1") && !strings.Contains(url, "localhost") {
|
||||
if !strings.Contains(url, "tunein.com/radio/") {
|
||||
return nil, fmt.Errorf("url is not a TuneIn radio URL")
|
||||
}
|
||||
|
||||
@@ -256,7 +256,7 @@ func fetchTuneInMetadata(url string) (*Metadata, error) {
|
||||
}
|
||||
|
||||
func fetchSpotifyMetadata(url string) (*Metadata, error) {
|
||||
if !strings.Contains(url, "open.spotify.com/") && !strings.Contains(url, "127.0.0.1") && !strings.Contains(url, "localhost") {
|
||||
if !strings.Contains(url, "open.spotify.com/") {
|
||||
return nil, fmt.Errorf("url is not a Spotify URL")
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
func TestFetchTuneInMetadata(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
html := `
|
||||
<!doctype html>
|
||||
<html>
|
||||
@@ -30,23 +30,23 @@ func TestFetchTuneInMetadata(t *testing.T) {
|
||||
|
||||
defer func() { httpClient = oldClient }()
|
||||
|
||||
metadata, err := fetchTuneInMetadata(ts.URL + "/radio/WDR-2-Rheinland-1004-s213886/")
|
||||
metadata, err := fetchTuneInMetadata("https://tunein.com/radio/WDR-2-Rheinland-1004-s213886/")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchTuneInMetadata() error = %v", err)
|
||||
}
|
||||
|
||||
if metadata == nil {
|
||||
t.Fatal("fetchTuneInMetadata() returned nil metadata")
|
||||
} else {
|
||||
expectedName := "WDR 2 Rheinland"
|
||||
if metadata.Name != expectedName {
|
||||
t.Errorf("metadata.Name = %v, want %v", metadata.Name, expectedName)
|
||||
}
|
||||
}
|
||||
|
||||
expectedArtwork := "https://cdn-radiotime-logos.tunein.com/s213886g.png"
|
||||
if metadata.Artwork != expectedArtwork {
|
||||
t.Errorf("metadata.Artwork = %v, want %v", metadata.Artwork, expectedArtwork)
|
||||
}
|
||||
expectedName := "WDR 2 Rheinland"
|
||||
if metadata.Name != expectedName {
|
||||
t.Errorf("metadata.Name = %v, want %v", metadata.Name, expectedName)
|
||||
}
|
||||
|
||||
expectedArtwork := "https://cdn-radiotime-logos.tunein.com/s213886g.png"
|
||||
if metadata.Artwork != expectedArtwork {
|
||||
t.Errorf("metadata.Artwork = %v, want %v", metadata.Artwork, expectedArtwork)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ func TestResolveLocationSpotify(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFetchSpotifyMetadata(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
html := `
|
||||
<!doctype html>
|
||||
<html>
|
||||
@@ -185,22 +185,22 @@ func TestFetchSpotifyMetadata(t *testing.T) {
|
||||
|
||||
defer func() { httpClient = oldClient }()
|
||||
|
||||
metadata, err := fetchSpotifyMetadata(ts.URL + "/album/7F50uh7oGitmAEScRKV6pD")
|
||||
metadata, err := fetchSpotifyMetadata("https://open.spotify.com/album/7F50uh7oGitmAEScRKV6pD")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchSpotifyMetadata() error = %v", err)
|
||||
}
|
||||
|
||||
if metadata == nil {
|
||||
t.Fatal("fetchSpotifyMetadata() returned nil metadata")
|
||||
} else {
|
||||
expectedName := "Terminal Caribe - Album by Santi & Tuğçe"
|
||||
if metadata.Name != expectedName {
|
||||
t.Errorf("metadata.Name = %v, want %v", metadata.Name, expectedName)
|
||||
}
|
||||
}
|
||||
|
||||
expectedArtwork := "https://i.scdn.co/image/ab67616d0000b273f0e55478f4a15182405bcb47"
|
||||
if metadata.Artwork != expectedArtwork {
|
||||
t.Errorf("metadata.Artwork = %v, want %v", metadata.Artwork, expectedArtwork)
|
||||
}
|
||||
expectedName := "Terminal Caribe - Album by Santi & Tuğçe"
|
||||
if metadata.Name != expectedName {
|
||||
t.Errorf("metadata.Name = %v, want %v", metadata.Name, expectedName)
|
||||
}
|
||||
|
||||
expectedArtwork := "https://i.scdn.co/image/ab67616d0000b273f0e55478f4a15182405bcb47"
|
||||
if metadata.Artwork != expectedArtwork {
|
||||
t.Errorf("metadata.Artwork = %v, want %v", metadata.Artwork, expectedArtwork)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-141
@@ -1054,43 +1054,6 @@ func main() {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "tunein",
|
||||
Usage: "Play a TuneIn station / episode / program by guide ID (#226)",
|
||||
Action: playTuneIn,
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "station",
|
||||
Usage: "TuneIn live-station guide ID (e.g. s14991)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "episode",
|
||||
Usage: "TuneIn single-episode guide ID (e.g. e789012)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "program",
|
||||
Usage: "TuneIn podcast/program guide ID (e.g. p123456)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "id",
|
||||
Usage: "TuneIn guide ID; kind auto-detected from s/e/p prefix",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "name",
|
||||
Aliases: []string{"n"},
|
||||
Usage: "Override the display name (skips name lookup)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "artwork",
|
||||
Usage: "Override the artwork URL (skips artwork lookup)",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "no-lookup",
|
||||
Usage: "Skip the TuneIn describe lookup; send the bare ContentItem",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "availability",
|
||||
Usage: "Show service availability",
|
||||
@@ -1349,19 +1312,6 @@ func main() {
|
||||
},
|
||||
Before: RequireHost,
|
||||
},
|
||||
{
|
||||
Name: "timezone",
|
||||
Usage: "Set display timezone (IANA zone, e.g. Europe/Berlin)",
|
||||
Action: setClockDisplayTimezone,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "tz",
|
||||
Usage: "IANA timezone identifier (e.g. Europe/Berlin, America/New_York)",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
Before: RequireHost,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1528,64 +1478,6 @@ func main() {
|
||||
},
|
||||
},
|
||||
},
|
||||
// Stereo-pair (group) commands — ST-10 only
|
||||
{
|
||||
Name: "group",
|
||||
Aliases: []string{"g"},
|
||||
Usage: "ST-10 stereo-pair management (left/right channel pairing)",
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
Name: "status",
|
||||
Usage: "Show the device's current stereo-pair configuration",
|
||||
Action: getGroupStatus,
|
||||
Before: RequireHost,
|
||||
},
|
||||
{
|
||||
Name: "create",
|
||||
Usage: "Form a stereo pair (LEFT speaker becomes master)",
|
||||
Action: createGroup,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "left",
|
||||
Aliases: []string{"l"},
|
||||
Usage: "IP address of the LEFT speaker (will be master)",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "right",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "IP address of the RIGHT speaker",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "name",
|
||||
Aliases: []string{"n"},
|
||||
Usage: "Pair name (defaults to \"<left> + <right>\")",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "rename",
|
||||
Usage: "Rename the existing stereo pair on the device",
|
||||
Action: renameGroup,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "name",
|
||||
Aliases: []string{"n"},
|
||||
Usage: "New pair name",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
Before: RequireHost,
|
||||
},
|
||||
{
|
||||
Name: "remove",
|
||||
Usage: "Dissolve the device's stereo pair",
|
||||
Action: removeGroup,
|
||||
Before: RequireHost,
|
||||
},
|
||||
},
|
||||
},
|
||||
// Advanced Audio commands
|
||||
{
|
||||
Name: "audio",
|
||||
@@ -2146,30 +2038,6 @@ func main() {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "pair",
|
||||
Usage: "Pair the device with a Marge cloud account (Stockholm registration)",
|
||||
Action: pairDevice,
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "id",
|
||||
Usage: "Marge account ID (e.g., 1234567)",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "token",
|
||||
Usage: "User authorization token",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "unpair",
|
||||
Usage: "Unpair the device from its Marge cloud account",
|
||||
Action: unpairDevice,
|
||||
Before: RequireHost,
|
||||
},
|
||||
},
|
||||
},
|
||||
// Token commands
|
||||
@@ -2201,7 +2069,7 @@ func main() {
|
||||
&cli.StringFlag{
|
||||
Name: "filter",
|
||||
Aliases: []string{"f"},
|
||||
Usage: "Filter events by type (comma-separated): nowPlaying,volume,connection,preset,zone,group,bass,sdkInfo,userActivity",
|
||||
Usage: "Filter events by type (comma-separated): nowPlaying,volume,connection,preset,zone,bass,sdkInfo,userActivity",
|
||||
},
|
||||
&cli.DurationFlag{
|
||||
Name: "duration",
|
||||
@@ -2213,10 +2081,6 @@ func main() {
|
||||
Name: "no-reconnect",
|
||||
Usage: "Disable automatic reconnection on connection loss",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "debug",
|
||||
Usage: "Print raw WebSocket frames to stderr — one of: all, unknown, errors",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "verbose",
|
||||
Aliases: []string{"v"},
|
||||
@@ -2229,10 +2093,6 @@ func main() {
|
||||
},
|
||||
}
|
||||
|
||||
// Speaker provisioning (factory-reset, Wi-Fi, URL rewrite, pairing).
|
||||
// Defined in cmd_setup.go to keep the top-level command list readable.
|
||||
app.Commands = append(app.Commands, setupCommand())
|
||||
|
||||
// Sort commands alphabetically (including subcommands and flags recursively)
|
||||
sortCommands(app.Commands)
|
||||
|
||||
|
||||
@@ -14,16 +14,16 @@ func TestParseHostPort(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "IPv4 with port",
|
||||
input: "192.0.2.10:8090",
|
||||
input: "192.168.1.10:8090",
|
||||
defaultPort: 8080,
|
||||
wantHost: "192.0.2.10",
|
||||
wantHost: "192.168.1.10",
|
||||
wantPort: 8090,
|
||||
},
|
||||
{
|
||||
name: "IPv4 without port",
|
||||
input: "192.0.2.10",
|
||||
input: "192.168.1.10",
|
||||
defaultPort: 8080,
|
||||
wantHost: "192.0.2.10",
|
||||
wantHost: "192.168.1.10",
|
||||
wantPort: 8080,
|
||||
},
|
||||
{
|
||||
@@ -63,30 +63,30 @@ func TestParseHostPort(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "invalid port - non-numeric",
|
||||
input: "192.0.2.10:abc",
|
||||
input: "192.168.1.10:abc",
|
||||
defaultPort: 8080,
|
||||
wantHost: "192.0.2.10",
|
||||
wantHost: "192.168.1.10",
|
||||
wantPort: 8080,
|
||||
},
|
||||
{
|
||||
name: "invalid port - too high",
|
||||
input: "192.0.2.10:99999",
|
||||
input: "192.168.1.10:99999",
|
||||
defaultPort: 8080,
|
||||
wantHost: "192.0.2.10",
|
||||
wantHost: "192.168.1.10",
|
||||
wantPort: 8080,
|
||||
},
|
||||
{
|
||||
name: "invalid port - zero",
|
||||
input: "192.0.2.10:0",
|
||||
input: "192.168.1.10:0",
|
||||
defaultPort: 8080,
|
||||
wantHost: "192.0.2.10",
|
||||
wantHost: "192.168.1.10",
|
||||
wantPort: 8080,
|
||||
},
|
||||
{
|
||||
name: "invalid port - negative",
|
||||
input: "192.0.2.10:-123",
|
||||
input: "192.168.1.10:-123",
|
||||
defaultPort: 8080,
|
||||
wantHost: "192.0.2.10",
|
||||
wantHost: "192.168.1.10",
|
||||
wantPort: 8080,
|
||||
},
|
||||
{
|
||||
@@ -105,37 +105,37 @@ func TestParseHostPort(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "multiple colons - malformed",
|
||||
input: "192.0.2.100:8090:extra",
|
||||
input: "192.168.1.100:8090:extra",
|
||||
defaultPort: 8080,
|
||||
wantHost: "192.0.2.100:8090:extra",
|
||||
wantHost: "192.168.1.100:8090:extra",
|
||||
wantPort: 8080,
|
||||
},
|
||||
{
|
||||
name: "standard SoundTouch default",
|
||||
input: "192.0.2.10",
|
||||
input: "192.168.1.10",
|
||||
defaultPort: 8090,
|
||||
wantHost: "192.0.2.10",
|
||||
wantHost: "192.168.1.10",
|
||||
wantPort: 8090,
|
||||
},
|
||||
{
|
||||
name: "valid high port",
|
||||
input: "192.0.2.100:65535",
|
||||
input: "192.168.1.100:65535",
|
||||
defaultPort: 8080,
|
||||
wantHost: "192.0.2.100",
|
||||
wantHost: "192.168.1.100",
|
||||
wantPort: 65535,
|
||||
},
|
||||
{
|
||||
name: "valid low port",
|
||||
input: "192.0.2.100:1",
|
||||
input: "192.168.1.100:1",
|
||||
defaultPort: 8080,
|
||||
wantHost: "192.0.2.100",
|
||||
wantHost: "192.168.1.100",
|
||||
wantPort: 1,
|
||||
},
|
||||
{
|
||||
name: "real SoundTouch device example",
|
||||
input: "192.0.2.10:8090",
|
||||
input: "192.168.1.10:8090",
|
||||
defaultPort: 8080,
|
||||
wantHost: "192.0.2.10",
|
||||
wantHost: "192.168.1.10",
|
||||
wantPort: 8090,
|
||||
},
|
||||
{
|
||||
@@ -166,8 +166,8 @@ func BenchmarkParseHostPort(b *testing.B) {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"with_port", "192.0.2.100:8090"},
|
||||
{"without_port", "192.0.2.100"},
|
||||
{"with_port", "192.168.1.100:8090"},
|
||||
{"without_port", "192.168.1.100"},
|
||||
{"hostname_with_port", "soundtouch.local:8090"},
|
||||
{"ipv6_with_port", "[::1]:8090"},
|
||||
}
|
||||
@@ -193,26 +193,26 @@ func TestParseHostPortSoundTouchScenarios(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "typical_cli_usage",
|
||||
input: "192.0.2.10:8091",
|
||||
input: "192.168.1.10:8091",
|
||||
defaultPort: 8090,
|
||||
description: "User specifies full host:port",
|
||||
wantHost: "192.0.2.10",
|
||||
wantHost: "192.168.1.10",
|
||||
wantPort: 8091,
|
||||
},
|
||||
{
|
||||
name: "discovery_result_host_only",
|
||||
input: "192.0.2.10",
|
||||
input: "192.168.1.10",
|
||||
defaultPort: 8090,
|
||||
description: "Discovery returns IP, CLI uses default port",
|
||||
wantHost: "192.0.2.10",
|
||||
wantHost: "192.168.1.10",
|
||||
wantPort: 8090,
|
||||
},
|
||||
{
|
||||
name: "custom_port_override",
|
||||
input: "192.0.2.100:9000",
|
||||
input: "192.168.1.100:9000",
|
||||
defaultPort: 8090,
|
||||
description: "User overrides default SoundTouch port",
|
||||
wantHost: "192.0.2.100",
|
||||
wantHost: "192.168.1.100",
|
||||
wantPort: 9000,
|
||||
},
|
||||
{
|
||||
@@ -225,10 +225,10 @@ func TestParseHostPortSoundTouchScenarios(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "invalid_port_fallback",
|
||||
input: "192.0.2.10:invalid",
|
||||
input: "192.168.1.10:invalid",
|
||||
defaultPort: 8090,
|
||||
description: "Malformed port should fallback to default",
|
||||
wantHost: "192.0.2.10",
|
||||
wantHost: "192.168.1.10",
|
||||
wantPort: 8090,
|
||||
},
|
||||
}
|
||||
|
||||
+130
-585
File diff suppressed because it is too large
Load Diff
@@ -1,159 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func TestPrintRoutes(t *testing.T) {
|
||||
// Initialize a minimal server to get the router
|
||||
server := handlers.NewServer(nil, nil, "http://localhost:8000", true, true, true)
|
||||
r := setupRouter(server, nil)
|
||||
|
||||
var routes []string
|
||||
walkFunc := func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
|
||||
route = strings.ReplaceAll(route, "/*/", "/")
|
||||
handlerName := runtime.FuncForPC(reflect.ValueOf(handler).Pointer()).Name()
|
||||
// Clean up the handler name (remove package path)
|
||||
// For example, "github.com/gesellix/bose-soundtouch/cmd/soundtouch-service.setupRouter.func1"
|
||||
// or "command-line-arguments.setupRouter.func1"
|
||||
// or "main.setupRouter.func1"
|
||||
parts := strings.Split(handlerName, "/")
|
||||
if len(parts) > 0 {
|
||||
handlerName = parts[len(parts)-1]
|
||||
}
|
||||
// Now we might have "soundtouch-service.setupRouter.func1"
|
||||
// or "command-line-arguments.setupRouter.func1"
|
||||
// or "main.setupRouter.func1"
|
||||
// Let's remove the first part if it's a known varying package name
|
||||
if idx := strings.Index(handlerName, "setupRouter"); idx != -1 {
|
||||
handlerName = handlerName[idx:]
|
||||
}
|
||||
// In case it's not setupRouter but still has a package prefix
|
||||
for {
|
||||
dotIdx := strings.Index(handlerName, ".")
|
||||
if dotIdx == -1 {
|
||||
break
|
||||
}
|
||||
prefix := handlerName[:dotIdx]
|
||||
if prefix == "main" || prefix == "command-line-arguments" || strings.Contains(prefix, "soundtouch-service") {
|
||||
handlerName = handlerName[dotIdx+1:]
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Also remove any ".funcN" suffix if it's an anonymous function
|
||||
if idx := strings.Index(handlerName, ".func"); idx != -1 {
|
||||
handlerName = handlerName[:idx]
|
||||
}
|
||||
|
||||
routes = append(routes, fmt.Sprintf("%-8s %-60s %s", method, route, handlerName))
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := chi.Walk(r, walkFunc); err != nil {
|
||||
t.Fatalf("Failed to walk routes: %v", err)
|
||||
}
|
||||
|
||||
sort.Strings(routes)
|
||||
|
||||
output := strings.Join(routes, "\n") + "\n"
|
||||
|
||||
// Define snapshot path
|
||||
snapshotPath := "testdata/router_routes.txt"
|
||||
actualPath := "testdata/router_routes.actual.txt"
|
||||
|
||||
// Always write the current (actual) routes to a file
|
||||
if err := os.WriteFile(actualPath, []byte(output), 0644); err != nil {
|
||||
t.Fatalf("Failed to write actual routes: %v", err)
|
||||
}
|
||||
|
||||
// Check if snapshot exists
|
||||
if _, err := os.Stat(snapshotPath); os.IsNotExist(err) {
|
||||
// Create testdata directory if it doesn't exist
|
||||
if err := os.MkdirAll("testdata", 0755); err != nil {
|
||||
t.Fatalf("Failed to create testdata directory: %v", err)
|
||||
}
|
||||
// Initial snapshot creation
|
||||
if err := os.WriteFile(snapshotPath, []byte(output), 0644); err != nil {
|
||||
t.Fatalf("Failed to write snapshot: %v", err)
|
||||
}
|
||||
t.Logf("Initial snapshot created at %s", snapshotPath)
|
||||
return
|
||||
}
|
||||
|
||||
// Read existing snapshot
|
||||
existingOutput, err := os.ReadFile(snapshotPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read snapshot: %v", err)
|
||||
}
|
||||
|
||||
if string(existingOutput) != output {
|
||||
t.Errorf("Router routes changed! Diff the snapshot at %s with %s", snapshotPath, actualPath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPUTRenameRoutesToLocalHandler reproduces the runtime routing
|
||||
// behaviour the user saw on their deployed v0.80.0: a PUT to
|
||||
// /streaming/account/{a}/device/{d} should land on
|
||||
// HandleMargeUpdateDevice, not fall through to the [UNHANDLED]
|
||||
// proxy. The handlers-package test (TestIssue285_*) uses a simplified
|
||||
// router that doesn't have the overlapping `/device` and
|
||||
// `/device/{device}` route groups, so it can't catch a chi radix-
|
||||
// tree resolution that prefers the more-specific subrouter.
|
||||
//
|
||||
// This test exercises the actual production setupRouter so a
|
||||
// regression in the route topology is caught against the same chi
|
||||
// behaviour speakers will see.
|
||||
func TestPUTRenameRoutesToLocalHandler(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "router-rename-")
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir temp: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
server := handlers.NewServer(ds, nil, "http://localhost:8000", false, false, false)
|
||||
r := setupRouter(server, nil)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
body := `<?xml version="1.0" encoding="UTF-8" ?><device deviceid="AABBCCDDEEFF"><name>Living Room SoundTouch</name><macaddress>AABBCCDDEEFF</macaddress></device>`
|
||||
|
||||
req, err := http.NewRequest(http.MethodPut,
|
||||
ts.URL+"/streaming/account/1111111/device/AABBCCDDEEFF",
|
||||
strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("build request: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/xml")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("PUT: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// 200 means our local HandleMargeUpdateDevice handled it.
|
||||
// 401 / 502 / anything else means the request fell through to
|
||||
// the [UNHANDLED] proxy and got the upstream response — which
|
||||
// is exactly the failure mode #285 was supposed to fix.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("PUT status = %d, want 200 (local handler). Anything else means the request fell through to [UNHANDLED] proxy — chi is routing to a different subrouter than the PUT registration intended.", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
*.actual.txt
|
||||
-173
@@ -1,173 +0,0 @@
|
||||
CONNECT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
CONNECT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-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 /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
DELETE /setup/devices/{deviceId} handlers.(*Server).HandleRemoveDevice-fm
|
||||
DELETE /setup/dns-discoveries handlers.(*Server).HandleClearDNSDiscoveries-fm
|
||||
DELETE /setup/interactions/sessions handlers.(*Server).HandleCleanupSessions-fm
|
||||
DELETE /setup/interactions/sessions/{session} handlers.(*Server).HandleDeleteSession-fm
|
||||
DELETE /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeRemoveDevice-fm
|
||||
DELETE /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeRemovePreset-fm
|
||||
DELETE /streaming/account/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
|
||||
GET / handlers.(*Server).HandleRoot-fm
|
||||
GET /accounts/{account}/devices handlers.(*Server).HandleMargeAccountDevices-fm
|
||||
GET /accounts/{account}/devices/{device}/group handlers.(*Server).HandleMargeDeviceGroup-fm
|
||||
GET /accounts/{account}/devices/{device}/group/ handlers.(*Server).HandleMargeDeviceGroup-fm
|
||||
GET /accounts/{account}/devices/{device}/group/member handlers.(*Server).HandleMargeDeviceGroupMember-fm
|
||||
GET /accounts/{account}/devices/{device}/group/server handlers.(*Server).HandleMargeDeviceGroupServer-fm
|
||||
GET /accounts/{account}/devices/{device}/presets handlers.(*Server).HandleMargePresets-fm
|
||||
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
|
||||
GET /bmx/tunein/v1/navigate/* handlers.(*Server).HandleTuneInNavigate-fm
|
||||
GET /bmx/tunein/v1/playback/episode/{podcastID} handlers.(*Server).HandleTuneInPlaybackPodcast-fm
|
||||
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 /core02/svc-bmx-adapter-orion/prod/orion/station handlers.(*Server).HandleOrionPlayback-fm
|
||||
GET /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
GET /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
GET /custom/v1/playback/{encodedURL} handlers.(*Server).HandleCustomPlayback-fm
|
||||
GET /customer/account/{account} handlers.(*Server).HandleMargeAccountProfile-fm
|
||||
GET /docs/* handlers.(*Server).HandleDocs-fm
|
||||
GET /favicon.ico setupRouter
|
||||
GET /health handlers.(*Server).HandleHealth-fm
|
||||
GET /media/* handlers.(*Server).HandleMedia
|
||||
GET /media/aftertouch-ding.wav handlers.(*Server).HandleDing-fm
|
||||
GET /mgmt/accounts/ handlers.(*Server).HandleMgmtListAccounts-fm
|
||||
GET /mgmt/accounts/{accountId} handlers.(*Server).HandleMgmtAccountDetails-fm
|
||||
GET /mgmt/accounts/{accountId}/speakers handlers.(*Server).HandleMgmtListSpeakers-fm
|
||||
GET /mgmt/amazon/accounts handlers.(*Server).HandleMgmtAmazonAccounts-fm
|
||||
GET /mgmt/amazon/callback handlers.(*Server).HandleMgmtAmazonCallback-fm
|
||||
GET /mgmt/amazon/token handlers.(*Server).HandleMgmtAmazonToken-fm
|
||||
GET /mgmt/devices/{deviceId}/events handlers.(*Server).HandleMgmtDeviceEvents-fm
|
||||
GET /mgmt/spotify/accounts handlers.(*Server).HandleMgmtSpotifyAccounts-fm
|
||||
GET /mgmt/spotify/callback handlers.(*Server).HandleMgmtSpotifyCallback-fm
|
||||
GET /mgmt/spotify/token handlers.(*Server).HandleMgmtSpotifyToken-fm
|
||||
GET /setup/account-id-suggestions/{deviceId} handlers.(*Server).HandleAccountIDSuggestions-fm
|
||||
GET /setup/ca.crt handlers.(*Server).HandleGetCACert-fm
|
||||
GET /setup/device-summary/{deviceId} handlers.(*Server).HandleDeviceSummary-fm
|
||||
GET /setup/devices handlers.(*Server).HandleListDiscoveredDevices-fm
|
||||
GET /setup/devices/{deviceId}/events handlers.(*Server).HandleGetDeviceEvents-fm
|
||||
GET /setup/discovery-status handlers.(*Server).HandleGetDiscoveryStatus-fm
|
||||
GET /setup/dns-discoveries handlers.(*Server).HandleGetDNSDiscoveries-fm
|
||||
GET /setup/dns-discoveries/download handlers.(*Server).HandleDownloadDNSDiscoveries-fm
|
||||
GET /setup/export/diagnostic handlers.(*Server).HandleExportDiagnostic-fm
|
||||
GET /setup/health handlers.(*Server).HandleHealthChecks-fm
|
||||
GET /setup/info/{deviceId} handlers.(*Server).HandleGetDeviceInfo-fm
|
||||
GET /setup/interaction-content handlers.(*Server).HandleGetInteractionContent-fm
|
||||
GET /setup/interaction-stats handlers.(*Server).HandleGetInteractionStats-fm
|
||||
GET /setup/interactions handlers.(*Server).HandleListInteractions-fm
|
||||
GET /setup/interactions/sessions/{session}/download handlers.(*Server).HandleDownloadSession-fm
|
||||
GET /setup/logging-settings handlers.(*Server).HandleGetLoggingSettings-fm
|
||||
GET /setup/logs handlers.(*Server).HandleGetLogs-fm
|
||||
GET /setup/settings handlers.(*Server).HandleGetSettings-fm
|
||||
GET /setup/summary/{deviceId} handlers.(*Server).HandleGetMigrationSummary-fm
|
||||
GET /setup/version handlers.(*Server).HandleGetVersionInfo-fm
|
||||
GET /streaming/account/{account}/device/{device}/group handlers.(*Server).HandleMargeDeviceGroup-fm
|
||||
GET /streaming/account/{account}/device/{device}/group/ handlers.(*Server).HandleMargeDeviceGroup-fm
|
||||
GET /streaming/account/{account}/device/{device}/group/member handlers.(*Server).HandleMargeDeviceGroupMember-fm
|
||||
GET /streaming/account/{account}/device/{device}/group/server handlers.(*Server).HandleMargeDeviceGroupServer-fm
|
||||
GET /streaming/account/{account}/device/{device}/presets handlers.(*Server).HandleMargePresets-fm
|
||||
GET /streaming/account/{account}/device/{device}/recent handlers.(*Server).HandleMargeRecents-fm
|
||||
GET /streaming/account/{account}/device/{device}/recents handlers.(*Server).HandleMargeRecents-fm
|
||||
GET /streaming/account/{account}/devices handlers.(*Server).HandleMargeAccountDevices-fm
|
||||
GET /streaming/account/{account}/emailaddress handlers.(*Server).HandleMargeGetEmailAddress-fm
|
||||
GET /streaming/account/{account}/full handlers.(*Server).HandleMargeAccountFull-fm
|
||||
GET /streaming/account/{account}/presets handlers.(*Server).HandleMargeAccountPresets-fm
|
||||
GET /streaming/account/{account}/presets/all handlers.(*Server).HandleMargeAccountPresets-fm
|
||||
GET /streaming/account/{account}/provider_settings handlers.(*Server).HandleMargeProviderSettings-fm
|
||||
GET /streaming/account/{account}/sources handlers.(*Server).HandleMargeAccountSources-fm
|
||||
GET /streaming/device/{device}/streaming_token handlers.(*Server).HandleMargeStreamingToken-fm
|
||||
GET /streaming/device_setting/account/{account}/device/{device}/device_settings handlers.(*Server).HandleMargeGetDeviceSettings-fm
|
||||
GET /streaming/resources/api_versions.xml handlers.(*Server).HandleMargeAPIVersions-fm
|
||||
GET /streaming/software/update/account/{account} handlers.(*Server).HandleMargeSoftwareUpdate-fm
|
||||
GET /streaming/sourceproviders handlers.(*Server).HandleMargeSourceProviders-fm
|
||||
GET /updates/soundtouch handlers.(*Server).HandleMargeSoftwareUpdate-fm
|
||||
GET /v1/blacklist/{deviceId} setupRouter
|
||||
GET /web/* setupRouter.(*Server).HandleWeb
|
||||
HEAD /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
HEAD /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
OPTIONS /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
OPTIONS /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
PATCH /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
PATCH /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
POST /accounts/{account}/devices handlers.(*Server).HandleMargeAddDevice-fm
|
||||
POST /accounts/{account}/devices/{device}/presets/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
|
||||
POST /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleMargeAddRecent-fm
|
||||
POST /accounts/{account}/group handlers.(*Server).HandleMargeAddGroup-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/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 /core02/svc-bmx-adapter-orion/prod/orion/token handlers.(*Server).HandleOrionToken-fm
|
||||
POST /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
POST /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
POST /customer/account/{account} handlers.(*Server).HandleMargeUpdateAccountProfile-fm
|
||||
POST /customer/account/{account}/password handlers.(*Server).HandleMargeChangePassword-fm
|
||||
POST /mgmt/accounts/{accountId}/language handlers.(*Server).HandleMgmtUpdateAccountLanguage-fm
|
||||
POST /mgmt/accounts/{accountId}/provider-settings handlers.(*Server).HandleMgmtUpdateAccountProviderSetting-fm
|
||||
POST /mgmt/amazon/confirm handlers.(*Server).HandleMgmtAmazonConfirm-fm
|
||||
POST /mgmt/amazon/init handlers.(*Server).HandleMgmtAmazonInit-fm
|
||||
POST /mgmt/amazon/prime handlers.(*Server).HandleMgmtPrimeDeviceAmazon-fm
|
||||
POST /mgmt/spotify/confirm handlers.(*Server).HandleMgmtSpotifyConfirm-fm
|
||||
POST /mgmt/spotify/entity handlers.(*Server).HandleMgmtSpotifyEntity-fm
|
||||
POST /mgmt/spotify/init handlers.(*Server).HandleMgmtSpotifyInit-fm
|
||||
POST /mgmt/spotify/prime handlers.(*Server).HandleMgmtPrimeDevice-fm
|
||||
POST /oauth/account/{account}/music/musicprovider/{sourceID}/token/cs handlers.(*Server).HandleBoseAccountToken-fm
|
||||
POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token handlers.(*Server).HandleBoseLegacyToken-fm
|
||||
POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token/cs1 handlers.(*Server).HandleBoseToken-fm
|
||||
POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token/cs3 handlers.(*Server).HandleBoseToken-fm
|
||||
POST /setup/backup/{deviceId} handlers.(*Server).HandleBackupConfig-fm
|
||||
POST /setup/devices handlers.(*Server).HandleAddManualDevice-fm
|
||||
POST /setup/discover handlers.(*Server).HandleTriggerDiscovery-fm
|
||||
POST /setup/ensure-remote-services/{deviceId} handlers.(*Server).HandleEnsureRemoteServices-fm
|
||||
POST /setup/health/fix handlers.(*Server).HandleHealthFix-fm
|
||||
POST /setup/logging-settings handlers.(*Server).HandleUpdateLoggingSettings-fm
|
||||
POST /setup/migrate/{deviceId} handlers.(*Server).HandleMigrateDevice-fm
|
||||
POST /setup/pair-account/{deviceId} handlers.(*Server).HandlePairAccount-fm
|
||||
POST /setup/peer-probe/{deviceId} handlers.(*Server).HandlePeerProbe-fm
|
||||
POST /setup/reboot/{deviceId} handlers.(*Server).HandleRebootDevice-fm
|
||||
POST /setup/remove-remote-services/{deviceId} handlers.(*Server).HandleRemoveRemoteServices-fm
|
||||
POST /setup/revert/{deviceId} handlers.(*Server).HandleRevertMigration-fm
|
||||
POST /setup/settings handlers.(*Server).HandleUpdateSettings-fm
|
||||
POST /setup/sync/{deviceId} handlers.(*Server).HandleInitialSync-fm
|
||||
POST /setup/test-connection/{deviceId} handlers.(*Server).HandleTestConnection-fm
|
||||
POST /setup/test-dns/{deviceId} handlers.(*Server).HandleTestDNSRedirection-fm
|
||||
POST /setup/test-hosts/{deviceId} handlers.(*Server).HandleTestHostsRedirection-fm
|
||||
POST /setup/trust-ca/{deviceId} handlers.(*Server).HandleTrustCACert-fm
|
||||
POST /streaming/account handlers.(*Server).HandleMargeCreateAccount-fm
|
||||
POST /streaming/account/login handlers.(*Server).HandleMargeLogin-fm
|
||||
POST /streaming/account/{account}/device/ handlers.(*Server).HandleMargeAddDevice-fm
|
||||
POST /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeAddDevice-fm
|
||||
POST /streaming/account/{account}/device/{device}/presets/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
|
||||
POST /streaming/account/{account}/device/{device}/recent handlers.(*Server).HandleMargeAddRecent-fm
|
||||
POST /streaming/account/{account}/group handlers.(*Server).HandleMargeAddGroup-fm
|
||||
POST /streaming/account/{account}/group/ handlers.(*Server).HandleMargeAddGroup-fm
|
||||
POST /streaming/account/{account}/group/{groupId} handlers.(*Server).HandleMargeModifyGroup-fm
|
||||
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
|
||||
POST /streaming/support/power_on handlers.(*Server).HandleMargePowerOn-fm
|
||||
POST /v1/scmudc/{deviceId} handlers.(*Server).HandleAppEvents-fm
|
||||
POST /v1/stapp/{deviceId} handlers.(*Server).HandleAppEvents-fm
|
||||
PUT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
PUT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
PUT /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeUpdateDevice-fm
|
||||
PUT /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
|
||||
TRACE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
TRACE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
@@ -1,2 +0,0 @@
|
||||
soundtouch-web
|
||||
soundtouch-web-test
|
||||
@@ -1,276 +0,0 @@
|
||||
# SoundTouch Web Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
The `soundtouch-web` tool provides a modern single-page application (SPA) for controlling Bose SoundTouch devices. Built with a JSON API backend and client-side JavaScript rendering, it offers superior performance and eliminates template rendering issues.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Single-Page Application Design
|
||||
|
||||
The architecture eliminates Go template dependencies and provides:
|
||||
- **JSON API Backend**: Pure Go server returning only JSON responses
|
||||
- **Client-Side Rendering**: JavaScript handles all HTML generation
|
||||
- **WebSocket Real-time**: Bi-directional communication for live updates
|
||||
- **Better Performance**: No server-side template processing
|
||||
- **Easier Development**: Clear separation of frontend/backend concerns
|
||||
|
||||
### Core Components
|
||||
|
||||
#### 1. Main Application (`main.go`)
|
||||
- **Entry Point**: Handles command-line arguments and application initialization
|
||||
- **SPA Routing**: Serves static HTML file for all non-API routes
|
||||
- **Device Discovery**: Automatic discovery of SoundTouch devices using unified discovery service
|
||||
- **JSON API Server**: Configures API routes and serves the SPA
|
||||
- **Context Management**: Proper context handling for timeouts and cancellation
|
||||
|
||||
#### 2. HTTP Handlers (`handlers/handlers.go`)
|
||||
- **WebApp Structure**: Central application state management
|
||||
- **JSON API Endpoints**: RESTful API returning only JSON responses
|
||||
- **Device Control**: Device control with proper validation and error handling
|
||||
- **Modular Design**: Separated control actions into focused functions
|
||||
|
||||
#### 3. WebSocket Support (`handlers/websocket.go`)
|
||||
- **Real-time Updates**: Live device status streaming to web clients
|
||||
- **Device WebSocket Connections**: Maintains persistent connections to SoundTouch devices
|
||||
- **Event Handling**: Processes nowPlaying, volume, and connection state updates
|
||||
- **Status Synchronization**: Keeps device status current across all connected clients
|
||||
|
||||
#### 4. Type Definitions (`webtypes/types.go`)
|
||||
- **Device Management**: Structures for device connections and status
|
||||
- **API Responses**: Standardized JSON response format
|
||||
- **WebSocket Messages**: Real-time message types
|
||||
- **Template Data**: HTML template data structures
|
||||
|
||||
### Key Features Implemented
|
||||
|
||||
#### Device Discovery & Management
|
||||
- **Auto-discovery**: Finds SoundTouch devices on local network using mDNS/UPnP
|
||||
- **Multi-device Support**: Manages multiple devices simultaneously
|
||||
- **Connection Tracking**: Monitors device availability and connection status
|
||||
- **Device Information**: Displays device details (name, type, IP address)
|
||||
|
||||
#### Real-time Control Interface
|
||||
- **Now Playing**: Live track information with artwork display
|
||||
- **Playback Controls**: Play/pause/stop/next/previous with visual feedback
|
||||
- **Volume Control**: Real-time volume slider with mute functionality
|
||||
- **Bass Adjustment**: Bass level control for supported devices
|
||||
- **Preset Management**: Quick access to saved presets (1-6)
|
||||
- **Source Selection**: Input switching (Spotify, TuneIn, Bluetooth, AUX, etc.)
|
||||
|
||||
#### Web Interface
|
||||
- **Single-Page Application**: Self-contained HTML file with embedded CSS and JavaScript
|
||||
- **Responsive Design**: Bootstrap 5-based UI optimized for desktop and mobile
|
||||
- **Client-Side Routing**: JavaScript handles page navigation without page reloads
|
||||
- **Dynamic Rendering**: All HTML generated client-side from JSON data
|
||||
- **Real-time Updates**: WebSocket-powered live status updates
|
||||
- **Performance Optimized**: Fast loading and no template rendering delays
|
||||
|
||||
#### API Endpoints
|
||||
```
|
||||
GET / # SPA - serves static/index.html
|
||||
GET /api/devices # List all devices (JSON)
|
||||
GET /api/device/{id} # Get device info (JSON)
|
||||
POST /api/discover # Trigger device discovery
|
||||
GET /api/control/{id}/play # Playback control
|
||||
GET /api/control/{id}/pause # Pause playback
|
||||
GET /api/control/{id}/stop # Stop playback
|
||||
GET /api/control/{id}/next # Next track
|
||||
GET /api/control/{id}/previous # Previous track
|
||||
POST /api/control/{id}/volume # Set volume (JSON body)
|
||||
GET /api/control/{id}/mute # Toggle mute
|
||||
POST /api/control/{id}/bass # Set bass level (JSON body)
|
||||
GET /api/control/{id}/preset?id=N # Select preset
|
||||
GET /api/control/{id}/source?name=X # Select source
|
||||
```
|
||||
|
||||
#### WebSocket Events
|
||||
- **Connection**: `ws://localhost:8080/ws`
|
||||
- **Device Updates**: Real-time device list changes
|
||||
- **Status Updates**: Live playback and volume changes
|
||||
- **Connection Monitoring**: Device availability status
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Frontend Architecture
|
||||
- **Single HTML File**: Complete application in `static/index.html`
|
||||
- **Embedded CSS**: Bootstrap 5 with custom Bose-inspired styling
|
||||
- **Vanilla JavaScript**: No framework dependencies, fast performance
|
||||
- **Client-Side Routing**: JavaScript manages page state without reloads
|
||||
- **Dynamic Components**: HTML elements generated from JSON API responses
|
||||
|
||||
### Error Handling & Validation
|
||||
- **Input Validation**: Proper bounds checking for volume (0-100) and bass (-9 to 9)
|
||||
- **HTTP Status Codes**: Appropriate response codes for different error conditions
|
||||
- **JSON Error Responses**: Structured error messages for API consumers
|
||||
- **Client-Side Error Display**: JavaScript toast notifications for user feedback
|
||||
|
||||
### Code Quality
|
||||
- **golangci-lint Compliance**: Passes all configured lint checks
|
||||
- **Context Handling**: Proper context propagation and timeout management
|
||||
- **Error Checking**: All JSON encoding/decoding operations checked
|
||||
- **Type Safety**: Strong typing with dedicated type package
|
||||
- **Test Coverage**: Comprehensive unit tests for handlers and types
|
||||
|
||||
### WebSocket Integration
|
||||
- **Gabbo Protocol**: Native SoundTouch WebSocket protocol implementation
|
||||
- **Event Processing**: Handles all documented SoundTouch WebSocket events
|
||||
- **Connection Management**: Automatic reconnection and health monitoring
|
||||
- **Bi-directional Communication**: Both status monitoring and device control
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Core Libraries
|
||||
- **chi v5**: HTTP router (inherited from existing codebase)
|
||||
- **gorilla/websocket**: WebSocket implementation
|
||||
- **Go standard library**: html/template, net/http, encoding/json
|
||||
|
||||
### Project Dependencies
|
||||
- **pkg/client**: SoundTouch HTTP and WebSocket client library
|
||||
- **pkg/discovery**: Device discovery service (mDNS/UPnP)
|
||||
- **pkg/models**: XML/JSON data structures for SoundTouch API
|
||||
- **pkg/config**: Configuration management
|
||||
|
||||
### Frontend Dependencies
|
||||
- **Bootstrap 5**: CSS framework for responsive design
|
||||
- **Bootstrap Icons**: Icon library for UI elements
|
||||
- **Vanilla JavaScript**: No external JS frameworks, pure WebSocket implementation
|
||||
|
||||
## Build & Testing
|
||||
|
||||
### Build Commands
|
||||
```bash
|
||||
# Build the web application
|
||||
cd cmd/soundtouch-web
|
||||
go build -o soundtouch-web
|
||||
|
||||
# Build all project components (includes soundtouch-web)
|
||||
make build
|
||||
|
||||
# Cross-platform builds
|
||||
make build-all
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Run unit tests
|
||||
go test ./cmd/soundtouch-web/...
|
||||
|
||||
# Run with coverage
|
||||
go test -cover ./cmd/soundtouch-web/...
|
||||
|
||||
# Lint checking
|
||||
golangci-lint run cmd/soundtouch-web/...
|
||||
```
|
||||
|
||||
### Development Server
|
||||
```bash
|
||||
# Run development server
|
||||
cd cmd/soundtouch-web
|
||||
go run main.go -port 8080
|
||||
|
||||
# Access the web interface
|
||||
open http://localhost:8080
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Command Line Options
|
||||
```bash
|
||||
soundtouch-web [options]
|
||||
|
||||
Options:
|
||||
-port string Web server port (default "8080")
|
||||
-host string Specific device host for single-device mode (optional)
|
||||
```
|
||||
|
||||
### File Structure
|
||||
```
|
||||
cmd/soundtouch-web/
|
||||
├── main.go # Application entry point
|
||||
├── soundtouch-web # Built binary
|
||||
├── handlers/
|
||||
│ ├── handlers.go # HTTP request handlers
|
||||
│ ├── handlers_test.go # Handler tests
|
||||
│ └── websocket.go # WebSocket functionality
|
||||
├── webtypes/
|
||||
│ ├── types.go # Type definitions
|
||||
│ └── types_test.go # Type tests
|
||||
├── templates/
|
||||
│ ├── layout.html # Base HTML layout
|
||||
│ ├── index.html # Device list page
|
||||
│ └── device.html # Device control page
|
||||
├── static/
|
||||
│ └── style.css # Additional CSS styles
|
||||
└── README.md # User documentation
|
||||
```
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
### Supported Browsers
|
||||
- **Chrome 80+** (recommended)
|
||||
- **Firefox 75+**
|
||||
- **Safari 13+**
|
||||
- **Edge 80+**
|
||||
|
||||
### Required Features
|
||||
- WebSocket support
|
||||
- CSS Grid and Flexbox
|
||||
- ES6 JavaScript features
|
||||
- JSON API support
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Design Principles
|
||||
- **Local Network Only**: Designed for trusted local network environments
|
||||
- **No Authentication**: Assumes local network security
|
||||
- **CORS Policy**: Restricted to same-origin requests
|
||||
- **Input Validation**: All user inputs validated on server side
|
||||
|
||||
### Network Security
|
||||
- **Port Usage**: Uses standard HTTP port (configurable)
|
||||
- **WebSocket Security**: Same-origin WebSocket connections only
|
||||
- **No External Dependencies**: All resources served locally
|
||||
|
||||
## Performance Characteristics
|
||||
|
||||
### Resource Usage
|
||||
- **Memory**: Minimal footprint, scales with number of discovered devices
|
||||
- **CPU**: Low usage, event-driven architecture
|
||||
- **Network**: Efficient WebSocket connections, HTTP REST for control
|
||||
|
||||
### Scalability
|
||||
- **Device Limits**: Designed for typical home networks (5-20 devices)
|
||||
- **Concurrent Users**: Multiple browser sessions supported
|
||||
- **Update Frequency**: Real-time updates without polling
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Potential Features
|
||||
- **Zone Management**: Multi-room audio control
|
||||
- **Preset Programming**: Advanced preset configuration
|
||||
- **Mobile PWA**: Progressive Web App for mobile installation
|
||||
- **Theme Support**: Additional UI themes
|
||||
- **Device Grouping**: Logical device organization
|
||||
|
||||
### Technical Improvements
|
||||
- **Caching**: Enhanced device status caching
|
||||
- **Compression**: WebSocket message compression
|
||||
- **Persistence**: Device settings persistence
|
||||
- **Metrics**: Usage analytics and performance monitoring
|
||||
|
||||
## Integration with Main Project
|
||||
|
||||
### Project Alignment
|
||||
- **Consistent Architecture**: Follows established project patterns
|
||||
- **Shared Libraries**: Leverages existing pkg/ modules
|
||||
- **Build Integration**: Included in main Makefile targets
|
||||
- **Documentation**: Consistent with project documentation standards
|
||||
|
||||
### Migration Path
|
||||
- **Cloud Replacement**: Serves as local alternative to Bose cloud services
|
||||
- **API Compatibility**: Maintains compatibility with existing SoundTouch APIs
|
||||
- **User Experience**: Familiar interface for existing SoundTouch app users
|
||||
- **Long-term Support**: Designed for continued operation post-2026
|
||||
|
||||
This implementation provides a robust, feature-complete web interface for SoundTouch device control, ensuring continued functionality beyond the official app's lifecycle while maintaining high code quality and user experience standards.
|
||||
@@ -1,330 +0,0 @@
|
||||
# SoundTouch Web UI
|
||||
|
||||
A modern single-page web application (SPA) for controlling Bose SoundTouch devices. Built with a JSON API backend and client-side JavaScript rendering for superior performance and maintainability.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser → Static HTML → JavaScript → JSON API → Go Server
|
||||
↓
|
||||
Client-Side Rendering
|
||||
```
|
||||
|
||||
### Key Benefits
|
||||
- **Better Performance**: No server-side template processing overhead
|
||||
- **Improved Maintainability**: Clear separation between frontend (JavaScript) and backend (Go)
|
||||
- **Real-time Experience**: Smooth client-side updates without page reloads
|
||||
- **Mobile Ready**: The JSON API can power both this web interface and mobile applications
|
||||
|
||||
## Features
|
||||
|
||||
Based on captured WebSocket interactions and device API capabilities, this web UI provides:
|
||||
|
||||
### Device Management
|
||||
- **Auto-discovery** of SoundTouch devices on the network
|
||||
- **Real-time status monitoring** via WebSocket connections
|
||||
- **Multi-device support** with centralized control
|
||||
- **Connection status** indicators and health monitoring
|
||||
|
||||
### Playback Control
|
||||
- **Play/Pause/Stop/Next/Previous** controls
|
||||
- **Now playing information** with artwork, track details, and progress
|
||||
- **Real-time updates** of playback state changes
|
||||
- **Source selection** from available inputs (Spotify, TuneIn, Bluetooth, AUX, etc.)
|
||||
|
||||
### Audio Controls
|
||||
- **Volume control** with real-time slider updates
|
||||
- **Mute/Unmute** functionality
|
||||
- **Bass adjustment** (on supported models)
|
||||
- **Audio level monitoring** and statistics
|
||||
|
||||
### Preset Management
|
||||
- **6 preset buttons** with visual feedback
|
||||
- **Preset content display** showing station/playlist names
|
||||
- **One-click preset selection**
|
||||
|
||||
### Advanced Features
|
||||
- **WebSocket real-time updates** for instant state synchronization
|
||||
- **Responsive design** optimized for desktop and mobile
|
||||
- **Dark mode support** (auto-detects system preference)
|
||||
- **Accessibility features** (keyboard navigation, screen reader support)
|
||||
- **Network statistics** and device health monitoring
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Main Device Overview
|
||||
The main page shows all discovered devices with their current status, now-playing information, and quick controls.
|
||||
|
||||
### Detailed Device Control
|
||||
Individual device pages provide full control over:
|
||||
- Detailed now-playing information with artwork
|
||||
- Comprehensive audio controls (volume, bass)
|
||||
- Full preset and source selection
|
||||
- Real-time status updates
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
- Go 1.21 or later
|
||||
- Access to SoundTouch devices on the same network
|
||||
- Modern web browser with WebSocket support
|
||||
|
||||
### Building
|
||||
```bash
|
||||
# From project root
|
||||
make build
|
||||
|
||||
# Or manually
|
||||
cd cmd/soundtouch-web
|
||||
go build -o soundtouch-web
|
||||
```
|
||||
|
||||
### Running
|
||||
```bash
|
||||
# Run with default settings (port 8080)
|
||||
./soundtouch-web
|
||||
|
||||
# Specify custom port
|
||||
./soundtouch-web -port 8888
|
||||
|
||||
# Connect to specific device
|
||||
./soundtouch-web -host 192.0.2.100
|
||||
```
|
||||
|
||||
### Command Line Options
|
||||
```
|
||||
-port string Web server port (default "8080")
|
||||
-host string Specific SoundTouch device host (optional, enables single-device mode)
|
||||
-help Show help information
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Accessing the Interface
|
||||
1. Start the application
|
||||
2. Open your web browser and navigate to `http://localhost:8080`
|
||||
3. Click "Discover Devices" to find SoundTouch devices on your network
|
||||
4. Click on any device for detailed control, or use quick controls from the main page
|
||||
|
||||
### Device Discovery
|
||||
The application automatically discovers SoundTouch devices using:
|
||||
- **mDNS discovery** for local network devices
|
||||
- **UPnP/SSDP discovery** as fallback
|
||||
- **Manual device addition** via IP address
|
||||
|
||||
### Real-time Updates
|
||||
The interface maintains WebSocket connections to each device for instant updates of:
|
||||
- Now playing information and artwork
|
||||
- Volume and audio settings changes
|
||||
- Playback status (play/pause/stop)
|
||||
- Connection status and device health
|
||||
|
||||
### Responsive Design
|
||||
- **Desktop**: Full-featured interface with side-by-side panels
|
||||
- **Tablet**: Optimized layout with touch-friendly controls
|
||||
- **Mobile**: Stacked interface with gesture support
|
||||
|
||||
## API Endpoints
|
||||
|
||||
The web UI exposes a REST API for programmatic control:
|
||||
|
||||
### Device Management
|
||||
```
|
||||
GET /api/devices # List all discovered devices
|
||||
GET /api/device/{id} # Get specific device info
|
||||
POST /api/discover # Trigger device discovery
|
||||
```
|
||||
|
||||
### Device Control
|
||||
```
|
||||
GET /api/control/{id}/play # Start playback
|
||||
GET /api/control/{id}/pause # Pause playback
|
||||
GET /api/control/{id}/stop # Stop playback
|
||||
GET /api/control/{id}/next # Next track
|
||||
GET /api/control/{id}/previous # Previous track
|
||||
POST /api/control/{id}/volume # Set volume (body: {"level": 50})
|
||||
GET /api/control/{id}/mute # Mute audio
|
||||
GET /api/control/{id}/unmute # Unmute audio
|
||||
POST /api/control/{id}/bass # Set bass (body: {"level": 0})
|
||||
GET /api/control/{id}/preset?id=1 # Select preset
|
||||
GET /api/control/{id}/source?name=SPOTIFY # Select source
|
||||
```
|
||||
|
||||
### WebSocket Events
|
||||
Connect to `/ws` for real-time updates:
|
||||
```javascript
|
||||
const ws = new WebSocket('ws://localhost:8080/ws');
|
||||
ws.onmessage = function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
// Handle device updates, status changes, etc.
|
||||
};
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Single-Page Application Architecture
|
||||
- **JSON API Backend**: Go server providing RESTful endpoints
|
||||
- **Client-Side Rendering**: JavaScript handles all UI rendering
|
||||
- **WebSocket Real-time**: Bi-directional real-time communication
|
||||
- **No Template Dependencies**: Eliminates server-side template issues
|
||||
|
||||
### Backend Components
|
||||
- **Discovery Service**: Finds and manages SoundTouch devices
|
||||
- **WebSocket Manager**: Maintains real-time connections to devices
|
||||
- **JSON API Server**: RESTful interface returning only JSON
|
||||
- **Device Manager**: Tracks device state and health
|
||||
|
||||
### Frontend Components
|
||||
- **Bootstrap 5**: Modern responsive UI framework
|
||||
- **Vanilla JavaScript**: No framework dependencies, fast loading
|
||||
- **WebSocket Client**: Real-time bidirectional communication
|
||||
- **Dynamic Rendering**: Client-side HTML generation from JSON
|
||||
|
||||
### Communication Flow
|
||||
1. **SPA Loading**: Single HTML file with embedded CSS and JavaScript
|
||||
2. **JSON API**: Device discovery and control via REST endpoints
|
||||
3. **WebSocket (Device)**: Real-time status updates from SoundTouch devices
|
||||
4. **WebSocket (Browser)**: Real-time UI updates to web clients
|
||||
5. **Client Rendering**: JavaScript dynamically creates all UI elements
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
```
|
||||
cmd/soundtouch-web/
|
||||
├── main.go # Application entry point and SPA routing
|
||||
├── handlers/ # HTTP and WebSocket handlers
|
||||
│ ├── handlers.go # JSON API endpoints
|
||||
│ └── websocket.go # WebSocket management
|
||||
├── webtypes/ # Type definitions
|
||||
│ └── types.go # Request/response types
|
||||
├── static/ # Static assets
|
||||
│ ├── index.html # Single-page application
|
||||
│ └── js/ # Legacy JS files (reference)
|
||||
├── templates/ # Legacy templates (unused in SPA)
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
### Adding New Features
|
||||
1. **API Endpoints**: Add new JSON routes in `setupRoutes()` and `handlers.go`
|
||||
2. **WebSocket Events**: Extend event handlers in WebSocket client
|
||||
3. **UI Components**: Add JavaScript rendering functions in `static/index.html`
|
||||
4. **Device Controls**: Implement new control commands and update client-side handlers
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Unit tests
|
||||
go test ./...
|
||||
|
||||
# Manual testing with multiple devices
|
||||
./soundtouch-web -port 8080
|
||||
|
||||
# API testing
|
||||
curl http://localhost:8080/api/devices
|
||||
```
|
||||
|
||||
## WebSocket Protocol Analysis
|
||||
|
||||
This UI is based on extensive analysis of captured SoundTouch WebSocket interactions, including:
|
||||
|
||||
### Message Types Implemented
|
||||
- **SoundTouchSdkInfo**: Initial handshake and version info
|
||||
- **nowPlayingUpdated**: Real-time track information
|
||||
- **volumeUpdated**: Audio level changes
|
||||
- **recentsUpdated**: Recently played items
|
||||
- **userActivityUpdate**: User interaction notifications
|
||||
|
||||
### Request/Response Patterns
|
||||
- **Device Information**: System details and capabilities
|
||||
- **Audio Controls**: Volume, bass, mute controls
|
||||
- **Playback Control**: Play/pause/stop/skip commands
|
||||
- **Source Selection**: Input switching (Spotify, TuneIn, etc.)
|
||||
- **Preset Management**: Saved station/playlist access
|
||||
|
||||
### Gabbo Protocol Features
|
||||
- **Persistent Connections**: Maintains long-lived WebSocket connections
|
||||
- **Request Correlation**: Uses request IDs for response matching
|
||||
- **Real-time Events**: Instant updates for all device state changes
|
||||
- **Bi-directional Control**: Both status monitoring and device control
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
### Supported Browsers
|
||||
- **Chrome 80+** (recommended)
|
||||
- **Firefox 75+**
|
||||
- **Safari 13+**
|
||||
- **Edge 80+**
|
||||
|
||||
### Required Features
|
||||
- WebSocket support
|
||||
- CSS Grid and Flexbox
|
||||
- ES6 JavaScript features
|
||||
- Responsive CSS media queries
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Local Network Only**: Designed for local network device control
|
||||
- **No Authentication**: Assumes trusted local network environment
|
||||
- **CORS Policy**: Restricted to same-origin requests
|
||||
- **WebSocket Security**: Uses same-origin WebSocket connections
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Devices Not Found**
|
||||
- Ensure devices are on the same network
|
||||
- Check firewall settings (ports 8090, 8080)
|
||||
- Click "Discover Devices" button to trigger discovery
|
||||
|
||||
**WebSocket Connection Failed**
|
||||
- Verify device supports WebSocket connections
|
||||
- Check browser console for connection errors
|
||||
- Refresh the page to reconnect WebSocket
|
||||
|
||||
**Control Commands Not Working**
|
||||
- Check device is powered on and connected
|
||||
- Verify device is not in exclusive mode (e.g., Spotify Connect active)
|
||||
- Look for error notifications in the UI
|
||||
|
||||
**Page Shows Template Errors**
|
||||
- This has been fixed in the SPA implementation
|
||||
- Ensure you're accessing the correct URL (localhost:8080)
|
||||
- Clear browser cache if you see old template-based content
|
||||
|
||||
### Debug Mode
|
||||
Add verbose logging by setting environment variable:
|
||||
```bash
|
||||
export DEBUG=true
|
||||
./soundtouch-web
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
This web UI is part of the larger SoundTouch Go library project. See the main project README for contribution guidelines.
|
||||
|
||||
### Architecture Benefits
|
||||
The new SPA approach provides:
|
||||
- **Better Performance**: No server-side template rendering
|
||||
- **Easier Development**: Clear separation of frontend/backend
|
||||
- **Mobile Ready**: Same JSON API can power mobile apps
|
||||
- **Scalable**: Single-page app architecture
|
||||
|
||||
### Feature Requests
|
||||
Based on WebSocket interaction analysis, potential future features:
|
||||
- Zone/multi-room management
|
||||
- Clock display control
|
||||
- Software update management
|
||||
- Advanced preset programming
|
||||
- Progressive Web App (PWA) features
|
||||
|
||||
## License
|
||||
|
||||
Same as the parent project - see main repository LICENSE file.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- Built on the comprehensive SoundTouch Go library
|
||||
- UI design inspired by modern audio control interfaces
|
||||
- WebSocket protocol reverse-engineered from captured device interactions
|
||||
- Bootstrap and Bootstrap Icons for responsive design components
|
||||
@@ -1,225 +0,0 @@
|
||||
// Package main provides a web UI for controlling Bose SoundTouch devices.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
version = "dev"
|
||||
commit = "unknown"
|
||||
date = "unknown"
|
||||
repoURL = "https://github.com/gesellix/bose-soundtouch"
|
||||
)
|
||||
|
||||
func updateBuildInfo() {
|
||||
if info, ok := debug.ReadBuildInfo(); ok {
|
||||
if info.Main.Path != "" {
|
||||
repoURL = "https://" + info.Main.Path
|
||||
}
|
||||
|
||||
if info.Main.Version != "" && info.Main.Version != "(devel)" {
|
||||
version = info.Main.Version
|
||||
}
|
||||
|
||||
for _, setting := range info.Settings {
|
||||
switch setting.Key {
|
||||
case "vcs.revision":
|
||||
commit = setting.Value
|
||||
case "vcs.time":
|
||||
if t, err := time.Parse(time.RFC3339, setting.Value); err == nil {
|
||||
date = t.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
updateBuildInfo()
|
||||
|
||||
app := &cli.App{
|
||||
Name: "soundtouch-web",
|
||||
Usage: "Web UI for controlling Bose SoundTouch devices",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "port",
|
||||
Aliases: []string{"p"},
|
||||
Usage: "HTTP port to listen on",
|
||||
Value: "8080",
|
||||
EnvVars: []string{"PORT"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "bind",
|
||||
Usage: "Address for the HTTP listener: host, IP, or local interface name (e.g. eth0). Leave empty to listen on all interfaces",
|
||||
EnvVars: []string{"BIND_ADDR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "interface",
|
||||
Usage: "Network interface name (e.g. eth0) for mDNS and UPnP device discovery. Defaults to the --bind interface name when one was given; leave empty otherwise to auto-pick",
|
||||
EnvVars: []string{"DISCOVERY_INTERFACE"},
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "devices",
|
||||
Usage: "SoundTouch device IP address(es) to add manually (can be specified multiple times)",
|
||||
EnvVars: []string{"SOUNDTOUCH_DEVICES"},
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
port := c.String("port")
|
||||
rawBind := c.String("bind")
|
||||
|
||||
bindAddr, err := resolveBindAddr(rawBind)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if rawBind != "" && bindAddr != rawBind {
|
||||
log.Printf("Resolved --bind %q to %s", rawBind, bindAddr)
|
||||
}
|
||||
|
||||
rawIface := c.String("interface")
|
||||
manualHosts := c.StringSlice("devices")
|
||||
|
||||
ifaceName := defaultDiscoveryInterface(rawIface, rawBind, bindAddr)
|
||||
if rawIface == "" && ifaceName != "" {
|
||||
log.Printf("Defaulting --interface to %q from --bind", ifaceName)
|
||||
}
|
||||
|
||||
addr := ":" + port
|
||||
if bindAddr != "" {
|
||||
addr = bindAddr + ":" + port
|
||||
}
|
||||
|
||||
// Create web app without templates (SPA mode)
|
||||
webApp := soundtouchweb.NewWebApp()
|
||||
webApp.Version = version
|
||||
webApp.Commit = commit
|
||||
webApp.Date = date
|
||||
webApp.RepoURL = repoURL
|
||||
|
||||
discoveryService := soundtouchweb.NewDiscoveryService(ifaceName)
|
||||
|
||||
// Discover devices on startup
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
webApp.BroadcastDiscoveryStatus("starting", webApp.DeviceCount())
|
||||
|
||||
for _, host := range manualHosts {
|
||||
webApp.AddDeviceByHost(host, 8090, "manual")
|
||||
}
|
||||
|
||||
webApp.DiscoverDevices(ctx, discoveryService)
|
||||
|
||||
webApp.BroadcastDiscoveryStatus("completed", webApp.DeviceCount())
|
||||
webApp.BroadcastDeviceList()
|
||||
}()
|
||||
|
||||
r := chi.NewRouter()
|
||||
webApp.Mount(r, discoveryService)
|
||||
|
||||
log.Printf("AfterTouch Web UI starting on http://%s", addr)
|
||||
|
||||
return http.ListenAndServe(addr, r)
|
||||
},
|
||||
}
|
||||
|
||||
if err := app.Run(os.Args); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// defaultDiscoveryInterface picks the interface name to use for mDNS/UPnP
|
||||
// discovery. An explicit --interface always wins; otherwise, when --bind was
|
||||
// given an interface name (i.e. resolveBindAddr substituted an IP for it),
|
||||
// that name is reused so the common single-interface case "just works".
|
||||
// Returns the empty string when there is nothing to propagate, leaving the
|
||||
// discovery service to auto-pick.
|
||||
func defaultDiscoveryInterface(rawInterface, rawBind, resolvedBind string) string {
|
||||
if rawInterface != "" {
|
||||
return rawInterface
|
||||
}
|
||||
|
||||
if rawBind != "" && rawBind != resolvedBind {
|
||||
return rawBind
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveBindAddr returns the address to bind the HTTP listener to.
|
||||
//
|
||||
// If bindAddr names a local network interface, the interface's single IPv4
|
||||
// address is returned. When no IPv4 is present, the function falls back to the
|
||||
// interface's single non-link-local IPv6 address (wrapped in brackets so it
|
||||
// composes correctly with ":port"). Ambiguous interfaces (multiple addresses
|
||||
// in the chosen family) or interfaces with no usable address produce an error,
|
||||
// so misconfiguration surfaces immediately instead of becoming an obscure DNS
|
||||
// lookup failure at listen time.
|
||||
//
|
||||
// If bindAddr is not an interface name — including the empty string, a host
|
||||
// name, or a literal IP — it is returned unchanged.
|
||||
func resolveBindAddr(bindAddr string) (string, error) {
|
||||
// A lookup failure here just means bindAddr isn't an interface name
|
||||
// (it's a host, IP, or empty); fall through to pass-through.
|
||||
iface, _ := net.InterfaceByName(bindAddr)
|
||||
if iface == nil {
|
||||
return bindAddr, nil
|
||||
}
|
||||
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("--bind %q: failed to list addresses for interface: %w", bindAddr, err)
|
||||
}
|
||||
|
||||
var ipv4, ipv6 []net.IP
|
||||
|
||||
for _, addr := range addrs {
|
||||
var ip net.IP
|
||||
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
ip = v.IP
|
||||
case *net.IPAddr:
|
||||
ip = v.IP
|
||||
}
|
||||
|
||||
if ip == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
ipv4 = append(ipv4, v4)
|
||||
} else if !ip.IsLinkLocalUnicast() {
|
||||
// Skip IPv6 link-local (fe80::); it requires a zone ID and
|
||||
// can't be used as a plain "[ip]:port" listen address.
|
||||
ipv6 = append(ipv6, ip)
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case len(ipv4) == 1:
|
||||
return ipv4[0].String(), nil
|
||||
case len(ipv4) > 1:
|
||||
return "", fmt.Errorf("--bind %q: interface has multiple IPv4 addresses (%v); specify one directly", bindAddr, ipv4)
|
||||
case len(ipv6) == 1:
|
||||
return "[" + ipv6[0].String() + "]", nil
|
||||
case len(ipv6) > 1:
|
||||
return "", fmt.Errorf("--bind %q: interface has multiple IPv6 addresses (%v); specify one directly", bindAddr, ipv6)
|
||||
default:
|
||||
return "", fmt.Errorf("--bind %q: interface has no usable IPv4 or IPv6 address", bindAddr)
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveBindAddr_PassThrough(t *testing.T) {
|
||||
// Inputs that don't match any local interface name must be returned
|
||||
// unchanged: empty string, hostnames, IPv4/IPv6 literals, and bogus
|
||||
// strings the user might have typed.
|
||||
tests := []string{
|
||||
"",
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"192.0.2.5",
|
||||
"::1",
|
||||
"definitely-not-an-iface-xyz",
|
||||
}
|
||||
|
||||
for _, input := range tests {
|
||||
t.Run(quoted(input), func(t *testing.T) {
|
||||
got, err := resolveBindAddr(input)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got != input {
|
||||
t.Errorf("got %q, want %q (input should pass through unchanged)", got, input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBindAddr_LoopbackInterface(t *testing.T) {
|
||||
loopback, expected, ok := findLoopbackWithSingleIPv4(t)
|
||||
if !ok {
|
||||
t.Skipf("no loopback interface with exactly one IPv4 address found")
|
||||
}
|
||||
|
||||
got, err := resolveBindAddr(loopback)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error resolving %q: %v", loopback, err)
|
||||
}
|
||||
|
||||
if got != expected {
|
||||
t.Errorf("got %q, want %q for loopback interface %q", got, expected, loopback)
|
||||
}
|
||||
}
|
||||
|
||||
// findLoopbackWithSingleIPv4 returns the name of a loopback interface and the
|
||||
// single IPv4 address attached to it. If the host has multiple loopback
|
||||
// interfaces or the loopback has zero or several IPv4 addresses, it returns
|
||||
// ok=false so the caller can skip the test rather than fail on an environment
|
||||
// quirk.
|
||||
func findLoopbackWithSingleIPv4(t *testing.T) (name, addr string, ok bool) {
|
||||
t.Helper()
|
||||
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
t.Fatalf("net.Interfaces: %v", err)
|
||||
}
|
||||
|
||||
for _, iface := range ifaces {
|
||||
if iface.Flags&net.FlagLoopback == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
addrs, addrErr := iface.Addrs()
|
||||
if addrErr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var ipv4s []string
|
||||
|
||||
for _, a := range addrs {
|
||||
if ipnet, isIPNet := a.(*net.IPNet); isIPNet {
|
||||
if v4 := ipnet.IP.To4(); v4 != nil {
|
||||
ipv4s = append(ipv4s, v4.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(ipv4s) == 1 {
|
||||
return iface.Name, ipv4s[0], true
|
||||
}
|
||||
}
|
||||
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func TestDefaultDiscoveryInterface(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rawInterface string
|
||||
rawBind string
|
||||
resolvedBind string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "explicit interface wins over bind-derived default",
|
||||
rawInterface: "eth1",
|
||||
rawBind: "eth0",
|
||||
resolvedBind: "192.0.2.5",
|
||||
want: "eth1",
|
||||
},
|
||||
{
|
||||
name: "derive from --bind when --bind was an interface name",
|
||||
rawInterface: "",
|
||||
rawBind: "eth0",
|
||||
resolvedBind: "192.0.2.5",
|
||||
want: "eth0",
|
||||
},
|
||||
{
|
||||
name: "no derivation when --bind was an IP literal",
|
||||
rawInterface: "",
|
||||
rawBind: "192.0.2.5",
|
||||
resolvedBind: "192.0.2.5",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "no derivation when --bind was a hostname (pass-through)",
|
||||
rawInterface: "",
|
||||
rawBind: "localhost",
|
||||
resolvedBind: "localhost",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "both empty stays empty (auto-pick)",
|
||||
rawInterface: "",
|
||||
rawBind: "",
|
||||
resolvedBind: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "explicit interface alone, --bind empty",
|
||||
rawInterface: "eth1",
|
||||
rawBind: "",
|
||||
resolvedBind: "",
|
||||
want: "eth1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := defaultDiscoveryInterface(tc.rawInterface, tc.rawBind, tc.resolvedBind)
|
||||
if got != tc.want {
|
||||
t.Errorf("got %q, want %q (rawInterface=%q rawBind=%q resolvedBind=%q)",
|
||||
got, tc.want, tc.rawInterface, tc.rawBind, tc.resolvedBind)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func quoted(s string) string {
|
||||
if s == "" {
|
||||
return "(empty)"
|
||||
}
|
||||
|
||||
return strings.ReplaceAll(s, "/", "_")
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
func withChiParams(r *http.Request, params map[string]string) *http.Request {
|
||||
rctx := chi.NewRouteContext()
|
||||
for k, v := range params {
|
||||
rctx.URLParams.Add(k, v)
|
||||
}
|
||||
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
|
||||
}
|
||||
|
||||
func TestSPARouting(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
expectedStatus int
|
||||
expectedHTML bool
|
||||
}{
|
||||
{
|
||||
name: "root path serves HTML",
|
||||
path: "/",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedHTML: true,
|
||||
},
|
||||
{
|
||||
name: "device path serves HTML",
|
||||
path: "/device/test-device",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedHTML: true,
|
||||
},
|
||||
{
|
||||
name: "arbitrary path serves HTML",
|
||||
path: "/some/random/path",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedHTML: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(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>AfterTouch Control Center</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">SPA Content</div>
|
||||
</body>
|
||||
</html>`))
|
||||
}
|
||||
|
||||
spaHandler(w, req)
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
|
||||
}
|
||||
|
||||
if tt.expectedHTML {
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "text/html") {
|
||||
t.Errorf("Expected HTML content type, got %s", contentType)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "<!doctype html>") {
|
||||
t.Errorf("Expected HTML content, got: %s", body)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIEndpoints(t *testing.T) {
|
||||
app := soundtouchweb.NewWebApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
method string
|
||||
expectedStatus int
|
||||
expectedJSON bool
|
||||
}{
|
||||
{
|
||||
name: "devices API returns JSON",
|
||||
path: "/api/devices",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedJSON: true,
|
||||
},
|
||||
{
|
||||
name: "discover API accepts POST",
|
||||
path: "/api/discover",
|
||||
method: "POST",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedJSON: true,
|
||||
},
|
||||
{
|
||||
name: "device API with ID",
|
||||
path: "/api/device/test-device",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusNotFound, // Device won't exist in test
|
||||
expectedJSON: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tt.method, tt.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
switch tt.path {
|
||||
case "/api/devices":
|
||||
app.HandleAPIDevices(w, req)
|
||||
case "/api/discover":
|
||||
app.HandleAPIDiscover(w, req)
|
||||
default:
|
||||
if strings.HasPrefix(tt.path, "/api/device/") {
|
||||
deviceID := strings.TrimPrefix(tt.path, "/api/device/")
|
||||
req = withChiParams(req, map[string]string{"id": deviceID})
|
||||
app.HandleAPIDevice(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
|
||||
}
|
||||
|
||||
if tt.expectedJSON {
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "application/json") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIResponseFormat(t *testing.T) {
|
||||
app := soundtouchweb.NewWebApp()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/devices", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleAPIDevices(w, req)
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
if response.Data == nil {
|
||||
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)
|
||||
}
|
||||
|
||||
if len(dataMap) != 0 {
|
||||
t.Errorf("Expected empty device map, got %d devices", len(dataMap))
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlAPIValidation(t *testing.T) {
|
||||
app := soundtouchweb.NewWebApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
method string
|
||||
body string
|
||||
expectedStatus int
|
||||
chiParams map[string]string
|
||||
}{
|
||||
{
|
||||
name: "missing device ID",
|
||||
path: "/api/control//play",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "invalid control path",
|
||||
path: "/api/control/device",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "unknown action",
|
||||
path: "/api/control/nonexistent/invalid",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusNotFound,
|
||||
chiParams: map[string]string{"id": "nonexistent", "action": "invalid"},
|
||||
},
|
||||
{
|
||||
name: "nonexistent device",
|
||||
path: "/api/control/nonexistent/play",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusNotFound,
|
||||
chiParams: map[string]string{"id": "nonexistent", "action": "play"},
|
||||
},
|
||||
{
|
||||
name: "unknown action with valid device",
|
||||
path: "/api/control/testdevice/unknownaction",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
chiParams: map[string]string{"id": "testdevice", "action": "unknownaction"},
|
||||
},
|
||||
}
|
||||
|
||||
// Add a mock device for testing unknown action validation
|
||||
mockDevice := webtypes.NewDeviceConnection(nil, &models.DeviceInfo{Name: "Test Device"})
|
||||
mockDevice.SetStatus(&webtypes.DeviceStatus{IsConnected: true})
|
||||
app.AddDevice("testdevice", mockDevice)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var req *http.Request
|
||||
if tt.body != "" {
|
||||
req = httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req = httptest.NewRequest(tt.method, tt.path, nil)
|
||||
}
|
||||
if tt.chiParams != nil {
|
||||
req = withChiParams(req, tt.chiParams)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
app.HandleAPIControl(w, req)
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
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)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Errorf("Invalid JSON response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success {
|
||||
t.Errorf("Expected success=false for error case, got success=true")
|
||||
}
|
||||
|
||||
if response.Error == "" {
|
||||
t.Errorf("Expected error message, got empty string")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSocketUpgrade(t *testing.T) {
|
||||
app := soundtouchweb.NewWebApp()
|
||||
|
||||
// Test WebSocket upgrade request
|
||||
req := httptest.NewRequest("GET", "/ws", nil)
|
||||
req.Header.Set("Connection", "upgrade")
|
||||
req.Header.Set("Upgrade", "websocket")
|
||||
req.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
|
||||
req.Header.Set("Sec-WebSocket-Version", "13")
|
||||
|
||||
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 := soundtouchweb.NewWebApp()
|
||||
|
||||
endpoints := []string{
|
||||
"/api/devices",
|
||||
"/api/device/test",
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
t.Run("JSON consistency for "+endpoint, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", endpoint, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
switch endpoint {
|
||||
case "/api/devices":
|
||||
app.HandleAPIDevices(w, req)
|
||||
default:
|
||||
if strings.HasPrefix(endpoint, "/api/device/") {
|
||||
deviceID := strings.TrimPrefix(endpoint, "/api/device/")
|
||||
req = withChiParams(req, map[string]string{"id": deviceID})
|
||||
app.HandleAPIDevice(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
if !response.Success && response.Error == "" {
|
||||
t.Errorf("Endpoint %s: error response should have error message", endpoint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -355,11 +355,9 @@ func handlePreset(event *models.PresetUpdatedEvent, verbose bool) {
|
||||
for _, preset := range presets.Preset {
|
||||
fmt.Printf(" 📻 Preset %d:", preset.ID)
|
||||
|
||||
// IsEmpty catches both <preset/> and INVALID_SOURCE
|
||||
// placeholders; using the nil-safe helpers below means the
|
||||
// inner Printf never dereferences a nil ContentItem.
|
||||
if !preset.IsEmpty() {
|
||||
fmt.Printf(" %s (%s)", preset.GetDisplayName(), preset.GetSource())
|
||||
if preset.ContentItem != nil {
|
||||
fmt.Printf(" %s", preset.ContentItem.ItemName)
|
||||
fmt.Printf(" (%s)", preset.ContentItem.Source)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
@@ -546,13 +544,13 @@ func printHelp() {
|
||||
fmt.Printf(" %s -discover\n", os.Args[0])
|
||||
fmt.Println()
|
||||
fmt.Println(" # Connect to specific device and monitor volume events only")
|
||||
fmt.Printf(" %s -host 192.0.2.10 -filter volume\n", os.Args[0])
|
||||
fmt.Printf(" %s -host 192.168.1.10 -filter volume\n", os.Args[0])
|
||||
fmt.Println()
|
||||
fmt.Println(" # Monitor for 5 minutes with verbose output")
|
||||
fmt.Printf(" %s -host 192.0.2.10 -duration 5m -verbose\n", os.Args[0])
|
||||
fmt.Printf(" %s -host 192.168.1.10 -duration 5m -verbose\n", os.Args[0])
|
||||
fmt.Println()
|
||||
fmt.Println(" # Monitor now playing and volume events")
|
||||
fmt.Printf(" %s -host 192.0.2.10 -filter nowPlaying,volume\n", os.Args[0])
|
||||
fmt.Printf(" %s -host 192.168.1.10 -filter nowPlaying,volume\n", os.Args[0])
|
||||
fmt.Println()
|
||||
fmt.Println("Event Types:")
|
||||
fmt.Println(" 🎵 nowPlaying - Track changes, playback status")
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
accounts/
|
||||
backend/
|
||||
certs/
|
||||
default/
|
||||
dns/
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
// func main() {
|
||||
// // Create a client for your SoundTouch device
|
||||
// config := &client.Config{
|
||||
// Host: "192.0.2.100",
|
||||
// Host: "192.168.1.100",
|
||||
// Port: 8090,
|
||||
// }
|
||||
// client := client.NewClient(config)
|
||||
@@ -70,7 +70,7 @@
|
||||
// soundtouch-cli discover devices
|
||||
//
|
||||
// # Control a device
|
||||
// soundtouch-cli --host 192.0.2.100 play start
|
||||
// soundtouch-cli --host 192.168.1.100 play start
|
||||
//
|
||||
// # Supported Features
|
||||
//
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
services:
|
||||
soundtouch-service:
|
||||
build:
|
||||
context: .
|
||||
target: soundtouch-service
|
||||
networks:
|
||||
- soundtouch-test-net
|
||||
volumes:
|
||||
- ./tests/integration/testdata:/app/data
|
||||
environment:
|
||||
- SPOTIFY_CLIENT_ID=mock-id
|
||||
- SPOTIFY_CLIENT_SECRET=mock-secret
|
||||
- SPOTIFY_TOKEN_URL=http://spotify-mock:8080/api/token
|
||||
- SPOTIFY_API_BASE=http://spotify-mock:8080
|
||||
- AMAZON_CLIENT_ID=mock-amazon-id
|
||||
- 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
-1
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
soundtouch-service:
|
||||
image: ghcr.io/gesellix/bose-soundtouch:${SOUNDTOUCH_VERSION:-latest}
|
||||
image: ghcr.io/gesellix/bose-soundtouch:latest
|
||||
# build: .
|
||||
container_name: soundtouch-service
|
||||
# Linux only, required for discovery. Swarm requires host network at the task level.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -143,7 +143,7 @@ Browse stored/local music library.
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
library, err := client.GetStoredMusicLibrary("AABBCCDDEEFF/0")
|
||||
library, err := client.GetStoredMusicLibrary("A81B6A536A98/0")
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
@@ -526,7 +526,7 @@ Search for stations and content.
|
||||
|
||||
**Response Body:**
|
||||
```xml
|
||||
<results deviceID="AABBCCDDEEFF" source="PANDORA" sourceAccount="user123">
|
||||
<results deviceID="A81B6A536A98" source="PANDORA" sourceAccount="user123">
|
||||
<songs>
|
||||
<searchResult source="PANDORA" sourceAccount="user123" token="S123">
|
||||
<name>Love Story</name>
|
||||
@@ -707,7 +707,7 @@ Navigation and station operations generate WebSocket events:
|
||||
Generated when stations are added/removed that affect presets.
|
||||
|
||||
```xml
|
||||
<presetsUpdated deviceID="AABBCCDDEEFF">
|
||||
<presetsUpdated deviceID="A81B6A536A98">
|
||||
<presets>
|
||||
<!-- Updated preset list -->
|
||||
</presets>
|
||||
@@ -719,7 +719,7 @@ Generated when stations are added/removed that affect presets.
|
||||
Generated when station operations affect current playback.
|
||||
|
||||
```xml
|
||||
<nowPlayingUpdated deviceID="AABBCCDDEEFF">
|
||||
<nowPlayingUpdated deviceID="A81B6A536A98">
|
||||
<nowPlaying source="PANDORA">
|
||||
<ContentItem source="PANDORA" location="R456" sourceAccount="user123" isPresetable="true">
|
||||
<itemName>Taylor Swift Radio</itemName>
|
||||
|
||||
+2
-2
@@ -73,8 +73,8 @@ For web components:
|
||||
When creating test data for API endpoints, prefer real device responses over hypothetical examples:
|
||||
|
||||
- **Available test endpoints**:
|
||||
- `http://192.0.2.11:8090/now_playing` - Different response type 1
|
||||
- `http://192.0.2.10:8090/now_playing` - Different response type 2
|
||||
- `http://192.168.178.28:8090/now_playing` - Different response type 1
|
||||
- `http://192.168.178.35:8090/now_playing` - Different response type 2
|
||||
- **Usage**: Fetch real responses to create accurate test fixtures
|
||||
- **Privacy**: Anonymize any personal data (account names, personal playlists, etc.)
|
||||
- **Coverage**: Use multiple real devices to cover different response variations
|
||||
|
||||
@@ -197,12 +197,12 @@ err := client.SelectContentItem(contentItem)
|
||||
### CLI Usage
|
||||
```bash
|
||||
# streamUrl format
|
||||
soundtouch-cli --host 192.0.2.100 source internet-radio \
|
||||
soundtouch-cli --host 192.168.1.100 source internet-radio \
|
||||
--location "http://contentapi.gmuth.de/station.php?name=MyStation&streamUrl=https://stream.example.com/radio" \
|
||||
--name "My Station"
|
||||
|
||||
# Direct stream
|
||||
soundtouch-cli --host 192.0.2.100 source internet-radio \
|
||||
soundtouch-cli --host 192.168.1.100 source internet-radio \
|
||||
--location "https://stream.example.com/radio" \
|
||||
--name "Direct Stream"
|
||||
```
|
||||
|
||||
@@ -46,7 +46,7 @@ usb0 Link encap:Ethernet HWaddr CA:FE:BA:BE:1E:47
|
||||
|
||||
Sun Feb 1 20:35:24 CET 2026
|
||||
|
||||
Device name: "Kitchen SoundTouch"
|
||||
Device name: "A Sound Machine"
|
||||
Country EU, Region (not set)
|
||||
Module type: scm
|
||||
root@spotty:~#
|
||||
@@ -83,7 +83,7 @@ usb0 Link encap:Ethernet HWaddr CA:FE:BA:BE:1E:47
|
||||
|
||||
Sun Feb 1 19:12:47 CET 2026
|
||||
|
||||
Device name: "Kitchen SoundTouch"
|
||||
Device name: "A Sound Machine"
|
||||
Country EU, Region (not set)
|
||||
Module type: scm
|
||||
root@spotty:~#
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
# Device-Local Install: Four User Journeys
|
||||
|
||||
A user-journey-shaped view of where AfterTouch sits today and where it could go. The same speaker, the same constraints, but four different audiences with non-overlapping needs:
|
||||
|
||||
1. **Initial setup / install** — getting AfterTouch onto a fresh or freshly-orphaned speaker.
|
||||
2. **Less-technical admin** — migration, maintenance, and recovery without a terminal.
|
||||
3. **Daily usage** — playing music, switching presets, on the couch or on the phone.
|
||||
4. **Automation** — driving the speaker from scripts, home automation, schedules.
|
||||
|
||||
Each journey is served by a different surface (CLI, web UI, GUI app, REST). Some surfaces serve more than one journey; some journeys are served badly today. This doc is informational; nothing here is a roadmap commitment.
|
||||
|
||||
Cross-cutting reference material — lessons from `GameTec-live/soundtouch-tiny`, plus a per-surface capability map — lives in the appendix.
|
||||
|
||||
---
|
||||
|
||||
## Journey 1: Initial setup / install
|
||||
|
||||
**Who.** Someone with a Bose speaker whose cloud just died. Could be technical (knows what SSH is) or not (knows what a USB stick is). Wants the speaker to play Internet Radio again with minimum fuss.
|
||||
|
||||
**Goal.** Get an AfterTouch instance reachable from the speaker, whether that instance lives on a separate host or on the speaker itself.
|
||||
|
||||
**Surfaces.** Shell (today), GUI installer (planned), pre-flashed stick (commercial offering, hypothetical).
|
||||
|
||||
### The three install patterns
|
||||
|
||||
#### Pattern A — External host
|
||||
|
||||
A separate machine (Raspberry Pi, NAS, always-on laptop) runs `soundtouch-service`. Speakers point at it via DNS rewrite at the router. No code on the speaker, no firmware risk.
|
||||
|
||||
- **Pros:** zero invasiveness, easy update (single host), unified for many speakers, no per-speaker storage limit.
|
||||
- **Cons:** requires an always-on host on the LAN, DNS rewrite at router scope, single point of failure.
|
||||
|
||||
#### Pattern B — SSH-curl on-device (current `scripts/on-device-install/`)
|
||||
|
||||
User SSHes in once, pipes the installer. Installs to `/mnt/nv/aftertouch`, symlinks `/opt/aftertouch`, registers `/etc/init.d/aftertouch` via `update-rc.d`. Daemon serves `:8000` on the speaker's own LAN address.
|
||||
|
||||
- **Pros:** no separate host, per-speaker isolation, survives router replacement.
|
||||
- **Cons:** SSH required for install and updates, ~12 MB binary stresses tiny rootfs partitions, no in-process restart on crash, some firmware images bind only loopback (issue #196).
|
||||
|
||||
#### Pattern C — Stick-driven on-device (*not* implemented here)
|
||||
|
||||
USB stick holds binary + bootstrap scripts. First install needs SSH (placing `/mnt/nv/rc.local`). After that, the NAND `rc.local` auto-syncs from any stick inserted with newer files. Stick can also carry one-shot configs (`wlan.conf`, `region.conf`, `name.conf`) consumed and wiped during boot.
|
||||
|
||||
- **Pros:** post-bootstrap updates need no SSH, stick wipe behavior keeps credentials short-lived, watchdog inside the bootstrap script restarts the agent on crash without a reboot.
|
||||
- **Cons:** first install still needs SSH; FAT32 stick on the speaker is unreliable for writes; user has to keep a stick around.
|
||||
|
||||
### The technical underpinning: `/mnt/nv/rc.local`
|
||||
|
||||
Both pattern C and any "shepherd-less" install on stock firmware depend on a single line in the stock init scripts:
|
||||
|
||||
```
|
||||
# /etc/init.d/shelby_local, start case
|
||||
[ -x /mnt/nv/rc.local ] && /mnt/nv/rc.local
|
||||
```
|
||||
|
||||
`shelby_local` is a stock Bose SysV script. Its `start` case fires at every boot from an `S`-symlink in `rcS.d/` (the misleading `K99shelby_local` symlink in `rc1.d/` is the *shutdown* path — same script, different case). `/mnt/nv` is the persistent read-write NAND partition; `rc.local` is intentionally exposed as an extension point. By the time it runs, rootfs is mounted read-only, `/mnt/nv` is read-write, network is configured, and `/media/sda1` is *typically* mounted by udev if a USB stick is present — but the mount is asynchronous and races the hook (polling for up to 30 s is one way to handle this).
|
||||
|
||||
**Stock firmware does not auto-copy anything from a USB stick into `/mnt/nv/rc.local`.** Inserting a stick alone is not enough. There is no udev rule, no autorun convention, no `shelby_usb` branch that handles this; `shelby_usb` only manages USB ethernet-gadget mode (`g_ether`) and the `microbswitch` helper on certain variants.
|
||||
|
||||
Placement happens one of two ways:
|
||||
|
||||
1. **Manual SSH bootstrap, once.** Shell access (via the `remote_services` stick trick) runs an installer that writes `/mnt/nv/rc.local`, makes it executable, and exits. After that single SSH session, the stick is no longer required to *trigger* anything — the NAND copy fires on every boot.
|
||||
2. **Self-update from a newer stick, after step 1.** Once `/mnt/nv/rc.local` exists *and contains the self-update logic*, inserting a stick with a newer `rc.local` (compared by mtime) lets the running NAND copy overwrite itself for the next boot. This gives the stick its "repair channel" property.
|
||||
|
||||
**The very first placement requires SSH.** Any zero-SSH install would need either a different stock-firmware hook (we have not found one usable across SoundTouch variants) or a custom firmware image. The `remote_services` stick is the only stick-content convention the stock firmware honors out of the box, and all it does is enable `sshd`.
|
||||
|
||||
### App-driven install (the missing middle)
|
||||
|
||||
The SSH session does **not** have to be a human SSH session. `pkg/ssh` (`NewClient`, `Run`, `ReadFile`, `ReadDir`, `UploadContent`) is already used by `pkg/service/setup/` to drive migration probes; the same primitives can drive an installer. The user never sees a terminal.
|
||||
|
||||
User-visible flow:
|
||||
|
||||
1. User runs an admin app on their laptop or phone.
|
||||
2. App walks them through preparing a `remote_services` stick — or writes one for them, if it can reach the host's USB subsystem.
|
||||
3. User inserts the stick into the speaker and power-cycles it. Stock firmware's `sshd` starts.
|
||||
4. App discovers the speaker via mDNS, dials SSH, runs the installer steps that today live behind `curl ... \| sh`. No `ssh` invocation, no `rw &&`, no copy-pasted IP.
|
||||
5. App verifies `curl http://<box>:8000` from inside the speaker via SSH and surfaces a clear success / failure state.
|
||||
6. App optionally removes `remote_services` from the stick and reboots the speaker, closing the SSH backdoor automatically.
|
||||
|
||||
Mapping each step to existing code:
|
||||
|
||||
| Step | Today's installer | App equivalent (`pkg/ssh`) |
|
||||
|---------------------|-------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------|
|
||||
| Remount rootfs rw | `mount -o remount,rw /` (inside init script) | `Client.Run("mount -o remount,rw /")` |
|
||||
| Make NAND dir | `mkdir -p $INSTALL_DIR` | `Client.Run("mkdir -p /mnt/nv/aftertouch")` |
|
||||
| Download binary | `curl -sSL ... -o binary` | local download on the app side, then `Client.UploadContent(bytes, "/mnt/nv/aftertouch/aftertouch-service")` |
|
||||
| Mark executable | `chmod +x` | `Client.Run("chmod +x ...")` |
|
||||
| Symlink `/opt` | `ln -sf $INSTALL_DIR /opt/aftertouch` | `Client.Run("ln -sf ...")` |
|
||||
| Install init script | `curl ... -o /etc/init.d/aftertouch && update-rc.d aftertouch defaults` | `Client.UploadContent` + `Client.Run` |
|
||||
| Start | `/etc/init.d/aftertouch start` | `Client.Run("/etc/init.d/aftertouch start")` |
|
||||
| Verify listener | `curl -fsS http://localhost:8000` inside the box | `Client.Run("curl -fsS http://localhost:8000")` |
|
||||
|
||||
No new SSH plumbing required. The pieces already exist for the setup probes.
|
||||
|
||||
### Storage budget
|
||||
|
||||
The on-device patterns share one hard constraint: storage. ST20 stock rootfs has ~4 MB free (issue #268); even with `/mnt/nv` (~30 MB free) the budget is tight, and a second binary for safe OTA updates doubles it. This is the primary motivation for a slimmer `soundtouch-service-mini` build target — see the appendix.
|
||||
|
||||
### Open decisions for this journey
|
||||
|
||||
- Do we keep pattern B as the technical-user path while building a Gio admin app for the rest?
|
||||
- Do we add a pattern-C-style "register a stick-update hook in `/mnt/nv/rc.local`" option as an opt-in, so users who do want a repair stick get one?
|
||||
- Pre-flashed sticks shipped as a kit: in scope or out?
|
||||
|
||||
---
|
||||
|
||||
## Journey 2: Less-technical admin (migration + maintenance)
|
||||
|
||||
**Who.** The person who already has AfterTouch installed somewhere and now needs to do something *after* install. They are comfortable opening apps and clicking buttons; they are not comfortable opening a terminal. The whole-household admin: parent, partner, roommate doing it for the household.
|
||||
|
||||
**Goal.** Migrate a speaker to a new AfterTouch instance, update the agent, view what's going on, recover a stuck device, change WLAN credentials, reapply config after factory reset — all without SSH.
|
||||
|
||||
**Surfaces.** GUI admin app (Gio, planned), `soundtouch-service` embedded web UI (today, technical-leaning), CLI (today, technical-only).
|
||||
|
||||
### What "admin" covers in practice
|
||||
|
||||
- **Migration of a new (or factory-reset) speaker** to an AfterTouch instance: rewrite the server URLs in `/mnt/nv/persistence.json`, restart the device, verify it talks to us.
|
||||
- **Agent update on an on-device install** (pattern B or C): push a new binary, restart, verify.
|
||||
- **Status and diagnostics**: is `aftertouch` running, is `:8000` listening, did the last preset save succeed, what does syslog say?
|
||||
- **Recovery**: speaker is stuck (won't respond to web UI, won't pair, lost WLAN). Today this almost always means SSH; with `pkg/ssh` behind a GUI, it can mean "click 'Diagnose' in the app."
|
||||
- **Bulk operations**: do all of the above across several speakers at once.
|
||||
- **Configuration drift**: WLAN password changed, region changed, speaker name changed, hosts file got rewritten — restore the AfterTouch overlay.
|
||||
|
||||
### How the GUI admin app shape would serve this
|
||||
|
||||
Same `pkg/ssh` primitives as Journey 1's installer, applied to post-install tasks. mDNS discovers all speakers on the LAN; the app fans operations out across them; SSH-driven actions stay hidden behind buttons. On a phone, the same app is the "speakers are unreachable, what now" diagnostic tool from another room.
|
||||
|
||||
Where today's surfaces fall short for this user:
|
||||
|
||||
- `soundtouch-service` web UI assumes the service is running and reachable. It cannot recover a broken installation or a stuck device.
|
||||
- CLI works but presumes terminal comfort.
|
||||
- The setup wizard in `soundtouch-service` handles initial migration well, but reapplying after factory reset is not first-class — see `docs/analysis/FACTORY-RESET-PROTOCOL.md`.
|
||||
|
||||
### Open decisions for this journey
|
||||
|
||||
- Does the admin app subsume the service web UI's admin tab, or do they coexist (admin app = onboarding + recovery; service web UI = ongoing operations once everything is healthy)?
|
||||
- WASM as a fallback surface: today's service web UI is browser-accessible from anywhere. Does a Gio admin app sacrifice that, or do we ship both?
|
||||
- Multi-household / multi-speaker: how much does the admin app need to know about distinguishing speakers vs distinguishing AfterTouch instances?
|
||||
|
||||
---
|
||||
|
||||
## Journey 3: Daily usage
|
||||
|
||||
**Who.** Anyone in the household using the speaker. Children pressing a preset button. The user opening a phone to switch from kitchen to living room. Guests asked to "just put on some jazz." Zero awareness of AfterTouch as a thing; the speaker is the speaker.
|
||||
|
||||
**Goal.** Music plays. Pressing preset 3 gives them what preset 3 should give them. Skipping a station, adjusting volume, browsing for a new station — all fast, no friction.
|
||||
|
||||
**Surfaces.** Physical preset buttons (always there), `soundtouch-web` (today), mobile app (Journey 2 admin app's daily-use mode), WASM-served browser UI (planned), Bose app while it still functions, voice assistants where wired up.
|
||||
|
||||
### What this layer needs to be good at
|
||||
|
||||
- **Preset playback works first try, every time.** The reliability bar is "is the kitchen radio still working?" Anything that fails on cold boot or after a Wi-Fi outage breaks the user's trust in the whole system.
|
||||
- **Switching stations quickly**, including discovery of new ones (e.g. `radio-browser.info`-style search).
|
||||
- **Volume and play / pause from any device the user has in hand.** Phone in pocket, laptop on table, browser tab open — all should work.
|
||||
- **Multi-room awareness** if the household has more than one speaker: which speaker is playing what, can I send this to the bedroom.
|
||||
- **Looking good.** This is the surface that gets seen daily by non-technical users. Visual polish matters more here than anywhere else in the stack.
|
||||
|
||||
### How surfaces map
|
||||
|
||||
- `soundtouch-web`: primary daily UI for desktop browsers and (responsively) for tablets. This is already shipped.
|
||||
- Mobile app: daily-use mode of the same Gio app that handles admin. Capability split — admin features only show up when the user is in admin mode.
|
||||
- WASM: same Gio app, served from `soundtouch-service` to anyone on the LAN. The "I forgot which device my login is on, just open a browser" fallback.
|
||||
- Physical preset buttons: handled at the agent level (the Bose firmware fires them; AfterTouch or the on-device agent reacts).
|
||||
|
||||
### Open decisions for this journey
|
||||
|
||||
- Do we keep `soundtouch-web` as a separate codebase (HTML/JS), or does it become a Gio WASM build sharing code with the admin app?
|
||||
- Mobile app store distribution: TestFlight for iOS (gated, slow), Play Store for Android (faster, AAB only), F-Droid as an open-source-friendly side path.
|
||||
- Multi-user state: presets per-user vs per-household. Out of scope here, but the daily surface is where it gets felt.
|
||||
|
||||
---
|
||||
|
||||
## Journey 4: Automation
|
||||
|
||||
**Who.** The same household, but acting through code: a Home Assistant config, a NodeRED flow, a cron job, a shell script, a webhook from a smart doorbell. The user is not present at the speaker; they want music to start when something else happens.
|
||||
|
||||
**Goal.** Headless, scriptable control. "Play preset 2 at 7:00 every weekday." "When the kids' bedtime alarm fires, fade volume to zero." "If I get home and the speaker is on, switch to my dinner playlist."
|
||||
|
||||
**Surfaces.** `soundtouch-cli` (today), REST endpoints on `soundtouch-service` (today), MQTT bridge / webhook outputs (hypothetical), Home Assistant integration (community).
|
||||
|
||||
### What this layer needs to be good at
|
||||
|
||||
- **Stable, versioned API surface.** Scripts and home automation flows live for years; breaking changes are expensive for users.
|
||||
- **CLI that works in pipelines.** Exit codes, machine-readable output (JSON), stable flag names. The reverse of the daily UI: zero polish, full predictability.
|
||||
- **Discoverability of capabilities.** Users need to find out what's possible (`soundtouch-cli help`, openapi spec on the service, examples in the docs).
|
||||
- **Idempotency.** Calling "set volume to 40" twice should not result in volume 80. Calling "switch to preset 3" when already on preset 3 should be a no-op.
|
||||
|
||||
### How surfaces map
|
||||
|
||||
- `soundtouch-cli`: the canonical surface for scripted control. Already covers most of the API.
|
||||
- `soundtouch-service` REST endpoints: same surface, network-accessible. Used by `soundtouch-web` and by third-party automation.
|
||||
- Home Assistant: external integration; track but do not own.
|
||||
- Webhooks / MQTT: not present today; would let speakers participate in event-driven flows. Out of scope for a first pass; worth a separate design doc when demand surfaces.
|
||||
|
||||
### Open decisions for this journey
|
||||
|
||||
- Stability commitments for the CLI and REST API: do we adopt semver for the public surface separately from the service version?
|
||||
- Authentication for the REST surface when exposed beyond loopback: needed before any internet exposure is sane.
|
||||
- OpenAPI / typed-client output for the service: nice-to-have for integration developers.
|
||||
|
||||
---
|
||||
|
||||
## Appendix: which surface serves which journey
|
||||
|
||||
| Surface | Journey 1 (install) | Journey 2 (admin) | Journey 3 (daily) | Journey 4 (automation) |
|
||||
|------------------------------------|---------------------|-------------------|-------------------|------------------------|
|
||||
| `soundtouch-cli` | partial (today) | partial (today) | no | primary |
|
||||
| `soundtouch-service` web UI | wizard portion | primary | partial | indirect (REST) |
|
||||
| `soundtouch-web` | no | no | primary | no |
|
||||
| GUI admin app (Gio, planned) | primary | primary | mobile mode | no |
|
||||
| Pre-flashed stick (hypothetical) | primary | recovery | no | no |
|
||||
| Physical preset buttons | no | no | primary | no |
|
||||
| Home Assistant / webhooks (future) | no | no | no | primary |
|
||||
|
||||
The diagonal isn't full because some journeys lack a polished surface today (Journey 1 mostly works but is shell-only; Journey 2 has gaps for recovery scenarios). The journey frame is what tells us *which* gaps to fill first.
|
||||
|
||||
## Appendix: per-surface capability constraints
|
||||
|
||||
The Gio admin app, if built, can target Windows / macOS / Linux / iOS / Android / WASM from one codebase. Each target has hard constraints:
|
||||
|
||||
- **WASM (browser).** Post-install REST control, device list and status, preset editing, station search. No mDNS (browsers cannot do raw multicast — fall back to manual IP entry or a backend bridge); no raw TCP, so no SSH and no install; no block-device access, so no stick writing. This is the "I just want to use my speakers" surface, equivalent to today's `soundtouch-web`.
|
||||
- **Mobile iOS.** Everything WASM does, plus Bonjour-based mDNS, plus full SSH client (so app-driven install and recovery work). No FAT32 stick writing — iOS has no filesystem-level block device access for third-party apps. Best paired with a pre-flashed stick or a friend's desktop install for the bootstrap.
|
||||
- **Mobile Android.** Same as iOS, plus FAT32 stick writing *if* the user grants USB-OTG host permission. UX caveat: most users will not know what USB host mode is.
|
||||
- **Desktop (Gio).** Full capability set. mDNS, SSH-driven install, FAT32 stick writing via standard block-device APIs, post-install control, recovery. The primary onboarding surface.
|
||||
|
||||
The pattern to follow is to write code so each capability degrades automatically based on what the runtime actually offers, rather than gating with build tags.
|
||||
|
||||
## Appendix: lessons from adjacent projects
|
||||
|
||||
### soundtouch-tiny (GameTec-live)
|
||||
|
||||
Minimal on-device cloud replacement: Internet Radio + TuneIn proxy + optional presets. Go stdlib only, small binary. Inspired by AfterTouch but trimmed. The author offered collaboration in PR #292.
|
||||
|
||||
This is the gap a **`soundtouch-service-mini` build target** would fill. The full `soundtouch-service` is justified for the external-host pattern (Pattern A) where space is not pressed; on-device (patterns B and C) the calculus is different — many users only need Internet Radio because that's the surface most affected by the cloud shutdown.
|
||||
|
||||
A mini build target in this repo would look like:
|
||||
|
||||
- same codebase, different `cmd/` entry point,
|
||||
- compiled with only the packages needed for Internet Radio + TuneIn shim + presets,
|
||||
- no Spotify, no parity tests, no setup wizard, no Bose-protocol-level proxy,
|
||||
- target size: under 4 MB so it fits the rootfs without `/mnt/nv` gymnastics, leaving room for a second binary for safe updates.
|
||||
|
||||
Open questions before committing:
|
||||
|
||||
1. Collaborate upstream with soundtouch-tiny, or build our own mini that shares code with the full service?
|
||||
2. Where to draw the feature line — "Internet Radio only" is clear; "Spotify too" would already blow the budget on ST20.
|
||||
3. Mini ships via Pattern B (SSH-curl) or Pattern C (stick)?
|
||||
4. Full service and mini service coexisting on the same LAN — mDNS service name, port choice, web UI port.
|
||||
|
||||
### Wails vs Gio
|
||||
|
||||
Both are Go. Different tradeoffs:
|
||||
|
||||
- **Wails v2**: bundles a WebView per OS, frontend is HTML/CSS/JS. Faster to a working UI if the team is comfortable with HTML. Targets Windows / macOS / Linux. No mobile, no WASM.
|
||||
- **Gio**: immediate-mode pure-Go UI. Smaller binaries, no WebView dependency. Targets Windows / macOS / Linux / iOS / Android / WASM. Steeper UI learning curve, mitigated by `gio-mw`.
|
||||
|
||||
The deciding factor is **mobile + WASM** (Journey 2 and Journey 3), not desktop alone. If "use a phone to set up a speaker" or "open the admin tool from any browser" is on the roadmap, Wails does not get us there.
|
||||
|
||||
## Appendix: documentation gap to close
|
||||
|
||||
Separate user-facing material to produce when we are ready (not in this comparison doc):
|
||||
|
||||
- **The `/mnt/nv/rc.local` hook** explained in user terms: what it does, when it fires, when *not* to use it, how to remove it cleanly. Bridges Journey 1 and Journey 2.
|
||||
- **Hooks we already maintain** at OS level: resolv.conf stability, `/etc/hosts` overlay, anything in `pkg/service/setup/` that touches device state. Reference, not narrative. Journey 2 troubleshooting.
|
||||
- **Storage budget per model**: rootfs free, `/mnt/nv` free, where the binary lands, which path applies to which ST model. Journey 1 sizing.
|
||||
- **Decision matrix**: external host vs on-device vs mini, plus "do I need Spotify? do I need migration? do I want one host or per-speaker isolation?" Journey 1 entry point.
|
||||
- **Stick file conventions**: what the `remote_services` stick does today, what we *might* add (presets / wlan / region) if we build a stick-driven path, and how that interacts with FAT credentials residency. Journey 1.
|
||||
- **Automation cookbook**: example Home Assistant config, example shell scripts, common pitfalls. Journey 4.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- AfterTouch installer: `scripts/on-device-install/install.sh`, `scripts/on-device-install/aftertouch` (init script), `scripts/on-device-install/README.md`.
|
||||
- AfterTouch SSH client: `pkg/ssh/ssh.go` (`NewClient`, `Run`, `ReadFile`, `ReadDir`, `UploadContent`), already used by `pkg/service/setup/`.
|
||||
- Storage limitations: issue #268 (ST20 rootfs free space), issue #196 (loopback-only bind), issue #250 (status reports running but unreachable).
|
||||
- soundtouch-tiny: `https://github.com/GameTec-live/soundtouch-tiny`, raised in PR #292 (`https://github.com/gesellix/Bose-SoundTouch/pull/292`).
|
||||
- opencloudtouch parallel discussion: `https://github.com/scheilch/opencloudtouch/discussions/201`.
|
||||
- Existing parity doc shape: `docs/PARITY-OPENCLOUDTOUCH.md` is the precedent for cross-project comparison documents.
|
||||
@@ -48,12 +48,6 @@ logread -f | grep -Ei '(marge|preset)'
|
||||
```
|
||||
This is particularly useful for debugging preset synchronization and service redirection issues.
|
||||
|
||||
For HTTPS / connection-refused debugging (e.g. `Curl 7, http 0`), drop the speaker's loopback chatter so only outbound calls remain visible:
|
||||
```bash
|
||||
logread -f | grep -v '127.0.0.1'
|
||||
```
|
||||
The speaker generates a steady stream of localhost-to-localhost HTTP traffic between its internal services; filtering it out makes the actual cloud / AfterTouch attempts (the ones that matter when diagnosing redirect or TLS issues) easy to read in real time.
|
||||
|
||||
---
|
||||
|
||||
## 2. Traffic Logging & Interception
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
# Bose SoundTouch Device Setup Flow
|
||||
|
||||
This document details the multi-step process required to fully set up a Bose SoundTouch device, as derived from the Stockholm firmware (`setup/js/`) analysis.
|
||||
|
||||
A complete setup flow involves a sequence of local (WebSocket) and cloud (HTTP) actions that move the device from a factory-reset state to a fully registered, functional system.
|
||||
|
||||
## 1. Local Coordination Stage (WebSocket)
|
||||
|
||||
Before a device can be controlled, it must be configured on the local network and named. These actions occur via a WebSocket connection to the device on port 8080.
|
||||
|
||||
### 1.1 Language Configuration (Optional)
|
||||
If the device is in a factory-reset state, the UI typically ensures the device language matches the user's choice.
|
||||
- **WebSocket Action**: `set_language`
|
||||
- **Internal Logic**: `SetupWizard.js` handles this via `set_device_language`.
|
||||
|
||||
### 1.2 Network Configuration (WiFi)
|
||||
Configures the device to connect to a specific wireless access point.
|
||||
- **File Reference**: `setup/js/workflow_wifi_setup.js`
|
||||
- **Logic**: Triggers a site survey, then sends SSID and credentials.
|
||||
- **WebSocket Command**: `set_WIFI_OLED` or similar internal method calls to configure the network profile.
|
||||
|
||||
### 1.3 Device Naming (Rename Step)
|
||||
Assigns a user-friendly name (e.g., "Living Room") to the device.
|
||||
- **File Reference**: `setup/js/workflow_rename.js`
|
||||
- **WebSocket Action**: `name`
|
||||
- **XML Payload**:
|
||||
```xml
|
||||
<name>Living Room</name>
|
||||
```
|
||||
- **Implementation**: The `RenameDevices.do_rename_devices()` function sends this to the device. The device then updates its local name and mDNS/SSDP broadcasts.
|
||||
|
||||
## 2. Cloud Interaction Stage (HTTP)
|
||||
|
||||
The device needs to be linked to a Bose "Marge" account to enable cloud-based features and music services.
|
||||
|
||||
### 2.1 Account Creation (Registration)
|
||||
If a user doesn't have an account, the setup client creates one.
|
||||
- **File Reference**: `setup/js/workflow_marge.js`
|
||||
- **Cloud Endpoint**: `POST https://streaming.bose.com/streaming/account`
|
||||
- **Payload**: XML containing name, email, password, and country.
|
||||
- **Content-Type**: `application/vnd.bose.customer-v1.0+xml`
|
||||
|
||||
### 2.2 Cloud Authentication (Login)
|
||||
The setup client must obtain a valid `accountId` and `userAuthToken` to pair the device.
|
||||
- **File Reference**: `setup/js/workflow_marge.js`
|
||||
- **Cloud Endpoint**: `POST https://streaming.bose.com/streaming/account/login`
|
||||
- **Payload**: XML containing username and password.
|
||||
- **Content-Type**: `application/vnd.bose.streaming-v1.2+xml`
|
||||
- **Result**: Returns a session token in the `Credentials` response header and the user's `account ID` in the XML body.
|
||||
|
||||
## 3. Registration Bridge (WebSocket to Cloud)
|
||||
|
||||
This is the final "pairing" step where the client tells the device which account it belongs to.
|
||||
|
||||
### 3.1 Device Registration (The "Pair" Step)
|
||||
The client sends the user's credentials to the device, which then registers itself with the cloud.
|
||||
- **File Reference**: `setup/js/workflow_add_devices.js`
|
||||
- **WebSocket Action**: `setMargeAccount`
|
||||
- **XML Payload**:
|
||||
```xml
|
||||
<PairDeviceWithAccount>
|
||||
<accountId>12345</accountId>
|
||||
<userAuthToken>jGwE... (truncated)</userAuthToken>
|
||||
</PairDeviceWithAccount>
|
||||
```
|
||||
- **Device Reaction**: Upon receiving this, the device makes its own outbound HTTP POST to the Marge service:
|
||||
`POST https://streaming.bose.com/{accountId}/devices`
|
||||
|
||||
## 4. Finalization
|
||||
|
||||
Once the registration is complete, the setup application (Stockholm) performs final cleanup. It's important to distinguish between **App State** (the Stockholm UI's persistent settings) and **Device State** (the physical speaker's configuration).
|
||||
|
||||
### 4.1 Exiting Setup Mode (App Settings)
|
||||
The Stockholm app communicates with its "native container" (the WebView bridge on iOS/Android/Windows/macOS) using a `setData` command in **JSON format**. This is an internal message to the application's persistent storage, **not a network command sent to the physical speaker**.
|
||||
|
||||
This command tells the Stockholm app which page to load on startup, effectively marking the setup as complete in the UI.
|
||||
|
||||
- **Internal Command**: `setData`
|
||||
- **Parameter**: `startupPage`
|
||||
- **Normal Value**: `index.html` (Normal mode)
|
||||
- **Setup Value**: `setup/index.html` (Setup mode)
|
||||
|
||||
**JSON Payload (Internal to Stockholm App)**:
|
||||
```json
|
||||
{
|
||||
"method": "setData",
|
||||
"params": {
|
||||
"name": "startupPage",
|
||||
"value": "index.html"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Other Common Internal Parameters**:
|
||||
- `changeStartupPage`: Set to `false` after a successful setup or update.
|
||||
- `tipsEnabled`: Set to `false` to suppress the "Getting Started" tutorials.
|
||||
- `promptUpdate`: Set to `true` if a firmware update was deferred during setup.
|
||||
|
||||
### 4.2 Device Finalization
|
||||
The physical speaker considers the setup "done" once it successfully processes the `<PairDeviceWithAccount>` XML message and completes its own handshake with the Marge cloud. There is no specific "Finalize" XML command sent to the speaker; the successful registration is the signal.
|
||||
|
||||
The `SetupWizard.js` calls `single_device_setup_done()` to trigger the internal `setData` updates described above. If these are not saved in the app's local storage, the Stockholm UI may return to the setup flow on next launch, even if the speaker is already paired.
|
||||
|
||||
---
|
||||
|
||||
## Summary of Scriptable Requirements
|
||||
|
||||
To automate a device setup using a custom tool (like `soundtouch-cli`), you must perform the following:
|
||||
1. **Configure WiFi**: (Assumed if device is reachable over IP).
|
||||
2. **Set Name**: Send the `<name>` WebSocket message (XML) to update the device identity.
|
||||
3. **Obtain Token**: Authenticate against the cloud service (Marge) via HTTP.
|
||||
4. **Pair Device**: Send the `<PairDeviceWithAccount>` WebSocket message (XML) with the account ID and token.
|
||||
|
||||
**Note**: The JSON `setData` commands are only necessary if you are building/controlling a version of the Stockholm UI itself. They are not required to configure the physical hardware.
|
||||
@@ -1,138 +0,0 @@
|
||||
# Encrypted Diagnostic Export
|
||||
|
||||
AfterTouch can produce an encrypted diagnostic report that users can download and
|
||||
send to the project maintainer without exposing sensitive data to third parties.
|
||||
The report is encrypted with an SSH public key using
|
||||
[`age`](https://github.com/FiloSottile/age); only the holder of the matching
|
||||
private key can read it.
|
||||
|
||||
---
|
||||
|
||||
## What the report contains
|
||||
|
||||
The encrypted `.age` file decrypts to a `.tar.gz` archive with:
|
||||
|
||||
- `diagnostic.json` — structured summary:
|
||||
- Service version and build info
|
||||
- Full health-check results (same data as the Health tab)
|
||||
- Per-device state: sources (IDs, names, SourceKeyTypes), presets (slot, name,
|
||||
Source, SourceID, location), device product code, firmware version, IP, name
|
||||
- `datastore/accounts/{id}/devices/{id}/*.xml` — raw XML files verbatim from
|
||||
the sender's datastore (`Presets.xml`, `Sources.xml`, `Recents.xml`, …)
|
||||
|
||||
Having both the structured JSON and the raw XML lets you compare what the
|
||||
service serves via HTTP against what is actually stored on disk.
|
||||
|
||||
**What is excluded from the JSON:** authentication tokens, credentials, OAuth
|
||||
secrets, Spotify refresh tokens. The raw XML files are included as-is.
|
||||
|
||||
---
|
||||
|
||||
## Maintainer setup (one-time)
|
||||
|
||||
> This section is for the project maintainer only.
|
||||
> Users never need to touch keys.
|
||||
|
||||
### 1. Generate the key pair
|
||||
|
||||
```bash
|
||||
bash scripts/setup-diagnostic-key.sh
|
||||
```
|
||||
|
||||
This creates:
|
||||
- `keys/private/diagnostic` — SSH ed25519 private key (**gitignored**, never commit)
|
||||
- `keys/private/diagnostic.pub` — copy for reference (**gitignored**)
|
||||
- `keys/public/diagnostic.pub` — public key committed to the repo
|
||||
|
||||
### 2. Add the public key to GitHub
|
||||
|
||||
Go to <https://github.com/settings/ssh/new> and paste the contents of
|
||||
`keys/public/diagnostic.pub`. This makes the key visible at
|
||||
<https://github.com/gesellix.keys> so users can independently verify that the
|
||||
key embedded in the binary matches a key actually controlled by the maintainer.
|
||||
|
||||
### 3. Embed the public key in the binary
|
||||
|
||||
Open `pkg/service/export/encrypt.go` and update the `DiagnosticPublicKey`
|
||||
constant to match the new public key:
|
||||
|
||||
```go
|
||||
const DiagnosticPublicKey = "ssh-ed25519 AAAA... aftertouch-diagnostic@gesellix"
|
||||
```
|
||||
|
||||
### 4. Commit
|
||||
|
||||
```bash
|
||||
git add keys/public/diagnostic.pub pkg/service/export/encrypt.go
|
||||
git commit -m "keys: add diagnostic SSH public key"
|
||||
```
|
||||
|
||||
`keys/private/` is `.gitignore`d — the private key will not be committed.
|
||||
|
||||
### 5. Back up the private key
|
||||
|
||||
The private key is **not** stored in git. Keep a copy in a secure location
|
||||
(password manager, encrypted USB drive, etc.). If it is lost, a new key pair
|
||||
must be generated and the constant in `encrypt.go` updated.
|
||||
|
||||
---
|
||||
|
||||
## Verifying the embedded key (users)
|
||||
|
||||
Users who want to confirm that the key embedded in their running binary matches
|
||||
the maintainer's GitHub SSH keys can run:
|
||||
|
||||
```bash
|
||||
# Compare the raw key text — both should show the same line:
|
||||
curl -s https://github.com/gesellix.keys
|
||||
cat keys/public/diagnostic.pub
|
||||
```
|
||||
|
||||
The key should appear verbatim in both outputs.
|
||||
|
||||
---
|
||||
|
||||
## Decrypting a received report (maintainer)
|
||||
|
||||
When a user sends you an `aftertouch-diagnostic-*.age` file, use the helper
|
||||
script (no extra tools needed — only Go and the private key). Run from the
|
||||
repository root directory:
|
||||
|
||||
```bash
|
||||
# Decrypt and extract in one step:
|
||||
go run scripts/decrypt-diagnostic.go aftertouch-diagnostic-<timestamp>.age | tar xz
|
||||
|
||||
# Or decrypt to a .tar.gz first, then inspect:
|
||||
go run scripts/decrypt-diagnostic.go aftertouch-diagnostic-<timestamp>.age > report.tar.gz
|
||||
tar xzf report.tar.gz
|
||||
# → diagnostic.json
|
||||
# → datastore/accounts/{id}/devices/{id}/Presets.xml (and Sources.xml, Recents.xml, …)
|
||||
```
|
||||
|
||||
The script uses only the `filippo.io/age` Go module — no separate `age` CLI
|
||||
installation required.
|
||||
|
||||
---
|
||||
|
||||
## User workflow
|
||||
|
||||
1. Open the AfterTouch admin UI and go to the **Health** tab.
|
||||
2. Click **Download diagnostic report**.
|
||||
3. The browser downloads `aftertouch-diagnostic-<timestamp>.age`.
|
||||
4. Attach the file to the GitHub issue or send it via a direct channel.
|
||||
|
||||
The file is opaque binary — the user cannot read it. All they see is that the
|
||||
report was generated and downloaded.
|
||||
|
||||
---
|
||||
|
||||
## Key rotation
|
||||
|
||||
If the private key is compromised or lost:
|
||||
|
||||
1. Run `scripts/setup-diagnostic-key.sh` (delete the old `keys/private/diagnostic` first).
|
||||
2. Add the new public key to GitHub and remove the old one.
|
||||
3. Update `DiagnosticPublicKey` in `encrypt.go`.
|
||||
4. Commit and tag a new release.
|
||||
|
||||
Old reports encrypted with the previous key cannot be decrypted with the new key.
|
||||
@@ -1,66 +0,0 @@
|
||||
# Technical Proposal: External Service Provider Abstraction
|
||||
|
||||
This document outlines a strategy to refactor the SoundTouch Service's content handling into a modular provider-based system.
|
||||
|
||||
## 1. Problem Statement
|
||||
Currently, content handling for BMX (Bose Media Exchange) services like TuneIn or RadioBrowser is deeply intertwined with the HTTP handlers and XML models. Adding a new content provider (e.g., Local Media, Podcast RSS) requires modifying several files and duplicating boilerplate code for HTTP requests and error handling.
|
||||
|
||||
## 2. Proposed Architecture
|
||||
|
||||
### 2.1 The Provider Interface
|
||||
We define a generic `ContentProvider` interface that abstracts away the source-specific logic (API calls, data parsing).
|
||||
|
||||
```go
|
||||
package provider
|
||||
|
||||
import "github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
|
||||
type ContentProvider interface {
|
||||
// ID returns the unique identifier for this provider (e.g. "RADIO_BROWSER")
|
||||
ID() string
|
||||
|
||||
// Resolve returns playback details for a given content identifier
|
||||
Resolve(id string) (*models.BmxPlaybackResponse, error)
|
||||
|
||||
// Search allows finding content within this provider
|
||||
Search(query string) ([]models.ContentItem, error)
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Provider Registry
|
||||
A central registry in `soundtouch-service` manages the lifecycle and selection of providers.
|
||||
|
||||
```go
|
||||
type Registry struct {
|
||||
providers map[string]ContentProvider
|
||||
}
|
||||
|
||||
func (r *Registry) Register(p ContentProvider) { ... }
|
||||
func (r *Registry) Get(id string) ContentProvider { ... }
|
||||
```
|
||||
|
||||
## 3. Implementation Plan
|
||||
|
||||
### 3.1 Phase 1: Modularize RadioBrowser
|
||||
1. **Extract Logic**: Move current RadioBrowser logic from `bmx.go` into a new package `pkg/service/providers/radiobrowser`.
|
||||
2. **Add Failover**: Implement the **API Failover** logic inspired by OpenCloudTouch.
|
||||
- Maintain a list of active RadioBrowser mirrors (e.g., `de1.api.radio-browser.info`, `nl1.api.radio-browser.info`).
|
||||
- Implement a round-robin or health-based selection strategy.
|
||||
3. **Implements Interface**: Ensure the new package satisfies the `ContentProvider` interface.
|
||||
|
||||
### 3.2 Phase 2: Refactor BMX Handlers
|
||||
- Update `HandleTuneInPlayback` and `HandleOrionPlayback` to use the registry.
|
||||
- The handlers will look up the provider based on the request context or URL parameters and delegate the resolution.
|
||||
|
||||
### 3.3 Phase 3: Dynamic Service Advertising
|
||||
- Modify `HandleBMXRegistry` to dynamically generate the `bmx_services.json` content based on the currently registered and enabled providers.
|
||||
|
||||
## 4. Benefits
|
||||
- **Resilience**: Centralized error handling and failover strategies for all external APIs.
|
||||
- **Extensibility**: New services can be added by simply implementing the interface and registering them at startup.
|
||||
- **Testability**: Providers can be unit-tested in isolation without mocking the entire HTTP server stack.
|
||||
- **Unified UI**: A future Web UI can query the registry to show available content sources and their statuses.
|
||||
|
||||
## 5. Next Steps
|
||||
1. Refine the `ContentProvider` interface to include metadata (icons, user-friendly names).
|
||||
2. Create a prototype for the `radiobrowser` provider with failover support.
|
||||
+24
-24
@@ -4,7 +4,7 @@ This document tracks the detailed evolution of features and capabilities in the
|
||||
|
||||
## Development Timeline
|
||||
|
||||
### Phase 1: Foundation (January 2026)
|
||||
### Phase 1: Foundation (November 2024 - December 2024)
|
||||
|
||||
#### Core HTTP Client
|
||||
- **HTTP Client with XML Support**: Complete client implementation for SoundTouch Web API
|
||||
@@ -23,7 +23,7 @@ This document tracks the detailed evolution of features and capabilities in the
|
||||
- Device connectivity testing
|
||||
- Simple information retrieval commands
|
||||
|
||||
### Phase 2: Media Control & Discovery (January 2026)
|
||||
### Phase 2: Media Control & Discovery (December 2024)
|
||||
|
||||
#### Media Controls
|
||||
- **Key Commands**: Complete implementation of `/key` endpoint
|
||||
@@ -48,7 +48,7 @@ This document tracks the detailed evolution of features and capabilities in the
|
||||
- **Comprehensive Commands**: Full coverage of implemented endpoints
|
||||
- **Interactive Features**: Better user experience with formatted output
|
||||
|
||||
### Phase 3: Advanced Audio Controls (January 2026)
|
||||
### Phase 3: Advanced Audio Controls (January 2025)
|
||||
|
||||
#### Audio Management Trilogy
|
||||
- **Bass Control**: `/bass` GET/POST endpoints
|
||||
@@ -56,7 +56,7 @@ This document tracks the detailed evolution of features and capabilities in the
|
||||
- Incremental bass adjustment
|
||||
- Device capability detection via `/bassCapabilities`
|
||||
- Safety limits and user warnings
|
||||
- **Balance Control**: `/balance` GET/POST endpoints
|
||||
- **Balance Control**: `/balance` GET/POST endpoints
|
||||
- Stereo balance adjustment (-50 to +50)
|
||||
- Left/right channel convenience methods
|
||||
- Balance centering functionality
|
||||
@@ -81,7 +81,7 @@ This document tracks the detailed evolution of features and capabilities in the
|
||||
- Preset categorization and filtering
|
||||
- **API Limitation Documentation**: Clarified that POST `/presets` is officially N/A
|
||||
|
||||
### Phase 4: System Features (January 2026)
|
||||
### Phase 4: System Features (January 2025)
|
||||
|
||||
#### Clock and Display Management
|
||||
- **Clock Time**: `/clockTime` GET/POST endpoints
|
||||
@@ -104,7 +104,7 @@ This document tracks the detailed evolution of features and capabilities in the
|
||||
- **Multiple Discovery Protocols**: Fallback discovery methods for different network environments
|
||||
- **Corporate Network Support**: Discovery options for restricted networks
|
||||
|
||||
### Phase 5: Real-time Events (January 2026)
|
||||
### Phase 5: Real-time Events (January 2025)
|
||||
|
||||
#### WebSocket Implementation
|
||||
- **WebSocket Client**: Complete WebSocket implementation for real-time events
|
||||
@@ -130,7 +130,7 @@ This document tracks the detailed evolution of features and capabilities in the
|
||||
- **Formatted Output**: Human-readable event display
|
||||
- **Demo Applications**: WebSocket demonstration tools
|
||||
|
||||
### Phase 6: Multiroom Zone Management (January 2026)
|
||||
### Phase 6: Multiroom Zone Management (January 2025)
|
||||
|
||||
#### Zone Operations
|
||||
- **Zone Information**: `/getZone` GET endpoint
|
||||
@@ -166,7 +166,7 @@ This document tracks the detailed evolution of features and capabilities in the
|
||||
- **Error Handling**: Specific zone-related error types
|
||||
- **Zone Builder**: Fluent API for zone construction
|
||||
|
||||
### Phase 7: Advanced Audio Controls (January 2026)
|
||||
### Phase 7: Advanced Audio Controls (January 2025)
|
||||
|
||||
#### Professional Audio Features
|
||||
- **DSP Audio Controls**: `/audiodspcontrols` GET/POST endpoints
|
||||
@@ -188,7 +188,7 @@ This document tracks the detailed evolution of features and capabilities in the
|
||||
- **Conditional Feature Availability**: Features only available on compatible devices
|
||||
- **Graceful Degradation**: Fallback to basic controls when advanced features unavailable
|
||||
|
||||
### Phase 8: Speaker Notification System (February 2026)
|
||||
### Phase 8: Speaker Notification System (February 2025)
|
||||
|
||||
#### Notification Features
|
||||
- **Text-to-Speech (TTS)**: `/speaker` POST endpoint for TTS messages
|
||||
@@ -231,7 +231,7 @@ This document tracks the detailed evolution of features and capabilities in the
|
||||
- **Parameter Validation**: Complete input validation and error handling
|
||||
- **Usage Examples**: Extensive real-world usage examples
|
||||
|
||||
### Phase 9: Bug Fixes and Stability (February 2026)
|
||||
### Phase 9: Bug Fixes and Stability (February 2025)
|
||||
|
||||
#### Critical Bug Fixes
|
||||
- **PlayNotificationBeep HTTP Method Fix**: Corrected `/playNotification` endpoint to use GET instead of POST
|
||||
@@ -249,17 +249,17 @@ This document tracks the detailed evolution of features and capabilities in the
|
||||
|
||||
### API Endpoint Coverage Evolution
|
||||
|
||||
| Phase | Endpoints Added | Cumulative Total | Completion % |
|
||||
|---------|-----------------|------------------|------------------|
|
||||
| Phase 1 | 4 | 4 | 15% |
|
||||
| Phase 2 | 6 | 10 | 38% |
|
||||
| Phase 3 | 8 | 18 | 69% |
|
||||
| Phase 4 | 3 | 21 | 81% |
|
||||
| Phase 5 | 1 | 22 | 85% |
|
||||
| Phase 6 | 2 | 24 | 92% |
|
||||
| Phase 7 | 3 | 27 | 96% |
|
||||
| Phase 8 | 2 | 29 | 100% |
|
||||
| Phase 9 | 0 | 29 | 100% (Bug fixes) |
|
||||
| Phase | Endpoints Added | Cumulative Total | Completion % |
|
||||
|-------|-----------------|------------------|--------------|
|
||||
| Phase 1 | 4 | 4 | 15% |
|
||||
| Phase 2 | 6 | 10 | 38% |
|
||||
| Phase 3 | 8 | 18 | 69% |
|
||||
| Phase 4 | 3 | 21 | 81% |
|
||||
| Phase 5 | 1 | 22 | 85% |
|
||||
| Phase 6 | 2 | 24 | 92% |
|
||||
| Phase 7 | 3 | 27 | 96% |
|
||||
| Phase 8 | 2 | 29 | 100% |
|
||||
| Phase 9 | 0 | 29 | 100% (Bug fixes) |
|
||||
|
||||
### Testing Evolution
|
||||
|
||||
@@ -283,7 +283,7 @@ This document tracks the detailed evolution of features and capabilities in the
|
||||
### CLI Tool Evolution
|
||||
|
||||
#### Command Categories Added by Phase
|
||||
- **Phase 1**: `info`, `name`, `capabilities`
|
||||
- **Phase 1**: `info`, `name`, `capabilities`
|
||||
- **Phase 2**: `discover`, `play`, `volume`, `key`
|
||||
- **Phase 3**: `bass`, `balance`, `source`, `presets`
|
||||
- **Phase 4**: `clock`, `network`
|
||||
@@ -294,7 +294,7 @@ This document tracks the detailed evolution of features and capabilities in the
|
||||
- **Phase 9**: Bug fixes (speaker beep reliability)
|
||||
|
||||
#### CLI Feature Enhancements
|
||||
- **Host:Port Parsing**: Support for `192.0.2.100:8090` format
|
||||
- **Host:Port Parsing**: Support for `192.168.1.100:8090` format
|
||||
- **Auto-Discovery Integration**: Seamless device discovery
|
||||
- **Formatted Output**: Human-readable, structured output
|
||||
- **Error Handling**: Comprehensive error messages and recovery suggestions
|
||||
@@ -371,4 +371,4 @@ This document tracks the detailed evolution of features and capabilities in the
|
||||
|
||||
---
|
||||
|
||||
**This document tracks the evolution of the Bose SoundTouch API client from initial concept to production-ready library.**
|
||||
**This document tracks the evolution of the Bose SoundTouch API client from initial concept to production-ready library.**
|
||||
+21
-21
@@ -11,23 +11,23 @@ The SoundTouch CLI now supports parsing host and port combinations in the `-host
|
||||
### Basic Host:Port Format
|
||||
```bash
|
||||
# Specify host and port together
|
||||
soundtouch-cli -host 192.0.2.100:8090 -info
|
||||
soundtouch-cli -host 192.0.2.10:8090 -play
|
||||
soundtouch-cli -host 192.168.1.100:8090 -info
|
||||
soundtouch-cli -host 192.168.178.35:8090 -play
|
||||
soundtouch-cli -host soundtouch.local:8090 -pause
|
||||
```
|
||||
|
||||
### Traditional Separate Flags (Still Supported)
|
||||
```bash
|
||||
# Traditional separate host and port flags
|
||||
soundtouch-cli -host 192.0.2.100 -port 8090 -info
|
||||
soundtouch-cli -host 192.0.2.10 -port 8090 -play
|
||||
soundtouch-cli -host 192.168.1.100 -port 8090 -info
|
||||
soundtouch-cli -host 192.168.178.35 -port 8090 -play
|
||||
```
|
||||
|
||||
### Precedence Rules
|
||||
When both formats are used, the port specified in the host:port format takes precedence:
|
||||
```bash
|
||||
# Uses port 8090 from host:port, ignores -port 9999
|
||||
soundtouch-cli -host 192.0.2.100:8090 -port 9999 -info
|
||||
soundtouch-cli -host 192.168.1.100:8090 -port 9999 -info
|
||||
```
|
||||
|
||||
## Supported Formats
|
||||
@@ -35,10 +35,10 @@ soundtouch-cli -host 192.0.2.100:8090 -port 9999 -info
|
||||
### IPv4 Addresses
|
||||
```bash
|
||||
# Standard IPv4 with port
|
||||
soundtouch-cli -host 192.0.2.100:8090 -info
|
||||
soundtouch-cli -host 192.168.1.100:8090 -info
|
||||
|
||||
# IPv4 without port (uses default 8090)
|
||||
soundtouch-cli -host 192.0.2.100 -info
|
||||
soundtouch-cli -host 192.168.1.100 -info
|
||||
```
|
||||
|
||||
### Hostnames
|
||||
@@ -99,8 +99,8 @@ Comprehensive test coverage in `cmd/soundtouch-cli/main_test.go`:
|
||||
|
||||
### Integration Tests
|
||||
Tested with real SoundTouch devices:
|
||||
- ✅ SoundTouch 10 (192.0.2.11:8090)
|
||||
- ✅ SoundTouch 20 (192.0.2.10:8090)
|
||||
- ✅ SoundTouch 10 (192.168.178.28:8090)
|
||||
- ✅ SoundTouch 20 (192.168.178.35:8090)
|
||||
|
||||
## Benefits
|
||||
|
||||
@@ -123,31 +123,31 @@ Tested with real SoundTouch devices:
|
||||
# Discover devices to find host:port
|
||||
$ soundtouch-cli -discover
|
||||
Found SoundTouch devices:
|
||||
My SoundTouch Device (192.0.2.10:8090) - SoundTouch 20
|
||||
My SoundTouch Device (192.168.1.10:8090) - SoundTouch 20
|
||||
|
||||
# Use discovered host:port directly
|
||||
$ soundtouch-cli -host 192.0.2.10:8090 -play
|
||||
$ soundtouch-cli -host 192.168.1.10:8090 -play
|
||||
```
|
||||
|
||||
### Different Port Scenarios
|
||||
```bash
|
||||
# Standard SoundTouch port
|
||||
soundtouch-cli -host 192.0.2.100:8090 -info
|
||||
soundtouch-cli -host 192.168.1.100:8090 -info
|
||||
|
||||
# Custom port (if device configured differently)
|
||||
soundtouch-cli -host 192.0.2.100:9000 -info
|
||||
soundtouch-cli -host 192.168.1.100:9000 -info
|
||||
|
||||
# Default port fallback
|
||||
soundtouch-cli -host 192.0.2.100 -info # Uses 8090
|
||||
soundtouch-cli -host 192.168.1.100 -info # Uses 8090
|
||||
```
|
||||
|
||||
### Error Scenarios
|
||||
```bash
|
||||
# Invalid port - uses default 8090
|
||||
soundtouch-cli -host 192.0.2.100:invalid -info
|
||||
soundtouch-cli -host 192.168.1.100:invalid -info
|
||||
|
||||
# Out of range port - uses default 8090
|
||||
soundtouch-cli -host 192.0.2.100:99999 -info
|
||||
soundtouch-cli -host 192.168.1.100:99999 -info
|
||||
|
||||
# Malformed input - treats as hostname
|
||||
soundtouch-cli -host "malformed::input" -info
|
||||
@@ -163,10 +163,10 @@ Options:
|
||||
-port <port> SoundTouch device port (default: 8090)
|
||||
|
||||
Examples:
|
||||
soundtouch-cli -host 192.0.2.100 -info
|
||||
soundtouch-cli -host 192.0.2.100:8090 -info
|
||||
soundtouch-cli -host 192.0.2.100:8090 -pause
|
||||
soundtouch-cli -host 192.0.2.100:8090 -preset 1
|
||||
soundtouch-cli -host 192.168.1.100 -info
|
||||
soundtouch-cli -host 192.168.1.100:8090 -info
|
||||
soundtouch-cli -host 192.168.1.100:8090 -pause
|
||||
soundtouch-cli -host 192.168.1.100:8090 -preset 1
|
||||
```
|
||||
|
||||
## Technical Implementation
|
||||
@@ -197,7 +197,7 @@ The parsed values are used throughout the CLI:
|
||||
|
||||
Potential improvements for the future:
|
||||
|
||||
1. **URL Format Support**: Support full URLs like `http://192.0.2.100:8090`
|
||||
1. **URL Format Support**: Support full URLs like `http://192.168.1.100:8090`
|
||||
2. **Service Discovery**: Auto-detect port via service discovery protocols
|
||||
3. **Configuration File**: Save frequently used host:port combinations
|
||||
4. **Environment Variables**: Support `SOUNDTOUCH_HOST` with host:port format
|
||||
|
||||
@@ -316,7 +316,7 @@ MX:3
|
||||
NOTIFY * HTTP/1.1
|
||||
HOST:239.255.255.250:1900
|
||||
CACHE-CONTROL:max-age=1800
|
||||
LOCATION:http://192.0.2.100:8090/device_description.xml
|
||||
LOCATION:http://192.168.1.100:8090/device_description.xml
|
||||
NT:upnp:rootdevice
|
||||
NTS:ssdp:alive
|
||||
USN:uuid:12345678-1234-1234-1234-123456789012::upnp:rootdevice
|
||||
@@ -328,7 +328,7 @@ HTTP/1.1 200 OK
|
||||
CACHE-CONTROL:max-age=1800
|
||||
DATE:Wed, 18 Dec 2024 10:30:00 GMT
|
||||
EXT:
|
||||
LOCATION:http://192.0.2.100:8090/device_description.xml
|
||||
LOCATION:http://192.168.1.100:8090/device_description.xml
|
||||
SERVER:Linux/3.0 UPnP/1.0 Device/1.0
|
||||
ST:upnp:rootdevice
|
||||
USN:uuid:12345678-1234-1234-1234-123456789012::upnp:rootdevice
|
||||
@@ -363,7 +363,7 @@ arp -a
|
||||
|
||||
# Scan local network segment (requires nmap)
|
||||
brew install nmap
|
||||
nmap -sn 192.0.2.0/24 # Adjust network range as needed
|
||||
nmap -sn 192.168.1.0/24 # Adjust network range as needed
|
||||
|
||||
# Quick ping sweep (built-in)
|
||||
for i in {1..254}; do ping -c 1 -t 1 192.168.1.$i >/dev/null 2>&1 && echo "192.168.1.$i is up"; done
|
||||
@@ -439,10 +439,10 @@ sudo tcpdump -i any -n -A 'port 5353' | grep -i soundtouch
|
||||
netstat -g
|
||||
|
||||
# Test UDP connectivity
|
||||
nc -u 192.0.2.100 8090 # Replace with actual device IP
|
||||
nc -u 192.168.1.100 8090 # Replace with actual device IP
|
||||
|
||||
# Test HTTP connectivity to discovered devices
|
||||
curl -i http://192.0.2.100:8090/info # SoundTouch info endpoint
|
||||
curl -i http://192.168.1.100:8090/info # SoundTouch info endpoint
|
||||
```
|
||||
|
||||
## Protocol Comparison
|
||||
|
||||
@@ -40,7 +40,7 @@ import (
|
||||
func main() {
|
||||
// Create client
|
||||
config := &client.Config{
|
||||
Host: "192.0.2.100",
|
||||
Host: "192.168.1.100",
|
||||
Port: 8090,
|
||||
}
|
||||
soundtouch := client.NewClient(config)
|
||||
|
||||
+25
-26
@@ -3,19 +3,10 @@
|
||||
This document summarizes the improvements made to the **Marge service** to improve parity with the upstream Bose SoundTouch service, along with open issues and proposed next steps.
|
||||
|
||||
#### ✅ Completed Improvements (Marge Service)
|
||||
* **Mapped Preset `buttonNumber`**: Correctly mapped the internal `ServicePreset.ID` or `ButtonNumber` to the `buttonNumber` XML attribute in the `/full` response and ensured it is persisted in the local datastore.
|
||||
* **High-Fidelity Device Metadata**: Improved the datastore to correctly extract, persist, and report detailed device `<components>` (e.g., `LIGHTSWITCH`, `SMSC`) and their firmware versions from upstream responses.
|
||||
* **Standardized Preferred Language**: Updated the default `preferredLanguage` to `de` in the `/full` response and added synchronization to persist it from upstream responses.
|
||||
* **Persisted Provider Settings**: Added support for persisting and echoing back `providerSettings` (e.g., `STREAMING_QUALITY`, `ELIGIBLE_FOR_TRIAL`) from the `/full` response.
|
||||
* **Mapped Preset `buttonNumber`**: Correctly mapped the internal `ServicePreset.ID` to the `buttonNumber` XML attribute in the `/full` response.
|
||||
* **Populated `contentItemType`**: The `contentItemType` (e.g., `tracklisturl`) is now correctly synchronized from upstream, persisted in the local datastore, and returned in the `/full` response for both presets and recents.
|
||||
* **Standardized Credential Types**: Adjusted the logic for Spotify to use the correct `token_version_3` type when a token is present in the `/full` response, improving parity with the upstream service. The service now respects existing `credential_type` values from `Sources.xml` (e.g., `token_version_3` for Spotify) while providing sensible defaults for new or incomplete sources.
|
||||
* **Structured Sources (Sources.xml)**: Refactored `Sources.xml` to use an attribute-based structure (`sourceid`, `source`, `status`, `sourceAccount`, etc.) matching the real device's output. Removed redundant nested tags like `<sourcename>`, `<username>`, and `<name>`.
|
||||
* **Nested Recents (Recents.xml)**: Implemented a nested `<contentItem>` structure within `<recent>` entries in `Recents.xml`, maintaining exact parity with the device's persistence format while supporting legacy flat formats for backward compatibility.
|
||||
* **Inconsistent `serialNumber` Casing**: Fixed the casing mismatch in the `/full` response where the upstream uses camelCase `<serialNumber>` in the top-level `<device>` and lowercase `<serialnumber>` in the nested `<attachedProduct>`. Local responses now correctly mirror this inconsistency.
|
||||
* **Attribute-level Parity**:
|
||||
* Ensured `sourceAccount=""` is preserved in XML even when empty, matching device behavior for sources like TUNEIN.
|
||||
* Fixed casing for attributes like `deviceID` and `utcTime` in `Recents.xml`.
|
||||
* Correctly mapped and persisted preset and recent `id` attributes during "Initial Data Sync".
|
||||
* **Device Name Consistency**: Fixed an issue where the device `<name>` was empty in some local `/full` responses by ensuring it is correctly populated from the datastore and synchronized from upstream.
|
||||
* **Improved XML Parity**: Empty `<name>` tags in the `/full` response are now self-closing (`<name/>`), matching upstream behavior.
|
||||
* **Timestamp-based ID Generation**: Implemented a 9-digit ID schema (`YYMMDD` + 3-digit counter) for `recent` items, ensuring IDs are large, unique, and stay within the 32-bit integer range.
|
||||
@@ -37,29 +28,28 @@ This document summarizes the improvements made to the **Marge service** to impro
|
||||
* **Device Identity**: Added `<serialNumber>` and `<updatedOn>` to both the top-level `<device>` and its `<attachedProduct>`, ensuring consistent device identification.
|
||||
* **Field-Level Parity**: Mapped missing fields like `<contentItemType>` and `<productlabel>` to match upstream expectations.
|
||||
* **Improved Source Matching**: Enhanced internal logic to correctly link presets and recents to their configured sources based on multiple identifiers (ID, Key, or Type).
|
||||
* **Verified Parity Mismatch Fixes**: The reproduction test `TestParityMismatchReproduction_V2` confirms parity for identified mismatches in `POST /recent` and `GET /recents`, including credentials and source-specific metadata.
|
||||
* **Verified Parity Mismatch Fixes**: Comprehensive reproduction tests (`TestParityMismatchReproduction_V2` and `TestParityMismatchReproduction_V3`) now confirm parity for identified mismatches in `POST /recent` and `GET /recents`, including credentials and source-specific metadata.
|
||||
* **Unified Response Logic**: Refactored the code so that both `POST /recent` and `GET /recents` use the same formatting functions, guaranteeing consistency.
|
||||
* **Robust Parity Detection**: Updated the local parity checker to be whitespace-insensitive for XML bodies, significantly reducing noise from minor indentation or newline differences.
|
||||
* **Maintainable XML Generation**: Reduced cyclomatic complexity and code duplication in `marge.go` by extracting focused helper functions for mapping internal data to response-specific XML models.
|
||||
|
||||
---
|
||||
|
||||
#### 🛠️ Open Issues and Next Steps
|
||||
|
||||
Based on the latest `parity_mismatches` and the high-fidelity `/full` account response comparison (diff14), here are the recommended areas for further work:
|
||||
Based on the latest `parity_mismatches`, here are the recommended areas for further work:
|
||||
|
||||
#### 1. BMX / TuneIn Playback Parity (Medium)
|
||||
Current mismatches in `/bmx/tunein/v1/playback/station/...` show differences in reporting URLs and missing links:
|
||||
* **Mismatched Parameters**: Local reporting URLs use `listen_id=1234567890`, while upstream uses a different session-based ID.
|
||||
* **Mismatched Parameters**: Local reporting URLs use `listen_id=3432432423`, while upstream uses a different session-based ID.
|
||||
* **Missing Links**: Some upstream responses include additional `_links` or metadata that are currently omitted in local responses.
|
||||
* **Action**: Improve the `HandleTuneInPlayback` logic to better mirror the upstream response structure and parameter generation.
|
||||
|
||||
#### 2. `/full` Account Response Data Gaps (Medium)
|
||||
While structural parity for the `/full` response is high, several value-level gaps remain as shown in `diff14`:
|
||||
* **Timestamp Formats**: Upstream uses ISO-8601 with milliseconds (e.g., `2024-06-23T07:40:36.000+00:00`), whereas some local fields still use Unix epoch integers (e.g., `1234567890`).
|
||||
* **Provider Settings**: The `providerSettings` block in the local response currently lacks crucial values like `keyName`, `providerId`, and `boseId` (appearing as empty tags).
|
||||
* **Component Metadata**: Local component types are sometimes empty (`type=""`) compared to upstream values like `LIGHTSWITCH` or `SMSC`.
|
||||
* **Source/Preset Identifiers**: Local IDs (e.g., `100004`) differ from upstream IDs (e.g., `1234567`), though this may be expected due to different account/device environments.
|
||||
* **Action**: Update the mapping logic in `marge.go` and `setup.go` to ensure all fields in the `/full` response are correctly populated with high-fidelity values and standard ISO-8601 timestamps.
|
||||
#### 2. Presets and Recents Parity (Medium)
|
||||
Further align the standalone `GET /presets` and `GET /recents` endpoints with the refined structural improvements introduced for the `/full` account response:
|
||||
* **Source Nesting**: Ensure the standalone responses also use the specialized nested `<source>` structure instead of mixed attributes when appropriate.
|
||||
* **Field Completeness**: Verify all metadata fields (e.g., `<contentItemType>`, `<lastplayedat>`) are consistently populated across all access paths.
|
||||
* **Action**: Evaluate if the specialized `FullResponsePreset` and `FullResponseRecent` models should be shared or mirrored in the standalone handlers.
|
||||
|
||||
#### 3. OAuth / Spotify Token Noise (Low/Medium)
|
||||
The `/oauth/device/.../token` endpoint frequently reports mismatches because tokens are naturally different between local and upstream.
|
||||
@@ -78,10 +68,19 @@ Analysis of device reboot logs revealed several data requirements:
|
||||
* **Power-On Details Tracking**: Implemented extraction and persistence of detailed device information (serial numbers, firmware version, product details, and MAC addresses) from the `POST /streaming/support/power_on` request. This data is now stored in the local datastore, improving our ability to respond accurately to subsequent management requests.
|
||||
* **Source Provider Mapping**: Synchronized local source provider IDs and timestamps with upstream data. The `RADIO_BROWSER` provider is included in the public `/streaming/sourceproviders` list to maintain internal functionality while acknowledging it as a parity gap.
|
||||
|
||||
#### 7. Account Full Response (/full) Structural & Value Parity (Completed)
|
||||
Structural and value gaps in the `/full` account response have been addressed:
|
||||
#### 7. Account Full Response (/full) Structural & Value Parity (In Progress)
|
||||
Based on `_/diffs/diff7/`, several structural and value gaps remain in the `/full` account response:
|
||||
|
||||
**Key Fixes:**
|
||||
* **Structural**:
|
||||
* **Nested Source Association**: Improved the matching logic in `mapRecentsToFullResponse` to correctly link recents to their specific `ConfiguredSource` (e.g., by matching `sourceid` attribute).
|
||||
* **XML Tag Formatting**: Standardized self-closing tags and element formatting to match upstream's multi-line or empty-element formatting in various contexts.
|
||||
**Remaining Findings:**
|
||||
* **Nested Source Inconsistency in Recents**: The `<source>` element within `<recent>` entries still frequently points to a generic fallback (ID `9330201`) instead of the specific source (e.g., Spotify ID `10863533`).
|
||||
* Missing/empty `<username>` at the `<preset>` level.
|
||||
* **Values**:
|
||||
* **Empty Device `<name>`**: Locally, the device `<name>` is empty in the response even when available in the datastore or upstream.
|
||||
* **Empty `<contentItemType>`**: Local responses have empty `<contentItemType>` in presets and recents, whereas upstream has `tracklisturl` or `stationurl`.
|
||||
* `preferredLanguage` mismatch (`en` vs `de`).
|
||||
|
||||
**Next Implementation Steps (Proposals):**
|
||||
1. **Fix Device `<name>` Population**: Investigate why `CreateAccountDevice` or `AccountFullToXML` is not correctly returning the device name even if it's synchronized.
|
||||
2. **Refine Source Association in Recents**: Improve the matching logic in `mapRecentsToFullResponse` to correctly link recents to their specific `ConfiguredSource` (e.g., by matching `sourceid` attribute).
|
||||
3. **Populate `contentItemType`**: Update the internal models and `SyncFromAccountFull` to correctly extract, persist, and echo back `contentItemType` (e.g., `tracklisturl`).
|
||||
4. **Handle Account Metadata**: Synchronize `preferredLanguage` from the upstream `/full` response to the local account state.
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
# Parity Analysis: Bose-SoundTouch (Go) vs. OpenCloudTouch (Python)
|
||||
|
||||
This document provides a comparative analysis of the current Go implementation and the `scheilch/opencloudtouch` project, identifying functional gaps and potential improvements.
|
||||
|
||||
## 1. Core Architecture and Language
|
||||
- **Bose-SoundTouch (Go)**: A high-performance, strongly typed backend with a CLI and background service. Focuses on full API coverage, parity testing, and robust hardware control (DSP, zones).
|
||||
- **OpenCloudTouch (OCT)**: A modern full-stack application (FastAPI + React/TypeScript). Prioritizes user experience with a web-based setup wizard and a clean abstraction for internet radio.
|
||||
|
||||
## 2. Functional Comparison
|
||||
|
||||
| Feature | Bose-SoundTouch (Go) | OpenCloudTouch (Python) |
|
||||
|:------------------------|:-----------------------------------------------------------|:------------------------------------------------------------------------|
|
||||
| **Setup Experience** | CLI-driven or manual API calls for migration (SSH, XML). | Web-based **Setup Wizard** guides through SSH, backup, and redirection. |
|
||||
| **Radio Support** | Static integration of **RadioBrowser** and TuneIn. | Dynamic **RadioBrowserAdapter** with automatic **API Failover**. |
|
||||
| **Commercial Services** | Deep integration (Spotify priming, Pandora, Deezer, etc.). | Basic support, focus is on local content and radio. |
|
||||
| **Hardware Control** | Extensive (Bass, Treble, Soundbar levels, Clock display). | Basic playback and zone controls. |
|
||||
| **Cloud Emulation** | High-fidelity parity (mirroring, discrepancy logging). | Functional emulation for local preset/recent persistence. |
|
||||
| **Notifications** | Built-in **TTS** and custom URL audio alerts. | Not a primary focus. |
|
||||
|
||||
## 3. Key Strengths of OpenCloudTouch
|
||||
- **Guided Onboarding**: The setup wizard reduces the entry barrier for non-technical users significantly.
|
||||
- **Resilient Radio**: The API failover for RadioBrowser ensures continuous service even if specific community-hosted API instances go offline.
|
||||
- **Modern API Stack**: Uses OpenAPI and generated TypeScript types for a seamless frontend integration.
|
||||
- **Provider Abstraction**: A cleaner internal separation between the "Bose World" (XML/BMX) and external content providers (RadioBrowser).
|
||||
|
||||
## 4. Suggested Improvements for Bose-SoundTouch
|
||||
|
||||
### A. Web-based Setup Wizard (High Priority)
|
||||
- Implement a state-driven wizard in the `soundtouch-service` to handle:
|
||||
- SSH activation (checking `/remote_services` via USB).
|
||||
- Automated backup of speaker configuration.
|
||||
- Verification of DNS/Hosts redirection.
|
||||
- Expose this via a simple embedded Web UI (using Go's `embed` package).
|
||||
|
||||
### B. RadioBrowser Failover (Medium Priority)
|
||||
- Adapt the failover logic from OCT:
|
||||
- Periodically refresh the list of available RadioBrowser API servers.
|
||||
- Implement a retry mechanism that switches servers on 5xx errors or timeouts.
|
||||
|
||||
### C. External Service Abstraction (Medium Priority)
|
||||
- Refactor the hardcoded BMX logic into a more modular **Provider System** (see `EXTERNAL-SERVICES-ABSTRACTION.md`).
|
||||
- This will allow easier addition of new sources (e.g., local DLNA, generic M3U playlists) without touching the core BMX handlers.
|
||||
|
||||
## 5. Summary
|
||||
While our Go project provides the most complete technical coverage of SoundTouch hardware and commercial services, OpenCloudTouch sets a higher standard for **user onboarding** and **service resilience** for community-driven content. Integrating a setup wizard and a more robust radio backend would make our project significantly more accessible and reliable.
|
||||
@@ -1,57 +0,0 @@
|
||||
# Parity Analysis: Bose-SoundTouch (Go) vs. SoundCork (Python)
|
||||
|
||||
This document provides a comparative analysis of the current Go implementation and the `deborahgu/soundcork` project, identifying functional gaps and potential improvements.
|
||||
|
||||
## 1. Core Architecture and Language
|
||||
- **Bose-SoundTouch (Go)**: Uses `chi` for routing and `encoding/xml` for data. High performance, strong typing, and precise MIME type handling (`application/vnd.bose.streaming-v1.2+xml`).
|
||||
- **SoundCork (Python)**: Uses `FastAPI` and `xml.etree.ElementTree`. Prioritizes flexibility and rapid prototyping of streaming service mocks.
|
||||
|
||||
## 2. Functional Comparison
|
||||
|
||||
| Feature | Bose-SoundTouch (Go) | SoundCork (Python) |
|
||||
|:---------------------|:----------------------------------------------------------------------------------------------------------------------|:----------------------------------------------------------------------------------------|
|
||||
| **Group Management** | Full CRUD: `POST /group`, `POST /group/{id}`, `DELETE /group/{id}` with XML datastore persistence (`Group_{id}.xml`). | Active group management (`groups.py`), supporting `/addGroup` and stereo pairing logic. |
|
||||
| **ZeroConf Priming** | Full DH key exchange + encrypted blob; fallback to `tokenType=accesstoken` for older firmware. | Simple `tokenType=accesstoken` push only; token expires after ~60 minutes. |
|
||||
| **BMX Services** | Supports TuneIn, Orion, and custom streams. | More modular `bmx_services.json` registry with broader mock support. |
|
||||
| **Persistence** | Mixed JSON/XML datastore. | Pure XML-based persistence per device/account. |
|
||||
| **Admin UI** | CLI-based (`soundtouch-cli`) or API-driven. | Draft Web UI for device discovery and account management (`admin.py`). |
|
||||
| **Discovery** | Integrated setup tools and SSDP/MDNS awareness. | Leverages `bosesoundtouchapi` Python library for active discovery. |
|
||||
|
||||
## 3. Key Strengths of SoundCork
|
||||
- **Group Pairing Logic**: Includes logic to manage master/slave relationships for SoundTouch 10 stereo pairs.
|
||||
- **Service Extensibility**: JSON-based registry for BMX services makes it easier to mock multiple providers (SiriusXM, Spotify) without code changes.
|
||||
- ~~**Mock Coverage**: Better coverage of "dummy" endpoints that respond with plausible XML (e.g., `customerSupport`).~~ **Addressed**: AfterTouch's `HandleNotFound` (registered via `r.NotFound`) logs every unimplemented endpoint as `[UNHANDLED]` and forwards the request to the Bose upstream via `HandleBoseProxy`. This provides at least the same coverage as static dummy responses, while also aiding discovery of new endpoints.
|
||||
|
||||
## 4. Suggested Implementation Steps for Bose-SoundTouch
|
||||
|
||||
### ✅ A. Implement Full Group Support (Completed)
|
||||
- `POST /group`, `POST /group/{id}`, `DELETE /group/{id}` implemented in `pkg/service/handlers/handlers_marge.go`.
|
||||
- Group CRUD persisted in XML datastore (`Group_{id}.xml`) via `pkg/service/datastore/datastore.go`.
|
||||
- `GET /group` on device registration reads the group the device belongs to.
|
||||
|
||||
### ✅ B. Proper ZeroConf Spotify Blob (Completed)
|
||||
- Full Spotify Connect ZeroConf protocol implemented in `pkg/service/spotify/zeroconf.go`.
|
||||
- Flow: `getInfo` (fetch speaker DH public key) → 768-bit DH key exchange → AES-128-CTR encrypted `LoginCredentials` protobuf blob → `addUser`.
|
||||
- Speaker can self-refresh credentials independently; no periodic re-priming needed for token expiry.
|
||||
- Automatic fallback to `tokenType=accesstoken` if `getInfo` fails (older firmware without DH support).
|
||||
- See `docs/concepts/spotify-priming-strategy.md` for full protocol details.
|
||||
- **Remaining gap**: Background watchdog to re-prime devices that lose their session (reboot / power loss). Not required for token expiry on modern firmware; only needed for the "speaker rebooted and lost state" recovery path and for older firmware on the fallback path (~45 min token expiry).
|
||||
|
||||
### C. Modularize BMX Registry (Medium Priority)
|
||||
- Extract the hardcoded service list in `HandleBMXRegistry` into an external `bmx-services.json` file.
|
||||
- Allow users to customize which mocked services are advertised to the speaker.
|
||||
|
||||
### D. Enhanced Source Management (Medium Priority)
|
||||
- Refine source learning logic to ensure all `sourceAccount` and `sourceName` metadata is correctly captured during synchronization, using patterns from `soundcork`'s `learnSource`.
|
||||
|
||||
### E. Basic Admin Web UI (Low Priority)
|
||||
- Develop a minimal internal status page to list active accounts and connected devices, improving usability over raw API calls.
|
||||
|
||||
## 5. Summary
|
||||
|
||||
Group support and ZeroConf Spotify priming are now feature-complete in AfterTouch. The Go implementation is structurally more consistent with recent reference recordings (e.g., `buttonNumber`, detailed `components`). SoundCork's remaining functional advantages are:
|
||||
|
||||
- **BMX service extensibility**: the `bmx_services.json` registry makes it trivial to add or mock new streaming providers without code changes (step C above).
|
||||
- **Group pairing logic**: master/slave relationship management for SoundTouch 10 stereo pairs goes beyond the CRUD AfterTouch implements.
|
||||
|
||||
For the broader ecosystem context (feature matrix across all community projects, AfterTouch open tasks, and cross-project observations) see [docs/analysis/bose-soundtouch-community-tools.md](analysis/bose-soundtouch-community-tools.md).
|
||||
+25
-25
@@ -10,20 +10,20 @@ SoundTouch devices support 6 preset slots that can store your favorite content f
|
||||
|
||||
### 1. See Current Presets
|
||||
```bash
|
||||
soundtouch-cli --host 192.0.2.100 preset list
|
||||
soundtouch-cli --host 192.168.1.100 preset list
|
||||
```
|
||||
|
||||
### 2. Store What's Currently Playing
|
||||
```bash
|
||||
# Store current song/station as preset 1
|
||||
soundtouch-cli --host 192.0.2.100 preset store-current --slot 1
|
||||
soundtouch-cli --host 192.168.1.100 preset store-current --slot 1
|
||||
```
|
||||
|
||||
### 3. Store Specific Content
|
||||
|
||||
#### Spotify Playlist
|
||||
```bash
|
||||
soundtouch-cli --host 192.0.2.100 preset store \
|
||||
soundtouch-cli --host 192.168.1.100 preset store \
|
||||
--slot 2 \
|
||||
--source SPOTIFY \
|
||||
--location "spotify:playlist:37i9dQZF1DXcBWIGoYBM5M" \
|
||||
@@ -32,26 +32,26 @@ soundtouch-cli --host 192.0.2.100 preset store \
|
||||
|
||||
#### Radio Station
|
||||
```bash
|
||||
soundtouch-cli --host 192.0.2.100 preset store \
|
||||
soundtouch-cli --host 192.168.1.100 preset store \
|
||||
--slot 3 \
|
||||
--source TUNEIN \
|
||||
--location "/v1/playback/station/s33828" \
|
||||
--location "/v1/playbook/station/s33828" \
|
||||
--name "K-LOVE Radio"
|
||||
```
|
||||
|
||||
### 4. Use Your Presets
|
||||
```bash
|
||||
# Play preset 1
|
||||
soundtouch-cli --host 192.0.2.100 preset select --slot 1
|
||||
soundtouch-cli --host 192.168.1.100 preset select --slot 1
|
||||
|
||||
# Play preset 2
|
||||
soundtouch-cli --host 192.0.2.100 preset select --slot 2
|
||||
soundtouch-cli --host 192.168.1.100 preset select --slot 2
|
||||
```
|
||||
|
||||
### 5. Remove Presets
|
||||
```bash
|
||||
# Remove preset 6
|
||||
soundtouch-cli --host 192.0.2.100 preset remove --slot 6
|
||||
soundtouch-cli --host 192.168.1.100 preset remove --slot 6
|
||||
```
|
||||
|
||||
## Getting Content Locations
|
||||
@@ -61,7 +61,7 @@ To store specific content, you need the `location` parameter. Here's how to get
|
||||
### Method 1: From Currently Playing Content
|
||||
```bash
|
||||
# Play the content you want to save, then:
|
||||
soundtouch-cli --host 192.0.2.100 play now
|
||||
soundtouch-cli --host 192.168.1.100 play now
|
||||
```
|
||||
|
||||
**Example output:**
|
||||
@@ -103,7 +103,7 @@ Just replace `https://open.spotify.com/` with `spotify:` and `/` with `:`.
|
||||
### Radio Stations
|
||||
```bash
|
||||
# TuneIn Radio
|
||||
--source TUNEIN --location "/v1/playback/station/s33828"
|
||||
--source TUNEIN --location "/v1/playbook/station/s33828"
|
||||
|
||||
# Internet Radio Stream
|
||||
--source LOCAL_INTERNET_RADIO --location "https://stream.example.com/jazz"
|
||||
@@ -135,7 +135,7 @@ import (
|
||||
func main() {
|
||||
// Create client
|
||||
c := client.NewClient(&client.Config{
|
||||
Host: "192.0.2.100",
|
||||
Host: "192.168.1.100",
|
||||
Port: 8090,
|
||||
})
|
||||
|
||||
@@ -235,43 +235,43 @@ select {} // Run forever
|
||||
### Family Setup
|
||||
```bash
|
||||
# Dad's morning playlist
|
||||
soundtouch-cli --host 192.0.2.100 preset store \
|
||||
soundtouch-cli --host 192.168.1.100 preset store \
|
||||
--slot 1 --source SPOTIFY \
|
||||
--location "spotify:playlist:morning-energy" \
|
||||
--name "Dad's Morning Mix"
|
||||
|
||||
# Mom's cooking music
|
||||
soundtouch-cli --host 192.0.2.100 preset store \
|
||||
soundtouch-cli --host 192.168.1.100 preset store \
|
||||
--slot 2 --source SPOTIFY \
|
||||
--location "spotify:playlist:cooking-vibes" \
|
||||
--name "Kitchen Tunes"
|
||||
|
||||
# Kids' bedtime stories
|
||||
soundtouch-cli --host 192.0.2.100 preset store \
|
||||
soundtouch-cli --host 192.168.1.100 preset store \
|
||||
--slot 3 --source TUNEIN \
|
||||
--location "/v1/playback/station/bedtime-stories" \
|
||||
--location "/v1/playbook/station/bedtime-stories" \
|
||||
--name "Bedtime Stories"
|
||||
```
|
||||
|
||||
### Party Mode
|
||||
```bash
|
||||
# Upbeat party playlist
|
||||
soundtouch-cli --host 192.0.2.100 preset store-current --slot 1
|
||||
soundtouch-cli --host 192.168.1.100 preset store-current --slot 1
|
||||
|
||||
# Chill background music
|
||||
soundtouch-cli --host 192.0.2.100 preset store-current --slot 2
|
||||
soundtouch-cli --host 192.168.1.100 preset store-current --slot 2
|
||||
|
||||
# Dance music
|
||||
soundtouch-cli --host 192.0.2.100 preset store-current --slot 3
|
||||
soundtouch-cli --host 192.168.1.100 preset store-current --slot 3
|
||||
```
|
||||
|
||||
### Smart Home Integration
|
||||
```bash
|
||||
# Morning routine (preset 1) - triggered by smart home at 7 AM
|
||||
soundtouch-cli --host 192.0.2.100 preset select --slot 1
|
||||
soundtouch-cli --host 192.168.1.100 preset select --slot 1
|
||||
|
||||
# Evening routine (preset 2) - triggered at sunset
|
||||
soundtouch-cli --host 192.0.2.100 preset select --slot 2
|
||||
soundtouch-cli --host 192.168.1.100 preset select --slot 2
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
@@ -286,26 +286,26 @@ Not all content can be saved as presets:
|
||||
### "All preset slots are occupied"
|
||||
```bash
|
||||
# See which presets you have
|
||||
soundtouch-cli --host 192.0.2.100 preset list
|
||||
soundtouch-cli --host 192.168.1.100 preset list
|
||||
|
||||
# Remove one you don't need
|
||||
soundtouch-cli --host 192.0.2.100 preset remove --slot 6
|
||||
soundtouch-cli --host 192.168.1.100 preset remove --slot 6
|
||||
|
||||
# Or overwrite an existing one
|
||||
soundtouch-cli --host 192.0.2.100 preset store-current --slot 6
|
||||
soundtouch-cli --host 192.168.1.100 preset store-current --slot 6
|
||||
```
|
||||
|
||||
### Getting Spotify URIs
|
||||
If you can't find Spotify URIs:
|
||||
|
||||
1. **Play the content** in Spotify on your SoundTouch
|
||||
2. **Check what's playing**: `soundtouch-cli --host 192.0.2.100 play now`
|
||||
2. **Check what's playing**: `soundtouch-cli --host 192.168.1.100 play now`
|
||||
3. **Copy the location** from the output
|
||||
|
||||
### Device Connection Issues
|
||||
```bash
|
||||
# Test connection first
|
||||
soundtouch-cli --host 192.0.2.100 info
|
||||
soundtouch-cli --host 192.168.1.100 info
|
||||
|
||||
# If that fails, check:
|
||||
# - Device IP address is correct
|
||||
|
||||
+45
-45
@@ -21,7 +21,7 @@ This document describes the most important patterns for the Bose SoundTouch API
|
||||
|
||||
**Key Aspects:**
|
||||
- **Native Builds**: Full API functionality for CLI and server
|
||||
- **WASM Builds**: Browser-compatible subset functionality
|
||||
- **WASM Builds**: Browser-compatible subset functionality
|
||||
- **Cross-Platform**: Linux, macOS, Windows support
|
||||
- **Embedded Assets**: Web UI directly embedded in binary
|
||||
|
||||
@@ -66,7 +66,7 @@ func (c *Client) GetNowPlaying() (*models.NowPlaying, error) {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
|
||||
var nowPlaying models.NowPlaying
|
||||
err = xml.NewDecoder(resp.Body).Decode(&nowPlaying)
|
||||
return &nowPlaying, err
|
||||
@@ -77,7 +77,7 @@ func (c *Client) GetNowPlaying() (*models.NowPlaying, error) {
|
||||
```go
|
||||
func (c *Client) SendKey(key models.Key) error {
|
||||
keyXML := fmt.Sprintf(`<key state="press" sender="GoClient">%s</key>`, key)
|
||||
|
||||
|
||||
resp, err := c.httpClient.Post(
|
||||
c.baseURL+"/key",
|
||||
"application/xml",
|
||||
@@ -117,14 +117,14 @@ func (d *DiscoveryService) DiscoverDevices() ([]Device, error) {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
|
||||
// Send M-SEARCH request
|
||||
searchRequest := "M-SEARCH * HTTP/1.1\r\n" +
|
||||
"HOST: 239.255.255.250:1900\r\n" +
|
||||
"MAN: \"ssdp:discover\"\r\n" +
|
||||
"ST: urn:schemas-upnp-org:device:MediaRenderer:1\r\n" +
|
||||
"MX: 3\r\n\r\n"
|
||||
|
||||
|
||||
// Implementation details...
|
||||
return devices, nil
|
||||
}
|
||||
@@ -158,13 +158,13 @@ func (e *EventClient) Subscribe(eventType string, handler EventHandler) {
|
||||
|
||||
func (e *EventClient) Start() error {
|
||||
u := url.URL{Scheme: "ws", Host: e.client.host + ":8090", Path: "/"}
|
||||
|
||||
|
||||
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.conn = conn
|
||||
|
||||
|
||||
go e.eventLoop()
|
||||
return nil
|
||||
}
|
||||
@@ -184,7 +184,7 @@ func (e *EventClient) eventLoop() {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if handler, exists := e.handlers[event.Type]; exists {
|
||||
go handler(event)
|
||||
}
|
||||
@@ -220,7 +220,7 @@ func wasmDiscoverDevices(this js.Value, args []js.Value) interface{} {
|
||||
handler := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||
go func() {
|
||||
devices, err := discovery.NewDiscoveryService(5*time.Second).DiscoverDevices()
|
||||
|
||||
|
||||
result := make(map[string]interface{})
|
||||
if err != nil {
|
||||
result["error"] = err.Error()
|
||||
@@ -228,13 +228,13 @@ func wasmDiscoverDevices(this js.Value, args []js.Value) interface{} {
|
||||
devicesJSON, _ := json.Marshal(devices)
|
||||
result["devices"] = string(devicesJSON)
|
||||
}
|
||||
|
||||
|
||||
// Call JavaScript callback
|
||||
args[0].Invoke(js.ValueOf(result))
|
||||
}()
|
||||
return nil
|
||||
})
|
||||
|
||||
|
||||
return handler
|
||||
}
|
||||
```
|
||||
@@ -280,7 +280,7 @@ func main() {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
for i, device := range devices {
|
||||
fmt.Printf("%d: %s (%s)\n", i+1, device.Name, device.Host)
|
||||
}
|
||||
@@ -300,7 +300,7 @@ func main() {
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
app.Run(os.Args)
|
||||
}
|
||||
|
||||
@@ -311,7 +311,7 @@ func getClientFromContext(c *cli.Context) *client.Client {
|
||||
devices, _ := discovery.DiscoverDevices()
|
||||
deviceHost = selectDeviceInteractive(devices)
|
||||
}
|
||||
|
||||
|
||||
return client.NewClient(deviceHost, 8090)
|
||||
}
|
||||
```
|
||||
@@ -327,34 +327,34 @@ var webAssets embed.FS
|
||||
|
||||
func main() {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
|
||||
// Embedded web assets
|
||||
webFS, err := fs.Sub(webAssets, "web")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
// SPA routing
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.FileServer(http.FS(webFS)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
data, err := webAssets.ReadFile("web/index.html")
|
||||
if err != nil {
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write(data)
|
||||
})
|
||||
|
||||
|
||||
// API endpoints
|
||||
mux.HandleFunc("/api/devices", handleDeviceDiscovery)
|
||||
mux.HandleFunc("/api/client/", handleClientProxy)
|
||||
|
||||
|
||||
log.Println("SoundTouch Web UI starting on :8080")
|
||||
log.Fatal(http.ListenAndServe(":8080", mux))
|
||||
}
|
||||
@@ -364,42 +364,42 @@ func main() {
|
||||
|
||||
```go
|
||||
func handleClientProxy(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract device IP from path: /api/client/192.0.2.100/now_playing
|
||||
// Extract device IP from path: /api/client/192.168.1.100/now_playing
|
||||
pathParts := strings.Split(r.URL.Path, "/")
|
||||
if len(pathParts) < 5 {
|
||||
http.Error(w, "Invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
deviceIP := pathParts[3]
|
||||
apiPath := "/" + strings.Join(pathParts[4:], "/")
|
||||
|
||||
|
||||
// Proxy request to SoundTouch device
|
||||
targetURL := fmt.Sprintf("http://%s:8090%s", deviceIP, apiPath)
|
||||
|
||||
|
||||
proxyReq, err := http.NewRequest(r.Method, targetURL, r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Copy headers
|
||||
for k, v := range r.Header {
|
||||
proxyReq.Header[k] = v
|
||||
}
|
||||
|
||||
|
||||
resp, err := http.DefaultClient.Do(proxyReq)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
|
||||
// Enable CORS
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
|
||||
// Copy response
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
io.Copy(w, resp.Body)
|
||||
@@ -448,7 +448,7 @@ func (p *PlayStatus) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error
|
||||
if err := d.DecodeElement(&s, &start); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
switch s {
|
||||
case string(PlayStatusPlaying), string(PlayStatusPaused), string(PlayStatusStopped):
|
||||
*p = PlayStatus(s)
|
||||
@@ -469,41 +469,41 @@ type Config struct {
|
||||
// Server configuration
|
||||
WebPort int `env:"WEB_PORT" default:"8080"`
|
||||
APITimeout time.Duration `env:"API_TIMEOUT" default:"10s"`
|
||||
|
||||
|
||||
// Discovery configuration
|
||||
DiscoveryTimeout time.Duration `env:"DISCOVERY_TIMEOUT" default:"5s"`
|
||||
CacheDevices bool `env:"CACHE_DEVICES" default:"true"`
|
||||
|
||||
|
||||
// CORS configuration (for web proxy)
|
||||
CORSOrigins []string `env:"CORS_ORIGINS" default:"*"`
|
||||
|
||||
|
||||
// Logging
|
||||
LogLevel string `env:"LOG_LEVEL" default:"info"`
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
var cfg Config
|
||||
|
||||
|
||||
// Load from .env file
|
||||
loadDotEnv()
|
||||
|
||||
|
||||
// Parse environment variables with reflection
|
||||
parseEnvVars(&cfg)
|
||||
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func parseEnvVars(cfg interface{}) {
|
||||
v := reflect.ValueOf(cfg).Elem()
|
||||
t := v.Type()
|
||||
|
||||
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
field := v.Field(i)
|
||||
fieldType := t.Field(i)
|
||||
|
||||
|
||||
envTag := fieldType.Tag.Get("env")
|
||||
defaultTag := fieldType.Tag.Get("default")
|
||||
|
||||
|
||||
if envTag != "" {
|
||||
if envValue := os.Getenv(envTag); envValue != "" {
|
||||
setFieldValue(field, envValue)
|
||||
@@ -545,11 +545,11 @@ func (m *MockClient) GetNowPlaying() (*models.NowPlaying, error) {
|
||||
if err, exists := m.errors["now_playing"]; exists {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
if resp, exists := m.responses["now_playing"]; exists {
|
||||
return resp.(*models.NowPlaying), nil
|
||||
}
|
||||
|
||||
|
||||
return &models.NowPlaying{
|
||||
Track: "Mock Track",
|
||||
Artist: "Mock Artist",
|
||||
@@ -577,8 +577,8 @@ CMD ["go", "test", "-v", "./..."]
|
||||
```bash
|
||||
# Makefile test target
|
||||
test-integration:
|
||||
docker compose -f test/docker-compose.yml up --build --abort-on-container-exit
|
||||
docker compose -f test/docker-compose.yml down
|
||||
docker-compose -f test/docker-compose.yml up --build --abort-on-container-exit
|
||||
docker-compose -f test/docker-compose.yml down
|
||||
```
|
||||
|
||||
## Recommended Project Structure
|
||||
@@ -741,7 +741,7 @@ type APIError struct {
|
||||
Message string `xml:",innerxml"`
|
||||
}
|
||||
|
||||
// pkg/models/device.go
|
||||
// pkg/models/device.go
|
||||
type DeviceInfo struct {
|
||||
XMLResponse
|
||||
Name string `xml:"name"`
|
||||
@@ -773,7 +773,7 @@ func main() {
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
app.Run(os.Args)
|
||||
}
|
||||
```
|
||||
@@ -802,4 +802,4 @@ func main() {
|
||||
|
||||
## Conclusion
|
||||
|
||||
This pattern collection enables the development of robust API clients for hardware devices that function both as native tools and as web applications. The combination of Go's type safety, WASM support, and a structured build system makes it possible to use a single codebase for various deployment scenarios.
|
||||
This pattern collection enables the development of robust API clients for hardware devices that function both as native tools and as web applications. The combination of Go's type safety, WASM support, and a structured build system makes it possible to use a single codebase for various deployment scenarios.
|
||||
+2
-5
@@ -8,9 +8,8 @@ Welcome to the documentation for the Bose SoundTouch Toolkit. This comprehensive
|
||||
- **[Complete Migration Guide](guides/MIGRATION-GUIDE.md)** - Step-by-step guide from Bose Cloud to local control
|
||||
- **[Getting Started](guides/GETTING-STARTED.md)** - Quick introduction to the toolkit
|
||||
|
||||
### For Existing Users
|
||||
### For Existing Users
|
||||
- **[Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)** - Prepare for the May 2026 shutdown
|
||||
- **[Backup Tool](../cmd/soundtouch-backup/README.md)** - Back up your cloud account and speaker data before shutdown
|
||||
- **[SoundTouch Service Guide](guides/SOUNDTOUCH-SERVICE.md)** - Advanced service configuration
|
||||
|
||||
## 📋 Essential Documentation
|
||||
@@ -18,7 +17,7 @@ Welcome to the documentation for the Bose SoundTouch Toolkit. This comprehensive
|
||||
The documentation is organized into three main categories:
|
||||
|
||||
### 1. **User Guides** - For everyday users migrating and managing devices
|
||||
### 2. **Technical Reference** - For developers and advanced configuration
|
||||
### 2. **Technical Reference** - For developers and advanced configuration
|
||||
### 3. **Concept Documentation** - For contributors and system architects
|
||||
|
||||
## 🗂 Documentation Structure
|
||||
@@ -41,7 +40,6 @@ The documentation is organized into three main categories:
|
||||
### Advanced Features
|
||||
- [MAC Address Mapping](guides/MAC-ADDRESS-MAPPING.md) - Device identification
|
||||
- [CLI Reference](guides/CLI-REFERENCE.md) - Command-line tools
|
||||
- [Backup Tool](../cmd/soundtouch-backup/README.md) - Cloud account and speaker data backup
|
||||
- [IoT Implementation Guide](guides/IOT-IMPLEMENTATION-GUIDE.md) - IoT integrations
|
||||
- [MQTT Integration Design](guides/MQTT-INTEGRATION-DESIGN.md) - MQTT setup
|
||||
|
||||
@@ -49,7 +47,6 @@ The documentation is organized into three main categories:
|
||||
|
||||
### API Documentation
|
||||
- [API Endpoints](reference/API-ENDPOINTS.md) - REST API reference
|
||||
- [Spotify Account Addition](reference/spotify-account-addition.md) - Technical requests for Spotify
|
||||
- [WebSocket Events](reference/WEBSOCKET-EVENTS.md) - Real-time events
|
||||
- [Zone Management](reference/ZONE-MANAGEMENT.md) - Multi-room control
|
||||
- [Preset Management](reference/PRESET-MANAGEMENT.md) - Preset operations
|
||||
|
||||
@@ -15,14 +15,14 @@ The current request recording system has fundamental issues when dealing with re
|
||||
|
||||
**Local Recording** (complete):
|
||||
```http
|
||||
### POST /v1/scmudc/AABBCCDDEEFF
|
||||
POST /v1/scmudc/AABBCCDDEEFF
|
||||
### POST /v1/scmudc/A81B6A536A98
|
||||
POST /v1/scmudc/A81B6A536A98
|
||||
Host: events.api.bosecm.com
|
||||
Content-Type: text/json; charset=utf-8
|
||||
Content-Length: 587
|
||||
Authorization: Bearer jGwEmFWr...
|
||||
|
||||
{"envelope":{"monoTime":234906,"payloadProtocolVersion":"3.1","payloadType":"scmudc","protocolVersion":"1.0","time":"2026-02-25T23:03:14.976349+00:00","uniqueId":"AABBCCDDEEFF"},"payload":{"deviceInfo":{"boseID":"1000001","deviceID":"AABBCCDDEEFF","deviceType":"SoundTouch 10","serialNumber":"I6332527703739342000020","softwareVersion":"27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29","systemSerialNumber":"069231P63364828AE"},"events":[{"data":{"play-state":"PAUSE_STATE"},"monoTime":234904,"time":"2026-02-25T23:03:14.973466+00:00","type":"play-state-changed"}]}}
|
||||
{"envelope":{"monoTime":234906,"payloadProtocolVersion":"3.1","payloadType":"scmudc","protocolVersion":"1.0","time":"2026-02-25T23:03:14.976349+00:00","uniqueId":"A81B6A536A98"},"payload":{"deviceInfo":{"boseID":"3230304","deviceID":"A81B6A536A98","deviceType":"SoundTouch 10","serialNumber":"I6332527703739342000020","softwareVersion":"27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29","systemSerialNumber":"069231P63364828AE"},"events":[{"data":{"play-state":"PAUSE_STATE"},"monoTime":234904,"time":"2026-02-25T23:03:14.973466+00:00","type":"play-state-changed"}]}}
|
||||
|
||||
{% raw %}
|
||||
> {%
|
||||
@@ -33,8 +33,8 @@ Authorization: Bearer jGwEmFWr...
|
||||
|
||||
**Mirror Recording** (missing body):
|
||||
```http
|
||||
### POST /v1/scmudc/AABBCCDDEEFF
|
||||
POST /v1/scmudc/AABBCCDDEEFF
|
||||
### POST /v1/scmudc/A81B6A536A98
|
||||
POST /v1/scmudc/A81B6A536A98
|
||||
Host: events.api.bosecm.com
|
||||
Content-Type: text/json; charset=utf-8
|
||||
Content-Length: 587
|
||||
|
||||
@@ -59,8 +59,8 @@ Based on analysis of recorded data:
|
||||
|
||||
### Before (Raw)
|
||||
```http
|
||||
### POST /v1/scmudc/AABBCCDDEEFF
|
||||
POST /v1/scmudc/AABBCCDDEEFF
|
||||
### POST /v1/scmudc/A81B6A536A98
|
||||
POST /v1/scmudc/A81B6A536A98
|
||||
Host: events.api.bosecm.com
|
||||
...
|
||||
|
||||
@@ -69,7 +69,7 @@ Host: events.api.bosecm.com
|
||||
|
||||
### After (Enriched)
|
||||
```http
|
||||
### POST /v1/scmudc/AABBCCDDEEFF
|
||||
### POST /v1/scmudc/A81B6A536A98
|
||||
// Origin: Internal System (device)
|
||||
// Action: play-item
|
||||
// Command: Billie Eilish - bad guy (instrumental version)
|
||||
@@ -87,7 +87,7 @@ Host: events.api.bosecm.com
|
||||
// <itemName>Billie Eilish - bad guy (instrumental version)</itemName>
|
||||
// <containerArt>https://i.scdn.co/image/ab67616d0000b273...</containerArt>
|
||||
// </ContentItem>
|
||||
POST /v1/scmudc/AABBCCDDEEFF
|
||||
POST /v1/scmudc/A81B6A536A98
|
||||
...
|
||||
|
||||
{% raw %}
|
||||
|
||||
@@ -121,7 +121,7 @@ sa.GetUnavailableServiceCount()
|
||||
### Basic Usage
|
||||
|
||||
```go
|
||||
client := client.NewClientFromHost("192.0.2.100")
|
||||
client := client.NewClientFromHost("192.168.1.100")
|
||||
|
||||
serviceAvailability, err := client.GetServiceAvailability()
|
||||
if err != nil {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 🎉 Introducing SoundTouch Service: Local Cloud Service Emulation
|
||||
|
||||
**Date**: February 2026
|
||||
**Version**: v2.0.0+
|
||||
**Date**: January 2024
|
||||
**Version**: v2.0.0+
|
||||
**Status**: Production Ready
|
||||
|
||||
## What's New?
|
||||
@@ -68,7 +68,7 @@ Our implementation is heavily inspired by and based on [SoundCork](https://githu
|
||||
|
||||
**Key contributions from SoundCork:**
|
||||
- Service emulation architecture
|
||||
- BMX/Marge endpoint discovery
|
||||
- BMX/Marge endpoint discovery
|
||||
- Device migration strategies
|
||||
- Python implementation reference
|
||||
|
||||
@@ -137,7 +137,7 @@ LOG_PROXY_BODY=true soundtouch-service
|
||||
## 🚀 Future Plans
|
||||
|
||||
- **Docker Images**: Official container images for easy deployment
|
||||
- **Cluster Support**: Multi-instance deployment for high availability
|
||||
- **Cluster Support**: Multi-instance deployment for high availability
|
||||
- **Advanced Analytics**: Machine learning-powered usage insights
|
||||
- **Extended Protocol Support**: Additional Bose protocol implementations
|
||||
- **Mobile App**: Companion mobile application for device management
|
||||
|
||||
@@ -3,19 +3,12 @@
|
||||
* [Introduction](README.md)
|
||||
|
||||
## User Guides
|
||||
* [Device-Local Install Journeys](DEVICE-LOCAL-INSTALL.md)
|
||||
* [Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)
|
||||
* [Self-Hosting AfterTouch](guides/SELF-HOSTING.md)
|
||||
* [Connecting Music Services](guides/MUSIC-SERVICES.md)
|
||||
* [Migration & Safety Guide](guides/MIGRATION-SAFETY.md)
|
||||
* [CLI Reference](guides/CLI-REFERENCE.md)
|
||||
* [Backup Tool](../cmd/soundtouch-backup/README.md)
|
||||
* [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)
|
||||
* [Deployment](guides/DEPLOYMENT.md)
|
||||
@@ -35,12 +28,10 @@
|
||||
## Technical Reference
|
||||
* [API Cookbook](reference/API-COOKBOOK.md)
|
||||
* [API Endpoints](reference/API-ENDPOINTS.md)
|
||||
* [Spotify Account Addition](reference/spotify-account-addition.md)
|
||||
* [Cloud API Emulation](reference/CLOUD-API.md)
|
||||
* [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)
|
||||
@@ -55,9 +46,6 @@
|
||||
* [Request Recording](REQUEST_RECORDING_CONCEPT.md)
|
||||
* [Spotify Priming Strategy](concepts/spotify-priming-strategy.md)
|
||||
* [Spotify OAuth](concepts/spotify-oauth.md)
|
||||
* [Encrypted Export](concepts/ENCRYPTED-EXPORT.md)
|
||||
* [Diagnostic Export (Maintainer Setup)](DIAGNOSTIC-EXPORT.md)
|
||||
* [soundtouch-web Roadmap](soundtouch-web-roadmap.md)
|
||||
|
||||
## Analysis & Research
|
||||
* [API Coverage Analysis](analysis/API-COVERAGE.md)
|
||||
@@ -65,25 +53,11 @@
|
||||
* [Upstream URLs](analysis/UPSTREAM-URLS.md)
|
||||
* [Anonymization Summary](analysis/ANONYMIZATION-SUMMARY.md)
|
||||
* [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md)
|
||||
* [Telnet (Port 17000) Migration Method](analysis/TELNET-MIGRATION-METHOD.md)
|
||||
* [Telnet Command Reference](analysis/TELNET-COMMAND-REFERENCE.md)
|
||||
* [Setup WebSocket Experiment](analysis/SETUP-WEBSOCKET-EXPERIMENT.md)
|
||||
* [Factory Reset Protocol](analysis/FACTORY-RESET-PROTOCOL.md)
|
||||
* [Wiki API Comparison](analysis/WIKI-COMPARISON.md)
|
||||
* [IoT Config Summary](analysis/IOT-CONFIG-SUMMARY.md)
|
||||
* [IoT Configuration Analysis](analysis/IOT-CONFIGURATION-ANALYSIS.md)
|
||||
* [Bose Lab Runbook](analysis/BOSE-LAB-RUNBOOK.md)
|
||||
* [Missing Routes Spotify](analysis/MISSING-ROUTES-SPOTIFY.md)
|
||||
* [Bose App ADB Emulator](analysis/BOSE-APP-ADB-Emulator.md)
|
||||
* [Community Tools](analysis/bose-soundtouch-community-tools.md)
|
||||
|
||||
## Parity Analysis
|
||||
* [Parity Improvements](PARITY-IMPROVEMENTS.md)
|
||||
* [Parity SoundCork](PARITY-SOUNDCORK.md)
|
||||
* [Parity OpenCloudTouch](PARITY-OPENCLOUDTOUCH.md)
|
||||
|
||||
## Appendix (Other Documents)
|
||||
* [External Services Abstraction](EXTERNAL-SERVICES-ABSTRACTION.md)
|
||||
* [API Navigation Reference](API-NAVIGATION-REFERENCE.md)
|
||||
* [Claude Instructions](CLAUDE.md)
|
||||
* [Content Selection Implementation](CONTENT-SELECTION-IMPLEMENTATION.md)
|
||||
@@ -107,5 +81,3 @@
|
||||
* [Power On Implementation Guide](power-on-implementation-guide.md)
|
||||
* [SCMUDC Events Analysis](scmudc-events-analysis.md)
|
||||
* [Parity Improvements](PARITY-IMPROVEMENTS.md)
|
||||
* [Parity SoundCork](PARITY-SOUNDCORK.md)
|
||||
* [Stockholm Port Guide](stockholm-port-guide.md)
|
||||
|
||||
@@ -275,8 +275,8 @@ Returns detected UPnP/DLNA media servers.
|
||||
**Response Example:**
|
||||
```xml
|
||||
<ListMediaServersResponse>
|
||||
<media_server id="2f402f80-da50-11e1-9b23-123456789012" mac="0017886e13fe" ip="192.0.2.4" manufacturer="Signify" model_name="Philips hue bridge 2015" friendly_name="Hue Bridge (192.0.2.4)" model_description="Philips hue Personal Wireless Lighting" location="http://192.0.2.4:80/description.xml" />
|
||||
<media_server id="d09708a1-5953-44bc-a413-123456789012" mac="S-1-5-21-240303764-901663538-1234567890-1001" ip="192.0.2.5" manufacturer="Microsoft Corporation" model_name="Windows Media Player Sharing" friendly_name="My NAS Media Library" model_description="" location="http://192.0.2.5:2869/upnphost/udhisapi.dll?content=uuid:d09708a1-5953-44bc-a413-123456789012" />
|
||||
<media_server id="2f402f80-da50-11e1-9b23-123456789012" mac="0017886e13fe" ip="192.168.1.4" manufacturer="Signify" model_name="Philips hue bridge 2015" friendly_name="Hue Bridge (192.168.1.4)" model_description="Philips hue Personal Wireless Lighting" location="http://192.168.1.4:80/description.xml" />
|
||||
<media_server id="d09708a1-5953-44bc-a413-123456789012" mac="S-1-5-21-240303764-901663538-1234567890-1001" ip="192.168.1.5" manufacturer="Microsoft Corporation" model_name="Windows Media Player Sharing" friendly_name="My NAS Media Library" model_description="" location="http://192.168.1.5:2869/upnphost/udhisapi.dll?content=uuid:d09708a1-5953-44bc-a413-123456789012" />
|
||||
</ListMediaServersResponse>
|
||||
```
|
||||
|
||||
@@ -641,15 +641,15 @@ Gets current stereo pair configuration.
|
||||
<groupRole>
|
||||
<deviceId>9070658C9D4A</deviceId>
|
||||
<role>LEFT</role>
|
||||
<ipAddress>192.0.2.131</ipAddress>
|
||||
<ipAddress>192.168.1.131</ipAddress>
|
||||
</groupRole>
|
||||
<groupRole>
|
||||
<deviceId>F45EAB3115DA</deviceId>
|
||||
<role>RIGHT</role>
|
||||
<ipAddress>192.0.2.134</ipAddress>
|
||||
<ipAddress>192.168.1.134</ipAddress>
|
||||
</groupRole>
|
||||
</roles>
|
||||
<senderIPAddress>192.0.2.131</senderIPAddress>
|
||||
<senderIPAddress>192.168.1.131</senderIPAddress>
|
||||
<status>GROUP_OK</status>
|
||||
</group>
|
||||
```
|
||||
@@ -671,12 +671,12 @@ Creates new stereo pair group.
|
||||
<groupRole>
|
||||
<deviceId>9070658C9D4A</deviceId>
|
||||
<role>LEFT</role>
|
||||
<ipAddress>192.0.2.131</ipAddress>
|
||||
<ipAddress>192.168.1.131</ipAddress>
|
||||
</groupRole>
|
||||
<groupRole>
|
||||
<deviceId>F45EAB3115DA</deviceId>
|
||||
<role>RIGHT</role>
|
||||
<ipAddress>192.0.2.134</ipAddress>
|
||||
<ipAddress>192.168.1.134</ipAddress>
|
||||
</groupRole>
|
||||
</roles>
|
||||
</group>
|
||||
@@ -707,12 +707,12 @@ Updates stereo pair group name.
|
||||
<groupRole>
|
||||
<deviceId>9070658C9D4A</deviceId>
|
||||
<role>LEFT</role>
|
||||
<ipAddress>192.0.2.131</ipAddress>
|
||||
<ipAddress>192.168.1.131</ipAddress>
|
||||
</groupRole>
|
||||
<groupRole>
|
||||
<deviceId>F45EAB3115DA</deviceId>
|
||||
<role>RIGHT</role>
|
||||
<ipAddress>192.0.2.134</ipAddress>
|
||||
<ipAddress>192.168.1.134</ipAddress>
|
||||
</groupRole>
|
||||
</roles>
|
||||
</group>
|
||||
@@ -762,7 +762,7 @@ Returns network status configuration.
|
||||
<name>eth0</name>
|
||||
<mac-addr>1004567890AA</mac-addr>
|
||||
<bindings>
|
||||
<ipv4address>192.0.2.131</ipv4address>
|
||||
<ipv4address>192.168.1.131</ipv4address>
|
||||
</bindings>
|
||||
<running>true</running>
|
||||
<kind>Wireless</kind>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
{%- comment -%}
|
||||
Render Mermaid diagrams in docs pages.
|
||||
|
||||
Markdown ```mermaid fenced blocks are emitted by Kramdown as
|
||||
<pre><code class="language-mermaid">…</code></pre>, but Mermaid only
|
||||
auto-renders elements with class="mermaid". This snippet rewrites the
|
||||
pre/code nodes into div.mermaid before initialising the library.
|
||||
|
||||
Loaded as an ES module from the jsDelivr CDN so we don't have to vendor
|
||||
the library into the repo. Pinned to a major version for cache stability.
|
||||
{%- endcomment -%}
|
||||
<script type="module">
|
||||
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
|
||||
|
||||
document.querySelectorAll('pre > code.language-mermaid').forEach((code) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'mermaid';
|
||||
div.textContent = code.textContent;
|
||||
code.parentElement.replaceWith(div);
|
||||
});
|
||||
|
||||
mermaid.initialize({ startOnLoad: false, securityLevel: 'strict' });
|
||||
mermaid.run();
|
||||
</script>
|
||||
@@ -1,78 +1,115 @@
|
||||
# Placeholder values for examples
|
||||
# Data Anonymization Summary
|
||||
|
||||
This repo is public. Documentation, READMEs, example configs, and test
|
||||
fixtures must never carry real LAN IPs, real device MACs, real Bose
|
||||
account IDs, or personal device names from any maintainer or
|
||||
contributor.
|
||||
This document summarizes all changes made to anonymize personal and specific data throughout the Bose SoundTouch Go client codebase.
|
||||
|
||||
This file is the **canonical mapping table** for the placeholders we
|
||||
use across the codebase. Use these values in new examples and tests.
|
||||
## Overview
|
||||
|
||||
## Placeholder mapping
|
||||
All specific IP addresses, device IDs, device names, and other potentially personal information have been replaced with generic, example values to protect privacy while maintaining the functionality and usefulness of the documentation and test examples.
|
||||
|
||||
| Concept | Placeholder |
|
||||
|------------------------|------------------------------------------------------------------------|
|
||||
| Example IP (primary) | `192.0.2.10` |
|
||||
| Example IP (secondary) | `192.0.2.11` |
|
||||
| Example IP (third) | `192.0.2.12` |
|
||||
| Network / CIDR | `192.0.2.0/24` |
|
||||
| External / non-LAN IP | `198.51.100.10` or `203.0.113.10` |
|
||||
| Gateway IP | `192.0.2.1` |
|
||||
| Device MAC (primary) | `AA:BB:CC:DD:EE:FF` (no separator: `AABBCCDDEEFF`) |
|
||||
| Device MAC (secondary) | `AA:BB:CC:DD:EE:01` (no separator: `AABBCCDDEE01`) |
|
||||
| Device ID (some XML) | `ABCD1234EFGH` — legacy placeholder still in some fixtures |
|
||||
| Device display name | `Living Room SoundTouch` / `Kitchen SoundTouch` / `Bedroom SoundTouch` |
|
||||
| Bose account ID | `1000001` / `1000002` |
|
||||
## Changes Made
|
||||
|
||||
`192.0.2.0/24`, `198.51.100.0/24`, and `203.0.113.0/24` are reserved
|
||||
by [RFC 5737](https://www.rfc-editor.org/rfc/rfc5737) exclusively for
|
||||
documentation. They won't ever route on a real network, so readers
|
||||
know at a glance that they're placeholders and not addresses they
|
||||
need to think about.
|
||||
### IP Addresses
|
||||
|
||||
`AA:BB:CC:DD:EE:FF` is the conventional "locally administered" MAC
|
||||
placeholder used in many vendor docs.
|
||||
**Original → Anonymized:**
|
||||
- `192.168.178.35` → `192.168.1.10`
|
||||
- `192.168.178.28` → `192.168.1.10`
|
||||
- `192.168.1.100` → `192.168.1.10`
|
||||
- `192.168.1.101` → `192.168.1.11`
|
||||
- `192.168.1.102` → `192.168.1.12`
|
||||
|
||||
`1000001` / `1000002` are well outside the range of real Bose customer
|
||||
account IDs (which are typically 6–7 digits with no leading 1 0 0…
|
||||
pattern) but stay numeric for parsers that expect integer-looking IDs.
|
||||
### Device IDs
|
||||
|
||||
## Why we don't use 192.168.1.x
|
||||
**Original → Anonymized:**
|
||||
- `A81B6A536A98` → `ABCD1234EFGH`
|
||||
- `1234567890AB` → `ABCD1234EFGH`
|
||||
- `1234567890AC` → `ABCD1234EFGH`
|
||||
|
||||
An earlier anonymisation pass used `192.168.1.x` as its target. That
|
||||
range is RFC-1918 private space — perfectly valid on real networks,
|
||||
which means a reader can't tell whether `192.168.1.10` is a
|
||||
placeholder or a documented LAN address. RFC-5737 ranges fix that:
|
||||
because they're reserved for documentation only, any reader knows on
|
||||
sight that they don't represent a real device.
|
||||
### Device Names
|
||||
|
||||
The `.md` / `.txt` portion of the `192.168.1.*` → `192.0.2.x` sweep
|
||||
is complete. Test files (`.go` / `.xml` / `.http`) still carry the
|
||||
old placeholder pending Phase 2 in the audit at
|
||||
`_/RFC-5737-cleanup/assessment.md`.
|
||||
**Original → Anonymized:**
|
||||
- `Sound Machinechen` → `My SoundTouch Device`
|
||||
|
||||
## How to audit before committing
|
||||
### MAC Addresses
|
||||
|
||||
When you add or edit examples that contain IP addresses, MACs, account
|
||||
IDs, or device names, mentally answer: "would I be comfortable
|
||||
publishing this on a postcard?" If not, swap in a placeholder from
|
||||
the table above.
|
||||
**Original → Anonymized:**
|
||||
- `A81B6A536A98` → `AA:BB:CC:DD:EE:FF`
|
||||
- `A81B6A849D99` → `AA:BB:CC:DD:EE:FF`
|
||||
- `A8:1B:6A:53:6A:98` → `AA:BB:CC:DD:EE:FF`
|
||||
- `A8:1B:6A:84:9D:99` → `AA:BB:CC:DD:EE:01`
|
||||
|
||||
Some patterns flag clearly-non-placeholder values:
|
||||
## Files Modified
|
||||
|
||||
```sh
|
||||
# Any IPv4 not in a documentation range or the 192.168.1.x default:
|
||||
git ls-files | xargs grep -hoE "[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+" 2>/dev/null \
|
||||
| grep -vE "^(192\.0\.2\.|198\.51\.100\.|203\.0\.113\.|0\.0\.0\.0|127\.0\.0\.1|255\.255\.255\.255|192\.168\.1\.[0-9])" \
|
||||
| sort -u
|
||||
### Documentation Files
|
||||
- `README.md` - Updated all IP addresses and device examples
|
||||
- `Makefile` - Updated example IP addresses in help text
|
||||
- `docs/SYSTEM-ENDPOINTS.md` - Anonymized all example data
|
||||
- `docs/VOLUME-CONTROLS.md` - Updated device IDs and IP addresses
|
||||
- `docs/KEY-CONTROLS.md` - Updated IP addresses
|
||||
- `docs/BASS-CONTROLS.md` - Updated device IDs
|
||||
- `docs/HOST-PORT-PARSING.md` - Updated IP addresses and device names
|
||||
- `docs/STATUS.md` - Updated IP addresses
|
||||
|
||||
# Any colon-separated MAC that doesn't start with AA:BB:CC:DD:EE:
|
||||
git ls-files | xargs grep -hoE "[0-9A-F]{2}(:[0-9A-F]{2}){5}" 2>/dev/null \
|
||||
| grep -vE "^AA:BB:CC:DD:EE:" \
|
||||
| sort -u
|
||||
```
|
||||
### Source Code Files
|
||||
- `cmd/soundtouch-cli/main.go` - Updated all example IP addresses in help text
|
||||
- `cmd/soundtouch-cli/main_test.go` - Updated test IP addresses
|
||||
|
||||
If real values slip into a commit, treat it as a sanitisation task:
|
||||
revert or fix, then audit nearby files for sibling leaks. Personal
|
||||
device names and Bose account IDs don't have a regex-friendly shape —
|
||||
catch those at review time.
|
||||
### Test Data Files
|
||||
- `pkg/client/testdata/info_response.xml` - Updated device ID, name, and network info
|
||||
- `pkg/client/testdata/info_response_st20.xml` - Updated device ID and network info
|
||||
- `pkg/client/testdata/capabilities_response.xml` - Updated device ID
|
||||
- `pkg/client/testdata/name_response.xml` - Updated device name
|
||||
- `pkg/client/testdata/networkinfo_response.xml` - Updated device ID and network info
|
||||
- `pkg/client/testdata/clockdisplay_response.xml` - Updated device ID
|
||||
|
||||
### Test Files
|
||||
- `pkg/client/client_test.go` - Updated device IDs, names, and IP addresses
|
||||
- `pkg/client/system_test.go` - Updated device IDs and IP addresses
|
||||
- `pkg/client/balance_test.go` - Updated device IDs in test responses
|
||||
- `pkg/client/bass_test.go` - Updated device IDs in test responses
|
||||
- `pkg/models/networkinfo_test.go` - Updated device IDs and network info
|
||||
|
||||
## Anonymization Strategy
|
||||
|
||||
### IP Addresses
|
||||
- Used standard RFC 1918 private IP ranges (192.168.1.x)
|
||||
- Maintained realistic network structure (same subnet for related devices)
|
||||
- Used sequential numbering (.10, .11, .12) for clarity
|
||||
|
||||
### Device IDs
|
||||
- Used generic alphanumeric pattern `ABCD1234EFGH`
|
||||
- Maintained consistent usage across all files
|
||||
- Preserved original length and format
|
||||
|
||||
### Device Names
|
||||
- Used generic but descriptive names like "My SoundTouch Device"
|
||||
- Removed any potentially personal identifiers
|
||||
|
||||
### MAC Addresses
|
||||
- Used standard placeholder format `AA:BB:CC:DD:EE:FF`
|
||||
- Used sequential variants (EE:01) when multiple addresses needed
|
||||
- Maintained proper MAC address format
|
||||
|
||||
## Verification
|
||||
|
||||
After anonymization:
|
||||
- ✅ All tests continue to pass
|
||||
- ✅ All builds succeed
|
||||
- ✅ Documentation remains accurate and useful
|
||||
- ✅ No personal data remains in examples
|
||||
- ✅ Functionality is preserved
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Privacy Protection**: No personal network information exposed
|
||||
2. **Professional Examples**: Clean, generic examples suitable for public documentation
|
||||
3. **Consistency**: Uniform use of example data across all files
|
||||
4. **Maintainability**: Easy to identify example vs. real data
|
||||
|
||||
## Standards Used
|
||||
|
||||
- **IP Addresses**: RFC 1918 private ranges (192.168.1.x/24)
|
||||
- **Device IDs**: Generic alphanumeric placeholders
|
||||
- **MAC Addresses**: Standard placeholder format
|
||||
- **Device Names**: Generic descriptive names
|
||||
|
||||
All changes maintain the original functionality while ensuring no personal or specific network information is exposed in the codebase.
|
||||
@@ -1,7 +1,7 @@
|
||||
# Bose SoundTouch API Coverage Analysis
|
||||
|
||||
**Last Updated:** February 2026
|
||||
**API Version:** Official Bose SoundTouch Web API v1.0
|
||||
**Last Updated:** January 2025
|
||||
**API Version:** Official Bose SoundTouch Web API v1.0
|
||||
**Implementation Status:** 100% Official Coverage + Extended Features
|
||||
|
||||
## Executive Summary
|
||||
@@ -10,7 +10,7 @@ This Go implementation provides **complete coverage** of the Bose SoundTouch Web
|
||||
|
||||
### Key Findings
|
||||
- ✅ **All essential user functionality implemented**
|
||||
- ✅ **Complete zone management implementation**
|
||||
- ✅ **Complete zone management implementation**
|
||||
- ✅ **Real-time WebSocket event system**
|
||||
- ✅ **Extended features beyond official specification**
|
||||
- ✅ **Complete advanced audio controls implementation**
|
||||
@@ -22,42 +22,42 @@ This Go implementation provides **complete coverage** of the Bose SoundTouch Web
|
||||
|
||||
### Implemented Endpoints: 20/21 (95%)
|
||||
|
||||
| Endpoint | Method | Status | Implementation | Notes |
|
||||
|------------------------------|----------|----------------------|--------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------|
|
||||
| `/key` | POST | ✅ **Complete** | `SendKey()`, `SendKeyPress()`, `SendKeyRelease()` | Full key simulation with press/release states |
|
||||
| `/select` | POST | ✅ **Complete** | `SelectSource()`, `SelectSpotify()`, etc. | Source selection with validation |
|
||||
| `/sources` | GET | ✅ **Complete** | `GetSources()` | Available audio sources |
|
||||
| `/bassCapabilities` | GET | ✅ **Complete** | `GetBassCapabilities()` | Bass capability detection |
|
||||
| `/bass` | GET/POST | ✅ **Complete** | `GetBass()`, `SetBass()`, `SetBassSafe()` | Bass control (-9 to +9) with safety limits |
|
||||
| `/getZone` | GET | ✅ **Complete** | `GetZone()`, `GetZoneStatus()`, `GetZoneMembers()` | Multiroom zone information |
|
||||
| `/setZone` | POST | ✅ **Complete** | `SetZone()`, `CreateZone()`, `AddToZone()`, `RemoveFromZone()` | Zone configuration and management |
|
||||
| `/now_playing` | GET | ✅ **Complete** | `GetNowPlaying()` | Current playback status with full metadata |
|
||||
| `/trackInfo` | GET | ❌ **Non-functional** | `GetTrackInfo()` | Documented but times out on real devices |
|
||||
| `/volume` | GET/POST | ✅ **Complete** | `GetVolume()`, `SetVolume()`, `SetVolumeSafe()` | Volume and mute control with safety features |
|
||||
| `/presets` | GET | ✅ **Complete** | `GetPresets()`, `GetNextAvailablePresetSlot()` | Preset configurations (read-only per API spec) |
|
||||
| `/info` | GET | ✅ **Complete** | `GetDeviceInfo()` | Device information and capabilities |
|
||||
| `/name` | POST | ✅ **Complete** | `SetName()` | Device name modification |
|
||||
| `/capabilities` | GET | ✅ **Complete** | `GetCapabilities()` | Device feature capabilities |
|
||||
| `/addZoneSlave` | POST | ✅ **Complete** | `AddZoneSlave()`, `AddZoneSlaveByDeviceID()` | Individual device addition to zone |
|
||||
| `/removeZoneSlave` | POST | ✅ **Complete** | `RemoveZoneSlave()`, `RemoveZoneSlaveByDeviceID()` | Individual device removal from zone |
|
||||
| `/audiodspcontrols` | GET/POST | ✅ **Complete** | `GetAudioDSPControls()`, `SetAudioDSPControls()`, `SetAudioMode()`, `SetVideoSyncAudioDelay()` | DSP audio modes and video sync delay |
|
||||
| `/audioproducttonecontrols` | GET/POST | ✅ **Complete** | `GetAudioProductToneControls()`, `SetAudioProductToneControls()`, `SetAdvancedBass()`, `SetAdvancedTreble()` | Advanced bass/treble controls |
|
||||
| `/audioproductlevelcontrols` | GET/POST | ✅ **Complete** | `GetAudioProductLevelControls()`, `SetAudioProductLevelControls()`, `SetFrontCenterSpeakerLevel()`, `SetRearSurroundSpeakersLevel()` | Speaker level controls |
|
||||
| `/speaker` | POST | ✅ **Complete** | `PlayTTS()`, `PlayURL()`, `PlayCustom()` | TTS and URL content playback for notifications |
|
||||
| `/playNotification` | GET | ✅ **Complete** | `PlayNotificationBeep()` | Simple notification beep sound |
|
||||
| Endpoint | Method | Status | Implementation | Notes |
|
||||
|----------|--------|--------|----------------|--------|
|
||||
| `/key` | POST | ✅ **Complete** | `SendKey()`, `SendKeyPress()`, `SendKeyRelease()` | Full key simulation with press/release states |
|
||||
| `/select` | POST | ✅ **Complete** | `SelectSource()`, `SelectSpotify()`, etc. | Source selection with validation |
|
||||
| `/sources` | GET | ✅ **Complete** | `GetSources()` | Available audio sources |
|
||||
| `/bassCapabilities` | GET | ✅ **Complete** | `GetBassCapabilities()` | Bass capability detection |
|
||||
| `/bass` | GET/POST | ✅ **Complete** | `GetBass()`, `SetBass()`, `SetBassSafe()` | Bass control (-9 to +9) with safety limits |
|
||||
| `/getZone` | GET | ✅ **Complete** | `GetZone()`, `GetZoneStatus()`, `GetZoneMembers()` | Multiroom zone information |
|
||||
| `/setZone` | POST | ✅ **Complete** | `SetZone()`, `CreateZone()`, `AddToZone()`, `RemoveFromZone()` | Zone configuration and management |
|
||||
| `/now_playing` | GET | ✅ **Complete** | `GetNowPlaying()` | Current playback status with full metadata |
|
||||
| `/trackInfo` | GET | ❌ **Non-functional** | `GetTrackInfo()` | Documented but times out on real devices |
|
||||
| `/volume` | GET/POST | ✅ **Complete** | `GetVolume()`, `SetVolume()`, `SetVolumeSafe()` | Volume and mute control with safety features |
|
||||
| `/presets` | GET | ✅ **Complete** | `GetPresets()`, `GetNextAvailablePresetSlot()` | Preset configurations (read-only per API spec) |
|
||||
| `/info` | GET | ✅ **Complete** | `GetDeviceInfo()` | Device information and capabilities |
|
||||
| `/name` | POST | ✅ **Complete** | `SetName()` | Device name modification |
|
||||
| `/capabilities` | GET | ✅ **Complete** | `GetCapabilities()` | Device feature capabilities |
|
||||
| `/addZoneSlave` | POST | ✅ **Complete** | `AddZoneSlave()`, `AddZoneSlaveByDeviceID()` | Individual device addition to zone |
|
||||
| `/removeZoneSlave` | POST | ✅ **Complete** | `RemoveZoneSlave()`, `RemoveZoneSlaveByDeviceID()` | Individual device removal from zone |
|
||||
| `/audiodspcontrols` | GET/POST | ✅ **Complete** | `GetAudioDSPControls()`, `SetAudioDSPControls()`, `SetAudioMode()`, `SetVideoSyncAudioDelay()` | DSP audio modes and video sync delay |
|
||||
| `/audioproducttonecontrols` | GET/POST | ✅ **Complete** | `GetAudioProductToneControls()`, `SetAudioProductToneControls()`, `SetAdvancedBass()`, `SetAdvancedTreble()` | Advanced bass/treble controls |
|
||||
| `/audioproductlevelcontrols` | GET/POST | ✅ **Complete** | `GetAudioProductLevelControls()`, `SetAudioProductLevelControls()`, `SetFrontCenterSpeakerLevel()`, `SetRearSurroundSpeakersLevel()` | Speaker level controls |
|
||||
| `/speaker` | POST | ✅ **Complete** | `PlayTTS()`, `PlayURL()`, `PlayCustom()` | TTS and URL content playback for notifications |
|
||||
| `/playNotification` | GET | ✅ **Complete** | `PlayNotificationBeep()` | Simple notification beep sound |
|
||||
|
||||
### Non-functional Endpoints: 1/21 (5%)
|
||||
|
||||
| Endpoint | Method | Status | Reason | Impact |
|
||||
|--------------|--------|----------------------|------------------------------------------------------|---------------------------------------|
|
||||
| `/trackInfo` | GET | ❌ **Non-functional** | Times out on real devices (AllegroWebserver timeout) | **None** - Use `/now_playing` instead |
|
||||
| Endpoint | Method | Status | Reason | Impact |
|
||||
|----------|--------|--------|--------|---------|
|
||||
| `/trackInfo` | GET | ❌ **Non-functional** | Times out on real devices (AllegroWebserver timeout) | **None** - Use `/now_playing` instead |
|
||||
|
||||
### Official Endpoints Not Supported by API: 1
|
||||
|
||||
| Endpoint | Method | Status | Official API Status |
|
||||
|-----------------|--------|-------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `/storePreset` | POST | ✅ **IMPLEMENTED** | Found via [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API) (official docs marked `/presets` POST as "N/A") |
|
||||
| `/removePreset` | POST | ✅ **IMPLEMENTED** | Found via [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API) |
|
||||
| Endpoint | Method | Status | Official API Status |
|
||||
|----------|--------|--------|-------------------|
|
||||
| `/storePreset` | POST | ✅ **IMPLEMENTED** | Found via [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API) (official docs marked `/presets` POST as "N/A") |
|
||||
| `/removePreset` | POST | ✅ **IMPLEMENTED** | Found via [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API) |
|
||||
|
||||
---
|
||||
|
||||
@@ -67,24 +67,24 @@ This Go implementation provides **complete coverage** of the Bose SoundTouch Web
|
||||
|
||||
**Note**: The `/speaker` and `/playNotification` endpoints were discovered via the [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API) and are now part of the official coverage.
|
||||
|
||||
| Endpoint | Method | Status | Notes |
|
||||
|-----------------|----------|--------------|--------------------------------------------------------------------|
|
||||
| `/name` | GET | 🔍 **Extra** | Official API only documents POST, but GET works with real hardware |
|
||||
| `/balance` | GET/POST | 🔍 **Extra** | Stereo balance control (-50 to +50) - not in API v1.0 |
|
||||
| `/clockTime` | GET/POST | 🔍 **Extra** | Device time management - works with real devices |
|
||||
| `/clockDisplay` | GET/POST | 🔍 **Extra** | Clock display settings and brightness |
|
||||
| `/networkInfo` | GET | 🔍 **Extra** | Network connectivity information |
|
||||
| Endpoint | Method | Status | Notes |
|
||||
|----------|--------|--------|--------|
|
||||
| `/name` | GET | 🔍 **Extra** | Official API only documents POST, but GET works with real hardware |
|
||||
| `/balance` | GET/POST | 🔍 **Extra** | Stereo balance control (-50 to +50) - not in API v1.0 |
|
||||
| `/clockTime` | GET/POST | 🔍 **Extra** | Device time management - works with real devices |
|
||||
| `/clockDisplay` | GET/POST | 🔍 **Extra** | Clock display settings and brightness |
|
||||
| `/networkInfo` | GET | 🔍 **Extra** | Network connectivity information |
|
||||
|
||||
### Advanced Implementation Features
|
||||
|
||||
| Feature | Status | Description |
|
||||
|-------------------------|-----------------------|-------------------------------------------------------------------------------------------------------|
|
||||
| **WebSocket Events** | ✅ **Complete** | Real-time device state monitoring (`nowPlayingUpdated`, `volumeUpdated`, etc.) |
|
||||
| **Device Discovery** | ✅ **Complete** | UPnP/SSDP + mDNS/Bonjour automatic discovery |
|
||||
| **Safety Features** | ✅ **Enhanced** | Volume limiting, bass clamping, input validation |
|
||||
| **High-Level Zone API** | ✅ **Superior** | Fluent zone management API replacing low-level slave operations |
|
||||
| **Preset Management** | ✅ **Wiki Documented** | Full preset CRUD via `/storePreset` and `/removePreset` endpoints (found via SoundTouch Plus Wiki) |
|
||||
| **Content Navigation** | ✅ **Complete** | Browse and search content via `/navigate`, `/searchStation`, `/addStation` (via SoundTouch Plus Wiki) |
|
||||
| Feature | Status | Description |
|
||||
|---------|--------|-------------|
|
||||
| **WebSocket Events** | ✅ **Complete** | Real-time device state monitoring (`nowPlayingUpdated`, `volumeUpdated`, etc.) |
|
||||
| **Device Discovery** | ✅ **Complete** | UPnP/SSDP + mDNS/Bonjour automatic discovery |
|
||||
| **Safety Features** | ✅ **Enhanced** | Volume limiting, bass clamping, input validation |
|
||||
| **High-Level Zone API** | ✅ **Superior** | Fluent zone management API replacing low-level slave operations |
|
||||
| **Preset Management** | ✅ **Wiki Documented** | Full preset CRUD via `/storePreset` and `/removePreset` endpoints (found via SoundTouch Plus Wiki) |
|
||||
| **Content Navigation** | ✅ **Complete** | Browse and search content via `/navigate`, `/searchStation`, `/addStation` (via SoundTouch Plus Wiki) |
|
||||
|
||||
---
|
||||
|
||||
@@ -95,17 +95,17 @@ This Go implementation provides **complete coverage** of the Bose SoundTouch Web
|
||||
**Official Low-Level API:**
|
||||
```go
|
||||
// Individual slave operations (exact official API implementation)
|
||||
client.AddZoneSlave("MASTER123", "SLAVE456", "192.0.2.101")
|
||||
client.RemoveZoneSlave("MASTER123", "SLAVE456", "192.0.2.101")
|
||||
client.AddZoneSlave("MASTER123", "SLAVE456", "192.168.1.101")
|
||||
client.RemoveZoneSlave("MASTER123", "SLAVE456", "192.168.1.101")
|
||||
```
|
||||
|
||||
**Enhanced High-Level API:**
|
||||
```go
|
||||
// High-level fluent API (enhanced implementation)
|
||||
zone := client.CreateZoneWithIPs("192.0.2.100", []string{"192.0.2.101", "192.0.2.102"})
|
||||
client.AddToZone("192.0.2.100", "192.0.2.103")
|
||||
client.RemoveFromZone("192.0.2.100", "192.0.2.101")
|
||||
client.DissolveZone("192.0.2.100")
|
||||
zone := client.CreateZoneWithIPs("192.168.1.100", []string{"192.168.1.101", "192.168.1.102"})
|
||||
client.AddToZone("192.168.1.100", "192.168.1.103")
|
||||
client.RemoveFromZone("192.168.1.100", "192.168.1.101")
|
||||
client.DissolveZone("192.168.1.100")
|
||||
```
|
||||
|
||||
**Advantages:**
|
||||
@@ -221,4 +221,4 @@ The single non-functional endpoint (`/trackInfo`) is **broken on real devices**
|
||||
|
||||
**Note**: All official API endpoints are implemented. The `/trackInfo` endpoint times out on real devices but is implemented and tested.
|
||||
|
||||
**Overall Assessment: Complete** ⭐⭐⭐⭐⭐
|
||||
**Overall Assessment: Complete** ⭐⭐⭐⭐⭐
|
||||
@@ -1,364 +0,0 @@
|
||||
# Bose SoundTouch Traffic Interception Runbook
|
||||
|
||||
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)
|
||||
- Docker installed
|
||||
- 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.0.2.50`) — no extra routing is needed. Use `adb shell ping 192.0.2.50` to confirm reachability.
|
||||
|
||||
Add Android SDK tools to your PATH (add to `~/.zshrc`):
|
||||
|
||||
```bash
|
||||
export PATH=$PATH:~/Library/Android/sdk/emulator
|
||||
export PATH=$PATH:~/Library/Android/sdk/platform-tools
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Extract APK from Real Device
|
||||
|
||||
Connect your Android device via USB with USB debugging enabled.
|
||||
|
||||
```bash
|
||||
adb devices
|
||||
# note your device ID, e.g. "ABC123"
|
||||
|
||||
adb -s ABC123 shell pm path com.bose.soundtouch
|
||||
# output e.g.: package:/data/app/~~xyz/com.bose.soundtouch-abc/base.apk
|
||||
|
||||
adb -s ABC123 pull /data/app/~~xyz/com.bose.soundtouch-abc/base.apk bose.apk
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Create Android Emulator (ARM64, API 33)
|
||||
|
||||
On Apple Silicon you need an ARM64 image. Use the `avdmanager` and `sdkmanager` CLI tools.
|
||||
|
||||
```bash
|
||||
# Install the system image
|
||||
~/Library/Android/sdk/cmdline-tools/latest/bin/sdkmanager \
|
||||
"system-images;android-33;google_apis;arm64-v8a"
|
||||
|
||||
# Create the AVD
|
||||
~/Library/Android/sdk/cmdline-tools/latest/bin/avdmanager create avd \
|
||||
-n Pixel_6_API33 \
|
||||
-k "system-images;android-33;google_apis;arm64-v8a" \
|
||||
-d "pixel_6"
|
||||
```
|
||||
|
||||
Alternatively create the AVD via Android Studio Device Manager (choose "Google APIs", arm64-v8a, API 33).
|
||||
|
||||
---
|
||||
|
||||
## 3. Start Emulator with Writable System
|
||||
|
||||
```bash
|
||||
# List available AVDs
|
||||
~/Library/Android/sdk/emulator/emulator -list-avds
|
||||
|
||||
# Start with writable system partition
|
||||
~/Library/Android/sdk/emulator/emulator -avd Pixel_6_API33 -writable-system
|
||||
```
|
||||
|
||||
Wait until the emulator has fully booted, then:
|
||||
|
||||
```bash
|
||||
adb -s emulator-5554 root
|
||||
adb -s emulator-5554 shell avbctl disable-verification
|
||||
adb -s emulator-5554 reboot
|
||||
|
||||
# After reboot:
|
||||
adb -s emulator-5554 root
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Install Bose APK
|
||||
|
||||
```bash
|
||||
adb -s emulator-5554 install bose.apk
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Set Up mitmproxy
|
||||
|
||||
```bash
|
||||
# Start mitmproxy (generates CA cert on first run)
|
||||
# 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):
|
||||
|
||||
```bash
|
||||
openssl x509 -in ~/.mitmproxy/mitmproxy-ca.pem -out ~/.mitmproxy/mitmproxy-ca-cert.pem
|
||||
|
||||
# Verify it's the mitmproxy cert, not another cert:
|
||||
openssl x509 -in ~/.mitmproxy/mitmproxy-ca-cert.pem -noout -issuer
|
||||
# should show: issuer= /CN=mitmproxy/O=mitmproxy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Install mitmproxy CA Certificate in Emulator
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Set System Proxy in Emulator
|
||||
|
||||
Find your Mac's local IP:
|
||||
|
||||
```bash
|
||||
ipconfig getifaddr en0
|
||||
# e.g. 192.0.2.123
|
||||
```
|
||||
|
||||
Set the proxy:
|
||||
|
||||
```bash
|
||||
adb -s emulator-5554 shell settings put global http_proxy 192.0.2.123:8080
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Set Up Frida (via Python venv)
|
||||
|
||||
```bash
|
||||
python3 -m venv /tmp/frida-venv
|
||||
/tmp/frida-venv/bin/pip install frida==17.9.1 frida-tools==14.8.1
|
||||
```
|
||||
|
||||
Download the frida-server binary for ARM64 Android:
|
||||
|
||||
```bash
|
||||
FRIDA_VERSION=17.9.1
|
||||
|
||||
curl -L "https://github.com/frida/frida/releases/download/${FRIDA_VERSION}/frida-server-${FRIDA_VERSION}-android-arm64.xz" \
|
||||
-o /tmp/frida-server.xz
|
||||
|
||||
unxz /tmp/frida-server.xz
|
||||
mv /tmp/frida-server-${FRIDA_VERSION}-android-arm64 /tmp/frida-server
|
||||
```
|
||||
|
||||
Push to emulator and start:
|
||||
|
||||
```bash
|
||||
adb -s emulator-5554 push /tmp/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 /data/local/tmp/frida-server &
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Download SSL Bypass Scripts
|
||||
|
||||
```bash
|
||||
BASE=https://raw.githubusercontent.com/httptoolkit/frida-interception-and-unpinning/main
|
||||
|
||||
curl -L "${BASE}/config.js" -o /tmp/config.js
|
||||
curl -L "${BASE}/android/android-system-certificate-injection.js" \
|
||||
-o /tmp/android-system-certificate-injection.js
|
||||
curl -L "${BASE}/android/android-proxy-override.js" \
|
||||
-o /tmp/android-proxy-override.js
|
||||
curl -L "${BASE}/android/android-certificate-unpinning.js" \
|
||||
-o /tmp/android-certificate-unpinning.js
|
||||
curl -L "${BASE}/android/android-certificate-unpinning-fallback.js" \
|
||||
-o /tmp/android-certificate-unpinning-fallback.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Configure config.js
|
||||
|
||||
Edit `/tmp/config.js` and set:
|
||||
|
||||
```javascript
|
||||
const CERT_PEM = `<contents of ~/.mitmproxy/mitmproxy-ca-cert.pem>`;
|
||||
|
||||
const PROXY_HOST = '192.0.2.123'; // your Mac IP
|
||||
const PROXY_PORT = 8080;
|
||||
```
|
||||
|
||||
Insert the full PEM content (from `-----BEGIN CERTIFICATE-----` to `-----END CERTIFICATE-----`) between the backticks.
|
||||
|
||||
Quick check that the right cert is in place:
|
||||
|
||||
```bash
|
||||
# The issuer inside config.js should be mitmproxy, not SoundTouch
|
||||
grep -A3 "CERT_PEM" /tmp/config.js | head -5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Start Interception
|
||||
|
||||
Make sure mitmweb is running, then:
|
||||
|
||||
```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 output in the Frida REPL:
|
||||
|
||||
```
|
||||
== System certificate trust injected ==
|
||||
== Proxy system configuration overridden to 192.0.2.123:8080 ==
|
||||
== Proxy configuration overridden to 192.0.2.123:8080 ==
|
||||
== Certificate unpinning completed ==
|
||||
== Unpinning fallback auto-patcher installed ==
|
||||
```
|
||||
|
||||
Open mitmweb at `http://127.0.0.1:8081` to observe traffic live.
|
||||
|
||||
---
|
||||
|
||||
## 12. Save & Replay Recordings
|
||||
|
||||
Traffic is saved to `bose_traffic.mitm` (set via `-w` flag in step 5).
|
||||
|
||||
```bash
|
||||
# Replay/analyse a saved recording:
|
||||
mitmweb -r bose_traffic.mitm
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cleanup
|
||||
|
||||
```bash
|
||||
# Remove proxy setting from emulator
|
||||
adb -s emulator-5554 shell settings delete global http_proxy
|
||||
|
||||
# Remove venv
|
||||
rm -rf /tmp/frida-venv /tmp/frida-server /tmp/frida-server.xz
|
||||
rm /tmp/config.js /tmp/android-*.js
|
||||
|
||||
# Stop emulator
|
||||
adb -s emulator-5554 emu kill
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|-----------------------------------------|--------------------------------------------------|--------------------------------------------------------------------------------|
|
||||
| `remount failed` | ARM64 emulator doesn't support overlayfs remount | Use `/data/misc/user/0/cacerts-added/` method instead |
|
||||
| `TLS: Trust anchor not found` | Wrong certificate in config.js | Check issuer: must be mitmproxy, not SoundTouch |
|
||||
| `Chain validation failed` | Private key included in cert | Re-extract with `openssl x509 -in mitmproxy-ca.pem -out mitmproxy-ca-cert.pem` |
|
||||
| `frida-server: connection refused` | frida-server not running | Re-run `adb shell su 0 /data/local/tmp/frida-server &` |
|
||||
| frida and frida-server version mismatch | Versions must be identical | Pin both to same version (e.g. `17.9.1`) |
|
||||
| `emulator: multiple AVDs` error | Emulator already running | Kill first: `adb emu kill`, then restart with `-writable-system` |
|
||||
|
||||
---
|
||||
|
||||
## App Automation Options
|
||||
|
||||
For most traffic-recording purposes, manually operating the app while mitmproxy captures is sufficient. If you need to automate specific interactions (e.g. to repeatably capture the requests triggered by startup or a particular action), the following tools are available.
|
||||
|
||||
### Starting the App
|
||||
|
||||
```bash
|
||||
# Via app drawer: swipe up on the home screen and tap "Bose SoundTouch"
|
||||
|
||||
# Via adb monkey (simplest)
|
||||
adb -s emulator-5554 shell monkey -p com.bose.soundtouch 1
|
||||
|
||||
# Via explicit intent (if the activity name is known)
|
||||
adb -s emulator-5554 shell am start -n com.bose.soundtouch/.MainActivity
|
||||
|
||||
# Look up all activities if the name is unknown
|
||||
adb -s emulator-5554 shell dumpsys package com.bose.soundtouch | grep Activity
|
||||
```
|
||||
|
||||
### adb — sufficient for simple cases
|
||||
|
||||
```bash
|
||||
# Tap at screen coordinates
|
||||
adb shell input tap 540 960
|
||||
|
||||
# Swipe
|
||||
adb shell input swipe 540 1500 540 500
|
||||
|
||||
# Type text
|
||||
adb shell input text "mytext"
|
||||
|
||||
# Take a screenshot
|
||||
adb shell screencap /sdcard/screen.png && adb pull /sdcard/screen.png
|
||||
```
|
||||
|
||||
### UIAutomator2 — inspect UI elements
|
||||
|
||||
```bash
|
||||
# Dump the current UI hierarchy to find element IDs
|
||||
adb shell uiautomator dump /sdcard/ui.xml
|
||||
adb pull /sdcard/ui.xml
|
||||
```
|
||||
|
||||
Open `ui.xml` to find element resource IDs, then target them precisely in scripts.
|
||||
|
||||
### Appium — full scripted automation
|
||||
|
||||
```python
|
||||
from appium import webdriver
|
||||
|
||||
driver = webdriver.Remote('http://localhost:4723/wd/hub', {
|
||||
'platformName': 'Android',
|
||||
'appPackage': 'com.bose.soundtouch',
|
||||
'appActivity': '.MainActivity',
|
||||
})
|
||||
|
||||
# Find an element by resource ID and tap it
|
||||
driver.find_element('id', 'com.bose.soundtouch:id/play_button').click()
|
||||
```
|
||||
|
||||
> **Note:** `monkey` is a stress-test tool that sends random events — use it only to launch the app, not to drive specific interactions.
|
||||
@@ -1,892 +0,0 @@
|
||||
# Bose SoundTouch – Traffic Analysis Runbook
|
||||
|
||||
> **Goal:** Set up a Raspberry Pi as a transparent access point to fully observe the traffic of the Bose SoundTouch app – specifically the pairing flow with the Bose Cloud. This serves as a basis for later reverse engineering / simulation of the cloud endpoints.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Component | Details |
|
||||
|--------------------|------------------------------------------------------------------|
|
||||
| Raspberry Pi | Pi 3 or newer, Raspberry Pi OS (Bullseye, Bookworm, Trixie) |
|
||||
| Network interfaces | `eth0` → LAN cable to FritzBox, `wlan0` → own Access Point |
|
||||
| FritzBox | Unchanged, assigns an IP to the Pi via DHCP on eth0 |
|
||||
| Custom DNS Server | Already present (or see Appendix A), incl. custom CA certificate |
|
||||
| Phone | Android, connects to the Pi's Wi-Fi |
|
||||
|
||||
### Network Architecture
|
||||
|
||||
```
|
||||
Internet
|
||||
↓
|
||||
FritzBox (existing, unchanged)
|
||||
↓ LAN cable (eth0)
|
||||
Raspberry Pi
|
||||
├── DNS Server → selective logging / redirection
|
||||
├── hostapd → custom Wi-Fi Access Point ("Bose-Lab")
|
||||
├── dnsmasq → DHCP for clients, DNS to custom server
|
||||
├── iptables → NAT, Forwarding eth0 ↔ wlan0
|
||||
├── tcpdump → full traffic capture
|
||||
└── (optional) mitmproxy → HTTPS decryption
|
||||
↓ Wi-Fi ("Bose-Lab")
|
||||
Android Phone
|
||||
└── Bose SoundTouch App
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 1 – Install Packages
|
||||
|
||||
```bash
|
||||
sudo apt update && sudo apt install -y \
|
||||
hostapd \ # Wi-Fi Access Point daemon
|
||||
dnsmasq \ # DHCP + DNS forwarding
|
||||
nftables \ # Modern NAT / firewall / forwarding
|
||||
tcpdump \ # Packet capture at all levels
|
||||
wireshark-common # tshark CLI (optional, for live analysis)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2 – Enable IP Forwarding
|
||||
|
||||
The Pi must forward packets between `wlan0` (phone) and `eth0` (FritzBox).
|
||||
|
||||
```bash
|
||||
# Active immediately (no reboot required)
|
||||
sudo sysctl -w net.ipv4.ip_forward=1
|
||||
|
||||
# Permanent (survives reboots)
|
||||
# On modern Debian, using a dedicated file in sysctl.d/ is more reliable:
|
||||
echo "net.ipv4.ip_forward=1" | sudo tee /etc/sysctl.d/99-ip-forward.conf
|
||||
|
||||
# Apply changes immediately
|
||||
sudo sysctl --system
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
```bash
|
||||
# After a reboot, ensure it is still '1'
|
||||
cat /proc/sys/net/ipv4/ip_forward
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3 – Static IP on wlan0 (systemd-networkd)
|
||||
|
||||
On modern Debian (Bookworm/Trixie), `dhcpcd` is replaced by `systemd-networkd`.
|
||||
|
||||
```bash
|
||||
# Create network configuration
|
||||
sudo tee /etc/systemd/network/08-wlan0.network << 'EOF'
|
||||
[Match]
|
||||
Name=wlan0
|
||||
|
||||
[Network]
|
||||
Address=192.168.10.1/24
|
||||
IPForward=yes
|
||||
ConfigureWithoutCarrier=yes
|
||||
DHCP=no
|
||||
IPv6AcceptRA=no
|
||||
EOF
|
||||
|
||||
# Restart service
|
||||
sudo systemctl enable systemd-networkd
|
||||
sudo systemctl restart systemd-networkd
|
||||
|
||||
# Ensure wpa_supplicant and NetworkManager don't interfere
|
||||
sudo nmcli device set wlan0 managed no
|
||||
sudo systemctl stop wpa_supplicant@wlan0
|
||||
sudo systemctl mask wpa_supplicant@wlan0
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
```bash
|
||||
ip addr show wlan0
|
||||
# Expected: ONLY inet 192.168.10.1/24 (NO second DHCP IP)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4 – hostapd (Access Point)
|
||||
|
||||
```bash
|
||||
sudo tee /etc/hostapd/hostapd.conf << 'EOF'
|
||||
interface=wlan0
|
||||
driver=nl80211
|
||||
ssid=Bose-Lab
|
||||
hw_mode=b
|
||||
#hw_mode=g
|
||||
channel=1
|
||||
#channel=6
|
||||
wmm_enabled=0
|
||||
auth_algs=1
|
||||
wpa=2
|
||||
wpa_passphrase=secret123
|
||||
wpa_key_mgmt=WPA-PSK
|
||||
wpa_pairwise=CCMP
|
||||
EOF
|
||||
|
||||
# The modern way is to just use hostapd.service which defaults to /etc/hostapd/hostapd.conf
|
||||
sudo systemctl unmask hostapd
|
||||
sudo systemctl enable --now hostapd
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
```bash
|
||||
sudo systemctl status hostapd
|
||||
# Expected: active (running)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5 – dnsmasq (DHCP + DNS)
|
||||
|
||||
dnsmasq gives the phone an IP and forwards DNS queries to the custom DNS server.
|
||||
|
||||
```bash
|
||||
# Back up original config
|
||||
sudo mv /etc/dnsmasq.conf /etc/dnsmasq.conf.bak
|
||||
|
||||
sudo tee /etc/dnsmasq.conf << 'EOF'
|
||||
interface=wlan0
|
||||
dhcp-range=192.168.10.100,192.168.10.200,24h
|
||||
dhcp-option=3,192.168.10.1
|
||||
dhcp-option=6,192.168.10.1
|
||||
|
||||
# DNS Upstream: custom server on localhost (adjust port if necessary)
|
||||
server=127.0.0.1#5353 # Example: custom server on port 5353
|
||||
# Alternatively: server=1.1.1.1 if DNS server runs directly on port 53
|
||||
|
||||
# Log all DNS queries (for initial analysis)
|
||||
log-queries
|
||||
log-facility=/var/log/dnsmasq.log
|
||||
EOF
|
||||
|
||||
sudo systemctl restart dnsmasq
|
||||
```
|
||||
|
||||
**Observe DNS log live:**
|
||||
```bash
|
||||
sudo tail -f /var/log/dnsmasq.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 6 – NAT and Forwarding (nftables)
|
||||
|
||||
On modern Debian (Bookworm/Trixie), `nftables` is the default and recommended way to manage NAT and traffic forwarding.
|
||||
|
||||
```bash
|
||||
# Define the NAT and Forwarding rules
|
||||
sudo tee /etc/nftables.conf << 'EOF'
|
||||
#!/usr/sbin/nft -f
|
||||
|
||||
flush ruleset
|
||||
|
||||
table inet filter {
|
||||
chain forward {
|
||||
type filter hook forward priority 0; policy drop;
|
||||
|
||||
# Allow traffic from phone (wlan0) to internet (eth0)
|
||||
iifname "wlan0" oifname "eth0" accept
|
||||
|
||||
# Allow established/related traffic back to the phone
|
||||
iifname "eth0" oifname "wlan0" ct state established,related accept
|
||||
}
|
||||
}
|
||||
|
||||
table ip nat {
|
||||
chain posterouting {
|
||||
type nat hook postrouting priority 100; policy accept;
|
||||
|
||||
# MASQUERADE outgoing packets on eth0
|
||||
oifname "eth0" masquerade
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Enable and start nftables
|
||||
sudo systemctl enable nftables
|
||||
sudo systemctl restart nftables
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
```bash
|
||||
sudo nft list ruleset
|
||||
# Expected: ruleset showing the forward and nat chains
|
||||
```
|
||||
|
||||
### WiFi "Bose-Lab" not visible?
|
||||
|
||||
If you cannot see the `Bose-Lab` SSID on your phone:
|
||||
|
||||
1. **Check hostapd status:** `sudo systemctl status hostapd`. If it failed with "nl80211: Driver does not support configured mode", try changing `hw_mode=g` to `hw_mode=b`.
|
||||
2. **Interface blocking:** Ensure `rfkill` hasn't blocked WiFi: `sudo rfkill unblock wlan`.
|
||||
3. **Country Code:** Some systems require a country code in `hostapd.conf` to enable the radio. Add `country_code=DE` (or your country) to the top of `/etc/hostapd/hostapd.conf` and restart hostapd: `sudo systemctl restart hostapd`.
|
||||
4. **Local Radio Check:** You can verify that the radio is actually configured as an AP: `iw dev wlan0 info`. Look for `type AP` and your SSID.
|
||||
> **Note:** Do NOT rely on `iw dev wlan0 scan` for your own SSID; many WiFi drivers cannot "scan" and "broadcast" simultaneously.
|
||||
5. **Debug Mode:** If the scan still returns nothing, stop the service and run hostapd in the foreground to see real-time errors:
|
||||
```bash
|
||||
sudo systemctl stop hostapd
|
||||
sudo hostapd -dd /etc/hostapd/hostapd.conf
|
||||
```
|
||||
Look for messages like `nl80211: Failed to set interface wlan0 into AP mode`. This usually means the hardware is busy or doesn't support the current `hw_mode` / `channel` combination.
|
||||
6. **Conflicting Services:** Ensure nothing else is managing `wlan0`. NetworkManager is common on modern Debian:
|
||||
```bash
|
||||
sudo nmcli device set wlan0 managed no
|
||||
```
|
||||
7. **Ghost IP Conflict:** If `ip addr show wlan0` shows both `192.168.10.1` and another IP (like `192.0.2.x`), `hostapd` will fail. This is usually caused by NetworkManager managing the interface. Ensure you've run:
|
||||
```bash
|
||||
sudo nmcli device set wlan0 managed no
|
||||
# If the ghost IP is still there, remove it manually:
|
||||
sudo ip addr del 192.0.2.0/24 dev wlan0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 7 – Install Custom CA Certificate on the Phone
|
||||
|
||||
Since a custom DNS server with a custom CA certificate is used, it must be trusted on the phone – otherwise, the app will block HTTPS connections to redirected domains.
|
||||
|
||||
### Copy CA Certificate to the Pi (if not already there)
|
||||
|
||||
If you haven't created a CA yet, follow **Appendix A** first.
|
||||
|
||||
```bash
|
||||
# Certificate is located e.g. at /etc/my-dns-ca/ca.crt
|
||||
# Temporarily make reachable via HTTP for easy download:
|
||||
cd /etc/my-dns-ca/
|
||||
python3 -m http.server 8080
|
||||
# → Reachable at http://192.168.10.1:8080/ca.crt
|
||||
```
|
||||
|
||||
### Install on Android
|
||||
|
||||
1. Connect phone to `Bose-Lab`
|
||||
2. Open browser → `http://192.168.10.1:8080/ca.crt`
|
||||
3. Download certificate
|
||||
4. **Settings → Security → Credentials → Install CA Certificate**
|
||||
5. Select certificate and confirm
|
||||
|
||||
> **Note:** Android distinguishes between system CAs and user CAs. User-installed CAs are accepted by many apps, but apps with certificate pinning (hardcoded certificate hashes) ignore them. Whether Bose uses pinning will be visible in the capture (Connection Reset after TLS ClientHello).
|
||||
|
||||
### Android 14+ Special Case
|
||||
|
||||
From Android 14 onwards, apps do not trust user CAs by default unless explicitly declared in the manifest. If the Bose app rejects the CA certificate:
|
||||
|
||||
```bash
|
||||
# Option A: Root + Magisk module "MagiskTrustUserCerts"
|
||||
# → moves user CAs to the system store
|
||||
|
||||
# Option B: Root + manually copy to system CA directory
|
||||
adb push ca.crt /system/etc/security/cacerts/
|
||||
adb shell chmod 644 /system/etc/security/cacerts/ca.crt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 8 – Capture Traffic
|
||||
|
||||
### All at once (recommended)
|
||||
|
||||
```bash
|
||||
# Full capture of all protocols on wlan0
|
||||
# Filename with timestamp for multiple sessions
|
||||
sudo tcpdump -i wlan0 \
|
||||
-w /tmp/bose-$(date +%Y%m%d-%H%M%S).pcap \
|
||||
-s 0 # full packet length (no truncation)
|
||||
|
||||
# End session: Ctrl+C
|
||||
```
|
||||
|
||||
### Targeted by protocol
|
||||
|
||||
```bash
|
||||
# DNS only (Port 53) – shows if app uses standard DNS
|
||||
sudo tcpdump -i wlan0 -n port 53
|
||||
|
||||
# HTTPS only – TLS connections to Bose Cloud
|
||||
sudo tcpdump -i wlan0 -n 'tcp port 443'
|
||||
|
||||
# mDNS (ZeroConf) – device discovery in LAN
|
||||
# Multicast group 224.0.0.1, Port 5353
|
||||
sudo tcpdump -i wlan0 -n 'udp port 5353'
|
||||
|
||||
# SSDP/UPnP – alternative device discovery
|
||||
sudo tcpdump -i wlan0 -n 'udp port 1900'
|
||||
|
||||
# Everything except DNS (reduces noise)
|
||||
sudo tcpdump -i wlan0 -n 'not port 53' -w /tmp/bose-nodns.pcap
|
||||
|
||||
# Traffic of a specific host only (filter by phone IP)
|
||||
# Read phone IP from dnsmasq.leases beforehand (see below)
|
||||
sudo tcpdump -i wlan0 -n host 192.168.10.101
|
||||
```
|
||||
|
||||
### Read SNI from TLS Traffic (without decryption)
|
||||
|
||||
```bash
|
||||
# Extract domains from TLS ClientHello (SNI is unencrypted)
|
||||
sudo tcpdump -i wlan0 -n 'tcp port 443' -A 2>/dev/null \
|
||||
| grep -oP '(?<=\x00)([a-zA-Z0-9.-]+\.(?:com|net|io|cloud|bose\.com))'
|
||||
```
|
||||
|
||||
### Readable mDNS Announcements output
|
||||
|
||||
```bash
|
||||
# tshark decodes mDNS directly
|
||||
sudo tshark -i wlan0 -f 'udp port 5353' -T fields \
|
||||
-e dns.qry.name \
|
||||
-e dns.resp.name \
|
||||
-e dns.a
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 9 – Analysis with Wireshark (on PC)
|
||||
|
||||
Transfer `.pcap` files from the Pi to the PC:
|
||||
|
||||
```bash
|
||||
# From the PC (scp)
|
||||
scp pi@192.168.10.1:/tmp/bose-*.pcap ~/Desktop/
|
||||
```
|
||||
|
||||
**Important Wireshark Filters:**
|
||||
|
||||
```
|
||||
# DNS only
|
||||
dns
|
||||
|
||||
# HTTPS only
|
||||
tcp.port == 443
|
||||
|
||||
# WebSocket connections (HTTP Upgrade)
|
||||
websocket
|
||||
|
||||
# mDNS
|
||||
mdns
|
||||
|
||||
# TLS Handshakes (SNI visible)
|
||||
tls.handshake.extensions_server_name
|
||||
|
||||
# Traffic of a specific domain (resolve by IP)
|
||||
http.host contains "bose"
|
||||
|
||||
# WebSocket frames
|
||||
websocket.payload
|
||||
```
|
||||
|
||||
> **Tip:** Wireshark decodes WebSocket frames automatically if it sees the HTTP Upgrade handshake in the same capture. For the pairing flow: filtering for `tls.handshake.extensions_server_name` shows all domains the app contacts, even without decryption.
|
||||
|
||||
---
|
||||
|
||||
## Step 10 – mitmproxy (optional, for HTTPS content)
|
||||
|
||||
Only useful if the CA certificate on the phone is trusted and no certificate pinning is active. `mitmproxy` acts as a Man-in-the-Middle by generating fake, on-the-fly certificates for any domain (e.g., `global.api.bose.io`) using your custom CA.
|
||||
|
||||
### 1. Configure mitmproxy to use your Custom CA
|
||||
|
||||
By default, `mitmproxy` creates its own CA in `~/.mitmproxy/`. To ensure the phone (which already trusts your `ca.crt`) accepts the traffic, you must tell `mitmproxy` to use your existing CA:
|
||||
|
||||
```bash
|
||||
# mitmproxy expects the CA in a specific PEM format (cert + key in one file)
|
||||
sudo mkdir -p ~/.mitmproxy
|
||||
sudo cat /etc/my-dns-ca/ca.crt /etc/my-dns-ca/ca.key | sudo tee ~/.mitmproxy/mitmproxy-ca.pem > /dev/null
|
||||
```
|
||||
|
||||
### 2. Install and Start mitmproxy
|
||||
|
||||
```bash
|
||||
# Install mitmproxy binary (stable version for aarch64)
|
||||
cd /tmp
|
||||
wget https://downloads.mitmproxy.org/12.2.1/mitmproxy-12.2.1-linux-aarch64.tar.gz
|
||||
tar -xzf mitmproxy-12.2.1-linux-aarch64.tar.gz
|
||||
sudo mv mitmproxy mitmdump mitmweb /usr/local/bin/
|
||||
rm mitmproxy-12.2.1-linux-aarch64.tar.gz
|
||||
|
||||
mitmproxy --version
|
||||
|
||||
# Transparent proxy on port 8080
|
||||
# It will now use the CA from ~/.mitmproxy/mitmproxy-ca.pem
|
||||
mitmproxy --mode transparent --listen-port 8080
|
||||
|
||||
# Alternatively: mitmdump for automatic logging to file
|
||||
# mitmdump --mode transparent --listen-port 8080 -w /tmp/bose-https.mitm
|
||||
```
|
||||
|
||||
### 3. Troubleshooting: TLS Handshake Failures
|
||||
|
||||
If you see `Client TLS handshake failed. The client does not trust the proxy's certificate for www.google.com` (or other domains) in the `mitmproxy` logs:
|
||||
|
||||
1. **HSTS and Pre-installed Pinning:** High-security sites like `www.google.com` use **HSTS (HTTP Strict Transport Security)** and have their certificates hardcoded (pinned) into browsers like Chrome and the Android system. **These will always fail with a User-installed CA.**
|
||||
2. **User vs. System CA Store:** On Android 7.0+, apps **do not trust User-installed CAs by default**. They only trust the "System" store.
|
||||
* **The Bose app:** If it fails, it's because it only trusts the System store or uses its own certificate pinning.
|
||||
* **The Fix (Rooted Phone):** Use a Magisk module like `AlwaysTrustUserCerts` or manually move your `ca.crt` to `/system/etc/security/cacerts/` (see Step 7).
|
||||
3. **The "Golden Rule" - Verify the Proxy is Working:**
|
||||
To confirm your CA and `mitmproxy` are correctly configured, test with a non-HSTS site on the phone's browser (e.g., `http://neverssl.com`). Once redirected to HTTPS, **inspect the certificate**. It should say it was issued by your "Bose-Lab Root CA" (or "SoundTouch Root CA").
|
||||
|
||||
* **If this works:** Your "factory" (mitmproxy + CA) is 100% correct. Any failure in the Bose app is due to its own security policy (ignore User Store or Pinning).
|
||||
* **If this fails:** Your CA is not trusted by the browser or `mitmproxy` is not using your PEM file.
|
||||
|
||||
Alternatively, use `curl` from a terminal emulator on the phone:
|
||||
```bash
|
||||
# This should work if the CA is in the user store and curl is told to use it
|
||||
curl -v --cacert /path/to/ca.crt https://example.com
|
||||
```
|
||||
4. **Check mitmproxy CA:** Ensure `mitmproxy` is actually using your CA. When it starts, it should NOT generate a new CA in `~/.mitmproxy/mitmproxy-ca.pem` if you've already placed yours there.
|
||||
|
||||
---
|
||||
|
||||
**nftables rule: redirect HTTPS traffic to mitmproxy**
|
||||
|
||||
```bash
|
||||
# Create a temporary file for the redirection rule
|
||||
sudo nft add table ip mitm
|
||||
sudo nft add chain ip mitm prerouting { type nat hook prerouting priority -100 \; }
|
||||
sudo nft add rule ip mitm prerouting iifname "wlan0" tcp dport 443 redirect to :8080
|
||||
```
|
||||
|
||||
**Remove rule when no longer needed:**
|
||||
|
||||
```bash
|
||||
sudo nft delete table ip mitm
|
||||
```
|
||||
|
||||
> **Detecting Certificate Pinning:** If the app immediately disconnects after mitmproxy redirection (connection reset directly after TLS ClientHello), pinning is active. In this case, Frida + root is needed to patch the pinning.
|
||||
|
||||
---
|
||||
|
||||
## Step 11 – Bypassing Android Trust Restrictions
|
||||
|
||||
If `neverssl.com` works in the browser but the Bose app shows `TLS handshake failed` in `mitmproxy`, the app is either ignoring the **User CA store** (common on Android 7+) or using **Certificate Pinning**.
|
||||
|
||||
### Option A: Move CA to System Store (Requires Root/Magisk)
|
||||
|
||||
This is the most reliable way to make apps trust your CA without modifying the app itself.
|
||||
|
||||
1. **Using Magisk (Recommended):**
|
||||
Install the **"AlwaysTrustUserCerts"** or **"Move Certificates"** module in Magisk. It automatically mirrors all certificates from the User store to the System store on every boot.
|
||||
|
||||
2. **Manual Move (via ADB):**
|
||||
Android system certificates are stored in `/system/etc/security/cacerts/` and must be named using the hash of the certificate.
|
||||
|
||||
```bash
|
||||
# 1. Get the hash of your certificate
|
||||
hash=$(openssl x509 -inform PEM -subject_hash_old -in ca.crt | head -1)
|
||||
|
||||
# 2. Rename the certificate locally
|
||||
cp ca.crt ${hash}.0
|
||||
|
||||
# 3. Push to the phone (requires remounting /system as read-write)
|
||||
adb push ${hash}.0 /sdcard/
|
||||
adb shell
|
||||
su
|
||||
mount -o rw,remount /
|
||||
cp /sdcard/${hash}.0 /system/etc/security/cacerts/
|
||||
chmod 644 /system/etc/security/cacerts/${hash}.0
|
||||
chown root:root /system/etc/security/cacerts/${hash}.0
|
||||
reboot
|
||||
```
|
||||
|
||||
### Option B: Patching the App (No Root Required)
|
||||
|
||||
If you cannot root your phone, you can modify the app's APK to trust user-installed certificates. This involves obtaining the APK, decompiling it, adding a network security configuration, and then repackaging and signing it.
|
||||
|
||||
#### 0. How to get the .apk file?
|
||||
|
||||
You have two main ways to get the official Bose SoundTouch APK:
|
||||
|
||||
**Method 1: Extract from your phone (Safest)**
|
||||
If the app is already installed on your phone, you can pull it using `adb`:
|
||||
```bash
|
||||
# 1. Find the package name (usually com.bose.soundtouch)
|
||||
adb shell pm list packages | grep bose
|
||||
|
||||
# 2. Get the full path to the APK on the phone
|
||||
adb shell pm path com.bose.soundtouch
|
||||
# Output: package:/data/app/~~...==/com.bose.soundtouch-.../base.apk
|
||||
|
||||
# 3. Pull the file to your computer
|
||||
adb pull /data/app/~~...==/com.bose.soundtouch-.../base.apk Bose-SoundTouch.apk
|
||||
```
|
||||
|
||||
**Method 2: Download from a Mirror (Easiest)**
|
||||
You can download the APK from reputable third-party sites.
|
||||
> **Warning:** Always verify the site's reputation.
|
||||
* [APKMirror](https://www.apkmirror.com/apk/bose-corporation/bose-soundtouch/)
|
||||
* [APKPure](https://apkpure.com/bose-soundtouch/com.bose.soundtouch)
|
||||
|
||||
#### 1. Automated Method: apk-mitm (Recommended)
|
||||
The easiest way is to use `apk-mitm`, which automates the entire process including fixing common certificate pinning libraries.
|
||||
|
||||
```bash
|
||||
# Requires Node.js installed on your PC
|
||||
npx apk-mitm Bose-SoundTouch.apk
|
||||
```
|
||||
This will produce a `Bose-SoundTouch-patched.apk` which you can install on your phone.
|
||||
|
||||
#### 2. Manual Method: Network Security Config
|
||||
If you prefer to do it manually:
|
||||
|
||||
1. **Decompile the APK:**
|
||||
```bash
|
||||
apktool d Bose-SoundTouch.apk
|
||||
```
|
||||
2. **Create/Modify `res/xml/network_security_config.xml`:**
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config>
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates src="user" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
```
|
||||
3. **Update `AndroidManifest.xml`:**
|
||||
Ensure the `<application>` tag includes: `android:networkSecurityConfig="@xml/network_security_config"`.
|
||||
4. **Repackage and Sign:**
|
||||
```bash
|
||||
apktool b Bose-SoundTouch -o Bose-SoundTouch-patched.apk
|
||||
# Sign with your own key
|
||||
# 1. Generate a keystore (if you don't have one)
|
||||
# Note: You can use ANY name/values here. The phone does not need to "know" or "trust" this key beforehand.
|
||||
# It only needs the APK to be digitally signed so the Android installer accepts it.
|
||||
keytool -genkey -v -keystore my-release-key.keystore -alias alias_name -keyalg RSA -keysize 2048 -validity 10000
|
||||
|
||||
# 2. Sign the APK
|
||||
apksigner sign --ks my-release-key.keystore --out Bose-SoundTouch-patched-signed.apk Bose-SoundTouch-patched.apk
|
||||
|
||||
# Alternatively, use uber-apk-signer (recommended for simplicity)
|
||||
# It handles zipalign and signing automatically.
|
||||
java -jar uber-apk-signer.jar --apk Bose-SoundTouch-patched.apk
|
||||
```
|
||||
|
||||
#### 3. Install the Patched APK
|
||||
|
||||
Once you have your `Bose-SoundTouch-patched.apk` (and it is signed), you need to install it on your phone.
|
||||
|
||||
**Important:** You must **uninstall the original Bose app first**. Android will not allow you to "update" the official app with your patched version because the digital signatures won't match.
|
||||
|
||||
**Method 1: via ADB (Recommended)**
|
||||
```bash
|
||||
# 1. Uninstall the original app
|
||||
adb uninstall com.bose.soundtouch
|
||||
|
||||
# 2. Install your patched version
|
||||
adb install Bose-SoundTouch-patched.apk
|
||||
```
|
||||
|
||||
**Method 2: Manual Transfer**
|
||||
1. Copy the `Bose-SoundTouch-patched.apk` to your phone's storage (via USB, Google Drive, or the Pi's HTTP server).
|
||||
2. On the phone, use a File Manager to open the APK.
|
||||
3. If prompted, allow "Install from Unknown Sources" for your File Manager.
|
||||
|
||||
### Option C: Using the macOS Bose SoundTouch App (No Root/Patching Required)
|
||||
|
||||
If you have a Mac, using the macOS version of the Bose SoundTouch app is often a good alternative. However, because the app is built on an **older version of Qt (5.7.0)**, it has specific trust and TLS compatibility issues that require extra steps.
|
||||
|
||||
#### 1. Install the Custom CA in macOS Keychain
|
||||
|
||||
1. Open **Keychain Access** on your Mac.
|
||||
2. Select the **System** keychain (or **login** if System is locked).
|
||||
3. Drag and drop your `ca.crt` file into the list.
|
||||
4. Double-click the newly added certificate (e.g., "Bose-Lab Root CA").
|
||||
5. Expand the **Trust** section.
|
||||
6. Set "When using this certificate" to **Always Trust**.
|
||||
7. Close the window and authenticate with your Mac password.
|
||||
|
||||
#### 2. Configure the Proxy
|
||||
|
||||
You can either configure the macOS system proxy manually or use `mitmproxy`'s automatic interception.
|
||||
|
||||
**Method 1: System Proxy (Manual)**
|
||||
1. Go to **System Settings → Network → Wi-Fi → Details... → Proxies**.
|
||||
2. Enable **HTTP Proxy** and **HTTPS Proxy**.
|
||||
3. Set Server to your Pi's IP (`192.168.10.1`) and Port to `8080`.
|
||||
4. Click **OK** and **Apply**.
|
||||
|
||||
**Method 2: mitmproxy Local Redirect (Automatic)**
|
||||
If you are running `mitmproxy` directly on your Mac (instead of the Pi), you can use the modern "Local Redirect" mode which doesn't require proxy settings:
|
||||
```bash
|
||||
# Install mitmproxy via Homebrew
|
||||
brew install mitmproxy
|
||||
|
||||
# Start mitmproxy in local redirect mode
|
||||
# This uses a macOS Network Extension to intercept traffic from specific apps
|
||||
mitmproxy --mode local
|
||||
```
|
||||
|
||||
#### 3. Special Troubleshooting: Legacy Qt 5.7.0 SSL Failures
|
||||
|
||||
If you see `SSL handshake failed` in the `mitmproxy` logs or the app's internal log (`log.txt`), the app's older networking stack is rejecting the connection. This is common because Qt 5.7.0 (2016) lacks support for **TLS 1.3** and many modern root certificates (like Let's Encrypt's **ISRG Root X1**).
|
||||
|
||||
**The Solution: Launch with SSL Bypass Flags**
|
||||
|
||||
Since the Bose macOS app is a hybrid of **Qt/Chromium** and **Node.js**, you must bypass the trust checks for both engines by launching the app from the terminal:
|
||||
|
||||
```bash
|
||||
# 1. Bypass QtWebEngine/Chromium (Qt 5.7) trust
|
||||
export QTWEBENGINE_CHROMIUM_FLAGS="--ignore-certificate-errors"
|
||||
|
||||
# 2. Bypass Node.js (SoundTouch Music Server) trust
|
||||
export NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
|
||||
# 3. (Optional) Provide your custom CA directly to Node.js
|
||||
export NODE_EXTRA_CA_CERTS="/path/to/your/ca.crt"
|
||||
|
||||
# 4. Launch the application
|
||||
"/Applications/SoundTouch/SoundTouch.app/Contents/MacOS/SoundTouch"
|
||||
```
|
||||
|
||||
#### 4. Verify and Capture
|
||||
|
||||
1. Open Safari and visit `https://neverssl.com`. Verify the certificate is issued by your custom CA.
|
||||
2. Launch the Bose app using the terminal command above.
|
||||
3. Watch the traffic flow in `mitmproxy`.
|
||||
|
||||
> **Note:** Even on macOS, **Certificate Pinning** is still possible if Bose implemented it specifically in the desktop app code. However, it is much less common on desktop apps than on mobile apps. If it works, you've saved yourself hours of Android patching!
|
||||
|
||||
### Option D: Patching the App with Frida (Requires Root)
|
||||
|
||||
If the app uses **Certificate Pinning** (hardcoded hashes), even moving the CA to the System store won't work. You must disable the pinning check in the app's code.
|
||||
|
||||
1. **Install Frida** on your PC and `frida-server` on the rooted phone.
|
||||
2. **Use a universal bypass script:**
|
||||
```bash
|
||||
frida -U -f com.bose.soundtouch -l https://codeshare.frida.re/@pcipolloni/universal-android-ssl-pinning-bypass-with-frida/ --no-pause
|
||||
```
|
||||
*(Replace `com.bose.soundtouch` with the actual package name if different).*
|
||||
|
||||
## Step 12 – Alternative: Regular HTTP Proxy Mode
|
||||
|
||||
If the **Transparent AP** setup (Steps 1–6) is too complex or you are experiencing routing issues, you can use `mitmproxy` as a **Regular HTTP Proxy**.
|
||||
|
||||
### 1. How it works
|
||||
In this mode, the Pi acts as a simple server on port 8080. You tell your phone's Wi-Fi settings to send all traffic to `192.168.10.1:8080`.
|
||||
|
||||
* **Pros:** No complex `nftables` or NAT rules required.
|
||||
* **Cons:** Many Android apps (and background processes) ignore system-wide proxy settings. **HTTPS still requires a trusted CA for decryption.**
|
||||
|
||||
### 2. Start mitmproxy in Regular Mode
|
||||
```bash
|
||||
# Stop transparent mode first if it's running
|
||||
# No special flags needed for regular mode
|
||||
mitmproxy --listen-port 8080
|
||||
```
|
||||
|
||||
### 3. Configure the Phone
|
||||
1. Go to **Settings → Wi-Fi → Bose-Lab**.
|
||||
2. Select **Modify Network** (or the "i" icon).
|
||||
3. Set **Proxy** to **Manual**.
|
||||
4. **Proxy hostname:** `192.168.10.1`
|
||||
5. **Proxy port:** `8080`
|
||||
6. Save and try to browse a site.
|
||||
|
||||
---
|
||||
|
||||
## Step 13 – Extracting for soundtouch-service
|
||||
|
||||
You can extract interactions (especially unencrypted WebSockets on port 8090) from a `.pcap` and format them for use in `soundtouch-service`.
|
||||
|
||||
### 1. Extract Traffic using Go
|
||||
|
||||
A helper script is provided in `scripts/extract-ws.go`. It automatically detects, unmasks, and decompresses (GZIP) WebSocket frames, and also extracts DNS, MDNS, and SSDP traffic.
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
go get github.com/google/gopacket
|
||||
|
||||
# Run extraction (outputs multiple files: .ws.http, .dns.txt, .mdns.txt, .ssdp.txt)
|
||||
# The results will be saved beside your .pcap file
|
||||
go run scripts/extract-ws.go your_capture.pcap [filter_ip]
|
||||
|
||||
# Example: Filter for a specific speaker's IP in WebSocket messages
|
||||
go run scripts/extract-ws.go capture.pcap 192.168.100.1
|
||||
```
|
||||
|
||||
### 2. Manual Extraction with tshark
|
||||
|
||||
If you only need a quick look at the payloads:
|
||||
|
||||
```bash
|
||||
# Extract all WebSocket text payloads
|
||||
tshark -r your_capture.pcap -Y "websocket.payload.text" -T fields -e websocket.payload.text
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 14 – Extracting from Internal App Logs (macOS)
|
||||
|
||||
If you are using the macOS app and cannot decrypt the cloud traffic due to pinning, you can still extract the JSON/XML messages from the app's internal communication log.
|
||||
|
||||
A helper script is provided in `scripts/extract-log-interactions.go`. It parses the interleaved "Native" and "Network" calls to reconstruct the application's internal state and cloud requests.
|
||||
|
||||
```bash
|
||||
# Run extraction from the log file
|
||||
# Outputs a chronological record of internal events and network URLs
|
||||
go run scripts/extract-log-interactions.go path/to/log.txt > extracted-interactions.http
|
||||
```
|
||||
|
||||
**What this shows:**
|
||||
- **TO NETWORK:** The URLs the app is about to call (intercepted before encryption).
|
||||
- **FROM NATIVE:** Data being returned from the OS or Cloud to the UI.
|
||||
- **TO NATIVE:** Commands being sent from the UI to the underlying engines.
|
||||
|
||||
This is a powerful "Plan B" when HTTPS decryption is blocked, as the app essentially logs its own decrypted data for you.
|
||||
|
||||
---
|
||||
|
||||
## Helper Commands / Troubleshooting
|
||||
|
||||
After a Pi reboot, everything should come up automatically. If not:
|
||||
|
||||
```bash
|
||||
# Restart and enable all core services
|
||||
sudo systemctl restart systemd-networkd
|
||||
sudo systemctl enable --now hostapd
|
||||
sudo systemctl enable --now dnsmasq
|
||||
sudo systemctl restart nftables
|
||||
|
||||
# Verify the unmanaged state of wlan0 (nmcli)
|
||||
sudo nmcli device set wlan0 managed no
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What to Expect
|
||||
|
||||
| Protocol | Port | Tool | Visibility |
|
||||
|----------------------|------------|--------------------------|------------------------------------------------|
|
||||
| DNS (Standard) | UDP 53 | tcpdump, dnsmasq log | Full, plaintext |
|
||||
| HTTPS / REST | TCP 443 | tcpdump (SNI), mitmproxy | SNI without decryption, content with mitmproxy |
|
||||
| WebSockets | TCP 443/80 | Wireshark | Frames decoded if TLS is broken |
|
||||
| mDNS / ZeroConf | UDP 5353 | tcpdump, tshark | Full, plaintext |
|
||||
| SSDP / UPnP | UDP 1900 | tcpdump | Full, plaintext |
|
||||
| SoundTouch local API | TCP 8090 | tcpdump | Full, plaintext (no TLS) |
|
||||
|
||||
> **Expectation for Bose SoundTouch:** The app likely uses standard DNS (older app generation), REST/HTTPS for the pairing flow with the cloud, WebSockets for push events from the device, and mDNS for local device discovery. The local device API on port 8090 is HTTP without TLS – this traffic is always readable.
|
||||
|
||||
---
|
||||
|
||||
## Next Steps After Analysis
|
||||
|
||||
1. Extract domains from DNS log and SNI → List of all Bose endpoints
|
||||
2. HTTP methods and paths from mitmproxy log → Reconstruct API structure
|
||||
3. Document auth flow (OAuth2? Proprietary? Token format?)
|
||||
4. Build a minimal mock server simulating the critical endpoints
|
||||
5. Testing: App against mock server → does pairing work offline?
|
||||
|
||||
---
|
||||
|
||||
## Appendix A – Generating a Custom CA Certificate
|
||||
|
||||
If you don't have a custom DNS server with a CA yet, you can create one directly on the Pi. Alternatively, if you are already using the `soundtouch-service` from this repository, you can reuse its CA certificate located in the `data/certs/` directory.
|
||||
|
||||
### 0. (Optional) Copy an Existing CA from another host
|
||||
|
||||
If you are already using the `soundtouch-service` on another machine (e.g., your notebook), you can copy the existing CA to the Pi instead of generating a new one:
|
||||
|
||||
```bash
|
||||
# On your Pi:
|
||||
sudo mkdir -p /etc/my-dns-ca
|
||||
sudo chown $USER:$USER /etc/my-dns-ca
|
||||
|
||||
# Run this on your notebook (replace hostnames and paths):
|
||||
# Note: This is easiest if your SSH key is added to the Pi and soundtouch-service host.
|
||||
# If you run into permission issues with sudo, ensure the source user has passwordless sudo for 'cat'.
|
||||
|
||||
# Step A: Download from source to your notebook
|
||||
ssh soundtouch-service "sudo cat /var/lib/soundtouch-service/certs/ca.crt" > ca.crt
|
||||
ssh soundtouch-service "sudo cat /var/lib/soundtouch-service/certs/ca.key" > ca.key
|
||||
|
||||
# Step B: Upload from notebook to the Pi
|
||||
scp ca.crt ca.key soundtouch-access-point:/tmp/
|
||||
ssh soundtouch-access-point "sudo mv /tmp/ca.crt /tmp/ca.key /etc/my-dns-ca/ && sudo chown root:root /etc/my-dns-ca/ca.*"
|
||||
rm ca.crt ca.key
|
||||
```
|
||||
|
||||
### 1. Create CA Key and Certificate
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/my-dns-ca
|
||||
cd /etc/my-dns-ca
|
||||
|
||||
# Generate CA private key
|
||||
sudo openssl genrsa -out ca.key 4096
|
||||
|
||||
# Generate Root CA certificate
|
||||
# Note: we explicitly add basicConstraints=CA:TRUE for modern TLS clients
|
||||
sudo openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 \
|
||||
-out ca.crt \
|
||||
-subj "/C=DE/O=Bose-Lab/CN=Bose-Lab Root CA" \
|
||||
-addext "basicConstraints=critical,CA:TRUE" \
|
||||
-addext "keyUsage=critical,keyCertSign,cRLSign"
|
||||
```
|
||||
|
||||
### 2. Generate a Certificate for Interception (Example)
|
||||
|
||||
To intercept `global.api.bose.io`, you need a certificate for it, signed by your CA:
|
||||
|
||||
```bash
|
||||
# Generate server key
|
||||
sudo openssl genrsa -out bose.key 2048
|
||||
|
||||
# Create CSR (Certificate Signing Request) configuration
|
||||
sudo tee bose.ext << 'EOF'
|
||||
authorityKeyIdentifier=keyid,issuer
|
||||
basicConstraints=CA:FALSE
|
||||
keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
|
||||
subjectAltName = @alt_names
|
||||
|
||||
[alt_names]
|
||||
DNS.1 = global.api.bose.io
|
||||
DNS.2 = *.bose.io
|
||||
EOF
|
||||
|
||||
# Generate CSR
|
||||
sudo openssl req -new -key bose.key -out bose.csr \
|
||||
-subj "/C=DE/O=Bose-Lab/CN=global.api.bose.io"
|
||||
|
||||
# Sign the certificate with your CA
|
||||
sudo openssl x509 -req -in bose.csr -CA ca.crt -CAkey ca.key \
|
||||
-CAcreateserial -out bose.crt -days 365 -sha256 -extfile bose.ext
|
||||
```
|
||||
|
||||
### 3. Usage in your DNS/HTTPS Server
|
||||
|
||||
Your custom server (e.g., a small Go or Python script) would then use `bose.crt` and `bose.key` to serve HTTPS traffic for those domains.
|
||||
|
||||
## Appendix B – Helpful Commands
|
||||
|
||||
```bash
|
||||
# Which IPs did the phone receive?
|
||||
cat /var/lib/misc/dnsmasq.leases
|
||||
|
||||
# Is the access point active?
|
||||
sudo systemctl status hostapd
|
||||
|
||||
# Is dnsmasq active?
|
||||
sudo systemctl status dnsmasq
|
||||
|
||||
# Check interfaces and IPs
|
||||
ip addr show
|
||||
|
||||
# Check routing table
|
||||
ip route show
|
||||
|
||||
# Show active nftables rules
|
||||
sudo nft list ruleset
|
||||
|
||||
# All running tcpdump processes
|
||||
pgrep -a tcpdump
|
||||
|
||||
# Test the Pi's own DNS resolution
|
||||
dig @127.0.0.1 -p 5353 global.api.bose.io
|
||||
|
||||
# Check network connectivity from the phone (from the Pi)
|
||||
ping 192.168.10.101 # Phone IP from dnsmasq.leases
|
||||
```
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
To enable offline operation or use custom services like **SoundCork** or **ÜberBöse API**, SoundTouch devices must be redirected from Bose's official cloud endpoints to a local or custom server. This document outlines the three known methods to achieve this, gathered from community reverse-engineering efforts in the **SoundCork** and **ÜberBöse API** projects.
|
||||
|
||||
> A fourth, **SSH-free** path — driving the device's diagnostic shell on TCP port 17000 — is being added as a peer to the XML and DNS methods. See **[TELNET-MIGRATION-METHOD.md](TELNET-MIGRATION-METHOD.md)** for the use cases, community findings, and feasibility analysis. The `/etc/hosts` method documented below is now deprecated and will not be exposed in the web UI.
|
||||
|
||||
## Overview of Redirection Targets
|
||||
|
||||
SoundTouch devices primarily communicate with the following domains:
|
||||
@@ -34,25 +32,19 @@ The most robust and granular method involves modifying the device's private conf
|
||||
Requires SSH access to the device.
|
||||
```xml
|
||||
<SoundTouchSdkPrivateCfg>
|
||||
<margeServerUrl>http://192.0.2.10:8000</margeServerUrl>
|
||||
<statsServerUrl>http://192.0.2.10:8000</statsServerUrl>
|
||||
<swUpdateUrl>http://192.0.2.10:8000/updates/soundtouch</swUpdateUrl>
|
||||
<bmxRegistryUrl>http://192.0.2.10:8000/bmx/registry/v1/services</bmxRegistryUrl>
|
||||
<margeServerUrl>http://192.168.1.10:8000/marge</margeServerUrl>
|
||||
<statsServerUrl>http://192.168.1.10:8000</statsServerUrl>
|
||||
<swUpdateUrl>http://192.168.1.10:8000/updates/soundtouch</swUpdateUrl>
|
||||
<bmxRegistryUrl>http://192.168.1.10:8000/bmx/registry/v1/services</bmxRegistryUrl>
|
||||
</SoundTouchSdkPrivateCfg>
|
||||
```
|
||||
|
||||
> **Note on `margeServerUrl`** — `soundtouch-service` mounts the marge endpoints
|
||||
> at the **root** of port 8000, so the URL has no `/marge` suffix.
|
||||
> [`deborahgu/soundcork`](https://github.com/deborahgu/soundcork) routes marge
|
||||
> under a `/marge` sub-path, so users redirecting to soundcork must append it
|
||||
> (`http://192.0.2.10:8000/marge`).
|
||||
|
||||
### Pros & Cons
|
||||
| Pros | Cons |
|
||||
|:----------------------------------------------------------------------------------------------|:-------------------------------------------------------------------------------|
|
||||
| **Granular Control**: Redirect specific services while leaving others (e.g., updates) intact. | **Requires SSH**: Must have root/SSH access to the device. |
|
||||
| **Persistent**: Survives software updates (usually). | **Syntax Sensitive**: Errors in XML can cause boot issues or service failures. |
|
||||
| **Native**: Uses the device's built-in configuration mechanism. | |
|
||||
| Pros | Cons |
|
||||
| :--- | :--- |
|
||||
| **Granular Control**: Redirect specific services while leaving others (e.g., updates) intact. | **Requires SSH**: Must have root/SSH access to the device. |
|
||||
| **Persistent**: Survives software updates (usually). | **Syntax Sensitive**: Errors in XML can cause boot issues or service failures. |
|
||||
| **Native**: Uses the device's built-in configuration mechanism. | |
|
||||
|
||||
---
|
||||
|
||||
@@ -68,17 +60,17 @@ This method uses the standard Linux hosts file to redirect traffic at the networ
|
||||
### Implementation
|
||||
Requires SSH access. Add entries for the target domains:
|
||||
```text
|
||||
192.0.2.10 streaming.bose.com
|
||||
192.0.2.10 updates.bose.com
|
||||
192.0.2.10 stats.bose.com
|
||||
192.168.1.10 streaming.bose.com
|
||||
192.168.1.10 updates.bose.com
|
||||
192.168.1.10 stats.bose.com
|
||||
```
|
||||
|
||||
### Pros & Cons
|
||||
| Pros | Cons |
|
||||
|:--------------------------------------------------------------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **Simple**: Easy to understand and implement. | **Requires SSH**: Must have root access. |
|
||||
| Pros | Cons |
|
||||
| :--- | :--- |
|
||||
| **Simple**: Easy to understand and implement. | **Requires SSH**: Must have root access. |
|
||||
| **Universal**: Affects all processes on the device attempting to reach those domains. | **HTTPS Issues**: Redirecting HTTPS domains to a local IP will cause SSL certificate errors unless the device is patched to skip verification or trust a custom CA. |
|
||||
| | **Brittle**: Some firmware versions may overwrite `/etc/hosts` on reboot. |
|
||||
| | **Brittle**: Some firmware versions may overwrite `/etc/hosts` on reboot. |
|
||||
|
||||
---
|
||||
|
||||
@@ -112,12 +104,12 @@ sed "s#\^https:....bose.\+apigee..net..#http[aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
4. Restore execution permissions and reboot.
|
||||
|
||||
### Pros & Cons
|
||||
| Pros | Cons |
|
||||
|:------------------------------------------------------------------------------------|:--------------------------------------------------------------------------------------|
|
||||
| **Bypass Config**: Works even if the firmware ignores XML settings. | **High Risk**: Modifying binaries can lead to permanent bricks or boot loops. |
|
||||
| Pros | Cons |
|
||||
| :--- | :--- |
|
||||
| **Bypass Config**: Works even if the firmware ignores XML settings. | **High Risk**: Modifying binaries can lead to permanent bricks or boot loops. |
|
||||
| **Hardcoded Redirects**: Can catch URLs that aren't exposed in configuration files. | **Length Constraint**: Custom URLs must fit within the space of the original strings. |
|
||||
| | **Firmware Specific**: Patches must be reapplied after every software update. |
|
||||
| | **Complexity**: Requires understanding of binary structures and potential checksums. |
|
||||
| | **Firmware Specific**: Patches must be reapplied after every software update. |
|
||||
| | **Complexity**: Requires understanding of binary structures and potential checksums. |
|
||||
|
||||
---
|
||||
|
||||
@@ -125,11 +117,11 @@ sed "s#\^https:....bose.\+apigee..net..#http[aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
|
||||
### Summary Table
|
||||
|
||||
| Method | Primary Use Case | Ease | Safety | Persistence | Granularity |
|
||||
|:-----------------|:----------------------------|:-----:|:------:|:-----------:|:-----------:|
|
||||
| **XML Config** | Logical service redirection | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
| **`/etc/hosts`** | Quick global DNS override | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐ |
|
||||
| **Binary Patch** | Bypassing hardcoded checks | ⭐ | ⭐ | ⭐ | ⭐⭐⭐ |
|
||||
| Method | Primary Use Case | Ease | Safety | Persistence | Granularity |
|
||||
| :--- | :--- | :---: | :---: | :---: | :---: |
|
||||
| **XML Config** | Logical service redirection | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
| **`/etc/hosts`** | Quick global DNS override | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐ |
|
||||
| **Binary Patch** | Bypassing hardcoded checks | ⭐ | ⭐ | ⭐ | ⭐⭐⭐ |
|
||||
|
||||
---
|
||||
|
||||
@@ -141,7 +133,7 @@ A common question is whether these methods can be used in isolation or if they m
|
||||
If your firmware does not strictly enforce the `IsItBose` check for the specific URLs you are changing, **Method 1 (XML)** is sufficient. This is the cleanest approach and is used by the `soundtouch-service` migration tool.
|
||||
|
||||
### Scenario B: XML Config + Binary Patching (The "Locked" Case)
|
||||
On some newer firmware versions, even if you change the `<margeServerUrl>` in the XML to `http://192.0.2.10`, the internal library (`libBmxAccountHsm.so`) will validate the string against the hardcoded Bose regex.
|
||||
On some newer firmware versions, even if you change the `<margeServerUrl>` in the XML to `http://192.168.1.10`, the internal library (`libBmxAccountHsm.so`) will validate the string against the hardcoded Bose regex.
|
||||
* **Symptom**: The device ignores the XML setting or fails to connect despite the correct URL being present.
|
||||
* **Solution**: You **must** apply the **Binary Patch (Method 3)** to neutralize the `IsItBose` check *in addition* to the XML change.
|
||||
|
||||
@@ -184,10 +176,9 @@ As suggested by community members, you can configure the device to trust your ow
|
||||
- **Method B (Symlinks)**: Add the certificate to `/etc/ssl/certs/` and create a hash symlink using `c_rehash` (if available) or manual mapping.
|
||||
|
||||
**Pros & Cons**:
|
||||
|
||||
| Pros | Cons |
|
||||
|:-------------------------------------------------------|:-----------------------------------------------------------------------|
|
||||
| **Secure**: Maintains end-to-end encryption. | **Requires SSH**: Must have root access to modify the trust store. |
|
||||
| Pros | Cons |
|
||||
| :--- | :--- |
|
||||
| **Secure**: Maintains end-to-end encryption. | **Requires SSH**: Must have root access to modify the trust store. |
|
||||
| **Clean**: No binary patching required for SSL bypass. | **Update Risk**: Firmware updates might overwrite the `ca-bundle.crt`. |
|
||||
|
||||
### Option 2: SSL Verification Bypass
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
# What a SoundTouch speaker does during factory reset
|
||||
|
||||
Observed live on ST10 firmware `27.0.6.46330.5043500` (build `epdbuild.trunk.hepdswbld04.2022-08-04`) on 2026-05-12, by running `soundtouch-cli setup factory-reset` and tailing the speaker's `logread` over SSH. The trace is preserved at `_/logs/factory-reset.txt` for reference.
|
||||
|
||||
## Sequence
|
||||
|
||||
1. **Telnet receives `sys factorydefault`.** The diagnostic shell on port 17000 accepts the command and acknowledges. Some firmwares close the socket as part of the reboot — our CLI's `setup factory-reset` tolerates that as success.
|
||||
|
||||
2. **Speaker DELETEs itself from its marge account.** Before wiping anything, the firmware does:
|
||||
```
|
||||
[MargeStateAssociated] HandleRemoveDeviceRequest - Removing this device from the user's Marge account
|
||||
[MargeClient] RemoveDevice calling Marge Server with https://streaming.bose.com/streaming/account/{accountId}/device/{deviceId}
|
||||
[MargeClient] RemoveDeviceCB - Device removed from the user's Marge account
|
||||
[MargeStateAssociated] HandleRemoveDeviceRequestSuccessCB, Marge returned: {"ok": true}
|
||||
```
|
||||
AfterTouch already handles this — `HandleMargeRemoveDevice` (`pkg/service/handlers/handlers_marge.go:633`) routed via `r.Delete("/device/{device}", …)` in `cmd/soundtouch-service/main.go:955`. The handler calls `marge.RemoveDeviceFromAccount(s.ds, account, device)` and prunes the device from the datastore.
|
||||
|
||||
3. **Speaker notifies its LAN peers.** Two HTTP POSTs to each known peer at `:8090/notification`:
|
||||
```
|
||||
[NotificationSender] SendNotifyLisas_: URL: >>http://192.0.2.122:8090/notification<<, m_msgdata.size(58)
|
||||
[SimpleURLFetcher] multipart/form-data text/xml
|
||||
```
|
||||
~58 bytes of `multipart/form-data` carrying `text/xml`. "Lisas" is the firmware's internal term for LAN peers (devices on the same account on the same network segment). AfterTouch is **not** on this path — it's pure peer-to-peer over the LAN. Peers presumably refresh their account info as a result.
|
||||
|
||||
4. **Local state teardown.** Bluetooth pairings cleared (`BTRemoteDeviceAccess::ClearPrevPairedList`), zone/group state torn down, all source proxies disconnected (`STSAccountProxy::Disconnect Requested` × many).
|
||||
|
||||
5. **Persistence cleanup.** Logs, core dumps wiped (`FactoryDefault: Clearing the CoreDump and BoseLogs … rm -rf /mnt/nv/BoseLog/*`). Notably **NOT wiped**: `/mnt/nv/aftertouch.resolv.conf`, `/mnt/nv/rc.local`'s Aftertouch hook, and `/mnt/nv/BoseApp-Persistence/1/SystemConfigurationDB.xml`. The reset only touches log directories and account-specific persistence under the same `/mnt/nv/BoseApp-Persistence/1/` tree.
|
||||
|
||||
6. **Reboot into setup mode.** Speaker drops Wi-Fi, comes back as its own AP `Bose SoundTouch XXXX` on 192.0.2.1.
|
||||
|
||||
## Implications for migration ordering
|
||||
|
||||
The DELETE in step 2 only reaches AfterTouch if the speaker's `margeURL` already points at AfterTouch *at the moment of reset*. A speaker still pointing at `streaming.bose.com` sends it into the void → AfterTouch keeps a stale `account/{id}/device/{id}` entry until someone manually prunes it.
|
||||
|
||||
Therefore for a clean datastore lifecycle on an already-Bose-paired speaker:
|
||||
|
||||
1. Migrate URLs first (`setup migrate --method=resolv` or `--method=telnet`).
|
||||
2. Reboot to apply.
|
||||
3. Factory reset.
|
||||
4. Re-provision.
|
||||
|
||||
`soundtouch-cli setup plan --reset` currently runs factory-reset first (optimal for already-on-AfterTouch speakers); both `setup plan --reset` and `setup factory-reset` print a one-line note explaining the ordering tradeoff so users can pick the right sequence for their starting state.
|
||||
|
||||
## Implications for AfterTouch behaviour
|
||||
|
||||
- The DELETE handler is already correct; no changes needed.
|
||||
- AfterTouch is invisible to the LAN-peer notification step — that's just LAN HTTP between speakers.
|
||||
- If you build a "consolidate account" / "migrate fleet" feature later, the peer-notification channel is the propagation path the firmware uses internally; AfterTouch doesn't need to do anything analogous.
|
||||
- The persistence layer at `/mnt/nv/` is **factory-reset-resistant**. Our DNS-redirect migration (`setup migrate --method=resolv`) writes there specifically so AfterTouch routing survives a reset. This is intentional — the user can factory-reset a speaker freely without re-running migration.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Are there other peer endpoints the firmware POSTs to besides `:8090/notification`? Worth checking on a 3-speaker LAN.
|
||||
- Does the `:8090/notification` payload format match the format used for play-as-notification audio pushes, or is it a distinct message shape? The "size(58)" byte count is too small for an audio URL but big enough for an XML envelope with an event type.
|
||||
|
||||
If you want either of these answered, capture two synchronised `logread -f` streams from two LAN speakers while one is being reset.
|
||||
|
||||
## Runbook — reset & re-provision an ST10 on AfterTouch
|
||||
|
||||
End-to-end command sequence used during the 2026-05-12 bare-pairing experiment, recorded verbatim from the test session. Replace IPs, SSID, password, service URL, and account ID with your own. Two manual Wi-Fi switches happen between `factory-reset` and `wifi-push` (host joins the speaker's AP) and again between `wifi-push` and `wait-online` (host re-joins home Wi-Fi).
|
||||
|
||||
```bash
|
||||
# === 1. Reconnaissance — confirm what state the speaker is in before touching it. ===
|
||||
|
||||
# Identity, network, sources, presets.
|
||||
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup inspect
|
||||
|
||||
# Green/red status across every migration axis (SSH, telnet, CA, pairing, …).
|
||||
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup verify \
|
||||
--service-url=https://soundtouch.fritz.box
|
||||
|
||||
# What `setup plan --reset` would recommend, so you can preview the sequence.
|
||||
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup plan \
|
||||
--service-url=https://soundtouch.fritz.box --reset
|
||||
|
||||
|
||||
# === 2. Reset and Wi-Fi re-provisioning. ===
|
||||
|
||||
# Tell the speaker to wipe itself. Speaker drops Wi-Fi and reboots into AP mode.
|
||||
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup factory-reset
|
||||
|
||||
# Manual: switch this host to the speaker's setup AP.
|
||||
# macOS: networksetup -setairportnetwork en0 "Bose SoundTouch XXXX"
|
||||
|
||||
# Poll 192.0.2.1:8090/info until the speaker answers (interval=2s, timeout=5m).
|
||||
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup wait-ap
|
||||
|
||||
# Push home Wi-Fi credentials. NOTE the single-quoted password: zsh expands `!`
|
||||
# inside double quotes as history-expansion and will refuse the command.
|
||||
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup wifi-push \
|
||||
--ssid="wifi-name" --pass='a.secure!password'
|
||||
|
||||
# Manual: switch host back to home Wi-Fi.
|
||||
# macOS: networksetup -setairportnetwork en0 "wifi-name" 'a.secure!password'
|
||||
|
||||
# mDNS-poll for the speaker on the home network, matched by deviceID suffix
|
||||
# (which survives the reset since it's the MAC). Returns the new IP.
|
||||
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup wait-online --match=536A98
|
||||
|
||||
|
||||
# === 3. Clock, migrate, pair. From here on use the new IP wait-online reported. ===
|
||||
|
||||
# Set the speaker's wall-clock. `clock set --time=now` fails on FW 27;
|
||||
# `clock now` is the working subcommand.
|
||||
go run ./cmd/soundtouch-cli --host 192.0.2.123 clock now
|
||||
|
||||
# Reboot to clear any half-initialized resolver / NTP state from the wifi-push flap.
|
||||
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup reboot
|
||||
|
||||
# Apply DNS-redirect migration: routes *.bose.com to AfterTouch and installs its CA.
|
||||
# Idempotent; safe to re-run.
|
||||
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup migrate \
|
||||
--service-url=https://soundtouch.fritz.box --method=resolv
|
||||
|
||||
# Reboot again so the envswitch parallel-persistence layer and the resolv hook
|
||||
# both take effect on the next boot.
|
||||
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup reboot
|
||||
|
||||
# Pair the device with an AfterTouch account — bare experiment variant.
|
||||
# Drop --mode=bare and add --name=… / --language=… for the full state-machine variant.
|
||||
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup pair \
|
||||
--mode=bare --account=1111111 --service-url='https://soundtouch.fritz.box'
|
||||
|
||||
|
||||
# === 4. Verify. ===
|
||||
|
||||
# Reboot to verify persistence survives.
|
||||
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup reboot
|
||||
|
||||
# Snapshot the result. margeAccountUUID should still equal --account, and Sources
|
||||
# should list ~14 entries (TUNEIN, RADIO_BROWSER, LOCAL_INTERNET_RADIO,
|
||||
# SPOTIFY slots, AIRPLAY, etc.) materialized by the firmware.
|
||||
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup inspect
|
||||
```
|
||||
|
||||
Total wall-clock for the above on this hardware: roughly 5 minutes including the two manual Wi-Fi switches and three reboots.
|
||||
@@ -1,43 +0,0 @@
|
||||
# Spotify Account Addition Implementation Status
|
||||
|
||||
To fully replace Bose cloud services for the Spotify account addition flow in the "Stockholm" SoundTouch application, the following routes have been implemented in the `soundtouch-service`:
|
||||
|
||||
## 1. OAuth Token Exchange (Bose Cloud)
|
||||
|
||||
The Stockholm background worker (in `worker_common.js` and `spotify_worker.js`) performs a token exchange using an authorization code.
|
||||
|
||||
* **Route**: `POST /oauth/account/{account}/music/musicprovider/{sourceID}/token/cs`
|
||||
* **Purpose**: To exchange the Spotify authorization code for a Bose-mediated token.
|
||||
* **Implementation**: `HandleBoseAccountToken` in `pkg/service/handlers/handlers_oauth.go`.
|
||||
* **Registration**: Registered in `cmd/soundtouch-service/main.go` under the `/oauth` route group.
|
||||
|
||||
## 2. Cloud Source Registration (Marge Service)
|
||||
|
||||
The SoundTouch application registers a new music source (e.g., Spotify) with the Bose cloud profile.
|
||||
|
||||
* **Route**: `POST /streaming/account/{account}/source`
|
||||
* **Purpose**: To add the new source (username, credentials, display name) to the user's emulated cloud profile.
|
||||
* **Implementation**: `HandleMargeAddSource` in `pkg/service/handlers/handlers_marge.go`.
|
||||
* **Registration**: Registered in `cmd/soundtouch-service/main.go` under the `/streaming` route group.
|
||||
* **Payload Format**: XML `application/vnd.bose.streaming-v1.1+xml` containing `<source>` with `<username>`, `<sourceproviderid>`, and `<credential type="token_version_3">`.
|
||||
|
||||
## 3. Redirect Handling (Browser to App)
|
||||
|
||||
The `soundtouch://` deep link redirect URI is handled by the management interface which provides the OAuth callback.
|
||||
|
||||
* **Callback Route**: `GET /mgmt/spotify/callback`
|
||||
* **Implementation**: `HandleMgmtSpotifyCallback` in `pkg/service/handlers/handlers_mgmt.go`.
|
||||
* **Confirmation Route**: `POST /mgmt/spotify/confirm` (used by mobile apps for deep-link codes).
|
||||
* **Implementation**: `HandleMgmtSpotifyConfirm` in `pkg/service/handlers/handlers_mgmt.go`.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
1. **Marge Add Source**:
|
||||
* `HandleMargeAddSource` in `pkg/service/handlers/handlers_marge.go` parses the incoming XML and persists the new source to the `DataStore` for the corresponding account.
|
||||
|
||||
2. **OAuth Account Token Exchange**:
|
||||
* `HandleBoseAccountToken` in `pkg/service/handlers/handlers_oauth.go` supports the `/oauth/account/.../token/cs` path.
|
||||
* It responds with a JSON payload including `access_token` and `token_type` "Bearer" after exchanging the code via `ExchangeCodeAndStore`.
|
||||
|
||||
3. **Router Registration**:
|
||||
* These paths are registered in `cmd/soundtouch-service/main.go` within the `/streaming`, `/oauth`, and `/mgmt` route blocks.
|
||||
@@ -1,215 +0,0 @@
|
||||
# Experiment: Does bare `setMargeAccount` work outside the SETUP bracket?
|
||||
|
||||
## Why we are doing this
|
||||
|
||||
Our captured pairing flow (`docs/reference/DEVICE-PAIRING-FLOW.md`) shows the official Bose app always sends `setMargeAccount` *inside* a `SETUP_START` → `SETUP_ENTER` → `SETUP_LEAVE` state-machine bracket over WebSocket. The question this experiment answers:
|
||||
|
||||
> If we open a WebSocket to a factory-reset speaker and send **only** `setMargeAccount` — no surrounding setupState messages — does the device honor it and write its persistence files (`SystemConfigurationDB.xml`, `Sources.xml`) cleanly?
|
||||
|
||||
The answer determines the shape of `PairAccount`:
|
||||
|
||||
- **If YES:** `PairAccount` becomes uniform: WebSocket-first, HTTP `/setMargeAccount` second, telnet `envswitch accountid set` third. One function, one ordering, all callers.
|
||||
- **If NO:** WebSocket pairing is only meaningful inside the full state machine. Factory-reset path uses the state machine; re-pair path keeps today's HTTP→telnet ordering.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- A SoundTouch speaker that has been **factory-reset** and joined to the test Wi-Fi.
|
||||
- Speaker reachable on `:8090` (HTTP API) and `:8080` (WebSocket).
|
||||
- Speaker's runtime marge URL already points at AfterTouch (run the existing telnet URL rewrite first — otherwise the device's downstream POST will land on the dead Bose cloud and we will not be able to distinguish "WS message refused" from "downstream cloud failed").
|
||||
- A free 7-digit account ID — for example, generated via `setup.GenerateAccountID(nil)`.
|
||||
|
||||
## Step 0 — Baseline
|
||||
|
||||
```bash
|
||||
DEVICE=192.168.x.x
|
||||
curl -s http://$DEVICE:8090/info | xmllint --format -
|
||||
curl -s http://$DEVICE:8090/sources | xmllint --format -
|
||||
curl -s http://$DEVICE:8090/presets | xmllint --format -
|
||||
```
|
||||
|
||||
Record:
|
||||
|
||||
- `<margeAccountUUID>` — expect empty on a factory-reset device.
|
||||
- `<margeURL>` — expect the AfterTouch URL (preflight already applied).
|
||||
- `<sources>` — expect a minimal list.
|
||||
- `<presets>` — expect `<presets/>`.
|
||||
|
||||
## Step 1 — Send bare `setMargeAccount` over WebSocket
|
||||
|
||||
Build the CLI once:
|
||||
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
Then run the bare path against the speaker:
|
||||
|
||||
```bash
|
||||
DEVICE=192.168.x.x
|
||||
./build/soundtouch-cli setup pair --host=$DEVICE --account=1234567 --mode=bare
|
||||
```
|
||||
|
||||
What it does:
|
||||
|
||||
1. Reads `/info` to discover `deviceID`, logs the pre-state.
|
||||
2. Opens a WebSocket to `$DEVICE:8080` with the `gabbo` subprotocol.
|
||||
3. Sends exactly one frame — the `setMargeAccount` envelope — **without** any preceding `SETUP_START`/`SETUP_ENTER`.
|
||||
4. Reads frames for up to `--step-timeout=8s` (configurable), looking for an ack referencing our `requestID`.
|
||||
5. Closes the WebSocket, waits 2 s, re-reads `/info`, prints whether `margeAccountUUID` now equals our supplied ID.
|
||||
|
||||
The exact frame sent (built by `setup.SetupSession.SetMargeAccount`):
|
||||
|
||||
```xml
|
||||
<msg><header deviceID="DEVICE_ID" url="setMargeAccount" method="POST"><request requestID="1"/></header><body>
|
||||
<PairDeviceWithAccount>
|
||||
<accountId>1234567</accountId>
|
||||
<userAuthToken>Bearer aftertouch</userAuthToken>
|
||||
</PairDeviceWithAccount>
|
||||
</body></msg>
|
||||
```
|
||||
|
||||
Outcomes the CLI will surface:
|
||||
|
||||
- `Device accepted bare pairing.` (post-`/info` shows our ID) → **bare path works**.
|
||||
- `setMargeAccount: device rejected setMargeAccount: …` → device returned an `<error>` body → **bare path refused explicitly**.
|
||||
- `setMargeAccount: await ack for setMargeAccount: …` (timeout or EOF) → **bare path refused silently**.
|
||||
- `Device did NOT persist the pairing — bare path likely refused silently.` → ack received but persistence didn't follow.
|
||||
|
||||
## Step 2 — Record outcome
|
||||
|
||||
After step 1 (regardless of which branch happened):
|
||||
|
||||
```bash
|
||||
sleep 2
|
||||
curl -s http://$DEVICE:8090/info | grep margeAccountUUID
|
||||
```
|
||||
|
||||
| Observed result | Verdict |
|
||||
|------------------------------------------------------------------------------|-----------------------------|
|
||||
| `<margeAccountUUID>1234567</margeAccountUUID>` appears | **YES** — Option 1 wins |
|
||||
| `<margeAccountUUID></margeAccountUUID>` still empty, no error frame received | Refused silently → **NO** |
|
||||
| Error frame returned (e.g. `<error name="UNSUPPORTED_STATE"/>`) | Refused explicitly → **NO** |
|
||||
| Device drops the WebSocket connection without replying | Refused → **NO** |
|
||||
|
||||
If verdict is YES, also verify the device wrote persistence cleanly. Reboot the device, then:
|
||||
|
||||
```bash
|
||||
ssh root@$DEVICE 'cat /mnt/nv/BoseApp-Persistence/1/SystemConfigurationDB.xml'
|
||||
ssh root@$DEVICE 'cat /mnt/nv/BoseApp-Persistence/1/Sources.xml'
|
||||
curl -s http://$DEVICE:8090/info | grep margeAccountUUID
|
||||
```
|
||||
|
||||
The UUID must still be present after reboot, and `SystemConfigurationDB.xml` must contain `<AccountUUID>1234567</AccountUUID>`. If it survives reboot, **YES** is confirmed.
|
||||
|
||||
## Step 3 — Control: full state machine
|
||||
|
||||
Factory-reset the same speaker again and run the full state machine — the same CLI, `--mode=full`:
|
||||
|
||||
```bash
|
||||
./build/soundtouch-cli setup pair --host=$DEVICE --account=1234567 --mode=full
|
||||
```
|
||||
|
||||
This drives `setup.Manager.ExecuteInitPlan` with `SkipURLRewrite=true`, which runs:
|
||||
|
||||
```
|
||||
SETUP_START
|
||||
SETUP_IDENTIFY_DEVICE_ENTER
|
||||
language sysLanguage=2
|
||||
SETUP_ENTER
|
||||
SETUP_IDENTIFY_DEVICE_LEAVE
|
||||
setMargeAccount …
|
||||
SETUP_LEAVE
|
||||
pushCustomerSupportInfoToMarge
|
||||
```
|
||||
|
||||
The CLI logs every step with status. Confirm `/info`, persistence, and reboot-survival checks pass. If the bare path failed but the full path succeeds, the SETUP bracket is load-bearing — a follow-up bisect (e.g. `SETUP_START + setMargeAccount + SETUP_LEAVE` only) tells us *which* surrounding messages the firmware actually requires.
|
||||
|
||||
## Full reset-and-rebuild loop
|
||||
|
||||
Once the bare/full question is decided, the loop for repeated experiments is:
|
||||
|
||||
```bash
|
||||
# 0. Speaker is currently on home Wi-Fi at $DEVICE.
|
||||
# Capture deviceID-suffix + current SSID first so wait-online and
|
||||
# wifi-push have the right inputs.
|
||||
./build/soundtouch-cli setup inspect --host=$DEVICE
|
||||
./build/soundtouch-cli setup factory-reset --host=$DEVICE
|
||||
|
||||
# 1. Manually switch this host to the speaker's AP (Bose SoundTouch XXXX).
|
||||
# macOS: networksetup -setairportnetwork en0 "Bose SoundTouch XXXX"
|
||||
|
||||
./build/soundtouch-cli setup wait-ap
|
||||
./build/soundtouch-cli setup wifi-push --ssid="$HOME_SSID" --pass="$HOME_PASS"
|
||||
|
||||
# 2. Manually switch this host back to home Wi-Fi.
|
||||
|
||||
./build/soundtouch-cli setup wait-online --match=DE4803 # deviceID suffix from /info before reset
|
||||
# (note the new IP from the "Speaker discovered" line)
|
||||
|
||||
NEW_IP=192.168.x.y
|
||||
./build/soundtouch-cli setup migrate --host=$NEW_IP --service-url=http://aftertouch.local:8000 # default --method=telnet
|
||||
|
||||
# Optional, if you want the DNS-redirect path instead of (or alongside) telnet envswitch:
|
||||
# 1. ./build/soundtouch-cli setup ssh-check --host=$NEW_IP # USB-stick procedure if 22 is closed
|
||||
# 2. ./build/soundtouch-cli setup install-ca --host=$NEW_IP --service-url=http://aftertouch.local:8000
|
||||
# 3. ./build/soundtouch-cli setup migrate --host=$NEW_IP --service-url=http://aftertouch.local:8000 --method=resolv
|
||||
./build/soundtouch-cli setup pair --host=$NEW_IP --mode=bare # or --mode=full
|
||||
```
|
||||
|
||||
The two manual lines are user-side Wi-Fi switches that can't be automated portably. The `wait-ap` and `wait-online` subcommands poll for the corresponding network state, so timing them is hands-off.
|
||||
|
||||
## Recording the result
|
||||
|
||||
Append to this file under `## Results`:
|
||||
|
||||
```
|
||||
- Date: YYYY-MM-DD
|
||||
- Firmware: 27.x.x
|
||||
- Model: ST10 / ST20 / ST30 / ST300
|
||||
- Bare setMargeAccount accepted: yes/no
|
||||
- Persistence written: yes/no
|
||||
- Survives reboot: yes/no
|
||||
- Notes: ...
|
||||
```
|
||||
|
||||
One row per device tested. Once two devices on different firmware confirm the same verdict, we treat it as decided.
|
||||
|
||||
## Results
|
||||
|
||||
- Date: 2026-05-13
|
||||
- Firmware: 27.0.6.46330.5043500 (build epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29)
|
||||
- Model: SoundTouch 10 (deviceID AABBCCDDEEFF)
|
||||
- Bare setMargeAccount accepted: **yes** — pre-/info margeAccountUUID="" → post-/info margeAccountUUID="1111111"
|
||||
- Persistence written: **yes** — device materialized 14-entry Sources.xml on its own
|
||||
- Survives reboot: **yes** — `setup inspect` after `setup reboot` shows margeAccountUUID still 1111111
|
||||
- Notes: After bare pairing, the speaker did the full post-pairing handshake against AfterTouch (POST /streaming/support/power_on, GET /streaming/sourceproviders, GET /streaming/account/{id}/full, group/, provider_settings). No SETUP_START/SETUP_ENTER/SETUP_LEAVE was ever sent. Verdict: bare path is functionally equivalent to the full state machine on this firmware.
|
||||
|
||||
### Implication for the codebase
|
||||
|
||||
- `pkg/service/setup/setup_session.go` keeps the full state machine for completeness, but
|
||||
- `pkg/service/setup/init_plan.go`'s default could be simplified to "send setMargeAccount only" once we have one more confirming run on a different model.
|
||||
- The OCT issue-167 SSH-XML seeding workaround is **not required**.
|
||||
|
||||
### Appendix — SystemConfigurationDB.xml comparison
|
||||
|
||||
Post-experiment we compared the device-written `/mnt/nv/BoseApp-Persistence/1/SystemConfigurationDB.xml` from the bare-paired speaker against two SSH backups taken from speakers originally paired by the official Bose app (account 1000001, devices `A_Sound_Machine` and `Sound_Machinechen`). The diff is much smaller than expected — only two fields differ, and neither is set by the pairing protocol itself:
|
||||
|
||||
| Field | Bare-paired (1111111) | Real-Bose-paired (1000001) | Set by |
|
||||
|--------------------------|--------------------------------------------|----------------------------|-----------------------------------------------------------------------------------------------------------|
|
||||
| `DeviceName` | `Bose SoundTouch 536A98` (factory default) | `Living Room SoundTouch` | `name` WS message — only sent in `--mode=full` |
|
||||
| `AccountAssociatedEMail` | empty | **empty** | Never populated, even by real Bose |
|
||||
| `AccountUUID` | `1111111` | `1000001` | `setMargeAccount` — both paths set it |
|
||||
| `Locale` | empty | **empty** | Never populated, even by real Bose |
|
||||
| `acctMode` | `global` | `global` | Firmware-default; no protocol path observed to change it |
|
||||
| `isMultiDeviceAccount` | `false` | `true` | Derived from the cloud's `/streaming/account/{id}/full` response — count of `<devices>` > 1 flips it true |
|
||||
| `margeAuthServerToken` | empty | **empty** | Never populated, even by real Bose |
|
||||
| `Password` | (encrypted blob) | (encrypted blob) | Device-local key; expected to differ |
|
||||
|
||||
Three of the seven informational fields are empty even after a real-Bose pairing — the firmware simply doesn't populate `AccountAssociatedEMail`, `Locale`, or `margeAuthServerToken` from the pairing flow. So bare pairing isn't missing any field that real pairing fills.
|
||||
|
||||
The two genuinely different fields:
|
||||
|
||||
- **`DeviceName`** — pure UX. Settable any time post-pair via `name` POST (`soundtouch-cli name set --value=…`) or by sending the `name` WS message during `--mode=full` pairing.
|
||||
- **`isMultiDeviceAccount`** — not a pairing concern. It's derived from the account's device count on AfterTouch's side; flips to `true` automatically the next time the speaker refreshes account state if a second speaker has been paired to the same account.
|
||||
|
||||
So the experiment's YES verdict stands unqualified: bare `setMargeAccount` produces a `SystemConfigurationDB.xml` functionally equivalent to one written by the official pairing flow.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user