Compare commits

..
1 Commits
Author SHA1 Message Date
Tobias Gesellchen 1288a619f7 Add Stockholm Mini
This is also a refactoring of our api paths
2026-02-21 00:49:18 +01:00
724 changed files with 9967 additions and 108266 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
-17
View File
@@ -1,17 +0,0 @@
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.html]
# HTML-specific formatting
# Standardize on tag layout
ij_html_do_not_indent_children_of_tags = html,body,thead,tbody,tfoot
ij_html_keep_blank_lines = 1
ij_html_attribute_wrap = normal
ij_html_space_inside_empty_tag = false
+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)
+57 -12
View File
@@ -1,29 +1,74 @@
# CodeQL configuration
# https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning
# CodeQL configuration for enhanced security analysis
# See: https://docs.github.com/en/code-security/codeql-cli/using-the-codeql-cli/creating-codeql-query-suites
name: "Go Security Analysis"
disable-default-queries: false
queries:
# Include default security queries
- uses: security-extended
- uses: security-and-quality
# Paths to include
# Additional Go-specific security queries
- name: go-security-extra
uses:
- go/bad-redirect-check
- go/clear-text-logging
- go/incorrect-integer-conversion
- go/log-injection
- go/missing-regexp-anchor
- go/path-injection
- go/request-forgery
- go/sensitive-package-import
- go/sql-injection
- go/uncontrolled-allocation-size
- go/unsafe-quoting
- go/useless-regexp-character-escape
- go/zip-slip
# Configure paths to exclude from analysis
paths-ignore:
- "**/*.pb.go" # Generated protobuf files
- "**/*_gen.go" # Generated code
- "**/vendor/**" # Vendor dependencies
- "**/build/**" # Build artifacts
- "**/scripts/**" # Build scripts
- "**/*_test.go" # Test files (optional - remove if you want to analyze tests)
# Configure paths to include (if not specified, all Go files are included)
paths:
- "cmd/**/*.go"
- "pkg/**/*.go"
- "*.go"
# Paths to exclude from analysis
paths-ignore:
- "**/*.pb.go" # Generated protobuf files
- "**/*_gen.go" # Generated code
- "**/vendor/**" # Vendor dependencies
- "**/build/**" # Build artifacts
- "**/scripts/**" # Build scripts
- "**/*_test.go" # Test files
# Query filters to reduce noise
query-filters:
- exclude:
id: go/unused-variable
reason: "Can be noisy in development"
- exclude:
id: go/hardcoded-credentials
reason: "Will be handled by separate secret scanning"
# Configuration for specific query packs
packs:
# Use the official CodeQL Go queries
- codeql/go-queries
# Additional community query packs for enhanced security
- codeql/go-queries@~0.0.0 # Latest version
# Custom configuration for specific queries
query-config:
go/path-injection:
# Configure severity levels
severity: "error"
go/sql-injection:
severity: "error"
go/request-forgery:
severity: "warning"
go/log-injection:
severity: "warning"
go/clear-text-logging:
severity: "note"
-85
View File
@@ -38,69 +38,6 @@ updates:
patterns:
- "golang.org/*"
# Hugo module dependency updates (docs site)
- package-ecosystem: "gomod"
directory: "/docs"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "UTC"
open-pull-requests-limit: 3
reviewers:
- "gesellix"
assignees:
- "gesellix"
commit-message:
prefix: "deps"
include: "scope"
labels:
- "dependencies"
- "go"
- "docs"
rebase-strategy: "auto"
# Example module dependency updates
- package-ecosystem: "gomod"
directory: "/examples/navigation-station-demo"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "UTC"
open-pull-requests-limit: 3
reviewers:
- "gesellix"
assignees:
- "gesellix"
commit-message:
prefix: "deps"
include: "scope"
labels:
- "dependencies"
- "go"
rebase-strategy: "auto"
- package-ecosystem: "gomod"
directory: "/examples/preset-management"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "UTC"
open-pull-requests-limit: 3
reviewers:
- "gesellix"
assignees:
- "gesellix"
commit-message:
prefix: "deps"
include: "scope"
labels:
- "dependencies"
- "go"
rebase-strategy: "auto"
# GitHub Actions workflow dependency updates
- package-ecosystem: "github-actions"
directory: "/"
@@ -161,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 -19
View File
@@ -3,7 +3,7 @@
"retryOn429": true,
"retryCount": 3,
"fallbackRetryDelay": "30s",
"aliveStatusCodes": [200, 202, 206],
"aliveStatusCodes": [200, 206],
"ignorePatterns": [
{
"pattern": "^http://localhost"
@@ -25,24 +25,6 @@
},
{
"pattern": "^https://pkg.go.dev.*badge"
},
{
"pattern": "^/images/[^/]+\\.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
+50 -204
View File
@@ -1,8 +1,5 @@
name: CI
permissions:
contents: read
on:
push:
branches: [main]
@@ -17,15 +14,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 +31,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 +40,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 +54,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@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1
uses: golangci/golangci-lint-action@v9
with:
version: latest
args: --timeout=5m
@@ -86,76 +71,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@v6
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 +111,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,35 +135,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
- name: Warn on pending images
run: |
IMAGES=(
"dashboard-home.png"
"account-creation.png"
"account-dashboard.png"
"usb-remote-services.png"
"device-discovery.png"
"device-registration.png"
"account-migration.png"
"migration-setup.png"
"migration-progress.png"
"migration-health.png"
"migration-complete.png"
"backup-setup.png"
)
for img in "${IMAGES[@]}"; do
if [ ! -f "docs/static/images/$img" ]; then
echo "::warning file=docs/content/docs/guides/MIGRATION-GUIDE.md::Pending image '$img' is missing from docs/static/images/"
fi
done
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: Validate API documentation
run: |
@@ -226,7 +150,7 @@ jobs:
echo "Validating API documentation consistency..."
# Check API cookbook
if [ -f "docs/content/docs/reference/API-COOKBOOK.md" ]; then
if [ -f "docs/reference/API-COOKBOOK.md" ]; then
echo "✓ API Cookbook exists"
else
echo "✗ API Cookbook missing"
@@ -234,7 +158,7 @@ jobs:
fi
# Check getting started guide
if [ -f "docs/content/docs/guides/GETTING-STARTED.md" ]; then
if [ -f "docs/guides/GETTING-STARTED.md" ]; then
echo "✓ Getting Started guide exists"
else
echo "✗ Getting Started guide missing"
@@ -248,16 +172,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 +199,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 +227,38 @@ 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@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.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@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.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@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.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@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.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@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.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@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.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 +293,7 @@ jobs:
- name: Update commit status
if: always()
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
uses: actions/github-script@v8
with:
script: |
try {
-56
View File
@@ -1,56 +0,0 @@
name: "CodeQL Advanced"
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
schedule:
- cron: '36 6 * * 1'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
permissions:
security-events: write
packages: read
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: go
build-mode: manual
- language: javascript-typescript
build-mode: none
- language: python
build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install libpcap (required for Go build)
if: matrix.language == 'go'
run: sudo apt-get install -y libpcap-dev
- name: Initialize CodeQL
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
config-file: ${{ matrix.language == 'go' && './.github/codeql-config.yml' || '' }}
- name: Build Go (required for manual build-mode)
if: matrix.language == 'go'
run: go build ./...
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
category: "/language:${{ matrix.language }}"
+8 -13
View File
@@ -20,23 +20,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@v6
- name: Setup Pages
id: pages
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Setup Hugo
uses: peaceiris/actions-hugo@2752ce1d29631191ea3f27c23495fa06139a5b78 # v3.2.1
uses: actions/configure-pages@v5
- name: Build with Jekyll
uses: actions/jekyll-build-pages@v1
with:
hugo-version: 'latest'
extended: true
- name: Build with Hugo
run: hugo --source docs/ --minify --destination ../_site --baseURL "${{ steps.pages.outputs.base_url }}"
env:
HUGO_ENVIRONMENT: production
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 -87
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@v6
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@v7
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@v6
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@v6
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@v7
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@v7
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@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.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@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.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,13 @@ 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@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.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@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.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@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.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 +531,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"
+61 -26
View File
@@ -14,23 +14,20 @@ jobs:
vulnerability-scan:
name: Vulnerability Scan
runs-on: ubuntu-latest
permissions:
contents: read
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 govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest
- name: Install security scanning tools
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
go install github.com/sonatypecommunity/nancy@latest
- name: Run govulncheck (Official Go vulnerability scanner)
run: |
@@ -38,24 +35,34 @@ jobs:
govulncheck ./...
echo "::endgroup::"
- name: Run Nancy vulnerability scanner
run: |
echo "::group::Running Nancy dependency scanner"
go list -json -deps ./... | nancy sleuth
echo "::endgroup::"
- name: Upload vulnerability scan results
if: failure()
uses: actions/upload-artifact@v6
with:
name: vulnerability-scan-results
path: |
vulnerability-report.json
nancy-report.json
static-analysis:
name: Static Security Analysis
runs-on: ubuntu-latest
permissions:
contents: read
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
@@ -67,7 +74,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
@@ -78,24 +85,48 @@ jobs:
- name: Upload Semgrep SARIF results
if: always()
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: semgrep.sarif
continue-on-error: true
codeql-analysis:
name: CodeQL Analysis
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: go
config-file: ./.github/codeql-config.yml
- name: Autobuild
uses: github/codeql-action/autobuild@v4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:go"
dependency-review:
name: Dependency Review
runs-on: ubuntu-latest
permissions:
contents: read
if: github.event_name == 'pull_request'
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
@@ -104,10 +135,8 @@ jobs:
security-summary:
name: Security Summary
runs-on: ubuntu-latest
needs: [vulnerability-scan, static-analysis]
needs: [vulnerability-scan, static-analysis, codeql-analysis]
if: always()
permissions:
contents: read
steps:
- name: Security scan summary
@@ -127,11 +156,17 @@ jobs:
echo "❌ **Static Analysis**: FAILED" >> $GITHUB_STEP_SUMMARY
fi
if [[ "${{ needs.codeql-analysis.result }}" == "success" ]]; then
echo "✅ **CodeQL Analysis**: PASSED" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **CodeQL Analysis**: FAILED" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "For detailed results, check the individual job logs above." >> $GITHUB_STEP_SUMMARY
- name: Fail on security issues
if: needs.vulnerability-scan.result == 'failure' || needs.static-analysis.result == 'failure'
if: needs.vulnerability-scan.result == 'failure' || needs.static-analysis.result == 'failure' || needs.codeql-analysis.result == 'failure'
run: |
echo "Security scan detected issues. Please review the results above."
exit 1
-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
-47
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,34 +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
# Code-scanning working notes — snapshot + remediation plan; not committed
# until the sweep is complete and the notes are stable.
CODE-SCANNING-NOTES.md
# Plan/tracking note for the Health-tab debug-utility programme.
# Living document; commit history of the checks themselves is the
# source of truth for what shipped.
SERVICE-HEALTH.md
# Diagnostic encryption keys — private key stays local with the maintainer
keys/private/
# Hugo (docs site)
# Hugo build artifacts (docs site)
docs/.hugo_build.lock
docs/public/
docs/resources/
-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.
+9 -29
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
@@ -88,7 +76,7 @@ When filing a bug report, include:
Feature requests are welcome! Please:
1. **Check if the feature already exists** in documentation
2. **Verify it's supported by the SoundTouch API** (see [official API docs](docs/content/docs/reference/API-ENDPOINTS.md))
2. **Verify it's supported by the SoundTouch API** (see [official API docs](docs/reference/API-ENDPOINTS.md))
3. **Explain the use case** and how it benefits users
### 🔧 Contributing Code
@@ -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,20 +465,12 @@ 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/)
- [Effective Go](https://golang.org/doc/effective_go.html)
- [Bose SoundTouch API Documentation](docs/content/docs/reference/API-ENDPOINTS.md)
- [Project Architecture](docs/content/docs/appendix/PROJECT-PATTERNS.md)
- [Bose SoundTouch API Documentation](docs/reference/API-ENDPOINTS.md)
- [Project Architecture](docs/PROJECT-PATTERNS.md)
- [Development Status](docs/archive/STATUS.md)
---
+10 -40
View File
@@ -1,12 +1,5 @@
# Build stage
FROM --platform=$BUILDPLATFORM golang:1.26.3-alpine AS builder
# Declare automatic platform ARGs to make them available in build stage
# See https://docs.docker.com/reference/dockerfile#automatic-platform-args-in-the-global-scope
# We should not set defaults here, but rely on BuildKit to set them matching the BUILDPLATFORM
ARG TARGETARCH
ARG TARGETOS
ARG TARGETVARIANT
FROM golang:1.26.0-alpine AS builder
WORKDIR /app
@@ -18,53 +11,30 @@ RUN go mod download
COPY . .
# Build the soundtouch-service
RUN if [ "${TARGETARCH}" = "arm" ] && [ -n "${TARGETVARIANT}" ]; then \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} GOARM=${TARGETVARIANT#v} go build -o /soundtouch-service ./cmd/soundtouch-service; \
else \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /soundtouch-service ./cmd/soundtouch-service; \
fi
RUN CGO_ENABLED=0 GOOS=linux go build -o /soundtouch-service ./cmd/soundtouch-service
# 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 -307
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 dev-docs dev-docs-tidy hugo
# 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,40 +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
# Documentation site (Hugo + Hextra via Docker)
# First run: make dev-docs-tidy (downloads Hextra, writes docs/go.sum)
# Then: make dev-docs (http://localhost:1313, live reload)
dev-docs:
docker compose -f docker-compose.docs.yml up
dev-docs-tidy:
docker compose -f docker-compose.docs.yml run --rm hugo mod tidy --source docs/
# Run any hugo CLI command inside the docs container:
# make hugo ARGS="version"
# make hugo ARGS="new content/docs/guides/my-guide.md"
ARGS ?=
hugo:
docker compose -f docker-compose.docs.yml run --rm hugo --source docs/ $(ARGS)
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"
@@ -492,11 +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-docs - Serve documentation site locally via Docker (http://localhost:1313)"
@echo " dev-docs-tidy - Run hugo mod tidy (first run, or after hugo.toml module changes)"
@echo " hugo ARGS=... - Run any hugo CLI command via Docker (e.g. make hugo ARGS=version)"
@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"
@@ -508,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"
@@ -538,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"
+516 -128
View File
@@ -1,157 +1,545 @@
# <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.
## The Bose Cloud Has Shut Down
## Features
Bose shut down SoundTouch cloud services on **May 6, 2026**. Presets, music service browsing, and stereo pairing no longer work through Bose's infrastructure. AfterTouch restores all of these — no Bose infrastructure required.
-**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
- 🧹 **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/docs/guides/SURVIVAL-GUIDE/) 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.
Not sure which approach fits your situation? See the [Deployment Overview](./docs/content/docs/guides/DEPLOYMENT-OVERVIEW.md) — it compares running AfterTouch on a Raspberry Pi or other always-on host against running it directly on the SoundTouch speaker, with links to step-by-step walkthroughs for each path.
**Getting started:**
**Already migrated before May 6** — your presets and credentials are preserved. AfterTouch picks up where the Bose cloud left off.
**Starting fresh (or after a factory reset)** — create a local account, configure your speakers, and start using them immediately.
**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/docs/guides/DEVICE-INITIAL-SETUP/) and [Migration Guide](https://gesellix.github.io/Bose-SoundTouch/docs/guides/MIGRATION-GUIDE/) 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/docs/guides/CLI-REFERENCE/) 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/docs/reference/API-ENDPOINTS/) 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
- **📝 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/docs/guides/GETTING-STARTED/)
- [Survival Guide](https://gesellix.github.io/Bose-SoundTouch/docs/guides/SURVIVAL-GUIDE/)
- [Migration Guide](https://gesellix.github.io/Bose-SoundTouch/docs/guides/MIGRATION-GUIDE/)
- [Device Initial Setup](https://gesellix.github.io/Bose-SoundTouch/docs/guides/DEVICE-INITIAL-SETUP/)
- [Migration & Safety Guide](https://gesellix.github.io/Bose-SoundTouch/docs/guides/MIGRATION-SAFETY/)
- [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/docs/guides/CLI-REFERENCE/)
- [SoundTouch Service Guide](https://gesellix.github.io/Bose-SoundTouch/docs/guides/SOUNDTOUCH-SERVICE/)
- [HTTPS & CA Setup](https://gesellix.github.io/Bose-SoundTouch/docs/guides/HTTPS-SETUP/)
- [API Reference](https://gesellix.github.io/Bose-SoundTouch/docs/reference/API-ENDPOINTS/)
- 📖 [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).
-242
View File
@@ -1,242 +0,0 @@
// Package main provides a debug tool for analyzing device consolidation and migration scenarios.
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func main() {
if len(os.Args) < 2 {
fmt.Println("Usage: debug-consolidation <data-directory>")
fmt.Println("Example: debug-consolidation /var/lib/soundtouch-service")
os.Exit(1)
}
dataDir := os.Args[1]
fmt.Printf("🔍 Analyzing device consolidation in: %s\n", dataDir)
// Initialize datastore
ds := datastore.NewDataStore(dataDir)
// List all devices
devices, err := ds.ListAllDevices()
if err != nil {
log.Fatalf("Failed to list devices: %v", err)
}
fmt.Printf("📱 Found %d device entries:\n", len(devices))
for i := range devices {
device := &devices[i]
fmt.Printf(" %d. %s (Account: %s)\n", i+1, device.DeviceID, device.AccountID)
fmt.Printf(" Name: %s\n", device.Name)
fmt.Printf(" IP: %s, MAC: %s, Serial: %s\n",
device.IPAddress, device.MacAddress, device.DeviceSerialNumber)
// Check directory contents
deviceDir := ds.AccountDeviceDir(device.AccountID, device.DeviceID)
analyzeDeviceDirectory(deviceDir, device.DeviceID)
fmt.Println()
}
// Group devices by potential physical device
fmt.Println("🔄 Analyzing potential consolidation opportunities:")
deviceGroups := groupDevicesByIdentity(devices)
for i, group := range deviceGroups {
if len(group) <= 1 {
continue
}
fmt.Printf(" Group %d - %d entries for same physical device:\n", i+1, len(group))
for i := range group {
device := &group[i]
deviceDir := ds.AccountDeviceDir(device.AccountID, device.DeviceID)
fileCount := countFiles(deviceDir)
fmt.Printf(" - %s (%d files)\n", device.DeviceID, fileCount)
}
// Recommend consolidation target
macDevice := findMACBasedDevice(group)
if macDevice != nil {
fmt.Printf(" → Recommend keeping: %s (MAC-based)\n", macDevice.DeviceID)
} else {
fmt.Printf(" → No clear MAC-based target found\n")
}
fmt.Println()
}
}
func analyzeDeviceDirectory(dirPath, deviceID string) {
entries, err := os.ReadDir(dirPath)
if err != nil {
fmt.Printf(" Directory: %s (Error: %v)\n", dirPath, err)
return
}
fmt.Printf(" Directory: %s (%d files)\n", dirPath, len(entries))
// Check for important files
importantFiles := []string{"DeviceInfo.xml", "Presets.xml", "Recents.xml", "Sources.xml"}
for _, fileName := range importantFiles {
filePath := filepath.Join(dirPath, fileName)
if stat, err := os.Stat(filePath); err == nil {
status := "✓"
if stat.Size() == 0 {
status = "⚠️ (empty)"
} else if stat.Size() < 100 {
status = "⚠️ (very small)"
}
fmt.Printf(" %s %s (%d bytes)\n", status, fileName, stat.Size())
} else {
fmt.Printf(" ❌ %s (missing)\n", fileName)
}
}
// Check if deviceID looks like MAC address
if isLikelyMACAddress(deviceID) {
fmt.Printf(" 📍 Device ID appears to be MAC address format\n")
} else {
fmt.Printf(" 📍 Device ID appears to be %s format\n", guessIDType(deviceID))
}
}
func countFiles(dirPath string) int {
entries, err := os.ReadDir(dirPath)
if err != nil {
return 0
}
count := 0
for _, entry := range entries {
if !entry.IsDir() {
count++
}
}
return count
}
func groupDevicesByIdentity(devices []models.ServiceDeviceInfo) [][]models.ServiceDeviceInfo {
var groups [][]models.ServiceDeviceInfo
// Simple grouping by MAC address and serial number
macGroups := make(map[string][]models.ServiceDeviceInfo)
serialGroups := make(map[string][]models.ServiceDeviceInfo)
ipGroups := make(map[string][]models.ServiceDeviceInfo)
for i := range devices {
device := &devices[i]
// Group by MAC address
if device.MacAddress != "" {
macGroups[device.MacAddress] = append(macGroups[device.MacAddress], *device)
}
// Group by serial number
if device.DeviceSerialNumber != "" {
serialGroups[device.DeviceSerialNumber] = append(serialGroups[device.DeviceSerialNumber], *device)
}
// Group by IP address
if device.IPAddress != "" {
ipGroups[device.IPAddress] = append(ipGroups[device.IPAddress], *device)
}
}
// Merge groups - prioritize MAC address grouping
processed := make(map[string]bool)
for _, macDevices := range macGroups {
if len(macDevices) > 1 {
groups = append(groups, macDevices)
for i := range macDevices {
processed[macDevices[i].DeviceID] = true
}
}
}
// Check for serial number groups not already processed
for _, serialDevices := range serialGroups {
if len(serialDevices) > 1 {
unprocessed := []models.ServiceDeviceInfo{}
for i := range serialDevices {
if !processed[serialDevices[i].DeviceID] {
unprocessed = append(unprocessed, serialDevices[i])
}
}
if len(unprocessed) > 1 {
groups = append(groups, unprocessed)
for i := range unprocessed {
processed[unprocessed[i].DeviceID] = true
}
}
}
}
return groups
}
func findMACBasedDevice(devices []models.ServiceDeviceInfo) *models.ServiceDeviceInfo {
for i := range devices {
if isLikelyMACAddress(devices[i].DeviceID) {
return &devices[i]
}
}
return nil
}
func isLikelyMACAddress(id string) bool {
// MAC addresses are typically 12 hex characters without separators
// or 17 characters with separators (XX:XX:XX:XX:XX:XX)
if len(id) == 12 {
for _, c := range id {
if (c < '0' || c > '9') && (c < 'A' || c > 'F') && (c < 'a' || c > 'f') {
return false
}
}
return true
}
return false
}
func guessIDType(id string) string {
if len(id) > 15 && (id[0] == 'I' || id[0] == 'K') {
return "serial number"
}
// Check if it looks like an IP address
if len(id) >= 7 && len(id) <= 15 {
dotCount := 0
for _, c := range id {
if c == '.' {
dotCount++
} else if c < '0' || c > '9' {
break
}
}
if dotCount == 3 {
return "IP address"
}
}
return "unknown"
}
-13
View File
@@ -1,13 +0,0 @@
package main
import "strings"
// sanitizeLog strips newline characters from s to prevent log-injection
// (CodeQL go/log-injection). Values from speakers, HTTP requests, and
// external APIs may contain attacker-controlled newlines.
func sanitizeLog(s string) string {
s = strings.ReplaceAll(s, "\n", `\n`)
s = strings.ReplaceAll(s, "\r", `\r`)
return s
}
-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", sanitizeLog(s.HTTPAddr()))
if addr := s.TelnetAddr(); addr != "" {
log.Printf("fake speaker telnet listening on tcp://%s", sanitizeLog(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", sanitizeLog(target), sanitizeLog(*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
}
-13
View File
@@ -1,13 +0,0 @@
package main
import "strings"
// sanitizeLog strips newline characters from s to prevent log-injection
// (CodeQL go/log-injection). Values from speakers, HTTP requests, and
// external APIs may contain attacker-controlled newlines.
func sanitizeLog(s string) string {
s = strings.ReplaceAll(s, "\n", `\n`)
s = strings.ReplaceAll(s, "\r", `\r`)
return s
}
+2 -2
View File
@@ -116,7 +116,7 @@ func main() {
defer close(entries)
if *verbose {
log.Printf("mDNS: Starting scan for service '%s' with timeout %v", sanitizeLog(*service), *timeout)
log.Printf("mDNS: Starting scan for service '%s' with timeout %v", *service, *timeout)
}
// Query for services
@@ -196,7 +196,7 @@ func parseServiceEntry(entry *mdns.ServiceEntry, verbose bool) *ServiceInfo {
if verbose {
log.Printf("mDNS: Received service entry: Name='%s', Host='%s', Port=%d, AddrV4=%v, AddrV6=%v",
sanitizeLog(entry.Name), sanitizeLog(entry.Host), entry.Port, entry.AddrV4, entry.AddrV6)
entry.Name, entry.Host, entry.Port, entry.AddrV4, entry.AddrV6)
}
service := &ServiceInfo{
-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/content/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)
-128
View File
@@ -1,128 +0,0 @@
package main
import (
"fmt"
"io"
"net/http"
"strings"
"github.com/urfave/cli/v2"
)
// cloudCommand assembles the `soundtouch-cli cloud …` command group.
// All subcommands talk to the AfterTouch service (not the speaker directly)
// and require --service-url.
func cloudCommand() *cli.Command {
return &cli.Command{
Name: "cloud",
Usage: "Manage AfterTouch service data (sources, accounts, devices)",
Subcommands: []*cli.Command{
cloudSourceCmd(),
},
}
}
func cloudSourceCmd() *cli.Command {
return &cli.Command{
Name: "source",
Usage: "Manage sources stored in AfterTouch",
Subcommands: []*cli.Command{
cloudSourceRemoveCmd(),
},
}
}
func cloudSourceRemoveCmd() *cli.Command {
return &cli.Command{
Name: "remove",
Usage: "Remove a source from AfterTouch's datastore for a specific device",
Flags: append(CloudCommonFlags,
&cli.StringFlag{
Name: "account",
Aliases: []string{"a"},
Usage: "Account ID",
Required: true,
},
&cli.StringFlag{
Name: "device",
Aliases: []string{"d"},
Usage: "Device ID",
Required: true,
},
&cli.StringFlag{
Name: "id",
Usage: "Source ID to remove (e.g. 10002)",
},
&cli.StringFlag{
Name: "type",
Aliases: []string{"t"},
Usage: "Source type to remove (e.g. INTERNET_RADIO). Resolved to a canonical ID; fails if multiple sources share the type.",
},
),
Action: cloudSourceRemove,
}
}
// canonicalSourceID maps well-known SourceKeyType values to their canonical IDs.
// Used to resolve --type to an ID without requiring a round-trip GET.
// TODO We need to ensure that ids here are consistent with the ones used in the AfterTouch service.
var canonicalSourceID = map[string]string{
"AUX": "10001",
"INTERNET_RADIO": "10002",
"LOCAL_INTERNET_RADIO": "10003",
"TUNEIN": "10004",
"RADIO_BROWSER": "10005",
}
func cloudSourceRemove(c *cli.Context) error {
serviceURL := strings.TrimRight(c.String("service-url"), "/")
account := c.String("account")
device := c.String("device")
sourceID := c.String("id")
sourceType := strings.ToUpper(c.String("type"))
if sourceID == "" && sourceType == "" {
return fmt.Errorf("one of --id or --type is required")
}
if sourceID != "" && sourceType != "" {
return fmt.Errorf("only one of --id or --type may be given")
}
if sourceType != "" {
id, ok := canonicalSourceID[sourceType]
if !ok {
return fmt.Errorf("unknown source type %q; use --id for non-canonical sources", sourceType)
}
sourceID = id
}
url := fmt.Sprintf("%s/setup/sources/%s/%s/%s", serviceURL, account, device, sourceID)
req, err := http.NewRequest(http.MethodDelete, url, nil)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNoContent {
PrintSuccess(fmt.Sprintf("Removed source %s from device %s (account %s)", sourceID, device, account))
if sourceType != "" {
fmt.Printf(" Type: %s\n", sourceType)
}
return nil
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
return fmt.Errorf("service returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
-5
View File
@@ -15,11 +15,6 @@ import (
func discoverDevices(c *cli.Context) error {
fmt.Printf("Discovering SoundTouch devices...\n")
// CLI discovery is interactive — flip on verbose protocol logging
// so operators can see per-packet / per-header detail. The service
// binary leaves this off so its log stays terse.
discovery.SetVerbose(c.Bool("verbose"))
// Load configuration
cfg, err := config.LoadFromEnv()
if err != nil {
+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")
}
}
-107
View File
@@ -1,11 +1,7 @@
package main
import (
"encoding/base64"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/models"
@@ -255,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)
@@ -687,51 +628,3 @@ func boolToStatus(b bool) string {
return "❌ No"
}
// notifySourcesUpdated POSTs a sourcesUpdated notification directly to the
// speaker's :8090/notification endpoint. The speaker re-fetches its source
// list from AfterTouch immediately. Requires network access to the speaker.
func notifySourcesUpdated(c *cli.Context) error {
if err := RequireHost(c); err != nil {
return err
}
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
info, err := client.GetDeviceInfo()
if err != nil {
return fmt.Errorf("failed to get device info from %s: %w", clientConfig.Host, err)
}
body := fmt.Sprintf(`<updates deviceID="%s"><sourcesUpdated/></updates>`, info.DeviceID)
notifyURL := fmt.Sprintf("http://%s:8090/notification", clientConfig.Host)
req, err := http.NewRequest(http.MethodPost, notifyURL, strings.NewReader(body))
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/xml")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("post to speaker: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
return fmt.Errorf("speaker returned %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
}
PrintSuccess(fmt.Sprintf("Sent sourcesUpdated to %s (%s)", info.DeviceID, clientConfig.Host))
return nil
}
-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 -12
View File
@@ -19,16 +19,6 @@ import (
"github.com/urfave/cli/v2"
)
// CloudCommonFlags defines flags for commands that talk to the AfterTouch service.
var CloudCommonFlags = []cli.Flag{
&cli.StringFlag{
Name: "service-url",
Usage: "AfterTouch service URL",
Required: true,
EnvVars: []string{"AFTERTOUCH_URL"},
},
}
// CommonFlags defines flags that are shared across multiple commands
var CommonFlags = []cli.Flag{
&cli.StringFlag{
@@ -206,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")
}
@@ -266,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 -184
View File
@@ -132,11 +132,6 @@ func main() {
Aliases: []string{"a"},
Usage: "Show detailed information for all devices",
},
&cli.BoolFlag{
Name: "verbose",
Aliases: []string{"v"},
Usage: "Print per-packet/per-header SSDP and mDNS trace logs",
},
},
},
},
@@ -929,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)",
@@ -1059,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",
@@ -1146,12 +1076,6 @@ func main() {
Action: introspectAllServices,
Before: RequireHost,
},
{
Name: "notify-updated",
Usage: "Tell the speaker to re-fetch its source list from AfterTouch",
Action: notifySourcesUpdated,
Before: RequireHost,
},
},
},
// Bass commands
@@ -1360,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,
},
},
},
},
@@ -1539,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",
@@ -2157,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
@@ -2212,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",
@@ -2224,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"},
@@ -2240,14 +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())
// AfterTouch service management (sources, accounts, devices).
// Defined in cmd_cloud.go.
app.Commands = append(app.Commands, cloudCommand())
// 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,
},
}
-13
View File
@@ -1,13 +0,0 @@
package main
import "strings"
// sanitizeLog strips newline characters from s to prevent log-injection
// (CodeQL go/log-injection). Values from speakers, HTTP requests, and
// external APIs may contain attacker-controlled newlines.
func sanitizeLog(s string) string {
s = strings.ReplaceAll(s, "\n", `\n`)
s = strings.ReplaceAll(s, "\r", `\r`)
return s
}
File diff suppressed because it is too large Load Diff
+11 -103
View File
@@ -18,9 +18,10 @@ func TestApplyPersistedSettings(t *testing.T) {
t.Run("overrides true with false", func(t *testing.T) {
config := &serviceConfig{
redact: true,
logBody: true,
record: true,
redact: true,
logBody: true,
record: true,
enableSoundcorkProxy: true,
}
// Simulate the bug by using the old bitwise OR logic in the test,
@@ -28,9 +29,10 @@ func TestApplyPersistedSettings(t *testing.T) {
// config.redact = config.redact || false -> stays true
settings := datastore.Settings{
RedactLogs: false,
LogBodies: false,
RecordInteractions: false,
RedactLogs: false,
LogBodies: false,
RecordInteractions: false,
EnableSoundcorkProxy: false,
}
err := ds.SaveSettings(settings)
if err != nil {
@@ -48,6 +50,9 @@ func TestApplyPersistedSettings(t *testing.T) {
if config.record != false {
t.Errorf("Expected record to be false, got true")
}
if config.enableSoundcorkProxy != false {
t.Errorf("Expected enableSoundcorkProxy to be false, got true")
}
})
t.Run("retains false when settings are false", func(t *testing.T) {
@@ -90,100 +95,3 @@ func TestApplyPersistedSettings(t *testing.T) {
}
})
}
func TestMergeTLSExtraHosts(t *testing.T) {
cases := []struct {
name string
cli []string
persisted []string
want []string
}{
{
name: "CLI only",
cli: []string{"a.example"},
persisted: nil,
want: []string{"a.example"},
},
{
name: "Persisted only",
cli: nil,
persisted: []string{"b.example"},
want: []string{"b.example"},
},
{
name: "CLI wins ordering, persisted appended",
cli: []string{"a.example"},
persisted: []string{"b.example"},
want: []string{"a.example", "b.example"},
},
{
name: "Dedupes overlap",
cli: []string{"a.example", "b.example"},
persisted: []string{"b.example", "c.example"},
want: []string{"a.example", "b.example", "c.example"},
},
{
name: "Drops empty + whitespace",
cli: []string{" ", "a.example", ""},
persisted: []string{"", " b.example "},
want: []string{"a.example", "b.example"},
},
{
name: "Both empty",
cli: nil,
persisted: nil,
want: []string{},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := mergeTLSExtraHosts(tc.cli, tc.persisted)
if len(got) != len(tc.want) {
t.Fatalf("len mismatch: got %v, want %v", got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Errorf("index %d: got %q, want %q (full: %v vs %v)", i, got[i], tc.want[i], got, tc.want)
}
}
})
}
}
func TestGetDomains_IncludesOAuthDerivation(t *testing.T) {
// Hostname-based serverURL: the derived OAuth variant must end up
// in the served TLS cert SAN list, otherwise the speaker rejects
// the TLS handshake on Spotify / Amazon Music token refresh.
got := getDomains("http://mac.fritz.box:8000", "https://mac.fritz.box:8443", "mac.fritz.box", nil)
want := "macoauth.fritz.box"
if !contains(got, want) {
t.Errorf("expected SAN list to include %q (derived from serverURL), got: %v", want, got)
}
}
func TestGetDomains_IPServerURLProducesNoOAuthDerivation(t *testing.T) {
// IP-based serverURL deliberately yields no derivation (the speaker's
// `<first-label>oauth.<rest>` construction would be malformed for an
// IP and no DNS resolver can answer for it). The cert SAN list must
// not pretend to cover something that can never be queried.
got := getDomains("http://192.168.0.30:8000", "https://192.168.0.30:8443", "192.168.0.30", nil)
for _, h := range got {
if h == "192oauth.168.0.30" {
t.Errorf("SAN list must not include malformed IP-derived OAuth name, got: %v", got)
}
}
}
func contains(haystack []string, needle string) bool {
for _, h := range haystack {
if h == needle {
return true
}
}
return false
}
-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
-175
View File
@@ -1,175 +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 /setup/sources/{account}/{device}/{sourceID} handlers.(*Server).HandleDeleteSource-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 /bmx/tunein/v1/search/next handlers.(*Server).HandleTuneInSearchNext-fm
GET /ced/* handlers.(*Server).HandleCedStatic
GET /core02/svc-bmx-adapter-orion/prod/orion/station handlers.(*Server).HandleOrionPlayback-fm
GET /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
GET /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
GET /custom/v1/playback/{encodedURL} handlers.(*Server).HandleCustomPlayback-fm
GET /customer/account/{account} handlers.(*Server).HandleMargeAccountProfile-fm
GET /docs/* handlers.(*Server).HandleDocs-fm
GET /favicon.ico setupRouter
GET /health handlers.(*Server).HandleHealth-fm
GET /media/* handlers.(*Server).HandleMedia
GET /media/aftertouch-ding.wav handlers.(*Server).HandleDing-fm
GET /mgmt/accounts/ handlers.(*Server).HandleMgmtListAccounts-fm
GET /mgmt/accounts/{accountId} handlers.(*Server).HandleMgmtAccountDetails-fm
GET /mgmt/accounts/{accountId}/speakers handlers.(*Server).HandleMgmtListSpeakers-fm
GET /mgmt/amazon/accounts handlers.(*Server).HandleMgmtAmazonAccounts-fm
GET /mgmt/amazon/callback handlers.(*Server).HandleMgmtAmazonCallback-fm
GET /mgmt/amazon/token handlers.(*Server).HandleMgmtAmazonToken-fm
GET /mgmt/devices/{deviceId}/events handlers.(*Server).HandleMgmtDeviceEvents-fm
GET /mgmt/spotify/accounts handlers.(*Server).HandleMgmtSpotifyAccounts-fm
GET /mgmt/spotify/callback handlers.(*Server).HandleMgmtSpotifyCallback-fm
GET /mgmt/spotify/token handlers.(*Server).HandleMgmtSpotifyToken-fm
GET /setup/account-id-suggestions/{deviceId} handlers.(*Server).HandleAccountIDSuggestions-fm
GET /setup/ca.crt handlers.(*Server).HandleGetCACert-fm
GET /setup/device-summary/{deviceId} handlers.(*Server).HandleDeviceSummary-fm
GET /setup/devices handlers.(*Server).HandleListDiscoveredDevices-fm
GET /setup/devices/{deviceId}/events handlers.(*Server).HandleGetDeviceEvents-fm
GET /setup/discovery-status handlers.(*Server).HandleGetDiscoveryStatus-fm
GET /setup/dns-discoveries handlers.(*Server).HandleGetDNSDiscoveries-fm
GET /setup/dns-discoveries/download handlers.(*Server).HandleDownloadDNSDiscoveries-fm
GET /setup/export/diagnostic handlers.(*Server).HandleExportDiagnostic-fm
GET /setup/health handlers.(*Server).HandleHealthChecks-fm
GET /setup/info/{deviceId} handlers.(*Server).HandleGetDeviceInfo-fm
GET /setup/interaction-content handlers.(*Server).HandleGetInteractionContent-fm
GET /setup/interaction-stats handlers.(*Server).HandleGetInteractionStats-fm
GET /setup/interactions handlers.(*Server).HandleListInteractions-fm
GET /setup/interactions/sessions/{session}/download handlers.(*Server).HandleDownloadSession-fm
GET /setup/logging-settings handlers.(*Server).HandleGetLoggingSettings-fm
GET /setup/logs handlers.(*Server).HandleGetLogs-fm
GET /setup/settings handlers.(*Server).HandleGetSettings-fm
GET /setup/summary/{deviceId} handlers.(*Server).HandleGetMigrationSummary-fm
GET /setup/version handlers.(*Server).HandleGetVersionInfo-fm
GET /streaming/account/{account}/device/{device}/group handlers.(*Server).HandleMargeDeviceGroup-fm
GET /streaming/account/{account}/device/{device}/group/ handlers.(*Server).HandleMargeDeviceGroup-fm
GET /streaming/account/{account}/device/{device}/group/member handlers.(*Server).HandleMargeDeviceGroupMember-fm
GET /streaming/account/{account}/device/{device}/group/server handlers.(*Server).HandleMargeDeviceGroupServer-fm
GET /streaming/account/{account}/device/{device}/presets handlers.(*Server).HandleMargePresets-fm
GET /streaming/account/{account}/device/{device}/recent handlers.(*Server).HandleMargeRecents-fm
GET /streaming/account/{account}/device/{device}/recents handlers.(*Server).HandleMargeRecents-fm
GET /streaming/account/{account}/devices handlers.(*Server).HandleMargeAccountDevices-fm
GET /streaming/account/{account}/emailaddress handlers.(*Server).HandleMargeGetEmailAddress-fm
GET /streaming/account/{account}/full handlers.(*Server).HandleMargeAccountFull-fm
GET /streaming/account/{account}/presets handlers.(*Server).HandleMargeAccountPresets-fm
GET /streaming/account/{account}/presets/all handlers.(*Server).HandleMargeAccountPresets-fm
GET /streaming/account/{account}/provider_settings handlers.(*Server).HandleMargeProviderSettings-fm
GET /streaming/account/{account}/sources handlers.(*Server).HandleMargeAccountSources-fm
GET /streaming/device/{device}/streaming_token handlers.(*Server).HandleMargeStreamingToken-fm
GET /streaming/device_setting/account/{account}/device/{device}/device_settings handlers.(*Server).HandleMargeGetDeviceSettings-fm
GET /streaming/resources/api_versions.xml handlers.(*Server).HandleMargeAPIVersions-fm
GET /streaming/software/update/account/{account} handlers.(*Server).HandleMargeSoftwareUpdate-fm
GET /streaming/sourceproviders handlers.(*Server).HandleMargeSourceProviders-fm
GET /updates/soundtouch handlers.(*Server).HandleMargeSoftwareUpdate-fm
GET /v1/blacklist/{deviceId} setupRouter
GET /web/* setupRouter.(*Server).HandleWeb
HEAD /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
HEAD /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
OPTIONS /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
OPTIONS /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
PATCH /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
PATCH /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
POST /accounts/{account}/devices handlers.(*Server).HandleMargeAddDevice-fm
POST /accounts/{account}/devices/{device}/presets/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
POST /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleMargeAddRecent-fm
POST /accounts/{account}/group handlers.(*Server).HandleMargeAddGroup-fm
POST /accounts/{account}/group/ handlers.(*Server).HandleMargeAddGroup-fm
POST /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeModifyGroup-fm
POST /alexa/certificate handlers.(*Server).HandleAlexaCertificate-fm
POST /bmx/tunein/v1/favorite/{stationID} handlers.(*Server).HandleTuneInFavorite-fm
POST /bmx/tunein/v1/report handlers.(*Server).HandleTuneInReport-fm
POST /bmx/tunein/v1/token handlers.(*Server).HandleTuneInToken-fm
POST /core02/svc-bmx-adapter-orion/prod/orion/token handlers.(*Server).HandleOrionToken-fm
POST /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
POST /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
POST /customer/account/{account} handlers.(*Server).HandleMargeUpdateAccountProfile-fm
POST /customer/account/{account}/password handlers.(*Server).HandleMargeChangePassword-fm
POST /mgmt/accounts/{accountId}/language handlers.(*Server).HandleMgmtUpdateAccountLanguage-fm
POST /mgmt/accounts/{accountId}/provider-settings handlers.(*Server).HandleMgmtUpdateAccountProviderSetting-fm
POST /mgmt/amazon/confirm handlers.(*Server).HandleMgmtAmazonConfirm-fm
POST /mgmt/amazon/init handlers.(*Server).HandleMgmtAmazonInit-fm
POST /mgmt/amazon/prime handlers.(*Server).HandleMgmtPrimeDeviceAmazon-fm
POST /mgmt/spotify/confirm handlers.(*Server).HandleMgmtSpotifyConfirm-fm
POST /mgmt/spotify/entity handlers.(*Server).HandleMgmtSpotifyEntity-fm
POST /mgmt/spotify/init handlers.(*Server).HandleMgmtSpotifyInit-fm
POST /mgmt/spotify/prime handlers.(*Server).HandleMgmtPrimeDevice-fm
POST /oauth/account/{account}/music/musicprovider/{sourceID}/token/cs handlers.(*Server).HandleBoseAccountToken-fm
POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token handlers.(*Server).HandleBoseLegacyToken-fm
POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token/cs1 handlers.(*Server).HandleBoseToken-fm
POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token/cs3 handlers.(*Server).HandleBoseToken-fm
POST /setup/backup/{deviceId} handlers.(*Server).HandleBackupConfig-fm
POST /setup/devices handlers.(*Server).HandleAddManualDevice-fm
POST /setup/discover handlers.(*Server).HandleTriggerDiscovery-fm
POST /setup/ensure-remote-services/{deviceId} handlers.(*Server).HandleEnsureRemoteServices-fm
POST /setup/health/fix handlers.(*Server).HandleHealthFix-fm
POST /setup/logging-settings handlers.(*Server).HandleUpdateLoggingSettings-fm
POST /setup/migrate/{deviceId} handlers.(*Server).HandleMigrateDevice-fm
POST /setup/pair-account/{deviceId} handlers.(*Server).HandlePairAccount-fm
POST /setup/peer-probe/{deviceId} handlers.(*Server).HandlePeerProbe-fm
POST /setup/reboot/{deviceId} handlers.(*Server).HandleRebootDevice-fm
POST /setup/remove-remote-services/{deviceId} handlers.(*Server).HandleRemoveRemoteServices-fm
POST /setup/revert/{deviceId} handlers.(*Server).HandleRevertMigration-fm
POST /setup/settings handlers.(*Server).HandleUpdateSettings-fm
POST /setup/sync/{deviceId} handlers.(*Server).HandleInitialSync-fm
POST /setup/test-connection/{deviceId} handlers.(*Server).HandleTestConnection-fm
POST /setup/test-dns/{deviceId} handlers.(*Server).HandleTestDNSRedirection-fm
POST /setup/test-hosts/{deviceId} handlers.(*Server).HandleTestHostsRedirection-fm
POST /setup/trust-ca/{deviceId} handlers.(*Server).HandleTrustCACert-fm
POST /streaming/account handlers.(*Server).HandleMargeCreateAccount-fm
POST /streaming/account/login handlers.(*Server).HandleMargeLogin-fm
POST /streaming/account/{account}/device/ handlers.(*Server).HandleMargeAddDevice-fm
POST /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeAddDevice-fm
POST /streaming/account/{account}/device/{device}/presets/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
POST /streaming/account/{account}/device/{device}/recent handlers.(*Server).HandleMargeAddRecent-fm
POST /streaming/account/{account}/group handlers.(*Server).HandleMargeAddGroup-fm
POST /streaming/account/{account}/group/ handlers.(*Server).HandleMargeAddGroup-fm
POST /streaming/account/{account}/group/{groupId} handlers.(*Server).HandleMargeModifyGroup-fm
POST /streaming/account/{account}/source handlers.(*Server).HandleMargeAddSource-fm
POST /streaming/device_setting/account/{account}/device/{device}/device_settings handlers.(*Server).HandleMargeUpdateDeviceSettings-fm
POST /streaming/music/musicprovider/{providerID}/is_eligible handlers.(*Server).HandleMusicProviderIsEligible-fm
POST /streaming/music/musicprovider/{providerID}/trial/is_eligible handlers.(*Server).HandleMusicProviderIsEligible-fm
POST /streaming/stats/error handlers.(*Server).HandleErrorStats-fm
POST /streaming/stats/usage handlers.(*Server).HandleUsageStats-fm
POST /streaming/support/customersupport handlers.(*Server).HandleMargeCustomerSupport-fm
POST /streaming/support/power_on handlers.(*Server).HandleMargePowerOn-fm
POST /v1/scmudc/{deviceId} handlers.(*Server).HandleAppEvents-fm
POST /v1/stapp/{deviceId} handlers.(*Server).HandleAppEvents-fm
PUT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
PUT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
PUT /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeUpdateDevice-fm
PUT /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
TRACE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
TRACE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
-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
-13
View File
@@ -1,13 +0,0 @@
package main
import "strings"
// sanitizeLog strips newline characters from s to prevent log-injection
// (CodeQL go/log-injection). Values from speakers, HTTP requests, and
// external APIs may contain attacker-controlled newlines.
func sanitizeLog(s string) string {
s = strings.ReplaceAll(s, "\n", `\n`)
s = strings.ReplaceAll(s, "\r", `\r`)
return s
}
-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", sanitizeLog(rawBind), sanitizeLog(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", sanitizeLog(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", sanitizeLog(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")
-2
View File
@@ -1,9 +1,7 @@
accounts/
backend/
certs/
default/
dns/
interactions/
parity_mismatches/
patterns.json
settings.json
+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
-32
View File
@@ -1,32 +0,0 @@
# Local Hugo/Hextra documentation server.
#
# Usage:
# make dev-docs # start the live-reload server (http://localhost:1313)
# make dev-docs-tidy # run hugo mod tidy (required on first run, or after
# # changing hugo.toml module imports)
# make hugo ARGS="..." # run any other hugo CLI command, e.g.
# # make hugo ARGS="version"
# # make hugo ARGS="new content/blog/my-post.md"
#
# The hugomods/hugo:exts image bundles Hugo extended + Go so Hugo modules
# (Hextra) work without any extra tooling on the host.
services:
hugo:
image: hugomods/hugo:exts
# --source docs/ because docs/ is the Hugo root inside the repo.
# --baseURL / overrides the production subpath (/Bose-SoundTouch/) so
# absolute links work at http://localhost:1313/ during local development.
# The full repo is mounted so enableGitInfo can read git history.
command: server --source docs/ --baseURL / --bind 0.0.0.0 --buildDrafts --navigateToChanged
ports:
- "1313:1313"
volumes:
- .:/src
# Persist the Hugo module cache across runs so 'hugo mod tidy' only
# downloads Hextra once.
- hugo-mod-cache:/root/.cache/hugo_cache
working_dir: /src
volumes:
hugo-mod-cache:
+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.
@@ -1,9 +1,3 @@
---
title: "Navigation API Reference"
sidebar:
exclude: true
---
# Navigation API Reference
## Overview
@@ -149,7 +143,7 @@ Browse stored/local music library.
**Example:**
```go
library, err := client.GetStoredMusicLibrary("AABBCCDDEEFF/0")
library, err := client.GetStoredMusicLibrary("A81B6A536A98/0")
```
**Validation:**
@@ -532,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>
@@ -713,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>
@@ -725,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>
@@ -1,18 +1,13 @@
---
title: "CLAUDE.md - Development Guidelines for Bose SoundTouch Project"
sidebar:
exclude: true
---
# CLAUDE.md - Development Guidelines for Bose SoundTouch Project
## Documentation Overview
This document contains important development guidelines for working on the Bose SoundTouch project. Please also read the following documentation:
- **[PLAN.md](../../../archive/PLAN.md)** - Project planning and roadmap
- **[PLAN.md](archive/PLAN.md)** - Project planning and roadmap
- **[PROJECT-PATTERNS.md](PROJECT-PATTERNS.md)** - Project structure and design patterns
- **[API-ENDPOINTS.md](../reference/API-ENDPOINTS.md)** - API endpoints overview
- **[API-ENDPOINTS.md](reference/API-ENDPOINTS.md)** - API endpoints overview
- **[SoundTouch Web API.pdf](2025.12.18%20SoundTouch%20Web%20API.pdf)** - Official API documentation
## Development Guidelines
@@ -78,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
@@ -1,9 +1,3 @@
---
title: "Content Selection Implementation Summary"
sidebar:
exclude: true
---
# Content Selection Implementation Summary
This document summarizes the implementation of advanced content selection features for the Bose SoundTouch Go client, including full support for the LOCAL_INTERNET_RADIO streamUrl format and LOCAL_MUSIC/STORED_MUSIC content selection.
@@ -29,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
@@ -61,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 \
@@ -124,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
@@ -175,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
@@ -192,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,
@@ -203,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"
```
@@ -218,9 +195,9 @@ soundtouch-cli --host 192.0.2.100 source internet-radio \
- [SoundTouch WebServices API Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)
- [LOCAL_INTERNET_RADIO - streamUrl format](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API#select-local_internet_radio---streamurl-format)
- [LOCAL_MUSIC](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API#select-local_music)
- [Content Selection Example](https://github.com/gesellix/Bose-SoundTouch/tree/main/examples/content-selection/README.md)
- [CLI Reference](../guides/CLI-REFERENCE.md)
- [Content Selection Example (Direct)](https://github.com/gesellix/Bose-SoundTouch/tree/main/examples/content-selection)
- [Content Selection Example](../examples/content-selection/README.md)
- [CLI Reference](guides/CLI-REFERENCE.md)
- [Content Selection Example (Direct)](../examples/content-selection/)
## ✅ Verification
@@ -1,9 +1,3 @@
---
title: "Device Customization Setup Guide"
sidebar:
exclude: true
---
# Device Customization Setup Guide
This guide documents the manual steps required to configure your Bose SoundTouch device for customization using the SoundCork approach.
@@ -52,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:~#
@@ -89,7 +83,7 @@ usb0 Link encap:Ethernet HWaddr CA:FE:BA:BE:1E:47
Sun Feb 1 19:12:47 CET 2026
Device name: "Kitchen SoundTouch"
Device name: "A Sound Machine"
Country EU, Region (not set)
Module type: scm
root@spotty:~#
@@ -1,9 +1,3 @@
---
title: "Device Logging & Troubleshooting"
sidebar:
exclude: true
---
# Device Logging & Troubleshooting
Accessing logs from SoundTouch devices is critical for debugging custom service integrations and understanding internal device behavior. This document outlines the methods for collecting logs, as discovered by the **SoundCork** and **ÜberBöse API** communities.
@@ -54,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
@@ -86,7 +74,7 @@ If you have a managed switch or a router capable of port mirroring, you can use
### "IsItBose" Validation Failures
If the device fails to connect to your custom service despite correct configuration, it may be failing the internal `IsItBose` regex check.
- **Evidence**: Look for SSL handshake failures or "Unauthorized" errors in your service logs.
- **Solution**: See the [Binary Patching section in DEVICE-REDIRECT-METHODS.md](../analysis/DEVICE-REDIRECT-METHODS.md#method-3-binary-patching).
- **Solution**: See the [Binary Patching section in DEVICE-REDIRECT-METHODS.md](analysis/DEVICE-REDIRECT-METHODS.md#method-3-binary-patching).
### Disappearing Sources (TuneIn/Local Radio)
If `TUNEIN` or `LOCAL_INTERNET_RADIO` sources disappear after a reboot in an offline environment.
@@ -1,16 +1,10 @@
---
title: "Feature Development History"
sidebar:
exclude: true
---
# Feature Development History
This document tracks the detailed evolution of features and capabilities in the Bose SoundTouch API client library.
## 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
@@ -29,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
@@ -54,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
@@ -62,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
@@ -87,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
@@ -110,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
@@ -136,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
@@ -172,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
@@ -194,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
@@ -237,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
@@ -255,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
@@ -289,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`
@@ -300,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
@@ -377,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.**
@@ -1,9 +1,3 @@
---
title: "Host:Port Parsing Feature"
sidebar:
exclude: true
---
# Host:Port Parsing Feature
This document describes the automatic host:port parsing functionality added to the SoundTouch CLI, which allows users to specify both host and port in a single `-host` flag.
@@ -17,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
@@ -41,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
@@ -105,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
@@ -129,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
@@ -169,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
@@ -203,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
@@ -1,9 +1,3 @@
---
title: "Manual Network Discovery on macOS"
sidebar:
exclude: true
---
# Manual Network Discovery on macOS
This document provides comprehensive guidance for manually discovering network services and devices using built-in macOS tools and command-line utilities. This is particularly useful for troubleshooting network discovery issues or understanding what services are available on your local network.
@@ -322,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
@@ -334,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
@@ -369,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
@@ -445,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,9 +1,3 @@
---
title: "Navigation and Station Management Guide"
sidebar:
exclude: true
---
# Navigation and Station Management Guide
## Overview
@@ -46,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)
@@ -901,4 +895,4 @@ For additional help:
---
*This guide covers the complete navigation and station management functionality. For preset management, see [PRESET-MANAGEMENT.md](../reference/PRESET-MANAGEMENT.md).*
*This guide covers the complete navigation and station management functionality. For preset management, see [PRESET-MANAGEMENT.md](reference/PRESET-MANAGEMENT.md).*
@@ -1,9 +1,3 @@
---
title: "Official SoundTouch Web API Verification"
sidebar:
exclude: true
---
# Official SoundTouch Web API Verification
**Source**: Official Bose SoundTouch Web API v1.0 Documentation (January 7, 2026)
@@ -1,9 +1,3 @@
---
title: "Preset Management Quick Start Guide"
sidebar:
exclude: true
---
# Preset Management Quick Start Guide
**Save your favorite music, radio stations, and playlists as 1-6 presets for instant access.**
@@ -16,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" \
@@ -38,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
@@ -67,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:**
@@ -109,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"
@@ -141,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,
})
@@ -241,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
@@ -292,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
@@ -338,11 +332,11 @@ soundtouch-cli --host 192.0.2.100 info
## Next Steps
- 📖 [Complete CLI Reference](../guides/CLI-REFERENCE.md)
- 🔧 [Full Implementation Guide](../reference/PRESET-MANAGEMENT.md)
- 📡 [WebSocket Events Documentation](../reference/WEBSOCKET-EVENTS.md)
- 💻 [Preset Management Example](https://github.com/gesellix/Bose-SoundTouch/tree/main/examples/preset-management)
- 📚 [API Endpoints Overview](../reference/API-ENDPOINTS.md)
- 📖 [Complete CLI Reference](guides/CLI-REFERENCE.md)
- 🔧 [Full Implementation Guide](reference/PRESET-MANAGEMENT.md)
- 📡 [WebSocket Events Documentation](reference/WEBSOCKET-EVENTS.md)
- 💻 [Preset Management Example](../examples/preset-management/)
- 📚 [API Endpoints Overview](reference/API-ENDPOINTS.md)
## Need Help?
@@ -1,9 +1,3 @@
---
title: "Project Structure Patterns: Bose SoundTouch API Client"
sidebar:
exclude: true
---
# Project Structure Patterns: Bose SoundTouch API Client
## Summary for Reuse in API Client Projects
@@ -27,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
@@ -72,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
@@ -83,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",
@@ -123,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
}
@@ -164,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
}
@@ -190,7 +184,7 @@ func (e *EventClient) eventLoop() {
}
return
}
if handler, exists := e.handlers[event.Type]; exists {
go handler(event)
}
@@ -226,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()
@@ -234,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
}
```
@@ -286,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)
}
@@ -306,7 +300,7 @@ func main() {
},
},
}
app.Run(os.Args)
}
@@ -317,7 +311,7 @@ func getClientFromContext(c *cli.Context) *client.Client {
devices, _ := discovery.DiscoverDevices()
deviceHost = selectDeviceInteractive(devices)
}
return client.NewClient(deviceHost, 8090)
}
```
@@ -333,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))
}
@@ -370,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)
@@ -454,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)
@@ -475,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)
@@ -551,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",
@@ -583,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
@@ -747,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"`
@@ -779,7 +773,7 @@ func main() {
},
},
}
app.Run(os.Args)
}
```
@@ -808,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.
+32
View File
@@ -0,0 +1,32 @@
# Bose SoundTouch Toolkit Documentation
Welcome to the documentation for the Bose SoundTouch Toolkit. This toolkit helps you keep your Bose SoundTouch speakers functional even after the Bose Cloud shutdown in May 2026.
## 📖 Quick Links
- [Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)
- [Migration & Safety Guide](guides/MIGRATION-SAFETY.md)
- [CLI Reference](guides/CLI-REFERENCE.md)
- [Getting Started](guides/GETTING-STARTED.md)
- [SoundTouch Service Guide](guides/SOUNDTOUCH-SERVICE.md)
## 🗂 Documentation Structure
### User Guides
- [Initial Device Setup](guides/DEVICE-INITIAL-SETUP.md)
- [HTTPS Setup](guides/HTTPS-SETUP.md)
- [Deployment Guide](guides/DEPLOYMENT.md)
- [Raspberry Pi Setup](guides/RASPBERRY-PI.md)
- [Troubleshooting](guides/TROUBLESHOOTING.md)
### Technical Reference
- [API Endpoints](reference/API-ENDPOINTS.md)
- [WebSocket Events](reference/WEBSOCKET-EVENTS.md)
- [Zone Management](reference/ZONE-MANAGEMENT.md)
- [Preset Management](reference/PRESET-MANAGEMENT.md)
### Analysis & Research
- [Upstream URLs](analysis/UPSTREAM-URLS.md)
- [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md)
For a complete list of all documents, see the [Summary](SUMMARY.md).
@@ -1,9 +1,3 @@
---
title: "Service Availability Implementation Summary"
sidebar:
exclude: true
---
# Service Availability Implementation Summary
## Overview
@@ -127,7 +121,7 @@ sa.GetUnavailableServiceCount()
### Basic Usage
```go
client := client.NewClientFromHost("192.0.2.100")
client := client.NewClientFromHost("192.168.1.100")
serviceAvailability, err := client.GetServiceAvailability()
if err != nil {
@@ -1,13 +1,7 @@
---
title: "🎉 Introducing SoundTouch Service: Local Cloud Service Emulation"
sidebar:
exclude: true
---
# 🎉 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?
@@ -74,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
@@ -125,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
@@ -143,17 +137,17 @@ 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
## 📚 Documentation
- **[Complete Service Guide](../guides/SOUNDTOUCH-SERVICE.md)**: Comprehensive setup and configuration
- **[API Reference](../guides/SOUNDTOUCH-SERVICE.md#api-reference)**: Full endpoint documentation
- **[Migration Guide](../guides/SOUNDTOUCH-SERVICE.md#device-migration)**: Step-by-step device migration
- **[Troubleshooting](../guides/SOUNDTOUCH-SERVICE.md#troubleshooting)**: Common issues and solutions
- **[Complete Service Guide](guides/SOUNDTOUCH-SERVICE.md)**: Comprehensive setup and configuration
- **[API Reference](guides/SOUNDTOUCH-SERVICE.md#api-reference)**: Full endpoint documentation
- **[Migration Guide](guides/SOUNDTOUCH-SERVICE.md#device-migration)**: Step-by-step device migration
- **[Troubleshooting](guides/SOUNDTOUCH-SERVICE.md#troubleshooting)**: Common issues and solutions
## 🤝 Contributing
@@ -178,9 +172,9 @@ The collaborative spirit of reverse engineering and documentation in the SoundTo
## 🔗 Links
- **[Main Repository](https://github.com/gesellix/bose-soundtouch)**
- **[Service Documentation](../guides/SOUNDTOUCH-SERVICE.md)**
- **[CLI Documentation](../guides/CLI-REFERENCE.md)**
- **[Getting Started Guide](../guides/GETTING-STARTED.md)**
- **[Service Documentation](guides/SOUNDTOUCH-SERVICE.md)**
- **[CLI Documentation](guides/CLI-REFERENCE.md)**
- **[Getting Started Guide](guides/GETTING-STARTED.md)**
- **[SoundCork Project](https://github.com/deborahgu/soundcork)**
- **[ÜberBöse API](https://github.com/julius-d/ueberboese-api)**
+67
View File
@@ -0,0 +1,67 @@
# Table of Contents
* [Introduction](README.md)
## User Guides
* [Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)
* [Migration & Safety Guide](guides/MIGRATION-SAFETY.md)
* [CLI Reference](guides/CLI-REFERENCE.md)
* [Getting Started](guides/GETTING-STARTED.md)
* [SoundTouch Service](guides/SOUNDTOUCH-SERVICE.md)
* [Initial Device Setup](guides/DEVICE-INITIAL-SETUP.md)
* [HTTPS Setup](guides/HTTPS-SETUP.md)
* [Deployment](guides/DEPLOYMENT.md)
* [Raspberry Pi Guide](guides/RASPBERRY-PI.md)
* [Troubleshooting](guides/TROUBLESHOOTING.md)
* [Useful Links](#useful-links)
### Useful Links
* [Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)
* [Raspberry Pi Installer](../scripts/raspberry-pi/README.md)
* [Updating the Service](../scripts/raspberry-pi/README.md#updating-to-a-new-version)
* [CLI Reference](guides/CLI-REFERENCE.md)
## Technical Reference
* [API Cookbook](reference/API-COOKBOOK.md)
* [API Endpoints](reference/API-ENDPOINTS.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)
* [Discovery](reference/DISCOVERY.md)
* [Zone Management](reference/ZONE-MANAGEMENT.md)
* [Preset Management](reference/PRESET-MANAGEMENT.md)
* [Source Selection](reference/SOURCE-SELECTION.md)
* [Volume Controls](reference/VOLUME-CONTROLS.md)
* [RadioBrowser](reference/radio-browser.md)
* [Bass Controls](reference/BASS-CONTROLS.md)
* [Key Controls](reference/KEY-CONTROLS.md)
* [Feature Mapping](reference/FEATURE-MAPPING.md)
## Analysis & Research
* [API Coverage Analysis](analysis/API-COVERAGE.md)
* [Supported URLs](analysis/SUPPORTED-URLS.md)
* [Upstream URLs](analysis/UPSTREAM-URLS.md)
* [Anonymization Summary](analysis/ANONYMIZATION-SUMMARY.md)
* [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md)
* [Stockholm App Analysis](analysis/stockholm-app-analysis.md)
* [Wiki API Comparison](analysis/WIKI-COMPARISON.md)
## Appendix (Other Documents)
* [API Navigation Reference](API-NAVIGATION-REFERENCE.md)
* [Claude Instructions](CLAUDE.md)
* [Content Selection Implementation](CONTENT-SELECTION-IMPLEMENTATION.md)
* [Device Customization Setup](DEVICE-CUSTOMIZATION-SETUP.md)
* [Device Logging](DEVICE-LOGGING.md)
* [Feature History](FEATURE_HISTORY.md)
* [Host/Port Parsing](HOST-PORT-PARSING.md)
* [Manual Network Discovery](MANUAL-NETWORK-DISCOVERY.md)
* [Navigation Guide](NAVIGATION-GUIDE.md)
* [Official API Verification](OFFICIAL-API-VERIFICATION.md)
* [Preset Quickstart](PRESET-QUICKSTART.md)
* [Project Patterns](PROJECT-PATTERNS.md)
* [Service Availability Implementation](SERVICE-AVAILABILITY-IMPLEMENTATION.md)
* [SoundTouch Service Announcement](SOUNDTOUCH-SERVICE-ANNOUNCEMENT.md)
* [Undocumented Community Features](UNDOCUMENTED-COMMUNITY-FEATURES.md)
* [Unimplemented Endpoints](UNIMPLEMENTED-ENDPOINTS.md)
* [Preset Store](preset-store.md)
@@ -1,9 +1,3 @@
---
title: "Undocumented Community Features & API Discoveries"
sidebar:
exclude: true
---
# Undocumented Community Features & API Discoveries
This document captures advanced API endpoints and device behaviors discovered by the SoundTouch community through reverse engineering projects like **SoundCork** and **ÜberBöse API**. These features are not documented in the official Bose SoundTouch Web API v1.0 but are crucial for full device emulation and offline operation.
## Cloud Emulation (Marge/BMX) Discoveries
@@ -1,9 +1,3 @@
---
title: "Unimplemented SoundTouch API Endpoints"
sidebar:
exclude: true
---
# Unimplemented SoundTouch API Endpoints
**Last Updated:** January 2026
@@ -281,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>
```
@@ -647,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>
```
@@ -677,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>
@@ -713,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>
@@ -768,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>
+11
View File
@@ -0,0 +1,11 @@
title: Bose SoundTouch Toolkit
description: Documentation for controlling and preserving Bose SoundTouch devices
remote_theme: pages-themes/minimal@v0.2.0
plugins:
- jekyll-remote-theme
- jekyll-relative-links
relative_links:
enabled: true
collections: true
include:
- SUMMARY.md
-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>

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