mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-31 14:57:17 +00:00
Compare commits
+26
-8
@@ -5,6 +5,24 @@
|
||||
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
|
||||
UPNP_ENABLED=true
|
||||
@@ -25,23 +43,23 @@ CACHE_TTL=30s
|
||||
|
||||
# Examples:
|
||||
# Single device with default port:
|
||||
# PREFERRED_DEVICES="192.168.1.100"
|
||||
# PREFERRED_DEVICES="192.0.2.100"
|
||||
|
||||
# Single device with custom name:
|
||||
# PREFERRED_DEVICES="Living Room@192.168.1.100"
|
||||
# PREFERRED_DEVICES="Living Room@192.0.2.100"
|
||||
|
||||
# Single device with custom port:
|
||||
# PREFERRED_DEVICES="192.168.1.100:8091"
|
||||
# PREFERRED_DEVICES="192.0.2.100:8091"
|
||||
|
||||
# Multiple devices with mixed configurations:
|
||||
PREFERRED_DEVICES="Living Room@192.168.1.100:8090;Kitchen@192.168.1.101;192.168.1.102:8091"
|
||||
PREFERRED_DEVICES="Living Room@192.0.2.100:8090;Kitchen@192.0.2.101;192.0.2.102:8091"
|
||||
|
||||
# Real example based on your devices:
|
||||
# PREFERRED_DEVICES="Sound Machinechen@192.168.178.35;A Sound Machine@192.168.178.28"
|
||||
# Example — replace with your speakers' names and IPs:
|
||||
# PREFERRED_DEVICES="Living Room SoundTouch@192.0.2.10;Kitchen SoundTouch@192.0.2.11"
|
||||
|
||||
# Alternative format examples:
|
||||
# PREFERRED_DEVICES="192.168.178.35;192.168.178.28"
|
||||
# PREFERRED_DEVICES="SoundTouch 10@192.168.178.35;SoundTouch 20@192.168.178.28"
|
||||
# PREFERRED_DEVICES="192.0.2.10;192.0.2.11"
|
||||
# PREFERRED_DEVICES="SoundTouch 10@192.0.2.10;SoundTouch 20@192.0.2.11"
|
||||
|
||||
# Spotify Integration
|
||||
# Create an app at https://developer.spotify.com/dashboard
|
||||
|
||||
@@ -30,7 +30,7 @@ A clear and concise description of what you expected to happen.
|
||||
**Command/Code that failed**
|
||||
```bash
|
||||
# If using CLI tool, provide the exact command
|
||||
soundtouch-cli --host 192.168.1.100 info get
|
||||
soundtouch-cli --host 192.0.2.100 info get
|
||||
|
||||
# If using Go library, provide minimal code example
|
||||
```
|
||||
|
||||
@@ -163,7 +163,7 @@ body:
|
||||
label: Network Configuration
|
||||
description: Details about your network setup (if relevant to the issue)
|
||||
placeholder: |
|
||||
- Device IP: 192.168.1.100
|
||||
- Device IP: 192.0.2.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.168.1.100 info get
|
||||
soundtouch-cli --host 192.168.1.100 play start
|
||||
soundtouch-cli --host 192.0.2.100 info get
|
||||
soundtouch-cli --host 192.0.2.100 play start
|
||||
# ... etc
|
||||
```
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ client.NewFeature(parameters)
|
||||
|
||||
```bash
|
||||
# CLI example
|
||||
soundtouch-cli --host 192.168.1.100 new-feature --param value
|
||||
soundtouch-cli --host 192.0.2.100 new-feature --param value
|
||||
```
|
||||
|
||||
**Priority**
|
||||
|
||||
@@ -131,7 +131,7 @@ body:
|
||||
render: go
|
||||
placeholder: |
|
||||
// Example of how you envision using this feature
|
||||
client := soundtouch.New("192.168.1.100", 8090)
|
||||
client := soundtouch.New("192.0.2.100", 8090)
|
||||
|
||||
// Your desired API call
|
||||
result, err := client.NewFeature(options)
|
||||
|
||||
@@ -98,3 +98,25 @@ updates:
|
||||
- "dependencies"
|
||||
- "docker"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
# npm dependency updates
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "thursday"
|
||||
time: "09:00"
|
||||
timezone: "UTC"
|
||||
open-pull-requests-limit: 3
|
||||
reviewers:
|
||||
- "gesellix"
|
||||
assignees:
|
||||
- "gesellix"
|
||||
commit-message:
|
||||
prefix: "deps"
|
||||
include: "scope"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "npm"
|
||||
- "frontend"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"retryOn429": true,
|
||||
"retryCount": 3,
|
||||
"fallbackRetryDelay": "30s",
|
||||
"aliveStatusCodes": [200, 206],
|
||||
"aliveStatusCodes": [200, 202, 206],
|
||||
"ignorePatterns": [
|
||||
{
|
||||
"pattern": "^http://localhost"
|
||||
|
||||
@@ -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.168.1.100]
|
||||
- Device IP: [e.g. 192.0.2.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.168.1.100 new-command
|
||||
soundtouch-cli --host 192.0.2.100 new-command
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
+133
-41
@@ -17,15 +17,15 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
run: make test-http-client
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v6
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
with:
|
||||
file: ./coverage.out
|
||||
flags: unittests
|
||||
@@ -66,10 +66,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Run golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
|
||||
with:
|
||||
version: latest
|
||||
args: --timeout=5m
|
||||
@@ -86,39 +86,76 @@ jobs:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
goos: [linux, darwin, windows]
|
||||
goarch: [amd64, arm64]
|
||||
exclude:
|
||||
# Windows ARM64 builds are experimental
|
||||
- goos: windows
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
- goos: linux
|
||||
goarch: arm
|
||||
goarm: 7
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
- goos: freebsd
|
||||
goarch: amd64
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Build CLI
|
||||
- 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
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
GOARM: ${{ matrix.goarm }}
|
||||
CGO_ENABLED: 0
|
||||
run: |
|
||||
output_name="soundtouch-cli-${{ matrix.goos }}-${{ matrix.goarch }}"
|
||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||
output_name="${output_name}.exe"
|
||||
ARCH_SUFFIX="${{ matrix.goos }}-${{ matrix.goarch }}"
|
||||
if [[ -n "${{ matrix.goarm }}" ]]; then
|
||||
ARCH_SUFFIX="${ARCH_SUFFIX}v${{ matrix.goarm }}"
|
||||
fi
|
||||
go build -trimpath -ldflags="-s -w" -o "$output_name" ./cmd/soundtouch-cli
|
||||
|
||||
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/
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: soundtouch-cli-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: soundtouch-cli-*
|
||||
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm }}
|
||||
path: build/
|
||||
|
||||
security:
|
||||
name: Basic Security Check
|
||||
@@ -126,10 +163,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
@@ -153,7 +190,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check documentation links
|
||||
run: |
|
||||
@@ -211,10 +248,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
@@ -238,7 +275,7 @@ jobs:
|
||||
|
||||
func main() {
|
||||
// Test basic client creation
|
||||
c := client.NewClientFromHost("192.168.1.100")
|
||||
c := client.NewClientFromHost("192.0.2.100")
|
||||
fmt.Printf("Client created for %s\n", c.BaseURL())
|
||||
|
||||
// Test models can be imported
|
||||
@@ -266,14 +303,28 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Determine push eligibility
|
||||
id: push-check
|
||||
run: |
|
||||
# Push on main, and on same-repo PRs (forks can't push to GHCR via GITHUB_TOKEN).
|
||||
SHOULD_PUSH="false"
|
||||
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/main" ]]; then
|
||||
SHOULD_PUSH="true"
|
||||
elif [[ "${{ github.event_name }}" == "pull_request" && \
|
||||
"${{ github.event.pull_request.head.repo.full_name }}" == "${{ github.repository }}" ]]; then
|
||||
SHOULD_PUSH="true"
|
||||
fi
|
||||
echo "should-push=$SHOULD_PUSH" >> "$GITHUB_OUTPUT"
|
||||
echo "Will push: $SHOULD_PUSH"
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
uses: docker/login-action@v4
|
||||
if: steps.push-check.outputs.should-push == 'true'
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -281,20 +332,22 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for soundtouch-service
|
||||
id: meta-service
|
||||
uses: docker/metadata-action@v6
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=ref,event=pr
|
||||
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-service Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-service
|
||||
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
|
||||
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
push: ${{ steps.push-check.outputs.should-push == 'true' }}
|
||||
tags: ${{ steps.meta-service.outputs.tags }}
|
||||
labels: ${{ steps.meta-service.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
@@ -302,25 +355,64 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for soundtouch-web
|
||||
id: meta-web
|
||||
uses: docker/metadata-action@v6
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}-web
|
||||
tags: |
|
||||
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=ref,event=pr
|
||||
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@v7
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-web
|
||||
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
|
||||
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
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
|
||||
@@ -355,7 +447,7 @@ jobs:
|
||||
|
||||
- name: Update commit status
|
||||
if: always()
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
try {
|
||||
|
||||
@@ -20,18 +20,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v6
|
||||
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
||||
- name: Build with Jekyll
|
||||
uses: actions/jekyll-build-pages@v1
|
||||
uses: actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697 # v1.0.13
|
||||
with:
|
||||
source: 'docs/'
|
||||
destination: '_site'
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
|
||||
with:
|
||||
path: '_site'
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v5
|
||||
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: ${{ env.GO_VERSION_FILE }}
|
||||
|
||||
@@ -102,15 +102,15 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: ${{ env.GO_VERSION_FILE }}
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
@@ -206,7 +206,7 @@ jobs:
|
||||
echo "✅ Checksums generated successfully"
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm }}
|
||||
path: |
|
||||
@@ -223,7 +223,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download binary artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: binaries-*
|
||||
path: ./binaries
|
||||
@@ -280,7 +280,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload checksums
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: checksums
|
||||
path: |
|
||||
@@ -291,7 +291,7 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload all release assets
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: release-assets
|
||||
path: binaries/release-files/
|
||||
@@ -305,12 +305,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download release assets
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: release-assets
|
||||
path: ./release-assets
|
||||
@@ -358,7 +358,7 @@ jobs:
|
||||
|
||||
func main() {
|
||||
// Create client
|
||||
c := client.New("192.168.1.100", 8090)
|
||||
c := client.New("192.0.2.100", 8090)
|
||||
|
||||
// Get device info
|
||||
info, err := c.GetInfo()
|
||||
@@ -468,7 +468,7 @@ jobs:
|
||||
echo "release_notes_file=release_notes.md" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
tag_name: ${{ github.event.inputs.tag }}
|
||||
name: "Bose SoundTouch Go Library ${{ github.event.inputs.tag }}"
|
||||
@@ -494,13 +494,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download release assets
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: release-assets
|
||||
path: ./release-assets
|
||||
|
||||
- name: Upload additional assets to existing release
|
||||
uses: softprops/action-gh-release@v3
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
tag_name: ${{ github.event.release.tag_name }}
|
||||
files: |
|
||||
@@ -521,13 +521,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -535,7 +535,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for soundtouch-service
|
||||
id: meta-service
|
||||
uses: docker/metadata-action@v6
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
@@ -544,7 +544,7 @@ jobs:
|
||||
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
|
||||
|
||||
- name: Build and push soundtouch-service Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-service
|
||||
@@ -557,7 +557,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for soundtouch-web
|
||||
id: meta-web
|
||||
uses: docker/metadata-action@v6
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}-web
|
||||
tags: |
|
||||
@@ -566,7 +566,7 @@ jobs:
|
||||
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
|
||||
|
||||
- name: Build and push soundtouch-web Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-web
|
||||
|
||||
@@ -19,10 +19,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
|
||||
- name: Upload vulnerability scan results
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: vulnerability-scan-results
|
||||
path: |
|
||||
@@ -63,10 +63,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Run Semgrep security analysis
|
||||
uses: semgrep/semgrep-action@v1
|
||||
uses: semgrep/semgrep-action@713efdd345f3035192eaa63f56867b88e63e4e5d # v1 (no v1.x.y semver tag exists)
|
||||
with:
|
||||
config: >-
|
||||
p/security-audit
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
|
||||
- name: Upload Semgrep SARIF results
|
||||
if: always()
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
sarif_file: semgrep.sarif
|
||||
continue-on-error: true
|
||||
@@ -110,22 +110,22 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install libpcap
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
languages: go
|
||||
config-file: ./.github/codeql-config.yml
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
category: "/language:go"
|
||||
|
||||
@@ -138,10 +138,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Dependency Review
|
||||
uses: actions/dependency-review-action@v4
|
||||
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
|
||||
with:
|
||||
fail-on-severity: moderate
|
||||
allow-ghsas: GHSA-xxxx-xxxx-xxxx # Add specific allowlisted advisories if needed
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
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
|
||||
+23
@@ -16,12 +16,14 @@ dist/
|
||||
/soundtouch-cli
|
||||
/soundtouch-service
|
||||
/soundtouch-web
|
||||
/dummy-speaker
|
||||
/example-mdns
|
||||
/example-upnp
|
||||
/example-unified
|
||||
/mdns-scanner
|
||||
/websocket-demo
|
||||
/main
|
||||
/screenshots
|
||||
|
||||
# Environment configuration
|
||||
.env
|
||||
@@ -41,10 +43,13 @@ go.work.sum
|
||||
|
||||
# Dependency directories
|
||||
vendor/
|
||||
node_modules/
|
||||
|
||||
# IDE and editor files
|
||||
.vscode/
|
||||
.idea/
|
||||
.claude/
|
||||
.junie/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
@@ -96,3 +101,21 @@ pids
|
||||
|
||||
# dotenv environment variables file (but keep .env.example)
|
||||
!.env.example
|
||||
|
||||
# Stockholm frontend — generated by `make prepare-stockholm`, not committed
|
||||
stockholm/
|
||||
|
||||
!pkg/service/stockholm/
|
||||
|
||||
# Stockholm source zip — large binary, place manually at stockholm_zip/stockholm.zip
|
||||
stockholm_zip/*.zip
|
||||
|
||||
# Local working-tree notes — running pickup-here log (NEXT) + archive of
|
||||
# resolved items (DONE). Both are session-local scratch, not project docs.
|
||||
NEXT.md
|
||||
DONE.md
|
||||
|
||||
# Plan/tracking note for the Health-tab debug-utility programme.
|
||||
# Living document; commit history of the checks themselves is the
|
||||
# source of truth for what shipped.
|
||||
SERVICE-HEALTH.md
|
||||
|
||||
@@ -78,6 +78,13 @@ 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
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
# 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.
|
||||
+26
-6
@@ -2,6 +2,17 @@
|
||||
|
||||
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)
|
||||
@@ -15,6 +26,7 @@ Thank you for your interest in contributing to the Bose SoundTouch API Client! T
|
||||
- [Reporting Issues](#reporting-issues)
|
||||
- [Device Testing](#device-testing)
|
||||
- [Community](#community)
|
||||
- [Support the Project](#support-the-project)
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
@@ -145,7 +157,7 @@ golangci-lint run --fix
|
||||
go install ./cmd/soundtouch-cli
|
||||
|
||||
# Run integration tests (requires real device)
|
||||
make test-integration HOST=192.168.1.100
|
||||
make test-integration HOST=192.0.2.100
|
||||
```
|
||||
|
||||
### Environment Setup
|
||||
@@ -154,7 +166,7 @@ For development with real devices, create a `.env` file:
|
||||
|
||||
```env
|
||||
# Optional: Pre-configured device for testing
|
||||
SOUNDTOUCH_HOST=192.168.1.100
|
||||
SOUNDTOUCH_HOST=192.0.2.100
|
||||
SOUNDTOUCH_PORT=8090
|
||||
|
||||
# Optional: Enable debug logging
|
||||
@@ -328,7 +340,7 @@ When possible, test with real SoundTouch devices:
|
||||
|
||||
```bash
|
||||
# Set device IP for integration tests
|
||||
export SOUNDTOUCH_HOST=192.168.1.100
|
||||
export SOUNDTOUCH_HOST=192.0.2.100
|
||||
go test -tags integration ./pkg/client/
|
||||
```
|
||||
|
||||
@@ -349,7 +361,7 @@ go test -tags integration ./pkg/client/
|
||||
// Basic usage:
|
||||
//
|
||||
// client := client.NewClient(&client.Config{
|
||||
// Host: "192.168.1.100",
|
||||
// Host: "192.0.2.100",
|
||||
// Port: 8090,
|
||||
// })
|
||||
//
|
||||
@@ -398,8 +410,8 @@ If you have access to other SoundTouch models:
|
||||
|
||||
2. **Test basic functionality**:
|
||||
```bash
|
||||
./soundtouch-cli -h 192.168.1.100 info get
|
||||
./soundtouch-cli -h 192.168.1.100 now-playing get
|
||||
./soundtouch-cli -h 192.0.2.100 info get
|
||||
./soundtouch-cli -h 192.0.2.100 now-playing get
|
||||
```
|
||||
|
||||
3. **Report compatibility** in your PR or issue
|
||||
@@ -465,6 +477,14 @@ Contributors will be:
|
||||
- **Mentioned in release notes** for significant contributions
|
||||
- **Credited in documentation** where appropriate
|
||||
|
||||
## Support the Project
|
||||
|
||||
If you want to support the maintenance effort beyond code:
|
||||
|
||||
[](https://github.com/sponsors/gesellix)
|
||||
|
||||
Sponsorship is entirely optional. Code, docs, and bug reports remain the most useful contributions for the project itself.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Go Documentation](https://golang.org/doc/)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Dockerfile.stockholm — builds the Stockholm frontend preparation image.
|
||||
#
|
||||
# This image clones krahl/soundcork-stockholm-app, installs the required tools
|
||||
# (prettier, patch, unzip, jq), and is used exclusively to run the entrypoint
|
||||
# preparation step that extracts and patches the Stockholm frontend.
|
||||
#
|
||||
# Java is NOT included — we stop before `exec java`.
|
||||
#
|
||||
# Usage (see Makefile targets build-stockholm-image / prepare-stockholm):
|
||||
#
|
||||
# docker build --build-arg STOCKHOLM_APP_REF=main \
|
||||
# -f Dockerfile.stockholm -t soundcork-stockholm-app .
|
||||
#
|
||||
# docker run --rm \
|
||||
# -v "$PWD/stockholm_zip:/app/stockholm_zip:ro" \
|
||||
# -v "$PWD/stockholm:/app/stockholm" \
|
||||
# --entrypoint bash soundcork-stockholm-app \
|
||||
# -c 'awk "/^exec java/{exit} {print}" /app/docker-entrypoint.sh | bash'
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
ARG STOCKHOLM_APP_REF=main
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
git \
|
||||
jq \
|
||||
unzip \
|
||||
nodejs \
|
||||
npm \
|
||||
patch && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN npm install -g prettier@3.8.3 && npm cache clean --force
|
||||
|
||||
RUN git clone --depth 1 --branch "${STOCKHOLM_APP_REF}" \
|
||||
https://github.com/krahl/soundcork-stockholm-app /app
|
||||
|
||||
WORKDIR /app
|
||||
@@ -1,4 +1,7 @@
|
||||
.PHONY: all build build-cli test test-coverage check fmt vet lint clean dev help
|
||||
.PHONY: all build build-cli test test-coverage test-http-client test-http-client-rotate check fmt vet lint clean dev help screenshots build-stockholm-image prepare-stockholm update-static-deps
|
||||
|
||||
# Load .env if present (simple KEY=VALUE format, no shell quoting)
|
||||
-include .env
|
||||
|
||||
# Go parameters
|
||||
GOCMD=go
|
||||
@@ -31,6 +34,22 @@ 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)
|
||||
|
||||
all: check build
|
||||
|
||||
build: build-cli build-service build-web build-examples build-favicon-gen build-backup
|
||||
@@ -130,6 +149,20 @@ test-coverage:
|
||||
|
||||
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
|
||||
@@ -169,7 +202,9 @@ test-http-client:
|
||||
/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=$$?; \
|
||||
@@ -212,6 +247,18 @@ 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
|
||||
@@ -219,7 +266,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.168.1.10"; \
|
||||
echo "Usage: make dev-info HOST=192.0.2.10"; \
|
||||
exit 1; \
|
||||
fi
|
||||
$(BUILD_DIR)/$(BINARY_NAME) -host $(HOST) -info
|
||||
@@ -295,7 +342,7 @@ dev-backup-local: build-backup
|
||||
dev-web-host: build-web
|
||||
@echo "Starting web UI with specific host..."
|
||||
@if [ -z "$(HOST)" ]; then \
|
||||
echo "Usage: make dev-web-host HOST=192.168.1.10"; \
|
||||
echo "Usage: make dev-web-host HOST=192.0.2.10"; \
|
||||
exit 1; \
|
||||
fi
|
||||
cd cmd/soundtouch-web && ../../$(BUILD_DIR)/$(WEB_NAME) -host $(HOST)
|
||||
@@ -307,6 +354,10 @@ install: build-cli build-service build-web build-backup
|
||||
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..."
|
||||
$(GOCLEAN)
|
||||
@@ -327,6 +378,69 @@ 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-run-host:
|
||||
@echo "Running Docker container..."
|
||||
@echo "Note: --network host is used for discovery (Linux only). For macOS/Windows use port mapping."
|
||||
@@ -336,6 +450,10 @@ docker-run-ports:
|
||||
@echo "Running Docker container with port mapping (discovery will be manual)..."
|
||||
docker run --rm -it -p 8000:8000 -v $$(pwd)/data:/app/data soundtouch-service
|
||||
|
||||
screenshots:
|
||||
@echo "Capturing documentation screenshots..."
|
||||
@bash scripts/screenshots/run.sh
|
||||
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo " build - Build the CLI tool, service, and examples"
|
||||
@@ -348,6 +466,8 @@ help:
|
||||
@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"
|
||||
@@ -356,6 +476,8 @@ help:
|
||||
@echo " dev - Build and show CLI help"
|
||||
@echo " dev-service - Build and run service locally"
|
||||
@echo " dev-service-proxy - Build and run service with proxy (PROXY_URL=url required)"
|
||||
@echo " dev-service-stockholm - Build and run service with Stockholm frontend (requires prior 'make prepare-stockholm')"
|
||||
@echo " screenshots - Capture documentation screenshots (headless Chrome via chromedp)"
|
||||
@echo " dev-discover - Build and run device discovery"
|
||||
@echo " dev-info - Build and get device info (HOST=ip required)"
|
||||
@echo " dev-mdns - Build and run mDNS discovery example"
|
||||
@@ -379,13 +501,16 @@ help:
|
||||
@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.168.1.50:8001"
|
||||
@echo " make dev-service-proxy PROXY_URL=http://192.0.2.50:8001"
|
||||
@echo " make dev-discover"
|
||||
@echo " make dev-info HOST=192.168.1.10"
|
||||
@echo " make dev-info HOST=192.0.2.10"
|
||||
@echo " make dev-mdns"
|
||||
@echo " make dev-mdns-verbose"
|
||||
@echo " make dev-mdns-timeout TIMEOUT=10s"
|
||||
@@ -396,6 +521,6 @@ help:
|
||||
@echo " make dev-scan-soundtouch"
|
||||
@echo " make dev-web"
|
||||
@echo " make dev-web-port PORT=8888"
|
||||
@echo " make dev-web-host HOST=192.168.1.10"
|
||||
@echo " make dev-web-host HOST=192.0.2.10"
|
||||
@echo " make test"
|
||||
@echo " make build-all"
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
# Bose SoundTouch Toolkit
|
||||
# <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>
|
||||
|
||||
[](https://pkg.go.dev/github.com/gesellix/bose-soundtouch)
|
||||
[](https://goreportcard.com/report/github.com/gesellix/bose-soundtouch)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
> Independent project. Not affiliated with or endorsed by Bose Corporation.
|
||||
> Independent project. **Not affiliated with, endorsed by, sponsored
|
||||
> by, or otherwise connected to Bose Corporation.** See
|
||||
> [Disclaimer](#disclaimer) for the full statement.
|
||||
|
||||
## Context: Cloud Shutdown
|
||||
|
||||
@@ -20,6 +23,8 @@ See the [Survival Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SURVI
|
||||
|
||||
A local server that replaces the Bose cloud ("AfterTouch"). Once your speaker is redirected to it, you have full control without any Bose cloud dependency. The built-in web UI at `http://localhost:8000` handles all setup — no config files needed to get started.
|
||||
|
||||
If you don't want to run a server for this - no problem. The service is small enough to run on the SoundTouch itself. See the [On-Device Installer](./scripts/on-device-install/README.md) for instructions.
|
||||
|
||||
**Two scenarios:**
|
||||
|
||||
**Before shutdown — migrate your existing setup**
|
||||
@@ -120,8 +125,35 @@ See the [API Reference](https://gesellix.github.io/Bose-SoundTouch/reference/API
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and pull requests welcome — code, documentation, bug reports, and feature ideas all land in the same place. By submitting a contribution you agree to license it under MIT. For significant changes please open an issue first to discuss the approach. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide.
|
||||
|
||||
## Support the project
|
||||
|
||||
If this toolkit kept a speaker (or several) of yours alive past the Bose cloud shutdown and you want to give back, [GitHub Sponsors](https://github.com/sponsors/gesellix) is open. No expectation — everything in this repo stays MIT regardless.
|
||||
|
||||
[](https://github.com/sponsors/gesellix)
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This is an independent open-source project. **Bose** and **SoundTouch**
|
||||
are registered trademarks of Bose Corporation in the United States and
|
||||
other countries. This project is **not affiliated with, endorsed by,
|
||||
sponsored by, or otherwise connected to** Bose Corporation.
|
||||
|
||||
The toolkit exists solely to restore functionality of Bose SoundTouch
|
||||
speakers after the official cloud service shutdown on May 6, 2026.
|
||||
Reverse engineering for the sole purpose of interoperability is
|
||||
permitted under [EU Directive 2009/24/EC, Article 6](https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32009L0024)
|
||||
("Decompilation"), and comparable provisions in other jurisdictions.
|
||||
|
||||
The optional Stockholm frontend integration (`STOCKHOLM_DIR`) requires
|
||||
the user to supply the Stockholm web-app sources themselves; no Bose
|
||||
code is redistributed in this repository.
|
||||
|
||||
The software is provided AS IS, without warranty. Use at your own risk.
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
SoundTouch is a trademark of Bose Corporation.
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// Command dummy-speaker runs an HTTP-only fake SoundTouch speaker and
|
||||
// optionally registers it with a running soundtouch-service so the web UI
|
||||
// has a device to display.
|
||||
//
|
||||
// Intended for documentation screenshots and local UI smoke checks. Do not
|
||||
// use against a real network — the fixture payload is synthetic and would
|
||||
// confuse other tooling that expects live device data.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// dummy-speaker --port 8090 --register http://localhost:8000
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/testing/fakespeaker"
|
||||
)
|
||||
|
||||
func main() {
|
||||
listen := flag.String("listen", "127.0.0.1:8090", "bind address for the fake speaker's HTTP API")
|
||||
telnetListen := flag.String("telnet-listen", "127.0.0.1:17000", "bind address for the fake speaker's telnet diagnostic shell (empty to disable)")
|
||||
register := flag.String("register", "", "service base URL (e.g. http://localhost:8000) to self-register with via POST /setup/devices")
|
||||
registerAs := flag.String("register-as", "", "address to send to /setup/devices (defaults to --listen)")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
s, err := fakespeaker.Start(fakespeaker.Config{
|
||||
HTTPListen: *listen,
|
||||
TelnetListen: *telnetListen,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("start fake speaker: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("fake speaker HTTP listening on http://%s", s.HTTPAddr())
|
||||
|
||||
if addr := s.TelnetAddr(); addr != "" {
|
||||
log.Printf("fake speaker telnet listening on tcp://%s", addr)
|
||||
}
|
||||
|
||||
if *register != "" {
|
||||
target := *registerAs
|
||||
if target == "" {
|
||||
target = s.HTTPAddr()
|
||||
}
|
||||
|
||||
if err := registerWithService(*register, target); err != nil {
|
||||
log.Printf("self-register failed: %v (continuing anyway)", err)
|
||||
} else {
|
||||
log.Printf("registered %s with service at %s", target, *register)
|
||||
}
|
||||
}
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sig
|
||||
|
||||
log.Printf("shutting down")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := s.Stop(ctx); err != nil {
|
||||
log.Printf("stop: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func registerWithService(serviceURL, deviceAddr string) error {
|
||||
body, err := json.Marshal(map[string]string{"ip": deviceAddr})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, serviceURL+"/setup/devices", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("service responded %s", resp.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -107,10 +107,10 @@ Backs up each speaker over its HTTP API on port 8090. With `--ssh`, also capture
|
||||
soundtouch-backup local
|
||||
|
||||
# Specific speaker
|
||||
soundtouch-backup local --host 192.168.178.28
|
||||
soundtouch-backup local --host 192.0.2.11
|
||||
|
||||
# Multiple speakers
|
||||
soundtouch-backup local --host 192.168.178.28 --host 192.168.178.35
|
||||
soundtouch-backup local --host 192.0.2.11 --host 192.0.2.10
|
||||
|
||||
# Include SSH filesystem backup
|
||||
soundtouch-backup local --ssh
|
||||
|
||||
@@ -157,6 +157,33 @@ func setClockTimeNow(c *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// setClockDisplayTimezone POSTs only the timezoneInfo attribute,
|
||||
// leaving format/brightness untouched. Useful after a clock now to
|
||||
// make the speaker's logs and front-panel display tick in local time
|
||||
// instead of UTC.
|
||||
func setClockDisplayTimezone(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
tz := c.String("tz")
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Setting clock timezone to %s", tz), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
request := models.NewClockDisplayRequest().SetTimeZone(tz)
|
||||
if err := client.SetClockDisplay(request); err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to set timezone: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess(fmt.Sprintf("Timezone set to %s", tz))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getClockDisplay retrieves the current clock display settings
|
||||
func getClockDisplay(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
@@ -23,6 +23,12 @@ 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")
|
||||
@@ -60,6 +66,10 @@ 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...")
|
||||
|
||||
@@ -127,11 +137,78 @@ 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, "bass": true,
|
||||
"preset": true, "zone": true, "group": true, "bass": true,
|
||||
"sdkInfo": true, "userActivity": true,
|
||||
}
|
||||
|
||||
@@ -217,6 +294,13 @@ 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) {
|
||||
@@ -329,9 +413,11 @@ func handlePresetEvent(event *models.PresetUpdatedEvent, verbose bool) {
|
||||
for _, preset := range presets.Preset {
|
||||
fmt.Printf(" 📻 Preset %d:", preset.ID)
|
||||
|
||||
if preset.ContentItem != nil {
|
||||
fmt.Printf(" %s", preset.ContentItem.ItemName)
|
||||
fmt.Printf(" (%s)", preset.ContentItem.Source)
|
||||
// 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())
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
@@ -358,6 +444,34 @@ 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)
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
// happyAddGroupServer fakes a speaker's /addGroup that echoes the request
|
||||
// with an assigned ID and GROUP_OK status, matching real hardware behaviour.
|
||||
func happyAddGroupServer(t *testing.T, assignedID string) (*httptest.Server, *[]string) {
|
||||
t.Helper()
|
||||
|
||||
bodies := make([]string, 0)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/addGroup" || r.Method != http.MethodPost {
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
bodies = append(bodies, string(body))
|
||||
|
||||
var got models.Group
|
||||
if err := xml.Unmarshal(body, &got); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
|
||||
got.ID = assignedID
|
||||
got.Status = "GROUP_OK"
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
|
||||
enc, _ := xml.Marshal(&got)
|
||||
_, _ = w.Write(enc)
|
||||
}))
|
||||
|
||||
return srv, &bodies
|
||||
}
|
||||
|
||||
func newTestGroupClient(serverURL string) *client.Client {
|
||||
return client.NewClientFromHost(serverURL)
|
||||
}
|
||||
|
||||
func sampleGroupRequest(leftIP, rightIP string) *models.Group {
|
||||
return &models.Group{
|
||||
Name: "Living Room",
|
||||
MasterDeviceID: "9070658C9D4A",
|
||||
Roles: models.GroupRoles{
|
||||
Roles: []models.GroupRole{
|
||||
{DeviceID: "9070658C9D4A", Role: "LEFT", IPAddress: leftIP},
|
||||
{DeviceID: "F45EAB3115DA", Role: "RIGHT", IPAddress: rightIP},
|
||||
},
|
||||
},
|
||||
// senderIPAddress is intentionally not set here; propagateAddGroup
|
||||
// adds it to the slave's copy only.
|
||||
}
|
||||
}
|
||||
|
||||
func TestPropagateAddGroup_BothSucceed(t *testing.T) {
|
||||
leftSrv, leftBodies := happyAddGroupServer(t, "9999999")
|
||||
defer leftSrv.Close()
|
||||
|
||||
rightSrv, rightBodies := happyAddGroupServer(t, "9999999")
|
||||
defer rightSrv.Close()
|
||||
|
||||
leftClient := newTestGroupClient(leftSrv.URL)
|
||||
rightClient := newTestGroupClient(rightSrv.URL)
|
||||
|
||||
req := sampleGroupRequest("192.0.2.131", "192.0.2.134")
|
||||
|
||||
leftOut, rightOut := propagateAddGroup(leftClient, rightClient, "192.0.2.131", "192.0.2.134", req)
|
||||
|
||||
if leftOut.err != nil {
|
||||
t.Errorf("LEFT err = %v, want nil", leftOut.err)
|
||||
}
|
||||
|
||||
if rightOut.err != nil {
|
||||
t.Errorf("RIGHT err = %v, want nil", rightOut.err)
|
||||
}
|
||||
|
||||
if leftOut.group == nil || leftOut.group.ID != "9999999" || leftOut.group.Status != "GROUP_OK" {
|
||||
t.Errorf("LEFT group = %+v, want id=9999999 status=GROUP_OK", leftOut.group)
|
||||
}
|
||||
|
||||
if rightOut.group == nil || rightOut.group.Status != "GROUP_OK" {
|
||||
t.Errorf("RIGHT group = %+v, want status=GROUP_OK", rightOut.group)
|
||||
}
|
||||
|
||||
// Both speakers must have received the roles, but only the slave's payload
|
||||
// carries senderIPAddress — see propagateAddGroup for the why.
|
||||
for label, bodies := range map[string]*[]string{"LEFT": leftBodies, "RIGHT": rightBodies} {
|
||||
if len(*bodies) != 1 {
|
||||
t.Fatalf("%s: expected exactly one POST, got %d", label, len(*bodies))
|
||||
}
|
||||
|
||||
body := (*bodies)[0]
|
||||
for _, want := range []string{"<role>LEFT</role>", "<role>RIGHT</role>"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("%s body missing %q\nbody:\n%s", label, want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
leftBody := (*leftBodies)[0]
|
||||
if strings.Contains(leftBody, "<senderIPAddress>") {
|
||||
t.Errorf("LEFT (master) body must NOT carry <senderIPAddress>, otherwise the master flips into slave mode (issue #252)\nbody:\n%s", leftBody)
|
||||
}
|
||||
|
||||
rightBody := (*rightBodies)[0]
|
||||
if !strings.Contains(rightBody, "<senderIPAddress>192.0.2.131</senderIPAddress>") {
|
||||
t.Errorf("RIGHT (slave) body must carry <senderIPAddress>192.0.2.131</senderIPAddress>\nbody:\n%s", rightBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPropagateAddGroup_RightFails(t *testing.T) {
|
||||
leftSrv, _ := happyAddGroupServer(t, "9999999")
|
||||
defer leftSrv.Close()
|
||||
|
||||
rightSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
}))
|
||||
defer rightSrv.Close()
|
||||
|
||||
leftClient := newTestGroupClient(leftSrv.URL)
|
||||
rightClient := newTestGroupClient(rightSrv.URL)
|
||||
|
||||
req := sampleGroupRequest("192.0.2.131", "192.0.2.134")
|
||||
|
||||
leftOut, rightOut := propagateAddGroup(leftClient, rightClient, "192.0.2.131", "192.0.2.134", req)
|
||||
|
||||
if leftOut.err != nil {
|
||||
t.Errorf("LEFT err = %v, want nil", leftOut.err)
|
||||
}
|
||||
|
||||
if rightOut.err == nil {
|
||||
t.Error("RIGHT err = nil, want non-nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostAddGroup_StatusOtherThanGroupOKIsError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(`<group><status>GROUP_NOT_READY</status></group>`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
out := postAddGroup(newTestGroupClient(srv.URL), "test", sampleGroupRequest("1.1.1.1", "2.2.2.2"))
|
||||
|
||||
if out.err == nil {
|
||||
t.Fatal("expected error for non-GROUP_OK status")
|
||||
}
|
||||
|
||||
if !strings.Contains(out.err.Error(), "GROUP_NOT_READY") {
|
||||
t.Errorf("error %q does not mention returned status", out.err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostAddGroup_EmptyStatusIsAccepted(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(`<group id="42"><name>n</name></group>`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
out := postAddGroup(newTestGroupClient(srv.URL), "test", sampleGroupRequest("1.1.1.1", "2.2.2.2"))
|
||||
|
||||
if out.err != nil {
|
||||
t.Errorf("err = %v, want nil for empty status (some firmware omits it)", out.err)
|
||||
}
|
||||
|
||||
if out.group == nil || out.group.ID != "42" {
|
||||
t.Errorf("group = %+v, want id=42", out.group)
|
||||
}
|
||||
}
|
||||
@@ -177,23 +177,36 @@ func getPresets(c *cli.Context) error {
|
||||
|
||||
fmt.Printf("Device Presets:\n")
|
||||
|
||||
if len(presets.Preset) == 0 {
|
||||
// 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 {
|
||||
fmt.Printf(" No presets configured\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf(" Configured Presets:\n")
|
||||
|
||||
for _, preset := range presets.Preset {
|
||||
for _, preset := range configured {
|
||||
fmt.Printf(" %d. %s\n", preset.ID, preset.GetDisplayName())
|
||||
fmt.Printf(" Source: %s\n", preset.ContentItem.Source)
|
||||
fmt.Printf(" Source: %s\n", preset.GetSource())
|
||||
|
||||
if preset.ContentItem.SourceAccount != "" && preset.ContentItem.SourceAccount != preset.ContentItem.Source {
|
||||
fmt.Printf(" Account: %s\n", preset.ContentItem.SourceAccount)
|
||||
if account := preset.GetSourceAccount(); account != "" && account != preset.GetSource() {
|
||||
fmt.Printf(" Account: %s\n", account)
|
||||
}
|
||||
|
||||
if preset.ContentItem.Location != "" {
|
||||
fmt.Printf(" Location: %s\n", preset.ContentItem.Location)
|
||||
if location := preset.GetLocation(); location != "" {
|
||||
fmt.Printf(" Location: %s\n", location)
|
||||
}
|
||||
|
||||
// Show preset creation time if available
|
||||
|
||||
@@ -17,7 +17,7 @@ func TestIntrospectCommands(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "introspect service with source flag",
|
||||
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "source", "introspect", "--source", "SPOTIFY"},
|
||||
args: []string{"soundtouch-cli", "--host", "192.0.2.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.168.1.100", "source", "introspect-spotify"},
|
||||
args: []string{"soundtouch-cli", "--host", "192.0.2.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.168.1.100", "source", "introspect", "--source", "SPOTIFY", "--account", "my_spotify_account"},
|
||||
args: []string{"soundtouch-cli", "--host", "192.0.2.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.168.1.100", "source", "introspect"},
|
||||
args: []string{"soundtouch-cli", "--host", "192.0.2.100", "source", "introspect"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -17,7 +17,7 @@ func TestRecentsCommands(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "recents list command",
|
||||
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "recents", "list"},
|
||||
args: []string{"soundtouch-cli", "--host", "192.0.2.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.168.1.100", "recents", "filter", "--source", "SPOTIFY"},
|
||||
args: []string{"soundtouch-cli", "--host", "192.0.2.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.168.1.100", "recents", "latest"},
|
||||
args: []string{"soundtouch-cli", "--host", "192.0.2.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.168.1.100", "recents", "stats"},
|
||||
args: []string{"soundtouch-cli", "--host", "192.0.2.100", "recents", "stats"},
|
||||
expectedOutput: []string{
|
||||
"Getting recent items statistics",
|
||||
"Recent Items Statistics",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,310 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+117
-1
@@ -1054,6 +1054,43 @@ 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",
|
||||
@@ -1312,6 +1349,19 @@ 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1478,6 +1528,64 @@ 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",
|
||||
@@ -2093,7 +2201,7 @@ func main() {
|
||||
&cli.StringFlag{
|
||||
Name: "filter",
|
||||
Aliases: []string{"f"},
|
||||
Usage: "Filter events by type (comma-separated): nowPlaying,volume,connection,preset,zone,bass,sdkInfo,userActivity",
|
||||
Usage: "Filter events by type (comma-separated): nowPlaying,volume,connection,preset,zone,group,bass,sdkInfo,userActivity",
|
||||
},
|
||||
&cli.DurationFlag{
|
||||
Name: "duration",
|
||||
@@ -2105,6 +2213,10 @@ 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"},
|
||||
@@ -2117,6 +2229,10 @@ func main() {
|
||||
},
|
||||
}
|
||||
|
||||
// Speaker provisioning (factory-reset, Wi-Fi, URL rewrite, pairing).
|
||||
// Defined in cmd_setup.go to keep the top-level command list readable.
|
||||
app.Commands = append(app.Commands, setupCommand())
|
||||
|
||||
// Sort commands alphabetically (including subcommands and flags recursively)
|
||||
sortCommands(app.Commands)
|
||||
|
||||
|
||||
@@ -14,16 +14,16 @@ func TestParseHostPort(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "IPv4 with port",
|
||||
input: "192.168.1.10:8090",
|
||||
input: "192.0.2.10:8090",
|
||||
defaultPort: 8080,
|
||||
wantHost: "192.168.1.10",
|
||||
wantHost: "192.0.2.10",
|
||||
wantPort: 8090,
|
||||
},
|
||||
{
|
||||
name: "IPv4 without port",
|
||||
input: "192.168.1.10",
|
||||
input: "192.0.2.10",
|
||||
defaultPort: 8080,
|
||||
wantHost: "192.168.1.10",
|
||||
wantHost: "192.0.2.10",
|
||||
wantPort: 8080,
|
||||
},
|
||||
{
|
||||
@@ -63,30 +63,30 @@ func TestParseHostPort(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "invalid port - non-numeric",
|
||||
input: "192.168.1.10:abc",
|
||||
input: "192.0.2.10:abc",
|
||||
defaultPort: 8080,
|
||||
wantHost: "192.168.1.10",
|
||||
wantHost: "192.0.2.10",
|
||||
wantPort: 8080,
|
||||
},
|
||||
{
|
||||
name: "invalid port - too high",
|
||||
input: "192.168.1.10:99999",
|
||||
input: "192.0.2.10:99999",
|
||||
defaultPort: 8080,
|
||||
wantHost: "192.168.1.10",
|
||||
wantHost: "192.0.2.10",
|
||||
wantPort: 8080,
|
||||
},
|
||||
{
|
||||
name: "invalid port - zero",
|
||||
input: "192.168.1.10:0",
|
||||
input: "192.0.2.10:0",
|
||||
defaultPort: 8080,
|
||||
wantHost: "192.168.1.10",
|
||||
wantHost: "192.0.2.10",
|
||||
wantPort: 8080,
|
||||
},
|
||||
{
|
||||
name: "invalid port - negative",
|
||||
input: "192.168.1.10:-123",
|
||||
input: "192.0.2.10:-123",
|
||||
defaultPort: 8080,
|
||||
wantHost: "192.168.1.10",
|
||||
wantHost: "192.0.2.10",
|
||||
wantPort: 8080,
|
||||
},
|
||||
{
|
||||
@@ -105,37 +105,37 @@ func TestParseHostPort(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "multiple colons - malformed",
|
||||
input: "192.168.1.100:8090:extra",
|
||||
input: "192.0.2.100:8090:extra",
|
||||
defaultPort: 8080,
|
||||
wantHost: "192.168.1.100:8090:extra",
|
||||
wantHost: "192.0.2.100:8090:extra",
|
||||
wantPort: 8080,
|
||||
},
|
||||
{
|
||||
name: "standard SoundTouch default",
|
||||
input: "192.168.1.10",
|
||||
input: "192.0.2.10",
|
||||
defaultPort: 8090,
|
||||
wantHost: "192.168.1.10",
|
||||
wantHost: "192.0.2.10",
|
||||
wantPort: 8090,
|
||||
},
|
||||
{
|
||||
name: "valid high port",
|
||||
input: "192.168.1.100:65535",
|
||||
input: "192.0.2.100:65535",
|
||||
defaultPort: 8080,
|
||||
wantHost: "192.168.1.100",
|
||||
wantHost: "192.0.2.100",
|
||||
wantPort: 65535,
|
||||
},
|
||||
{
|
||||
name: "valid low port",
|
||||
input: "192.168.1.100:1",
|
||||
input: "192.0.2.100:1",
|
||||
defaultPort: 8080,
|
||||
wantHost: "192.168.1.100",
|
||||
wantHost: "192.0.2.100",
|
||||
wantPort: 1,
|
||||
},
|
||||
{
|
||||
name: "real SoundTouch device example",
|
||||
input: "192.168.1.10:8090",
|
||||
input: "192.0.2.10:8090",
|
||||
defaultPort: 8080,
|
||||
wantHost: "192.168.1.10",
|
||||
wantHost: "192.0.2.10",
|
||||
wantPort: 8090,
|
||||
},
|
||||
{
|
||||
@@ -166,8 +166,8 @@ func BenchmarkParseHostPort(b *testing.B) {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"with_port", "192.168.1.100:8090"},
|
||||
{"without_port", "192.168.1.100"},
|
||||
{"with_port", "192.0.2.100:8090"},
|
||||
{"without_port", "192.0.2.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.168.1.10:8091",
|
||||
input: "192.0.2.10:8091",
|
||||
defaultPort: 8090,
|
||||
description: "User specifies full host:port",
|
||||
wantHost: "192.168.1.10",
|
||||
wantHost: "192.0.2.10",
|
||||
wantPort: 8091,
|
||||
},
|
||||
{
|
||||
name: "discovery_result_host_only",
|
||||
input: "192.168.1.10",
|
||||
input: "192.0.2.10",
|
||||
defaultPort: 8090,
|
||||
description: "Discovery returns IP, CLI uses default port",
|
||||
wantHost: "192.168.1.10",
|
||||
wantHost: "192.0.2.10",
|
||||
wantPort: 8090,
|
||||
},
|
||||
{
|
||||
name: "custom_port_override",
|
||||
input: "192.168.1.100:9000",
|
||||
input: "192.0.2.100:9000",
|
||||
defaultPort: 8090,
|
||||
description: "User overrides default SoundTouch port",
|
||||
wantHost: "192.168.1.100",
|
||||
wantHost: "192.0.2.100",
|
||||
wantPort: 9000,
|
||||
},
|
||||
{
|
||||
@@ -225,10 +225,10 @@ func TestParseHostPortSoundTouchScenarios(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "invalid_port_fallback",
|
||||
input: "192.168.1.10:invalid",
|
||||
input: "192.0.2.10:invalid",
|
||||
defaultPort: 8090,
|
||||
description: "Malformed port should fallback to default",
|
||||
wantHost: "192.168.1.10",
|
||||
wantHost: "192.0.2.10",
|
||||
wantPort: 8090,
|
||||
},
|
||||
}
|
||||
|
||||
+239
-86
@@ -7,6 +7,7 @@ import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -23,9 +25,11 @@ import (
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/logbuf"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/stockholm"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/urfave/cli/v2"
|
||||
@@ -172,9 +176,44 @@ func initMusicServices(config serviceConfig, server *handlers.Server) {
|
||||
}
|
||||
}
|
||||
|
||||
// logBufferCapacityFromEnv reads SOUNDTOUCH_LOG_BUFFER_LINES and
|
||||
// returns a positive capacity. Invalid or unset values fall back
|
||||
// to the default; a value of 0 or negative is treated as "disable"
|
||||
// and returns 0 so the caller can skip wiring the buffer.
|
||||
func logBufferCapacityFromEnv(defaultCap int) int {
|
||||
raw := os.Getenv("SOUNDTOUCH_LOG_BUFFER_LINES")
|
||||
if raw == "" {
|
||||
return defaultCap
|
||||
}
|
||||
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
log.Printf("[Logs] Invalid SOUNDTOUCH_LOG_BUFFER_LINES=%q, using default %d", raw, defaultCap)
|
||||
return defaultCap
|
||||
}
|
||||
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
func main() {
|
||||
updateBuildInfo()
|
||||
|
||||
// Mirror log output to an in-memory ring buffer so the admin
|
||||
// UI can show a live trace. Stderr keeps receiving every line
|
||||
// verbatim — the buffer is a second sink, not a replacement.
|
||||
// Installing this before the cli.Action runs means every
|
||||
// log.Printf from initialisation onwards is captured.
|
||||
var logBuf *logbuf.Buffer
|
||||
|
||||
if bufCap := logBufferCapacityFromEnv(2000); bufCap > 0 {
|
||||
logBuf = logbuf.New(bufCap)
|
||||
log.SetOutput(io.MultiWriter(os.Stderr, logBuf))
|
||||
}
|
||||
|
||||
app := &cli.App{
|
||||
Name: "soundtouch-service",
|
||||
Usage: "Local service for Bose SoundTouch cloud emulation and management",
|
||||
@@ -336,26 +375,16 @@ func main() {
|
||||
Usage: "External base URL for OAuth callbacks behind reverse proxy",
|
||||
EnvVars: []string{"BASE_URL"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "mirror-enabled",
|
||||
Usage: "Enable background mirroring to Bose Cloud",
|
||||
EnvVars: []string{"MIRROR_ENABLED"},
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "mirror-endpoints",
|
||||
Usage: "Endpoints to mirror to Bose Cloud (comma-separated or multiple flags)",
|
||||
EnvVars: []string{"MIRROR_ENDPOINTS"},
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "skip-mirror-endpoints",
|
||||
Usage: "Endpoints to skip mirroring to Bose Cloud (comma-separated or multiple flags)",
|
||||
EnvVars: []string{"SKIP_MIRROR_ENDPOINTS"},
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "internal-paths",
|
||||
Usage: "Paths for internal requests (comma-separated or multiple flags)",
|
||||
EnvVars: []string{"INTERNAL_PATHS"},
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "tls-extra-host",
|
||||
Usage: "Additional DNS name or IP to include in the server TLS certificate SAN list (repeatable)",
|
||||
EnvVars: []string{"TLS_EXTRA_HOST"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "migration-enabled",
|
||||
Usage: "Enable device directory migration from serial to MAC-based structure",
|
||||
@@ -368,10 +397,15 @@ func main() {
|
||||
EnvVars: []string{"MIGRATION_DRY_RUN"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "preferred-source",
|
||||
Usage: "Preferred source of truth (local or upstream)",
|
||||
Value: "local",
|
||||
EnvVars: []string{"PREFERRED_SOURCE"},
|
||||
Name: "stockholm-dir",
|
||||
Usage: "Path to the extracted Stockholm frontend directory (enables Stockholm UI when set)",
|
||||
EnvVars: []string{"STOCKHOLM_DIR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "stockholm-base-path",
|
||||
Usage: "URL prefix under which the Stockholm UI is served (e.g. /stockholm). Empty serves at root.",
|
||||
Value: "/stockholm",
|
||||
EnvVars: []string{"STOCKHOLM_BASE_PATH"},
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
@@ -391,7 +425,7 @@ func main() {
|
||||
hostname = "localhost"
|
||||
}
|
||||
|
||||
config.domains = getDomains(config.serverURL, config.httpsServerURL, hostname)
|
||||
config.domains = getDomains(config.serverURL, config.httpsServerURL, hostname, config.tlsExtraHosts)
|
||||
|
||||
cm := initCertificateManager(config.dataDir, config.hostname)
|
||||
sm := setup.NewManager(config.serverURL, ds, cm)
|
||||
@@ -399,11 +433,12 @@ func main() {
|
||||
sm.MgmtPassword = config.mgmtPassword
|
||||
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record)
|
||||
sm.GetDNSRunning = server.GetDNSRunning
|
||||
server.SetLogBuffer(logBuf)
|
||||
server.SetHTTPServerURL(config.httpsServerURL)
|
||||
server.SetExpectedHosts(config.domains)
|
||||
server.SetVersionInfo(version, commit, date, repoURL)
|
||||
server.SetDiscoverySettings(config.discoveryInterval, config.discoveryEnabled)
|
||||
server.SetDNSSettings(persisted.DNSEnabled, strings.Join(persisted.DNSUpstream, ","), persisted.DNSBindAddr)
|
||||
server.SetMirrorSettings(persisted.MirrorEnabled, persisted.MirrorEndpoints, persisted.SkipMirrorEndpoints, persisted.PreferredSource)
|
||||
server.SetInternalPaths(persisted.InternalPaths)
|
||||
server.SetSpotifyConfig(config.spotifyClientID, config.spotifyClientSecret, config.spotifyRedirectURI)
|
||||
server.SetAmazonConfig(config.amazonClientID, config.amazonClientSecret, config.amazonRedirectURI)
|
||||
@@ -468,7 +503,20 @@ func main() {
|
||||
|
||||
startDeviceDiscovery(server)
|
||||
|
||||
r := setupRouter(server)
|
||||
var stockholmHandler *stockholm.Handler
|
||||
|
||||
if config.stockholmDir != "" {
|
||||
sh, shErr := stockholm.New(config.stockholmDir, config.dataDir, config.serverURL, config.stockholmBasePath)
|
||||
if shErr != nil {
|
||||
log.Printf("Warning: Failed to initialise Stockholm handler: %v", shErr)
|
||||
} else {
|
||||
stockholmHandler = sh
|
||||
|
||||
log.Printf("Stockholm frontend enabled from %s", config.stockholmDir)
|
||||
}
|
||||
}
|
||||
|
||||
r := setupRouter(server, stockholmHandler)
|
||||
|
||||
log.Printf("Go service starting on %s", config.serverURL)
|
||||
|
||||
@@ -484,6 +532,8 @@ func main() {
|
||||
}
|
||||
|
||||
startHTTPSServer(config.httpsAddr, r, tlsConfig, config.httpsServerURL)
|
||||
|
||||
runHTTPSPreflight(config.httpsServerURL, config.serverURL, config.dnsEnabled, server.ResolveServerURLIPForPreflight)
|
||||
}()
|
||||
|
||||
return http.ListenAndServe(config.addr, r)
|
||||
@@ -528,10 +578,8 @@ type serviceConfig struct {
|
||||
dnsEnabled bool
|
||||
dnsUpstream string
|
||||
dnsBind string
|
||||
mirrorEnabled bool
|
||||
mirrorEndpoints []string
|
||||
skipMirrorEndpoints []string
|
||||
internalPaths []string
|
||||
tlsExtraHosts []string
|
||||
discoveryEnabled bool
|
||||
discoveryInterval time.Duration
|
||||
domains []string
|
||||
@@ -549,7 +597,8 @@ type serviceConfig struct {
|
||||
mgmtPassword string
|
||||
migrationEnabled bool
|
||||
migrationDryRun bool
|
||||
preferredSource string
|
||||
stockholmDir string
|
||||
stockholmBasePath string
|
||||
}
|
||||
|
||||
func loadConfig(c *cli.Context) serviceConfig {
|
||||
@@ -587,7 +636,8 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
httpsServerURL = "https://" + hostname + ":" + httpsPort
|
||||
}
|
||||
|
||||
domains := getDomains(serverURL, httpsServerURL, hostname)
|
||||
tlsExtraHosts := c.StringSlice("tls-extra-host")
|
||||
domains := getDomains(serverURL, httpsServerURL, hostname, tlsExtraHosts)
|
||||
|
||||
redact := c.Bool("redact-logs")
|
||||
logBody := c.Bool("log-bodies")
|
||||
@@ -619,13 +669,11 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
amazonProfileURL := c.String("amazon-profile-url")
|
||||
mgmtUsername := c.String("mgmt-username")
|
||||
mgmtPassword := c.String("mgmt-password")
|
||||
mirrorEnabled := c.Bool("mirror-enabled")
|
||||
mirrorEndpoints := c.StringSlice("mirror-endpoints")
|
||||
skipMirrorEndpoints := c.StringSlice("skip-mirror-endpoints")
|
||||
internalPaths := c.StringSlice("internal-paths")
|
||||
migrationEnabled := c.Bool("migration-enabled")
|
||||
migrationDryRun := c.Bool("migration-dry-run")
|
||||
preferredSource := c.String("preferred-source")
|
||||
stockholmDir := c.String("stockholm-dir")
|
||||
stockholmBasePath := c.String("stockholm-base-path")
|
||||
|
||||
return serviceConfig{
|
||||
port: port,
|
||||
@@ -642,10 +690,8 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
dnsEnabled: dnsEnabled,
|
||||
dnsUpstream: dnsUpstream,
|
||||
dnsBind: dnsBind,
|
||||
mirrorEnabled: mirrorEnabled,
|
||||
mirrorEndpoints: mirrorEndpoints,
|
||||
skipMirrorEndpoints: skipMirrorEndpoints,
|
||||
internalPaths: internalPaths,
|
||||
tlsExtraHosts: tlsExtraHosts,
|
||||
discoveryEnabled: discoveryEnabled,
|
||||
discoveryInterval: discoveryInterval,
|
||||
domains: domains,
|
||||
@@ -663,11 +709,12 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
mgmtPassword: mgmtPassword,
|
||||
migrationEnabled: migrationEnabled,
|
||||
migrationDryRun: migrationDryRun,
|
||||
preferredSource: preferredSource,
|
||||
stockholmDir: stockholmDir,
|
||||
stockholmBasePath: stockholmBasePath,
|
||||
}
|
||||
}
|
||||
|
||||
func getDomains(serverURL, httpsServerURL, hostname string) []string {
|
||||
func getDomains(serverURL, httpsServerURL, hostname string, extraHosts []string) []string {
|
||||
domainsMap := map[string]bool{
|
||||
// RFC-compliant wildcards for API patterns
|
||||
"*.api.bose.io": true,
|
||||
@@ -700,6 +747,15 @@ func getDomains(serverURL, httpsServerURL, hostname string) []string {
|
||||
domainsMap[strings.ToLower(u.Hostname())] = true
|
||||
}
|
||||
|
||||
// Explicit overrides / additions for multi-homed hosts, reverse proxies,
|
||||
// or browsing the admin UI via a LAN IP that isn't part of serverURL.
|
||||
for _, h := range extraHosts {
|
||||
h = strings.ToLower(strings.TrimSpace(h))
|
||||
if h != "" {
|
||||
domainsMap[h] = true
|
||||
}
|
||||
}
|
||||
|
||||
domains := make([]string, 0, len(domainsMap))
|
||||
for d := range domainsMap {
|
||||
domains = append(domains, d)
|
||||
@@ -749,10 +805,6 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
|
||||
config.dnsBind = persisted.DNSBindAddr
|
||||
}
|
||||
|
||||
config.mirrorEnabled = persisted.MirrorEnabled
|
||||
config.mirrorEndpoints = persisted.MirrorEndpoints
|
||||
config.skipMirrorEndpoints = persisted.SkipMirrorEndpoints
|
||||
config.preferredSource = persisted.PreferredSource
|
||||
config.internalPaths = persisted.InternalPaths
|
||||
|
||||
// CLI/env args take precedence; only apply persisted credentials when not set via CLI.
|
||||
@@ -791,21 +843,17 @@ func applyPersistedMusicServiceCredentials(config *serviceConfig, persisted data
|
||||
|
||||
func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datastore.Settings {
|
||||
settings := datastore.Settings{
|
||||
ServerURL: config.serverURL,
|
||||
HTTPServerURL: config.httpsServerURL,
|
||||
RedactLogs: config.redact,
|
||||
LogBodies: config.logBody,
|
||||
RecordInteractions: config.record,
|
||||
DiscoveryEnabled: config.discoveryEnabled,
|
||||
DiscoveryInterval: config.discoveryInterval.String(),
|
||||
DNSEnabled: config.dnsEnabled,
|
||||
DNSUpstream: strings.Split(config.dnsUpstream, ","),
|
||||
DNSBindAddr: config.dnsBind,
|
||||
MirrorEnabled: config.mirrorEnabled,
|
||||
MirrorEndpoints: config.mirrorEndpoints,
|
||||
SkipMirrorEndpoints: config.skipMirrorEndpoints,
|
||||
PreferredSource: config.preferredSource,
|
||||
InternalPaths: config.internalPaths,
|
||||
ServerURL: config.serverURL,
|
||||
HTTPServerURL: config.httpsServerURL,
|
||||
RedactLogs: config.redact,
|
||||
LogBodies: config.logBody,
|
||||
RecordInteractions: config.record,
|
||||
DiscoveryEnabled: config.discoveryEnabled,
|
||||
DiscoveryInterval: config.discoveryInterval.String(),
|
||||
DNSEnabled: config.dnsEnabled,
|
||||
DNSUpstream: strings.Split(config.dnsUpstream, ","),
|
||||
DNSBindAddr: config.dnsBind,
|
||||
InternalPaths: config.internalPaths,
|
||||
Shortcuts: map[string]int{
|
||||
"/.well-known/appspecific/com.chrome.devtools.json": http.StatusNotFound,
|
||||
"/sw.js": http.StatusNotFound,
|
||||
@@ -850,22 +898,42 @@ func startDeviceDiscovery(server *handlers.Server) {
|
||||
}()
|
||||
}
|
||||
|
||||
func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// TrustedRealIP must run before any handler that reads r.RemoteAddr —
|
||||
// SnapshotMiddleware captures the request, and several handlers
|
||||
// (HandleMargePowerOn, etc.) inspect the source IP. The middleware is
|
||||
// gated on Settings.TrustForwardedHeaders; when off (the safe default),
|
||||
// it returns nil and we skip Use'ing it entirely.
|
||||
if mw := server.TrustedRealIPMiddleware(); mw != nil {
|
||||
r.Use(mw)
|
||||
}
|
||||
|
||||
r.Use(server.SnapshotMiddleware)
|
||||
r.Use(server.OriginMiddleware)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(server.PeerObserverMiddleware)
|
||||
r.Use(server.ShortcutMiddleware)
|
||||
r.Use(server.MirrorMiddleware)
|
||||
r.Use(server.RecordMiddleware)
|
||||
|
||||
r.Get("/", server.HandleRoot)
|
||||
r.Get("/health", server.HandleHealth)
|
||||
// Passive peer-reachability probe. Registers a device IP with the
|
||||
// in-process observer, nudges :8090/swUpdateCheck, and waits for
|
||||
// any inbound from that IP. Used post-migration where the daemon
|
||||
// caches its swUpdateUrl at boot and the active round-trip can't
|
||||
// reach it without a reboot.
|
||||
r.Post("/setup/peer-probe/{deviceId}", server.HandlePeerProbe)
|
||||
r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
||||
r.URL.Path = "/media/favicon-braille.svg"
|
||||
server.HandleMedia()(w, r)
|
||||
// The favicon lives in the embedded web/img bundle, not under
|
||||
// static/media — HandleMedia would 404. HandleWeb serves from
|
||||
// webFS at its native path.
|
||||
r.URL.Path = "/web/img/favicon-braille.svg"
|
||||
server.HandleWeb()(w, r)
|
||||
})
|
||||
|
||||
r.Get("/media/aftertouch-ding.wav", server.HandleDing)
|
||||
r.Get("/media/*", server.HandleMedia())
|
||||
r.Get("/bmx-icons/*", server.HandleBmxIcons())
|
||||
r.Get("/ced/*", server.HandleCedStatic())
|
||||
@@ -889,11 +957,27 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Post("/v1/favorite/{stationID}", server.HandleTuneInFavorite)
|
||||
r.Delete("/v1/favorite/{stationID}", server.HandleTuneInDeleteFavorite)
|
||||
})
|
||||
|
||||
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
|
||||
r.Post("/core02/svc-bmx-adapter-orion/prod/orion/token", server.HandleOrionToken)
|
||||
})
|
||||
|
||||
// Orion (LOCAL_INTERNET_RADIO) lives at the top level — the BMX registry
|
||||
// advertises baseUrl `{BMX_SERVER}/core02/svc-bmx-adapter-orion/prod/orion`
|
||||
// (no `/bmx/` prefix; verified against the upstream capture in
|
||||
// pkg/service/handlers/static/bmx_services_ustream.json), so speakers
|
||||
// reach the token + station endpoints at exactly these paths under
|
||||
// either DNS-interception or URL-flip migration.
|
||||
r.Post("/core02/svc-bmx-adapter-orion/prod/orion/token", server.HandleOrionToken)
|
||||
r.Get("/core02/svc-bmx-adapter-orion/prod/orion/station", server.HandleOrionPlayback)
|
||||
|
||||
// SiriusXM lives at the top level by the same convention. bmx_services.json
|
||||
// advertises baseUrl `{BMX_SERVER}/core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter`
|
||||
// (no /bmx/ prefix), so speakers reach this exact path under either
|
||||
// migration mode. The bare path returns the service descriptor (matches
|
||||
// soundcork main.py:805); sub-paths advertised by the descriptor's _links
|
||||
// (/availability, /navigate, /token, /logout) currently log + 404 so
|
||||
// future implementation work has visibility into real speaker calls.
|
||||
r.HandleFunc("/core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter", server.HandleSiriusXMLiveAdapter)
|
||||
r.HandleFunc("/core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/*", server.HandleSiriusXMLiveAdapterSubpath)
|
||||
|
||||
r.Get("/custom/v1/playback/{encodedURL}", server.HandleCustomPlayback)
|
||||
|
||||
r.Route("/streaming", func(r chi.Router) {
|
||||
@@ -911,31 +995,47 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Get("/presets/all", server.HandleMargeAccountPresets)
|
||||
r.Get("/provider_settings", server.HandleMargeProviderSettings)
|
||||
|
||||
// All `/device` routes share one chi subrouter. Two
|
||||
// overlapping subrouters (`/device` + `/device/{device}`)
|
||||
// caused chi's radix-tree resolver to bind a runtime
|
||||
// request to the more-specific prefix even when only the
|
||||
// less-specific subrouter had a matching method handler,
|
||||
// producing the [UNHANDLED] → upstream-proxy fall-through
|
||||
// behind issue #285's first-attempted fix. One subrouter
|
||||
// keeps every device-scoped path resolvable; see
|
||||
// TestPUTRenameRoutesToLocalHandler for the regression
|
||||
// against the production router.
|
||||
r.Route("/device", func(r chi.Router) {
|
||||
r.Post("/", server.HandleMargeAddDevice)
|
||||
r.Post("/{device}", server.HandleMargeAddDevice)
|
||||
// PUT is the rename / update path — speakers fire
|
||||
// this against PUT /streaming/account/{a}/device/{d}
|
||||
// when the user renames via Bose App or
|
||||
// `soundtouch-cli name set`. Issue #285.
|
||||
r.Put("/{device}", server.HandleMargeUpdateDevice)
|
||||
r.Delete("/{device}", server.HandleMargeRemoveDevice)
|
||||
|
||||
r.Get("/{device}/presets", server.HandleMargePresets)
|
||||
r.Post("/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Put("/{device}/preset/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Delete("/{device}/preset/{presetNumber}", server.HandleMargeRemovePreset)
|
||||
r.Get("/{device}/recent", server.HandleMargeRecents)
|
||||
r.Get("/{device}/recents", server.HandleMargeRecents)
|
||||
r.Post("/{device}/recent", server.HandleMargeAddRecent)
|
||||
|
||||
r.Get("/{device}/group", server.HandleMargeDeviceGroup)
|
||||
r.Get("/{device}/group/", server.HandleMargeDeviceGroup)
|
||||
r.Get("/{device}/group/server", server.HandleMargeDeviceGroupServer)
|
||||
r.Get("/{device}/group/member", server.HandleMargeDeviceGroupMember)
|
||||
})
|
||||
|
||||
r.Route("/device/{device}", func(r chi.Router) {
|
||||
r.Get("/presets", server.HandleMargePresets)
|
||||
r.Post("/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Put("/preset/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Delete("/preset/{presetNumber}", server.HandleMargeRemovePreset)
|
||||
r.Get("/recent", server.HandleMargeRecents)
|
||||
r.Get("/recents", server.HandleMargeRecents)
|
||||
r.Post("/recent", server.HandleMargeAddRecent)
|
||||
|
||||
r.Get("/group", server.HandleMargeDeviceGroup)
|
||||
r.Get("/group/", server.HandleMargeDeviceGroup)
|
||||
r.Get("/group/server", server.HandleMargeDeviceGroupServer)
|
||||
r.Get("/group/member", server.HandleMargeDeviceGroupMember)
|
||||
})
|
||||
|
||||
// Speakers POST to /group/ (with trailing slash) when forwarding
|
||||
// the addGroup payload to Marge during stereo-pair formation --
|
||||
// see issue #252. Register both forms so chi accepts either.
|
||||
r.Post("/group", server.HandleMargeAddGroup)
|
||||
r.Post("/group/", server.HandleMargeAddGroup)
|
||||
r.Post("/group/{groupId}", server.HandleMargeModifyGroup)
|
||||
r.Delete("/group/{groupId}", server.HandleMargeDeleteGroup)
|
||||
|
||||
r.Delete("/device/{device}", server.HandleMargeRemoveDevice)
|
||||
})
|
||||
|
||||
r.Get("/device/{device}/streaming_token", server.HandleMargeStreamingToken)
|
||||
@@ -980,6 +1080,7 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Get("/devices/{device}/group/member", server.HandleMargeDeviceGroupMember)
|
||||
|
||||
r.Post("/group", server.HandleMargeAddGroup)
|
||||
r.Post("/group/", server.HandleMargeAddGroup)
|
||||
r.Post("/group/{groupId}", server.HandleMargeModifyGroup)
|
||||
r.Delete("/group/{groupId}", server.HandleMargeDeleteGroup)
|
||||
r.Get("/devices/{device}/presets", server.HandleMargePresets)
|
||||
@@ -1003,7 +1104,6 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Post("/account/{account}/music/musicprovider/{sourceID}/token/cs", server.HandleBoseAccountToken)
|
||||
r.Post("/device/{deviceID}/music/musicprovider/{sourceID}/token/cs1", server.HandleBoseToken)
|
||||
r.Post("/device/{deviceID}/music/musicprovider/{sourceID}/token/cs3", server.HandleBoseToken)
|
||||
r.HandleFunc("/*", server.HandleBoseProxy)
|
||||
})
|
||||
|
||||
r.Route("/v1", func(r chi.Router) {
|
||||
@@ -1055,8 +1155,6 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
})
|
||||
})
|
||||
|
||||
r.Get("/proxy/*", server.HandleProxyRequest)
|
||||
|
||||
r.Route("/setup", func(r chi.Router) {
|
||||
r.Get("/devices", server.HandleListDiscoveredDevices)
|
||||
r.Post("/devices", server.HandleAddManualDevice)
|
||||
@@ -1070,6 +1168,8 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Post("/migrate/{deviceId}", server.HandleMigrateDevice)
|
||||
r.Post("/revert/{deviceId}", server.HandleRevertMigration)
|
||||
r.Post("/reboot/{deviceId}", server.HandleRebootDevice)
|
||||
r.Get("/account-id-suggestions/{deviceId}", server.HandleAccountIDSuggestions)
|
||||
r.Post("/pair-account/{deviceId}", server.HandlePairAccount)
|
||||
r.Post("/trust-ca/{deviceId}", server.HandleTrustCACert)
|
||||
r.Post("/ensure-remote-services/{deviceId}", server.HandleEnsureRemoteServices)
|
||||
r.Post("/remove-remote-services/{deviceId}", server.HandleRemoveRemoteServices)
|
||||
@@ -1079,14 +1179,12 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Post("/test-hosts/{deviceId}", server.HandleTestHostsRedirection)
|
||||
r.Post("/test-dns/{deviceId}", server.HandleTestDNSRedirection)
|
||||
r.Get("/ca.crt", server.HandleGetCACert)
|
||||
r.Get("/proxy-settings", server.HandleGetProxySettings)
|
||||
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
|
||||
r.Get("/logging-settings", server.HandleGetLoggingSettings)
|
||||
r.Post("/logging-settings", server.HandleUpdateLoggingSettings)
|
||||
r.Get("/version", server.HandleGetVersionInfo)
|
||||
r.Get("/interaction-stats", server.HandleGetInteractionStats)
|
||||
r.Get("/interactions", server.HandleListInteractions)
|
||||
r.Get("/interaction-content", server.HandleGetInteractionContent)
|
||||
r.Get("/parity-mismatches", server.HandleListParityMismatches)
|
||||
r.Delete("/parity-mismatches", server.HandleClearParityMismatches)
|
||||
r.Get("/interactions/sessions/{session}/download", server.HandleDownloadSession)
|
||||
r.Delete("/interactions/sessions/{session}", server.HandleDeleteSession)
|
||||
r.Delete("/interactions/sessions", server.HandleCleanupSessions)
|
||||
@@ -1096,8 +1194,24 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
|
||||
|
||||
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
|
||||
r.Get("/device-summary/{deviceId}", server.HandleDeviceSummary)
|
||||
|
||||
r.Get("/health", server.HandleHealthChecks)
|
||||
r.Post("/health/fix", server.HandleHealthFix)
|
||||
r.Get("/logs", server.HandleGetLogs)
|
||||
|
||||
// Serve Stockholm setup wizard pages for paths not matched by the management API.
|
||||
// The Stockholm frontend has a setup/ directory that must be accessible at /setup/*.
|
||||
if stockholmHandler != nil {
|
||||
r.Get("/*", stockholmHandler.HandleStatic)
|
||||
r.Get("/", stockholmHandler.HandleStatic)
|
||||
}
|
||||
})
|
||||
|
||||
if stockholmHandler != nil {
|
||||
stockholmHandler.Mount(r)
|
||||
}
|
||||
|
||||
r.NotFound(server.HandleNotFound)
|
||||
|
||||
return r
|
||||
@@ -1160,6 +1274,45 @@ func startHTTPSServer(httpsAddr string, r http.Handler, tlsConfig *tls.Config, h
|
||||
}()
|
||||
}
|
||||
|
||||
// runHTTPSPreflight checks whether speakers' implicit :443 target reaches
|
||||
// AfterTouch. Runs after the HTTPS listener has had a moment to come up; if
|
||||
// the listener is already on :443 the check is skipped. Emits a single WARN
|
||||
// log line with actionable guidance when either probe fails.
|
||||
//
|
||||
// Only runs when dnsEnabled is true: the :443 reachability only matters when
|
||||
// speakers are reaching AfterTouch via intercepted Bose hostnames (i.e. the
|
||||
// DNS migration method). For direct SDK-override migration the speaker
|
||||
// connects to the configured https-port directly, so :443 is irrelevant.
|
||||
// Users with external DNS interception (Pi-hole, router rules) can still see
|
||||
// the live result on /setup/settings even when this startup warn is silent.
|
||||
func runHTTPSPreflight(httpsServerURL, serverURL string, dnsEnabled bool, resolver func(string) (string, error)) {
|
||||
if !dnsEnabled {
|
||||
return
|
||||
}
|
||||
|
||||
port := handlers.PortFromHTTPSServerURL(httpsServerURL)
|
||||
if port == 0 {
|
||||
// Can't determine the listener port — be silent rather than misleading.
|
||||
return
|
||||
}
|
||||
|
||||
// Give the listener a head start so a successful bind beats the probe.
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
res := handlers.Check443Reachability(port, serverURL, resolver, handlers.ProbeDialTimeoutStartup)
|
||||
|
||||
guidance := handlers.FormatPreflightGuidance(port, res)
|
||||
if guidance == "" {
|
||||
if !res.Skipped {
|
||||
log.Printf("HTTPS pre-flight: :443 reachable at localhost and %s ✓", res.LANHost)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Print(guidance)
|
||||
}
|
||||
|
||||
// matchesDomain checks if a certificate domain (which may be a wildcard) matches a server name
|
||||
func matchesDomain(certDomain, serverName string) bool {
|
||||
if certDomain == serverName {
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"reflect"
|
||||
"runtime"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@@ -17,7 +19,7 @@ import (
|
||||
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)
|
||||
r := setupRouter(server, nil)
|
||||
|
||||
var routes []string
|
||||
walkFunc := func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
|
||||
@@ -102,3 +104,56 @@ func TestPrintRoutes(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+34
-16
@@ -1,13 +1,15 @@
|
||||
CONNECT /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
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 /oauth/* handlers.(*Server).HandleBoseProxy-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/parity-mismatches handlers.(*Server).HandleClearParityMismatches-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
|
||||
@@ -30,12 +32,16 @@ GET /bmx/tunein/v1/playback/episodes/{podcastID} handlers.(
|
||||
GET /bmx/tunein/v1/playback/station/{stationID} handlers.(*Server).HandleTuneInPlayback-fm
|
||||
GET /bmx/tunein/v1/search handlers.(*Server).HandleTuneInSearch-fm
|
||||
GET /ced/* handlers.(*Server).HandleCedStatic
|
||||
GET /core02/svc-bmx-adapter-orion/prod/orion/station handlers.(*Server).HandleOrionPlayback-fm
|
||||
GET /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
GET /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
GET /custom/v1/playback/{encodedURL} handlers.(*Server).HandleCustomPlayback-fm
|
||||
GET /customer/account/{account} handlers.(*Server).HandleMargeAccountProfile-fm
|
||||
GET /docs/* handlers.(*Server).HandleDocs-fm
|
||||
GET /favicon.ico setupRouter
|
||||
GET /health handlers.(*Server).HandleHealth-fm
|
||||
GET /media/* handlers.(*Server).HandleMedia
|
||||
GET /media/aftertouch-ding.wav handlers.(*Server).HandleDing-fm
|
||||
GET /mgmt/accounts/ handlers.(*Server).HandleMgmtListAccounts-fm
|
||||
GET /mgmt/accounts/{accountId} handlers.(*Server).HandleMgmtAccountDetails-fm
|
||||
GET /mgmt/accounts/{accountId}/speakers handlers.(*Server).HandleMgmtListSpeakers-fm
|
||||
@@ -46,21 +52,22 @@ GET /mgmt/devices/{deviceId}/events handlers.(
|
||||
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 /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
GET /proxy/* handlers.(*Server).HandleProxyRequest-fm
|
||||
GET /setup/account-id-suggestions/{deviceId} handlers.(*Server).HandleAccountIDSuggestions-fm
|
||||
GET /setup/ca.crt handlers.(*Server).HandleGetCACert-fm
|
||||
GET /setup/device-summary/{deviceId} handlers.(*Server).HandleDeviceSummary-fm
|
||||
GET /setup/devices handlers.(*Server).HandleListDiscoveredDevices-fm
|
||||
GET /setup/devices/{deviceId}/events handlers.(*Server).HandleGetDeviceEvents-fm
|
||||
GET /setup/discovery-status handlers.(*Server).HandleGetDiscoveryStatus-fm
|
||||
GET /setup/dns-discoveries handlers.(*Server).HandleGetDNSDiscoveries-fm
|
||||
GET /setup/dns-discoveries/download handlers.(*Server).HandleDownloadDNSDiscoveries-fm
|
||||
GET /setup/health handlers.(*Server).HandleHealthChecks-fm
|
||||
GET /setup/info/{deviceId} handlers.(*Server).HandleGetDeviceInfo-fm
|
||||
GET /setup/interaction-content handlers.(*Server).HandleGetInteractionContent-fm
|
||||
GET /setup/interaction-stats handlers.(*Server).HandleGetInteractionStats-fm
|
||||
GET /setup/interactions handlers.(*Server).HandleListInteractions-fm
|
||||
GET /setup/interactions/sessions/{session}/download handlers.(*Server).HandleDownloadSession-fm
|
||||
GET /setup/parity-mismatches handlers.(*Server).HandleListParityMismatches-fm
|
||||
GET /setup/proxy-settings handlers.(*Server).HandleGetProxySettings-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
|
||||
@@ -86,20 +93,25 @@ GET /streaming/sourceproviders handlers.(
|
||||
GET /updates/soundtouch handlers.(*Server).HandleMargeSoftwareUpdate-fm
|
||||
GET /v1/blacklist/{deviceId} setupRouter
|
||||
GET /web/* setupRouter.(*Server).HandleWeb
|
||||
HEAD /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
OPTIONS /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
PATCH /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
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/core02/svc-bmx-adapter-orion/prod/orion/token handlers.(*Server).HandleOrionToken-fm
|
||||
POST /bmx/orion/v1/playback/station/{data} handlers.(*Server).HandleOrionPlayback-fm
|
||||
POST /bmx/tunein/v1/favorite/{stationID} handlers.(*Server).HandleTuneInFavorite-fm
|
||||
POST /bmx/tunein/v1/report handlers.(*Server).HandleTuneInReport-fm
|
||||
POST /bmx/tunein/v1/token handlers.(*Server).HandleTuneInToken-fm
|
||||
POST /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
|
||||
@@ -111,7 +123,6 @@ POST /mgmt/spotify/confirm handlers.(
|
||||
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/* handlers.(*Server).HandleBoseProxy-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
|
||||
@@ -120,8 +131,11 @@ POST /setup/backup/{deviceId} handlers.(
|
||||
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/proxy-settings handlers.(*Server).HandleUpdateProxySettings-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
|
||||
@@ -138,6 +152,7 @@ POST /streaming/account/{account}/device/{device} handlers.(
|
||||
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
|
||||
@@ -149,6 +164,9 @@ POST /streaming/support/customersupport handlers.(
|
||||
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 /oauth/* handlers.(*Server).HandleBoseProxy-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 /oauth/* handlers.(*Server).HandleBoseProxy-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
|
||||
|
||||
@@ -88,7 +88,7 @@ go build -o soundtouch-web
|
||||
./soundtouch-web -port 8888
|
||||
|
||||
# Connect to specific device
|
||||
./soundtouch-web -host 192.168.1.100
|
||||
./soundtouch-web -host 192.0.2.100
|
||||
```
|
||||
|
||||
### Command Line Options
|
||||
|
||||
+175
-5
@@ -2,16 +2,53 @@
|
||||
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",
|
||||
@@ -25,25 +62,76 @@ func main() {
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "bind",
|
||||
Usage: "Network interface to bind to",
|
||||
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")
|
||||
bindAddr := c.String("bind")
|
||||
rawBind := c.String("bind")
|
||||
|
||||
bindAddr, err := resolveBindAddr(rawBind)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if rawBind != "" && bindAddr != rawBind {
|
||||
log.Printf("Resolved --bind %q to %s", rawBind, bindAddr)
|
||||
}
|
||||
|
||||
rawIface := c.String("interface")
|
||||
manualHosts := c.StringSlice("devices")
|
||||
|
||||
ifaceName := defaultDiscoveryInterface(rawIface, rawBind, bindAddr)
|
||||
if rawIface == "" && ifaceName != "" {
|
||||
log.Printf("Defaulting --interface to %q from --bind", ifaceName)
|
||||
}
|
||||
|
||||
addr := ":" + port
|
||||
if bindAddr != "" {
|
||||
addr = bindAddr + ":" + port
|
||||
}
|
||||
|
||||
webApp := soundtouchweb.New()
|
||||
// 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)
|
||||
webApp.Mount(r, discoveryService)
|
||||
|
||||
log.Printf("SoundTouch Web UI starting on http://%s", addr)
|
||||
log.Printf("AfterTouch Web UI starting on http://%s", addr)
|
||||
|
||||
return http.ListenAndServe(addr, r)
|
||||
},
|
||||
@@ -53,3 +141,85 @@ func main() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
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, "/", "_")
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb"
|
||||
@@ -55,19 +54,22 @@ func TestSPARouting(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", tt.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Simulate SPA routing handler
|
||||
spaHandler := func(w http.ResponseWriter, r *http.Request) {
|
||||
// If it's an API route, let it pass through
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/static/") || strings.HasPrefix(r.URL.Path, "/ws") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Serve the SPA index.html content (simulated)
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>SoundTouch Web</title>
|
||||
<title>AfterTouch Control Center</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">SPA Content</div>
|
||||
@@ -124,7 +126,7 @@ func TestAPIEndpoints(t *testing.T) {
|
||||
name: "device API with ID",
|
||||
path: "/api/device/test-device",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusNotFound,
|
||||
expectedStatus: http.StatusNotFound, // Device won't exist in test
|
||||
expectedJSON: true,
|
||||
},
|
||||
}
|
||||
@@ -157,6 +159,7 @@ func TestAPIEndpoints(t *testing.T) {
|
||||
t.Errorf("Expected JSON content type, got %s", contentType)
|
||||
}
|
||||
|
||||
// Validate JSON response structure
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Errorf("Invalid JSON response: %v", err)
|
||||
@@ -179,6 +182,7 @@ func TestAPIResponseFormat(t *testing.T) {
|
||||
t.Fatalf("Failed to decode JSON response: %v", err)
|
||||
}
|
||||
|
||||
// Check API response structure
|
||||
if !response.Success {
|
||||
t.Errorf("Expected success=true, got success=%v", response.Success)
|
||||
}
|
||||
@@ -187,6 +191,7 @@ func TestAPIResponseFormat(t *testing.T) {
|
||||
t.Errorf("Expected data field to be present")
|
||||
}
|
||||
|
||||
// Data should be an empty map for no devices
|
||||
dataMap, ok := response.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Errorf("Expected data to be a map, got %T", response.Data)
|
||||
@@ -243,13 +248,10 @@ func TestControlAPIValidation(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
mockDevice := &webtypes.DeviceConnection{
|
||||
Client: nil,
|
||||
DeviceInfo: &models.DeviceInfo{Name: "Test Device"},
|
||||
LastSeen: time.Now(),
|
||||
Status: webtypes.DeviceStatus{IsConnected: true},
|
||||
}
|
||||
app.Devices["testdevice"] = mockDevice
|
||||
// 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) {
|
||||
@@ -271,6 +273,7 @@ func TestControlAPIValidation(t *testing.T) {
|
||||
t.Errorf("Test %s: Expected status %d, got %d. Response: %s", tt.name, tt.expectedStatus, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Validate error response format
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "application/json") {
|
||||
t.Errorf("Expected JSON content type, got %s", contentType)
|
||||
@@ -295,6 +298,7 @@ func TestControlAPIValidation(t *testing.T) {
|
||||
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")
|
||||
@@ -303,7 +307,12 @@ func TestWebSocketUpgrade(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// The actual WebSocket upgrade will fail in test environment,
|
||||
// but we can check that the handler exists and accepts the request
|
||||
app.HandleWebSocket(w, req)
|
||||
|
||||
// In a real test environment, this would fail with a websocket upgrade error
|
||||
// We're just checking the handler doesn't panic and processes the request
|
||||
}
|
||||
|
||||
func TestJSONAPIConsistency(t *testing.T) {
|
||||
@@ -330,16 +339,19 @@ func TestJSONAPIConsistency(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// All API endpoints should return JSON
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "application/json") {
|
||||
t.Errorf("Endpoint %s should return JSON, got %s", endpoint, contentType)
|
||||
}
|
||||
|
||||
// All responses should follow APIResponse structure
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Errorf("Endpoint %s returned invalid JSON: %v", endpoint, err)
|
||||
}
|
||||
|
||||
// Response should have either data or error
|
||||
if response.Success && response.Data == nil {
|
||||
t.Errorf("Endpoint %s: success response should have data", endpoint)
|
||||
}
|
||||
|
||||
@@ -355,9 +355,11 @@ func handlePreset(event *models.PresetUpdatedEvent, verbose bool) {
|
||||
for _, preset := range presets.Preset {
|
||||
fmt.Printf(" 📻 Preset %d:", preset.ID)
|
||||
|
||||
if preset.ContentItem != nil {
|
||||
fmt.Printf(" %s", preset.ContentItem.ItemName)
|
||||
fmt.Printf(" (%s)", preset.ContentItem.Source)
|
||||
// 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())
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
@@ -544,13 +546,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.168.1.10 -filter volume\n", os.Args[0])
|
||||
fmt.Printf(" %s -host 192.0.2.10 -filter volume\n", os.Args[0])
|
||||
fmt.Println()
|
||||
fmt.Println(" # Monitor for 5 minutes with verbose output")
|
||||
fmt.Printf(" %s -host 192.168.1.10 -duration 5m -verbose\n", os.Args[0])
|
||||
fmt.Printf(" %s -host 192.0.2.10 -duration 5m -verbose\n", os.Args[0])
|
||||
fmt.Println()
|
||||
fmt.Println(" # Monitor now playing and volume events")
|
||||
fmt.Printf(" %s -host 192.168.1.10 -filter nowPlaying,volume\n", os.Args[0])
|
||||
fmt.Printf(" %s -host 192.0.2.10 -filter nowPlaying,volume\n", os.Args[0])
|
||||
fmt.Println()
|
||||
fmt.Println("Event Types:")
|
||||
fmt.Println(" 🎵 nowPlaying - Track changes, playback status")
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
// func main() {
|
||||
// // Create a client for your SoundTouch device
|
||||
// config := &client.Config{
|
||||
// Host: "192.168.1.100",
|
||||
// Host: "192.0.2.100",
|
||||
// Port: 8090,
|
||||
// }
|
||||
// client := client.NewClient(config)
|
||||
@@ -70,7 +70,7 @@
|
||||
// soundtouch-cli discover devices
|
||||
//
|
||||
// # Control a device
|
||||
// soundtouch-cli --host 192.168.1.100 play start
|
||||
// soundtouch-cli --host 192.0.2.100 play start
|
||||
//
|
||||
// # Supported Features
|
||||
//
|
||||
|
||||
Binary file not shown.
@@ -143,7 +143,7 @@ Browse stored/local music library.
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
library, err := client.GetStoredMusicLibrary("A81B6A536A98/0")
|
||||
library, err := client.GetStoredMusicLibrary("AABBCCDDEEFF/0")
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
@@ -526,7 +526,7 @@ Search for stations and content.
|
||||
|
||||
**Response Body:**
|
||||
```xml
|
||||
<results deviceID="A81B6A536A98" source="PANDORA" sourceAccount="user123">
|
||||
<results deviceID="AABBCCDDEEFF" source="PANDORA" sourceAccount="user123">
|
||||
<songs>
|
||||
<searchResult source="PANDORA" sourceAccount="user123" token="S123">
|
||||
<name>Love Story</name>
|
||||
@@ -707,7 +707,7 @@ Navigation and station operations generate WebSocket events:
|
||||
Generated when stations are added/removed that affect presets.
|
||||
|
||||
```xml
|
||||
<presetsUpdated deviceID="A81B6A536A98">
|
||||
<presetsUpdated deviceID="AABBCCDDEEFF">
|
||||
<presets>
|
||||
<!-- Updated preset list -->
|
||||
</presets>
|
||||
@@ -719,7 +719,7 @@ Generated when stations are added/removed that affect presets.
|
||||
Generated when station operations affect current playback.
|
||||
|
||||
```xml
|
||||
<nowPlayingUpdated deviceID="A81B6A536A98">
|
||||
<nowPlayingUpdated deviceID="AABBCCDDEEFF">
|
||||
<nowPlaying source="PANDORA">
|
||||
<ContentItem source="PANDORA" location="R456" sourceAccount="user123" isPresetable="true">
|
||||
<itemName>Taylor Swift Radio</itemName>
|
||||
|
||||
+2
-2
@@ -73,8 +73,8 @@ For web components:
|
||||
When creating test data for API endpoints, prefer real device responses over hypothetical examples:
|
||||
|
||||
- **Available test endpoints**:
|
||||
- `http://192.168.178.28:8090/now_playing` - Different response type 1
|
||||
- `http://192.168.178.35:8090/now_playing` - Different response type 2
|
||||
- `http://192.0.2.11:8090/now_playing` - Different response type 1
|
||||
- `http://192.0.2.10:8090/now_playing` - Different response type 2
|
||||
- **Usage**: Fetch real responses to create accurate test fixtures
|
||||
- **Privacy**: Anonymize any personal data (account names, personal playlists, etc.)
|
||||
- **Coverage**: Use multiple real devices to cover different response variations
|
||||
|
||||
@@ -197,12 +197,12 @@ err := client.SelectContentItem(contentItem)
|
||||
### CLI Usage
|
||||
```bash
|
||||
# streamUrl format
|
||||
soundtouch-cli --host 192.168.1.100 source internet-radio \
|
||||
soundtouch-cli --host 192.0.2.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.168.1.100 source internet-radio \
|
||||
soundtouch-cli --host 192.0.2.100 source internet-radio \
|
||||
--location "https://stream.example.com/radio" \
|
||||
--name "Direct Stream"
|
||||
```
|
||||
|
||||
@@ -46,7 +46,7 @@ usb0 Link encap:Ethernet HWaddr CA:FE:BA:BE:1E:47
|
||||
|
||||
Sun Feb 1 20:35:24 CET 2026
|
||||
|
||||
Device name: "A Sound Machine"
|
||||
Device name: "Kitchen SoundTouch"
|
||||
Country EU, Region (not set)
|
||||
Module type: scm
|
||||
root@spotty:~#
|
||||
@@ -83,7 +83,7 @@ usb0 Link encap:Ethernet HWaddr CA:FE:BA:BE:1E:47
|
||||
|
||||
Sun Feb 1 19:12:47 CET 2026
|
||||
|
||||
Device name: "A Sound Machine"
|
||||
Device name: "Kitchen SoundTouch"
|
||||
Country EU, Region (not set)
|
||||
Module type: scm
|
||||
root@spotty:~#
|
||||
|
||||
@@ -48,6 +48,12 @@ 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
|
||||
|
||||
+24
-24
@@ -4,7 +4,7 @@ This document tracks the detailed evolution of features and capabilities in the
|
||||
|
||||
## Development Timeline
|
||||
|
||||
### Phase 1: Foundation (November 2024 - December 2024)
|
||||
### Phase 1: Foundation (January 2026)
|
||||
|
||||
#### Core HTTP Client
|
||||
- **HTTP Client with XML Support**: Complete client implementation for SoundTouch Web API
|
||||
@@ -23,7 +23,7 @@ This document tracks the detailed evolution of features and capabilities in the
|
||||
- Device connectivity testing
|
||||
- Simple information retrieval commands
|
||||
|
||||
### Phase 2: Media Control & Discovery (December 2024)
|
||||
### Phase 2: Media Control & Discovery (January 2026)
|
||||
|
||||
#### Media Controls
|
||||
- **Key Commands**: Complete implementation of `/key` endpoint
|
||||
@@ -48,7 +48,7 @@ This document tracks the detailed evolution of features and capabilities in the
|
||||
- **Comprehensive Commands**: Full coverage of implemented endpoints
|
||||
- **Interactive Features**: Better user experience with formatted output
|
||||
|
||||
### Phase 3: Advanced Audio Controls (January 2025)
|
||||
### Phase 3: Advanced Audio Controls (January 2026)
|
||||
|
||||
#### Audio Management Trilogy
|
||||
- **Bass Control**: `/bass` GET/POST endpoints
|
||||
@@ -56,7 +56,7 @@ This document tracks the detailed evolution of features and capabilities in the
|
||||
- Incremental bass adjustment
|
||||
- Device capability detection via `/bassCapabilities`
|
||||
- Safety limits and user warnings
|
||||
- **Balance Control**: `/balance` GET/POST endpoints
|
||||
- **Balance Control**: `/balance` GET/POST endpoints
|
||||
- Stereo balance adjustment (-50 to +50)
|
||||
- Left/right channel convenience methods
|
||||
- Balance centering functionality
|
||||
@@ -81,7 +81,7 @@ This document tracks the detailed evolution of features and capabilities in the
|
||||
- Preset categorization and filtering
|
||||
- **API Limitation Documentation**: Clarified that POST `/presets` is officially N/A
|
||||
|
||||
### Phase 4: System Features (January 2025)
|
||||
### Phase 4: System Features (January 2026)
|
||||
|
||||
#### Clock and Display Management
|
||||
- **Clock Time**: `/clockTime` GET/POST endpoints
|
||||
@@ -104,7 +104,7 @@ This document tracks the detailed evolution of features and capabilities in the
|
||||
- **Multiple Discovery Protocols**: Fallback discovery methods for different network environments
|
||||
- **Corporate Network Support**: Discovery options for restricted networks
|
||||
|
||||
### Phase 5: Real-time Events (January 2025)
|
||||
### Phase 5: Real-time Events (January 2026)
|
||||
|
||||
#### WebSocket Implementation
|
||||
- **WebSocket Client**: Complete WebSocket implementation for real-time events
|
||||
@@ -130,7 +130,7 @@ This document tracks the detailed evolution of features and capabilities in the
|
||||
- **Formatted Output**: Human-readable event display
|
||||
- **Demo Applications**: WebSocket demonstration tools
|
||||
|
||||
### Phase 6: Multiroom Zone Management (January 2025)
|
||||
### Phase 6: Multiroom Zone Management (January 2026)
|
||||
|
||||
#### Zone Operations
|
||||
- **Zone Information**: `/getZone` GET endpoint
|
||||
@@ -166,7 +166,7 @@ This document tracks the detailed evolution of features and capabilities in the
|
||||
- **Error Handling**: Specific zone-related error types
|
||||
- **Zone Builder**: Fluent API for zone construction
|
||||
|
||||
### Phase 7: Advanced Audio Controls (January 2025)
|
||||
### Phase 7: Advanced Audio Controls (January 2026)
|
||||
|
||||
#### Professional Audio Features
|
||||
- **DSP Audio Controls**: `/audiodspcontrols` GET/POST endpoints
|
||||
@@ -188,7 +188,7 @@ This document tracks the detailed evolution of features and capabilities in the
|
||||
- **Conditional Feature Availability**: Features only available on compatible devices
|
||||
- **Graceful Degradation**: Fallback to basic controls when advanced features unavailable
|
||||
|
||||
### Phase 8: Speaker Notification System (February 2025)
|
||||
### Phase 8: Speaker Notification System (February 2026)
|
||||
|
||||
#### Notification Features
|
||||
- **Text-to-Speech (TTS)**: `/speaker` POST endpoint for TTS messages
|
||||
@@ -231,7 +231,7 @@ This document tracks the detailed evolution of features and capabilities in the
|
||||
- **Parameter Validation**: Complete input validation and error handling
|
||||
- **Usage Examples**: Extensive real-world usage examples
|
||||
|
||||
### Phase 9: Bug Fixes and Stability (February 2025)
|
||||
### Phase 9: Bug Fixes and Stability (February 2026)
|
||||
|
||||
#### Critical Bug Fixes
|
||||
- **PlayNotificationBeep HTTP Method Fix**: Corrected `/playNotification` endpoint to use GET instead of POST
|
||||
@@ -249,17 +249,17 @@ This document tracks the detailed evolution of features and capabilities in the
|
||||
|
||||
### API Endpoint Coverage Evolution
|
||||
|
||||
| Phase | Endpoints Added | Cumulative Total | Completion % |
|
||||
|-------|-----------------|------------------|--------------|
|
||||
| Phase 1 | 4 | 4 | 15% |
|
||||
| Phase 2 | 6 | 10 | 38% |
|
||||
| Phase 3 | 8 | 18 | 69% |
|
||||
| Phase 4 | 3 | 21 | 81% |
|
||||
| Phase 5 | 1 | 22 | 85% |
|
||||
| Phase 6 | 2 | 24 | 92% |
|
||||
| Phase 7 | 3 | 27 | 96% |
|
||||
| Phase 8 | 2 | 29 | 100% |
|
||||
| Phase 9 | 0 | 29 | 100% (Bug fixes) |
|
||||
| Phase | Endpoints Added | Cumulative Total | Completion % |
|
||||
|---------|-----------------|------------------|------------------|
|
||||
| Phase 1 | 4 | 4 | 15% |
|
||||
| Phase 2 | 6 | 10 | 38% |
|
||||
| Phase 3 | 8 | 18 | 69% |
|
||||
| Phase 4 | 3 | 21 | 81% |
|
||||
| Phase 5 | 1 | 22 | 85% |
|
||||
| Phase 6 | 2 | 24 | 92% |
|
||||
| Phase 7 | 3 | 27 | 96% |
|
||||
| Phase 8 | 2 | 29 | 100% |
|
||||
| Phase 9 | 0 | 29 | 100% (Bug fixes) |
|
||||
|
||||
### Testing Evolution
|
||||
|
||||
@@ -283,7 +283,7 @@ This document tracks the detailed evolution of features and capabilities in the
|
||||
### CLI Tool Evolution
|
||||
|
||||
#### Command Categories Added by Phase
|
||||
- **Phase 1**: `info`, `name`, `capabilities`
|
||||
- **Phase 1**: `info`, `name`, `capabilities`
|
||||
- **Phase 2**: `discover`, `play`, `volume`, `key`
|
||||
- **Phase 3**: `bass`, `balance`, `source`, `presets`
|
||||
- **Phase 4**: `clock`, `network`
|
||||
@@ -294,7 +294,7 @@ This document tracks the detailed evolution of features and capabilities in the
|
||||
- **Phase 9**: Bug fixes (speaker beep reliability)
|
||||
|
||||
#### CLI Feature Enhancements
|
||||
- **Host:Port Parsing**: Support for `192.168.1.100:8090` format
|
||||
- **Host:Port Parsing**: Support for `192.0.2.100:8090` format
|
||||
- **Auto-Discovery Integration**: Seamless device discovery
|
||||
- **Formatted Output**: Human-readable, structured output
|
||||
- **Error Handling**: Comprehensive error messages and recovery suggestions
|
||||
@@ -371,4 +371,4 @@ This document tracks the detailed evolution of features and capabilities in the
|
||||
|
||||
---
|
||||
|
||||
**This document tracks the evolution of the Bose SoundTouch API client from initial concept to production-ready library.**
|
||||
**This document tracks the evolution of the Bose SoundTouch API client from initial concept to production-ready library.**
|
||||
|
||||
+21
-21
@@ -11,23 +11,23 @@ The SoundTouch CLI now supports parsing host and port combinations in the `-host
|
||||
### Basic Host:Port Format
|
||||
```bash
|
||||
# Specify host and port together
|
||||
soundtouch-cli -host 192.168.1.100:8090 -info
|
||||
soundtouch-cli -host 192.168.178.35:8090 -play
|
||||
soundtouch-cli -host 192.0.2.100:8090 -info
|
||||
soundtouch-cli -host 192.0.2.10: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.168.1.100 -port 8090 -info
|
||||
soundtouch-cli -host 192.168.178.35 -port 8090 -play
|
||||
soundtouch-cli -host 192.0.2.100 -port 8090 -info
|
||||
soundtouch-cli -host 192.0.2.10 -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.168.1.100:8090 -port 9999 -info
|
||||
soundtouch-cli -host 192.0.2.100:8090 -port 9999 -info
|
||||
```
|
||||
|
||||
## Supported Formats
|
||||
@@ -35,10 +35,10 @@ soundtouch-cli -host 192.168.1.100:8090 -port 9999 -info
|
||||
### IPv4 Addresses
|
||||
```bash
|
||||
# Standard IPv4 with port
|
||||
soundtouch-cli -host 192.168.1.100:8090 -info
|
||||
soundtouch-cli -host 192.0.2.100:8090 -info
|
||||
|
||||
# IPv4 without port (uses default 8090)
|
||||
soundtouch-cli -host 192.168.1.100 -info
|
||||
soundtouch-cli -host 192.0.2.100 -info
|
||||
```
|
||||
|
||||
### Hostnames
|
||||
@@ -99,8 +99,8 @@ Comprehensive test coverage in `cmd/soundtouch-cli/main_test.go`:
|
||||
|
||||
### Integration Tests
|
||||
Tested with real SoundTouch devices:
|
||||
- ✅ SoundTouch 10 (192.168.178.28:8090)
|
||||
- ✅ SoundTouch 20 (192.168.178.35:8090)
|
||||
- ✅ SoundTouch 10 (192.0.2.11:8090)
|
||||
- ✅ SoundTouch 20 (192.0.2.10:8090)
|
||||
|
||||
## Benefits
|
||||
|
||||
@@ -123,31 +123,31 @@ Tested with real SoundTouch devices:
|
||||
# Discover devices to find host:port
|
||||
$ soundtouch-cli -discover
|
||||
Found SoundTouch devices:
|
||||
My SoundTouch Device (192.168.1.10:8090) - SoundTouch 20
|
||||
My SoundTouch Device (192.0.2.10:8090) - SoundTouch 20
|
||||
|
||||
# Use discovered host:port directly
|
||||
$ soundtouch-cli -host 192.168.1.10:8090 -play
|
||||
$ soundtouch-cli -host 192.0.2.10:8090 -play
|
||||
```
|
||||
|
||||
### Different Port Scenarios
|
||||
```bash
|
||||
# Standard SoundTouch port
|
||||
soundtouch-cli -host 192.168.1.100:8090 -info
|
||||
soundtouch-cli -host 192.0.2.100:8090 -info
|
||||
|
||||
# Custom port (if device configured differently)
|
||||
soundtouch-cli -host 192.168.1.100:9000 -info
|
||||
soundtouch-cli -host 192.0.2.100:9000 -info
|
||||
|
||||
# Default port fallback
|
||||
soundtouch-cli -host 192.168.1.100 -info # Uses 8090
|
||||
soundtouch-cli -host 192.0.2.100 -info # Uses 8090
|
||||
```
|
||||
|
||||
### Error Scenarios
|
||||
```bash
|
||||
# Invalid port - uses default 8090
|
||||
soundtouch-cli -host 192.168.1.100:invalid -info
|
||||
soundtouch-cli -host 192.0.2.100:invalid -info
|
||||
|
||||
# Out of range port - uses default 8090
|
||||
soundtouch-cli -host 192.168.1.100:99999 -info
|
||||
soundtouch-cli -host 192.0.2.100:99999 -info
|
||||
|
||||
# Malformed input - treats as hostname
|
||||
soundtouch-cli -host "malformed::input" -info
|
||||
@@ -163,10 +163,10 @@ Options:
|
||||
-port <port> SoundTouch device port (default: 8090)
|
||||
|
||||
Examples:
|
||||
soundtouch-cli -host 192.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
|
||||
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
|
||||
```
|
||||
|
||||
## Technical Implementation
|
||||
@@ -197,7 +197,7 @@ The parsed values are used throughout the CLI:
|
||||
|
||||
Potential improvements for the future:
|
||||
|
||||
1. **URL Format Support**: Support full URLs like `http://192.168.1.100:8090`
|
||||
1. **URL Format Support**: Support full URLs like `http://192.0.2.100:8090`
|
||||
2. **Service Discovery**: Auto-detect port via service discovery protocols
|
||||
3. **Configuration File**: Save frequently used host:port combinations
|
||||
4. **Environment Variables**: Support `SOUNDTOUCH_HOST` with host:port format
|
||||
|
||||
@@ -316,7 +316,7 @@ MX:3
|
||||
NOTIFY * HTTP/1.1
|
||||
HOST:239.255.255.250:1900
|
||||
CACHE-CONTROL:max-age=1800
|
||||
LOCATION:http://192.168.1.100:8090/device_description.xml
|
||||
LOCATION:http://192.0.2.100:8090/device_description.xml
|
||||
NT:upnp:rootdevice
|
||||
NTS:ssdp:alive
|
||||
USN:uuid:12345678-1234-1234-1234-123456789012::upnp:rootdevice
|
||||
@@ -328,7 +328,7 @@ HTTP/1.1 200 OK
|
||||
CACHE-CONTROL:max-age=1800
|
||||
DATE:Wed, 18 Dec 2024 10:30:00 GMT
|
||||
EXT:
|
||||
LOCATION:http://192.168.1.100:8090/device_description.xml
|
||||
LOCATION:http://192.0.2.100:8090/device_description.xml
|
||||
SERVER:Linux/3.0 UPnP/1.0 Device/1.0
|
||||
ST:upnp:rootdevice
|
||||
USN:uuid:12345678-1234-1234-1234-123456789012::upnp:rootdevice
|
||||
@@ -363,7 +363,7 @@ arp -a
|
||||
|
||||
# Scan local network segment (requires nmap)
|
||||
brew install nmap
|
||||
nmap -sn 192.168.1.0/24 # Adjust network range as needed
|
||||
nmap -sn 192.0.2.0/24 # Adjust network range as needed
|
||||
|
||||
# Quick ping sweep (built-in)
|
||||
for i in {1..254}; do ping -c 1 -t 1 192.168.1.$i >/dev/null 2>&1 && echo "192.168.1.$i is up"; done
|
||||
@@ -439,10 +439,10 @@ sudo tcpdump -i any -n -A 'port 5353' | grep -i soundtouch
|
||||
netstat -g
|
||||
|
||||
# Test UDP connectivity
|
||||
nc -u 192.168.1.100 8090 # Replace with actual device IP
|
||||
nc -u 192.0.2.100 8090 # Replace with actual device IP
|
||||
|
||||
# Test HTTP connectivity to discovered devices
|
||||
curl -i http://192.168.1.100:8090/info # SoundTouch info endpoint
|
||||
curl -i http://192.0.2.100:8090/info # SoundTouch info endpoint
|
||||
```
|
||||
|
||||
## Protocol Comparison
|
||||
|
||||
@@ -40,7 +40,7 @@ import (
|
||||
func main() {
|
||||
// Create client
|
||||
config := &client.Config{
|
||||
Host: "192.168.1.100",
|
||||
Host: "192.0.2.100",
|
||||
Port: 8090,
|
||||
}
|
||||
soundtouch := client.NewClient(config)
|
||||
|
||||
@@ -37,9 +37,8 @@ This document summarizes the improvements made to the **Marge service** to impro
|
||||
* **Device Identity**: Added `<serialNumber>` and `<updatedOn>` to both the top-level `<device>` and its `<attachedProduct>`, ensuring consistent device identification.
|
||||
* **Field-Level Parity**: Mapped missing fields like `<contentItemType>` and `<productlabel>` to match upstream expectations.
|
||||
* **Improved Source Matching**: Enhanced internal logic to correctly link presets and recents to their configured sources based on multiple identifiers (ID, Key, or Type).
|
||||
* **Verified Parity Mismatch Fixes**: Comprehensive reproduction tests (`TestParityMismatchReproduction_V2` and `TestParityMismatchReproduction_V3`) now confirm parity for identified mismatches in `POST /recent` and `GET /recents`, including credentials and source-specific metadata.
|
||||
* **Verified Parity Mismatch Fixes**: The reproduction test `TestParityMismatchReproduction_V2` confirms parity for identified mismatches in `POST /recent` and `GET /recents`, including credentials and source-specific metadata.
|
||||
* **Unified Response Logic**: Refactored the code so that both `POST /recent` and `GET /recents` use the same formatting functions, guaranteeing consistency.
|
||||
* **Robust Parity Detection**: Updated the local parity checker to be whitespace-insensitive for XML bodies, significantly reducing noise from minor indentation or newline differences.
|
||||
* **Maintainable XML Generation**: Reduced cyclomatic complexity and code duplication in `marge.go` by extracting focused helper functions for mapping internal data to response-specific XML models.
|
||||
|
||||
---
|
||||
|
||||
+25
-25
@@ -10,20 +10,20 @@ SoundTouch devices support 6 preset slots that can store your favorite content f
|
||||
|
||||
### 1. See Current Presets
|
||||
```bash
|
||||
soundtouch-cli --host 192.168.1.100 preset list
|
||||
soundtouch-cli --host 192.0.2.100 preset list
|
||||
```
|
||||
|
||||
### 2. Store What's Currently Playing
|
||||
```bash
|
||||
# Store current song/station as preset 1
|
||||
soundtouch-cli --host 192.168.1.100 preset store-current --slot 1
|
||||
soundtouch-cli --host 192.0.2.100 preset store-current --slot 1
|
||||
```
|
||||
|
||||
### 3. Store Specific Content
|
||||
|
||||
#### Spotify Playlist
|
||||
```bash
|
||||
soundtouch-cli --host 192.168.1.100 preset store \
|
||||
soundtouch-cli --host 192.0.2.100 preset store \
|
||||
--slot 2 \
|
||||
--source SPOTIFY \
|
||||
--location "spotify:playlist:37i9dQZF1DXcBWIGoYBM5M" \
|
||||
@@ -32,26 +32,26 @@ soundtouch-cli --host 192.168.1.100 preset store \
|
||||
|
||||
#### Radio Station
|
||||
```bash
|
||||
soundtouch-cli --host 192.168.1.100 preset store \
|
||||
soundtouch-cli --host 192.0.2.100 preset store \
|
||||
--slot 3 \
|
||||
--source TUNEIN \
|
||||
--location "/v1/playbook/station/s33828" \
|
||||
--location "/v1/playback/station/s33828" \
|
||||
--name "K-LOVE Radio"
|
||||
```
|
||||
|
||||
### 4. Use Your Presets
|
||||
```bash
|
||||
# Play preset 1
|
||||
soundtouch-cli --host 192.168.1.100 preset select --slot 1
|
||||
soundtouch-cli --host 192.0.2.100 preset select --slot 1
|
||||
|
||||
# Play preset 2
|
||||
soundtouch-cli --host 192.168.1.100 preset select --slot 2
|
||||
soundtouch-cli --host 192.0.2.100 preset select --slot 2
|
||||
```
|
||||
|
||||
### 5. Remove Presets
|
||||
```bash
|
||||
# Remove preset 6
|
||||
soundtouch-cli --host 192.168.1.100 preset remove --slot 6
|
||||
soundtouch-cli --host 192.0.2.100 preset remove --slot 6
|
||||
```
|
||||
|
||||
## Getting Content Locations
|
||||
@@ -61,7 +61,7 @@ To store specific content, you need the `location` parameter. Here's how to get
|
||||
### Method 1: From Currently Playing Content
|
||||
```bash
|
||||
# Play the content you want to save, then:
|
||||
soundtouch-cli --host 192.168.1.100 play now
|
||||
soundtouch-cli --host 192.0.2.100 play now
|
||||
```
|
||||
|
||||
**Example output:**
|
||||
@@ -103,7 +103,7 @@ Just replace `https://open.spotify.com/` with `spotify:` and `/` with `:`.
|
||||
### Radio Stations
|
||||
```bash
|
||||
# TuneIn Radio
|
||||
--source TUNEIN --location "/v1/playbook/station/s33828"
|
||||
--source TUNEIN --location "/v1/playback/station/s33828"
|
||||
|
||||
# Internet Radio Stream
|
||||
--source LOCAL_INTERNET_RADIO --location "https://stream.example.com/jazz"
|
||||
@@ -135,7 +135,7 @@ import (
|
||||
func main() {
|
||||
// Create client
|
||||
c := client.NewClient(&client.Config{
|
||||
Host: "192.168.1.100",
|
||||
Host: "192.0.2.100",
|
||||
Port: 8090,
|
||||
})
|
||||
|
||||
@@ -235,43 +235,43 @@ select {} // Run forever
|
||||
### Family Setup
|
||||
```bash
|
||||
# Dad's morning playlist
|
||||
soundtouch-cli --host 192.168.1.100 preset store \
|
||||
soundtouch-cli --host 192.0.2.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.168.1.100 preset store \
|
||||
soundtouch-cli --host 192.0.2.100 preset store \
|
||||
--slot 2 --source SPOTIFY \
|
||||
--location "spotify:playlist:cooking-vibes" \
|
||||
--name "Kitchen Tunes"
|
||||
|
||||
# Kids' bedtime stories
|
||||
soundtouch-cli --host 192.168.1.100 preset store \
|
||||
soundtouch-cli --host 192.0.2.100 preset store \
|
||||
--slot 3 --source TUNEIN \
|
||||
--location "/v1/playbook/station/bedtime-stories" \
|
||||
--location "/v1/playback/station/bedtime-stories" \
|
||||
--name "Bedtime Stories"
|
||||
```
|
||||
|
||||
### Party Mode
|
||||
```bash
|
||||
# Upbeat party playlist
|
||||
soundtouch-cli --host 192.168.1.100 preset store-current --slot 1
|
||||
soundtouch-cli --host 192.0.2.100 preset store-current --slot 1
|
||||
|
||||
# Chill background music
|
||||
soundtouch-cli --host 192.168.1.100 preset store-current --slot 2
|
||||
soundtouch-cli --host 192.0.2.100 preset store-current --slot 2
|
||||
|
||||
# Dance music
|
||||
soundtouch-cli --host 192.168.1.100 preset store-current --slot 3
|
||||
soundtouch-cli --host 192.0.2.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.168.1.100 preset select --slot 1
|
||||
soundtouch-cli --host 192.0.2.100 preset select --slot 1
|
||||
|
||||
# Evening routine (preset 2) - triggered at sunset
|
||||
soundtouch-cli --host 192.168.1.100 preset select --slot 2
|
||||
soundtouch-cli --host 192.0.2.100 preset select --slot 2
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
@@ -286,26 +286,26 @@ Not all content can be saved as presets:
|
||||
### "All preset slots are occupied"
|
||||
```bash
|
||||
# See which presets you have
|
||||
soundtouch-cli --host 192.168.1.100 preset list
|
||||
soundtouch-cli --host 192.0.2.100 preset list
|
||||
|
||||
# Remove one you don't need
|
||||
soundtouch-cli --host 192.168.1.100 preset remove --slot 6
|
||||
soundtouch-cli --host 192.0.2.100 preset remove --slot 6
|
||||
|
||||
# Or overwrite an existing one
|
||||
soundtouch-cli --host 192.168.1.100 preset store-current --slot 6
|
||||
soundtouch-cli --host 192.0.2.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.168.1.100 play now`
|
||||
2. **Check what's playing**: `soundtouch-cli --host 192.0.2.100 play now`
|
||||
3. **Copy the location** from the output
|
||||
|
||||
### Device Connection Issues
|
||||
```bash
|
||||
# Test connection first
|
||||
soundtouch-cli --host 192.168.1.100 info
|
||||
soundtouch-cli --host 192.0.2.100 info
|
||||
|
||||
# If that fails, check:
|
||||
# - Device IP address is correct
|
||||
|
||||
@@ -364,7 +364,7 @@ func main() {
|
||||
|
||||
```go
|
||||
func handleClientProxy(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract device IP from path: /api/client/192.168.1.100/now_playing
|
||||
// Extract device IP from path: /api/client/192.0.2.100/now_playing
|
||||
pathParts := strings.Split(r.URL.Path, "/")
|
||||
if len(pathParts) < 5 {
|
||||
http.Error(w, "Invalid path", http.StatusBadRequest)
|
||||
|
||||
@@ -15,14 +15,14 @@ The current request recording system has fundamental issues when dealing with re
|
||||
|
||||
**Local Recording** (complete):
|
||||
```http
|
||||
### POST /v1/scmudc/A81B6A536A98
|
||||
POST /v1/scmudc/A81B6A536A98
|
||||
### POST /v1/scmudc/AABBCCDDEEFF
|
||||
POST /v1/scmudc/AABBCCDDEEFF
|
||||
Host: events.api.bosecm.com
|
||||
Content-Type: text/json; charset=utf-8
|
||||
Content-Length: 587
|
||||
Authorization: Bearer jGwEmFWr...
|
||||
|
||||
{"envelope":{"monoTime":234906,"payloadProtocolVersion":"3.1","payloadType":"scmudc","protocolVersion":"1.0","time":"2026-02-25T23:03:14.976349+00:00","uniqueId":"A81B6A536A98"},"payload":{"deviceInfo":{"boseID":"3230304","deviceID":"A81B6A536A98","deviceType":"SoundTouch 10","serialNumber":"I6332527703739342000020","softwareVersion":"27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29","systemSerialNumber":"069231P63364828AE"},"events":[{"data":{"play-state":"PAUSE_STATE"},"monoTime":234904,"time":"2026-02-25T23:03:14.973466+00:00","type":"play-state-changed"}]}}
|
||||
{"envelope":{"monoTime":234906,"payloadProtocolVersion":"3.1","payloadType":"scmudc","protocolVersion":"1.0","time":"2026-02-25T23:03:14.976349+00:00","uniqueId":"AABBCCDDEEFF"},"payload":{"deviceInfo":{"boseID":"1000001","deviceID":"AABBCCDDEEFF","deviceType":"SoundTouch 10","serialNumber":"I6332527703739342000020","softwareVersion":"27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29","systemSerialNumber":"069231P63364828AE"},"events":[{"data":{"play-state":"PAUSE_STATE"},"monoTime":234904,"time":"2026-02-25T23:03:14.973466+00:00","type":"play-state-changed"}]}}
|
||||
|
||||
{% raw %}
|
||||
> {%
|
||||
@@ -33,8 +33,8 @@ Authorization: Bearer jGwEmFWr...
|
||||
|
||||
**Mirror Recording** (missing body):
|
||||
```http
|
||||
### POST /v1/scmudc/A81B6A536A98
|
||||
POST /v1/scmudc/A81B6A536A98
|
||||
### POST /v1/scmudc/AABBCCDDEEFF
|
||||
POST /v1/scmudc/AABBCCDDEEFF
|
||||
Host: events.api.bosecm.com
|
||||
Content-Type: text/json; charset=utf-8
|
||||
Content-Length: 587
|
||||
|
||||
@@ -59,8 +59,8 @@ Based on analysis of recorded data:
|
||||
|
||||
### Before (Raw)
|
||||
```http
|
||||
### POST /v1/scmudc/A81B6A536A98
|
||||
POST /v1/scmudc/A81B6A536A98
|
||||
### POST /v1/scmudc/AABBCCDDEEFF
|
||||
POST /v1/scmudc/AABBCCDDEEFF
|
||||
Host: events.api.bosecm.com
|
||||
...
|
||||
|
||||
@@ -69,7 +69,7 @@ Host: events.api.bosecm.com
|
||||
|
||||
### After (Enriched)
|
||||
```http
|
||||
### POST /v1/scmudc/A81B6A536A98
|
||||
### POST /v1/scmudc/AABBCCDDEEFF
|
||||
// Origin: Internal System (device)
|
||||
// Action: play-item
|
||||
// Command: Billie Eilish - bad guy (instrumental version)
|
||||
@@ -87,7 +87,7 @@ Host: events.api.bosecm.com
|
||||
// <itemName>Billie Eilish - bad guy (instrumental version)</itemName>
|
||||
// <containerArt>https://i.scdn.co/image/ab67616d0000b273...</containerArt>
|
||||
// </ContentItem>
|
||||
POST /v1/scmudc/A81B6A536A98
|
||||
POST /v1/scmudc/AABBCCDDEEFF
|
||||
...
|
||||
|
||||
{% raw %}
|
||||
|
||||
@@ -121,7 +121,7 @@ sa.GetUnavailableServiceCount()
|
||||
### Basic Usage
|
||||
|
||||
```go
|
||||
client := client.NewClientFromHost("192.168.1.100")
|
||||
client := client.NewClientFromHost("192.0.2.100")
|
||||
|
||||
serviceAvailability, err := client.GetServiceAvailability()
|
||||
if err != nil {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 🎉 Introducing SoundTouch Service: Local Cloud Service Emulation
|
||||
|
||||
**Date**: January 2024
|
||||
**Version**: v2.0.0+
|
||||
**Date**: February 2026
|
||||
**Version**: v2.0.0+
|
||||
**Status**: Production Ready
|
||||
|
||||
## What's New?
|
||||
@@ -68,7 +68,7 @@ Our implementation is heavily inspired by and based on [SoundCork](https://githu
|
||||
|
||||
**Key contributions from SoundCork:**
|
||||
- Service emulation architecture
|
||||
- BMX/Marge endpoint discovery
|
||||
- BMX/Marge endpoint discovery
|
||||
- Device migration strategies
|
||||
- Python implementation reference
|
||||
|
||||
@@ -137,7 +137,7 @@ LOG_PROXY_BODY=true soundtouch-service
|
||||
## 🚀 Future Plans
|
||||
|
||||
- **Docker Images**: Official container images for easy deployment
|
||||
- **Cluster Support**: Multi-instance deployment for high availability
|
||||
- **Cluster Support**: Multi-instance deployment for high availability
|
||||
- **Advanced Analytics**: Machine learning-powered usage insights
|
||||
- **Extended Protocol Support**: Additional Bose protocol implementations
|
||||
- **Mobile App**: Companion mobile application for device management
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
* [Request Recording](REQUEST_RECORDING_CONCEPT.md)
|
||||
* [Spotify Priming Strategy](concepts/spotify-priming-strategy.md)
|
||||
* [Spotify OAuth](concepts/spotify-oauth.md)
|
||||
* [soundtouch-web Roadmap](soundtouch-web-roadmap.md)
|
||||
|
||||
## Analysis & Research
|
||||
* [API Coverage Analysis](analysis/API-COVERAGE.md)
|
||||
@@ -61,6 +62,10 @@
|
||||
* [Upstream URLs](analysis/UPSTREAM-URLS.md)
|
||||
* [Anonymization Summary](analysis/ANONYMIZATION-SUMMARY.md)
|
||||
* [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md)
|
||||
* [Telnet (Port 17000) Migration Method](analysis/TELNET-MIGRATION-METHOD.md)
|
||||
* [Telnet Command Reference](analysis/TELNET-COMMAND-REFERENCE.md)
|
||||
* [Setup WebSocket Experiment](analysis/SETUP-WEBSOCKET-EXPERIMENT.md)
|
||||
* [Factory Reset Protocol](analysis/FACTORY-RESET-PROTOCOL.md)
|
||||
* [Wiki API Comparison](analysis/WIKI-COMPARISON.md)
|
||||
* [IoT Config Summary](analysis/IOT-CONFIG-SUMMARY.md)
|
||||
* [IoT Configuration Analysis](analysis/IOT-CONFIGURATION-ANALYSIS.md)
|
||||
@@ -100,3 +105,4 @@
|
||||
* [SCMUDC Events Analysis](scmudc-events-analysis.md)
|
||||
* [Parity Improvements](PARITY-IMPROVEMENTS.md)
|
||||
* [Parity SoundCork](PARITY-SOUNDCORK.md)
|
||||
* [Stockholm Port Guide](stockholm-port-guide.md)
|
||||
|
||||
@@ -275,8 +275,8 @@ Returns detected UPnP/DLNA media servers.
|
||||
**Response Example:**
|
||||
```xml
|
||||
<ListMediaServersResponse>
|
||||
<media_server id="2f402f80-da50-11e1-9b23-123456789012" mac="0017886e13fe" ip="192.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" />
|
||||
<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" />
|
||||
</ListMediaServersResponse>
|
||||
```
|
||||
|
||||
@@ -641,15 +641,15 @@ Gets current stereo pair configuration.
|
||||
<groupRole>
|
||||
<deviceId>9070658C9D4A</deviceId>
|
||||
<role>LEFT</role>
|
||||
<ipAddress>192.168.1.131</ipAddress>
|
||||
<ipAddress>192.0.2.131</ipAddress>
|
||||
</groupRole>
|
||||
<groupRole>
|
||||
<deviceId>F45EAB3115DA</deviceId>
|
||||
<role>RIGHT</role>
|
||||
<ipAddress>192.168.1.134</ipAddress>
|
||||
<ipAddress>192.0.2.134</ipAddress>
|
||||
</groupRole>
|
||||
</roles>
|
||||
<senderIPAddress>192.168.1.131</senderIPAddress>
|
||||
<senderIPAddress>192.0.2.131</senderIPAddress>
|
||||
<status>GROUP_OK</status>
|
||||
</group>
|
||||
```
|
||||
@@ -671,12 +671,12 @@ Creates new stereo pair group.
|
||||
<groupRole>
|
||||
<deviceId>9070658C9D4A</deviceId>
|
||||
<role>LEFT</role>
|
||||
<ipAddress>192.168.1.131</ipAddress>
|
||||
<ipAddress>192.0.2.131</ipAddress>
|
||||
</groupRole>
|
||||
<groupRole>
|
||||
<deviceId>F45EAB3115DA</deviceId>
|
||||
<role>RIGHT</role>
|
||||
<ipAddress>192.168.1.134</ipAddress>
|
||||
<ipAddress>192.0.2.134</ipAddress>
|
||||
</groupRole>
|
||||
</roles>
|
||||
</group>
|
||||
@@ -707,12 +707,12 @@ Updates stereo pair group name.
|
||||
<groupRole>
|
||||
<deviceId>9070658C9D4A</deviceId>
|
||||
<role>LEFT</role>
|
||||
<ipAddress>192.168.1.131</ipAddress>
|
||||
<ipAddress>192.0.2.131</ipAddress>
|
||||
</groupRole>
|
||||
<groupRole>
|
||||
<deviceId>F45EAB3115DA</deviceId>
|
||||
<role>RIGHT</role>
|
||||
<ipAddress>192.168.1.134</ipAddress>
|
||||
<ipAddress>192.0.2.134</ipAddress>
|
||||
</groupRole>
|
||||
</roles>
|
||||
</group>
|
||||
@@ -762,7 +762,7 @@ Returns network status configuration.
|
||||
<name>eth0</name>
|
||||
<mac-addr>1004567890AA</mac-addr>
|
||||
<bindings>
|
||||
<ipv4address>192.168.1.131</ipv4address>
|
||||
<ipv4address>192.0.2.131</ipv4address>
|
||||
</bindings>
|
||||
<running>true</running>
|
||||
<kind>Wireless</kind>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{%- comment -%}
|
||||
Render Mermaid diagrams in docs pages.
|
||||
|
||||
Markdown ```mermaid fenced blocks are emitted by Kramdown as
|
||||
<pre><code class="language-mermaid">…</code></pre>, but Mermaid only
|
||||
auto-renders elements with class="mermaid". This snippet rewrites the
|
||||
pre/code nodes into div.mermaid before initialising the library.
|
||||
|
||||
Loaded as an ES module from the jsDelivr CDN so we don't have to vendor
|
||||
the library into the repo. Pinned to a major version for cache stability.
|
||||
{%- endcomment -%}
|
||||
<script type="module">
|
||||
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
|
||||
|
||||
document.querySelectorAll('pre > code.language-mermaid').forEach((code) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'mermaid';
|
||||
div.textContent = code.textContent;
|
||||
code.parentElement.replaceWith(div);
|
||||
});
|
||||
|
||||
mermaid.initialize({ startOnLoad: false, securityLevel: 'strict' });
|
||||
mermaid.run();
|
||||
</script>
|
||||
@@ -1,115 +1,78 @@
|
||||
# Data Anonymization Summary
|
||||
# Placeholder values for examples
|
||||
|
||||
This document summarizes all changes made to anonymize personal and specific data throughout the Bose SoundTouch Go client codebase.
|
||||
This repo is public. Documentation, READMEs, example configs, and test
|
||||
fixtures must never carry real LAN IPs, real device MACs, real Bose
|
||||
account IDs, or personal device names from any maintainer or
|
||||
contributor.
|
||||
|
||||
## Overview
|
||||
This file is the **canonical mapping table** for the placeholders we
|
||||
use across the codebase. Use these values in new examples and tests.
|
||||
|
||||
All specific IP addresses, device IDs, device names, and other potentially personal information have been replaced with generic, example values to protect privacy while maintaining the functionality and usefulness of the documentation and test examples.
|
||||
## Placeholder mapping
|
||||
|
||||
## Changes Made
|
||||
| Concept | Placeholder |
|
||||
|------------------------|------------------------------------------------------------------------|
|
||||
| Example IP (primary) | `192.0.2.10` |
|
||||
| Example IP (secondary) | `192.0.2.11` |
|
||||
| Example IP (third) | `192.0.2.12` |
|
||||
| Network / CIDR | `192.0.2.0/24` |
|
||||
| External / non-LAN IP | `198.51.100.10` or `203.0.113.10` |
|
||||
| Gateway IP | `192.0.2.1` |
|
||||
| Device MAC (primary) | `AA:BB:CC:DD:EE:FF` (no separator: `AABBCCDDEEFF`) |
|
||||
| Device MAC (secondary) | `AA:BB:CC:DD:EE:01` (no separator: `AABBCCDDEE01`) |
|
||||
| Device ID (some XML) | `ABCD1234EFGH` — legacy placeholder still in some fixtures |
|
||||
| Device display name | `Living Room SoundTouch` / `Kitchen SoundTouch` / `Bedroom SoundTouch` |
|
||||
| Bose account ID | `1000001` / `1000002` |
|
||||
|
||||
### IP Addresses
|
||||
`192.0.2.0/24`, `198.51.100.0/24`, and `203.0.113.0/24` are reserved
|
||||
by [RFC 5737](https://www.rfc-editor.org/rfc/rfc5737) exclusively for
|
||||
documentation. They won't ever route on a real network, so readers
|
||||
know at a glance that they're placeholders and not addresses they
|
||||
need to think about.
|
||||
|
||||
**Original → Anonymized:**
|
||||
- `192.168.178.35` → `192.168.1.10`
|
||||
- `192.168.178.28` → `192.168.1.10`
|
||||
- `192.168.1.100` → `192.168.1.10`
|
||||
- `192.168.1.101` → `192.168.1.11`
|
||||
- `192.168.1.102` → `192.168.1.12`
|
||||
`AA:BB:CC:DD:EE:FF` is the conventional "locally administered" MAC
|
||||
placeholder used in many vendor docs.
|
||||
|
||||
### Device IDs
|
||||
`1000001` / `1000002` are well outside the range of real Bose customer
|
||||
account IDs (which are typically 6–7 digits with no leading 1 0 0…
|
||||
pattern) but stay numeric for parsers that expect integer-looking IDs.
|
||||
|
||||
**Original → Anonymized:**
|
||||
- `A81B6A536A98` → `ABCD1234EFGH`
|
||||
- `1234567890AB` → `ABCD1234EFGH`
|
||||
- `1234567890AC` → `ABCD1234EFGH`
|
||||
## Why we don't use 192.168.1.x
|
||||
|
||||
### Device Names
|
||||
An earlier anonymisation pass used `192.168.1.x` as its target. That
|
||||
range is RFC-1918 private space — perfectly valid on real networks,
|
||||
which means a reader can't tell whether `192.168.1.10` is a
|
||||
placeholder or a documented LAN address. RFC-5737 ranges fix that:
|
||||
because they're reserved for documentation only, any reader knows on
|
||||
sight that they don't represent a real device.
|
||||
|
||||
**Original → Anonymized:**
|
||||
- `Sound Machinechen` → `My SoundTouch Device`
|
||||
The `.md` / `.txt` portion of the `192.168.1.*` → `192.0.2.x` sweep
|
||||
is complete. Test files (`.go` / `.xml` / `.http`) still carry the
|
||||
old placeholder pending Phase 2 in the audit at
|
||||
`_/RFC-5737-cleanup/assessment.md`.
|
||||
|
||||
### MAC Addresses
|
||||
## How to audit before committing
|
||||
|
||||
**Original → Anonymized:**
|
||||
- `A81B6A536A98` → `AA:BB:CC:DD:EE:FF`
|
||||
- `A81B6A849D99` → `AA:BB:CC:DD:EE:FF`
|
||||
- `A8:1B:6A:53:6A:98` → `AA:BB:CC:DD:EE:FF`
|
||||
- `A8:1B:6A:84:9D:99` → `AA:BB:CC:DD:EE:01`
|
||||
When you add or edit examples that contain IP addresses, MACs, account
|
||||
IDs, or device names, mentally answer: "would I be comfortable
|
||||
publishing this on a postcard?" If not, swap in a placeholder from
|
||||
the table above.
|
||||
|
||||
## Files Modified
|
||||
Some patterns flag clearly-non-placeholder values:
|
||||
|
||||
### Documentation Files
|
||||
- `README.md` - Updated all IP addresses and device examples
|
||||
- `Makefile` - Updated example IP addresses in help text
|
||||
- `docs/SYSTEM-ENDPOINTS.md` - Anonymized all example data
|
||||
- `docs/VOLUME-CONTROLS.md` - Updated device IDs and IP addresses
|
||||
- `docs/KEY-CONTROLS.md` - Updated IP addresses
|
||||
- `docs/BASS-CONTROLS.md` - Updated device IDs
|
||||
- `docs/HOST-PORT-PARSING.md` - Updated IP addresses and device names
|
||||
- `docs/STATUS.md` - Updated IP addresses
|
||||
```sh
|
||||
# Any IPv4 not in a documentation range or the 192.168.1.x default:
|
||||
git ls-files | xargs grep -hoE "[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+" 2>/dev/null \
|
||||
| grep -vE "^(192\.0\.2\.|198\.51\.100\.|203\.0\.113\.|0\.0\.0\.0|127\.0\.0\.1|255\.255\.255\.255|192\.168\.1\.[0-9])" \
|
||||
| sort -u
|
||||
|
||||
### Source Code Files
|
||||
- `cmd/soundtouch-cli/main.go` - Updated all example IP addresses in help text
|
||||
- `cmd/soundtouch-cli/main_test.go` - Updated test IP addresses
|
||||
# Any colon-separated MAC that doesn't start with AA:BB:CC:DD:EE:
|
||||
git ls-files | xargs grep -hoE "[0-9A-F]{2}(:[0-9A-F]{2}){5}" 2>/dev/null \
|
||||
| grep -vE "^AA:BB:CC:DD:EE:" \
|
||||
| sort -u
|
||||
```
|
||||
|
||||
### Test Data Files
|
||||
- `pkg/client/testdata/info_response.xml` - Updated device ID, name, and network info
|
||||
- `pkg/client/testdata/info_response_st20.xml` - Updated device ID and network info
|
||||
- `pkg/client/testdata/capabilities_response.xml` - Updated device ID
|
||||
- `pkg/client/testdata/name_response.xml` - Updated device name
|
||||
- `pkg/client/testdata/networkinfo_response.xml` - Updated device ID and network info
|
||||
- `pkg/client/testdata/clockdisplay_response.xml` - Updated device ID
|
||||
|
||||
### Test Files
|
||||
- `pkg/client/client_test.go` - Updated device IDs, names, and IP addresses
|
||||
- `pkg/client/system_test.go` - Updated device IDs and IP addresses
|
||||
- `pkg/client/balance_test.go` - Updated device IDs in test responses
|
||||
- `pkg/client/bass_test.go` - Updated device IDs in test responses
|
||||
- `pkg/models/networkinfo_test.go` - Updated device IDs and network info
|
||||
|
||||
## Anonymization Strategy
|
||||
|
||||
### IP Addresses
|
||||
- Used standard RFC 1918 private IP ranges (192.168.1.x)
|
||||
- Maintained realistic network structure (same subnet for related devices)
|
||||
- Used sequential numbering (.10, .11, .12) for clarity
|
||||
|
||||
### Device IDs
|
||||
- Used generic alphanumeric pattern `ABCD1234EFGH`
|
||||
- Maintained consistent usage across all files
|
||||
- Preserved original length and format
|
||||
|
||||
### Device Names
|
||||
- Used generic but descriptive names like "My SoundTouch Device"
|
||||
- Removed any potentially personal identifiers
|
||||
|
||||
### MAC Addresses
|
||||
- Used standard placeholder format `AA:BB:CC:DD:EE:FF`
|
||||
- Used sequential variants (EE:01) when multiple addresses needed
|
||||
- Maintained proper MAC address format
|
||||
|
||||
## Verification
|
||||
|
||||
After anonymization:
|
||||
- ✅ All tests continue to pass
|
||||
- ✅ All builds succeed
|
||||
- ✅ Documentation remains accurate and useful
|
||||
- ✅ No personal data remains in examples
|
||||
- ✅ Functionality is preserved
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Privacy Protection**: No personal network information exposed
|
||||
2. **Professional Examples**: Clean, generic examples suitable for public documentation
|
||||
3. **Consistency**: Uniform use of example data across all files
|
||||
4. **Maintainability**: Easy to identify example vs. real data
|
||||
|
||||
## Standards Used
|
||||
|
||||
- **IP Addresses**: RFC 1918 private ranges (192.168.1.x/24)
|
||||
- **Device IDs**: Generic alphanumeric placeholders
|
||||
- **MAC Addresses**: Standard placeholder format
|
||||
- **Device Names**: Generic descriptive names
|
||||
|
||||
All changes maintain the original functionality while ensuring no personal or specific network information is exposed in the codebase.
|
||||
If real values slip into a commit, treat it as a sanitisation task:
|
||||
revert or fix, then audit nearby files for sibling leaks. Personal
|
||||
device names and Bose account IDs don't have a regex-friendly shape —
|
||||
catch those at review time.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Bose SoundTouch API Coverage Analysis
|
||||
|
||||
**Last Updated:** January 2025
|
||||
**API Version:** Official Bose SoundTouch Web API v1.0
|
||||
**Last Updated:** February 2026
|
||||
**API Version:** Official Bose SoundTouch Web API v1.0
|
||||
**Implementation Status:** 100% Official Coverage + Extended Features
|
||||
|
||||
## Executive Summary
|
||||
@@ -10,7 +10,7 @@ This Go implementation provides **complete coverage** of the Bose SoundTouch Web
|
||||
|
||||
### Key Findings
|
||||
- ✅ **All essential user functionality implemented**
|
||||
- ✅ **Complete zone management implementation**
|
||||
- ✅ **Complete zone management implementation**
|
||||
- ✅ **Real-time WebSocket event system**
|
||||
- ✅ **Extended features beyond official specification**
|
||||
- ✅ **Complete advanced audio controls implementation**
|
||||
@@ -22,42 +22,42 @@ This Go implementation provides **complete coverage** of the Bose SoundTouch Web
|
||||
|
||||
### Implemented Endpoints: 20/21 (95%)
|
||||
|
||||
| Endpoint | Method | Status | Implementation | Notes |
|
||||
|----------|--------|--------|----------------|--------|
|
||||
| `/key` | POST | ✅ **Complete** | `SendKey()`, `SendKeyPress()`, `SendKeyRelease()` | Full key simulation with press/release states |
|
||||
| `/select` | POST | ✅ **Complete** | `SelectSource()`, `SelectSpotify()`, etc. | Source selection with validation |
|
||||
| `/sources` | GET | ✅ **Complete** | `GetSources()` | Available audio sources |
|
||||
| `/bassCapabilities` | GET | ✅ **Complete** | `GetBassCapabilities()` | Bass capability detection |
|
||||
| `/bass` | GET/POST | ✅ **Complete** | `GetBass()`, `SetBass()`, `SetBassSafe()` | Bass control (-9 to +9) with safety limits |
|
||||
| `/getZone` | GET | ✅ **Complete** | `GetZone()`, `GetZoneStatus()`, `GetZoneMembers()` | Multiroom zone information |
|
||||
| `/setZone` | POST | ✅ **Complete** | `SetZone()`, `CreateZone()`, `AddToZone()`, `RemoveFromZone()` | Zone configuration and management |
|
||||
| `/now_playing` | GET | ✅ **Complete** | `GetNowPlaying()` | Current playback status with full metadata |
|
||||
| `/trackInfo` | GET | ❌ **Non-functional** | `GetTrackInfo()` | Documented but times out on real devices |
|
||||
| `/volume` | GET/POST | ✅ **Complete** | `GetVolume()`, `SetVolume()`, `SetVolumeSafe()` | Volume and mute control with safety features |
|
||||
| `/presets` | GET | ✅ **Complete** | `GetPresets()`, `GetNextAvailablePresetSlot()` | Preset configurations (read-only per API spec) |
|
||||
| `/info` | GET | ✅ **Complete** | `GetDeviceInfo()` | Device information and capabilities |
|
||||
| `/name` | POST | ✅ **Complete** | `SetName()` | Device name modification |
|
||||
| `/capabilities` | GET | ✅ **Complete** | `GetCapabilities()` | Device feature capabilities |
|
||||
| `/addZoneSlave` | POST | ✅ **Complete** | `AddZoneSlave()`, `AddZoneSlaveByDeviceID()` | Individual device addition to zone |
|
||||
| `/removeZoneSlave` | POST | ✅ **Complete** | `RemoveZoneSlave()`, `RemoveZoneSlaveByDeviceID()` | Individual device removal from zone |
|
||||
| `/audiodspcontrols` | GET/POST | ✅ **Complete** | `GetAudioDSPControls()`, `SetAudioDSPControls()`, `SetAudioMode()`, `SetVideoSyncAudioDelay()` | DSP audio modes and video sync delay |
|
||||
| `/audioproducttonecontrols` | GET/POST | ✅ **Complete** | `GetAudioProductToneControls()`, `SetAudioProductToneControls()`, `SetAdvancedBass()`, `SetAdvancedTreble()` | Advanced bass/treble controls |
|
||||
| `/audioproductlevelcontrols` | GET/POST | ✅ **Complete** | `GetAudioProductLevelControls()`, `SetAudioProductLevelControls()`, `SetFrontCenterSpeakerLevel()`, `SetRearSurroundSpeakersLevel()` | Speaker level controls |
|
||||
| `/speaker` | POST | ✅ **Complete** | `PlayTTS()`, `PlayURL()`, `PlayCustom()` | TTS and URL content playback for notifications |
|
||||
| `/playNotification` | GET | ✅ **Complete** | `PlayNotificationBeep()` | Simple notification beep sound |
|
||||
| Endpoint | Method | Status | Implementation | Notes |
|
||||
|------------------------------|----------|----------------------|--------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------|
|
||||
| `/key` | POST | ✅ **Complete** | `SendKey()`, `SendKeyPress()`, `SendKeyRelease()` | Full key simulation with press/release states |
|
||||
| `/select` | POST | ✅ **Complete** | `SelectSource()`, `SelectSpotify()`, etc. | Source selection with validation |
|
||||
| `/sources` | GET | ✅ **Complete** | `GetSources()` | Available audio sources |
|
||||
| `/bassCapabilities` | GET | ✅ **Complete** | `GetBassCapabilities()` | Bass capability detection |
|
||||
| `/bass` | GET/POST | ✅ **Complete** | `GetBass()`, `SetBass()`, `SetBassSafe()` | Bass control (-9 to +9) with safety limits |
|
||||
| `/getZone` | GET | ✅ **Complete** | `GetZone()`, `GetZoneStatus()`, `GetZoneMembers()` | Multiroom zone information |
|
||||
| `/setZone` | POST | ✅ **Complete** | `SetZone()`, `CreateZone()`, `AddToZone()`, `RemoveFromZone()` | Zone configuration and management |
|
||||
| `/now_playing` | GET | ✅ **Complete** | `GetNowPlaying()` | Current playback status with full metadata |
|
||||
| `/trackInfo` | GET | ❌ **Non-functional** | `GetTrackInfo()` | Documented but times out on real devices |
|
||||
| `/volume` | GET/POST | ✅ **Complete** | `GetVolume()`, `SetVolume()`, `SetVolumeSafe()` | Volume and mute control with safety features |
|
||||
| `/presets` | GET | ✅ **Complete** | `GetPresets()`, `GetNextAvailablePresetSlot()` | Preset configurations (read-only per API spec) |
|
||||
| `/info` | GET | ✅ **Complete** | `GetDeviceInfo()` | Device information and capabilities |
|
||||
| `/name` | POST | ✅ **Complete** | `SetName()` | Device name modification |
|
||||
| `/capabilities` | GET | ✅ **Complete** | `GetCapabilities()` | Device feature capabilities |
|
||||
| `/addZoneSlave` | POST | ✅ **Complete** | `AddZoneSlave()`, `AddZoneSlaveByDeviceID()` | Individual device addition to zone |
|
||||
| `/removeZoneSlave` | POST | ✅ **Complete** | `RemoveZoneSlave()`, `RemoveZoneSlaveByDeviceID()` | Individual device removal from zone |
|
||||
| `/audiodspcontrols` | GET/POST | ✅ **Complete** | `GetAudioDSPControls()`, `SetAudioDSPControls()`, `SetAudioMode()`, `SetVideoSyncAudioDelay()` | DSP audio modes and video sync delay |
|
||||
| `/audioproducttonecontrols` | GET/POST | ✅ **Complete** | `GetAudioProductToneControls()`, `SetAudioProductToneControls()`, `SetAdvancedBass()`, `SetAdvancedTreble()` | Advanced bass/treble controls |
|
||||
| `/audioproductlevelcontrols` | GET/POST | ✅ **Complete** | `GetAudioProductLevelControls()`, `SetAudioProductLevelControls()`, `SetFrontCenterSpeakerLevel()`, `SetRearSurroundSpeakersLevel()` | Speaker level controls |
|
||||
| `/speaker` | POST | ✅ **Complete** | `PlayTTS()`, `PlayURL()`, `PlayCustom()` | TTS and URL content playback for notifications |
|
||||
| `/playNotification` | GET | ✅ **Complete** | `PlayNotificationBeep()` | Simple notification beep sound |
|
||||
|
||||
### Non-functional Endpoints: 1/21 (5%)
|
||||
|
||||
| Endpoint | Method | Status | Reason | Impact |
|
||||
|----------|--------|--------|--------|---------|
|
||||
| `/trackInfo` | GET | ❌ **Non-functional** | Times out on real devices (AllegroWebserver timeout) | **None** - Use `/now_playing` instead |
|
||||
| Endpoint | Method | Status | Reason | Impact |
|
||||
|--------------|--------|----------------------|------------------------------------------------------|---------------------------------------|
|
||||
| `/trackInfo` | GET | ❌ **Non-functional** | Times out on real devices (AllegroWebserver timeout) | **None** - Use `/now_playing` instead |
|
||||
|
||||
### Official Endpoints Not Supported by API: 1
|
||||
|
||||
| Endpoint | Method | Status | Official API Status |
|
||||
|----------|--------|--------|-------------------|
|
||||
| `/storePreset` | POST | ✅ **IMPLEMENTED** | Found via [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API) (official docs marked `/presets` POST as "N/A") |
|
||||
| `/removePreset` | POST | ✅ **IMPLEMENTED** | Found via [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API) |
|
||||
| Endpoint | Method | Status | Official API Status |
|
||||
|-----------------|--------|-------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `/storePreset` | POST | ✅ **IMPLEMENTED** | Found via [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API) (official docs marked `/presets` POST as "N/A") |
|
||||
| `/removePreset` | POST | ✅ **IMPLEMENTED** | Found via [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API) |
|
||||
|
||||
---
|
||||
|
||||
@@ -67,24 +67,24 @@ This Go implementation provides **complete coverage** of the Bose SoundTouch Web
|
||||
|
||||
**Note**: The `/speaker` and `/playNotification` endpoints were discovered via the [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API) and are now part of the official coverage.
|
||||
|
||||
| Endpoint | Method | Status | Notes |
|
||||
|----------|--------|--------|--------|
|
||||
| `/name` | GET | 🔍 **Extra** | Official API only documents POST, but GET works with real hardware |
|
||||
| `/balance` | GET/POST | 🔍 **Extra** | Stereo balance control (-50 to +50) - not in API v1.0 |
|
||||
| `/clockTime` | GET/POST | 🔍 **Extra** | Device time management - works with real devices |
|
||||
| `/clockDisplay` | GET/POST | 🔍 **Extra** | Clock display settings and brightness |
|
||||
| `/networkInfo` | GET | 🔍 **Extra** | Network connectivity information |
|
||||
| Endpoint | Method | Status | Notes |
|
||||
|-----------------|----------|--------------|--------------------------------------------------------------------|
|
||||
| `/name` | GET | 🔍 **Extra** | Official API only documents POST, but GET works with real hardware |
|
||||
| `/balance` | GET/POST | 🔍 **Extra** | Stereo balance control (-50 to +50) - not in API v1.0 |
|
||||
| `/clockTime` | GET/POST | 🔍 **Extra** | Device time management - works with real devices |
|
||||
| `/clockDisplay` | GET/POST | 🔍 **Extra** | Clock display settings and brightness |
|
||||
| `/networkInfo` | GET | 🔍 **Extra** | Network connectivity information |
|
||||
|
||||
### Advanced Implementation Features
|
||||
|
||||
| Feature | Status | Description |
|
||||
|---------|--------|-------------|
|
||||
| **WebSocket Events** | ✅ **Complete** | Real-time device state monitoring (`nowPlayingUpdated`, `volumeUpdated`, etc.) |
|
||||
| **Device Discovery** | ✅ **Complete** | UPnP/SSDP + mDNS/Bonjour automatic discovery |
|
||||
| **Safety Features** | ✅ **Enhanced** | Volume limiting, bass clamping, input validation |
|
||||
| **High-Level Zone API** | ✅ **Superior** | Fluent zone management API replacing low-level slave operations |
|
||||
| **Preset Management** | ✅ **Wiki Documented** | Full preset CRUD via `/storePreset` and `/removePreset` endpoints (found via SoundTouch Plus Wiki) |
|
||||
| **Content Navigation** | ✅ **Complete** | Browse and search content via `/navigate`, `/searchStation`, `/addStation` (via SoundTouch Plus Wiki) |
|
||||
| Feature | Status | Description |
|
||||
|-------------------------|-----------------------|-------------------------------------------------------------------------------------------------------|
|
||||
| **WebSocket Events** | ✅ **Complete** | Real-time device state monitoring (`nowPlayingUpdated`, `volumeUpdated`, etc.) |
|
||||
| **Device Discovery** | ✅ **Complete** | UPnP/SSDP + mDNS/Bonjour automatic discovery |
|
||||
| **Safety Features** | ✅ **Enhanced** | Volume limiting, bass clamping, input validation |
|
||||
| **High-Level Zone API** | ✅ **Superior** | Fluent zone management API replacing low-level slave operations |
|
||||
| **Preset Management** | ✅ **Wiki Documented** | Full preset CRUD via `/storePreset` and `/removePreset` endpoints (found via SoundTouch Plus Wiki) |
|
||||
| **Content Navigation** | ✅ **Complete** | Browse and search content via `/navigate`, `/searchStation`, `/addStation` (via SoundTouch Plus Wiki) |
|
||||
|
||||
---
|
||||
|
||||
@@ -95,17 +95,17 @@ This Go implementation provides **complete coverage** of the Bose SoundTouch Web
|
||||
**Official Low-Level API:**
|
||||
```go
|
||||
// Individual slave operations (exact official API implementation)
|
||||
client.AddZoneSlave("MASTER123", "SLAVE456", "192.168.1.101")
|
||||
client.RemoveZoneSlave("MASTER123", "SLAVE456", "192.168.1.101")
|
||||
client.AddZoneSlave("MASTER123", "SLAVE456", "192.0.2.101")
|
||||
client.RemoveZoneSlave("MASTER123", "SLAVE456", "192.0.2.101")
|
||||
```
|
||||
|
||||
**Enhanced High-Level API:**
|
||||
```go
|
||||
// High-level fluent API (enhanced implementation)
|
||||
zone := client.CreateZoneWithIPs("192.168.1.100", []string{"192.168.1.101", "192.168.1.102"})
|
||||
client.AddToZone("192.168.1.100", "192.168.1.103")
|
||||
client.RemoveFromZone("192.168.1.100", "192.168.1.101")
|
||||
client.DissolveZone("192.168.1.100")
|
||||
zone := client.CreateZoneWithIPs("192.0.2.100", []string{"192.0.2.101", "192.0.2.102"})
|
||||
client.AddToZone("192.0.2.100", "192.0.2.103")
|
||||
client.RemoveFromZone("192.0.2.100", "192.0.2.101")
|
||||
client.DissolveZone("192.0.2.100")
|
||||
```
|
||||
|
||||
**Advantages:**
|
||||
@@ -221,4 +221,4 @@ The single non-functional endpoint (`/trackInfo`) is **broken on real devices**
|
||||
|
||||
**Note**: All official API endpoints are implemented. The `/trackInfo` endpoint times out on real devices but is implemented and tested.
|
||||
|
||||
**Overall Assessment: Complete** ⭐⭐⭐⭐⭐
|
||||
**Overall Assessment: Complete** ⭐⭐⭐⭐⭐
|
||||
|
||||
@@ -26,7 +26,7 @@ Read on for the full manual walkthrough and the rationale behind each step.
|
||||
|
||||
> **BLE limitation**: Android emulators do not expose Bluetooth hardware. The Bose app's default setup path (BLE Wi-Fi provisioning) therefore cannot be used to configure a factory-reset speaker from the emulator. Use **AP mode** instead: provision the speaker's Wi-Fi credentials via the Mac command line first (see [DEVICE-INITIAL-SETUP.md § 6](../guides/DEVICE-INITIAL-SETUP.md)), then the app can discover the already-networked speaker via mDNS/SSDP without BLE.
|
||||
|
||||
> **Emulator ↔ local network**: The emulator routes all traffic through the Mac's active network interface. Once the speaker is on the same LAN as the Mac, the emulator can reach it at its normal LAN IP (e.g. `192.168.1.50`) — no extra routing is needed. Use `adb shell ping 192.168.1.50` to confirm reachability.
|
||||
> **Emulator ↔ local network**: The emulator routes all traffic through the Mac's active network interface. Once the speaker is on the same LAN as the Mac, the emulator can reach it at its normal LAN IP (e.g. `192.0.2.50`) — no extra routing is needed. Use `adb shell ping 192.0.2.50` to confirm reachability.
|
||||
|
||||
Add Android SDK tools to your PATH (add to `~/.zshrc`):
|
||||
|
||||
@@ -149,13 +149,13 @@ Find your Mac's local IP:
|
||||
|
||||
```bash
|
||||
ipconfig getifaddr en0
|
||||
# e.g. 192.168.1.123
|
||||
# e.g. 192.0.2.123
|
||||
```
|
||||
|
||||
Set the proxy:
|
||||
|
||||
```bash
|
||||
adb -s emulator-5554 shell settings put global http_proxy 192.168.1.123:8080
|
||||
adb -s emulator-5554 shell settings put global http_proxy 192.0.2.123:8080
|
||||
```
|
||||
|
||||
---
|
||||
@@ -214,7 +214,7 @@ Edit `/tmp/config.js` and set:
|
||||
```javascript
|
||||
const CERT_PEM = `<contents of ~/.mitmproxy/mitmproxy-ca-cert.pem>`;
|
||||
|
||||
const PROXY_HOST = '192.168.1.123'; // your Mac IP
|
||||
const PROXY_HOST = '192.0.2.123'; // your Mac IP
|
||||
const PROXY_PORT = 8080;
|
||||
```
|
||||
|
||||
@@ -251,8 +251,8 @@ Expected output in the Frida REPL:
|
||||
|
||||
```
|
||||
== System certificate trust injected ==
|
||||
== Proxy system configuration overridden to 192.168.1.123:8080 ==
|
||||
== Proxy configuration overridden to 192.168.1.123:8080 ==
|
||||
== Proxy system configuration overridden to 192.0.2.123:8080 ==
|
||||
== Proxy configuration overridden to 192.0.2.123:8080 ==
|
||||
== Certificate unpinning completed ==
|
||||
== Unpinning fallback auto-patcher installed ==
|
||||
```
|
||||
|
||||
@@ -236,11 +236,11 @@ If you cannot see the `Bose-Lab` SSID on your phone:
|
||||
```bash
|
||||
sudo nmcli device set wlan0 managed no
|
||||
```
|
||||
7. **Ghost IP Conflict:** If `ip addr show wlan0` shows both `192.168.10.1` and another IP (like `192.168.178.x`), `hostapd` will fail. This is usually caused by NetworkManager managing the interface. Ensure you've run:
|
||||
7. **Ghost IP Conflict:** If `ip addr show wlan0` shows both `192.168.10.1` and another IP (like `192.0.2.x`), `hostapd` will fail. This is usually caused by NetworkManager managing the interface. Ensure you've run:
|
||||
```bash
|
||||
sudo nmcli device set wlan0 managed no
|
||||
# If the ghost IP is still there, remove it manually:
|
||||
sudo ip addr del 192.168.178.X/24 dev wlan0
|
||||
sudo ip addr del 192.0.2.0/24 dev wlan0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
To enable offline operation or use custom services like **SoundCork** or **ÜberBöse API**, SoundTouch devices must be redirected from Bose's official cloud endpoints to a local or custom server. This document outlines the three known methods to achieve this, gathered from community reverse-engineering efforts in the **SoundCork** and **ÜberBöse API** projects.
|
||||
|
||||
> A fourth, **SSH-free** path — driving the device's diagnostic shell on TCP port 17000 — is being added as a peer to the XML and DNS methods. See **[TELNET-MIGRATION-METHOD.md](TELNET-MIGRATION-METHOD.md)** for the use cases, community findings, and feasibility analysis. The `/etc/hosts` method documented below is now deprecated and will not be exposed in the web UI.
|
||||
|
||||
## Overview of Redirection Targets
|
||||
|
||||
SoundTouch devices primarily communicate with the following domains:
|
||||
@@ -32,19 +34,25 @@ The most robust and granular method involves modifying the device's private conf
|
||||
Requires SSH access to the device.
|
||||
```xml
|
||||
<SoundTouchSdkPrivateCfg>
|
||||
<margeServerUrl>http://192.168.1.10:8000/marge</margeServerUrl>
|
||||
<statsServerUrl>http://192.168.1.10:8000</statsServerUrl>
|
||||
<swUpdateUrl>http://192.168.1.10:8000/updates/soundtouch</swUpdateUrl>
|
||||
<bmxRegistryUrl>http://192.168.1.10:8000/bmx/registry/v1/services</bmxRegistryUrl>
|
||||
<margeServerUrl>http://192.0.2.10:8000</margeServerUrl>
|
||||
<statsServerUrl>http://192.0.2.10:8000</statsServerUrl>
|
||||
<swUpdateUrl>http://192.0.2.10:8000/updates/soundtouch</swUpdateUrl>
|
||||
<bmxRegistryUrl>http://192.0.2.10:8000/bmx/registry/v1/services</bmxRegistryUrl>
|
||||
</SoundTouchSdkPrivateCfg>
|
||||
```
|
||||
|
||||
> **Note on `margeServerUrl`** — `soundtouch-service` mounts the marge endpoints
|
||||
> at the **root** of port 8000, so the URL has no `/marge` suffix.
|
||||
> [`deborahgu/soundcork`](https://github.com/deborahgu/soundcork) routes marge
|
||||
> under a `/marge` sub-path, so users redirecting to soundcork must append it
|
||||
> (`http://192.0.2.10:8000/marge`).
|
||||
|
||||
### Pros & Cons
|
||||
| Pros | Cons |
|
||||
| :--- | :--- |
|
||||
| **Granular Control**: Redirect specific services while leaving others (e.g., updates) intact. | **Requires SSH**: Must have root/SSH access to the device. |
|
||||
| **Persistent**: Survives software updates (usually). | **Syntax Sensitive**: Errors in XML can cause boot issues or service failures. |
|
||||
| **Native**: Uses the device's built-in configuration mechanism. | |
|
||||
| Pros | Cons |
|
||||
|:----------------------------------------------------------------------------------------------|:-------------------------------------------------------------------------------|
|
||||
| **Granular Control**: Redirect specific services while leaving others (e.g., updates) intact. | **Requires SSH**: Must have root/SSH access to the device. |
|
||||
| **Persistent**: Survives software updates (usually). | **Syntax Sensitive**: Errors in XML can cause boot issues or service failures. |
|
||||
| **Native**: Uses the device's built-in configuration mechanism. | |
|
||||
|
||||
---
|
||||
|
||||
@@ -60,17 +68,17 @@ This method uses the standard Linux hosts file to redirect traffic at the networ
|
||||
### Implementation
|
||||
Requires SSH access. Add entries for the target domains:
|
||||
```text
|
||||
192.168.1.10 streaming.bose.com
|
||||
192.168.1.10 updates.bose.com
|
||||
192.168.1.10 stats.bose.com
|
||||
192.0.2.10 streaming.bose.com
|
||||
192.0.2.10 updates.bose.com
|
||||
192.0.2.10 stats.bose.com
|
||||
```
|
||||
|
||||
### Pros & Cons
|
||||
| Pros | Cons |
|
||||
| :--- | :--- |
|
||||
| **Simple**: Easy to understand and implement. | **Requires SSH**: Must have root access. |
|
||||
| Pros | Cons |
|
||||
|:--------------------------------------------------------------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **Simple**: Easy to understand and implement. | **Requires SSH**: Must have root access. |
|
||||
| **Universal**: Affects all processes on the device attempting to reach those domains. | **HTTPS Issues**: Redirecting HTTPS domains to a local IP will cause SSL certificate errors unless the device is patched to skip verification or trust a custom CA. |
|
||||
| | **Brittle**: Some firmware versions may overwrite `/etc/hosts` on reboot. |
|
||||
| | **Brittle**: Some firmware versions may overwrite `/etc/hosts` on reboot. |
|
||||
|
||||
---
|
||||
|
||||
@@ -104,12 +112,12 @@ sed "s#\^https:....bose.\+apigee..net..#http[aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
4. Restore execution permissions and reboot.
|
||||
|
||||
### Pros & Cons
|
||||
| Pros | Cons |
|
||||
| :--- | :--- |
|
||||
| **Bypass Config**: Works even if the firmware ignores XML settings. | **High Risk**: Modifying binaries can lead to permanent bricks or boot loops. |
|
||||
| Pros | Cons |
|
||||
|:------------------------------------------------------------------------------------|:--------------------------------------------------------------------------------------|
|
||||
| **Bypass Config**: Works even if the firmware ignores XML settings. | **High Risk**: Modifying binaries can lead to permanent bricks or boot loops. |
|
||||
| **Hardcoded Redirects**: Can catch URLs that aren't exposed in configuration files. | **Length Constraint**: Custom URLs must fit within the space of the original strings. |
|
||||
| | **Firmware Specific**: Patches must be reapplied after every software update. |
|
||||
| | **Complexity**: Requires understanding of binary structures and potential checksums. |
|
||||
| | **Firmware Specific**: Patches must be reapplied after every software update. |
|
||||
| | **Complexity**: Requires understanding of binary structures and potential checksums. |
|
||||
|
||||
---
|
||||
|
||||
@@ -117,11 +125,11 @@ sed "s#\^https:....bose.\+apigee..net..#http[aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
|
||||
### Summary Table
|
||||
|
||||
| Method | Primary Use Case | Ease | Safety | Persistence | Granularity |
|
||||
| :--- | :--- | :---: | :---: | :---: | :---: |
|
||||
| **XML Config** | Logical service redirection | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
| **`/etc/hosts`** | Quick global DNS override | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐ |
|
||||
| **Binary Patch** | Bypassing hardcoded checks | ⭐ | ⭐ | ⭐ | ⭐⭐⭐ |
|
||||
| Method | Primary Use Case | Ease | Safety | Persistence | Granularity |
|
||||
|:-----------------|:----------------------------|:-----:|:------:|:-----------:|:-----------:|
|
||||
| **XML Config** | Logical service redirection | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
| **`/etc/hosts`** | Quick global DNS override | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐ |
|
||||
| **Binary Patch** | Bypassing hardcoded checks | ⭐ | ⭐ | ⭐ | ⭐⭐⭐ |
|
||||
|
||||
---
|
||||
|
||||
@@ -133,7 +141,7 @@ A common question is whether these methods can be used in isolation or if they m
|
||||
If your firmware does not strictly enforce the `IsItBose` check for the specific URLs you are changing, **Method 1 (XML)** is sufficient. This is the cleanest approach and is used by the `soundtouch-service` migration tool.
|
||||
|
||||
### Scenario B: XML Config + Binary Patching (The "Locked" Case)
|
||||
On some newer firmware versions, even if you change the `<margeServerUrl>` in the XML to `http://192.168.1.10`, the internal library (`libBmxAccountHsm.so`) will validate the string against the hardcoded Bose regex.
|
||||
On some newer firmware versions, even if you change the `<margeServerUrl>` in the XML to `http://192.0.2.10`, the internal library (`libBmxAccountHsm.so`) will validate the string against the hardcoded Bose regex.
|
||||
* **Symptom**: The device ignores the XML setting or fails to connect despite the correct URL being present.
|
||||
* **Solution**: You **must** apply the **Binary Patch (Method 3)** to neutralize the `IsItBose` check *in addition* to the XML change.
|
||||
|
||||
@@ -176,9 +184,10 @@ As suggested by community members, you can configure the device to trust your ow
|
||||
- **Method B (Symlinks)**: Add the certificate to `/etc/ssl/certs/` and create a hash symlink using `c_rehash` (if available) or manual mapping.
|
||||
|
||||
**Pros & Cons**:
|
||||
| Pros | Cons |
|
||||
| :--- | :--- |
|
||||
| **Secure**: Maintains end-to-end encryption. | **Requires SSH**: Must have root access to modify the trust store. |
|
||||
|
||||
| Pros | Cons |
|
||||
|:-------------------------------------------------------|:-----------------------------------------------------------------------|
|
||||
| **Secure**: Maintains end-to-end encryption. | **Requires SSH**: Must have root access to modify the trust store. |
|
||||
| **Clean**: No binary patching required for SSL bypass. | **Update Risk**: Firmware updates might overwrite the `ca-bundle.crt`. |
|
||||
|
||||
### Option 2: SSL Verification Bypass
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# What a SoundTouch speaker does during factory reset
|
||||
|
||||
Observed live on ST10 firmware `27.0.6.46330.5043500` (build `epdbuild.trunk.hepdswbld04.2022-08-04`) on 2026-05-12, by running `soundtouch-cli setup factory-reset` and tailing the speaker's `logread` over SSH. The trace is preserved at `_/logs/factory-reset.txt` for reference.
|
||||
|
||||
## Sequence
|
||||
|
||||
1. **Telnet receives `sys factorydefault`.** The diagnostic shell on port 17000 accepts the command and acknowledges. Some firmwares close the socket as part of the reboot — our CLI's `setup factory-reset` tolerates that as success.
|
||||
|
||||
2. **Speaker DELETEs itself from its marge account.** Before wiping anything, the firmware does:
|
||||
```
|
||||
[MargeStateAssociated] HandleRemoveDeviceRequest - Removing this device from the user's Marge account
|
||||
[MargeClient] RemoveDevice calling Marge Server with https://streaming.bose.com/streaming/account/{accountId}/device/{deviceId}
|
||||
[MargeClient] RemoveDeviceCB - Device removed from the user's Marge account
|
||||
[MargeStateAssociated] HandleRemoveDeviceRequestSuccessCB, Marge returned: {"ok": true}
|
||||
```
|
||||
AfterTouch already handles this — `HandleMargeRemoveDevice` (`pkg/service/handlers/handlers_marge.go:633`) routed via `r.Delete("/device/{device}", …)` in `cmd/soundtouch-service/main.go:955`. The handler calls `marge.RemoveDeviceFromAccount(s.ds, account, device)` and prunes the device from the datastore.
|
||||
|
||||
3. **Speaker notifies its LAN peers.** Two HTTP POSTs to each known peer at `:8090/notification`:
|
||||
```
|
||||
[NotificationSender] SendNotifyLisas_: URL: >>http://192.0.2.122:8090/notification<<, m_msgdata.size(58)
|
||||
[SimpleURLFetcher] multipart/form-data text/xml
|
||||
```
|
||||
~58 bytes of `multipart/form-data` carrying `text/xml`. "Lisas" is the firmware's internal term for LAN peers (devices on the same account on the same network segment). AfterTouch is **not** on this path — it's pure peer-to-peer over the LAN. Peers presumably refresh their account info as a result.
|
||||
|
||||
4. **Local state teardown.** Bluetooth pairings cleared (`BTRemoteDeviceAccess::ClearPrevPairedList`), zone/group state torn down, all source proxies disconnected (`STSAccountProxy::Disconnect Requested` × many).
|
||||
|
||||
5. **Persistence cleanup.** Logs, core dumps wiped (`FactoryDefault: Clearing the CoreDump and BoseLogs … rm -rf /mnt/nv/BoseLog/*`). Notably **NOT wiped**: `/mnt/nv/aftertouch.resolv.conf`, `/mnt/nv/rc.local`'s Aftertouch hook, and `/mnt/nv/BoseApp-Persistence/1/SystemConfigurationDB.xml`. The reset only touches log directories and account-specific persistence under the same `/mnt/nv/BoseApp-Persistence/1/` tree.
|
||||
|
||||
6. **Reboot into setup mode.** Speaker drops Wi-Fi, comes back as its own AP `Bose SoundTouch XXXX` on 192.0.2.1.
|
||||
|
||||
## Implications for migration ordering
|
||||
|
||||
The DELETE in step 2 only reaches AfterTouch if the speaker's `margeURL` already points at AfterTouch *at the moment of reset*. A speaker still pointing at `streaming.bose.com` sends it into the void → AfterTouch keeps a stale `account/{id}/device/{id}` entry until someone manually prunes it.
|
||||
|
||||
Therefore for a clean datastore lifecycle on an already-Bose-paired speaker:
|
||||
|
||||
1. Migrate URLs first (`setup migrate --method=resolv` or `--method=telnet`).
|
||||
2. Reboot to apply.
|
||||
3. Factory reset.
|
||||
4. Re-provision.
|
||||
|
||||
`soundtouch-cli setup plan --reset` currently runs factory-reset first (optimal for already-on-AfterTouch speakers); both `setup plan --reset` and `setup factory-reset` print a one-line note explaining the ordering tradeoff so users can pick the right sequence for their starting state.
|
||||
|
||||
## Implications for AfterTouch behaviour
|
||||
|
||||
- The DELETE handler is already correct; no changes needed.
|
||||
- AfterTouch is invisible to the LAN-peer notification step — that's just LAN HTTP between speakers.
|
||||
- If you build a "consolidate account" / "migrate fleet" feature later, the peer-notification channel is the propagation path the firmware uses internally; AfterTouch doesn't need to do anything analogous.
|
||||
- The persistence layer at `/mnt/nv/` is **factory-reset-resistant**. Our DNS-redirect migration (`setup migrate --method=resolv`) writes there specifically so AfterTouch routing survives a reset. This is intentional — the user can factory-reset a speaker freely without re-running migration.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Are there other peer endpoints the firmware POSTs to besides `:8090/notification`? Worth checking on a 3-speaker LAN.
|
||||
- Does the `:8090/notification` payload format match the format used for play-as-notification audio pushes, or is it a distinct message shape? The "size(58)" byte count is too small for an audio URL but big enough for an XML envelope with an event type.
|
||||
|
||||
If you want either of these answered, capture two synchronised `logread -f` streams from two LAN speakers while one is being reset.
|
||||
|
||||
## Runbook — reset & re-provision an ST10 on AfterTouch
|
||||
|
||||
End-to-end command sequence used during the 2026-05-12 bare-pairing experiment, recorded verbatim from the test session. Replace IPs, SSID, password, service URL, and account ID with your own. Two manual Wi-Fi switches happen between `factory-reset` and `wifi-push` (host joins the speaker's AP) and again between `wifi-push` and `wait-online` (host re-joins home Wi-Fi).
|
||||
|
||||
```bash
|
||||
# === 1. Reconnaissance — confirm what state the speaker is in before touching it. ===
|
||||
|
||||
# Identity, network, sources, presets.
|
||||
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup inspect
|
||||
|
||||
# Green/red status across every migration axis (SSH, telnet, CA, pairing, …).
|
||||
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup verify \
|
||||
--service-url=https://soundtouch.fritz.box
|
||||
|
||||
# What `setup plan --reset` would recommend, so you can preview the sequence.
|
||||
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup plan \
|
||||
--service-url=https://soundtouch.fritz.box --reset
|
||||
|
||||
|
||||
# === 2. Reset and Wi-Fi re-provisioning. ===
|
||||
|
||||
# Tell the speaker to wipe itself. Speaker drops Wi-Fi and reboots into AP mode.
|
||||
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup factory-reset
|
||||
|
||||
# Manual: switch this host to the speaker's setup AP.
|
||||
# macOS: networksetup -setairportnetwork en0 "Bose SoundTouch XXXX"
|
||||
|
||||
# Poll 192.0.2.1:8090/info until the speaker answers (interval=2s, timeout=5m).
|
||||
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup wait-ap
|
||||
|
||||
# Push home Wi-Fi credentials. NOTE the single-quoted password: zsh expands `!`
|
||||
# inside double quotes as history-expansion and will refuse the command.
|
||||
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup wifi-push \
|
||||
--ssid="wifi-name" --pass='a.secure!password'
|
||||
|
||||
# Manual: switch host back to home Wi-Fi.
|
||||
# macOS: networksetup -setairportnetwork en0 "wifi-name" 'a.secure!password'
|
||||
|
||||
# mDNS-poll for the speaker on the home network, matched by deviceID suffix
|
||||
# (which survives the reset since it's the MAC). Returns the new IP.
|
||||
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup wait-online --match=536A98
|
||||
|
||||
|
||||
# === 3. Clock, migrate, pair. From here on use the new IP wait-online reported. ===
|
||||
|
||||
# Set the speaker's wall-clock. `clock set --time=now` fails on FW 27;
|
||||
# `clock now` is the working subcommand.
|
||||
go run ./cmd/soundtouch-cli --host 192.0.2.123 clock now
|
||||
|
||||
# Reboot to clear any half-initialized resolver / NTP state from the wifi-push flap.
|
||||
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup reboot
|
||||
|
||||
# Apply DNS-redirect migration: routes *.bose.com to AfterTouch and installs its CA.
|
||||
# Idempotent; safe to re-run.
|
||||
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup migrate \
|
||||
--service-url=https://soundtouch.fritz.box --method=resolv
|
||||
|
||||
# Reboot again so the envswitch parallel-persistence layer and the resolv hook
|
||||
# both take effect on the next boot.
|
||||
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup reboot
|
||||
|
||||
# Pair the device with an AfterTouch account — bare experiment variant.
|
||||
# Drop --mode=bare and add --name=… / --language=… for the full state-machine variant.
|
||||
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup pair \
|
||||
--mode=bare --account=1111111 --service-url='https://soundtouch.fritz.box'
|
||||
|
||||
|
||||
# === 4. Verify. ===
|
||||
|
||||
# Reboot to verify persistence survives.
|
||||
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup reboot
|
||||
|
||||
# Snapshot the result. margeAccountUUID should still equal --account, and Sources
|
||||
# should list ~14 entries (TUNEIN, RADIO_BROWSER, LOCAL_INTERNET_RADIO,
|
||||
# SPOTIFY slots, AIRPLAY, etc.) materialized by the firmware.
|
||||
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup inspect
|
||||
```
|
||||
|
||||
Total wall-clock for the above on this hardware: roughly 5 minutes including the two manual Wi-Fi switches and three reboots.
|
||||
@@ -0,0 +1,215 @@
|
||||
# Experiment: Does bare `setMargeAccount` work outside the SETUP bracket?
|
||||
|
||||
## Why we are doing this
|
||||
|
||||
Our captured pairing flow (`docs/reference/DEVICE-PAIRING-FLOW.md`) shows the official Bose app always sends `setMargeAccount` *inside* a `SETUP_START` → `SETUP_ENTER` → `SETUP_LEAVE` state-machine bracket over WebSocket. The question this experiment answers:
|
||||
|
||||
> If we open a WebSocket to a factory-reset speaker and send **only** `setMargeAccount` — no surrounding setupState messages — does the device honor it and write its persistence files (`SystemConfigurationDB.xml`, `Sources.xml`) cleanly?
|
||||
|
||||
The answer determines the shape of `PairAccount`:
|
||||
|
||||
- **If YES:** `PairAccount` becomes uniform: WebSocket-first, HTTP `/setMargeAccount` second, telnet `envswitch accountid set` third. One function, one ordering, all callers.
|
||||
- **If NO:** WebSocket pairing is only meaningful inside the full state machine. Factory-reset path uses the state machine; re-pair path keeps today's HTTP→telnet ordering.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- A SoundTouch speaker that has been **factory-reset** and joined to the test Wi-Fi.
|
||||
- Speaker reachable on `:8090` (HTTP API) and `:8080` (WebSocket).
|
||||
- Speaker's runtime marge URL already points at AfterTouch (run the existing telnet URL rewrite first — otherwise the device's downstream POST will land on the dead Bose cloud and we will not be able to distinguish "WS message refused" from "downstream cloud failed").
|
||||
- A free 7-digit account ID — for example, generated via `setup.GenerateAccountID(nil)`.
|
||||
|
||||
## Step 0 — Baseline
|
||||
|
||||
```bash
|
||||
DEVICE=192.168.x.x
|
||||
curl -s http://$DEVICE:8090/info | xmllint --format -
|
||||
curl -s http://$DEVICE:8090/sources | xmllint --format -
|
||||
curl -s http://$DEVICE:8090/presets | xmllint --format -
|
||||
```
|
||||
|
||||
Record:
|
||||
|
||||
- `<margeAccountUUID>` — expect empty on a factory-reset device.
|
||||
- `<margeURL>` — expect the AfterTouch URL (preflight already applied).
|
||||
- `<sources>` — expect a minimal list.
|
||||
- `<presets>` — expect `<presets/>`.
|
||||
|
||||
## Step 1 — Send bare `setMargeAccount` over WebSocket
|
||||
|
||||
Build the CLI once:
|
||||
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
Then run the bare path against the speaker:
|
||||
|
||||
```bash
|
||||
DEVICE=192.168.x.x
|
||||
./build/soundtouch-cli setup pair --host=$DEVICE --account=1234567 --mode=bare
|
||||
```
|
||||
|
||||
What it does:
|
||||
|
||||
1. Reads `/info` to discover `deviceID`, logs the pre-state.
|
||||
2. Opens a WebSocket to `$DEVICE:8080` with the `gabbo` subprotocol.
|
||||
3. Sends exactly one frame — the `setMargeAccount` envelope — **without** any preceding `SETUP_START`/`SETUP_ENTER`.
|
||||
4. Reads frames for up to `--step-timeout=8s` (configurable), looking for an ack referencing our `requestID`.
|
||||
5. Closes the WebSocket, waits 2 s, re-reads `/info`, prints whether `margeAccountUUID` now equals our supplied ID.
|
||||
|
||||
The exact frame sent (built by `setup.SetupSession.SetMargeAccount`):
|
||||
|
||||
```xml
|
||||
<msg><header deviceID="DEVICE_ID" url="setMargeAccount" method="POST"><request requestID="1"/></header><body>
|
||||
<PairDeviceWithAccount>
|
||||
<accountId>1234567</accountId>
|
||||
<userAuthToken>Bearer aftertouch</userAuthToken>
|
||||
</PairDeviceWithAccount>
|
||||
</body></msg>
|
||||
```
|
||||
|
||||
Outcomes the CLI will surface:
|
||||
|
||||
- `Device accepted bare pairing.` (post-`/info` shows our ID) → **bare path works**.
|
||||
- `setMargeAccount: device rejected setMargeAccount: …` → device returned an `<error>` body → **bare path refused explicitly**.
|
||||
- `setMargeAccount: await ack for setMargeAccount: …` (timeout or EOF) → **bare path refused silently**.
|
||||
- `Device did NOT persist the pairing — bare path likely refused silently.` → ack received but persistence didn't follow.
|
||||
|
||||
## Step 2 — Record outcome
|
||||
|
||||
After step 1 (regardless of which branch happened):
|
||||
|
||||
```bash
|
||||
sleep 2
|
||||
curl -s http://$DEVICE:8090/info | grep margeAccountUUID
|
||||
```
|
||||
|
||||
| Observed result | Verdict |
|
||||
|------------------------------------------------------------------------------|-----------------------------|
|
||||
| `<margeAccountUUID>1234567</margeAccountUUID>` appears | **YES** — Option 1 wins |
|
||||
| `<margeAccountUUID></margeAccountUUID>` still empty, no error frame received | Refused silently → **NO** |
|
||||
| Error frame returned (e.g. `<error name="UNSUPPORTED_STATE"/>`) | Refused explicitly → **NO** |
|
||||
| Device drops the WebSocket connection without replying | Refused → **NO** |
|
||||
|
||||
If verdict is YES, also verify the device wrote persistence cleanly. Reboot the device, then:
|
||||
|
||||
```bash
|
||||
ssh root@$DEVICE 'cat /mnt/nv/BoseApp-Persistence/1/SystemConfigurationDB.xml'
|
||||
ssh root@$DEVICE 'cat /mnt/nv/BoseApp-Persistence/1/Sources.xml'
|
||||
curl -s http://$DEVICE:8090/info | grep margeAccountUUID
|
||||
```
|
||||
|
||||
The UUID must still be present after reboot, and `SystemConfigurationDB.xml` must contain `<AccountUUID>1234567</AccountUUID>`. If it survives reboot, **YES** is confirmed.
|
||||
|
||||
## Step 3 — Control: full state machine
|
||||
|
||||
Factory-reset the same speaker again and run the full state machine — the same CLI, `--mode=full`:
|
||||
|
||||
```bash
|
||||
./build/soundtouch-cli setup pair --host=$DEVICE --account=1234567 --mode=full
|
||||
```
|
||||
|
||||
This drives `setup.Manager.ExecuteInitPlan` with `SkipURLRewrite=true`, which runs:
|
||||
|
||||
```
|
||||
SETUP_START
|
||||
SETUP_IDENTIFY_DEVICE_ENTER
|
||||
language sysLanguage=2
|
||||
SETUP_ENTER
|
||||
SETUP_IDENTIFY_DEVICE_LEAVE
|
||||
setMargeAccount …
|
||||
SETUP_LEAVE
|
||||
pushCustomerSupportInfoToMarge
|
||||
```
|
||||
|
||||
The CLI logs every step with status. Confirm `/info`, persistence, and reboot-survival checks pass. If the bare path failed but the full path succeeds, the SETUP bracket is load-bearing — a follow-up bisect (e.g. `SETUP_START + setMargeAccount + SETUP_LEAVE` only) tells us *which* surrounding messages the firmware actually requires.
|
||||
|
||||
## Full reset-and-rebuild loop
|
||||
|
||||
Once the bare/full question is decided, the loop for repeated experiments is:
|
||||
|
||||
```bash
|
||||
# 0. Speaker is currently on home Wi-Fi at $DEVICE.
|
||||
# Capture deviceID-suffix + current SSID first so wait-online and
|
||||
# wifi-push have the right inputs.
|
||||
./build/soundtouch-cli setup inspect --host=$DEVICE
|
||||
./build/soundtouch-cli setup factory-reset --host=$DEVICE
|
||||
|
||||
# 1. Manually switch this host to the speaker's AP (Bose SoundTouch XXXX).
|
||||
# macOS: networksetup -setairportnetwork en0 "Bose SoundTouch XXXX"
|
||||
|
||||
./build/soundtouch-cli setup wait-ap
|
||||
./build/soundtouch-cli setup wifi-push --ssid="$HOME_SSID" --pass="$HOME_PASS"
|
||||
|
||||
# 2. Manually switch this host back to home Wi-Fi.
|
||||
|
||||
./build/soundtouch-cli setup wait-online --match=DE4803 # deviceID suffix from /info before reset
|
||||
# (note the new IP from the "Speaker discovered" line)
|
||||
|
||||
NEW_IP=192.168.x.y
|
||||
./build/soundtouch-cli setup migrate --host=$NEW_IP --service-url=http://aftertouch.local:8000 # default --method=telnet
|
||||
|
||||
# Optional, if you want the DNS-redirect path instead of (or alongside) telnet envswitch:
|
||||
# 1. ./build/soundtouch-cli setup ssh-check --host=$NEW_IP # USB-stick procedure if 22 is closed
|
||||
# 2. ./build/soundtouch-cli setup install-ca --host=$NEW_IP --service-url=http://aftertouch.local:8000
|
||||
# 3. ./build/soundtouch-cli setup migrate --host=$NEW_IP --service-url=http://aftertouch.local:8000 --method=resolv
|
||||
./build/soundtouch-cli setup pair --host=$NEW_IP --mode=bare # or --mode=full
|
||||
```
|
||||
|
||||
The two manual lines are user-side Wi-Fi switches that can't be automated portably. The `wait-ap` and `wait-online` subcommands poll for the corresponding network state, so timing them is hands-off.
|
||||
|
||||
## Recording the result
|
||||
|
||||
Append to this file under `## Results`:
|
||||
|
||||
```
|
||||
- Date: YYYY-MM-DD
|
||||
- Firmware: 27.x.x
|
||||
- Model: ST10 / ST20 / ST30 / ST300
|
||||
- Bare setMargeAccount accepted: yes/no
|
||||
- Persistence written: yes/no
|
||||
- Survives reboot: yes/no
|
||||
- Notes: ...
|
||||
```
|
||||
|
||||
One row per device tested. Once two devices on different firmware confirm the same verdict, we treat it as decided.
|
||||
|
||||
## Results
|
||||
|
||||
- Date: 2026-05-13
|
||||
- Firmware: 27.0.6.46330.5043500 (build epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29)
|
||||
- Model: SoundTouch 10 (deviceID AABBCCDDEEFF)
|
||||
- Bare setMargeAccount accepted: **yes** — pre-/info margeAccountUUID="" → post-/info margeAccountUUID="1111111"
|
||||
- Persistence written: **yes** — device materialized 14-entry Sources.xml on its own
|
||||
- Survives reboot: **yes** — `setup inspect` after `setup reboot` shows margeAccountUUID still 1111111
|
||||
- Notes: After bare pairing, the speaker did the full post-pairing handshake against AfterTouch (POST /streaming/support/power_on, GET /streaming/sourceproviders, GET /streaming/account/{id}/full, group/, provider_settings). No SETUP_START/SETUP_ENTER/SETUP_LEAVE was ever sent. Verdict: bare path is functionally equivalent to the full state machine on this firmware.
|
||||
|
||||
### Implication for the codebase
|
||||
|
||||
- `pkg/service/setup/setup_session.go` keeps the full state machine for completeness, but
|
||||
- `pkg/service/setup/init_plan.go`'s default could be simplified to "send setMargeAccount only" once we have one more confirming run on a different model.
|
||||
- The OCT issue-167 SSH-XML seeding workaround is **not required**.
|
||||
|
||||
### Appendix — SystemConfigurationDB.xml comparison
|
||||
|
||||
Post-experiment we compared the device-written `/mnt/nv/BoseApp-Persistence/1/SystemConfigurationDB.xml` from the bare-paired speaker against two SSH backups taken from speakers originally paired by the official Bose app (account 1000001, devices `A_Sound_Machine` and `Sound_Machinechen`). The diff is much smaller than expected — only two fields differ, and neither is set by the pairing protocol itself:
|
||||
|
||||
| Field | Bare-paired (1111111) | Real-Bose-paired (1000001) | Set by |
|
||||
|--------------------------|--------------------------------------------|----------------------------|-----------------------------------------------------------------------------------------------------------|
|
||||
| `DeviceName` | `Bose SoundTouch 536A98` (factory default) | `Living Room SoundTouch` | `name` WS message — only sent in `--mode=full` |
|
||||
| `AccountAssociatedEMail` | empty | **empty** | Never populated, even by real Bose |
|
||||
| `AccountUUID` | `1111111` | `1000001` | `setMargeAccount` — both paths set it |
|
||||
| `Locale` | empty | **empty** | Never populated, even by real Bose |
|
||||
| `acctMode` | `global` | `global` | Firmware-default; no protocol path observed to change it |
|
||||
| `isMultiDeviceAccount` | `false` | `true` | Derived from the cloud's `/streaming/account/{id}/full` response — count of `<devices>` > 1 flips it true |
|
||||
| `margeAuthServerToken` | empty | **empty** | Never populated, even by real Bose |
|
||||
| `Password` | (encrypted blob) | (encrypted blob) | Device-local key; expected to differ |
|
||||
|
||||
Three of the seven informational fields are empty even after a real-Bose pairing — the firmware simply doesn't populate `AccountAssociatedEMail`, `Locale`, or `margeAuthServerToken` from the pairing flow. So bare pairing isn't missing any field that real pairing fills.
|
||||
|
||||
The two genuinely different fields:
|
||||
|
||||
- **`DeviceName`** — pure UX. Settable any time post-pair via `name` POST (`soundtouch-cli name set --value=…`) or by sending the `name` WS message during `--mode=full` pairing.
|
||||
- **`isMultiDeviceAccount`** — not a pairing concern. It's derived from the account's device count on AfterTouch's side; flips to `true` automatically the next time the speaker refreshes account state if a second speaker has been paired to the same account.
|
||||
|
||||
So the experiment's YES verdict stands unqualified: bare `setMargeAccount` produces a `SystemConfigurationDB.xml` functionally equivalent to one written by the official pairing flow.
|
||||
@@ -5,8 +5,8 @@ This document provides a comprehensive analysis of the `/supportedURLs` endpoint
|
||||
## Discovery Summary
|
||||
|
||||
**Test Devices:**
|
||||
- Device 1: `192.168.178.28:8090` (deviceID: `08DF1F0BA325`)
|
||||
- Device 2: `192.168.178.35:8090` (deviceID: `A81B6A536A98`)
|
||||
- Device 1: `192.0.2.11:8090` (deviceID: `08DF1F0BA325`)
|
||||
- Device 2: `192.0.2.10:8090` (deviceID: `AABBCCDDEEFF`)
|
||||
|
||||
**Key Findings:**
|
||||
- Both devices return identical endpoint lists
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
# Bose SoundTouch Telnet (Port 17000) Command Reference
|
||||
|
||||
A consolidated reference for the diagnostic shell that listens on TCP port
|
||||
17000 across the SoundTouch line. Compiled from multiple community sources
|
||||
to give a single map of what's been observed in the wild — useful both for
|
||||
implementing automation against it (see
|
||||
[TELNET-MIGRATION-METHOD.md](TELNET-MIGRATION-METHOD.md)) and for manual
|
||||
recovery / WiFi setup.
|
||||
|
||||
> **Important caveat.** The command set is firmware-dependent. Anything that
|
||||
> existed in firmware 1.x–7.x (`flarn2006`'s era) was progressively trimmed;
|
||||
> some commands listed here have been removed on firmware 27.x. Where a
|
||||
> command's availability is known to vary, the **Availability** column says so.
|
||||
|
||||
### Telnet via Docker (when not installed locally)
|
||||
|
||||
```shell
|
||||
docker run --rm --name telnet -it --env IP=192.0.2.123 alpine:edge ash -c 'apk add -U busybox-extras && telnet $IP 17000'
|
||||
```
|
||||
|
||||
## Sources
|
||||
|
||||
| # | Source | Era / focus |
|
||||
|----|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| S1 | [flarn2006: "Hacking the Bose SoundTouch and its Linux insides"](https://flarn2006.blogspot.com/2014/09/hacking-bose-soundtouch-and-its-linux.html) (2014) | Firmware 1.x–7.x; root shell discovery, codenames |
|
||||
| S2 | [Sam Hobbs: "Connect Bose SoundTouch 10 to WiFi using Linux Telnet"](https://samhobbs.co.uk/2016/01/connect-bose-soundtouch-10-wifi-using-linux-telnet) (2016) | ST 10 setup mode; `network`/`sys` families |
|
||||
| S3 | [izndgroup: "Connect Bose SoundTouch 10 to WiFi"](https://technical.izndgroup.com/2021/02/connect-bose-soundtouch-10-to-wifi.html) (2021) | Reissue of S2 with later-firmware notes |
|
||||
| S4 | [sijeffrey/SoundTouch — `bose` script](https://github.com/sijeffrey/SoundTouch/blob/master/bose) (2017) | `nc`-based remote-control script using `sys`/`ws` |
|
||||
| S5 | [r/bose "SoundTouch telnet probing"](https://www.reddit.com/r/bose/comments/1o5zkym/soundtouch_telnet_probing/) | Recent (post-EOS) probing on ST 10 firmware `27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29`; comments mirrored in [#221](https://github.com/gesellix/Bose-SoundTouch/issues/221) |
|
||||
| S6 | Issue [#221](https://github.com/gesellix/Bose-SoundTouch/issues/221), [#236](https://github.com/gesellix/Bose-SoundTouch/issues/236), [deborahgu/soundcork#141](https://github.com/deborahgu/soundcork/issues/141) | The migration commands we already implement |
|
||||
|
||||
---
|
||||
|
||||
## Connecting to the shell
|
||||
|
||||
### From an already-on-network device
|
||||
|
||||
The shell binds to TCP port 17000 on every device family observed (ST 10/20/300, Wave III/IV, ST 520, SA-5 — see §"Firmware era notes" for caveats). No authentication.
|
||||
|
||||
```bash
|
||||
# A no-op probe just to verify reach.
|
||||
echo '' | nc -w 2 <device-ip> 17000
|
||||
|
||||
# Or interactively — works the same.
|
||||
telnet <device-ip> 17000
|
||||
```
|
||||
|
||||
The `bose` script (S4) goes one level lower and writes commands directly to a `/dev/tcp/<ip>/17000` redirection target instead of using `nc`. That's the same wire protocol with no library between.
|
||||
|
||||
### From a factory-fresh / WiFi-less device
|
||||
|
||||
Per S2/S3 — newer firmware may have closed this on some models:
|
||||
|
||||
1. **Enter setup mode.** Press and hold key **2** + **volume down** for 5 seconds until the WiFi LED turns amber.
|
||||
2. **Connect your laptop to the speaker's open access point.** The speaker becomes its own AP.
|
||||
3. **Telnet to `192.0.2.1` on port 17000.**
|
||||
|
||||
Once you've added a WiFi profile (see `network wifi profiles add` below) the speaker reboots into station mode and the AP goes away.
|
||||
|
||||
### Hardware key combinations on the device itself
|
||||
|
||||
| Combo | Effect | Source |
|
||||
|-------------------|------------------------------------------|--------|
|
||||
| `1` + volume-down | Factory reset | S2, S3 |
|
||||
| `2` + volume-down | Setup mode (open WiFi AP at `192.0.2.1`) | S2, S3 |
|
||||
| `3` + volume-down | Toggle WiFi / Bluetooth | S2, S3 |
|
||||
| `4` + volume-down | Check for software updates | S2, S3 |
|
||||
|
||||
---
|
||||
|
||||
## The `network` family — WiFi & interfaces
|
||||
|
||||
| Command | Purpose | Availability | Source |
|
||||
|------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------|----------------------------|--------|
|
||||
| `network wifi status` | Current SSID, state (e.g. `WIFI_STATION_CONNECTED`), signal strength. Returns XML-like `<WiFiStatus SSID="…" state="…">`. | Wide | S2, S3 |
|
||||
| `network wifi scan [<maxresults>]` | Site survey. | Wide | S2 |
|
||||
| `network wifi profiles info` | Lists stored WiFi profiles (passphrases shown encrypted). | Wide | S2, S3 |
|
||||
| `network wifi profiles add <ssid> <security> [<password>]` | Adds a WiFi network. `<security>` ∈ `none` \| `wep` \| `wpa_or_wpa2`. | Wide; setup-mode workhorse | S2, S3 |
|
||||
| `network wifi profiles clear` | Wipes all stored profiles. | Wide | S2 |
|
||||
| `network status` | All interfaces and IP addresses. | Wide | S2, S3 |
|
||||
| `network dhcp` | Current DHCP interface info. | Wide | S2 |
|
||||
| `network mode auto\|wifioff\|wifisetup` | Switch radio / setup-AP state. | Wide | S2 |
|
||||
|
||||
**Example session — adding a network from setup mode (S3):**
|
||||
|
||||
```
|
||||
network wifi profiles add foobarHub wpa_or_wpa2 topsecret
|
||||
```
|
||||
|
||||
The speaker stores the profile, drops the setup AP, and reboots into station mode.
|
||||
|
||||
---
|
||||
|
||||
## The `key` family — front-panel button emulation
|
||||
|
||||
Each `key …` command emulates a press of a physical button on the speaker
|
||||
or remote. Confirmed working on ST 10 / FW `27.0.6.46330.5043500` (S5);
|
||||
also visible on the ST 20/300/Wave captures in #221. Different from the
|
||||
`sys presetkey N p` form (S4) — the `key prefix_N` shape on FW 27 is what
|
||||
the device's own remote sends.
|
||||
|
||||
| Command | Effect | Source |
|
||||
|---------------------------------|---------------------------------------------------------------------------------------|--------|
|
||||
| `key prefix_1` … `key prefix_6` | Triggers preset 1–6 (same as a remote preset press). | S5 |
|
||||
| `key play` | Begin / resume playback. | S5 |
|
||||
| `key pause` | Pause playback. | S5 |
|
||||
| `key stop` | Stop playback (does **not** terminate the underlying stream). | S5 |
|
||||
| `key prev` | Restart current song / previous track. | S5 |
|
||||
| `key next` | Next track. | S5 |
|
||||
| `key aux` | Toggle Bluetooth / AUX input. | S5 |
|
||||
| `key power` | Echoes "OK" but no observable effect on FW 27.x — possibly handled at a higher layer. | S5 |
|
||||
|
||||
The S4 `bose` script's `sys presetkey N p` form still works, but `key prefix_N` is shorter and matches what the remote already does on FW 27.x.
|
||||
|
||||
---
|
||||
|
||||
## The `sys` family — system control & service URLs
|
||||
|
||||
The `sys` family is the one our migration uses (see §"What we use during migration"). Two distinct sub-syntaxes coexist:
|
||||
|
||||
- **Single-token verbs:** `sys reboot`, `sys volume`, `sys power`, etc.
|
||||
- **`sys configuration <key> <value>` setters** that modify persisted runtime configuration. Used for the four service URLs (margeServerUrl, statsServerUrl, swUpdateUrl, bmxRegistryUrl).
|
||||
|
||||
| Command | Purpose | Availability | Source |
|
||||
|---------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------|------------|
|
||||
| `sys reboot` | Restart the device. | Wide | S2, S6 |
|
||||
| `sys factorydefault` | Reset to factory defaults. | Wide | S1, S2 |
|
||||
| `sys ver` | Firmware version string, e.g. `BoseApp version: 27.0.6.46330.5043500 …`. | Wide; confirmed on FW 27.x | S1, S5 |
|
||||
| `sys power` | Toggle power. Confirmed working on older firmware via S2/S4; on FW 27.x ST 10 the response is `OK` but with **no observable effect** — power state may be controlled elsewhere on that build. | Varies | S2, S4, S5 |
|
||||
| `sys playpause` | Toggle playback. | Wide | S2 |
|
||||
| `sys stop`, `sys pause` | Accepted (return `OK`) but **no observable effect** on FW 27.x ST 10 — the working stop/pause path on that firmware is `key stop` / `key pause`. | Wide / no-op | S5 |
|
||||
| `sys volume` | Print current volume. The S4 script parses the 5th token of the first line. | Wide | S2, S4, S5 |
|
||||
| `sys volume <int>` | Set absolute volume to `<int>`. | Wide | S5 |
|
||||
| `sys volume up <n>` / `sys volume down <n>` | Adjust volume by `<n>` (steps, not dB). | Wide | S4 |
|
||||
| `sys volume <value> updateDisplay` | Set absolute volume and update the front-panel display. | Wide | S2 |
|
||||
| `sys presetkey <1-6> p` | Trigger a preset (`p` = press). Older shape of `key prefix_<N>`. | Wide | S4 |
|
||||
| `sys timeout inactivity disable` (or `off`) | Stop the auto-shutoff timer. May need to be sent twice. | Wide | S1, S2 |
|
||||
| `sys configuration` (no args) | Returns the usage hint `sys configuration <XMLTag> <XMLValue>` — confirms the underlying setter is XML-tag-keyed. | FW 27.x | S5 |
|
||||
| `sys configuration bmxRegistryUrl <url>` | Set the Bose Media eXchange registry URL. | Wide; **migration** | S6 |
|
||||
| `sys configuration statsServerUrl <url>` | Set the telemetry/stats endpoint. | Wide; **migration** | S6 |
|
||||
| `sys configuration margeServerUrl <url>` | Set the marge / streaming endpoint. | Wide; **migration** | S6 |
|
||||
| `sys configuration swUpdateUrl <url>` | Set the software-update endpoint. | Wide; **migration** | S6 |
|
||||
|
||||
Each `sys configuration` setter is reported by users to return `OK` on success. Wait for that token between commands (S6, `foob61451`).
|
||||
|
||||
---
|
||||
|
||||
## The `envswitch` family — parallel persistence layer
|
||||
|
||||
`envswitch` writes to a separate, lower-level persistence store that **wins on next reboot** if the corresponding `sys configuration` value differs. So our migration writes both — see TELNET-MIGRATION-METHOD.md §2.1.
|
||||
|
||||
| Command | Purpose | Source |
|
||||
|---------------------------------------------------|-----------------------------------------------------------------------------------------------|---------|
|
||||
| `envswitch boseurls set <margeUrl> <swUpdateUrl>` | Persist the marge and update URLs. **Two arguments**, in that order. | S6 |
|
||||
| `envswitch accountid set <numeric-id>` | Equivalent to the HTTP `/setMargeAccount` POST. Used as fallback in our `PairAccount` helper. | S6 |
|
||||
| `envswitch accountid get` | Plausible by symmetry but **not yet confirmed** across firmwares; we probe it best-effort. | (probe) |
|
||||
|
||||
---
|
||||
|
||||
## The `getpdo` family — read persisted configuration
|
||||
|
||||
`getpdo <selector>` prints the contents of a persisted-data-object. We use it as the verification step after writing URLs.
|
||||
|
||||
| Selector | Purpose | Source |
|
||||
|-------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------|
|
||||
| `getpdo CurrentSystemConfiguration` | Echoes the resolved URL set, including margeServerUrl/bmxRegistryUrl/statsServerUrl/swUpdateUrl. We grep our targetURL out of this to confirm a successful migration. | S6 |
|
||||
|
||||
---
|
||||
|
||||
## The `scm` family — service control
|
||||
|
||||
`scm` (System Control / Module manager) lets you inspect and restart internal services.
|
||||
|
||||
| Command | Purpose | Availability | Source |
|
||||
|-------------------------|------------------------------------------------------------------------------------------|----------------|------------------------------------------------------------------------------|
|
||||
| `scm list` | List running services. | Older firmware | S1 |
|
||||
| `scm restart <service>` | Restart a service by name. | Older firmware | S1 |
|
||||
| `scm uboot_ver` | Print bootloader version (`U-Boot 2013.01.01-…`). Confirmed working on SA-5 with FW 9.x. | Older firmware | [deborahgu/soundcork#141](https://github.com/deborahgu/soundcork/issues/141) |
|
||||
|
||||
---
|
||||
|
||||
## Shell-unlock commands
|
||||
|
||||
These are the commands that gated SSH access on older firmware. Both have been progressively removed; on FW 27.x they generally do nothing useful.
|
||||
|
||||
| Command | Purpose | Availability | Source |
|
||||
|-----------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------|----------------------------------------------------------------------------------|
|
||||
| `remote_services on` | Enable SSH on port 22. Volatile (re-enter after reboot). Response: `remote services on`. **Removed in FW 7.x+**. | Old | S1 |
|
||||
| `local_services on` | Alternative enablement; works on some firmware where `remote_services` was removed. SA-5 FW 9.x reports `local services on`, but this alone does not appear to grant SSH on most models. | Old, hit-or-miss | S1, [deborahgu/soundcork#141](https://github.com/deborahgu/soundcork/issues/141) |
|
||||
| `demo enter` / `mode enter` | Unlocks demo / button-test mode (used historically to recover bricked units). | Old | S1 |
|
||||
|
||||
---
|
||||
|
||||
## The `ws` and `swupdate` families
|
||||
|
||||
| Command | Purpose | Availability | Source |
|
||||
|------------------|---------------------------------------------------------------------------------------------------------|--------------|--------|
|
||||
| `ws getpresets` | Returns an XML list of presets — the S4 script parses the `<itemName>…<text>…` blocks to extract names. | Wide | S4 |
|
||||
| `swupdate abort` | Cancel a software update in progress. | Wide | S1 |
|
||||
|
||||
---
|
||||
|
||||
## `help`
|
||||
|
||||
Lists the commands available on the running firmware. **Frequently removed** on later firmware — returns `Command not found` on FW 27.x in many of the captures we have. Still worth probing once during preflight: a successful response is a quick way to enumerate what this specific build supports without trial-and-error.
|
||||
|
||||
---
|
||||
|
||||
## Device codenames (S1)
|
||||
|
||||
These show up in `getpdo`, `network status`, and SSH-side hostnames. Useful for matching captures to hardware.
|
||||
|
||||
| Codename | Hardware |
|
||||
|----------|------------------------------------------------|
|
||||
| `lisa` | Adapter (older speakers running Bose firmware) |
|
||||
| `spotty` | SoundTouch 20 |
|
||||
| `rhino` | SoundTouch 10 |
|
||||
| `mojo` | SoundTouch 30 |
|
||||
| `taigan` | SoundTouch Portable |
|
||||
|
||||
---
|
||||
|
||||
## Firmware era notes
|
||||
|
||||
- **Firmware 1.x–7.x** (S1 era): everything — `help`, `remote_services on`, full `scm`, and an in-shell login prompt. `flarn2006` documents the original Linux insides.
|
||||
- **Firmware 8.x–14.x** (S2 era): `remote_services on` removed; `network`, `sys`, `envswitch`, `getpdo` still present. `local_services on` works on some Wave/SA-5 models.
|
||||
- **Firmware 27.x** (S5/S6 era — the long-lived "frozen" build that survived through EOS): `help`, `remote_services on`, and `sys ver` removed in some builds; `sys configuration …` and `envswitch …` confirmed working on ST 10, ST 20, ST 300, Wave III, Wave IV. **This is the firmware our migration targets**. The Portable on more recent firmware drops further commands and is the hardest target.
|
||||
|
||||
S5 enumerated the **top-level command roots** that don't return "Command not found" on a vanilla ST 10 (`rhino`) running `27.0.6.46330.5043500`:
|
||||
|
||||
```
|
||||
key
|
||||
net
|
||||
sys
|
||||
getpdo
|
||||
```
|
||||
|
||||
Notably absent from that probe: `network`, `envswitch`, `scm`, `ws`, `swupdate`, `remote_services`, `local_services`, `demo`, `mode`, `help`. **However**, other captures on the same firmware family (S6, ST 20 / Wave III / Wave IV) accept `envswitch …`, suggesting either per-model variation in the shipped command table or an SSH/role gate the S5 author didn't trip. Implementations that use `envswitch` should treat its absence as a recoverable preflight outcome (we already do).
|
||||
|
||||
`net` is observed as a valid root by S5 but its sub-commands aren't enumerated; it may be a shorthand alias for `network` on FW 27.x ST 10.
|
||||
|
||||
---
|
||||
|
||||
## What we use during migration
|
||||
|
||||
For quick reference, the exact sequence our `pkg/service/setup.migrateViaTelnet` issues, all on the same connection, in this order:
|
||||
|
||||
```
|
||||
sys configuration bmxRegistryUrl <serverURL>/bmx/registry/v1/services
|
||||
sys configuration statsServerUrl <serverURL>
|
||||
sys configuration margeServerUrl <serverURL>
|
||||
sys configuration swUpdateUrl <serverURL>/updates/soundtouch
|
||||
envswitch boseurls set <serverURL> <serverURL>/updates/soundtouch
|
||||
getpdo CurrentSystemConfiguration
|
||||
```
|
||||
|
||||
Plus, when pairing a fresh device whose `:8090/setMargeAccount` is missing or wedged, the helper falls back to:
|
||||
|
||||
```
|
||||
envswitch accountid set <7-digit-id>
|
||||
```
|
||||
|
||||
Reboot is **not** part of these sequences — it stays a user-initiated action via the existing reboot button, which now accepts `?method=telnet|ssh` and sends `sys reboot` when telnet is picked.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope here, but worth recording
|
||||
|
||||
- **Setup-mode WiFi onboarding via 192.0.2.1.** The community uses this to add a fresh device to a network without the Bose app. Our `soundtouch-service` does not currently automate this, but `network wifi profiles add` is the entry point if we ever do.
|
||||
- **Direct preset / playback control via `sys`.** The S4 `bose` script demonstrates a viable headless remote-control path that does not need our marge emulation at all. Useful as a fallback for tooling on devices that refuse to talk to any cloud.
|
||||
- **`scm restart <service>`.** Not used today, but a possible recovery primitive on older firmware where a stuck service blocks streaming.
|
||||
@@ -0,0 +1,730 @@
|
||||
# Telnet (Port 17000) Migration Method — Analysis
|
||||
|
||||
This document captures the use cases, community findings, and feasibility analysis
|
||||
for adding a **Telnet/port 17000** migration path to `soundtouch-service` as a
|
||||
peer of the existing XML and DNS-based methods. The `/etc/hosts` method stays
|
||||
deprecated and is intentionally kept off the visible UI options.
|
||||
|
||||
> **Sources** — community discussion synthesised from
|
||||
> [gesellix/Bose-SoundTouch#221](https://github.com/gesellix/Bose-SoundTouch/issues/221),
|
||||
> [gesellix/Bose-SoundTouch#236](https://github.com/gesellix/Bose-SoundTouch/issues/236),
|
||||
> [scheilch/opencloudtouch#167](https://github.com/scheilch/opencloudtouch/issues/167),
|
||||
> [deborahgu/soundcork#228](https://github.com/deborahgu/soundcork/issues/228),
|
||||
> [deborahgu/soundcork#141](https://github.com/deborahgu/soundcork/issues/141),
|
||||
> the post-EOS walkthrough PDF in `docs/`,
|
||||
> [Bose SoundTouch Telnet Probing thread](https://www.reddit.com/r/bose/comments/1o5zkym/soundtouch_telnet_probing/),
|
||||
> and [flarn2006's blog post on hacking SoundTouch](https://flarn2006.blogspot.com/2014/09/hacking-bose-soundtouch-and-its-linux.html).
|
||||
|
||||
---
|
||||
|
||||
## 1. Why a third method is needed
|
||||
|
||||
The two currently shipped methods both have hard preconditions that block real
|
||||
users:
|
||||
|
||||
| Method | Preconditions | Failure modes seen in the wild |
|
||||
|-----------------------------------------|--------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **XML** (`SoundTouchSdkPrivateCfg.xml`) | SSH/root access — needs `remote_services` USB unlock first | Some firmware revisions (e.g. SA-5, ST520, latest ST Portable) refuse the USB unlock entirely; `remote_services on` was removed from the telnet command set in firmware 7.x and later. |
|
||||
| **DNS** (`resolv.conf` priority hook) | SSH/root access; service must own port 53 on the LAN gateway | Won't fit users behind ISP routers they can't reconfigure; still requires the device to be SSH-reachable to write the hook. |
|
||||
|
||||
The community has demonstrated a **third path that needs no SSH at all**:
|
||||
the device's built-in **diagnostic Telnet shell on TCP port 17000** accepts
|
||||
configuration commands that change exactly the same fields the XML method would.
|
||||
|
||||
### 1.1 Confirmed user reports (firmware 27.0.6.46330.5043500 unless noted)
|
||||
|
||||
| Reporter | Hardware | Outcome |
|
||||
|--------------------|---------------------------|-------------------------------------------------------------------------------------------------------------|
|
||||
| `foob61451` (#221) | ST 10, ST 20 (non-rooted) | All four URLs persisted via `sys configuration …`; `envswitch boseurls set …` survived `sys reboot`. |
|
||||
| `bveenker` (#221) | Wave III | URLs accepted; presets work after pairing via `/setMargeAccount` (see §3). |
|
||||
| `stephan48` (#221) | Wave IV | Telnet:1700 + USB stick `remote_services` did **not** work; **port 17000 telnet** worked for all four URLs. |
|
||||
| `mcdona1d` (#141) | ST 20, ST 300 | Confirmed working with `sys configuration …` + `envswitch …` + `sys reboot`. |
|
||||
| `TJGigs` (#228) | ST 20 ×2, ST 10 | Wraps telnet:17000 into an admin "Smart Inject" tool; uses `sys reboot` over telnet to nudge devices. |
|
||||
|
||||
So the method is plausible across **at least ST 10/20/300 and Wave III/IV** on
|
||||
the most common firmware that survived the EOS cut, **without the USB unlock
|
||||
dance** that newer firmware refuses.
|
||||
|
||||
---
|
||||
|
||||
## 2. The Telnet:17000 command set we rely on
|
||||
|
||||
> For a broader catalogue of every telnet command the community has documented
|
||||
> across firmware eras (the `key`, `network`, `sys`, `envswitch`, `getpdo`,
|
||||
> `scm`, `ws`, `swupdate`, and shell-unlock families), see
|
||||
> **[TELNET-COMMAND-REFERENCE.md](TELNET-COMMAND-REFERENCE.md)**. This
|
||||
> section only lists the subset our migration actually drives.
|
||||
|
||||
|
||||
### 2.1 URL configuration (the migration payload)
|
||||
|
||||
The sequence we send for `soundtouch-service` (community-validated in #221, #141):
|
||||
|
||||
```
|
||||
sys configuration bmxRegistryUrl http://<service-host>:8000/bmx/registry/v1/services
|
||||
sys configuration statsServerUrl http://<service-host>:8000
|
||||
sys configuration margeServerUrl http://<service-host>:8000
|
||||
sys configuration swUpdateUrl http://<service-host>:8000/updates/soundtouch
|
||||
envswitch boseurls set http://<service-host>:8000 http://<service-host>:8000/updates/soundtouch
|
||||
getpdo CurrentSystemConfiguration
|
||||
```
|
||||
|
||||
`sys reboot` is **not** part of this sequence. The migration flow only writes
|
||||
configuration — the reboot is user-initiated via the existing reboot button in
|
||||
the web UI, mirroring what XML/DNS migration already does. See §6.2 for how
|
||||
that button gains a `?method=ssh|telnet` selector.
|
||||
|
||||
Three important details from the discussion:
|
||||
|
||||
1. **`sys configuration` alone is not enough.** `stephan48` reported that
|
||||
without the `envswitch boseurls set …` line his typo in `bmxRegistryUrl` was
|
||||
silently restored on reboot — i.e. there is a parallel "envswitch" persistence
|
||||
layer that wins on next boot if you don't also write to it. **We must always
|
||||
issue both.**
|
||||
2. **margeServerUrl path is bare for `soundtouch-service`.** We mount the marge
|
||||
endpoints at the **root** of port 8000, matching what the existing XML
|
||||
migration writes (`Manager.migrateViaXML` in `pkg/service/setup/setup.go`
|
||||
sets `MargeServerUrl: targetURL` without any suffix). Some community
|
||||
recipes appended `/marge` because they were targeting
|
||||
[`deborahgu/soundcork`](https://github.com/deborahgu/soundcork), which
|
||||
routes marge under that sub-path. **For our service: bare URL. For users
|
||||
redirecting to soundcork: append `/marge`** to both `margeServerUrl` and
|
||||
the first argument of `envswitch boseurls set`.
|
||||
3. **Each command must be sent one at a time, waiting for the device's `OK`
|
||||
response** before sending the next one (`foob61451`'s explicit warning).
|
||||
|
||||
### 2.2 Account pairing fallback
|
||||
|
||||
`envswitch accountid set <numeric-id>` was reported by `bveenker` (#221) as an
|
||||
in-band equivalent to the HTTP `/setMargeAccount` call, useful when the
|
||||
`/setMargeAccount` endpoint is missing on the firmware (see §3).
|
||||
|
||||
### 2.3 Probing / preflight
|
||||
|
||||
- A bare TCP connect to `<deviceIP>:17000` answers (no auth) on devices we care
|
||||
about.
|
||||
- Useful read-only verification command: `getpdo CurrentSystemConfiguration` —
|
||||
prints the URLs after the changes have been applied so we can verify before
|
||||
rebooting.
|
||||
- `sys reboot` is the trigger that re-reads both layers.
|
||||
|
||||
### 2.4 What Telnet:17000 cannot do
|
||||
|
||||
- It does **not** install a custom CA. So if a user wants HTTPS rather than HTTP
|
||||
redirection to our service (the DNS-method scenario, where `resolv.conf`
|
||||
redirection collides with the device's TLS validation unless our root CA is
|
||||
trusted on the device), telnet alone won't cover it. This is fine for our
|
||||
default flow, which uses plain `http://` URLs to the service's port 8000.
|
||||
- It does not give us a way to read or write `Sources.xml` (third-party
|
||||
account credentials) — that still requires SSH, but for a migration we don't
|
||||
actually need it.
|
||||
|
||||
---
|
||||
|
||||
## 3. The `/setMargeAccount` problem (issue #236, #228)
|
||||
|
||||
### 3.1 What it is
|
||||
|
||||
A factory-reset speaker has an empty `<margeAccountUUID/>` in `:8090/info`. The
|
||||
marge endpoints fail with 502 / unhandled until that field is populated, which
|
||||
is why several users (#221, #236) saw **everything except AUX** broken after
|
||||
migration:
|
||||
|
||||
```
|
||||
POST http://<deviceIP>:8090/setMargeAccount
|
||||
Content-Type: application/xml
|
||||
|
||||
<PairDeviceWithAccount>
|
||||
<accountId>1234567</accountId>
|
||||
<userAuthToken>soundcorkdoesntcare</userAuthToken>
|
||||
</PairDeviceWithAccount>
|
||||
```
|
||||
|
||||
The values are not validated by the local service, so any numeric `accountId`
|
||||
will work — soundcork's runbook (#228) literally calls the token
|
||||
`soundcorkdoesntcare` to make the point.
|
||||
|
||||
### 3.2 Why it's broken in practice
|
||||
|
||||
There are **three independent failure modes** observed:
|
||||
|
||||
| Symptom | Cause | Detection |
|
||||
|-----------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------|
|
||||
| Endpoint returns 404 / "not implemented" | Newer firmware (e.g. some BST20 Portable, latest ST Portable) drops the endpoint entirely. | `GET /supportedURLs` does **not** list `/setMargeAccount` in `<URL location="…"/>`. |
|
||||
| Endpoint hangs (no response / socket stays open) | "Broken state" the user explicitly called out — endpoint advertised, but handler is wedged. | Caller has to time out; we currently have no timeout, so the request appears to hang the migration UI indefinitely. |
|
||||
| `POST /marge/streaming/support/power_on` → 502 unhandled (#236) | Device keeps polling marge after migration but no `margeAccountUUID` was ever assigned, so all subsequent calls fail. | `:8090/info` shows `<margeAccountUUID/>` empty after reboot. |
|
||||
|
||||
### 3.3 Required handling
|
||||
|
||||
Per the user's brief, the migration logic must:
|
||||
|
||||
1. **Probe** `GET http://<deviceIP>:8090/supportedURLs` and check whether
|
||||
`/setMargeAccount` is in the list **before** trying to POST it.
|
||||
2. **Time-bound** the POST aggressively (e.g. ≤5s connect + ≤10s read) and treat
|
||||
anything over the budget as a failure rather than waiting indefinitely.
|
||||
3. On either failure mode, **fall back** to the telnet equivalent
|
||||
`envswitch accountid set <id>` over the same `pkg/telnet` connection used
|
||||
for the URL flip. Reboot stays a user-initiated action (§6.2).
|
||||
4. If telnet:17000 is **also** unreachable, surface a clear "your firmware does
|
||||
not support unattended pairing — please pair manually via the official Bose
|
||||
app *before* it goes EOS, or open SSH and use the XML method" error rather
|
||||
than leaving the device in a half-migrated state.
|
||||
|
||||
### 3.4 Where the `<id>` comes from
|
||||
|
||||
The device's current account ID is already discoverable through endpoints we
|
||||
control:
|
||||
|
||||
- **`GET :8090/info`** returns `<margeAccountUUID>…</margeAccountUUID>`. If it
|
||||
is non-empty the device is already paired — **reuse that ID**, do not
|
||||
reassign. Our local marge accepts any ID, so the existing one is fine.
|
||||
- If it is empty (factory reset), the user picks one in the UI:
|
||||
1. **Pick from existing accounts.** The setup UI lists IDs returned by
|
||||
`DataStore.ListAccounts()` so a user can re-attach a fresh device to an
|
||||
account that already has presets/recents/sources.
|
||||
2. **Enter manually.** Free-form text input, validated as **exactly 7
|
||||
numeric digits** (the format every Bose-cloud-issued ID has had in the
|
||||
captures we've seen, and the format the wider community uses in their
|
||||
recipes).
|
||||
3. **Randomize.** A "Generate" button that picks a 7-digit number and
|
||||
re-rolls if it collides with an existing account in the local datastore.
|
||||
- **Telnet read-back (best-effort).** `envswitch accountid get` is plausible by
|
||||
symmetry with `envswitch accountid set` (#221) but is not yet confirmed
|
||||
across firmwares. We will probe it during preflight; if it returns a value
|
||||
we cross-check it against `:8090/info` and warn on mismatch.
|
||||
|
||||
This means the user is never *forced* to invent a number — the common path is
|
||||
"the device already has an ID, reuse it" — and the manual/randomize controls
|
||||
only show up when the device is genuinely fresh.
|
||||
|
||||
---
|
||||
|
||||
## 4. Port 17000 availability
|
||||
|
||||
The diagnostic shell is gated by firmware build and product family. Anecdotally:
|
||||
|
||||
- ST 10 / ST 20 / ST 300 / Wave III / Wave IV on FW 27.0.6 → **open**.
|
||||
- SA-5 with FW 9.x → some commands present (`local_services on`) but
|
||||
**no `remote_services on`** and no SSH on FW 9.0.43.23466 (#141).
|
||||
- Modern firmware on some Portables → endpoint set has shrunk further.
|
||||
|
||||
Because of this, we cannot assume port 17000 is reachable. The migration flow
|
||||
must:
|
||||
|
||||
1. **Probe** with a TCP connect to `<deviceIP>:17000`, with a tight timeout
|
||||
(≤2s). A successful TCP handshake is necessary but not sufficient — some
|
||||
hardened firmware closes the port immediately.
|
||||
2. **Banner check.** After connecting, read whatever the device sends within
|
||||
~1s. The diagnostic shell prints a small banner (firmware-dependent); a
|
||||
blank read or an immediate close means we should treat it as "telnet not
|
||||
usable" and disable the option.
|
||||
3. **Capability check.** Issue a no-op like `getpdo CurrentSystemConfiguration`
|
||||
and look for any non-empty response. If the device replies "Command not
|
||||
found" we abort and suggest XML or DNS instead.
|
||||
4. **Surface state to the UI.** The migration form should grey out the Telnet
|
||||
option when the probe fails and show *why* (closed, banner missing,
|
||||
command rejected) instead of letting the user click into a dead end.
|
||||
|
||||
---
|
||||
|
||||
## 5. Implementation feasibility — Telnet client in Go
|
||||
|
||||
This is a feasibility check only; no code is written yet.
|
||||
|
||||
### 5.1 Protocol
|
||||
|
||||
"Telnet" on port 17000 is effectively a line-oriented plain-TCP shell. The
|
||||
device prints a small prompt (`->` in the SA-5 captures from #141) and reads
|
||||
newline-terminated commands. There is **no** real Telnet option negotiation
|
||||
(no `IAC`/`DO`/`WILL` exchanges visible in the wild captures), so we don't
|
||||
need `golang.org/x/crypto/ssh`-class machinery.
|
||||
|
||||
### 5.2 Standard-library only
|
||||
|
||||
A minimal client is just `net.DialTimeout("tcp", host+":17000", 2*time.Second)` +
|
||||
`bufio.Scanner` + `time.Time`-based deadlines on `Conn`. No third-party Telnet
|
||||
library is needed; `github.com/reiver/go-telnet` would be overkill and adds
|
||||
maintenance surface for no benefit. This matches the project's KISS principle
|
||||
in `docs/CLAUDE.md` §3.
|
||||
|
||||
### 5.3 Cross-platform compatibility
|
||||
|
||||
`net.Dial` over TCP works identically on Windows, macOS, Linux and (with
|
||||
limitations on listening) WASM. WASM-side: `soundtouch-service` runs server-side
|
||||
anyway, so this only matters for `soundtouch-cli`, where TCP dial works in any
|
||||
target other than browser-WASM — an acceptable carve-out documented separately.
|
||||
|
||||
### 5.4 Concurrency / safety
|
||||
|
||||
Each migration is a single goroutine driving one device. The client must:
|
||||
|
||||
- enforce per-command response deadlines so a wedged device cannot stall the
|
||||
migration UI (mirrors the `/setMargeAccount` requirement);
|
||||
- abort the rest of the sequence on the first non-`OK` response so we don't
|
||||
half-write configuration;
|
||||
- always close the socket on error.
|
||||
|
||||
### 5.5 Testing strategy
|
||||
|
||||
We can test without a real speaker by spinning up a `net.Listen("tcp", "127.0.0.1:0")`
|
||||
in the test, scripting it to consume our commands and emit canned `OK`/error
|
||||
responses. That gives us deterministic coverage for:
|
||||
|
||||
- happy path (all four URLs accepted),
|
||||
- single-command failure → sequence aborts, no further commands sent,
|
||||
- "command not found" on `envswitch …` → fallback path exercised,
|
||||
- TCP closed mid-stream → migration aborts cleanly,
|
||||
- read deadline triggers when the device hangs (the broken-state simulation).
|
||||
|
||||
The repo already follows the "real device responses preferred, mock servers
|
||||
otherwise" rule (see `docs/CLAUDE.md` §1, §8). The tests above are the mock-server
|
||||
half of that pattern.
|
||||
|
||||
### 5.6 Where it lives
|
||||
|
||||
The protocol client is **a standalone package**, not buried inside
|
||||
`pkg/service/setup`, so it can be reused from CLI tools, future setup wizards,
|
||||
and tests without dragging the migration manager in:
|
||||
|
||||
```
|
||||
pkg/telnet/ # NEW reusable package
|
||||
client.go # Dial / SendCommand / Probe / Close
|
||||
client_test.go # mock-server tests against a net.Listen
|
||||
|
||||
pkg/service/setup/
|
||||
telnet_migration.go # NEW thin wrapper that imports pkg/telnet
|
||||
# and runs the URL config sequence
|
||||
marge_pairing.go # NEW /setMargeAccount probe + post + telnet
|
||||
# `envswitch accountid set` fallback
|
||||
setup.go # add MigrationMethodTelnet const + case
|
||||
```
|
||||
|
||||
UI plumbing is `pkg/service/handlers/web/index.html` (option list) and
|
||||
`pkg/service/handlers/web/js/script.js` (`toggleMigrationMethod()`). The
|
||||
deprecated `hosts` option is already hidden from the dropdown when we ship
|
||||
this; we just add a `telnet` option next to `xml`/`resolv`.
|
||||
|
||||
### 5.7 Verdict
|
||||
|
||||
**Feasible and small.** Estimated scope: ~200 lines of client code in
|
||||
`pkg/telnet`, ~300 lines of tests, plus a `MigrationMethodTelnet` branch in
|
||||
`Manager.MigrateSpeaker`, plus the preflight probe described in §4 and the
|
||||
`/setMargeAccount` guarding described in §3.
|
||||
|
||||
---
|
||||
|
||||
## 6. Decisions made (was: open questions)
|
||||
|
||||
1. **Account-ID generation.** Resolved — see §3.4. The migration form reads
|
||||
`:8090/info` first; if `margeAccountUUID` is non-empty it is reused.
|
||||
Otherwise the UI offers (a) pick from `DataStore.ListAccounts()`,
|
||||
(b) manual entry validated as 7 numeric digits, (c) a "Generate" button
|
||||
that randomizes a 7-digit number and re-rolls on collision.
|
||||
2. **Reboot policy.** Migration writes configuration only — it does **not**
|
||||
issue `sys reboot` itself. Reboot stays user-initiated via the existing
|
||||
reboot button in the web UI, the same way XML/DNS migration already works.
|
||||
That button's endpoint (`POST /setup/reboot/{deviceId}`,
|
||||
`Manager.Reboot(deviceIP)`) gains an optional `?method=ssh|telnet` query
|
||||
parameter; default stays `ssh` so existing behavior is preserved. The
|
||||
button itself uses a plain `confirm()` dialog before firing.
|
||||
3. **CA / HTTPS story.** Telnet has no way to install a custom CA. Documented
|
||||
as an explicit limitation: telnet method = HTTP-only redirect to our
|
||||
service. Users who need end-to-end TLS must use the XML or DNS method.
|
||||
*Possible future enhancement* — a hybrid "install CA via SSH/XML, then drive
|
||||
the URL flip via Telnet" path. Feasibility unknown; not in this iteration.
|
||||
|
||||
---
|
||||
|
||||
> **See §9 for the as-shipped state.** Section 7 below records the
|
||||
> original forecast; the wizard grew larger during implementation and
|
||||
> §9 documents what actually landed.
|
||||
|
||||
## 7. Summary of what changes when this lands
|
||||
|
||||
- **New reusable package `pkg/telnet`** — sibling of `pkg/ssh`, line-oriented
|
||||
TCP client with `Dial`, `SendCommand`, `Probe`, `Close`, all deadline-driven.
|
||||
No external dependencies, usable from CLI, service, and tests.
|
||||
- **New `MigrationMethodTelnet = "telnet"`** constant in `pkg/service/setup/setup.go`
|
||||
plus a `migrateViaTelnet` branch in `Manager.MigrateSpeaker`.
|
||||
- **New `pkg/service/setup/telnet_migration.go`** orchestrating the URL
|
||||
configuration sequence (§2.1) on top of `pkg/telnet`. Configuration only —
|
||||
no `sys reboot` here.
|
||||
- **New `pkg/service/setup/marge_pairing.go`** with `PairAccount(deviceIP, id)`:
|
||||
probes `/supportedURLs`, time-bounded `POST /setMargeAccount`, falls back to
|
||||
telnet `envswitch accountid set <id>` on missing/wedged endpoint.
|
||||
- **`Manager.Reboot` and `HandleRebootDevice` gain a method selector** —
|
||||
signature changes to `Reboot(deviceIP string, method RebootMethod) (string, error)`
|
||||
with `RebootMethodSSH` (default, today's behavior) and `RebootMethodTelnet`
|
||||
(sends `sys reboot` over a fresh `pkg/telnet` connection). Handler reads
|
||||
`?method=ssh|telnet` from the query string.
|
||||
- **`MigrationSummary` gains** `TelnetReachable`, `TelnetBanner`,
|
||||
`TelnetCommandsAccepted`, `SetMargeAccountSupported`, `CurrentAccountID`,
|
||||
`KnownAccountIDs` so the UI can show preflight outcomes and offer reuse.
|
||||
- **UI** — `web/index.html` dropdown gets a `telnet` option (greyed out when
|
||||
preflight fails) and a new pane for picking/entering/randomizing a 7-digit
|
||||
account ID when `:8090/info` reports an empty `margeAccountUUID`. The
|
||||
existing reboot button gets a method selector (radio or dropdown) wired to
|
||||
the new query param, with `confirm()` before firing. The legacy `hosts`
|
||||
option stays out of the dropdown (deprecated).
|
||||
|
||||
---
|
||||
|
||||
## 8. Device compatibility today
|
||||
|
||||
What follows is the current best read on which devices our `migrateViaTelnet`
|
||||
flow handles end-to-end, derived from the same six sources catalogued in
|
||||
[TELNET-COMMAND-REFERENCE.md](TELNET-COMMAND-REFERENCE.md) plus the issue
|
||||
threads cited above. This is migration-outcome perspective; for per-command
|
||||
availability see the reference doc.
|
||||
|
||||
### 8.1 Proven to work end-to-end
|
||||
|
||||
All on the firmware-27.0.6 family, which is what survived through Bose's
|
||||
end-of-service cut. Multi-reporter agreement on every row.
|
||||
|
||||
| Device | Reporter(s) | Source | Confirmed |
|
||||
|----------|-----------------------------|------------------|----------------------------------------------------------------------------|
|
||||
| ST 10 | foob61451, TJGigs | #221, #228 | All four URLs persist; `envswitch boseurls set` survives `sys reboot` |
|
||||
| ST 20 | foob61451, mcdona1d, TJGigs | #221, #141, #228 | Same; multiple independent reports |
|
||||
| ST 300 | mcdona1d | #141 | `sys configuration` + `envswitch` + `sys reboot` round-trip |
|
||||
| Wave III | bveenker | #221 | URLs accepted; presets work after pairing fallback (§3) |
|
||||
| Wave IV | stephan48 | #221 | Port-17000 path **was the only one that worked** — USB-stick unlock failed |
|
||||
|
||||
The exact sequence each reporter ran by hand is the sequence our migration
|
||||
sends (§2.1). So the migration's happy path is exercised against five
|
||||
hardware variants in independent captures.
|
||||
|
||||
### 8.2 Proven to need the pairing fallback
|
||||
|
||||
Migration of the URLs themselves works on these models, but
|
||||
`POST /setMargeAccount` is missing or wedged on the firmware build, so
|
||||
pairing has to go through the telnet `envswitch accountid set <id>` path
|
||||
that `setup.PairAccount` already implements.
|
||||
|
||||
| Device | Reporter | Source | Why fallback is needed |
|
||||
|--------------------------------|----------|-----------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| ST Portable / FW 27.0.6 | jmosen | #236 | After migration: `POST /marge/streaming/support/power_on` → 502; `<margeAccountUUID/>` empty. Time-bounded HTTP path fails; envswitch fallback succeeds. |
|
||||
| BST20 Portable (factory reset) | ubittner | scheilch/opencloudtouch#167 | `<margeAccountUUID/>` empty; `/setMargeAccount` not in `/supportedURLs`. HTTP path skipped entirely; only the telnet fallback works. |
|
||||
|
||||
### 8.3 Likely to fail (but the failure is clean)
|
||||
|
||||
Our preflight + abort-on-first-rejection design (`TestMigrateViaTelnet_CommandNotFoundAborts`)
|
||||
means none of these scenarios leave a device half-configured. The user is
|
||||
told what failed and pointed to the XML or DNS method.
|
||||
|
||||
| Device | Source | Likely cause |
|
||||
|--------------------------------------------|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **SA-5** (sound amplifier) on FW 9.0.43.x | soundcork#141 | FW 9.x has a different shell generation: `->` prompt, `local_services on`, `scm uboot_ver`. **`sys configuration` and `envswitch` are not documented as working there.** Migration fails on command #1. |
|
||||
| **Recent ST Portable** (post-27.0.6.46330) | #236 (indirect) | `/setMargeAccount` removal points to broader command-set shrinkage. If `envswitch accountid set` is also gone, both migration and pairing fallback fail; user is told to pair via the official Bose app before EOS, or use XML over SSH. |
|
||||
|
||||
### 8.4 Unknown — would benefit from real-device verification
|
||||
|
||||
| Device | Why unknown | What we'd want to confirm |
|
||||
|----------------------------|---------------------------------------------------------------------|--------------------------------------------------------------------------|
|
||||
| **ST 30** (`mojo`) | No concrete capture in any of the six sources | Almost certainly works — same FW family as ST 10/20/300 — but unverified |
|
||||
| **ST 520 / Home Cinema** | USB-unlock reports failing (#141), no port-17000 capture either way | Whether `sys configuration` and `envswitch` are exposed at all |
|
||||
| **Wave Music System I/II** | `flarn2006`-era hardware, not seen in 27.x reports | Whether port 17000 is even open on those models |
|
||||
|
||||
### 8.5 The S5 "valid roots" tension
|
||||
|
||||
S5 (the r/bose telnet-probing thread) lists only `key`, `net`, `sys`,
|
||||
`getpdo` as command roots that don't return "Command not found" on its
|
||||
ST 10 / FW 27.0.6 — which would seem to rule out `envswitch`. But foob61451
|
||||
on the same hardware/firmware ran `envswitch boseurls set` successfully
|
||||
(#221).
|
||||
|
||||
The most plausible reading is that **S5 is a non-exhaustive probe**, not a
|
||||
negative claim: the author writes "I've made some educated guesses and come
|
||||
up with the following valid commands" and never says they tested
|
||||
`envswitch`. We do not down-weight `envswitch` availability on the strength
|
||||
of S5 alone — but if a real-device run ever shows `envswitch` rejected on
|
||||
an ST 10, our preflight catches it, the migration aborts on the first
|
||||
non-OK response, and the user gets a clear error rather than partial state.
|
||||
|
||||
### 8.6 Failure-mode matrix
|
||||
|
||||
What `migrateViaTelnet` does in each failure mode (verified by
|
||||
`pkg/telnet` and `pkg/service/setup` unit tests):
|
||||
|
||||
| Failure | Outcome | Test |
|
||||
|------------------------------------------------|---------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------|
|
||||
| Port 17000 closed / TCP unreachable | `Dial` errors before any command is sent; UI shows the error; nothing persisted | `TestMigrateViaTelnet_DialFailureReturnsError` |
|
||||
| `sys configuration` rejected (cmd #1) | Sequence aborts; verification not sent; rest of commands not attempted | `TestMigrateViaTelnet_CommandNotFoundAborts` (envswitch variant — generalises) |
|
||||
| `envswitch boseurls set` rejected | Sequence aborts; runtime-only `sys configuration` state reverts on reboot — no permanent damage | `TestMigrateViaTelnet_CommandNotFoundAborts` |
|
||||
| Verification mismatch (URLs not echoed back) | Loud "verification failed" error; live state may persist until reboot but UI never claims success | `TestMigrateViaTelnet_VerifyMismatchFails` |
|
||||
| `/setMargeAccount` 502 / hang | 5s connect + 12s total budget enforced; falls through to telnet `envswitch accountid set` | `TestPairAccount_FallsBackWhenHTTPReturnsServerError` |
|
||||
| `/setMargeAccount` missing in `/supportedURLs` | HTTP path skipped; goes straight to telnet `envswitch accountid set` | `TestPairAccount_FallsBackWhenSetMargeAccountMissing` |
|
||||
| Both pairing paths unavailable | Structured error: "use the official Bose app before EOS, or open SSH and use the XML method" | `TestPairAccount_NoTelnetAndHTTPMissingReturnsClearError`, `TestPairAccount_TelnetCommandNotFoundReportsBothPaths` |
|
||||
|
||||
### 8.7 TL;DR
|
||||
|
||||
- **Green light** — ST 10, ST 20, ST 300, Wave III, Wave IV on FW 27.0.6 (multi-reporter agreement).
|
||||
- **Yellow** — ST Portable and BST20 Portable: migration works, pairing needs our fallback (already implemented).
|
||||
- **Red, but fails cleanly** — SA-5 on FW 9.x, possibly newer ST Portable builds.
|
||||
- **Unverified but expected to work** — ST 30, ST 520, Wave Music System I/II.
|
||||
|
||||
The most useful next verification step is touching a real ST 30 and ST 520
|
||||
— those are the two "expected to work" models with zero concrete captures.
|
||||
Beyond that, every behaviour the doc predicts is exercised by the unit
|
||||
tests in `pkg/telnet` and `pkg/service/setup`.
|
||||
|
||||
---
|
||||
|
||||
## 9. What actually shipped (post-implementation addendum)
|
||||
|
||||
§7 forecast the surface area roughly; the wizard ended up larger. This
|
||||
section is the present-day map of the migration tab and the supporting
|
||||
backend pieces — kept appended rather than rewritten in place so the
|
||||
feasibility analysis above stays a faithful design record.
|
||||
|
||||
### 9.1 Three-axis state model
|
||||
|
||||
`MigrationSummary` now exposes the four mechanism-specific booleans
|
||||
that `checkIsMigrated` writes individually:
|
||||
|
||||
- `XMLMigrated` — parsed SoundTouchSdkPrivateCfg.xml's URLs point at us.
|
||||
- `HostsMigrated` — `/etc/hosts` carries Bose-domain redirects (the
|
||||
deprecated method, kept detectable for legacy speakers).
|
||||
- `ResolvMigrated` — the `/etc/resolv.conf` priority-nameserver hook
|
||||
is in place (with CA trusted).
|
||||
- `TelnetMigrated` — `getpdo CurrentSystemConfiguration` reports the
|
||||
service hostname.
|
||||
|
||||
`IsMigrated` is the OR. Plus `IsPaired` from the live
|
||||
`:8090/info.margeAccountUUID` value.
|
||||
|
||||
The frontend opens with a state card that surfaces three orthogonal
|
||||
axes derived from these flags:
|
||||
|
||||
| Axis | Verdict semantics |
|
||||
|-------------------|-----------------------------------------------------------------------------------------------------------------------|
|
||||
| URL Configuration | URL flip active → ✅; original Bose URLs + DNS hook active → ✅ (intercepted); original + no DNS → ❌ (not intercepted). |
|
||||
| DNS Interception | None / resolv.conf hook / /etc/hosts (with deprecated badge). |
|
||||
| CA / TLS | Local root CA installed yes/no. |
|
||||
|
||||
Plus a Preconditions row: `remote_services` persistence, account
|
||||
pairing state, XML config backup presence. Action affordances
|
||||
(`Trust CA Now`, `Download CA cert`) live inline next to their verdicts.
|
||||
|
||||
### 9.2 Plan card with per-field URL editor
|
||||
|
||||
Replaces the XML method's `self/proxied/original` dropdowns and the
|
||||
duplicate URL inputs that used to live inside the telnet method pane:
|
||||
|
||||
- Target service URL input with `Save as default` (POSTs to
|
||||
`/setup/settings`, preserving the `***` secret-unchanged convention).
|
||||
- Capabilities header: detected transports (SSH / Telnet:17000) and
|
||||
the recipes AfterTouch can offer given those transports.
|
||||
- Service URLs table: four free-form URL inputs (Marge / Stats /
|
||||
SwUpdate / BmxRegistry) with on-keystroke validation
|
||||
(`validatePlanURLs`), a Soundcork-mode checkbox that flips `/marge`
|
||||
on `margeServerUrl`, and a `Reset to defaults` button.
|
||||
- Account pairing section: ID input + Generate + datastore picker;
|
||||
the implicit intent (`readPlanPairTarget`) queues a pair step at
|
||||
Apply when the input differs from the current `account_id`.
|
||||
- Suggested plan box: one-click conservative default — XML + HTTP
|
||||
when SSH works, Telnet + HTTP otherwise; "Already migrated" info
|
||||
state when `IsMigrated` is already true.
|
||||
|
||||
The per-field URLs feed both XML and Telnet migrations via the
|
||||
`marge_url` / `stats_url` / `sw_update_url` / `bmx_url` option family
|
||||
(see §9.6). Live preview rewrites `#planned-config` purely client-side
|
||||
on every keystroke — optimistic; the backend's perspective gates the
|
||||
write via §9.4's pre-flight.
|
||||
|
||||
### 9.3 Customize three-axis form
|
||||
|
||||
The `<details>` "Customize this migration" section replaces the old
|
||||
migration-method dropdown with three independent radio groups:
|
||||
|
||||
1. **URL flip transport**: XML / Telnet:17000 / Skip.
|
||||
2. **DNS interception**: None / `/etc/resolv.conf` hook.
|
||||
3. **Local CA install**: checkbox.
|
||||
|
||||
Each option carries a per-axis availability hint
|
||||
(`(SSH unreachable)`, `(already trusted)`, etc.) so users see *why*
|
||||
an option is disabled. `applyCustomPlan` orchestrates the chosen
|
||||
combination as a sequence of existing backend calls
|
||||
(`/setup/migrate?method=…` for each flip/resolv step plus
|
||||
`/setup/trust-ca` for standalone CA install, and the queued pair
|
||||
step from §9.2). Resolv already bundles a CA install, so a redundant
|
||||
standalone CA step is skipped. First failure aborts the rest.
|
||||
|
||||
### 9.4 Pre-flight panel
|
||||
|
||||
Both Apply paths run a visible pre-flight panel before any backend
|
||||
operation touches the speaker. Each check renders inline with the
|
||||
🕐 / ⟳ / ✅ / ❌ / — idiom. On all-green the panel holds for ~700ms so
|
||||
the success state registers, then auto-proceeds. On any failure the
|
||||
panel surfaces `Proceed Anyway` / `Cancel` buttons; default is to
|
||||
abort.
|
||||
|
||||
Checks:
|
||||
|
||||
| Check | When | Backend route |
|
||||
|---------------------------------------|------------------------------------------------------------|--------------------------------|
|
||||
| Backend summary re-check | always | `GET /setup/summary` |
|
||||
| HTTPS connection from device | `ssh_success && server_https_url` | `POST /setup/test-connection` |
|
||||
| Reachability check (passive observer) | `telnet_reachable && is_migrated` (see §9.8) | `POST /setup/peer-probe` |
|
||||
| Round-trip skip explainer | `telnet_reachable && !is_migrated` — runs after reboot | _none_ (UI-side skip row) |
|
||||
| DNS redirection from device | `methods.includes("resolv") && ssh_success` | `POST /setup/test-dns` |
|
||||
|
||||
The HTTPS check uses `use_explicit_ca=true` so it exercises the trust
|
||||
path even when CA install is part of the plan (i.e. forward-looking).
|
||||
The reachability skip row is explicit ("neither SSH nor Telnet:17000
|
||||
is reachable") rather than silently dropped, per the user's
|
||||
"feedback always visible" requirement.
|
||||
|
||||
### 9.5 Telnet round-trip probe — the SSH-less reachability check
|
||||
|
||||
> **REMOVED — see §9.8.** Empirical testing showed the swUpdate
|
||||
> daemon caches its target URL at boot and ignores live config
|
||||
> writes, so the active flip described below could never reach the
|
||||
> running daemon. The section is retained as a historical record of
|
||||
> what was tried; the running code uses the passive observer in §9.8.
|
||||
|
||||
The reachability gap §7 left open for USB-unlock-refusing speakers is
|
||||
closed by `Manager.RunTelnetRoundTripProbe`
|
||||
(`pkg/service/setup/telnet_probe.go`). Sequence:
|
||||
|
||||
1. Telnet `getpdo CurrentSystemConfiguration` to capture the
|
||||
speaker's current `swUpdateUrl`.
|
||||
2. Generate a random 24-hex-char token; register a one-shot signal
|
||||
channel under it on the new `probeRegistry` (sibling field on
|
||||
`handlers.Server`).
|
||||
3. Telnet `sys configuration swUpdateUrl <targetURL>/probe/<token>`
|
||||
— **runtime layer only, deliberately not `envswitch boseurls set
|
||||
…`**. The persistence layer keeps the original URL, so a reboot
|
||||
heals the device naturally if our restore step fails.
|
||||
4. `HTTP GET <deviceIP>:8090/swUpdateCheck` — the cleanest
|
||||
`:8090` endpoint that triggers exactly one outbound to the
|
||||
configured `swUpdateUrl`. Read-only on the cloud side
|
||||
(doesn't initiate an update); independent of `margeAccountUUID`
|
||||
so it works on factory-reset speakers.
|
||||
5. Wait on the registered channel up to `telnetProbeTimeout` (6s).
|
||||
6. Telnet `sys configuration swUpdateUrl <originalURL>` — deferred
|
||||
restore so it runs even on the failure path.
|
||||
|
||||
The new `/probe/{token}[/*]` catch-all on the root router signals the
|
||||
matching channel when the speaker's outbound lands. The response is
|
||||
a minimal `<swUpdateIndex/>` so the speaker's `swUpdateCheck`
|
||||
doesn't choke on a missing structure. The `/*` sub-path is
|
||||
registered because some firmware appends a path component to the
|
||||
configured `swUpdateUrl`.
|
||||
|
||||
### 9.6 Backend additions worth knowing
|
||||
|
||||
| Addition | Where | Why |
|
||||
|------------------------------------------------------------------------|----------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `applyURLOverrides(cfg, options)` | `pkg/service/setup/setup.go` | Per-field literal `marge_url` / `stats_url` / `sw_update_url` / `bmx_url` overrides win over `applyProxyOptions`. Honored by both `GetMigrationSummary` and `migrateViaXML`. |
|
||||
| `telnetURLsFromOptions(targetURL, options)` | `pkg/service/setup/telnet_migration.go` | Same option family as above, plus envswitch arg derivation rule (arg1 = final Marge verbatim; the soundcork-suffix case drops out). |
|
||||
| Per-axis booleans + `IsPaired` + `Warnings` | `MigrationSummary` | Surfaces partial-state cells and SSH-XML ⇄ telnet-getpdo cross-check disagreements. |
|
||||
| `parseGetpdoConfig` | `pkg/service/setup/preflight_crosscheck.go` | Parses the Protobuf-text-like nested-block reply (`key { text: "..." }`) FW 27.0.6 actually sends, plus the legacy `key=value` shape as a tolerance path. |
|
||||
| `peerObserver` + `RunPeerReachabilityProbe` + `/setup/peer-probe` | `pkg/service/handlers` / `pkg/service/setup` | §9.8. Replaces the removed `probeRegistry` + `RunTelnetRoundTripProbe` + `/setup/telnet-probe` from §9.5. |
|
||||
| `migrationOptionKeys` allow-list | `pkg/service/handlers/migration_options.go` | Unknown query keys never reach the manager. Both XML mode keys and `*_url` keys are recognised. |
|
||||
| Telnet client default timeouts: dial 4s, read 7s, write 3s, idle 600ms | `pkg/telnet/telnet.go` | Bumped from the original 2s/5s/2s/400ms after observing transient i/o-timeout flakes on healthy speakers that recovered on retry. |
|
||||
|
||||
### 9.7 Future probe candidates
|
||||
|
||||
- `:8090/pushCustomerSupportInfoToMarge` — flagged as a potential
|
||||
"ask the device about itself" probe that could feed a richer
|
||||
device-info pane (firmware build dates, hardware revisions). Not
|
||||
implemented.
|
||||
- Running the round-trip probe on SSH-capable speakers too (as
|
||||
additional validation alongside the curl-from-device HTTPS test),
|
||||
not just as the SSH-less fallback it is today. **Subsumed by §9.8
|
||||
— the round-trip probe is being removed; the passive observer is
|
||||
transport-agnostic and replaces it for migrated speakers.**
|
||||
|
||||
### 9.8 The swUpdate daemon-cache finding and removal of §9.5
|
||||
|
||||
The §9.5 round-trip probe was retired after empirical testing on a
|
||||
fully-migrated speaker (FW 27.0.6) revealed that the `swUpdate`
|
||||
daemon **caches its target URL at boot and ignores live config
|
||||
writes**. The diagnostic sequence:
|
||||
|
||||
1. Manual telnet flip of both layers — `sys configuration swUpdateUrl
|
||||
<probe-url>` (runtime) **and** `envswitch boseurls set <marge>
|
||||
<probe-url>` (persistence). `getpdo CurrentSystemConfiguration`
|
||||
confirmed both writes stuck.
|
||||
2. HTTP GET `:8090/swUpdateCheck` to trigger fan-out.
|
||||
3. Service access log showed the device outbound landed on
|
||||
`/updates/soundtouch` (the **previous** `swUpdateUrl` value, current
|
||||
at the last daemon boot) and `/streaming/software/update/account/<id>`
|
||||
(a separate Bose URL the daemon hits, routed to this service by DNS
|
||||
interception). The probe URL was never dialed.
|
||||
|
||||
This falsifies the original NEXT.md hypothesis that the persistence
|
||||
layer would override the runtime layer for the daemon's fan-out, and
|
||||
points instead at daemon-level URL caching. Two consequences:
|
||||
|
||||
- **The §9.5 probe cannot work on migrated speakers without a
|
||||
reboot.** The cached URL is set when the daemon starts; flipping
|
||||
config after that point has no effect on what the daemon dials.
|
||||
- **The §9.5 probe likely cannot work on unmigrated speakers
|
||||
either**, for the same reason — the daemon caches whatever URL it
|
||||
read at startup, which on an unmigrated speaker is the Bose cloud
|
||||
URL. We have no service running with the probe URL registered on
|
||||
unmigrated speakers, so the original "it worked in testing" claim
|
||||
has no empirical basis; it likely failed silently because nothing
|
||||
was watching.
|
||||
|
||||
The honest replacement is a **passive observer** (see
|
||||
`pkg/service/setup/peer_probe.go`):
|
||||
|
||||
1. Register the device IP with an in-process observer
|
||||
(`handlers.peerObserver`, wired via `PeerObserverMiddleware`).
|
||||
2. Nudge `:8090/swUpdateCheck` to make the daemon fan out *something*
|
||||
sooner than its ~5min timer.
|
||||
3. Wait up to 30s for any inbound from that IP. On a migrated
|
||||
speaker, DNS interception means the daemon's outbounds (update
|
||||
fan-out, marge polls, BMX registry calls) all funnel through this
|
||||
service regardless of which URL the daemon resolved internally —
|
||||
so reachability reduces to *"did the device dial us at all."*
|
||||
|
||||
Endpoint: `POST /setup/peer-probe/{deviceId}`. No device-state
|
||||
mutation; safe to re-run. Returns `{ok, result: {reached,
|
||||
observed_path, elapsed_ms}, error}` with the same UI keying as the
|
||||
old probe (`result.reached`).
|
||||
|
||||
#### 9.8.1 The pre-flight panel branch
|
||||
|
||||
The web UI's pre-flight orchestrator (`runApplyPreflight` in
|
||||
`script.js`) branches on `summary.is_migrated`:
|
||||
|
||||
| Migration state | Reachability row |
|
||||
|-----------------------------------|------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Migrated (`is_migrated=true`) | "Reachability check (passive observer)" — calls `POST /setup/peer-probe/{deviceId}`. |
|
||||
| Not migrated (incl. partial) | Skip row "Round-trip validation runs after Apply + reboot" with the rationale "daemon caches swUpdateUrl at boot". |
|
||||
|
||||
Per-axis booleans (`xml_migrated`, `hosts_migrated`, `resolv_migrated`,
|
||||
`telnet_migrated`) remain visible in the State card, so the user can
|
||||
see which parts of the migration are already in place even when the
|
||||
overall flag is false. The skip row does not attempt the active probe
|
||||
on unmigrated speakers — the canonical telnet flow is:
|
||||
|
||||
```
|
||||
Apply telnet config → user-initiated reboot → re-run pre-flight on
|
||||
the now-migrated speaker → passive observer confirms fan-out.
|
||||
```
|
||||
|
||||
#### 9.8.2 Removal trail
|
||||
|
||||
Removed (or scheduled for removal in a follow-up commit) at the time
|
||||
of §9.8 landing:
|
||||
|
||||
- `pkg/service/setup/telnet_probe.go` — `RunTelnetRoundTripProbe`,
|
||||
`ProbeRegistrar`, `TelnetProbeResult`, `generateProbeToken`.
|
||||
- `pkg/service/handlers/handlers_telnet_probe.go` — `HandleTelnetProbe`,
|
||||
`HandleProbeInbound`, `telnetProbeTimeout`, `telnetProbeResponse`.
|
||||
- `pkg/service/handlers/probe_registry.go` — `probeRegistry` + tests.
|
||||
- `Server.probes` field.
|
||||
- Routes `/probe/{token}`, `/probe/{token}/*`, `/setup/telnet-probe/{deviceId}`.
|
||||
- The `target_url` query-param plumbing on the deprecated endpoint.
|
||||
- `script.js` — `checkTelnetRoundTrip` (orchestrator call site removed
|
||||
in the commit that added the branch; function itself removed later).
|
||||
|
||||
`isCommandNotFound` and `parseGetpdoConfig` stay — they are also used
|
||||
by the migration writer (`telnet_migration.go`), preflight reader
|
||||
(`telnet_preflight.go`), pairing path (`marge_pairing.go`), and
|
||||
cross-check (`preflight_crosscheck.go`).
|
||||
@@ -656,10 +656,10 @@ func main() {
|
||||
soundtouch discover
|
||||
|
||||
# Device operations
|
||||
soundtouch --device 192.168.1.100 info
|
||||
soundtouch --device 192.168.1.100 play
|
||||
soundtouch --device 192.168.1.100 volume 50
|
||||
soundtouch --device 192.168.1.100 preset 1
|
||||
soundtouch --device 192.0.2.100 info
|
||||
soundtouch --device 192.0.2.100 play
|
||||
soundtouch --device 192.0.2.100 volume 50
|
||||
soundtouch --device 192.0.2.100 preset 1
|
||||
|
||||
# Interactive mode
|
||||
soundtouch interactive
|
||||
|
||||
@@ -171,7 +171,7 @@ This project implements a comprehensive Go client library and CLI tool for Bose
|
||||
- **Notification System**: TTS and URL playback with multi-language support
|
||||
- **API Compliance**: Proper press+release key pattern implementation
|
||||
- **Safety First**: Volume warnings and limits for user protection
|
||||
- **User Experience**: Host:port parsing (e.g., `-host 192.168.1.100:8090`)
|
||||
- **User Experience**: Host:port parsing (e.g., `-host 192.0.2.100:8090`)
|
||||
- **CLI Enhancement**: Direct flags for common operations and audio control
|
||||
- **Discovery Excellence**: Multi-protocol discovery (UPnP + mDNS) with caching
|
||||
- **Real Device Testing**: Validated with SoundTouch 10 and SoundTouch 20
|
||||
@@ -193,7 +193,7 @@ This project implements a comprehensive Go client library and CLI tool for Bose
|
||||
- **HTTP Client**: Mock server tests with real response data
|
||||
|
||||
### Integration Tests
|
||||
- **Real Devices**: SoundTouch 10 (192.168.1.10) and SoundTouch 20 (192.168.1.11)
|
||||
- **Real Devices**: SoundTouch 10 (192.0.2.10) and SoundTouch 20 (192.0.2.11)
|
||||
- **All Endpoints**: Validated against actual hardware
|
||||
- **Source Selection**: Tested with Spotify, TuneIn, and other available sources
|
||||
- **Bass Control**: Tested bass adjustment, validation, and device-specific behavior
|
||||
|
||||
@@ -7,7 +7,7 @@ This document serves as the entry point for understanding the comprehensive plan
|
||||
## Project Objectives
|
||||
|
||||
### Primary Goal
|
||||
Create a robust, local replacement for Bose's upstream services that can seamlessly handle the transition from cloud-dependent to fully autonomous operation while maintaining and improving upon the existing functionality.
|
||||
Create a robust replacement for Bose's upstream services that can seamlessly handle the transition from cloud-dependent to fully autonomous operation while maintaining and improving upon the existing functionality.
|
||||
|
||||
### Key Outcomes
|
||||
- **Zero-downtime transition** from Bose services to local management
|
||||
@@ -21,7 +21,7 @@ Create a robust, local replacement for Bose's upstream services that can seamles
|
||||
### Current State
|
||||
The existing SoundTouch service provides:
|
||||
- BMX service for TuneIn integration
|
||||
- Marge service for account and device management
|
||||
- Marge service for account and device management
|
||||
- Basic mirroring of upstream Bose endpoints
|
||||
- File-based persistence for device data
|
||||
- Migration support for device directory structures
|
||||
@@ -42,7 +42,7 @@ The enhanced system will add:
|
||||
- **Mirror-Enhanced Setup**: Use upstream data to enrich account creation
|
||||
- **Passive Data Collection**: Record account information during normal operations
|
||||
|
||||
### Case 1a: Fresh Device Registration
|
||||
### Case 1a: Fresh Device Registration
|
||||
- **Factory Reset Support**: Handle devices with no prior Bose association
|
||||
- **Default Configuration**: Initialize devices with sensible presets and sources
|
||||
- **Local-First Setup**: Complete registration without upstream dependencies
|
||||
@@ -100,7 +100,7 @@ data/
|
||||
- Basic API endpoints with comprehensive testing
|
||||
- Integration with existing datastore patterns
|
||||
|
||||
### Phase 2: Device Lifecycle (2-3 weeks) - Build on Existing Systems
|
||||
### Phase 2: Device Lifecycle (2-3 weeks) - Build on Existing Systems
|
||||
- Event processing using existing WebSocket system
|
||||
- Lifecycle integration with current discovery and migration
|
||||
- Enhanced logging building on existing parity detection
|
||||
@@ -178,4 +178,4 @@ This concept is detailed across several documents:
|
||||
3. **Resource Planning**: Allocate development resources for the three-phase implementation
|
||||
4. **Community Engagement**: Share plans with the community for feedback and contributions
|
||||
|
||||
This enhanced state management system represents a significant evolution of the SoundTouch service, transforming it from a basic cloud replacement into a comprehensive, future-proof device management platform that can serve users well beyond the Bose service shutdown timeline.
|
||||
This enhanced state management system represents a significant evolution of the SoundTouch service, transforming it from a basic replacement into a comprehensive, future-proof device management platform that can serve users well beyond the Bose service shutdown timeline.
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Spotify OAuth Integration
|
||||
|
||||
> **New here?** Start with [spotify-overview.md](spotify-overview.md) for the
|
||||
> mental model (Spotify Connect vs OAuth-intercept, DNS rewrite gotcha,
|
||||
> end-to-end token lifecycle). This document zooms in on the OAuth flows and
|
||||
> management endpoints.
|
||||
|
||||
The SoundTouch service supports Spotify OAuth integration to broker access tokens for SoundTouch speakers. This is particularly useful for maintaining Spotify Connect functionality after the Bose cloud shutdown (scheduled for May 2026).
|
||||
|
||||
## OAuth Flows
|
||||
@@ -91,43 +96,23 @@ sequenceDiagram
|
||||
Note over Speaker: Speaker now has Spotify access
|
||||
```
|
||||
|
||||
## Boot Primer Script
|
||||
## Priming Speakers
|
||||
|
||||
A boot primer script that uses these endpoints to feed Spotify tokens to speakers via ZeroConf is available in the `scripts/spotify/` directory: [spotify-boot-primer.sh](../../scripts/spotify/spotify-boot-primer.sh).
|
||||
|
||||
This script can be installed on the speaker itself (which runs embedded Linux) to automatically prime Spotify Connect at boot time. See [README.md](../../scripts/spotify/README.md) and [INSTALL.md](../../scripts/spotify/INSTALL.md) for instructions.
|
||||
|
||||
### Automated Installation via Service
|
||||
|
||||
The SoundTouch service provides a dedicated management endpoint to automatically handle the installation of the Spotify boot primer on the speaker:
|
||||
`POST /mgmt/devices/{deviceId}/spotify/install-primer`
|
||||
|
||||
### Automated Installation Steps
|
||||
When you run the Spotify primer installation, the service performs the following:
|
||||
1. **Directories**: Creates `/mnt/nv/bin` and `/mnt/nv/BoseApp-Persistence/1` on the speaker.
|
||||
2. **Binary**: Uploads the `spotify-boot-primer` script to the speaker.
|
||||
3. **Configuration**: Automatically generates and uploads `spotify-primer.conf` containing the service's URL and management credentials.
|
||||
4. **Boot Hook**: Injects a call to the primer in the speaker's `/mnt/nv/rc.local` using idempotent markers.
|
||||
5. **Environment**: Updates `/mnt/nv/.profile` to include `/mnt/nv/bin` in the `PATH` for easier manual troubleshooting via SSH.
|
||||
|
||||
- **Idempotent Patching**: The service uses explicit markers to inject the hook, ensuring it doesn't corrupt existing content.
|
||||
- **Coexistence**: The service-injected hook is designed to coexist with a manually installed `rc.local` (e.g., from the community gist). It only adds a call to `/mnt/nv/bin/spotify-boot-primer` if it's not already managed by a service-controlled block.
|
||||
- **Markers**: Look for the following markers in your speaker's `/mnt/nv/rc.local`:
|
||||
- `# --- Aftertouch Spotify hook START ---`
|
||||
- `# --- Aftertouch Spotify hook END ---`
|
||||
- **Cleanup**: Reverting a migration via the service will cleanly remove these marker-delimited blocks.
|
||||
> **Note:** The on-device boot-primer flow (installing `spotify-boot-primer.sh` onto the speaker's `/mnt/nv` and hooking it from `rc.local`) is **deprecated**. AfterTouch now uses a server-centric model: the service registers a `SPOTIFY` source in marge for the device's paired account and pushes credentials via ZeroConf from the server side, triggered on `power_on` and a manual "Prime" action. See [spotify-priming-strategy.md](spotify-priming-strategy.md) for the current model and rationale.
|
||||
>
|
||||
> The artifacts under `scripts/spotify/` are kept as historical reference for users who still rely on the on-device approach. There is no longer a `/mgmt/devices/{deviceId}/spotify/install-primer` endpoint.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|--------|---------------------------------------------------|-------|-----------------------------------------------------------------------|
|
||||
| POST | `/mgmt/devices/{deviceId}/spotify/install-primer` | Basic | Install Spotify boot primer on speaker (deviceId or IP) |
|
||||
| GET | `/mgmt/spotify/callback` | None | Browser OAuth callback (redirect from Spotify, returns HTML) |
|
||||
| POST | `/mgmt/spotify/init` | Basic | Start OAuth flow, returns authorization URL |
|
||||
| GET | `/mgmt/spotify/callback` | None | Browser OAuth callback (redirect from Spotify, returns HTML) |
|
||||
| POST | `/mgmt/spotify/confirm` | Basic | Mobile app confirm (ueberboese deep link delivers code, returns JSON) |
|
||||
| GET | `/mgmt/spotify/accounts` | Basic | List linked Spotify accounts (tokens stripped) |
|
||||
| GET | `/mgmt/spotify/token` | Basic | Get fresh access token (auto-refreshes if expired) |
|
||||
| POST | `/mgmt/spotify/entity` | Basic | Resolve Spotify URI to name + image URL |
|
||||
| POST | `/mgmt/spotify/prime` | Basic | Manually trigger server-side priming of a discovered speaker |
|
||||
|
||||
## Security
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
# Spotify on SoundTouch — Overview
|
||||
|
||||
This is the entry point for understanding how Spotify works on a SoundTouch
|
||||
speaker behind AfterTouch. Read this first; the deeper docs assume you already
|
||||
have the mental model below.
|
||||
|
||||
> **Premium likely required.** As far as we know, Spotify Connect on
|
||||
> SoundTouch only works with a Spotify Premium account — this matches our
|
||||
> testing and matches what other SoundTouch-replacement projects report, but
|
||||
> we have not exhaustively verified every account tier or region. None of the
|
||||
> workarounds in this document change Spotify's account-tier requirements.
|
||||
|
||||
## Two completely separate Spotify paths
|
||||
|
||||
These are routinely confused. They share a speaker and a Spotify account, but
|
||||
they ride on different infrastructure and fail for different reasons.
|
||||
|
||||
### 1. Spotify Connect (speaker-native, independent of AfterTouch)
|
||||
|
||||
- The speaker advertises itself on the LAN as a Spotify Connect endpoint
|
||||
(mDNS service `_spotify-connect._tcp`).
|
||||
- You open the Spotify app on your phone or desktop, tap the Connect device
|
||||
picker, and select the SoundTouch.
|
||||
- Audio streams directly from Spotify's CDN to the speaker. Token handling,
|
||||
session setup, and playback all happen between Spotify and the speaker.
|
||||
- **AfterTouch is not involved.** It still works even if AfterTouch is
|
||||
offline.
|
||||
|
||||
This is the simplest path. If you only want to push playback from your phone,
|
||||
you do not need to link Spotify to AfterTouch at all — see [Manual kick-start
|
||||
alternative](#manual-kick-start-alternative) below.
|
||||
|
||||
### 2. OAuth-intercept path (managed by AfterTouch)
|
||||
|
||||
This is what enables features that originate **from the speaker**:
|
||||
|
||||
- Spotify presets on the speaker's buttons.
|
||||
- Spotify playback from the Bose app's source picker.
|
||||
- "Resume Spotify" after a power cycle without touching the Spotify app.
|
||||
|
||||
After Bose's cloud shutdown (May 2026), the speaker can no longer reach
|
||||
Bose's OAuth server for Spotify token refresh. AfterTouch intercepts those
|
||||
calls via DNS, brokers tokens with Spotify using your linked account, and
|
||||
hands them back to the speaker.
|
||||
|
||||
The rest of this document describes that path.
|
||||
|
||||
## Setup at a glance
|
||||
|
||||
Full step-by-step is in
|
||||
[docs/guides/MUSIC-SERVICES.md](../guides/MUSIC-SERVICES.md). Summary:
|
||||
|
||||
1. **Register a Spotify developer app** (one-time, by the AfterTouch operator).
|
||||
2. **Configure AfterTouch** with the Client ID, Client Secret, and Redirect
|
||||
URI in the Settings tab.
|
||||
3. **Authorize your Spotify account** via the Local Account tab — completes
|
||||
the OAuth flow and persists a long-lived refresh token to AfterTouch's
|
||||
datastore.
|
||||
4. **Prime each speaker** so its source list and ZeroConf state know about
|
||||
Spotify.
|
||||
|
||||
After step 4, presets and Bose-app-initiated Spotify playback work.
|
||||
|
||||
## The DNS rewrite — easy to miss, breaks everything
|
||||
|
||||
Bose firmware does **not** read a separate OAuth server hostname from
|
||||
configuration. It derives the OAuth host from the marge host by inserting
|
||||
`oauth` into the first label:
|
||||
|
||||
| Purpose | Hostname |
|
||||
|-----------------|---------------------------|
|
||||
| Marge / sources | `streaming.bose.com` |
|
||||
| OAuth refresh | `streamingoauth.bose.com` |
|
||||
|
||||
**Both hostnames must resolve to AfterTouch.** AfterTouch's DNS server hijacks
|
||||
both, but if you bypass that DNS server (e.g. by hard-coding only the marge
|
||||
hostname in `/etc/hosts`, or by routing only one through a custom resolver),
|
||||
token refresh will silently die while the speaker still pulls sources.
|
||||
Symptom: the speaker briefly streams Spotify after priming, then stops at the
|
||||
first token refresh ~1 hour later.
|
||||
|
||||
If you self-host AfterTouch at e.g. `aftertouch.local`, you would equivalently
|
||||
need `aftertouchoauth.local` for the OAuth interception path.
|
||||
|
||||
## End-to-end token lifecycle
|
||||
|
||||
What actually happens, from priming to steady-state playback:
|
||||
|
||||
1. **Operator links Spotify account.** OAuth flow stores
|
||||
`{user_id, refresh_token, bose_secret}` in `spotify/accounts.json`. The
|
||||
`bose_secret` is an opaque surrogate (e.g. `bs-deadbeef…`) that AfterTouch
|
||||
issues; the speaker only ever sees this surrogate, never the real Spotify
|
||||
refresh token.
|
||||
2. **Priming runs.** Either on speaker `power_on`, on discovery, or on a
|
||||
manual `POST /mgmt/spotify/prime`. AfterTouch:
|
||||
- Resolves the speaker's currently-paired account via live `:8090/info`
|
||||
(`margeAccountUUID`).
|
||||
- Writes a `SPOTIFY` `ConfiguredSource` into marge under that account with
|
||||
`secret = bose_secret`, `secretType = token_version_3`.
|
||||
- POSTs `<updates><sourcesUpdated/></updates>` to the speaker's
|
||||
`:8090/notification`, causing the speaker to re-fetch
|
||||
`/streaming/account/{account}/full` and pick up the new source.
|
||||
- Optionally pushes a fresh access token to the speaker's ZeroConf
|
||||
endpoint (`:8200/zc?action=addUser`). This is best-effort — see
|
||||
[ZeroConf clientId and benign 404s](#zeroconf-clientid-and-benign-404s).
|
||||
3. **Speaker pulls sources.** It now has a SPOTIFY entry with the surrogate
|
||||
as its credential. The speaker stores this; from its perspective the
|
||||
surrogate is the refresh token.
|
||||
4. **Speaker uses Spotify.** When it needs a fresh access token (every ~1 h
|
||||
on Spotify's clock), it POSTs to
|
||||
`streamingoauth.bose.com/oauth/device/{deviceID}/music/musicprovider/15/token/cs3`
|
||||
with the surrogate.
|
||||
5. **AfterTouch translates.** DNS hijack routes the request to AfterTouch,
|
||||
which looks up the surrogate, performs the real refresh against Spotify
|
||||
using the stored refresh token, and returns the resulting access token to
|
||||
the speaker.
|
||||
6. **Speaker uses the access token** for Spotify Web API metadata calls
|
||||
(artwork, track lookups, playback container resolution).
|
||||
|
||||
Forensic details of the request shapes are in
|
||||
[docs/reference/spotify-account-addition.md](../reference/spotify-account-addition.md).
|
||||
The cryptographic specifics of the ZeroConf `addUser` blob are in
|
||||
[spotify-priming-strategy.md](spotify-priming-strategy.md).
|
||||
|
||||
## ZeroConf clientId and benign 404s
|
||||
|
||||
`GET http://<speaker>:8200/zc?action=getInfo` returns, among other fields:
|
||||
|
||||
```json
|
||||
"clientID": "79ebcb219e8e4e9a892e796607931810"
|
||||
"tokenType": "accesstoken"
|
||||
"activeUser": "<spotify-user-id-or-empty>"
|
||||
```
|
||||
|
||||
That `clientID` is **Bose's official Spotify Connect partner client_id**,
|
||||
baked into firmware. It is **not** the client_id of the developer app you
|
||||
registered for AfterTouch — those are two unrelated OAuth apps, by design.
|
||||
The Bose-baked one is what Spotify Connect uses when a Spotify mobile app
|
||||
discovers the speaker on the LAN. The AfterTouch-registered one is what
|
||||
brokers refresh tokens for the OAuth-intercept path. They never converge.
|
||||
|
||||
**Implication:** an access token AfterTouch obtained under its own client_id
|
||||
is not directly usable as a Spotify Connect session token. Pushing it via
|
||||
ZeroConf `addUser` is best-effort, and the speaker may respond with a `404`
|
||||
and an empty body when its `activeUser` already matches the username being
|
||||
pushed — that is the firmware's idiomatic "no transition required" signal,
|
||||
not a failure. AfterTouch recognises this case (`zeroconf.ErrAddUserNoOp`)
|
||||
and logs it as an expected no-op rather than an error.
|
||||
|
||||
A 404 **with a body**, or any other non-2xx, is treated as a real failure
|
||||
and logged loudly with the response headers and body so it can be
|
||||
diagnosed.
|
||||
|
||||
## Manual kick-start alternative
|
||||
|
||||
You can skip the OAuth setup entirely if you only want playback pushed from
|
||||
the Spotify app:
|
||||
|
||||
1. Open the Spotify mobile/desktop app.
|
||||
2. Start any track.
|
||||
3. Open the Connect device picker, select the SoundTouch.
|
||||
|
||||
The speaker now holds an in-memory Spotify Connect session and can play
|
||||
until next reboot. Presets and Bose-app-initiated Spotify playback will
|
||||
still not work — those require the OAuth-intercept path — but Spotify-app-
|
||||
initiated playback does.
|
||||
|
||||
## Troubleshooting quick reference
|
||||
|
||||
| Symptom | Most likely cause |
|
||||
|----------------------------------------------------|------------------------------------------------------------------------------------------------|
|
||||
| Preset stores then fails: "invalid SourceID" | No `SPOTIFY` source in marge for the speaker's paired account. Re-run priming. |
|
||||
| Preset stores fine; playback dies after ~1 hour | `streamingoauth.bose.com` not pointed at AfterTouch (DNS rewrite gap). |
|
||||
| Speaker has source but `Sources.xml` looks stale | `<sourcesUpdated/>` notification did not reach the speaker. Re-run priming or POST it by hand. |
|
||||
| ZeroConf `addUser` returns 404, empty body | Benign no-op; speaker already has `activeUser` set. Marge path is authoritative. |
|
||||
| Spotify Connect device picker doesn't show speaker | Unrelated to AfterTouch; check the speaker's mDNS visibility on the LAN. |
|
||||
|
||||
## Where to go next
|
||||
|
||||
- **Setup walkthrough:** [docs/guides/MUSIC-SERVICES.md](../guides/MUSIC-SERVICES.md)
|
||||
- **OAuth flow details (browser + mobile + endpoint table):** [spotify-oauth.md](spotify-oauth.md)
|
||||
- **Priming strategy, ZeroConf DH protocol, deployment topologies:** [spotify-priming-strategy.md](spotify-priming-strategy.md)
|
||||
- **Forensic request/response analysis from the Stockholm app:** [docs/reference/spotify-account-addition.md](../reference/spotify-account-addition.md)
|
||||
@@ -1,5 +1,9 @@
|
||||
# Spotify Priming Strategy
|
||||
|
||||
> **New here?** Start with [spotify-overview.md](spotify-overview.md) for the
|
||||
> mental model. This document goes deep on the priming protocol, ZeroConf DH
|
||||
> exchange, and deployment topologies.
|
||||
|
||||
This document outlines the strategy for ensuring Bose SoundTouch devices are correctly "primed" for Spotify Connect integration within the AfterTouch ecosystem.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
│ SoundTouch Service │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ HTTP Router & Middleware │
|
||||
│ ├── Mirror Middleware (Enhanced) │
|
||||
│ ├── Recorder Middleware │
|
||||
│ ├── Disparity Detection │
|
||||
│ └── Health Check Middleware │
|
||||
@@ -35,12 +34,11 @@
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Data Layer │
|
||||
│ ├── Enhanced DataStore ├── Event Store │
|
||||
│ ├── Mirror Cache ├── Metrics Store │
|
||||
│ └── Configuration Store └── Session Store │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ External Integrations │
|
||||
│ ├── Bose Services (Mirror) ├── Device Discovery │
|
||||
│ ├── BMX/TuneIn Services └── SSH/Setup Manager │
|
||||
│ ├── Device Discovery ├── BMX/TuneIn Services │
|
||||
│ └── SSH/Setup Manager │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -69,10 +67,6 @@ pkg/service/
|
||||
│ ├── processor.go
|
||||
│ ├── queue.go
|
||||
│ └── storage.go
|
||||
├── mirror/ # Enhanced mirroring (extends existing)
|
||||
│ ├── disparity.go
|
||||
│ ├── analyzer.go
|
||||
│ └── logger.go
|
||||
├── health/ # System monitoring
|
||||
│ ├── monitor.go
|
||||
│ ├── metrics.go
|
||||
@@ -115,20 +109,17 @@ type MigrationInfo struct {
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
DevicesMigrated int `json:"devices_migrated"`
|
||||
DevicesPending int `json:"devices_pending"`
|
||||
MirrorActive bool `json:"mirror_active"`
|
||||
Strategy string `json:"strategy"`
|
||||
RollbackData string `json:"rollback_data,omitempty"`
|
||||
}
|
||||
|
||||
type DataSourceConfig struct {
|
||||
Local bool `json:"local"`
|
||||
BoseMirror bool `json:"bose_mirror"`
|
||||
Primary string `json:"primary"` // "local" or "bose"
|
||||
}
|
||||
|
||||
type AccountSettings struct {
|
||||
AutoMigration bool `json:"auto_migration"`
|
||||
MirrorEndpoints []string `json:"mirror_endpoints"`
|
||||
RetentionDays int `json:"retention_days"`
|
||||
}
|
||||
```
|
||||
@@ -235,7 +226,6 @@ type EventSource string
|
||||
const (
|
||||
EventSourceWebSocket EventSource = "websocket"
|
||||
EventSourceDiscovery EventSource = "discovery"
|
||||
EventSourceMirror EventSource = "mirror"
|
||||
EventSourceSystem EventSource = "system"
|
||||
EventSourceAPI EventSource = "api"
|
||||
EventSourceUser EventSource = "user"
|
||||
@@ -336,12 +326,10 @@ Response: 200 OK
|
||||
"migration_info": {
|
||||
"started_at": "2024-01-18T09:00:00Z",
|
||||
"devices_migrated": 1,
|
||||
"devices_pending": 1,
|
||||
"mirror_active": true
|
||||
"devices_pending": 1
|
||||
},
|
||||
"data_sources": {
|
||||
"local": true,
|
||||
"bose_mirror": true,
|
||||
"primary": "bose"
|
||||
}
|
||||
}
|
||||
@@ -368,14 +356,14 @@ POST /api/v1/accounts/{account_id}/devices
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"device_id": "A81B6A536A98",
|
||||
"device_id": "AABBCCDDEEFF",
|
||||
"name": "Living Room Speaker",
|
||||
"registration_type": "fresh"
|
||||
}
|
||||
|
||||
Response: 201 Created
|
||||
{
|
||||
"device_id": "A81B6A536A98",
|
||||
"device_id": "AABBCCDDEEFF",
|
||||
"account_id": "acc_12345",
|
||||
"state": "registering",
|
||||
"created_at": "2024-01-20T10:00:00Z"
|
||||
@@ -388,7 +376,7 @@ GET /api/v1/accounts/{account_id}/devices/{device_id}/state
|
||||
|
||||
Response: 200 OK
|
||||
{
|
||||
"device_id": "A81B6A536A98",
|
||||
"device_id": "AABBCCDDEEFF",
|
||||
"account_id": "acc_12345",
|
||||
"state": "active",
|
||||
"metadata": {
|
||||
@@ -474,8 +462,7 @@ Response: 200 OK
|
||||
"services": {
|
||||
"account_manager": "healthy",
|
||||
"lifecycle_manager": "healthy",
|
||||
"event_processor": "healthy",
|
||||
"mirror_service": "warning"
|
||||
"event_processor": "healthy"
|
||||
},
|
||||
"statistics": {
|
||||
"total_accounts": 5,
|
||||
@@ -534,18 +521,15 @@ Response: 200 OK
|
||||
"started_at": "2024-01-18T09:00:00Z",
|
||||
"devices_migrated": 1,
|
||||
"devices_pending": 1,
|
||||
"mirror_active": true,
|
||||
"strategy": "gradual"
|
||||
},
|
||||
"bose_account_id": "bose-original-id",
|
||||
"data_sources": {
|
||||
"local": true,
|
||||
"bose_mirror": true,
|
||||
"primary": "bose"
|
||||
},
|
||||
"settings": {
|
||||
"auto_migration": false,
|
||||
"mirror_endpoints": ["/v1/presets", "/v1/recents"],
|
||||
"retention_days": 30
|
||||
}
|
||||
}
|
||||
@@ -555,7 +539,7 @@ Response: 200 OK
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"device_id": "A81B6A536A98",
|
||||
"device_id": "AABBCCDDEEFF",
|
||||
"account_id": "acc_12345",
|
||||
"state": "active",
|
||||
"created_at": "2024-01-20T10:00:00Z",
|
||||
@@ -568,7 +552,7 @@ Response: 200 OK
|
||||
"reason": "mdns_discovery",
|
||||
"source": "discovery",
|
||||
"context": {
|
||||
"ip_address": "192.168.1.100",
|
||||
"ip_address": "192.0.2.100",
|
||||
"discovery_method": "mdns"
|
||||
}
|
||||
},
|
||||
@@ -585,15 +569,15 @@ Response: 200 OK
|
||||
"type": "SoundTouch 30",
|
||||
"serial_number": "I6332527703739342000020",
|
||||
"firmware_version": "4.8.1.25341.2677643.1597353330",
|
||||
"mac_address": "A8:1B:6A:53:6A:98",
|
||||
"ip_address": "192.168.1.100",
|
||||
"mac_address": "AA:BB:CC:DD:EE:FF",
|
||||
"ip_address": "192.0.2.100",
|
||||
"last_seen": "2024-01-20T15:30:00Z",
|
||||
"is_legacy_id": false,
|
||||
"capabilities": ["multiroom", "bluetooth", "aux"]
|
||||
},
|
||||
"data_sources": {
|
||||
"presets": "local",
|
||||
"recents": "mirror_primary",
|
||||
"recents": "local",
|
||||
"sources": "local"
|
||||
},
|
||||
"migration": {
|
||||
@@ -615,14 +599,13 @@ Response: 200 OK
|
||||
|
||||
### Event Log Format (events.log)
|
||||
```
|
||||
# SoundTouch Service Event Log - Device A81B6A536A98
|
||||
# SoundTouch Service Event Log - Device AABBCCDDEEFF
|
||||
# Format: TIMESTAMP|EVENT_ID|EVENT_TYPE|SOURCE|DATA_JSON
|
||||
# Version: 1.0
|
||||
|
||||
2024-01-20T15:30:00.123Z|evt_12345|now_playing|websocket|{"source":"SPOTIFY","track":"Song Name","artist":"Artist Name","album":"Album Name"}
|
||||
2024-01-20T15:30:30.456Z|evt_12346|volume_changed|websocket|{"volume":45,"muted":false,"previous_volume":40}
|
||||
2024-01-20T15:31:00.789Z|evt_12347|preset_selected|websocket|{"preset":1,"source":"SPOTIFY","location":"spotify:track:123abc"}
|
||||
2024-01-20T15:31:15.012Z|evt_12348|disparity_detected|mirror|{"endpoint":"/v1/presets","local_hash":"abc123","upstream_hash":"def456","severity":"medium"}
|
||||
2024-01-20T15:32:00.345Z|evt_12349|health_check|system|{"response_time":42,"status":"healthy","connectivity":"online"}
|
||||
```
|
||||
|
||||
@@ -632,8 +615,8 @@ Response: 200 OK
|
||||
# Format: TIMESTAMP|DISPARITY_ID|DEVICE_ID|ACCOUNT_ID|ENDPOINT|TYPE|SEVERITY|DETAILS_JSON
|
||||
# Version: 1.0
|
||||
|
||||
2024-01-20T15:31:15.012Z|disp_12345|A81B6A536A98|acc_12345|/v1/presets|count_mismatch|medium|{"field_path":"preset_count","local_value":5,"upstream_value":4,"description":"Local has one additional preset"}
|
||||
2024-01-20T15:32:45.678Z|disp_12346|A81B6A536A98|acc_12345|/v1/recents|timestamp_format|low|{"field_path":"recent[0].utc_time","local_value":"2024-01-20T15:30:00Z","upstream_value":"1705761000","description":"Timestamp format difference"}
|
||||
2024-01-20T15:31:15.012Z|disp_12345|AABBCCDDEEFF|acc_12345|/v1/presets|count_mismatch|medium|{"field_path":"preset_count","local_value":5,"upstream_value":4,"description":"Local has one additional preset"}
|
||||
2024-01-20T15:32:45.678Z|disp_12346|AABBCCDDEEFF|acc_12345|/v1/recents|timestamp_format|low|{"field_path":"recent[0].utc_time","local_value":"2024-01-20T15:30:00Z","upstream_value":"1705761000","description":"Timestamp format difference"}
|
||||
2024-01-20T15:35:20.901Z|disp_12347|B92C7B647B09|acc_12345|/v1/account/full|structure_diff|high|{"field_path":"device[1].ip_address","local_value":"present","upstream_value":"missing","description":"IP address field missing in upstream response"}
|
||||
```
|
||||
|
||||
@@ -852,7 +835,6 @@ go test -bench=. ./...
|
||||
|
||||
### Response Time Targets
|
||||
- Local API requests: < 100ms (95th percentile)
|
||||
- Mirror requests: < 200ms overhead (asynchronous)
|
||||
- Discovery time: < 5s for network scan
|
||||
|
||||
### Resource Constraints
|
||||
@@ -925,12 +907,12 @@ type ServiceError struct {
|
||||
{
|
||||
"error": {
|
||||
"code": "DEVICE_NOT_FOUND",
|
||||
"message": "Device with ID 'A81B6A536A98' not found in account 'acc_12345'",
|
||||
"message": "Device with ID 'AABBCCDDEEFF' not found in account 'acc_12345'",
|
||||
"category": "validation",
|
||||
"timestamp": "2024-01-20T15:30:00Z",
|
||||
"context": {
|
||||
"account_id": "acc_12345",
|
||||
"device_id": "A81B6A536A98",
|
||||
"device_id": "AABBCCDDEEFF",
|
||||
"request_id": "req_67890"
|
||||
},
|
||||
"retryable": false,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the concept for simulating and replacing upstream Bose services with enhanced state management capabilities. The goal is to create a comprehensive local replacement that can handle device lifecycles, account management, and state synchronization while maintaining compatibility with existing SoundTouch devices.
|
||||
This document outlines the concept for simulating and replacing upstream Bose services with enhanced state management capabilities. The goal is to create a comprehensive replacement that can handle device lifecycles, account management, and state synchronization while maintaining compatibility with existing SoundTouch devices.
|
||||
|
||||
## Use Cases
|
||||
|
||||
@@ -95,13 +95,11 @@ data/
|
||||
"migration_status": {
|
||||
"started_at": "2024-01-18T09:00:00Z",
|
||||
"devices_migrated": 1,
|
||||
"devices_pending": 2,
|
||||
"mirror_active": true
|
||||
"devices_pending": 2
|
||||
},
|
||||
"bose_account_id": "bose-original-id",
|
||||
"data_sources": {
|
||||
"local": true,
|
||||
"bose_mirror": true,
|
||||
"primary": "bose"
|
||||
}
|
||||
}
|
||||
@@ -111,7 +109,7 @@ data/
|
||||
|
||||
```json
|
||||
{
|
||||
"device_id": "A81B6A536A98",
|
||||
"device_id": "AABBCCDDEEFF",
|
||||
"account_id": "account-12345",
|
||||
"state": "active",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
@@ -137,14 +135,14 @@ data/
|
||||
"type": "SoundTouch 30",
|
||||
"serial_number": "I6332527703739342000020",
|
||||
"firmware_version": "4.8.1.25341.2677643.1597353330",
|
||||
"mac_address": "A8:1B:6A:53:6A:98",
|
||||
"ip_address": "192.168.1.100",
|
||||
"mac_address": "AA:BB:CC:DD:EE:FF",
|
||||
"ip_address": "192.0.2.100",
|
||||
"last_seen": "2024-01-20T16:20:00Z",
|
||||
"is_legacy_id": false
|
||||
},
|
||||
"data_sources": {
|
||||
"presets": "local",
|
||||
"recents": "mirror_primary",
|
||||
"recents": "local",
|
||||
"sources": "local"
|
||||
},
|
||||
"migration": {
|
||||
@@ -159,14 +157,13 @@ data/
|
||||
### Event Log Format
|
||||
|
||||
```
|
||||
# Device Events Log - A81B6A536A98
|
||||
# Device Events Log - AABBCCDDEEFF
|
||||
# Format: TIMESTAMP|EVENT_TYPE|SOURCE|DATA
|
||||
|
||||
2024-01-20T16:15:00Z|now_playing|websocket|{"source":"SPOTIFY","track":"Song Name","artist":"Artist Name"}
|
||||
2024-01-20T16:15:30Z|volume_changed|websocket|{"volume":45,"muted":false}
|
||||
2024-01-20T16:16:00Z|preset_selected|websocket|{"preset":1,"source":"SPOTIFY","location":"spotify:track:123"}
|
||||
2024-01-20T16:18:00Z|disparity_detected|mirror|{"endpoint":"/v1/account/full","local_hash":"abc123","upstream_hash":"def456"}
|
||||
2024-01-20T16:20:00Z|device_online|discovery|{"ip":"192.168.1.100","method":"mdns"}
|
||||
2024-01-20T16:20:00Z|device_online|discovery|{"ip":"192.0.2.100","method":"mdns"}
|
||||
```
|
||||
|
||||
### Disparity Log Format
|
||||
@@ -175,9 +172,9 @@ data/
|
||||
# Parity Analysis Log
|
||||
# Format: TIMESTAMP|ENDPOINT|DEVICE|ACCOUNT|DISPARITY_TYPE|DETAILS
|
||||
|
||||
2024-01-20T16:18:00Z|/v1/account/full|A81B6A536A98|account-12345|content_mismatch|preset_count:local=5,upstream=4
|
||||
2024-01-20T16:19:15Z|/v1/presets|A81B6A536A98|account-12345|xml_structure|missing_container_art_in_local
|
||||
2024-01-20T16:20:30Z|/v1/recents|A81B6A536A98|account-12345|timestamp_format|local=RFC3339,upstream=custom
|
||||
2024-01-20T16:18:00Z|/v1/account/full|AABBCCDDEEFF|account-12345|content_mismatch|preset_count:local=5,upstream=4
|
||||
2024-01-20T16:19:15Z|/v1/presets|AABBCCDDEEFF|account-12345|xml_structure|missing_container_art_in_local
|
||||
2024-01-20T16:20:30Z|/v1/recents|AABBCCDDEEFF|account-12345|timestamp_format|local=RFC3339,upstream=custom
|
||||
```
|
||||
|
||||
## Implementation Strategy
|
||||
@@ -268,7 +265,7 @@ POST /api/v1/accounts/{account-id}/devices
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"device_id": "A81B6A536A98",
|
||||
"device_id": "AABBCCDDEEFF",
|
||||
"name": "Living Room Speaker",
|
||||
"registration_type": "fresh"
|
||||
}
|
||||
@@ -334,7 +331,7 @@ GET /api/v1/accounts/{account-id}/export
|
||||
### Quality Assurance
|
||||
|
||||
- Complete test coverage for all new functionality
|
||||
- Comprehensive linting with `golangci-lint run --fix`
|
||||
- Comprehensive linting with `golangci-lint run --fix`
|
||||
- Full test suite execution `go test ./...` for each milestone
|
||||
- Integration tests with existing functionality
|
||||
|
||||
@@ -390,4 +387,4 @@ Future improvements should maintain the simplicity-first approach:
|
||||
- Simple reporting mechanisms
|
||||
- Clear documentation for community contributions
|
||||
|
||||
This concept provides a solid, maintainable foundation for replacing Bose's upstream services. The emphasis on simplicity, existing system reuse, and comprehensive testing ensures reliable functionality while maintaining the debugging capabilities needed for small hardware deployments.
|
||||
This concept provides a solid, maintainable foundation for replacing Bose's upstream services. The emphasis on simplicity, existing system reuse, and comprehensive testing ensures reliable functionality while maintaining the debugging capabilities needed for small hardware deployments.
|
||||
|
||||
@@ -39,7 +39,7 @@ The current system uses multiple data collection methods to build a complete dev
|
||||
<info deviceID="ABCD1234EFGH">
|
||||
<name>My SoundTouch Device</name>
|
||||
<type>SoundTouch 10</type>
|
||||
<margeAccountUUID>3230304</margeAccountUUID>
|
||||
<margeAccountUUID>1000001</margeAccountUUID>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
@@ -50,7 +50,7 @@ The current system uses multiple data collection methods to build a complete dev
|
||||
<margeURL>https://streaming.bose.com</margeURL>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>AA:BB:CC:DD:EE:FF</macAddress>
|
||||
<ipAddress>192.168.1.10</ipAddress>
|
||||
<ipAddress>192.0.2.10</ipAddress>
|
||||
</networkInfo>
|
||||
<moduleType>sm2</moduleType>
|
||||
<variant>rhino</variant>
|
||||
@@ -169,7 +169,7 @@ The `/power_on` endpoint receives comprehensive device data that could replace m
|
||||
|
||||
```xml
|
||||
<device-data>
|
||||
<device id="A81B6A536A98">
|
||||
<device id="AABBCCDDEEFF">
|
||||
<serialnumber>I6332527703739342000020</serialnumber>
|
||||
<firmware-version>27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29</firmware-version>
|
||||
<product product_code="SoundTouch 10 sm2" type="5">
|
||||
@@ -179,12 +179,12 @@ The `/power_on` endpoint receives comprehensive device data that could replace m
|
||||
<diagnostic-data>
|
||||
<device-landscape>
|
||||
<rssi>Excellent</rssi>
|
||||
<gateway-ip-address>192.168.178.1</gateway-ip-address>
|
||||
<gateway-ip-address>192.0.2.1</gateway-ip-address>
|
||||
<macaddresses>
|
||||
<macaddress>A81B6A536A98</macaddress>
|
||||
<macaddress>A81B6A849D99</macaddress>
|
||||
<macaddress>AABBCCDDEEFF</macaddress>
|
||||
<macaddress>AABBCCDDEE01</macaddress>
|
||||
</macaddresses>
|
||||
<ip-address>192.168.178.35</ip-address>
|
||||
<ip-address>192.0.2.10</ip-address>
|
||||
<network-connection-type>Wireless</network-connection-type>
|
||||
</device-landscape>
|
||||
<network-landscape>
|
||||
|
||||
@@ -23,7 +23,7 @@ The `/power_on` endpoint provides rich device data that could eliminate network
|
||||
### Current /power_on Data
|
||||
```xml
|
||||
<device-data>
|
||||
<device id="A81B6A536A98"> <!-- ✅ Device MAC -->
|
||||
<device id="AABBCCDDEEFF"> <!-- ✅ Device MAC -->
|
||||
<serialnumber>I6332527703739342000020</serialnumber> <!-- ✅ Serial -->
|
||||
<firmware-version>27.0.6.46330.5043500...</firmware-version> <!-- ✅ FW -->
|
||||
<product product_code="SoundTouch 10 sm2" type="5"> <!-- ✅ Model -->
|
||||
@@ -33,12 +33,12 @@ The `/power_on` endpoint provides rich device data that could eliminate network
|
||||
<diagnostic-data>
|
||||
<device-landscape>
|
||||
<rssi>Excellent</rssi> <!-- ✅ Signal -->
|
||||
<gateway-ip-address>192.168.178.1</gateway-ip-address> <!-- ✅ Network -->
|
||||
<gateway-ip-address>192.0.2.1</gateway-ip-address> <!-- ✅ Network -->
|
||||
<macaddresses> <!-- ✅ All MACs -->
|
||||
<macaddress>A81B6A536A98</macaddress>
|
||||
<macaddress>A81B6A849D99</macaddress>
|
||||
<macaddress>AABBCCDDEEFF</macaddress>
|
||||
<macaddress>AABBCCDDEE01</macaddress>
|
||||
</macaddresses>
|
||||
<ip-address>192.168.178.35</ip-address> <!-- ✅ Current IP -->
|
||||
<ip-address>192.0.2.10</ip-address> <!-- ✅ Current IP -->
|
||||
<network-connection-type>Wireless</network-connection-type> <!-- ✅ Connection -->
|
||||
</device-landscape>
|
||||
</diagnostic-data>
|
||||
|
||||
@@ -39,12 +39,12 @@ Shows the network layout with Raspberry Pi, router, and SoundTouch devices.
|
||||
```
|
||||
Internet Cloud
|
||||
↑↓ (Optional - during migration)
|
||||
Home Router (192.168.1.1)
|
||||
├── Raspberry Pi (192.168.1.10) [SoundTouch Service]
|
||||
├── Living Room Speaker (192.168.1.100)
|
||||
├── Kitchen Speaker (192.168.1.101)
|
||||
├── Bedroom Speaker (192.168.1.102)
|
||||
└── Office Speaker (192.168.1.103)
|
||||
Home Router (192.0.2.1)
|
||||
├── Raspberry Pi (192.0.2.10) [SoundTouch Service]
|
||||
├── Living Room Speaker (192.0.2.100)
|
||||
├── Kitchen Speaker (192.0.2.101)
|
||||
├── Bedroom Speaker (192.0.2.102)
|
||||
└── Office Speaker (192.0.2.103)
|
||||
```
|
||||
|
||||
### Connections
|
||||
|
||||
@@ -200,7 +200,7 @@ echo "Proxy set to ${MAC_IP}:8080"
|
||||
Confirm emulator can reach the speaker:
|
||||
|
||||
```bash
|
||||
SPEAKER_IP=192.168.1.50 # adjust to your speaker's LAN IP
|
||||
SPEAKER_IP=192.0.2.50 # adjust to your speaker's LAN IP
|
||||
adb -s emulator-5554 shell ping -c 3 "$SPEAKER_IP"
|
||||
```
|
||||
|
||||
@@ -394,7 +394,7 @@ Raw log of the first interactive run. To be cleaned up into the runbook above.
|
||||
### Wi-Fi Provisioning (AP mode)
|
||||
- `airport` command not available on this macOS version (removed in recent releases)
|
||||
- Connect Mac to speaker AP via **System Settings → Wi-Fi** (SSID: "Bose SoundTouch XXXX")
|
||||
- Speaker AP gateway confirmed: `192.0.2.1` (client gets `192.0.2.2`), not `192.168.1.1` as previously assumed
|
||||
- Speaker AP gateway confirmed: `192.0.2.1` (client gets `192.0.2.2`), not `192.0.2.1` as previously assumed
|
||||
- `/gabbo_wifi` endpoint was hallucinated — actual endpoint verified from browser network capture (`_/device-reset/wifi-setup.txt`):
|
||||
- Site survey: `POST http://192.0.2.1:8090/performWirelessSiteSurvey` with `<PerformWirelessSiteSurvey timeout="5"/>`
|
||||
- Add profile: `POST http://192.0.2.1:8090/addWirelessProfile` with XML body, `securityType="wpa_or_wpa2"`
|
||||
|
||||
@@ -50,9 +50,6 @@ make build-service
|
||||
|
||||
Service listens on `:8000` by default. Web UI: `http://localhost:8000`
|
||||
|
||||
> To also enable mirror mode (forward unhandled requests to official Bose servers
|
||||
> for comparison), add: `--mirror-enabled --mirror-endpoints /streaming/`
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Start mitmproxy + Frida (new capture)
|
||||
@@ -236,9 +233,6 @@ Endpoints the service doesn't handle return `404 Not Found`. Check:
|
||||
```bash
|
||||
# From service stats
|
||||
curl -s http://localhost:8000/setup/interaction-stats | python3 -m json.tool
|
||||
|
||||
# List parity mismatches (local vs upstream divergence, if mirror enabled)
|
||||
curl -s http://localhost:8000/setup/parity-mismatches | python3 -m json.tool
|
||||
```
|
||||
|
||||
---
|
||||
@@ -298,8 +292,6 @@ Settings applied in the web UI before migration:
|
||||
| Target Domain | `soundtouch.local` (resolvable from speaker to `192.168.x.z`) |
|
||||
| DNS Discovery | enabled |
|
||||
| Upstream DNS | home Wi-Fi gateway |
|
||||
| Mirroring | enabled (for tracing while Bose cloud is still up) |
|
||||
| Mirrored endpoints | `/bmx/*`, `/streaming/*`, `/accounts/*`, `/v1/scmudc/*`, `/oauth/*` |
|
||||
| Proxy logging | enabled, including bodies |
|
||||
| Record interactions | enabled |
|
||||
| Skip recording | `/setup/*`, `/web/*` |
|
||||
@@ -361,7 +353,6 @@ All tests run from the **Devices → Migrate** panel after selecting the speaker
|
||||
|
||||
- Paired speaker to Bose account via app — succeeded ✅
|
||||
- Set presets via app — worked ✅
|
||||
- Mirroring active and functional during session ✅
|
||||
- No visible errors in app behaviour; service logs and interaction recordings not yet reviewed in detail
|
||||
|
||||
### Known Shell Warning (safe to ignore)
|
||||
|
||||
+127
-127
@@ -68,7 +68,7 @@ soundtouch-cli --host <device> info
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
soundtouch-cli --host 192.168.1.10 info
|
||||
soundtouch-cli --host 192.0.2.10 info
|
||||
```
|
||||
|
||||
#### `name get|set`
|
||||
@@ -119,16 +119,16 @@ soundtouch-cli --host <device> preset remove --slot <1-6>
|
||||
**Store Current Content Examples:**
|
||||
```bash
|
||||
# Store what's currently playing as preset 1
|
||||
soundtouch-cli --host 192.168.1.10 preset store-current --slot 1
|
||||
soundtouch-cli --host 192.0.2.10 preset store-current --slot 1
|
||||
|
||||
# Store current Spotify track as preset 3
|
||||
soundtouch-cli --host 192.168.1.10 preset store-current --slot 3
|
||||
soundtouch-cli --host 192.0.2.10 preset store-current --slot 3
|
||||
```
|
||||
|
||||
**Store Specific Content Examples:**
|
||||
```bash
|
||||
# Store Spotify playlist
|
||||
soundtouch-cli --host 192.168.1.10 preset store \
|
||||
soundtouch-cli --host 192.0.2.10 preset store \
|
||||
--slot 1 \
|
||||
--source SPOTIFY \
|
||||
--location "spotify:playlist:37i9dQZF1DXcBWIGoYBM5M" \
|
||||
@@ -136,14 +136,14 @@ soundtouch-cli --host 192.168.1.10 preset store \
|
||||
--name "Today's Top Hits"
|
||||
|
||||
# Store radio station
|
||||
soundtouch-cli --host 192.168.1.10 preset store \
|
||||
soundtouch-cli --host 192.0.2.10 preset store \
|
||||
--slot 2 \
|
||||
--source TUNEIN \
|
||||
--location "/v1/playbook/station/s33828" \
|
||||
--location "/v1/playback/station/s33828" \
|
||||
--name "K-LOVE Radio"
|
||||
|
||||
# Store internet radio
|
||||
soundtouch-cli --host 192.168.1.10 preset store \
|
||||
soundtouch-cli --host 192.0.2.10 preset store \
|
||||
--slot 3 \
|
||||
--source LOCAL_INTERNET_RADIO \
|
||||
--location "https://stream.example.com/jazz" \
|
||||
@@ -153,13 +153,13 @@ soundtouch-cli --host 192.168.1.10 preset store \
|
||||
**Selection and Management Examples:**
|
||||
```bash
|
||||
# List all presets
|
||||
soundtouch-cli --host 192.168.1.10 preset list
|
||||
soundtouch-cli --host 192.0.2.10 preset list
|
||||
|
||||
# Select preset 1
|
||||
soundtouch-cli --host 192.168.1.10 preset select --slot 1
|
||||
soundtouch-cli --host 192.0.2.10 preset select --slot 1
|
||||
|
||||
# Remove preset 6
|
||||
soundtouch-cli --host 192.168.1.10 preset remove --slot 6
|
||||
soundtouch-cli --host 192.0.2.10 preset remove --slot 6
|
||||
```
|
||||
|
||||
**Getting Content Locations:**
|
||||
@@ -168,10 +168,10 @@ To find content locations for the `--location` parameter:
|
||||
|
||||
```bash
|
||||
# Show current content details (includes location for all sources)
|
||||
soundtouch-cli --host 192.168.1.10 play now
|
||||
soundtouch-cli --host 192.0.2.10 play now
|
||||
|
||||
# Show detailed content information
|
||||
soundtouch-cli --host 192.168.1.10 play now --verbose
|
||||
soundtouch-cli --host 192.0.2.10 play now --verbose
|
||||
```
|
||||
|
||||
### Recent Content
|
||||
@@ -199,28 +199,28 @@ soundtouch-cli --host <device> recents stats
|
||||
**Basic Usage Examples:**
|
||||
```bash
|
||||
# List last 10 recent items (default)
|
||||
soundtouch-cli --host 192.168.1.10 recents list
|
||||
soundtouch-cli --host 192.0.2.10 recents list
|
||||
|
||||
# Show all recent items with detailed information
|
||||
soundtouch-cli --host 192.168.1.10 recents list --limit 0 --detailed
|
||||
soundtouch-cli --host 192.0.2.10 recents list --limit 0 --detailed
|
||||
|
||||
# Show only the most recent item
|
||||
soundtouch-cli --host 192.168.1.10 recents latest
|
||||
soundtouch-cli --host 192.0.2.10 recents latest
|
||||
```
|
||||
|
||||
**Filtering Examples:**
|
||||
```bash
|
||||
# Show only Spotify items
|
||||
soundtouch-cli --host 192.168.1.10 recents filter --source SPOTIFY
|
||||
soundtouch-cli --host 192.0.2.10 recents filter --source SPOTIFY
|
||||
|
||||
# Show only tracks (no stations or playlists)
|
||||
soundtouch-cli --host 192.168.1.10 recents filter --type track
|
||||
soundtouch-cli --host 192.0.2.10 recents filter --type track
|
||||
|
||||
# Show only presetable items
|
||||
soundtouch-cli --host 192.168.1.10 recents filter --type presetable
|
||||
soundtouch-cli --host 192.0.2.10 recents filter --type presetable
|
||||
|
||||
# Show last 5 local music items
|
||||
soundtouch-cli --host 192.168.1.10 recents filter --source LOCAL_MUSIC --limit 5
|
||||
soundtouch-cli --host 192.0.2.10 recents filter --source LOCAL_MUSIC --limit 5
|
||||
```
|
||||
|
||||
**Available Sources:**
|
||||
@@ -242,7 +242,7 @@ soundtouch-cli --host 192.168.1.10 recents filter --source LOCAL_MUSIC --limit 5
|
||||
**Statistics Example:**
|
||||
```bash
|
||||
# Get detailed statistics about recent content
|
||||
soundtouch-cli --host 192.168.1.10 recents stats
|
||||
soundtouch-cli --host 192.0.2.10 recents stats
|
||||
```
|
||||
|
||||
#### `presets` (Legacy)
|
||||
@@ -292,10 +292,10 @@ soundtouch-cli --host <device> preset --preset <1-6>
|
||||
**Examples:**
|
||||
```bash
|
||||
# Select preset 1
|
||||
soundtouch-cli --host 192.168.1.10 preset --preset 1
|
||||
soundtouch-cli --host 192.0.2.10 preset --preset 1
|
||||
|
||||
# Select preset 6
|
||||
soundtouch-cli --host 192.168.1.10 preset --preset 6
|
||||
soundtouch-cli --host 192.0.2.10 preset --preset 6
|
||||
```
|
||||
|
||||
#### `track`
|
||||
@@ -362,16 +362,16 @@ soundtouch-cli --host <device> volume down [--amount <1-10>]
|
||||
**Examples:**
|
||||
```bash
|
||||
# Get volume
|
||||
soundtouch-cli --host 192.168.1.10 volume get
|
||||
soundtouch-cli --host 192.0.2.10 volume get
|
||||
|
||||
# Set volume to 50
|
||||
soundtouch-cli --host 192.168.1.10 volume set --level 50
|
||||
soundtouch-cli --host 192.0.2.10 volume set --level 50
|
||||
|
||||
# Increase volume by 5
|
||||
soundtouch-cli --host 192.168.1.10 volume up --amount 5
|
||||
soundtouch-cli --host 192.0.2.10 volume up --amount 5
|
||||
|
||||
# Decrease volume by 3 (default amount is 2)
|
||||
soundtouch-cli --host 192.168.1.10 volume down --amount 3
|
||||
soundtouch-cli --host 192.0.2.10 volume down --amount 3
|
||||
```
|
||||
|
||||
### Audio Sources
|
||||
@@ -419,42 +419,42 @@ soundtouch-cli --host <device> source content --source <SOURCE> --location <LOCA
|
||||
**Examples:**
|
||||
```bash
|
||||
# List all sources
|
||||
soundtouch-cli --host 192.168.1.10 source list
|
||||
soundtouch-cli --host 192.0.2.10 source list
|
||||
|
||||
# Select Spotify
|
||||
soundtouch-cli --host 192.168.1.10 source spotify
|
||||
soundtouch-cli --host 192.0.2.10 source spotify
|
||||
|
||||
# Select Spotify with specific account
|
||||
soundtouch-cli --host 192.168.1.10 source select --source SPOTIFY --account user@example.com
|
||||
soundtouch-cli --host 192.0.2.10 source select --source SPOTIFY --account user@example.com
|
||||
|
||||
# Select Bluetooth
|
||||
soundtouch-cli --host 192.168.1.10 source bluetooth
|
||||
soundtouch-cli --host 192.0.2.10 source bluetooth
|
||||
|
||||
# Select internet radio with streamUrl format
|
||||
soundtouch-cli --host 192.168.1.10 source internet-radio \
|
||||
soundtouch-cli --host 192.0.2.10 source internet-radio \
|
||||
--location "http://contentapi.gmuth.de/station.php?name=MyStation&streamUrl=https://stream.example.com/radio" \
|
||||
--name "My Radio Station" \
|
||||
--artwork "https://example.com/art.png"
|
||||
|
||||
# Select internet radio with direct stream URL
|
||||
soundtouch-cli --host 192.168.1.10 source internet-radio \
|
||||
soundtouch-cli --host 192.0.2.10 source internet-radio \
|
||||
--location "https://stream.example.com/radio" \
|
||||
--name "My Stream"
|
||||
|
||||
# Select local music content (requires SoundTouch App Media Server)
|
||||
soundtouch-cli --host 192.168.1.10 source local-music \
|
||||
soundtouch-cli --host 192.0.2.10 source local-music \
|
||||
--location "album:983" \
|
||||
--account "3f205110-4a57-4e91-810a-123456789012" \
|
||||
--name "Welcome to the New"
|
||||
|
||||
# Select stored music content (requires UPnP/DLNA media server)
|
||||
soundtouch-cli --host 192.168.1.10 source stored-music \
|
||||
soundtouch-cli --host 192.0.2.10 source stored-music \
|
||||
--location "6_a2874b5d_4f83d999" \
|
||||
--account "d09708a1-5953-44bc-a413-123456789012/0" \
|
||||
--name "Christmas Album"
|
||||
|
||||
# Advanced content selection with all options
|
||||
soundtouch-cli --host 192.168.1.10 source content \
|
||||
soundtouch-cli --host 192.0.2.10 source content \
|
||||
--source LOCAL_INTERNET_RADIO \
|
||||
--location "https://stream.example.com/radio" \
|
||||
--name "My Stream" \
|
||||
@@ -462,22 +462,22 @@ soundtouch-cli --host 192.168.1.10 source content \
|
||||
--presetable
|
||||
|
||||
# Get introspect data for Spotify
|
||||
soundtouch-cli --host 192.168.1.10 source introspect --source SPOTIFY
|
||||
soundtouch-cli --host 192.0.2.10 source introspect --source SPOTIFY
|
||||
|
||||
# Get introspect data with account
|
||||
soundtouch-cli --host 192.168.1.10 source introspect --source SPOTIFY --account user@spotify.com
|
||||
soundtouch-cli --host 192.0.2.10 source introspect --source SPOTIFY --account user@spotify.com
|
||||
|
||||
# Spotify introspect (convenience command)
|
||||
soundtouch-cli --host 192.168.1.10 source introspect-spotify
|
||||
soundtouch-cli --host 192.0.2.10 source introspect-spotify
|
||||
|
||||
# Get introspect data for all available services
|
||||
soundtouch-cli --host 192.168.1.10 source introspect-all
|
||||
soundtouch-cli --host 192.0.2.10 source introspect-all
|
||||
|
||||
# Check service availability
|
||||
soundtouch-cli --host 192.168.1.10 source availability
|
||||
soundtouch-cli --host 192.0.2.10 source availability
|
||||
|
||||
# Compare sources and availability
|
||||
soundtouch-cli --host 192.168.1.10 source compare
|
||||
soundtouch-cli --host 192.0.2.10 source compare
|
||||
```
|
||||
|
||||
**Content Selection Commands:**
|
||||
@@ -496,12 +496,12 @@ The `internet-radio` command supports the streamUrl proxy format from the [Sound
|
||||
|
||||
```bash
|
||||
# Using contentapi.gmuth.de proxy for complex streams
|
||||
soundtouch-cli --host 192.168.1.10 source internet-radio \
|
||||
soundtouch-cli --host 192.0.2.10 source internet-radio \
|
||||
--location "http://contentapi.gmuth.de/station.php?name=Antenne%20Chillout&streamUrl=https://stream.antenne.de/chillout/stream/aacp" \
|
||||
--name "Antenne Chillout"
|
||||
|
||||
# Using local soundtouch-service for custom streams
|
||||
soundtouch-cli --host 192.168.1.10 source custom-radio \
|
||||
soundtouch-cli --host 192.0.2.10 source custom-radio \
|
||||
--url "https://stream.antenne.de/chillout/stream/aacp" \
|
||||
--name "Antenne Chillout" \
|
||||
--service-url "http://localhost:8080"
|
||||
@@ -543,19 +543,19 @@ soundtouch-cli --host <device> source introspect-all
|
||||
**Examples:**
|
||||
```bash
|
||||
# Get Spotify service status
|
||||
soundtouch-cli --host 192.168.1.10 source introspect --source SPOTIFY
|
||||
soundtouch-cli --host 192.0.2.10 source introspect --source SPOTIFY
|
||||
|
||||
# Get Spotify status with specific account
|
||||
soundtouch-cli --host 192.168.1.10 source introspect --source SPOTIFY --account my_spotify_user
|
||||
soundtouch-cli --host 192.0.2.10 source introspect --source SPOTIFY --account my_spotify_user
|
||||
|
||||
# Use Spotify convenience command
|
||||
soundtouch-cli --host 192.168.1.10 source introspect-spotify
|
||||
soundtouch-cli --host 192.0.2.10 source introspect-spotify
|
||||
|
||||
# Get status for all available streaming services
|
||||
soundtouch-cli --host 192.168.1.10 source introspect-all
|
||||
soundtouch-cli --host 192.0.2.10 source introspect-all
|
||||
|
||||
# Check which services are available before introspecting
|
||||
soundtouch-cli --host 192.168.1.10 source availability
|
||||
soundtouch-cli --host 192.0.2.10 source availability
|
||||
```
|
||||
|
||||
### Music Service Account Management
|
||||
@@ -604,40 +604,40 @@ soundtouch-cli --host <device> account remove-nas --user <GUID/0> [--name <NAME>
|
||||
**Examples:**
|
||||
```bash
|
||||
# List all configured music service accounts
|
||||
soundtouch-cli --host 192.168.1.10 account list
|
||||
soundtouch-cli --host 192.0.2.10 account list
|
||||
|
||||
# Add a Spotify Premium account
|
||||
soundtouch-cli --host 192.168.1.10 account add-spotify \
|
||||
soundtouch-cli --host 192.0.2.10 account add-spotify \
|
||||
--user "user@spotify.com" \
|
||||
--password "mypassword"
|
||||
|
||||
# Add a Pandora account
|
||||
soundtouch-cli --host 192.168.1.10 account add-pandora \
|
||||
soundtouch-cli --host 192.0.2.10 account add-pandora \
|
||||
--user "pandora_username" \
|
||||
--password "pandora_password"
|
||||
|
||||
# Add an Amazon Music account
|
||||
soundtouch-cli --host 192.168.1.10 account add-amazon \
|
||||
soundtouch-cli --host 192.0.2.10 account add-amazon \
|
||||
--user "amazon_user" \
|
||||
--password "amazon_password"
|
||||
|
||||
# Add a network music library (NAS/UPnP)
|
||||
soundtouch-cli --host 192.168.1.10 account add-nas \
|
||||
soundtouch-cli --host 192.0.2.10 account add-nas \
|
||||
--user "d09708a1-5953-44bc-a413-123456789012/0" \
|
||||
--name "My Music Server"
|
||||
|
||||
# Remove a Spotify account
|
||||
soundtouch-cli --host 192.168.1.10 account remove-spotify \
|
||||
soundtouch-cli --host 192.0.2.10 account remove-spotify \
|
||||
--user "user@spotify.com"
|
||||
|
||||
# Generic account management
|
||||
soundtouch-cli --host 192.168.1.10 account add \
|
||||
soundtouch-cli --host 192.0.2.10 account add \
|
||||
--source DEEZER \
|
||||
--user "deezer_user" \
|
||||
--password "deezer_pass" \
|
||||
--name "Deezer Premium"
|
||||
|
||||
soundtouch-cli --host 192.168.1.10 account remove \
|
||||
soundtouch-cli --host 192.0.2.10 account remove \
|
||||
--source DEEZER \
|
||||
--user "deezer_user"
|
||||
```
|
||||
@@ -676,16 +676,16 @@ soundtouch-cli --host <device> bass capabilities
|
||||
**Examples:**
|
||||
```bash
|
||||
# Get current bass
|
||||
soundtouch-cli --host 192.168.1.10 bass get
|
||||
soundtouch-cli --host 192.0.2.10 bass get
|
||||
|
||||
# Set bass to +3
|
||||
soundtouch-cli --host 192.168.1.10 bass set --level 3
|
||||
soundtouch-cli --host 192.0.2.10 bass set --level 3
|
||||
|
||||
# Increase bass by 2
|
||||
soundtouch-cli --host 192.168.1.10 bass up --amount 2
|
||||
soundtouch-cli --host 192.0.2.10 bass up --amount 2
|
||||
|
||||
# Decrease bass by 1 (default)
|
||||
soundtouch-cli --host 192.168.1.10 bass down
|
||||
soundtouch-cli --host 192.0.2.10 bass down
|
||||
```
|
||||
|
||||
### Balance Control
|
||||
@@ -716,16 +716,16 @@ soundtouch-cli --host <device> balance center
|
||||
**Examples:**
|
||||
```bash
|
||||
# Get balance
|
||||
soundtouch-cli --host 192.168.1.10 balance get
|
||||
soundtouch-cli --host 192.0.2.10 balance get
|
||||
|
||||
# Set balance 10 units to the right
|
||||
soundtouch-cli --host 192.168.1.10 balance set --level 10
|
||||
soundtouch-cli --host 192.0.2.10 balance set --level 10
|
||||
|
||||
# Shift left by 5 units (default)
|
||||
soundtouch-cli --host 192.168.1.10 balance left
|
||||
soundtouch-cli --host 192.0.2.10 balance left
|
||||
|
||||
# Center the balance
|
||||
soundtouch-cli --host 192.168.1.10 balance center
|
||||
soundtouch-cli --host 192.0.2.10 balance center
|
||||
```
|
||||
|
||||
### Clock and Time
|
||||
@@ -757,22 +757,22 @@ soundtouch-cli --host <device> clock display format --format <12|24>
|
||||
**Examples:**
|
||||
```bash
|
||||
# Get current time
|
||||
soundtouch-cli --host 192.168.1.10 clock get
|
||||
soundtouch-cli --host 192.0.2.10 clock get
|
||||
|
||||
# Set time to 2:30 PM
|
||||
soundtouch-cli --host 192.168.1.10 clock set --time "14:30"
|
||||
soundtouch-cli --host 192.0.2.10 clock set --time "14:30"
|
||||
|
||||
# Sync with system time
|
||||
soundtouch-cli --host 192.168.1.10 clock now
|
||||
soundtouch-cli --host 192.0.2.10 clock now
|
||||
|
||||
# Enable clock display
|
||||
soundtouch-cli --host 192.168.1.10 clock display enable
|
||||
soundtouch-cli --host 192.0.2.10 clock display enable
|
||||
|
||||
# Set 24-hour format
|
||||
soundtouch-cli --host 192.168.1.10 clock display format --format 24
|
||||
soundtouch-cli --host 192.0.2.10 clock display format --format 24
|
||||
|
||||
# Set high brightness
|
||||
soundtouch-cli --host 192.168.1.10 clock display brightness --brightness high
|
||||
soundtouch-cli --host 192.0.2.10 clock display brightness --brightness high
|
||||
```
|
||||
|
||||
### Network Information
|
||||
@@ -831,19 +831,19 @@ soundtouch-cli --host <device> zone set --master <ip> --members <ip1,ip2>
|
||||
**Examples:**
|
||||
```bash
|
||||
# Get current zone info
|
||||
soundtouch-cli --host 192.168.1.10 zone get
|
||||
soundtouch-cli --host 192.0.2.10 zone get
|
||||
|
||||
# Create zone with three speakers
|
||||
soundtouch-cli --host 192.168.1.10 zone create --members 192.168.1.11,192.168.1.12
|
||||
soundtouch-cli --host 192.0.2.10 zone create --members 192.0.2.11,192.0.2.12
|
||||
|
||||
# Add speaker to existing zone
|
||||
soundtouch-cli --host 192.168.1.10 zone add --member 192.168.1.13
|
||||
soundtouch-cli --host 192.0.2.10 zone add --member 192.0.2.13
|
||||
|
||||
# Remove speaker from zone
|
||||
soundtouch-cli --host 192.168.1.10 zone remove --member 192.168.1.12
|
||||
soundtouch-cli --host 192.0.2.10 zone remove --member 192.0.2.12
|
||||
|
||||
# Dissolve the zone (make all speakers independent)
|
||||
soundtouch-cli --host 192.168.1.10 zone dissolve
|
||||
soundtouch-cli --host 192.0.2.10 zone dissolve
|
||||
```
|
||||
|
||||
### Browse and Navigation
|
||||
@@ -877,22 +877,22 @@ soundtouch-cli --host <device> browse container --source <SOURCE> --location <LO
|
||||
**Examples:**
|
||||
```bash
|
||||
# Browse TuneIn stations
|
||||
soundtouch-cli --host 192.168.1.10 browse tunein
|
||||
soundtouch-cli --host 192.0.2.10 browse tunein
|
||||
|
||||
# Browse first 50 TuneIn stations
|
||||
soundtouch-cli --host 192.168.1.10 browse tunein --limit 50
|
||||
soundtouch-cli --host 192.0.2.10 browse tunein --limit 50
|
||||
|
||||
# Browse Pandora radio stations
|
||||
soundtouch-cli --host 192.168.1.10 browse pandora --source-account myuser123
|
||||
soundtouch-cli --host 192.0.2.10 browse pandora --source-account myuser123
|
||||
|
||||
# Browse Pandora with menu navigation
|
||||
soundtouch-cli --host 192.168.1.10 browse menu --source PANDORA --source-account myuser123 --menu radioStations --sort dateCreated
|
||||
soundtouch-cli --host 192.0.2.10 browse menu --source PANDORA --source-account myuser123 --menu radioStations --sort dateCreated
|
||||
|
||||
# Browse stored music library
|
||||
soundtouch-cli --host 192.168.1.10 browse stored-music --source-account device_12345
|
||||
soundtouch-cli --host 192.0.2.10 browse stored-music --source-account device_12345
|
||||
|
||||
# Browse into a music album container
|
||||
soundtouch-cli --host 192.168.1.10 browse container --source STORED_MUSIC --location "album:983" --type dir
|
||||
soundtouch-cli --host 192.0.2.10 browse container --source STORED_MUSIC --location "album:983" --type dir
|
||||
```
|
||||
|
||||
### Station Search and Management
|
||||
@@ -926,52 +926,52 @@ soundtouch-cli --host <device> station remove --source <SOURCE> --location <LOCA
|
||||
**Search Examples:**
|
||||
```bash
|
||||
# Search TuneIn for jazz stations
|
||||
soundtouch-cli --host 192.168.1.10 station search-tunein --query "jazz"
|
||||
soundtouch-cli --host 192.0.2.10 station search-tunein --query "jazz"
|
||||
|
||||
# Search Pandora for Taylor Swift
|
||||
soundtouch-cli --host 192.168.1.10 station search-pandora --source-account myuser123 --query "Taylor Swift"
|
||||
soundtouch-cli --host 192.0.2.10 station search-pandora --source-account myuser123 --query "Taylor Swift"
|
||||
|
||||
# Search Spotify for workout playlists
|
||||
soundtouch-cli --host 192.168.1.10 station search-spotify --source-account spotify_user --query "workout playlist"
|
||||
soundtouch-cli --host 192.0.2.10 station search-spotify --source-account spotify_user --query "workout playlist"
|
||||
|
||||
# General search across any source
|
||||
soundtouch-cli --host 192.168.1.10 station search --source TUNEIN --query "classic rock"
|
||||
soundtouch-cli --host 192.0.2.10 station search --source TUNEIN --query "classic rock"
|
||||
```
|
||||
|
||||
**Station Management Examples:**
|
||||
```bash
|
||||
# Add a station found from search results (use token from search output)
|
||||
soundtouch-cli --host 192.168.1.10 station add \
|
||||
soundtouch-cli --host 192.0.2.10 station add \
|
||||
--source TUNEIN \
|
||||
--token "c121508" \
|
||||
--name "Classic Rock Radio"
|
||||
|
||||
# Add Pandora station with account
|
||||
soundtouch-cli --host 192.168.1.10 station add \
|
||||
soundtouch-cli --host 192.0.2.10 station add \
|
||||
--source PANDORA \
|
||||
--source-account myuser123 \
|
||||
--token "TR:12345" \
|
||||
--name "My Custom Station"
|
||||
|
||||
# Remove a station (use location from browse/search results)
|
||||
soundtouch-cli --host 192.168.1.10 station remove \
|
||||
soundtouch-cli --host 192.0.2.10 station remove \
|
||||
--source TUNEIN \
|
||||
--location "/v1/playbook/station/s33828"
|
||||
--location "/v1/playback/station/s33828"
|
||||
```
|
||||
|
||||
**Workflow Example - Discover and Play New Content:**
|
||||
```bash
|
||||
# 1. Search for content
|
||||
soundtouch-cli --host 192.168.1.10 station search-tunein --query "smooth jazz"
|
||||
soundtouch-cli --host 192.0.2.10 station search-tunein --query "smooth jazz"
|
||||
|
||||
# 2. Add interesting station from results (copy token from output)
|
||||
soundtouch-cli --host 192.168.1.10 station add \
|
||||
soundtouch-cli --host 192.0.2.10 station add \
|
||||
--source TUNEIN \
|
||||
--token "c456789" \
|
||||
--name "Smooth Jazz 24/7"
|
||||
|
||||
# 3. Station is automatically playing! Or browse for more options:
|
||||
soundtouch-cli --host 192.168.1.10 browse tunein --limit 10
|
||||
soundtouch-cli --host 192.0.2.10 browse tunein --limit 10
|
||||
```
|
||||
|
||||
### Speaker Notifications and Content
|
||||
@@ -999,19 +999,19 @@ soundtouch-cli speaker help
|
||||
**TTS Examples:**
|
||||
```bash
|
||||
# Basic TTS in English
|
||||
soundtouch-cli --host 192.168.1.10 speaker tts \
|
||||
soundtouch-cli --host 192.0.2.10 speaker tts \
|
||||
--text "Hello, welcome home" \
|
||||
--app-key "your-app-key"
|
||||
|
||||
# TTS with volume and language
|
||||
soundtouch-cli --host 192.168.1.10 speaker tts \
|
||||
soundtouch-cli --host 192.0.2.10 speaker tts \
|
||||
--text "Bonjour le monde" \
|
||||
--app-key "your-app-key" \
|
||||
--volume 70 \
|
||||
--language FR
|
||||
|
||||
# TTS for home automation alert
|
||||
soundtouch-cli --host 192.168.1.10 speaker tts \
|
||||
soundtouch-cli --host 192.0.2.10 speaker tts \
|
||||
--text "Motion detected at front door" \
|
||||
--app-key "security-system-key" \
|
||||
--volume 80
|
||||
@@ -1020,13 +1020,13 @@ soundtouch-cli --host 192.168.1.10 speaker tts \
|
||||
**URL Content Examples:**
|
||||
```bash
|
||||
# Play audio file from URL
|
||||
soundtouch-cli --host 192.168.1.10 speaker url \
|
||||
soundtouch-cli --host 192.0.2.10 speaker url \
|
||||
--url "https://example.com/doorbell.mp3" \
|
||||
--app-key "your-app-key" \
|
||||
--volume 75
|
||||
|
||||
# Play with custom metadata
|
||||
soundtouch-cli --host 192.168.1.10 speaker url \
|
||||
soundtouch-cli --host 192.0.2.10 speaker url \
|
||||
--url "https://example.com/song.mp3" \
|
||||
--app-key "your-app-key" \
|
||||
--service "Music Service" \
|
||||
@@ -1035,7 +1035,7 @@ soundtouch-cli --host 192.168.1.10 speaker url \
|
||||
--volume 60
|
||||
|
||||
# Emergency alert
|
||||
soundtouch-cli --host 192.168.1.10 speaker url \
|
||||
soundtouch-cli --host 192.0.2.10 speaker url \
|
||||
--url "https://alerts.example.com/fire-alarm.wav" \
|
||||
--app-key "emergency-system" \
|
||||
--service "Emergency System" \
|
||||
@@ -1046,10 +1046,10 @@ soundtouch-cli --host 192.168.1.10 speaker url \
|
||||
**Simple Notifications:**
|
||||
```bash
|
||||
# Quick beep notification
|
||||
soundtouch-cli --host 192.168.1.10 speaker beep
|
||||
soundtouch-cli --host 192.0.2.10 speaker beep
|
||||
|
||||
# Test device connectivity with beep
|
||||
soundtouch-cli --host 192.168.1.10 speaker beep
|
||||
soundtouch-cli --host 192.0.2.10 speaker beep
|
||||
```
|
||||
|
||||
**Supported Languages for TTS:**
|
||||
@@ -1106,16 +1106,16 @@ soundtouch-cli --host <device> events subscribe [flags]
|
||||
**Examples:**
|
||||
```bash
|
||||
# Monitor all events
|
||||
soundtouch-cli --host 192.168.1.10 events subscribe
|
||||
soundtouch-cli --host 192.0.2.10 events subscribe
|
||||
|
||||
# Monitor only volume and now playing events
|
||||
soundtouch-cli --host 192.168.1.10 events subscribe --filter volume,nowPlaying
|
||||
soundtouch-cli --host 192.0.2.10 events subscribe --filter volume,nowPlaying
|
||||
|
||||
# Monitor for 5 minutes with verbose output
|
||||
soundtouch-cli --host 192.168.1.10 events subscribe --duration 5m --verbose
|
||||
soundtouch-cli --host 192.0.2.10 events subscribe --duration 5m --verbose
|
||||
|
||||
# Monitor zone events without automatic reconnection
|
||||
soundtouch-cli --host 192.168.1.10 events subscribe --filter zone --no-reconnect
|
||||
soundtouch-cli --host 192.0.2.10 events subscribe --filter zone --no-reconnect
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
@@ -1133,59 +1133,59 @@ soundtouch-cli --host 192.168.1.10 events subscribe --filter zone --no-reconnect
|
||||
soundtouch-cli discover devices
|
||||
|
||||
# Get device info
|
||||
soundtouch-cli --host 192.168.1.10 info
|
||||
soundtouch-cli --host 192.0.2.10 info
|
||||
|
||||
# Set comfortable volume and start playing
|
||||
soundtouch-cli --host 192.168.1.10 volume set --level 30
|
||||
soundtouch-cli --host 192.168.1.10 source spotify
|
||||
soundtouch-cli --host 192.168.1.10 play start
|
||||
soundtouch-cli --host 192.0.2.10 volume set --level 30
|
||||
soundtouch-cli --host 192.0.2.10 source spotify
|
||||
soundtouch-cli --host 192.0.2.10 play start
|
||||
```
|
||||
|
||||
### Daily Usage
|
||||
|
||||
```bash
|
||||
# Morning routine
|
||||
soundtouch-cli --host 192.168.1.10 preset --preset 1 # Morning playlist
|
||||
soundtouch-cli --host 192.168.1.10 volume set --level 25
|
||||
soundtouch-cli --host 192.0.2.10 preset --preset 1 # Morning playlist
|
||||
soundtouch-cli --host 192.0.2.10 volume set --level 25
|
||||
|
||||
# Pause for a call
|
||||
soundtouch-cli --host 192.168.1.10 play pause
|
||||
soundtouch-cli --host 192.0.2.10 play pause
|
||||
|
||||
# Resume
|
||||
soundtouch-cli --host 192.168.1.10 play start
|
||||
soundtouch-cli --host 192.0.2.10 play start
|
||||
|
||||
# Evening routine
|
||||
soundtouch-cli --host 192.168.1.10 preset --preset 3 # Evening playlist
|
||||
soundtouch-cli --host 192.168.1.10 volume set --level 15
|
||||
soundtouch-cli --host 192.0.2.10 preset --preset 3 # Evening playlist
|
||||
soundtouch-cli --host 192.0.2.10 volume set --level 15
|
||||
```
|
||||
|
||||
### Multi-room Setup
|
||||
|
||||
```bash
|
||||
# Create a zone with living room as master
|
||||
soundtouch-cli --host 192.168.1.10 zone create --members 192.168.1.11,192.168.1.12
|
||||
soundtouch-cli --host 192.0.2.10 zone create --members 192.0.2.11,192.0.2.12
|
||||
|
||||
# Control the whole zone from master
|
||||
soundtouch-cli --host 192.168.1.10 volume set --level 40
|
||||
soundtouch-cli --host 192.168.1.10 source spotify
|
||||
soundtouch-cli --host 192.168.1.10 preset --preset 2
|
||||
soundtouch-cli --host 192.0.2.10 volume set --level 40
|
||||
soundtouch-cli --host 192.0.2.10 source spotify
|
||||
soundtouch-cli --host 192.0.2.10 preset --preset 2
|
||||
|
||||
# Later, dissolve the zone
|
||||
soundtouch-cli --host 192.168.1.10 zone dissolve
|
||||
soundtouch-cli --host 192.0.2.10 zone dissolve
|
||||
```
|
||||
|
||||
### Audio Tuning
|
||||
|
||||
```bash
|
||||
# Get current audio settings
|
||||
soundtouch-cli --host 192.168.1.10 volume get
|
||||
soundtouch-cli --host 192.168.1.10 bass get
|
||||
soundtouch-cli --host 192.168.1.10 balance get
|
||||
soundtouch-cli --host 192.0.2.10 volume get
|
||||
soundtouch-cli --host 192.0.2.10 bass get
|
||||
soundtouch-cli --host 192.0.2.10 balance get
|
||||
|
||||
# Adjust for better sound
|
||||
soundtouch-cli --host 192.168.1.10 bass set --level 2 # Slight bass boost
|
||||
soundtouch-cli --host 192.168.1.10 balance set --level -5 # Slightly left
|
||||
soundtouch-cli --host 192.168.1.10 volume set --level 35 # Good listening level
|
||||
soundtouch-cli --host 192.0.2.10 bass set --level 2 # Slight bass boost
|
||||
soundtouch-cli --host 192.0.2.10 balance set --level -5 # Slightly left
|
||||
soundtouch-cli --host 192.0.2.10 volume set --level 35 # Good listening level
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
@@ -1235,7 +1235,7 @@ soundtouch-cli zone create --help
|
||||
You can set default values using environment variables:
|
||||
|
||||
```bash
|
||||
export SOUNDTOUCH_HOST=192.168.1.10
|
||||
export SOUNDTOUCH_HOST=192.0.2.10
|
||||
export SOUNDTOUCH_PORT=8090
|
||||
export SOUNDTOUCH_TIMEOUT=15s
|
||||
|
||||
@@ -1249,7 +1249,7 @@ soundtouch-cli volume get
|
||||
Create `~/.soundtouch.env`:
|
||||
|
||||
```
|
||||
SOUNDTOUCH_HOST=192.168.1.10
|
||||
SOUNDTOUCH_HOST=192.0.2.10
|
||||
SOUNDTOUCH_PORT=8090
|
||||
SOUNDTOUCH_TIMEOUT=15s
|
||||
SOUNDTOUCH_DISCOVERY_TIMEOUT=10s
|
||||
|
||||
@@ -177,8 +177,8 @@ server:
|
||||
|
||||
soundtouch:
|
||||
device_hosts:
|
||||
- "192.168.1.100"
|
||||
- "192.168.1.101"
|
||||
- "192.0.2.100"
|
||||
- "192.0.2.101"
|
||||
discovery_timeout: "30s"
|
||||
request_timeout: "15s"
|
||||
max_retries: 3
|
||||
@@ -820,7 +820,7 @@ services:
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
- DEVICE_HOSTS=192.168.1.100,192.168.1.101
|
||||
- DEVICE_HOSTS=192.0.2.100,192.0.2.101
|
||||
- LOG_LEVEL=info
|
||||
- METRICS_ENABLED=true
|
||||
volumes:
|
||||
@@ -927,7 +927,7 @@ kind: ConfigMap
|
||||
metadata:
|
||||
name: soundtouch-config
|
||||
data:
|
||||
device_hosts: "192.168.1.100,192.168.1.101,192.168.1.102"
|
||||
device_hosts: "192.0.2.100,192.0.2.101,192.0.2.102"
|
||||
```
|
||||
|
||||
#### Systemd Service
|
||||
|
||||
@@ -86,6 +86,7 @@ A factory reset wipes Wi-Fi credentials, account pairing, and all presets, retur
|
||||
| SoundTouch 10 | Power on; hold **Preset 1** + **Volume −** for 10 s | Wi-Fi indicator glows solid amber |
|
||||
| SoundTouch 20 | Power on; hold **Preset 1** + **Volume −** for 10 s | Lights blink L→R, then solid amber |
|
||||
| SoundTouch 20 Series III | Hold **Preset 1** + **Preset 6** simultaneously for ~10 s | White LED sweep |
|
||||
| SoundTouch 30 | Power on; hold **Preset 1** + **Volume −** for 10 s (display counts down 10–1) | Display shows "Hold to restore factory settings", then restarts |
|
||||
| SoundTouch 30 Series III | Hold **Preset 1** + **Preset 6** simultaneously for ~10 s | White LED sweep |
|
||||
| SoundTouch 300 | Hold **Volume −** until light bar blinks rapidly (~15 s) | Rapid blink → off → on |
|
||||
| SoundTouch 10 (alt) | Press and hold the back recessed **Reset** pinhole for 10 s | Status LED restarts |
|
||||
|
||||
@@ -172,14 +172,14 @@ discoverer := discovery.NewDiscoverer(discovery.Config{
|
||||
devices, err := discoverer.DiscoverDevices()
|
||||
|
||||
// Or connect directly if you know the IP
|
||||
soundtouch := client.NewClientFromHost("192.168.1.100")
|
||||
soundtouch := client.NewClientFromHost("192.0.2.100")
|
||||
```
|
||||
|
||||
### Client Configuration
|
||||
|
||||
```go
|
||||
config := client.ClientConfig{
|
||||
Host: "192.168.1.100",
|
||||
Host: "192.0.2.100",
|
||||
Port: 8090, // Default SoundTouch port
|
||||
Timeout: 10 * time.Second,
|
||||
UserAgent: "MyApp/1.0", // Optional
|
||||
@@ -318,7 +318,7 @@ import (
|
||||
|
||||
func main() {
|
||||
// Connect to device
|
||||
soundtouch := client.NewClientFromHost("192.168.1.100")
|
||||
soundtouch := client.NewClientFromHost("192.0.2.100")
|
||||
|
||||
// Create WebSocket client
|
||||
wsClient := soundtouch.NewWebSocketClient(nil)
|
||||
@@ -369,7 +369,7 @@ memberIDs := []string{"DEVICE456", "DEVICE789"}
|
||||
soundtouch.CreateZone(masterID, memberIDs)
|
||||
|
||||
// Add device to existing zone
|
||||
soundtouch.AddToZone("DEVICE999", "192.168.1.15")
|
||||
soundtouch.AddToZone("DEVICE999", "192.0.2.15")
|
||||
|
||||
// Remove device from zone
|
||||
soundtouch.RemoveFromZone("DEVICE456")
|
||||
@@ -387,7 +387,7 @@ soundtouch.DissolveZone()
|
||||
**Solutions:**
|
||||
- Ensure SoundTouch is powered on
|
||||
- Check both devices are on same network
|
||||
- Try specifying IP directly: `client.NewClientFromHost("192.168.1.100")`
|
||||
- Try specifying IP directly: `client.NewClientFromHost("192.0.2.100")`
|
||||
- Check firewall settings
|
||||
|
||||
### Connection Timeouts
|
||||
@@ -397,7 +397,7 @@ soundtouch.DissolveZone()
|
||||
**Solutions:**
|
||||
- Increase timeout: `Timeout: 30 * time.Second`
|
||||
- Verify IP address and port (default 8090)
|
||||
- Check network connectivity with `ping 192.168.1.100`
|
||||
- Check network connectivity with `ping 192.0.2.100`
|
||||
|
||||
### Volume/Control Issues
|
||||
```
|
||||
@@ -444,14 +444,14 @@ Use the included CLI for quick testing:
|
||||
go run ./cmd/soundtouch-cli discover devices
|
||||
|
||||
# Device info
|
||||
go run ./cmd/soundtouch-cli --host 192.168.1.100 info
|
||||
go run ./cmd/soundtouch-cli --host 192.0.2.100 info
|
||||
|
||||
# Basic controls
|
||||
go run ./cmd/soundtouch-cli --host 192.168.1.100 play start
|
||||
go run ./cmd/soundtouch-cli --host 192.168.1.100 volume set --level 50
|
||||
go run ./cmd/soundtouch-cli --host 192.0.2.100 play start
|
||||
go run ./cmd/soundtouch-cli --host 192.0.2.100 volume set --level 50
|
||||
|
||||
# WebSocket monitoring (use websocket-demo)
|
||||
go run ./cmd/websocket-demo --host 192.168.1.100
|
||||
go run ./cmd/websocket-demo --host 192.0.2.100
|
||||
```
|
||||
|
||||
### Configuration Management
|
||||
@@ -462,7 +462,7 @@ import "os"
|
||||
|
||||
host := os.Getenv("SOUNDTOUCH_HOST")
|
||||
if host == "" {
|
||||
host = "192.168.1.100" // fallback
|
||||
host = "192.0.2.100" // fallback
|
||||
}
|
||||
|
||||
soundtouch := client.NewClientFromHost(host)
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
SoundTouch speakers communicate with cloud services over HTTPS. For the local service to work over HTTPS, speakers must trust the AfterTouch Root CA. The service manages this automatically — it generates a CA on first start and the web UI guides you through installing it on each speaker as part of the migration flow.
|
||||
|
||||
> ### ⚠️ Speakers connect to `:443`, AfterTouch defaults to `:8443`
|
||||
>
|
||||
> Speakers build their target URLs from Bose hostnames *without* an explicit port, so they connect on the default HTTPS port **443**. AfterTouch's built-in HTTPS listener defaults to **8443** because port 443 is privileged on most Unix systems.
|
||||
>
|
||||
> **If you do nothing, speakers will fail with `Curl 7` / connection refused and nothing will appear in the AfterTouch HTTP log.**
|
||||
>
|
||||
> Pick one of the three options under [Binding to port 443](#binding-to-port-443) below. The settings page in the web UI shows a ✅ / ❌ indicator for `:443` reachability so you can confirm the routing is in place.
|
||||
|
||||
---
|
||||
|
||||
## How TLS works in AfterTouch
|
||||
@@ -41,9 +49,40 @@ http://<server>:8000/setup/ca.crt
|
||||
|
||||
Speakers expect HTTPS on the default port 443. Since binding to port 443 requires elevated privileges, you have three options:
|
||||
|
||||
1. **Port forwarding (recommended)**: Run the service on port 8443 and forward port 443 to it using `iptables` or your firewall/router.
|
||||
2. **Capabilities**: Grant the binary permission to bind low ports: `sudo setcap 'cap_net_bind_service=+ep' ./soundtouch-service`
|
||||
3. **Reverse proxy**: Use Nginx or Caddy in front of the service (see below).
|
||||
1. **Port forwarding (recommended)**: Run the service on port 8443 and forward port 443 to it using `iptables` or your firewall/router. Inside an LXC/Docker container or on the host:
|
||||
|
||||
```bash
|
||||
iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-port 8443
|
||||
iptables -t nat -A OUTPUT -p tcp --dport 443 -j REDIRECT --to-port 8443
|
||||
```
|
||||
|
||||
The first rule covers traffic arriving from speakers; the second covers loopback connections from the host itself (useful for the in-built pre-flight probe).
|
||||
|
||||
2. **Capabilities**: Grant the binary permission to bind low ports and start the listener directly on `:443`:
|
||||
|
||||
```bash
|
||||
sudo setcap 'cap_net_bind_service=+ep' ./soundtouch-service
|
||||
./soundtouch-service --https-port=443
|
||||
```
|
||||
|
||||
3. **Reverse proxy**: Use Nginx or Caddy on `:443` in front of the service (see below).
|
||||
|
||||
### Confirming `:443` is reachable
|
||||
|
||||
After applying any of the options above, open the AfterTouch web UI → **Settings**. The Target Domain row will show a second line:
|
||||
|
||||
* ✅ `:443 reachable on localhost and <IP> (forwarded to :8443)` — you're good.
|
||||
* ❌ `Speakers connect to :443 but AfterTouch listens on :8443.` — the routing is missing or not yet active.
|
||||
|
||||
A third line follows from the browser itself, which sits on the LAN exactly where the speakers do. The browser can't distinguish an untrusted-CA TLS error from a connection refusal, so it uses timing as a heuristic: a fast error means "no listener / firewall reset", a slower one means "something answered TCP". When the server-side and browser-side checks disagree, the UI flags it — that almost always means NAT, split-horizon DNS, or a host firewall sitting between AfterTouch and the LAN.
|
||||
|
||||
The same check runs once at service startup and prints a `[WARN]` log line if `:443` is unreachable, with the exact iptables/setcap commands for your current listener port.
|
||||
|
||||
#### When this check is shown
|
||||
|
||||
The `:443` indicator is only displayed when **AfterTouch's DNS interception is enabled** (Settings → "Enable DNS Discovery Server"). The check is only meaningful for the **DNS migration method**, where speakers reach AfterTouch via intercepted Bose hostnames and therefore on the implicit `:443`. The other migration method — writing direct `https://<host>:8443/...` URLs into the speaker's private config via SSH — uses the port that's literally in the URL, so `:443` is irrelevant and the check would only add noise.
|
||||
|
||||
If you intercept Bose hostnames **outside** AfterTouch (Pi-hole, router DNS rule, `/etc/hosts` on a gateway), the UI gate above will hide the indicator. The data is still in the `GET /setup/settings` JSON response (`https_443_localhost_reachable`, `https_443_lan_reachable`, `https_443_lan_host`) if you want to inspect it directly, or you can briefly enable AfterTouch's DNS server to see the indicator render.
|
||||
|
||||
---
|
||||
|
||||
@@ -70,6 +109,21 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
> **Tell the service to honour `X-Real-IP`/`X-Forwarded-For`.** When deploying
|
||||
> behind a reverse proxy on the same host as above, set
|
||||
> `"trust_forwarded_headers": true` in `data/settings.json`. With that flag
|
||||
> on, the service rewrites `r.RemoteAddr` from the proxy-supplied headers,
|
||||
> so handlers that act on the source IP (e.g. the Spotify priming triggered
|
||||
> by `/marge/streaming/support/power_on`) see the speaker's real address
|
||||
> instead of the proxy's loopback peer.
|
||||
>
|
||||
> By default only `127.0.0.0/8` and `::1/128` are trusted to set those
|
||||
> headers. If your reverse proxy lives on a different host, list its CIDR(s)
|
||||
> in `"trusted_proxy_cidrs"` (e.g. `["10.0.0.0/8"]`). Do **not** enable
|
||||
> `trust_forwarded_headers` on a flat LAN deployment without a proxy: a
|
||||
> malicious speaker on the LAN can send the headers itself and spoof its
|
||||
> source IP.
|
||||
|
||||
---
|
||||
|
||||
## Manual CA injection (advanced)
|
||||
|
||||
@@ -8,7 +8,7 @@ This guide explains how the SoundTouch service handles device identification thr
|
||||
|
||||
The SoundTouch service uses two different identifiers for devices:
|
||||
|
||||
- **MAC Address** (`A81B6A536A98`) - Used in HTTP API requests and UPnP discovery
|
||||
- **MAC Address** (`AABBCCDDEEFF`) - Used in HTTP API requests and UPnP discovery
|
||||
- **Serial Number** (`I6332527703739342000020`) - Used for internal file storage
|
||||
|
||||
The service automatically maps between these identifiers so that API requests using MAC addresses can access files stored using serial numbers.
|
||||
@@ -17,21 +17,21 @@ The service automatically maps between these identifiers so that API requests us
|
||||
|
||||
### Request Flow
|
||||
```
|
||||
1. HTTP Request: GET /streaming/account/3230304/device/A81B6A536A98/presets
|
||||
2. MAC Resolution: A81B6A536A98 → I6332527703739342000020
|
||||
3. File Access: accounts/3230304/devices/I6332527703739342000020/Presets.xml
|
||||
1. HTTP Request: GET /streaming/account/1000001/device/AABBCCDDEEFF/presets
|
||||
2. MAC Resolution: AABBCCDDEEFF → I6332527703739342000020
|
||||
3. File Access: accounts/1000001/devices/I6332527703739342000020/Presets.xml
|
||||
```
|
||||
|
||||
### UPnP Discovery Integration
|
||||
The service extracts MAC addresses from UPnP device descriptions:
|
||||
|
||||
```xml
|
||||
<!-- From http://192.168.1.100:8091/XD/BO5EBO5E-F00D-F00D-FEED-A81B6A536A98.xml -->
|
||||
<!-- From http://192.0.2.100:8091/XD/BO5EBO5E-F00D-F00D-FEED-AABBCCDDEEFF.xml -->
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<device>
|
||||
<friendlyName>Sound Machinery</friendlyName>
|
||||
<modelName>SoundTouch 10</modelName>
|
||||
<serialNumber>A81B6A536A98</serialNumber> <!-- MAC address here -->
|
||||
<serialNumber>AABBCCDDEEFF</serialNumber> <!-- MAC address here -->
|
||||
</device>
|
||||
</root>
|
||||
```
|
||||
@@ -51,12 +51,12 @@ The service handles all common MAC address formats automatically:
|
||||
|
||||
| Format | Example | Status |
|
||||
|-------------|---------------------|-------------|
|
||||
| Standard | `A81B6A536A98` | ✅ Supported |
|
||||
| Standard | `AABBCCDDEEFF` | ✅ Supported |
|
||||
| Lowercase | `a81b6a536a98` | ✅ Supported |
|
||||
| With Colons | `A8:1B:6A:53:6A:98` | ✅ Supported |
|
||||
| With Dashes | `A8-1B-6A-53-6A-98` | ✅ Supported |
|
||||
| With Colons | `AA:BB:CC:DD:EE:FF` | ✅ Supported |
|
||||
| With Dashes | `AA-BB-CC-DD-EE-FF` | ✅ Supported |
|
||||
| Mixed Case | `a81B6a536A98` | ✅ Supported |
|
||||
| With Spaces | ` A81B6A536A98 ` | ✅ Supported |
|
||||
| With Spaces | ` AABBCCDDEEFF ` | ✅ Supported |
|
||||
|
||||
## 🔧 **Troubleshooting**
|
||||
|
||||
@@ -64,22 +64,22 @@ The service handles all common MAC address formats automatically:
|
||||
|
||||
**Symptoms:**
|
||||
```
|
||||
GET /streaming/account/3230304/device/A81B6A536A98/presets
|
||||
GET /streaming/account/1000001/device/AABBCCDDEEFF/presets
|
||||
→ 500 Internal Server Error
|
||||
→ Log: "open .../devices/A81B6A536A98/Presets.xml: no such file or directory"
|
||||
→ Log: "open .../devices/AABBCCDDEEFF/Presets.xml: no such file or directory"
|
||||
```
|
||||
|
||||
**Diagnosis:**
|
||||
1. Check if mapping exists:
|
||||
```bash
|
||||
# Look for device directory
|
||||
ls data/accounts/3230304/devices/
|
||||
ls data/accounts/1000001/devices/
|
||||
# Should show serial numbers like: I6332527703739342000020
|
||||
```
|
||||
|
||||
2. Check DeviceInfo.xml:
|
||||
```bash
|
||||
cat data/accounts/3230304/devices/I6332527703739342000020/DeviceInfo.xml
|
||||
cat data/accounts/1000001/devices/I6332527703739342000020/DeviceInfo.xml
|
||||
# Look for <macAddress> field
|
||||
```
|
||||
|
||||
@@ -96,8 +96,8 @@ Ensure the MAC address is present:
|
||||
```xml
|
||||
<info deviceID="I6332527703739342000020">
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>A81B6A536A98</macAddress> <!-- Must be present -->
|
||||
<ipAddress>192.168.178.35</ipAddress>
|
||||
<macAddress>AABBCCDDEEFF</macAddress> <!-- Must be present -->
|
||||
<ipAddress>192.0.2.10</ipAddress>
|
||||
</networkInfo>
|
||||
</info>
|
||||
```
|
||||
@@ -106,16 +106,16 @@ Ensure the MAC address is present:
|
||||
If the device was added manually, ensure proper structure:
|
||||
```bash
|
||||
# Create device directory using serial number
|
||||
mkdir -p data/accounts/3230304/devices/I6332527703739342000020
|
||||
mkdir -p data/accounts/1000001/devices/I6332527703739342000020
|
||||
|
||||
# Create DeviceInfo.xml with MAC address
|
||||
cat > data/accounts/3230304/devices/I6332527703739342000020/DeviceInfo.xml << EOF
|
||||
cat > data/accounts/1000001/devices/I6332527703739342000020/DeviceInfo.xml << EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="I6332527703739342000020">
|
||||
<name>My SoundTouch Device</name>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>A81B6A536A98</macAddress>
|
||||
<ipAddress>192.168.1.100</ipAddress>
|
||||
<macAddress>AABBCCDDEEFF</macAddress>
|
||||
<ipAddress>192.0.2.100</ipAddress>
|
||||
</networkInfo>
|
||||
</info>
|
||||
EOF
|
||||
@@ -126,7 +126,7 @@ EOF
|
||||
**Check UPnP accessibility:**
|
||||
```bash
|
||||
# Test UPnP endpoint directly
|
||||
curl http://192.168.1.100:8091/XD/BO5EBO5E-F00D-F00D-FEED-A81B6A536A98.xml
|
||||
curl http://192.0.2.100:8091/XD/BO5EBO5E-F00D-F00D-FEED-AABBCCDDEEFF.xml
|
||||
|
||||
# Should return XML with <serialNumber> field
|
||||
```
|
||||
@@ -144,9 +144,9 @@ This should be handled automatically, but you can verify:
|
||||
**Test different formats:**
|
||||
```bash
|
||||
# All of these should work the same:
|
||||
curl http://localhost:8000/streaming/account/3230304/device/A81B6A536A98/presets
|
||||
curl http://localhost:8000/streaming/account/3230304/device/a81b6a536a98/presets
|
||||
curl http://localhost:8000/streaming/account/3230304/device/A8:1B:6A:53:6A:98/presets
|
||||
curl http://localhost:8000/streaming/account/1000001/device/AABBCCDDEEFF/presets
|
||||
curl http://localhost:8000/streaming/account/1000001/device/a81b6a536a98/presets
|
||||
curl http://localhost:8000/streaming/account/1000001/device/AA:BB:CC:DD:EE:FF/presets
|
||||
```
|
||||
|
||||
## 📊 **Monitoring and Diagnostics**
|
||||
@@ -162,7 +162,7 @@ Ensure proper directory organization:
|
||||
```
|
||||
data/
|
||||
└── accounts/
|
||||
└── 3230304/
|
||||
└── 1000001/
|
||||
└── devices/
|
||||
└── I6332527703739342000020/ # Serial number directory
|
||||
├── DeviceInfo.xml # Contains MAC address
|
||||
@@ -186,8 +186,8 @@ For developers interested in the technical details:
|
||||
// 1. Removing spaces, colons, and dashes
|
||||
// 2. Converting to uppercase
|
||||
// Examples:
|
||||
// "a8:1b:6a:53:6a:98" → "A81B6A536A98"
|
||||
// "A8-1B-6A-53-6A-98" → "A81B6A536A98"
|
||||
// "a8:1b:6a:53:6a:98" → "AABBCCDDEEFF"
|
||||
// "AA-BB-CC-DD-EE-FF" → "AABBCCDDEEFF"
|
||||
```
|
||||
|
||||
### Lookup Process
|
||||
|
||||
+135
-28
@@ -1,6 +1,6 @@
|
||||
# Migration Guide: From Bose Cloud to AfterTouch
|
||||
|
||||
This guide walks through the complete process of migrating your SoundTouch speakers from Bose's cloud services to **AfterTouch**, the local replacement provided by `soundtouch-service`. By the end, your speakers will work fully independently of Bose's servers.
|
||||
This guide walks through the complete process of migrating your SoundTouch speakers from Bose's cloud services to **AfterTouch**, the replacement provided by `soundtouch-service`. By the end, your speakers will work fully independently of Bose's servers.
|
||||
|
||||
For a shorter overview, see the [Survival Guide](SURVIVAL-GUIDE.md). For safety considerations and rollback options, see the [Migration & Safety Guide](MIGRATION-SAFETY.md).
|
||||
|
||||
@@ -35,7 +35,7 @@ The repository ships a `docker-compose.yml` ready for this use case. Clone or do
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env:
|
||||
# SOUNDTOUCH_HOSTNAME=192.168.1.100 ← your server's address
|
||||
# SOUNDTOUCH_HOSTNAME=192.0.2.100 ← your server's address
|
||||
# SOUNDTOUCH_VERSION=v0.70.0 ← pin to a release tag instead of 'latest'
|
||||
docker compose up -d
|
||||
```
|
||||
@@ -82,7 +82,7 @@ Open `http://<server>:8000` and go to the **Settings** tab.
|
||||
|
||||

|
||||
|
||||
Set the **Target Domain** to the address your speakers can reach — for example `https://soundtouch.fritz.box` or `http://192.168.1.100:8000`. This must be the host's address on your local network, not `localhost`.
|
||||
Set the **Target Domain** to the address your speakers can reach — for example `https://soundtouch.fritz.box` or `http://192.0.2.100:8000`. This must be the host's address on your local network, not `localhost`.
|
||||
|
||||
If you plan to use DNS/DHCP redirect, enable the **DNS Discovery Server** and set the **DNS Bind Address** to `:53`. The upstream DNS should be your router's IP, not the service's own address.
|
||||
|
||||
@@ -90,9 +90,13 @@ If you plan to use DNS/DHCP redirect, enable the **DNS Discovery Server** and se
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Enable SSH on each speaker
|
||||
## Step 3: Enable shell access on each speaker
|
||||
|
||||
The migration writes updated configuration to the speaker's filesystem, which requires SSH access. Enable it once per device:
|
||||
The wizard supports **two transports** for talking to the speaker. Pick whichever your device exposes:
|
||||
|
||||
### SSH (recommended — required for XML migration, DNS interception, and CA install)
|
||||
|
||||
The XML migration writes updated configuration to the speaker's filesystem, which requires SSH access. Enable it once per device:
|
||||
|
||||
1. Format a USB drive as FAT (FAT32). Some speakers require the **bootable flag** to be set on the partition — see [SoundCork issue #172](https://github.com/deborahgu/soundcork/issues/172) for details.
|
||||
2. Create an empty file named **`remote_services`** (no extension) in the root of the drive.
|
||||
@@ -102,6 +106,12 @@ The migration writes updated configuration to the speaker's filesystem, which re
|
||||
|
||||
You only need to do this once per speaker. SSH can remain enabled for future maintenance or be disabled after migration — your choice.
|
||||
|
||||
### Telnet:17000 (fallback when SSH isn't possible)
|
||||
|
||||
If the USB-stick unlock doesn't work on your speaker (some firmware revisions refuse it — notably SA-5, ST520, and recent ST Portables), the wizard falls back to the speaker's **built-in diagnostic shell on TCP port 17000**. No setup required — most SoundTouch firmware exposes it automatically. The wizard detects which transports are available and picks the right one; you don't have to choose manually.
|
||||
|
||||
Telnet-only migrations are limited to HTTP (no CA install possible without SSH). The wizard surfaces this clearly when it applies.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Add and sync your speaker
|
||||
@@ -124,44 +134,64 @@ If the Bose cloud is still running, Sync also fetches your account data from Bos
|
||||
|
||||
## Step 5: Migrate
|
||||
|
||||
Click **Migrate** next to a device on the Devices tab to open the Migration tab. It shows SSH status, CA trust status, and connection test results before letting you apply the redirect.
|
||||
Click **Migrate** next to a device on the Devices tab to open the Migration tab. The tab opens with a **Migration Summary** that shows where your speaker currently stands, then offers a one-click suggested plan and a fully customizable form underneath.
|
||||
|
||||

|
||||

|
||||
|
||||
Two redirect methods are available:
|
||||
### What you see at the top — the state card
|
||||
|
||||
### XML redirect (recommended for first-time / testing)
|
||||
Three rows tell you the speaker's current state at a glance:
|
||||
|
||||
Uploads a configuration file to the speaker via the SoundTouch Web API. This changes the application-level service URLs without touching the speaker's network configuration. It's the least invasive option.
|
||||
- **Transports** — whether SSH and Telnet:17000 are reachable. The wizard's choices are driven by these.
|
||||
- **Migration State** — three orthogonal axes:
|
||||
- *URL Configuration* — original Bose URLs or AfterTouch URLs (with a special "intercepted via DNS" verdict when the resolv.conf hook is doing the redirect).
|
||||
- *DNS Interception* — none, or `/etc/resolv.conf` hook active.
|
||||
- *CA / TLS* — local root CA installed on the device, with `Trust CA Now` and `Download CA cert` actions inline.
|
||||
- **Preconditions** — `remote_services` persistence, account pairing state, and XML config backup presence.
|
||||
|
||||
The web UI guides you through:
|
||||
1. Previewing the config change (current vs. planned XML)
|
||||
2. Optionally installing the AfterTouch CA certificate on the speaker (requires SSH; needed for HTTPS)
|
||||
3. Applying the XML redirect
|
||||
4. Verifying the speaker can reach the local service
|
||||
### The Plan card — the happy path
|
||||
|
||||
### DNS/DHCP redirect (recommended for permanent / all-device setup)
|
||||
Below the state card is the **Plan** card. For most users this is the only thing you'll touch:
|
||||
|
||||
Configures the speaker to use a custom DNS server that resolves Bose cloud hostnames to the local service. This is the most robust method — it covers all Bose endpoints automatically and survives reboots.
|
||||
1. **Target service URL** — pre-filled from your Settings. Edit inline and click *Save as default* to update Settings without bouncing tabs.
|
||||
2. **Capabilities** — what transports the speaker exposes and what AfterTouch can offer given those.
|
||||
3. **Service URLs** — four URL inputs (margeServerUrl, statsServerUrl, swUpdateUrl, bmxRegistryUrl) pre-filled with canonical defaults. Most users leave them as-is; soundcork users tick the *Soundcork mode* checkbox to append `/marge` to `margeServerUrl`. URL validation runs on every keystroke.
|
||||
4. **Account pairing** — pre-filled with the speaker's current account ID. Leave it to keep the existing pairing, change it to re-pair, or click *Generate* to assign a new 7-digit ID on a factory-reset device.
|
||||
5. **Suggested plan** — one big green button: *Apply Suggested Plan*. The wizard picks the most conservative recipe for your speaker (XML over SSH with HTTP when SSH works; telnet URL flip with HTTP when only telnet works) and runs it.
|
||||
|
||||
Requirements:
|
||||
- The AfterTouch DNS server must be running and bound to **port 53** on your network. Enable it in the **Settings** tab (`DNS Discovery` → enabled).
|
||||
- HTTPS is required. The web UI walks you through trusting the CA certificate on the speaker (via SSH).
|
||||
### What happens when you click Apply
|
||||
|
||||
The web UI guides you through:
|
||||
1. Verifying the DNS server is running and reachable
|
||||
2. Installing the CA certificate on the speaker
|
||||
3. Configuring the speaker to use the AfterTouch DNS server
|
||||
4. Verifying DNS resolution and HTTPS connectivity
|
||||
The wizard switches to a visible **Pre-flight checks** panel and runs every applicable verification before touching the speaker:
|
||||
|
||||
- **Backend summary re-check** — confirms transports, hostname resolution, and that the URLs you plan to write match what the backend would produce.
|
||||
- **HTTPS connection from device** (SSH-capable speakers) — uploads a temporary CA and runs `curl` from the speaker to your service.
|
||||
- **Reachability check (passive observer)** (already-migrated speakers) — nudges `:8090/swUpdateCheck` on the device and watches for *any* request from the speaker to land on the service. Used when the speaker is already migrated and the service is the natural target of its outbounds.
|
||||
- **"Round-trip validation runs after Apply + reboot"** (not-yet-migrated speakers) — surfaced as a skip row with a rationale. The speaker's swUpdate daemon caches its URL at boot, so there is no useful no-reboot round-trip check pre-migration; the canonical telnet flow is Apply → reboot → re-run pre-flight on the migrated speaker.
|
||||
- **DNS redirection from device** — when DNS interception is part of the plan.
|
||||
|
||||
On all-green, the wizard auto-proceeds. On any failure, it pauses with *Proceed Anyway* / *Cancel* buttons so you can override on a known-false-positive (slow DNS, etc.) or fix the underlying issue and retry.
|
||||
|
||||
### Customize this migration — for mix-and-match
|
||||
|
||||
Expand the `▸ Customize this migration` section to pick any combination of three independent axes:
|
||||
|
||||
- **URL flip transport** — XML over SSH / Telnet (Port 17000) / Skip
|
||||
- **DNS interception** — None / `/etc/resolv.conf` hook
|
||||
- **Local CA install** — checkbox (SSH-only)
|
||||
|
||||
Each option carries a per-axis availability hint (e.g. *(SSH unreachable)*, *(already trusted)*) so you see why an option is disabled before you pick. *Apply Custom Plan* runs the chosen combination as a sequence; the same pre-flight panel gates the execution.
|
||||
|
||||
> **Note**: DNS interception bundles the CA install on the backend, so a standalone CA-install step is skipped automatically when DNS is part of the plan. The wizard handles this for you.
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Reboot and verify
|
||||
|
||||
After migration, **power-cycle the speaker** (unplug and replug). This applies all configuration changes.
|
||||
After a successful Apply the wizard auto-expands the Customize section and highlights the **Reboot Speaker** button. Click it (or power-cycle the speaker manually) to apply all configuration changes. The reboot transport is picked automatically from your URL flip choice — telnet reboot for SSH-less speakers, SSH reboot otherwise.
|
||||
|
||||
After reboot:
|
||||
- The speaker should appear as **migrated** in the Devices tab
|
||||
- The state card on the Migration tab should now show ✅ for URL Configuration (or "intercepted via DNS" if you used the resolv.conf hook)
|
||||
- Presets should load and play (served from the local service)
|
||||
- TuneIn browsing should work
|
||||
- Recently played items should appear
|
||||
@@ -176,12 +206,89 @@ Each speaker is migrated independently. You can run multiple migrations in paral
|
||||
|
||||
---
|
||||
|
||||
## Alternative: CLI-driven factory-reset workflow
|
||||
|
||||
If you prefer scripting the migration, or the wizard isn't an option (headless server, automation, batch onboarding of many speakers), `soundtouch-cli` exposes the same building blocks. The flow below is **not** an in-place migration — it factory-resets the speaker and brings it up fresh against AfterTouch, so any data Bose preserved on the device is wiped. Use this when:
|
||||
|
||||
- You're starting from a factory-reset speaker anyway.
|
||||
- The wizard's in-place migration didn't take and you want a clean slate.
|
||||
- You're scripting setup for many speakers and want a reproducible recipe.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- AfterTouch service running and reachable at a stable URL (e.g., `https://soundtouch.local` from your `.env`).
|
||||
- The speaker reachable on its current IP (passed as `--host`).
|
||||
- For the AP-mode handover step, your laptop must be able to join the speaker's `Bose SoundTouch` Wi-Fi (you'll switch between home Wi-Fi and the speaker's AP).
|
||||
|
||||
### The full sequence
|
||||
|
||||
```bash
|
||||
# 1. Plan what the reset+pair pipeline will write (dry run, no changes yet).
|
||||
soundtouch-cli --host 192.0.2.50 setup plan \
|
||||
--reset=true --include-pair=false \
|
||||
--service-url='https://soundtouch.local'
|
||||
|
||||
# 2. Trigger the factory reset. The speaker reboots into AP mode.
|
||||
soundtouch-cli --host 192.0.2.50 setup factory-reset
|
||||
|
||||
# --- Manual step: join the speaker's Wi-Fi AP (SSID "Bose SoundTouch ...") ---
|
||||
|
||||
# 3. Wait for the AP-mode endpoint to answer.
|
||||
soundtouch-cli setup wait-ap
|
||||
|
||||
# 4. Push your home Wi-Fi credentials to the speaker.
|
||||
# Run twice if the first attempt's ACK races the AP teardown — the second
|
||||
# one is a no-op if the first succeeded.
|
||||
soundtouch-cli setup wifi-push --ssid="YourHomeSSID" --pass='your-wifi-password'
|
||||
|
||||
# --- Manual step: switch your laptop back to the home Wi-Fi network ---
|
||||
|
||||
# 5. Wait for the speaker to come back online on the home network.
|
||||
# --match takes the last 4-6 hex chars of the speaker's MAC (visible on
|
||||
# the bottom of the device).
|
||||
soundtouch-cli setup wait-online --match=42CAFE
|
||||
|
||||
# 6. Pair the speaker with an AfterTouch account.
|
||||
# --mode=full runs the canonical WebSocket SETUP sequence (matches the
|
||||
# Bose app's flow); --account is the 7-digit account ID AfterTouch
|
||||
# should attach the speaker to.
|
||||
soundtouch-cli --host 192.0.2.50 setup pair \
|
||||
--mode=full --account=1111111 \
|
||||
--service-url='https://soundtouch.local'
|
||||
```
|
||||
|
||||
### Verifying the result
|
||||
|
||||
After pairing completes:
|
||||
|
||||
- The speaker should appear on the **Devices** tab in the web UI.
|
||||
- AUX should switch and play audio when selected.
|
||||
- Pressing presets should fetch their content from AfterTouch (the `[LOG]` rows on the service confirm).
|
||||
- TuneIn search and playback should work end-to-end.
|
||||
|
||||
If any of these fail post-pair, see [Troubleshooting](TROUBLESHOOTING.md) — most commonly the speaker just needs a power cycle to pick up everything cleanly.
|
||||
|
||||
### Differences vs the wizard
|
||||
|
||||
| Aspect | Wizard (in-place migration) | CLI factory-reset workflow |
|
||||
|-------------------------------------|---------------------------------------------------------|---------------------------------------------------------|
|
||||
| Preserves speaker's existing state | yes (Presets, recents, attached account) | **no** — wipes everything |
|
||||
| Requires Wi-Fi-network switching | no | yes (laptop joins speaker AP, then home network) |
|
||||
| Scriptable / reproducible | clickable, not scriptable | full bash recipe |
|
||||
| Cloud-side data (Bose Marge backup) | preserved if Sync ran while cloud was alive | not relevant — fresh account on AfterTouch |
|
||||
| Best for | "I want this speaker to keep working with what's on it" | "I want a clean, reproducible setup against AfterTouch" |
|
||||
|
||||
The wizard is still the recommended path for a one-off migration of an existing setup. The CLI workflow is the right choice when you're scripting, batching, or already starting from a reset.
|
||||
|
||||
---
|
||||
|
||||
## Rollback
|
||||
|
||||
If you need to undo a migration:
|
||||
|
||||
- **From the web UI**: Use the **Revert** action on the device — this restores the `.original` backup files created on the speaker during migration.
|
||||
- **Via SSH**: The original config is backed up on the speaker with a `.original` suffix. Restore it manually if the UI is unreachable.
|
||||
- **From the web UI**: Use the **Revert to Defaults** action on the device — this restores the `.original` backup files created on the speaker during the XML migration.
|
||||
- **Telnet-only migrations**: the wizard writes both the runtime configuration layer (`sys configuration …`) and the persistent layer (`envswitch boseurls set …`) so the migration survives reboot. If you want to revert quickly, the cleanest path is to re-run the wizard with the original Bose URLs in the URL editor.
|
||||
- **Via SSH**: The original XML config is backed up on the speaker with a `.original` suffix. Restore it manually if the UI is unreachable.
|
||||
- **Factory reset**: As a last resort, perform a factory reset (see [Device Initial Setup](DEVICE-INITIAL-SETUP.md) for button sequences). This wipes all configuration and returns the speaker to out-of-box state.
|
||||
|
||||
---
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
This guide explains how to link your Spotify or Amazon Music account to AfterTouch so your speakers can stream music from those services.
|
||||
|
||||
> For Spotify, a higher-level mental model of how the integration works —
|
||||
> Spotify Connect vs. AfterTouch's OAuth-intercept path, the
|
||||
> `streamingoauth.bose.com` DNS gotcha, and the token lifecycle — is in
|
||||
> [docs/concepts/spotify-overview.md](../concepts/spotify-overview.md).
|
||||
> Read that if priming or playback isn't behaving as you'd expect.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -69,7 +69,7 @@ In a web browser on any device on your network, go to:
|
||||
http://<your-server-ip>:8000
|
||||
```
|
||||
|
||||
Replace `<your-server-ip>` with the actual IP address of the computer running AfterTouch. For example: `http://192.168.1.100:8000`.
|
||||
Replace `<your-server-ip>` with the actual IP address of the computer running AfterTouch. For example: `http://192.0.2.100:8000`.
|
||||
|
||||
If you are on the same computer that is running AfterTouch, you can use `http://localhost:8000`.
|
||||
|
||||
@@ -82,7 +82,7 @@ You should see the AfterTouch web interface with tabs: Overview, Settings, Devic
|
||||
This is the most important setting. Go to the **Settings** tab and set the **Target Domain** to the full address of your AfterTouch server — the same address you used to open the web interface:
|
||||
|
||||
```
|
||||
http://192.168.1.100:8000
|
||||
http://192.0.2.100:8000
|
||||
```
|
||||
|
||||
Use the IP address of your server, **not** `localhost`. Your speakers need to reach this address over the network, and they cannot resolve `localhost`.
|
||||
|
||||
@@ -13,8 +13,6 @@ The service provides:
|
||||
- **🌐 Web Management UI**: Browser-based interface for device management
|
||||
- **💾 Persistent Data**: Store device configurations, presets, and usage statistics
|
||||
- **📝 HTTP Recording**: Persist all interactions as re-playable `.http` files
|
||||
- **🔄 Endpoint Mirroring**: Asynchronously mirror local requests to Bose cloud for parity testing
|
||||
- **⚖️ Parity Logging**: Detect and record discrepancies between local and official Bose responses
|
||||
- **📥 Session Archiving**: Download entire interaction sessions as `.tar.gz` for offline analysis
|
||||
- **🔍 Auto-Discovery**: Automatically detect and configure SoundTouch devices
|
||||
- **🔒 Offline Operation**: Continue using full device functionality without internet
|
||||
@@ -154,26 +152,28 @@ The service supports multiple ways to configure its behavior. When multiple sour
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Variable | Flag | Description | Default |
|
||||
|------------------------------------|----------------------------|---------------------------------------------------------------------------------------------------------|---------------------------|
|
||||
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
|
||||
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
|
||||
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
|
||||
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
|
||||
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
|
||||
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL | `https://<hostname>:8443` |
|
||||
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
|
||||
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
|
||||
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
|
||||
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
|
||||
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
|
||||
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
|
||||
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
|
||||
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for DNS/DHCP migration) | `:53` |
|
||||
| `MIRROR_ENABLED` | | Enable background mirroring of specific endpoints to Bose cloud | `false` |
|
||||
| `MIRROR_ENDPOINTS` | | Comma-separated list of path patterns to mirror (e.g., `/streaming/account/*/device/*/recent`) | `[]` |
|
||||
| `INTERNAL_PATHS` | `--internal-paths` | Paths for internal requests to exclude from recording (e.g., `/setup/*`, `/web/*`) | `[]` |
|
||||
| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` |
|
||||
| Variable | Flag | Description | Default |
|
||||
|------------------------------------|----------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
|
||||
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
|
||||
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
|
||||
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
|
||||
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
|
||||
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
|
||||
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL | `https://<hostname>:8443` |
|
||||
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
|
||||
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
|
||||
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
|
||||
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
|
||||
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
|
||||
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
|
||||
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
|
||||
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for DNS/DHCP migration) | `:53` |
|
||||
| `INTERNAL_PATHS` | `--internal-paths` | Paths for internal requests to exclude from recording (e.g., `/setup/*`, `/web/*`) | `[]` |
|
||||
| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` |
|
||||
| `STOCKHOLM_DIR` | `--stockholm-dir` | Path to extracted Stockholm frontend directory — enables the Stockholm UI when set | *(disabled)* |
|
||||
| `MARGE_URL` | | Streaming/marge base URL used when rewriting `stockholm/json/config.json`. Defaults to `SERVER_URL`. Set to `SERVER_URL/marge` only when using a soundcork backend. | *(same as `SERVER_URL`)* |
|
||||
| `MARGE_AUTH_TOKEN` | | Pre-seeds the Stockholm `margeAuthToken` state (skips the login step for the first session) | *(empty)* |
|
||||
| `MARGE_ACCOUNT_ID` | | Pre-seeds the Stockholm `margeAccountID` state (used to filter device-discovery results by account) | *(empty)* |
|
||||
|
||||
### Configuration Examples
|
||||
|
||||
@@ -188,6 +188,57 @@ SERVER_URL=https://my-soundtouch.example.com soundtouch-service --port 443
|
||||
LOG_PROXY_BODY=true REDACT_PROXY_LOGS=false soundtouch-service
|
||||
```
|
||||
|
||||
## Stockholm Frontend
|
||||
|
||||
The Stockholm frontend is the patched Bose SoundTouch app UI served directly by the service. When enabled, opening `http://<server>:8000` in a browser shows the full app interface, which communicates with your speakers via the local service instead of Bose's cloud.
|
||||
|
||||
### Getting the Stockholm files
|
||||
|
||||
The Stockholm UI files are not bundled in this repository — you supply them from [krahl/soundcork-stockholm-app](https://github.com/krahl/soundcork-stockholm-app). See that project's README for how to obtain the `stockholm.zip`. Once you have it:
|
||||
|
||||
```bash
|
||||
# 1. Place stockholm.zip in stockholm_zip/
|
||||
mkdir -p stockholm_zip
|
||||
cp /path/to/stockholm.zip stockholm_zip/
|
||||
|
||||
# 2. Build the Docker image that applies the patches
|
||||
make build-stockholm-image
|
||||
|
||||
# 3. Extract and patch the frontend into ./stockholm/
|
||||
make prepare-stockholm
|
||||
```
|
||||
|
||||
The `./stockholm/` directory is now ready to use.
|
||||
|
||||
### Enabling the Stockholm UI
|
||||
|
||||
Pass the directory to the service at startup:
|
||||
|
||||
```bash
|
||||
# Development (recommended): builds the service and runs it with
|
||||
# Stockholm enabled, checking that prepare-stockholm has run.
|
||||
make dev-service-stockholm
|
||||
|
||||
# Binary
|
||||
soundtouch-service --stockholm-dir ./stockholm
|
||||
|
||||
# Environment variable
|
||||
STOCKHOLM_DIR=./stockholm soundtouch-service
|
||||
|
||||
# Docker Compose — add to the environment section of docker-compose.yml
|
||||
# STOCKHOLM_DIR=/app/stockholm
|
||||
# and mount the stockholm/ directory into the container
|
||||
```
|
||||
|
||||
### Stockholm environment variables
|
||||
|
||||
| Variable | Description |
|
||||
|--------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `STOCKHOLM_DIR` | Path to the extracted Stockholm frontend (enables the UI) |
|
||||
| `MARGE_URL` | Override the streaming/marge URL written into `config.json`. Defaults to `SERVER_URL`. Only set this to `SERVER_URL/marge` when routing through a soundcork backend. |
|
||||
| `MARGE_AUTH_TOKEN` | Pre-seed the session auth token so the first app launch skips the login screen |
|
||||
| `MARGE_ACCOUNT_ID` | Pre-seed the account ID — device discovery will only show speakers on this account |
|
||||
|
||||
## Device Migration
|
||||
|
||||
### Understanding Migration
|
||||
@@ -213,10 +264,10 @@ Device migration switches your SoundTouch devices from Bose's cloud services to
|
||||
|
||||
```bash
|
||||
# Get migration summary first
|
||||
curl http://localhost:8000/setup/migration-summary/192.168.1.100
|
||||
curl http://localhost:8000/setup/migration-summary/192.0.2.100
|
||||
|
||||
# Perform migration
|
||||
curl -X POST http://localhost:8000/setup/migrate/192.168.1.100
|
||||
curl -X POST http://localhost:8000/setup/migrate/192.0.2.100
|
||||
|
||||
# Verify migration status
|
||||
curl http://localhost:8000/setup/devices
|
||||
@@ -225,13 +276,21 @@ curl http://localhost:8000/setup/devices
|
||||
#### Advanced Migration Options
|
||||
|
||||
```bash
|
||||
# Migration with proxy fallback for original services
|
||||
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?proxy_url=http://localhost:8000&marge=original&stats=original"
|
||||
|
||||
# Migration with custom target URL
|
||||
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?target_url=https://my-server.com:8000"
|
||||
curl -X POST "http://localhost:8000/setup/migrate/192.0.2.100?target_url=https://my-server.com:8000"
|
||||
|
||||
# Per-field literal URL overrides (preferred — used by the web wizard)
|
||||
curl -X POST "http://localhost:8000/setup/migrate/192.0.2.100?method=xml&target_url=http://server:8000&marge_url=http://server:8000/marge"
|
||||
|
||||
# SSH-less migration over the device's port-17000 diagnostic shell
|
||||
curl -X POST "http://localhost:8000/setup/migrate/192.0.2.100?method=telnet&target_url=http://server:8000"
|
||||
|
||||
# Legacy proxy-fallback for selected fields (kept for API back-compat)
|
||||
curl -X POST "http://localhost:8000/setup/migrate/192.0.2.100?proxy_url=http://localhost:8000&marge=original&stats=original"
|
||||
```
|
||||
|
||||
See the full parameter reference at `POST /setup/migrate/{deviceIP}` below for `method`, `target_url`, `*_url`, and the legacy mode selectors.
|
||||
|
||||
### Post-Migration Verification
|
||||
|
||||
After migration, verify the device is working correctly:
|
||||
@@ -241,10 +300,10 @@ After migration, verify the device is working correctly:
|
||||
curl http://localhost:8000/setup/devices
|
||||
|
||||
# Test preset functionality
|
||||
curl "http://192.168.1.100:8090/presets"
|
||||
curl "http://192.0.2.100:8090/presets"
|
||||
|
||||
# Monitor device events (if needed)
|
||||
curl "http://localhost:8000/events/192.168.1.100"
|
||||
curl "http://localhost:8000/events/192.0.2.100"
|
||||
```
|
||||
|
||||
#### DNS/DHCP Migration (DHCP-Aware DNS Redirection)
|
||||
@@ -316,34 +375,6 @@ You can enable and configure the DNS server via the Web UI or environment variab
|
||||
#### Manual Discovery via DNS
|
||||
Even without migrating a device, you can use the DNS server to discover what a device is querying by manually setting your router's DNS or the device's DNS to point to the AfterTouch service.
|
||||
|
||||
## Endpoint Mirroring & Parity Logging
|
||||
|
||||
The SoundTouch service includes a powerful **Mirroring** feature that allows you to handle requests locally while simultaneously forwarding them to the official Bose cloud in the background. This is primarily used for maintaining long-term compatibility and verifying the accuracy of the local emulation.
|
||||
|
||||
### How Mirroring Works
|
||||
|
||||
When an endpoint is configured for mirroring:
|
||||
1. **GET Requests**: Handled locally first (Primary). The response is returned to the speaker immediately. In the background, the same request is sent to Bose.
|
||||
2. **POST/PUT/DELETE Requests**: Handled locally first. The service then synchronously (but without blocking the speaker's response) forwards the request to Bose to ensure the "official" account state stays in sync with your local changes (e.g., updating a preset).
|
||||
|
||||
### Parity Logging
|
||||
|
||||
The **Parity Logger** automatically compares the response from your local service with the one received from Bose. If it detects any discrepancies, it:
|
||||
1. Logs a warning to the console: `[PARITY] Mismatch detected for GET /...`
|
||||
2. Saves a detailed JSON report to `data/parity_mismatches/`.
|
||||
|
||||
Each report includes the full request, both response bodies, and a summary of what differed (status codes, content types, or missing/different XML tags).
|
||||
|
||||
### Configuration
|
||||
|
||||
Mirroring is configured via the **Settings** tab in the Web UI or through global settings:
|
||||
- **Mirror Enabled**: Master switch for the mirroring infrastructure.
|
||||
- **Mirror Endpoints**: A list of URL path patterns to mirror. You can use wildcards (`*`) to match variable parts like account or device IDs.
|
||||
- Example: `/streaming/account/*/device/*/recent`
|
||||
- Example: `/accounts/*/devices/*/presets/*`
|
||||
|
||||
Mirrored requests are also recorded in the **Interaction Log** under the category `upstream-mirror`, allowing you to see side-by-side exactly how our service's behavior compares to the official one.
|
||||
|
||||
## API Reference
|
||||
|
||||
### Discovery & Setup
|
||||
@@ -357,7 +388,7 @@ Lists all discovered SoundTouch devices with their current status.
|
||||
{
|
||||
"device_id": "08DF1F0BA325",
|
||||
"name": "Living Room Speaker",
|
||||
"ip_address": "192.168.1.100",
|
||||
"ip_address": "192.0.2.100",
|
||||
"product_code": "SoundTouch 20",
|
||||
"firmware_version": "19.0.5",
|
||||
"migrated": true,
|
||||
@@ -393,19 +424,80 @@ Analyzes device configuration and provides migration preview.
|
||||
Migrates device to use local services.
|
||||
|
||||
**Query Parameters:**
|
||||
- `target_url`: Custom service URL (optional)
|
||||
- `proxy_url`: Proxy URL for fallback (optional)
|
||||
- `marge`: Set to "original" to proxy Marge requests (optional)
|
||||
- `stats`: Set to "original" to proxy stats requests (optional)
|
||||
- `sw_update`: Set to "original" to proxy update requests (optional)
|
||||
- `bmx`: Set to "original" to proxy BMX requests (optional)
|
||||
|
||||
| Parameter | Values | Notes |
|
||||
|--------------|-----------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `method` | `xml` (default), `telnet`, `resolv`, `hosts` (deprecated) | Picks the redirect mechanism. `xml` writes `SoundTouchSdkPrivateCfg.xml` via SSH; `telnet` flips the four URLs via the device's port-17000 diagnostic shell; `resolv` installs the `/etc/resolv.conf` priority-nameserver hook and the local CA via SSH. |
|
||||
| `target_url` | Any URL, e.g. `http://soundtouch.local:8000` | Service base URL the per-field defaults derive from. Falls back to the service's configured `ServerURL` when omitted. |
|
||||
| `proxy_url` | Any URL | Proxy base used when the legacy `marge=proxied` / `stats=proxied` / `sw_update=proxied` / `bmx=proxied` modes are set. Defaults to `target_url`. |
|
||||
|
||||
**Per-field implementation mode** (XML method's legacy semantics — kept for API back-compat, UI no longer sets them):
|
||||
|
||||
| Parameter | Values | Effect on the matching `*ServerUrl` / `*RegistryUrl` field |
|
||||
|-------------|-----------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `marge` | `self` (default), `proxied`, `original` | `self`: write `target_url` (canonical). `proxied`: write `<proxy_url>/proxy/<original-marge-url>`. `original`: keep the speaker's existing value. |
|
||||
| `stats` | same | same |
|
||||
| `sw_update` | same | same |
|
||||
| `bmx` | same | same |
|
||||
|
||||
**Per-field literal URL overrides** (preferred — used by the wizard's Plan card; honored for both `xml` and `telnet` methods):
|
||||
|
||||
| Parameter | Effect |
|
||||
|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `marge_url` | Writes the exact URL to `<margeServerUrl>` regardless of `target_url` derivation or `marge` mode. Empty / missing → fall back to canonical default from `target_url`. |
|
||||
| `stats_url` | Same shape for `<statsServerUrl>`. |
|
||||
| `sw_update_url` | Same for `<swUpdateUrl>`. |
|
||||
| `bmx_url` | Same for `<bmxRegistryUrl>`. |
|
||||
|
||||
**Precedence**: `*_url` overrides win over the `marge / stats / sw_update / bmx` mode selectors. The setup package applies `applyProxyOptions` first, then `applyURLOverrides` clobbers any field where a literal `*_url` was supplied. So if you send both `marge=proxied&marge_url=http://x:8000/marge`, the literal `http://x:8000/marge` is written.
|
||||
|
||||
**Soundcork redirect**: append `/marge` to `marge_url`. The telnet method derives `envswitch boseurls set <margeServerUrl> <swUpdateUrl>` from the final URLs verbatim, so the suffix propagates to the parallel persistence layer automatically — no separate flag needed.
|
||||
|
||||
**Examples**:
|
||||
|
||||
```bash
|
||||
# Canonical XML migration over SSH to the default service URL
|
||||
curl -X POST "http://localhost:8000/setup/migrate/192.0.2.100?method=xml"
|
||||
|
||||
# Telnet migration with the soundcork redirect (only marge gets the /marge suffix)
|
||||
curl -X POST "http://localhost:8000/setup/migrate/192.0.2.100?method=telnet&target_url=http://soundcork.local:8000&marge_url=http://soundcork.local:8000/marge"
|
||||
|
||||
# DNS interception (writes /etc/resolv.conf hook + installs CA) — *_url overrides are ignored
|
||||
curl -X POST "http://localhost:8000/setup/migrate/192.0.2.100?method=resolv&target_url=https://my-server.com:8443"
|
||||
```
|
||||
|
||||
#### `POST /setup/telnet-probe/{deviceIP}`
|
||||
SSH-less reachability check. Temporarily flips the speaker's `swUpdateUrl` via the port-17000 diagnostic shell, triggers `:8090/swUpdateCheck` on the device, and observes whether the resulting outbound lands on this service's `/probe/{token}` handler within 6 s. Always attempts to restore the original `swUpdateUrl` even on failure.
|
||||
|
||||
**Query Parameters:**
|
||||
- `target_url` (optional): defaults to the service's configured `ServerURL`. The probe URL written to the device is `<target_url>/probe/<token>`.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"result": {
|
||||
"reached": true,
|
||||
"restored": true,
|
||||
"original_url": "https://worldwide.bose.com/updates/soundtouch",
|
||||
"probe_url": "http://soundtouch.local:8000/probe/abc123…",
|
||||
"elapsed_ms": 412,
|
||||
"logs": "…"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`reached=true` means the device's outbound landed on our `/probe/{token}` route within the timeout. `restored=true` means the runtime `swUpdateUrl` was reverted to its captured original (the envswitch persistence layer is left untouched throughout, so a reboot heals the device naturally if our restore step fails).
|
||||
|
||||
#### `GET /probe/{token}[/*]`
|
||||
Catch-all endpoint that signals the matching pre-flight probe channel. Used internally by `/setup/telnet-probe/{deviceIP}`; not intended to be called directly by API consumers. Returns a minimal `<swUpdateIndex/>` XML so the device's `swUpdateCheck` doesn't choke on a missing structure.
|
||||
|
||||
### BMX Services (Bose Media eXchange)
|
||||
|
||||
#### `GET /bmx/registry/v1/services`
|
||||
Returns available media services for device registration.
|
||||
|
||||
#### `GET /bmx/tunein/v1/playbook/station/{stationID}`
|
||||
#### `GET /bmx/tunein/v1/playback/station/{stationID}`
|
||||
Provides TuneIn station playback information.
|
||||
|
||||
#### `GET /bmx/tunein/v1/podcast/{podcastID}`
|
||||
@@ -701,7 +793,6 @@ Clears all recorded DNS discovery data from memory and disk.
|
||||
- `/bmx/tunein/v1/*`: TuneIn radio emulation.
|
||||
- `/marge/accounts/*`: Account and device management.
|
||||
- `/marge/updates/soundtouch`: Software update emulation.
|
||||
- `/proxy/*`: Logging proxy for original Bose services.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -710,25 +801,25 @@ Clears all recorded DNS discovery data from memory and disk.
|
||||
#### Device Not Discovered
|
||||
```bash
|
||||
# Check network connectivity
|
||||
ping 192.168.1.100
|
||||
ping 192.0.2.100
|
||||
|
||||
# Trigger manual discovery
|
||||
curl -X POST http://localhost:8000/setup/discover
|
||||
|
||||
# Check device accessibility
|
||||
curl http://192.168.1.100:8090/info
|
||||
curl http://192.0.2.100:8090/info
|
||||
```
|
||||
|
||||
#### Migration Failures
|
||||
```bash
|
||||
# Check SSH connectivity
|
||||
ssh-keyscan 192.168.1.100
|
||||
ssh-keyscan 192.0.2.100
|
||||
|
||||
# Get migration summary
|
||||
curl http://localhost:8000/setup/migration-summary/192.168.1.100
|
||||
curl http://localhost:8000/setup/migration-summary/192.0.2.100
|
||||
|
||||
# Verify device configuration
|
||||
curl http://192.168.1.100:8090/info
|
||||
curl http://192.0.2.100:8090/info
|
||||
```
|
||||
|
||||
#### Service Connectivity Issues
|
||||
@@ -807,7 +898,7 @@ func main() {
|
||||
```yaml
|
||||
# configuration.yaml
|
||||
soundtouch:
|
||||
- host: 192.168.1.100
|
||||
- host: 192.0.2.100
|
||||
port: 8090
|
||||
name: "Living Room Speaker"
|
||||
|
||||
@@ -834,8 +925,9 @@ fi
|
||||
|
||||
- **Network Security**: The service binds to all interfaces by default. Consider using `BIND_ADDR=127.0.0.1` for localhost-only access.
|
||||
- **SSH Access**: Migration requires SSH access to devices. Ensure your network security policies allow this.
|
||||
- **Proxy Logging**: Disable `REDACT_PROXY_LOGS` only in development environments.
|
||||
- **Logging**: Disable `REDACT_PROXY_LOGS` only in development environments.
|
||||
- **Data Protection**: The data directory contains device configurations and usage patterns. Secure appropriately.
|
||||
- **Spotify / Amazon Music credential push (zeroconf)**: outbound credential-push requests are restricted to literal IP hosts on local-network ranges (loopback, RFC1918 private, IPv4/IPv6 link-local). Hostname-style URLs (DNS, mDNS `*.local`) are rejected at runtime; if you have a hostname, resolve it first (`getent hosts <name>` or `dig +short <name>`) and pass the resolved IP. This guards against a malicious LAN-resident speaker pointing the credential push at a non-speaker host (server-side request forgery).
|
||||
|
||||
## Performance Tuning
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ What **continues to work** regardless:
|
||||
- Bluetooth, AUX, and AirPlay inputs
|
||||
- Multiroom zones (local, peer-to-peer)
|
||||
|
||||
**AfterTouch** — the `soundtouch-service` — restores everything in the first list by running a local replacement for the Bose cloud on your own network.
|
||||
**AfterTouch** — the `soundtouch-service` — restores everything in the first list by running a replacement for the Bose cloud on your own network.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+222
-37
@@ -14,13 +14,13 @@ Run these commands to quickly diagnose your setup:
|
||||
go run ./cmd/soundtouch-cli -discover
|
||||
|
||||
# 2. Test specific device connection
|
||||
go run ./cmd/soundtouch-cli -host 192.168.1.100 -info
|
||||
go run ./cmd/soundtouch-cli -host 192.0.2.100 -info
|
||||
|
||||
# 3. Test basic controls
|
||||
go run ./cmd/soundtouch-cli -host 192.168.1.100 -volume
|
||||
go run ./cmd/soundtouch-cli -host 192.0.2.100 -volume
|
||||
|
||||
# 4. Test network connectivity
|
||||
ping 192.168.1.100
|
||||
ping 192.0.2.100
|
||||
```
|
||||
|
||||
---
|
||||
@@ -70,7 +70,7 @@ discoverer := discovery.NewDiscoverer(discovery.Config{
|
||||
#### 5. **Use Manual IP**
|
||||
```go
|
||||
// Bypass discovery entirely
|
||||
client := client.NewClientFromHost("192.168.1.100")
|
||||
client := client.NewClientFromHost("192.0.2.100")
|
||||
```
|
||||
|
||||
### ❌ "Discovery timeout"
|
||||
@@ -93,10 +93,10 @@ discoverer := discovery.NewDiscoverer(discovery.Config{
|
||||
2. **Check network performance:**
|
||||
```bash
|
||||
# Test network latency
|
||||
ping -c 4 192.168.1.1
|
||||
ping -c 4 192.0.2.1
|
||||
|
||||
# Check for network congestion
|
||||
iperf3 -c 192.168.1.1 # If iperf server available
|
||||
iperf3 -c 192.0.2.1 # If iperf server available
|
||||
```
|
||||
|
||||
3. **Use wired connection if possible**
|
||||
@@ -105,11 +105,115 @@ iperf3 -c 192.168.1.1 # If iperf server available
|
||||
|
||||
## 🌐 **Connection Issues**
|
||||
|
||||
### ❌ Every cloud source shows `status="UNAVAILABLE"` / can't stream anything
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- The speaker's `/sources` (or the soundtouch-cli `source availability` output) lists every cloud-backed source — Spotify, TuneIn, Internet Radio, AirPlay, Amazon, Alexa — as `status="UNAVAILABLE"`.
|
||||
- Often only AUX shows `status="READY"`.
|
||||
- The speaker can be reached on the LAN (`:8090/info` works) but no Internet streaming source can be selected.
|
||||
|
||||
This is a different failure mode from the [`Curl 7` case below](#-speaker-logs-curl-7-http-0-and-aftertouch-sees-no-http-requests): the speaker can reach AfterTouch but doesn't have the account state to authenticate any cloud surface, so every cloud handler 401s itself out.
|
||||
|
||||
**Three-step diagnostic checklist** (in order — the cause is almost always one of these):
|
||||
|
||||
#### 1. Is `:443` reachable on AfterTouch?
|
||||
|
||||
The AfterTouch Settings tab now ships a preflight that flips ✅ / ❌ for whether the speaker can open a TLS handshake to AfterTouch's HTTPS listener. If `:443` is ❌, follow the steps in [HTTPS-SETUP.md → Binding to port 443](HTTPS-SETUP.md#binding-to-port-443).
|
||||
|
||||
A failing preflight at this layer typically presents as `Curl 7, http 0` in the speaker's syslog (see the [`Curl 7` entry below](#-speaker-logs-curl-7-http-0-and-aftertouch-sees-no-http-requests) for the focused walkthrough).
|
||||
|
||||
#### 2. Does the speaker have a `margeAccountUUID`?
|
||||
|
||||
```bash
|
||||
curl -s http://<speaker-ip>:8090/info | xmllint --xpath '/info/margeAccountUUID/text()' -
|
||||
```
|
||||
|
||||
If the element is empty (or you get no output), the speaker has no account token — every cloud surface that requires authentication will 401 itself out. The Migration tab in AfterTouch detects this and renders:
|
||||
|
||||
> **Current: ❌ Not paired (factory-reset or never paired) — set an ID to pair as part of Apply**
|
||||
|
||||
The Devices list also shows a `⚠ Not paired — re-pair` badge. To resolve, **open the Migration tab**, pick a previous account ID from the dropdown (or click **Generate**), and click **Apply** — same flow as the [factory-reset recovery](#-presets-flash-then-revert-to-select-a-preset-after-a-factory-reset) section below.
|
||||
|
||||
#### 3. What does `logread` say while you trigger a failing source?
|
||||
|
||||
SSH into the speaker (see [DEVICE-LOGGING.md](../DEVICE-LOGGING.md#1-accessing-system-logs-requires-root)) and capture:
|
||||
|
||||
```bash
|
||||
logread -f | grep -v '127.0.0.1:'
|
||||
```
|
||||
|
||||
…while you select a failing source in the SoundTouch app or via `soundtouch-cli`. The lines around the failed attempt usually name the failing host + protocol — TLS handshake error, token fetch 401, missing route, etc. — and that's enough to file an actionable issue.
|
||||
|
||||
**Common outcomes:**
|
||||
|
||||
- ❌ `:443` → fix HTTPS routing, sources transition to READY on the next refresh.
|
||||
- ❌ `margeAccountUUID` empty → run Migration → Apply, sources reappear after `<sourcesUpdated/>` triggers a `/sources` re-sync.
|
||||
- Everything looks right but sources still UNAVAILABLE → the `logread` snippet is the next signal; open an issue with it attached.
|
||||
|
||||
> **Note on the firmware-internal placeholder sources.** The `<sourceItem source="SPOTIFY" sourceAccount="SpotifyConnectUserName" ...>`, `SpotifyAlexaUserName`, `UPNP/UPnPUserName`, `STORED_MUSIC_MEDIA_RENDERER/StoredMusicUserName`, and `QPLAY/QPlay{1,2}UserName` entries that appear in `/sources` even on a broken or unpaired speaker are *firmware-synthesized*. They show up regardless of AfterTouch's source list — their `status="UNAVAILABLE"` does not indicate an AfterTouch problem. Use the three checks above to diagnose the actual cause.
|
||||
|
||||
### ❌ Speaker logs `Curl 7, http 0` and AfterTouch sees no HTTP requests
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
In the speaker's log (see [DEVICE-LOGGING.md](../DEVICE-LOGGING.md#1-accessing-system-logs-requires-root) for the SSH/`logread` setup — the filtered command `logread -f | grep -v '127.0.0.1'` is what you want here):
|
||||
|
||||
```
|
||||
SimpleURLFetcher: retry needed, Curl 7, http 0
|
||||
```
|
||||
|
||||
In the AfterTouch service log: plenty of `[DNS] Intercepted query …` lines but **zero** HTTP requests after each DNS lookup.
|
||||
|
||||
**Cause:** speakers connect to Bose hostnames over implicit HTTPS, i.e. port **443**. AfterTouch's built-in HTTPS listener defaults to **8443** because 443 is privileged. The speaker resolves the right IP, dials `:443`, and gets connection refused — which is what `Curl 7` reports.
|
||||
|
||||
**Verify:**
|
||||
|
||||
```bash
|
||||
curl -ksS -o /dev/null -w "443=%{http_code}\n" https://localhost:443/
|
||||
curl -ksS -o /dev/null -w "8443=%{http_code}\n" https://localhost:8443/
|
||||
```
|
||||
|
||||
Expected when the misconfiguration is present: `443=000` plus a `curl: (7) Failed to connect …` line, `8443=200` (or any 3-digit code).
|
||||
|
||||
**Fix:** route `:443` to AfterTouch's HTTPS listener — see [HTTPS-SETUP.md → Binding to port 443](HTTPS-SETUP.md#binding-to-port-443). The AfterTouch settings page shows a ✅ / ❌ indicator for `:443` reachability once the routing is in place.
|
||||
|
||||
### ❌ Presets flash then revert to "Select a preset" after a factory reset
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- You factory-reset a SoundTouch (Wave / 10 / 20 / 30 / …) that was previously migrated.
|
||||
- After reconnecting it to Wi-Fi, AfterTouch sees the speaker again, but pressing a preset on the device or in the app makes the display briefly show the preset name and then revert to *"Select a preset or explore music in the SoundTouch App"*.
|
||||
- Spotify presets show the same revert unless Spotify Connect is started from the mobile app first.
|
||||
- The speaker's `/sources` is missing TUNEIN / LOCAL_INTERNET_RADIO / DEEZER / your linked Spotify account — only AUX, BLUETOOTH, AIRPLAY, the SpotifyConnectUserName placeholder, NOTIFICATION, and QPLAY appear.
|
||||
|
||||
**Cause:**
|
||||
|
||||
A factory reset wipes `/mnt/nv/BoseApp-Persistence/1/Marge.xml` — the file that carries the speaker's auth token for the AfterTouch (or Bose) cloud service. The migrated URL configuration is preserved (it lives in `envswitch`), so the speaker keeps talking to AfterTouch, but with no token it can't authenticate for preset playback. Separately, the device's `/sources` cache is reduced until it receives a `<sourcesUpdated/>` notification.
|
||||
|
||||
**Fix:**
|
||||
|
||||
1. **Re-open the Migration tab** in the AfterTouch UI. The wizard reads `/info`, sees `margeAccountUUID` is empty, and renders:
|
||||
|
||||
> **Current: ❌ Not paired (factory-reset or never paired) — set an ID to pair as part of Apply**
|
||||
|
||||
The devices list now also shows a `⚠ Not paired — re-pair` badge next to such speakers, so you don't have to remember to open the Migration tab cold.
|
||||
|
||||
2. **Pick the previously-used account ID** from the "pick from datastore" dropdown (if AfterTouch remembers it), or click **Generate** for a fresh one.
|
||||
|
||||
3. **Click Apply.** The wizard runs `pair-account` along with the rest, recreating `Marge.xml` on the device with the chosen ID.
|
||||
|
||||
4. **Click Data Sync** (Tab 3). AfterTouch persists the speaker's presets/recents/sources and posts a `<sourcesUpdated/>` notification to the device — the missing TUNEIN / LOCAL_INTERNET_RADIO / DEEZER / linked Spotify entries reappear in `/sources` automatically.
|
||||
|
||||
5. Press a preset. It should play normally.
|
||||
|
||||
If presets still won't play after step 5, capture `logread -f | grep -v '127.0.0.1:'` on the speaker (see [DEVICE-LOGGING.md](../DEVICE-LOGGING.md#1-accessing-system-logs-requires-root)) while pressing the preset and file an issue with the snippet — the lines around the failed playback name the deeper cause.
|
||||
|
||||
### ❌ "Connection refused"
|
||||
|
||||
**Symptoms:**
|
||||
```go
|
||||
Failed to connect: dial tcp 192.168.1.100:8090: connection refused
|
||||
Failed to connect: dial tcp 192.0.2.100:8090: connection refused
|
||||
```
|
||||
|
||||
**Diagnostic Steps:**
|
||||
@@ -117,12 +221,12 @@ Failed to connect: dial tcp 192.168.1.100:8090: connection refused
|
||||
#### 1. **Verify IP and Port**
|
||||
```bash
|
||||
# Test if port 8090 is open
|
||||
telnet 192.168.1.100 8090
|
||||
telnet 192.0.2.100 8090
|
||||
# OR
|
||||
nc -zv 192.168.1.100 8090
|
||||
nc -zv 192.0.2.100 8090
|
||||
|
||||
# Scan for open ports
|
||||
nmap -p 8080-8100 192.168.1.100
|
||||
nmap -p 8080-8100 192.0.2.100
|
||||
```
|
||||
|
||||
#### 2. **Check Device Status**
|
||||
@@ -133,10 +237,10 @@ nmap -p 8080-8100 192.168.1.100
|
||||
#### 3. **Router/Network Issues**
|
||||
```bash
|
||||
# Check routing
|
||||
traceroute 192.168.1.100
|
||||
traceroute 192.0.2.100
|
||||
|
||||
# Test basic connectivity
|
||||
ping -c 4 192.168.1.100
|
||||
ping -c 4 192.0.2.100
|
||||
```
|
||||
|
||||
### ❌ "Timeout" / "Context deadline exceeded"
|
||||
@@ -151,7 +255,7 @@ Failed to get device info: context deadline exceeded
|
||||
#### 1. **Increase Client Timeout**
|
||||
```go
|
||||
config := client.ClientConfig{
|
||||
Host: "192.168.1.100",
|
||||
Host: "192.0.2.100",
|
||||
Port: 8090,
|
||||
Timeout: 30 * time.Second, // Increase from default 10s
|
||||
}
|
||||
@@ -160,7 +264,7 @@ config := client.ClientConfig{
|
||||
#### 2. **Check Network Latency**
|
||||
```bash
|
||||
# Test response time
|
||||
ping -c 10 192.168.1.100
|
||||
ping -c 10 192.0.2.100
|
||||
|
||||
# Should be < 100ms typically
|
||||
```
|
||||
@@ -181,7 +285,7 @@ Failed to connect: dial tcp: lookup soundtouch.local: no such host
|
||||
|
||||
1. **Use IP instead of hostname:**
|
||||
```go
|
||||
client := client.NewClientFromHost("192.168.1.100") // Not "soundtouch.local"
|
||||
client := client.NewClientFromHost("192.0.2.100") // Not "soundtouch.local"
|
||||
```
|
||||
|
||||
2. **Fix DNS/mDNS:**
|
||||
@@ -279,6 +383,87 @@ client.SelectAux()
|
||||
|
||||
---
|
||||
|
||||
## 🎶 **Music Service & Preset Issues**
|
||||
|
||||
### ❌ Spotify preset fails with "Current content cannot be saved as preset"
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
You push playback to the speaker via Spotify Connect from the Spotify mobile/desktop app. Audio plays fine. You try to store it as a preset and the CLI reports:
|
||||
|
||||
```
|
||||
$ soundtouch-cli preset store-current --slot 2
|
||||
Storing current content as preset 2 from 192.168.x.y:8090...
|
||||
✗ Current content cannot be saved as preset
|
||||
Content: <track name>
|
||||
Source: SPOTIFY
|
||||
2026/05/16 09:13:10 current content cannot be preset
|
||||
```
|
||||
|
||||
…and `soundtouch-cli play now` shows `Source Account: SpotifyConnectUserName`.
|
||||
|
||||
**Cause:**
|
||||
|
||||
The speaker firmware marks Spotify-Connect-pushed content as **non-presetable** at the NowPlaying layer:
|
||||
|
||||
```xml
|
||||
<ContentItem source="SPOTIFY" type="DO_NOT_RESUME" ...
|
||||
sourceAccount="SpotifyConnectUserName" isPresetable="false">
|
||||
```
|
||||
|
||||
That `isPresetable="false"` means the firmware can't independently re-fetch the stream later — it only knows about the session token your phone pushed via the Spotify Connect protocol, which is ephemeral. The speaker refuses the preset *locally*, before any storePreset request reaches AfterTouch's marge.
|
||||
|
||||
**Why an OAuth-linked Spotify account changes the answer:**
|
||||
|
||||
When AfterTouch has a Spotify OAuth account linked (see [MUSIC-SERVICES.md](MUSIC-SERVICES.md)), the speaker has a *persistent* Spotify source it can use to resolve the content URI later — typically an album/playlist container. With that source available, the firmware rewrites the content item from `DO_NOT_RESUME` to `tracklisturl` at save time, flips `isPresetable` to `true`, and the preset goes through. The recall path then routes through AfterTouch's `/oauth/.../cs3` token broker, which returns a Spotify access token for your linked account.
|
||||
|
||||
**Fix:**
|
||||
|
||||
1. Set up Spotify OAuth in AfterTouch following [MUSIC-SERVICES.md](MUSIC-SERVICES.md). The high-level model (Spotify Connect vs the OAuth-intercept path, the `streamingoauth.bose.com` DNS rewrite, the token lifecycle) is in [spotify-overview.md](../concepts/spotify-overview.md).
|
||||
2. Make sure you're on **v0.84.0 or later** — earlier versions had a custom-OAuth-client bug that caused playback to hang at "Buffering".
|
||||
3. Re-prime the speaker (Migration tab → **Prime Spotify**, or wait for the watchdog), then retry the preset save with Connect-pushed playback.
|
||||
|
||||
**What this won't fix:**
|
||||
|
||||
A Connect-only setup with no OAuth account linked in AfterTouch — that's a firmware-level constraint we can't route around from the server side. The speaker simply doesn't have credentials it can use to replay the content later, so it refuses to preset.
|
||||
|
||||
### ❌ TuneIn (or Internet Radio) missing from `/sources` after a factory reset
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- The speaker is happily migrated and reachable; most cloud sources work.
|
||||
- `curl http://<speaker-ip>:8090/sources` lists AUX, Bluetooth, Spotify Connect placeholders, etc. — but **no `TUNEIN` entry**.
|
||||
- `soundtouch-cli source content --source TUNEIN --type stationurl --location /v1/playback/station/<id> --name '<name>'` fails with `1005` (or playing a TuneIn preset silently does nothing).
|
||||
- Other devices on the same setup have `TUNEIN` in `/sources` and work fine.
|
||||
|
||||
**Cause:**
|
||||
|
||||
TuneIn is **not a default source** on a freshly factory-reset SoundTouch. The speaker only adds `TUNEIN` to its `Sources.xml` after the source has been played at least once. Until then, source-selection requests for `TUNEIN` are rejected as invalid.
|
||||
|
||||
This is firmware behaviour — independent of AfterTouch — and is why one device can have `TUNEIN` and a sibling device (just reset) can be missing it. The same applies to `LOCAL_INTERNET_RADIO` if the speaker was reset before any LIR content was played.
|
||||
|
||||
**Fix:**
|
||||
|
||||
Play any TuneIn station once to register the source. Two equivalent paths:
|
||||
|
||||
1. **Via the SoundTouch app** — open the app, pick TuneIn, play any station. The source appears in `/sources` after a few seconds.
|
||||
2. **Via `soundtouch-cli`** on a device that *does* still have TuneIn registered, or by first registering it with a known-working station:
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <speaker-ip> source content \
|
||||
--source TUNEIN --type stationurl \
|
||||
--location /v1/playback/station/s166521 \
|
||||
--name 'SMOOTH JAZZ'
|
||||
```
|
||||
|
||||
(Station `s166521` is one that works for AfterTouch testing; any valid TuneIn station ID works.)
|
||||
|
||||
Once the source plays once, it gets persisted to `/mnt/nv/BoseApp-Persistence/1/Sources.xml` and subsequent TuneIn requests succeed without needing the app.
|
||||
|
||||
**For speakers without SSH:**
|
||||
|
||||
If `soundtouch-cli source content --source TUNEIN ...` returns `1005` on a reset device that has never had TuneIn, the speaker is refusing because the source isn't registered yet — chicken-and-egg. The SoundTouch app is then the only practical path to register it; we can't write `Sources.xml` directly over telnet on most models.
|
||||
|
||||
## 🔊 **Volume & Audio Issues**
|
||||
|
||||
### ❌ "Volume control not working"
|
||||
@@ -361,8 +546,8 @@ client.SetBalanceSafe(10) // Falls back gracefully
|
||||
|
||||
**Symptoms:**
|
||||
```bash
|
||||
$ go run ./cmd/soundtouch-cli --host 192.168.178.35 sp beep
|
||||
Playing notification beep from 192.168.178.35:8090...
|
||||
$ go run ./cmd/soundtouch-cli --host 192.0.2.10 sp beep
|
||||
Playing notification beep from 192.0.2.10:8090...
|
||||
✗ Failed to play notification beep: API request failed with status 400
|
||||
```
|
||||
|
||||
@@ -384,10 +569,10 @@ func (c *Client) PlayNotificationBeep() error {
|
||||
Both commands should now work identically:
|
||||
```bash
|
||||
# CLI command
|
||||
go run ./cmd/soundtouch-cli --host 192.168.178.35 sp beep
|
||||
go run ./cmd/soundtouch-cli --host 192.0.2.10 sp beep
|
||||
|
||||
# Direct curl (for comparison)
|
||||
curl http://192.168.178.35:8090/playNotification
|
||||
curl http://192.0.2.10:8090/playNotification
|
||||
```
|
||||
|
||||
### ❌ "speaker" commands not supported
|
||||
@@ -445,7 +630,7 @@ fmt.Printf("Current source: %s, status: %s\n",
|
||||
|
||||
**Symptoms:**
|
||||
```go
|
||||
Failed to connect WebSocket: dial ws://192.168.1.100:8080/: connection refused
|
||||
Failed to connect WebSocket: dial ws://192.0.2.100:8080/: connection refused
|
||||
```
|
||||
|
||||
**Solutions:**
|
||||
@@ -453,7 +638,7 @@ Failed to connect WebSocket: dial ws://192.168.1.100:8080/: connection refused
|
||||
#### 1. **Verify WebSocket Port (8080)**
|
||||
```bash
|
||||
# WebSocket uses port 8080, not 8090
|
||||
nc -zv 192.168.1.100 8080
|
||||
nc -zv 192.0.2.100 8080
|
||||
```
|
||||
|
||||
#### 2. **Check Protocol Specification**
|
||||
@@ -462,7 +647,7 @@ nc -zv 192.168.1.100 8080
|
||||
wsClient := client.NewWebSocketClient(nil)
|
||||
|
||||
// Manual connection (if needed)
|
||||
url := "ws://192.168.1.100:8080/"
|
||||
url := "ws://192.0.2.100:8080/"
|
||||
headers := http.Header{}
|
||||
headers.Set("Sec-WebSocket-Protocol", "gabbo")
|
||||
```
|
||||
@@ -492,7 +677,7 @@ wsClient := client.NewWebSocketClient(config)
|
||||
2. **Check network stability:**
|
||||
```bash
|
||||
# Test for packet loss
|
||||
ping -c 100 192.168.1.100 | grep loss
|
||||
ping -c 100 192.0.2.100 | grep loss
|
||||
```
|
||||
|
||||
3. **Power management issues:**
|
||||
@@ -624,7 +809,7 @@ transport := &http.Transport{
|
||||
}
|
||||
|
||||
config := client.ClientConfig{
|
||||
Host: "192.168.1.100",
|
||||
Host: "192.0.2.100",
|
||||
Port: 8090,
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
@@ -646,14 +831,14 @@ config.Logger = &client.DefaultLogger{} // Or custom logger
|
||||
|
||||
```bash
|
||||
# Capture SoundTouch traffic
|
||||
sudo tcpdump -i any host 192.168.1.100 and port 8090
|
||||
sudo tcpdump -i any host 192.0.2.100 and port 8090
|
||||
|
||||
# Monitor WebSocket traffic
|
||||
sudo tcpdump -i any host 192.168.1.100 and port 8080
|
||||
sudo tcpdump -i any host 192.0.2.100 and port 8080
|
||||
|
||||
# HTTP debugging with curl
|
||||
curl -v http://192.168.1.100:8090/info
|
||||
curl -v http://192.168.1.100:8090/volume
|
||||
curl -v http://192.0.2.100:8090/info
|
||||
curl -v http://192.0.2.100:8090/volume
|
||||
```
|
||||
|
||||
---
|
||||
@@ -823,9 +1008,9 @@ Use this checklist to systematically troubleshoot issues:
|
||||
|
||||
**Symptoms:**
|
||||
```
|
||||
GET /streaming/account/3230304/device/A81B6A536A98/presets
|
||||
GET /streaming/account/1000001/device/AABBCCDDEEFF/presets
|
||||
→ 500 Internal Server Error
|
||||
→ Log: "open .../devices/A81B6A536A98/Presets.xml: no such file or directory"
|
||||
→ Log: "open .../devices/AABBCCDDEEFF/Presets.xml: no such file or directory"
|
||||
```
|
||||
|
||||
**Cause:** The service uses MAC addresses in API requests but stores files using device serial numbers. A mapping system resolves MAC addresses to serial numbers automatically.
|
||||
@@ -840,13 +1025,13 @@ sudo systemctl restart soundtouch-service
|
||||
2. **Check device directory structure**:
|
||||
```bash
|
||||
# Files should be stored by serial number, not MAC
|
||||
ls data/accounts/3230304/devices/
|
||||
# Should show: I6332527703739342000020/ (not A81B6A536A98/)
|
||||
ls data/accounts/1000001/devices/
|
||||
# Should show: I6332527703739342000020/ (not AABBCCDDEEFF/)
|
||||
```
|
||||
|
||||
3. **Verify DeviceInfo.xml contains MAC address**:
|
||||
```bash
|
||||
cat data/accounts/3230304/devices/*/DeviceInfo.xml | grep macAddress
|
||||
cat data/accounts/1000001/devices/*/DeviceInfo.xml | grep macAddress
|
||||
```
|
||||
|
||||
**For detailed diagnosis and solutions**, see: [**MAC Address Mapping Guide**](MAC-ADDRESS-MAPPING.md)
|
||||
@@ -874,7 +1059,7 @@ SSH ping from device failed and service-side DNS lookup also failed
|
||||
```
|
||||
or:
|
||||
```
|
||||
resolved "soundtouch.local" to 192.168.1.100 from service, not from device —
|
||||
resolved "soundtouch.local" to 192.0.2.100 from service, not from device —
|
||||
result may be wrong if NAT or split-DNS is in use
|
||||
```
|
||||
|
||||
@@ -893,7 +1078,7 @@ ssh root@<speaker-ip>
|
||||
# Try to resolve the service hostname
|
||||
ping -c 1 soundtouch.local
|
||||
# or use the IP directly to verify connectivity
|
||||
ping -c 1 192.168.1.100
|
||||
ping -c 1 192.0.2.100
|
||||
|
||||
# Check the speaker's current DNS config
|
||||
cat /etc/resolv.conf
|
||||
@@ -911,8 +1096,8 @@ The most reliable fix. If the hostname cannot be resolved from the device, use a
|
||||
|
||||
```bash
|
||||
# In your .env
|
||||
SERVER_URL=http://192.168.1.100:8000
|
||||
HTTPS_SERVER_URL=https://192.168.1.100:8443
|
||||
SERVER_URL=http://192.0.2.100:8000
|
||||
HTTPS_SERVER_URL=https://192.0.2.100:8443
|
||||
```
|
||||
|
||||
HTTPS works correctly with IP addresses — the service certificate includes the IP as a Subject Alternative Name (SAN).
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user