Compare commits

..
4 Commits
Author SHA1 Message Date
Tobias Gesellchen 49ec2d58db wip 2026-03-07 19:51:19 +01:00
Tobias Gesellchen c78664ee59 wip 2026-03-07 19:51:19 +01:00
Tobias Gesellchen c17ba0e839 Add Stockholm Mini (3)
This is also a refactoring of our api paths
2026-03-07 19:51:19 +01:00
Tobias Gesellchen 17f052308f wip 2026-03-07 19:51:19 +01:00
622 changed files with 12019 additions and 80681 deletions
-5
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
```
+1 -1
View File
@@ -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
```
+1 -1
View File
@@ -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**
+1 -1
View File
@@ -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)
-22
View File
@@ -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"
+1 -16
View File
@@ -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": [
+2 -2
View File
@@ -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
View File
@@ -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@v3
- 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@v3
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@v5
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@v6
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 {
+5 -5
View File
@@ -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
+31 -86
View File
@@ -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@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@v3
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@v5
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@v6
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"
+13 -22
View File
@@ -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
-50
View File
@@ -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
-34
View File
@@ -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,21 +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
-7
View File
@@ -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
-228
View File
@@ -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
View File
@@ -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:
[![GitHub Sponsors](https://img.shields.io/github/sponsors/gesellix?label=Sponsor%20on%20GitHub&logo=GitHub&color=ea4aaa)](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
View File
@@ -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"]
-40
View File
@@ -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
+36 -288
View File
@@ -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"
+520 -130
View File
@@ -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.
[![Go Reference](https://pkg.go.dev/badge/github.com/gesellix/bose-soundtouch.svg)](https://pkg.go.dev/github.com/gesellix/bose-soundtouch)
[![Go Report Card](https://goreportcard.com/badge/github.com/gesellix/bose-soundtouch)](https://goreportcard.com/report/github.com/gesellix/bose-soundtouch)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
- 📻 **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
- **🎮 Stockholm Mini**: A minimal reverse-engineered UI for device control (accessible at `/web/stockholm-mini/`)
- **💾 Persistent Data**: Store presets, recents, and sources locally
- **🔄 Endpoint Mirroring**: Asynchronously mirror local requests to Bose cloud for parity testing
- **⚖️ Parity Logging**: Detect and record discrepancies between local and official Bose responses
- **📝 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.
[![GitHub Sponsors](https://img.shields.io/github/sponsors/gesellix?label=Sponsor%20on%20GitHub&logo=GitHub&color=ea4aaa)](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).
-107
View File
@@ -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
}
-152
View File
@@ -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
}
-23
View File
@@ -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)
}
}
-23
View File
@@ -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)
}
}
-212
View File
@@ -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
-119
View File
@@ -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
}
-252
View File
@@ -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")
}
-288
View File
@@ -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 ""
}
-172
View File
@@ -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) }
-37
View File
@@ -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)
}
}
-67
View File
@@ -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 {
-27
View File
@@ -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)
+4 -118
View File
@@ -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)
-315
View File
@@ -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()
}
}
-184
View File
@@ -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)
}
}
+7 -20
View File
@@ -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
+4 -4
View File
@@ -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,
},
{
+4 -4
View File
@@ -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
-310
View File
@@ -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")
}
}
-57
View File
@@ -1,9 +1,7 @@
package main
import (
"encoding/base64"
"fmt"
"net/url"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/models"
@@ -253,61 +251,6 @@ func selectLocalInternetRadio(c *cli.Context) error {
return nil
}
// selectCustomRadio handles selecting custom radio stream via soundtouch-service
func selectCustomRadio(c *cli.Context) error {
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
streamURL := c.String("url")
itemName := c.String("name")
containerArt := c.String("artwork")
serviceURL := c.String("service-url")
encodedURL := base64.URLEncoding.EncodeToString([]byte(streamURL))
location := fmt.Sprintf("%s/custom/v1/playback/%s", serviceURL, encodedURL)
params := url.Values{}
if itemName != "" {
params.Add("name", itemName)
}
if containerArt != "" {
params.Add("imageUrl", containerArt)
}
if len(params) > 0 {
location += "?" + params.Encode()
}
// Check LOCAL_INTERNET_RADIO availability
checker := NewServiceAvailabilityChecker(client)
if !checker.CheckSourceAvailable("LOCAL_INTERNET_RADIO", "select custom radio") {
return fmt.Errorf("LOCAL_INTERNET_RADIO is not available")
}
PrintDeviceHeader("Selecting custom radio stream", clientConfig.Host, clientConfig.Port)
if itemName != "" {
fmt.Printf(" Station: %s\n", itemName)
}
fmt.Printf(" URL: %s\n", streamURL)
fmt.Printf(" Proxy: %s\n", location)
err = client.SelectLocalInternetRadio(location, "", itemName, containerArt)
if err != nil {
return fmt.Errorf("failed to select custom radio: %w", err)
}
PrintSuccess("Custom radio stream selected")
return nil
}
// selectLocalMusic handles selecting LOCAL_MUSIC source
func selectLocalMusic(c *cli.Context) error {
clientConfig := GetClientConfig(c)
-152
View File
@@ -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
}
-157
View File
@@ -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)
}
}
+2 -2
View File
@@ -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")
}
+22 -22
View File
@@ -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 -169
View File
@@ -924,34 +924,6 @@ func main() {
},
},
},
{
Name: "custom-radio",
Usage: "Select custom radio stream via soundtouch-service",
Action: selectCustomRadio,
Before: RequireHost,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "url",
Aliases: []string{"u"},
Usage: "Stream URL",
Required: true,
},
&cli.StringFlag{
Name: "name",
Aliases: []string{"n"},
Usage: "Station name",
},
&cli.StringFlag{
Name: "artwork",
Usage: "Station artwork URL",
},
&cli.StringFlag{
Name: "service-url",
Usage: "URL of the soundtouch-service (default: http://localhost:8080)",
Value: "http://localhost:8080",
},
},
},
{
Name: "local-music",
Usage: "Select local music content (LOCAL_MUSIC)",
@@ -1054,43 +1026,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 +1284,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 +1450,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 +2010,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 +2041,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 +2053,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 +2065,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)
+32 -32
View File
@@ -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,
},
}
File diff suppressed because it is too large Load Diff
-159
View File
@@ -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
-172
View File
@@ -1,172 +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/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
-2
View File
@@ -1,2 +0,0 @@
soundtouch-web
soundtouch-web-test
-276
View File
@@ -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.
-330
View File
@@ -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
-225
View File
@@ -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, "/", "_")
}
-363
View File
@@ -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)
}
})
}
}
+6 -8
View File
@@ -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
View File
@@ -1,5 +1,4 @@
accounts/
backend/
certs/
default/
dns/
+2 -2
View File
@@ -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
//
-46
View File
@@ -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
View File
@@ -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.
+4 -4
View File
@@ -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
View File
@@ -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
+5 -22
View File
@@ -23,14 +23,6 @@ All content selection features from the [SoundTouch WebServices API Wiki](https:
- Automatic defaults for missing parameters
- **Use Cases**: Internet radio streams, proxy-based radio services
#### `SelectLocalInternetRadio(location, ...)` via `soundtouch-service`
- **Purpose**: Select custom radio stream via local `soundtouch-service` proxy
- **Features**:
- Flexible stream URL encoding (Base64 or URL-escaped)
- Dynamic generation of Bose-compatible playback JSON
- Seamless integration with existing `LOCAL_INTERNET_RADIO` source
- **Use Case**: Playing any internet radio URL without external proxy dependencies
#### `SelectLocalMusic(location, sourceAccount, itemName, containerArt string) error`
- **Purpose**: Select LOCAL_MUSIC content from SoundTouch App Media Server
- **Requirements**: SoundTouch App Media Server running on a computer
@@ -55,15 +47,6 @@ soundtouch-cli --host <device> source internet-radio \
--artwork "https://example.com/art.png"
```
#### `soundtouch-cli source custom-radio`
```bash
soundtouch-cli --host <device> source custom-radio \
--url "https://stream.example.com/radio" \
--name "My Station" \
--artwork "https://example.com/art.png" \
--service-url "http://localhost:8080"
```
#### `soundtouch-cli source local-music`
```bash
soundtouch-cli --host <device> source local-music \
@@ -118,7 +101,7 @@ Comprehensive test suites implemented for all new functionality:
### Example Code
Complete working example demonstrating:
- LOCAL_INTERNET_RADIO with streamUrl proxy format
- LOCAL_INTERNET_RADIO with direct streams
- LOCAL_INTERNET_RADIO with direct streams
- LOCAL_MUSIC content selection
- STORED_MUSIC content selection
- Generic ContentItem usage
@@ -169,7 +152,7 @@ All convenience methods create properly structured `ContentItem` objects:
Based on the wiki structure, these related features are also supported:
1. **LOCAL_MUSIC**: ✅ Fully implemented
2. **STORED_MUSIC**: ✅ Fully implemented
2. **STORED_MUSIC**: ✅ Fully implemented
3. **SPOTIFY**: ✅ Previously implemented
4. **TUNEIN**: ✅ Previously implemented
5. **BLUETOOTH**: ✅ Previously implemented
@@ -186,7 +169,7 @@ err := client.SelectLocalInternetRadio(location, "", "My Station", "")
// Direct ContentItem
contentItem := &models.ContentItem{
Source: "LOCAL_INTERNET_RADIO",
Type: "stationurl",
Type: "stationurl",
Location: location,
ItemName: "My Station",
IsPresetable: true,
@@ -197,12 +180,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"
```
+2 -2
View File
@@ -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:~#
-6
View File
@@ -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
-114
View File
@@ -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.
-66
View File
@@ -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
View File
@@ -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
View File
@@ -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
+5 -5
View File
@@ -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
+1 -1
View File
@@ -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)
-87
View File
@@ -1,87 +0,0 @@
### Overview of Recent Improvements and Next Steps
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.
* **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.
* **Automatic Source Learning**: The service now extracts and persists full metadata (credentials, provider IDs, and custom names) from incoming `POST /recent` requests. This improves parity for subsequent `GET /recents` calls.
* **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.
* **Credential Preservation**: Improved `AddRecent` to correctly extract and echo back base64 tokens/credentials provided in the incoming request, improving source learning.
* **XML Formatting Parity**:
* Added `standalone="yes"` to the XML declaration for all Marge responses, including `recent`, `presets`, `full account`, `software update`, and `sourceproviders`.
* Enforced self-closing `<sourceSettings/>` tags for parity.
* Standardized date formatting to UTC with milliseconds (`.000+00:00`).
* Fixed casing for `/streaming/sourceproviders`: Root element is `<sourceProviders>`, but child elements are `<sourceprovider>` (all lowercase), matching upstream behavior.
* Implemented structured XML marshaling with consistent 2-space indentation for recents and source providers.
* **Improved TuneIn Parity**: Fixed TuneIn source mapping to use ID `25` and ensuring `sourcename` is empty in responses, matching upstream behavior for station playback.
* **High-Fidelity Full Account Sync**: Refactored the `/streaming/account/{accountId}/full` response to match the upstream structure. This includes:
* **Mapped Preset `buttonNumber`**: Correctly mapped the internal `ServicePreset.ID` to the `buttonNumber` XML attribute in the `/full` response.
* **Structured XML Marshaling**: Replaced manual string concatenation with structured Go models and `xml.Marshal` for the entire response.
* **Specific Response Models**: Introduced `FullResponseSource`, `FullResponsePreset`, and `FullResponseRecent` to accurately reflect the upstream structure where `<source>` is a child element, rather than a set of attributes.
* **Correct Nesting**: Ensured that `<presets>` and `<recents>` correctly nest their associated `<source>` details, resolving previous data omissions.
* **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.
* **Unified Response Logic**: Refactored the code so that both `POST /recent` and `GET /recents` use the same formatting functions, guaranteeing consistency.
* **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:
#### 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.
* **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.
#### 3. OAuth / Spotify Token Noise (Low/Medium)
The `/oauth/device/.../token` endpoint frequently reports mismatches because tokens are naturally different between local and upstream.
* **The Issue**: This creates "noise" in your parity reports that isn't actually a bug.
* **Action**: Update the parity detection logic (or the handler) to selectively ignore the `access_token` field while still verifying that the rest of the JSON structure (expires_in, scope, token_type) matches.
#### 4. Large IDs for Other Models (Medium)
While we fixed IDs for `recents`, other models like `presets` or `sources` might still use small auto-incrementing integers.
* **Action**: Evaluate if other endpoints should also transition to the timestamp-based ID schema to further reduce diff noise.
#### 5. Improved Data Persistence (Continuous)
Continue the "learning" approach for other services. For example, if we see a new `sourceproviderid` in a Spotify or TuneIn request, we should ensure it is stored and reused.
#### 6. Local Reboot & Device State Management (Continuous)
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:
**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.
-45
View File
@@ -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.
-57
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+5 -5
View File
@@ -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
+4 -4
View File
@@ -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 %}
+1 -1
View File
@@ -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 {
+5 -5
View File
@@ -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
@@ -119,7 +119,7 @@ soundtouch-service
```go
// Build custom applications on top of local services
client := &http.Client{}
resp, _ := client.Get("http://localhost:8000/setup/devices")
resp, _ := client.Get("http://localhost:8000/devices")
```
### Privacy-Conscious Users
@@ -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
+1 -26
View File
@@ -4,17 +4,11 @@
## User Guides
* [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)
@@ -34,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)
@@ -54,7 +46,6 @@
* [Request Recording](REQUEST_RECORDING_CONCEPT.md)
* [Spotify Priming Strategy](concepts/spotify-priming-strategy.md)
* [Spotify OAuth](concepts/spotify-oauth.md)
* [soundtouch-web Roadmap](soundtouch-web-roadmap.md)
## Analysis & Research
* [API Coverage Analysis](analysis/API-COVERAGE.md)
@@ -62,25 +53,12 @@
* [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)
* [Stockholm App Analysis](analysis/stockholm-app-analysis.md)
* [Wiki API Comparison](analysis/WIKI-COMPARISON.md)
* [IoT Config Summary](analysis/IOT-CONFIG-SUMMARY.md)
* [IoT Configuration Analysis](analysis/IOT-CONFIGURATION-ANALYSIS.md)
* [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)
@@ -103,6 +81,3 @@
* [Device Lifecycle Summary](device-lifecycle-summary.md)
* [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)
+10 -10
View File
@@ -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>
-24
View File
@@ -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>
+99 -62
View File
@@ -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 67 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.
+55 -55
View File
@@ -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** ⭐⭐⭐⭐⭐
-364
View File
@@ -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.
-892
View File
@@ -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 16) 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
```
+30 -39
View File
@@ -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
-136
View File
@@ -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.
-43
View File
@@ -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.
-215
View File
@@ -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.
+2 -2
View File
@@ -5,8 +5,8 @@ This document provides a comprehensive analysis of the `/supportedURLs` endpoint
## Discovery Summary
**Test Devices:**
- Device 1: `192.0.2.11:8090` (deviceID: `08DF1F0BA325`)
- Device 2: `192.0.2.10:8090` (deviceID: `AABBCCDDEEFF`)
- Device 1: `192.168.178.28:8090` (deviceID: `08DF1F0BA325`)
- Device 2: `192.168.178.35:8090` (deviceID: `A81B6A536A98`)
**Key Findings:**
- Both devices return identical endpoint lists

Some files were not shown because too many files have changed in this diff Show More