mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
Compare commits
@@ -0,0 +1,5 @@
|
||||
# Files intentionally not linked in docs/SUMMARY.md.
|
||||
# Paths are relative to the docs/ directory.
|
||||
# Lines starting with # and blank lines are ignored.
|
||||
|
||||
#analysis/bose-soundtouch-community-tools.md
|
||||
@@ -0,0 +1,17 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.html]
|
||||
# HTML-specific formatting
|
||||
# Standardize on tag layout
|
||||
ij_html_do_not_indent_children_of_tags = html,body,thead,tbody,tfoot
|
||||
ij_html_keep_blank_lines = 1
|
||||
ij_html_attribute_wrap = normal
|
||||
ij_html_space_inside_empty_tag = false
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
# Docker/Service Settings
|
||||
SOUNDTOUCH_HOSTNAME=soundtouch.local
|
||||
SOUNDTOUCH_VERSION=latest
|
||||
|
||||
# Discovery Settings
|
||||
DISCOVERY_TIMEOUT=5s
|
||||
|
||||
@@ -25,6 +25,24 @@
|
||||
},
|
||||
{
|
||||
"pattern": "^https://pkg.go.dev.*badge"
|
||||
},
|
||||
{
|
||||
"pattern": "^\\.\\./images/(dashboard-home|account-creation|account-dashboard|usb-remote-services|device-discovery|device-registration|account-migration|migration-setup|migration-progress|migration-health|migration-complete|backup-setup)\\.png$"
|
||||
},
|
||||
{
|
||||
"pattern": "https://www.contributor-covenant.org/version/2/0/code_of_conduct.html"
|
||||
},
|
||||
{
|
||||
"pattern": "https://www.apkmirror.com/apk/bose-corporation/bose-soundtouch/"
|
||||
},
|
||||
{
|
||||
"pattern": "https://apkpure.com/bose-soundtouch/com.bose.soundtouch"
|
||||
},
|
||||
{
|
||||
"pattern": "^https://bose\\.fandom\\.com/"
|
||||
},
|
||||
{
|
||||
"pattern": "^https://www\\.reddit\\.com/"
|
||||
}
|
||||
],
|
||||
"replacementPatterns": [
|
||||
|
||||
+81
-19
@@ -1,5 +1,8 @@
|
||||
name: CI
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -31,6 +34,9 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Install libpcap
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
@@ -40,8 +46,14 @@ jobs:
|
||||
- name: Run tests
|
||||
run: go test -v -race -coverprofile=coverage.out ./...
|
||||
|
||||
- name: Build service
|
||||
run: make build-service
|
||||
|
||||
- name: Run HTTP client integration tests
|
||||
run: make test-http-client
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
uses: codecov/codecov-action@v6
|
||||
with:
|
||||
file: ./coverage.out
|
||||
flags: unittests
|
||||
@@ -61,6 +73,9 @@ jobs:
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Install libpcap
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Run golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
@@ -97,10 +112,10 @@ jobs:
|
||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||
output_name="${output_name}.exe"
|
||||
fi
|
||||
go build -o "$output_name" ./cmd/soundtouch-cli
|
||||
go build -trimpath -ldflags="-s -w" -o "$output_name" ./cmd/soundtouch-cli
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: soundtouch-cli-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: soundtouch-cli-*
|
||||
@@ -118,6 +133,9 @@ jobs:
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Install libpcap
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Run basic vulnerability check
|
||||
run: |
|
||||
go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
@@ -138,11 +156,32 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Check documentation links
|
||||
uses: gaurav-nelson/github-action-markdown-link-check@v1
|
||||
with:
|
||||
use-quiet-mode: "yes"
|
||||
use-verbose-mode: "yes"
|
||||
config-file: ".github/markdown-link-check.json"
|
||||
run: |
|
||||
npm install -g markdown-link-check
|
||||
find . -name "*.md" -not -path "./tests/*" -not -path "./node_modules/*" -print0 | xargs -0 -n1 markdown-link-check -q -v -c .github/markdown-link-check.json
|
||||
|
||||
- name: Warn on pending images
|
||||
run: |
|
||||
IMAGES=(
|
||||
"dashboard-home.png"
|
||||
"account-creation.png"
|
||||
"account-dashboard.png"
|
||||
"usb-remote-services.png"
|
||||
"device-discovery.png"
|
||||
"device-registration.png"
|
||||
"account-migration.png"
|
||||
"migration-setup.png"
|
||||
"migration-progress.png"
|
||||
"migration-health.png"
|
||||
"migration-complete.png"
|
||||
"backup-setup.png"
|
||||
)
|
||||
|
||||
for img in "${IMAGES[@]}"; do
|
||||
if [ ! -f "docs/images/$img" ]; then
|
||||
echo "::warning file=docs/guides/MIGRATION-GUIDE.md::Pending image '$img' is missing from docs/images/"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Validate API documentation
|
||||
run: |
|
||||
@@ -181,7 +220,7 @@ jobs:
|
||||
|
||||
- name: Test CLI build and help
|
||||
run: |
|
||||
go build -o soundtouch-cli ./cmd/soundtouch-cli
|
||||
go build -trimpath -ldflags="-s -w" -o soundtouch-cli ./cmd/soundtouch-cli
|
||||
./soundtouch-cli -help
|
||||
|
||||
- name: Test library imports
|
||||
@@ -230,32 +269,55 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
- name: Extract metadata (tags, labels) for soundtouch-service
|
||||
id: meta-service
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=ref,event=pr
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
- name: Build and push soundtouch-service Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-service
|
||||
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
|
||||
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.meta-service.outputs.tags }}
|
||||
labels: ${{ steps.meta-service.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Extract metadata (tags, labels) for soundtouch-web
|
||||
id: meta-web
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}-web
|
||||
tags: |
|
||||
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=ref,event=pr
|
||||
|
||||
- name: Build and push soundtouch-web Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
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' }}
|
||||
tags: ${{ steps.meta-web.outputs.tags }}
|
||||
labels: ${{ steps.meta-web.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -293,7 +355,7 @@ jobs:
|
||||
|
||||
- name: Update commit status
|
||||
if: always()
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
try {
|
||||
|
||||
@@ -22,16 +22,16 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
uses: actions/configure-pages@v6
|
||||
- name: Build with Jekyll
|
||||
uses: actions/jekyll-build-pages@v1
|
||||
with:
|
||||
source: 'docs/'
|
||||
destination: '_site'
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: '_site'
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@v5
|
||||
|
||||
@@ -68,6 +68,9 @@ jobs:
|
||||
with:
|
||||
go-version-file: ${{ env.GO_VERSION_FILE }}
|
||||
|
||||
- name: Install libpcap
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Run tests before release
|
||||
run: |
|
||||
echo "Running final tests before release..."
|
||||
@@ -148,6 +151,7 @@ jobs:
|
||||
rm -f "$OUTPUT_NAME" "$OUTPUT_NAME.sha256" "$OUTPUT_NAME.sha512"
|
||||
|
||||
if ! go build \
|
||||
-trimpath \
|
||||
-ldflags="-s -w" \
|
||||
-o "$OUTPUT_NAME" \
|
||||
"$CMD_PATH"; then
|
||||
@@ -165,12 +169,20 @@ jobs:
|
||||
|
||||
# Build Service
|
||||
build_binary "soundtouch-service" "./cmd/soundtouch-service"
|
||||
|
||||
# Build Web
|
||||
build_binary "soundtouch-web" "./cmd/soundtouch-web"
|
||||
|
||||
# Build Backup
|
||||
build_binary "soundtouch-backup" "./cmd/soundtouch-backup"
|
||||
id: build
|
||||
|
||||
- name: Generate individual checksums
|
||||
run: |
|
||||
CLI_NAME="${{ steps.build.outputs.soundtouch-cli }}"
|
||||
SVC_NAME="${{ steps.build.outputs.soundtouch-service }}"
|
||||
WEB_NAME="${{ steps.build.outputs.soundtouch-web }}"
|
||||
BCK_NAME="${{ steps.build.outputs.soundtouch-backup }}"
|
||||
|
||||
# Use atomic operations to avoid conflicts
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
@@ -186,18 +198,22 @@ jobs:
|
||||
|
||||
generate_checksums "$CLI_NAME"
|
||||
generate_checksums "$SVC_NAME"
|
||||
generate_checksums "$WEB_NAME"
|
||||
generate_checksums "$BCK_NAME"
|
||||
|
||||
# Cleanup
|
||||
rm -rf "$TEMP_DIR"
|
||||
echo "✅ Checksums generated successfully"
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm }}
|
||||
path: |
|
||||
build/soundtouch-cli-v*
|
||||
build/soundtouch-service-v*
|
||||
build/soundtouch-web-v*
|
||||
build/soundtouch-backup-v*
|
||||
retention-days: 1
|
||||
|
||||
checksums:
|
||||
@@ -207,7 +223,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download binary artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: binaries-*
|
||||
path: ./binaries
|
||||
@@ -224,7 +240,7 @@ jobs:
|
||||
mkdir -p release-files
|
||||
|
||||
# Move all files from subdirectories to the collection directory
|
||||
find . -mindepth 2 -type f \( -name "soundtouch-cli-*" -o -name "soundtouch-service-*" \) -exec mv {} release-files/ \;
|
||||
find . -mindepth 2 -type f \( -name "soundtouch-cli-*" -o -name "soundtouch-service-*" -o -name "soundtouch-web-*" -o -name "soundtouch-backup-*" \) -exec mv {} release-files/ \;
|
||||
|
||||
# Remove empty directories
|
||||
find . -type d -empty -delete
|
||||
@@ -239,14 +255,14 @@ jobs:
|
||||
# Generate combined checksums (exclude individual .sha256/.sha512 files)
|
||||
if ls soundtouch-* 1> /dev/null 2>&1; then
|
||||
# Only checksum the actual binaries, not the .sha256/.sha512 files
|
||||
ls soundtouch-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha256sum > checksums.sha256
|
||||
ls soundtouch-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha512sum > checksums.sha512
|
||||
ls soundtouch-cli-* soundtouch-service-* soundtouch-web-* soundtouch-backup-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha256sum > checksums.sha256
|
||||
ls soundtouch-cli-* soundtouch-service-* soundtouch-web-* soundtouch-backup-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha512sum > checksums.sha512
|
||||
|
||||
echo "📋 Generated combined checksums:"
|
||||
cat checksums.sha256
|
||||
|
||||
# Verify all expected files are present (binaries only, not checksum files)
|
||||
EXPECTED_COUNT=14 # 7 platforms * 2 binaries
|
||||
EXPECTED_COUNT=28 # 7 platforms * 4 binaries
|
||||
ACTUAL_COUNT=$(ls soundtouch-* | grep -v '\.sha256$' | grep -v '\.sha512$' | wc -l)
|
||||
|
||||
if [[ $ACTUAL_COUNT -ne $EXPECTED_COUNT ]]; then
|
||||
@@ -264,7 +280,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload checksums
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: checksums
|
||||
path: |
|
||||
@@ -275,7 +291,7 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload all release assets
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-assets
|
||||
path: binaries/release-files/
|
||||
@@ -294,7 +310,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download release assets
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: release-assets
|
||||
path: ./release-assets
|
||||
@@ -377,6 +393,18 @@ jobs:
|
||||
./soundtouch-service
|
||||
\`\`\`
|
||||
|
||||
### SoundTouch Web
|
||||
\`\`\`bash
|
||||
# Start the web app
|
||||
./soundtouch-web
|
||||
\`\`\`
|
||||
|
||||
### SoundTouch Backup
|
||||
\`\`\`bash
|
||||
# Back up cloud account and all paired speakers in one go
|
||||
./soundtouch-backup all
|
||||
\`\`\`
|
||||
|
||||
## 🧪 Tested Hardware
|
||||
|
||||
- Bose SoundTouch 10
|
||||
@@ -395,7 +423,7 @@ jobs:
|
||||
- Windows (amd64)
|
||||
- FreeBSD (amd64)
|
||||
|
||||
Both `soundtouch-cli` and `soundtouch-service` are included.
|
||||
`soundtouch-cli`, `soundtouch-service`, `soundtouch-web`, and `soundtouch-backup` are included.
|
||||
|
||||
## 🔐 Checksums
|
||||
|
||||
@@ -440,7 +468,7 @@ jobs:
|
||||
echo "release_notes_file=release_notes.md" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ github.event.inputs.tag }}
|
||||
name: "Bose SoundTouch Go Library ${{ github.event.inputs.tag }}"
|
||||
@@ -450,6 +478,8 @@ jobs:
|
||||
files: |
|
||||
release-assets/soundtouch-cli-v*
|
||||
release-assets/soundtouch-service-v*
|
||||
release-assets/soundtouch-web-v*
|
||||
release-assets/soundtouch-backup-v*
|
||||
release-assets/checksums.sha256
|
||||
release-assets/checksums.sha512
|
||||
fail_on_unmatched_files: true
|
||||
@@ -464,18 +494,20 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download release assets
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: release-assets
|
||||
path: ./release-assets
|
||||
|
||||
- name: Upload additional assets to existing release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ github.event.release.tag_name }}
|
||||
files: |
|
||||
release-assets/soundtouch-cli-v*
|
||||
release-assets/soundtouch-service-v*
|
||||
release-assets/soundtouch-web-v*
|
||||
release-assets/soundtouch-backup-v*
|
||||
release-assets/checksums.sha256
|
||||
release-assets/checksums.sha512
|
||||
fail_on_unmatched_files: true
|
||||
@@ -492,18 +524,18 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
- name: Extract metadata (tags, labels) for soundtouch-service
|
||||
id: meta-service
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
@@ -511,13 +543,37 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}},value=v${{ needs.validate.outputs.version }}
|
||||
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
- name: Build and push soundtouch-service Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-service
|
||||
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.meta-service.outputs.tags }}
|
||||
labels: ${{ steps.meta-service.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Extract metadata (tags, labels) for soundtouch-web
|
||||
id: meta-web
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}-web
|
||||
tags: |
|
||||
type=semver,pattern={{version}},value=v${{ needs.validate.outputs.version }}
|
||||
type=semver,pattern={{major}}.{{minor}},value=v${{ needs.validate.outputs.version }}
|
||||
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
|
||||
|
||||
- name: Build and push soundtouch-web Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-web
|
||||
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
|
||||
push: true
|
||||
tags: ${{ steps.meta-web.outputs.tags }}
|
||||
labels: ${{ steps.meta-web.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -531,7 +587,7 @@ jobs:
|
||||
- name: Notify success
|
||||
run: |
|
||||
echo "🎉 Release ${{ needs.validate.outputs.version }} completed successfully!"
|
||||
echo "📦 Binaries built for 7 platforms (CLI and Service)"
|
||||
echo "📦 Binaries built for 7 platforms (CLI, Service, Web, and Backup)"
|
||||
echo "🐳 Docker image published to ghcr.io"
|
||||
echo "🔐 Checksums generated and verified"
|
||||
echo "📋 Release notes automatically generated"
|
||||
|
||||
@@ -14,6 +14,8 @@ jobs:
|
||||
vulnerability-scan:
|
||||
name: Vulnerability Scan
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -24,6 +26,9 @@ jobs:
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Install libpcap
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Install security scanning tools
|
||||
run: |
|
||||
go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
@@ -43,7 +48,7 @@ jobs:
|
||||
|
||||
- name: Upload vulnerability scan results
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: vulnerability-scan-results
|
||||
path: |
|
||||
@@ -53,6 +58,8 @@ jobs:
|
||||
static-analysis:
|
||||
name: Static Security Analysis
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -63,6 +70,9 @@ jobs:
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Install libpcap
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Install static analysis tools
|
||||
run: |
|
||||
go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||
@@ -102,6 +112,9 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install libpcap
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
@@ -119,6 +132,8 @@ jobs:
|
||||
dependency-review:
|
||||
name: Dependency Review
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
steps:
|
||||
@@ -137,6 +152,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [vulnerability-scan, static-analysis, codeql-analysis]
|
||||
if: always()
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Security scan summary
|
||||
|
||||
+11
@@ -12,8 +12,10 @@ dist/
|
||||
#example-upnp
|
||||
|
||||
# Root-level binary executables (exclude built binaries in root)
|
||||
/soundtouch-backup
|
||||
/soundtouch-cli
|
||||
/soundtouch-service
|
||||
/soundtouch-web
|
||||
/example-mdns
|
||||
/example-upnp
|
||||
/example-unified
|
||||
@@ -56,6 +58,15 @@ vendor/
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Android MITM setup — downloaded/generated artefacts, not committed
|
||||
scripts/android/bose.apk
|
||||
scripts/android/frida-server
|
||||
scripts/android/frida-server.xz
|
||||
scripts/android/frida/
|
||||
scripts/android/frida-venv/
|
||||
scripts/android/captures/
|
||||
scripts/android/mitm/
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
+40
-10
@@ -1,5 +1,12 @@
|
||||
# Build stage
|
||||
FROM golang:1.26.0-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.3-alpine AS builder
|
||||
|
||||
# Declare automatic platform ARGs to make them available in build stage
|
||||
# See https://docs.docker.com/reference/dockerfile#automatic-platform-args-in-the-global-scope
|
||||
# We should not set defaults here, but rely on BuildKit to set them matching the BUILDPLATFORM
|
||||
ARG TARGETARCH
|
||||
ARG TARGETOS
|
||||
ARG TARGETVARIANT
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -11,30 +18,53 @@ RUN go mod download
|
||||
COPY . .
|
||||
|
||||
# Build the soundtouch-service
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /soundtouch-service ./cmd/soundtouch-service
|
||||
RUN if [ "${TARGETARCH}" = "arm" ] && [ -n "${TARGETVARIANT}" ]; then \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} GOARM=${TARGETVARIANT#v} go build -o /soundtouch-service ./cmd/soundtouch-service; \
|
||||
else \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /soundtouch-service ./cmd/soundtouch-service; \
|
||||
fi
|
||||
|
||||
# Final stage
|
||||
FROM alpine:3.23
|
||||
# Build the soundtouch-web
|
||||
RUN if [ "${TARGETARCH}" = "arm" ] && [ -n "${TARGETVARIANT}" ]; then \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} GOARM=${TARGETVARIANT#v} go build -o /soundtouch-web ./cmd/soundtouch-web; \
|
||||
else \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /soundtouch-web ./cmd/soundtouch-web; \
|
||||
fi
|
||||
|
||||
# soundtouch-service image
|
||||
FROM alpine:3.23 AS soundtouch-service
|
||||
|
||||
# Install necessary runtime dependencies
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the binary from the builder stage
|
||||
COPY --from=builder /soundtouch-service /app/soundtouch-service
|
||||
|
||||
# Create data directory for persistence
|
||||
# Verify the binary works on the target platform
|
||||
RUN /app/soundtouch-service version || echo "Binary verification complete"
|
||||
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Set environment variables with defaults
|
||||
ENV PORT=8000
|
||||
ENV DATA_DIR=/app/data
|
||||
ENV LOG_PROXY_BODY=false
|
||||
ENV REDACT_PROXY_LOGS=true
|
||||
|
||||
# Expose the service port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run the service
|
||||
ENTRYPOINT ["/app/soundtouch-service"]
|
||||
|
||||
# soundtouch-web image
|
||||
FROM alpine:3.23 AS soundtouch-web
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /soundtouch-web /app/soundtouch-web
|
||||
|
||||
ENV PORT=8080
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["/app/soundtouch-web"]
|
||||
|
||||
@@ -14,77 +14,109 @@ BINARY_NAME=soundtouch-cli
|
||||
BINARY_PATH=./cmd/$(BINARY_NAME)
|
||||
SERVICE_NAME=soundtouch-service
|
||||
SERVICE_PATH=./cmd/$(SERVICE_NAME)
|
||||
WEB_NAME=soundtouch-web
|
||||
WEB_PATH=./cmd/$(WEB_NAME)
|
||||
EXAMPLE_MDNS_NAME=example-mdns
|
||||
EXAMPLE_MDNS_PATH=./cmd/$(EXAMPLE_MDNS_NAME)
|
||||
EXAMPLE_UPNP_NAME=example-upnp
|
||||
EXAMPLE_UPNP_PATH=./cmd/$(EXAMPLE_UPNP_NAME)
|
||||
SCANNER_NAME=mdns-scanner
|
||||
SCANNER_PATH=./cmd/$(SCANNER_NAME)
|
||||
FAVICON_GEN_NAME=favicon-gen
|
||||
FAVICON_GEN_PATH=./cmd/$(FAVICON_GEN_NAME)
|
||||
BACKUP_NAME=soundtouch-backup
|
||||
BACKUP_PATH=./cmd/$(BACKUP_NAME)
|
||||
BUILD_DIR=./build
|
||||
|
||||
# Version info
|
||||
# No ldflags needed - using debug.BuildInfo since Go 1.18
|
||||
# Build flags: strip debug info/DWARF for smaller binaries, remove local paths for reproducibility
|
||||
BUILDFLAGS=-trimpath -ldflags="-s -w"
|
||||
|
||||
all: check build
|
||||
|
||||
build: build-cli build-service build-examples
|
||||
build: build-cli build-service build-web build-examples build-favicon-gen build-backup
|
||||
|
||||
build-cli:
|
||||
@echo "Building $(BINARY_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME) $(BINARY_PATH)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) $(BINARY_PATH)
|
||||
|
||||
build-service:
|
||||
@echo "Building $(SERVICE_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME) $(SERVICE_PATH)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME) $(SERVICE_PATH)
|
||||
|
||||
build-web:
|
||||
@echo "Building $(WEB_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(WEB_NAME) $(WEB_PATH)
|
||||
|
||||
build-examples:
|
||||
@echo "Building $(EXAMPLE_MDNS_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME) $(EXAMPLE_MDNS_PATH)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME) $(EXAMPLE_MDNS_PATH)
|
||||
@echo "Building $(EXAMPLE_UPNP_NAME)..."
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME) $(EXAMPLE_UPNP_PATH)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME) $(EXAMPLE_UPNP_PATH)
|
||||
@echo "Building $(SCANNER_NAME)..."
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME) $(SCANNER_PATH)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME) $(SCANNER_PATH)
|
||||
|
||||
build-all: build-linux build-darwin build-windows build-examples-all
|
||||
build-favicon-gen:
|
||||
@echo "Building $(FAVICON_GEN_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(FAVICON_GEN_NAME) $(FAVICON_GEN_PATH)
|
||||
|
||||
build-backup:
|
||||
@echo "Building $(BACKUP_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME) $(BACKUP_PATH)
|
||||
|
||||
build-all: build-linux build-linux-armv7 build-darwin build-windows build-examples-all
|
||||
|
||||
build-linux:
|
||||
@echo "Building for Linux..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 $(BINARY_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME)-linux-amd64 $(SERVICE_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 $(BINARY_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-linux-amd64 $(SERVICE_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME)-linux-amd64 $(BACKUP_PATH)
|
||||
|
||||
build-linux-armv7:
|
||||
@echo "Building for Linux ARMv7 (CGO_ENABLED=0 for kernel 3.14+ compatibility)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-linux-armv7 $(SERVICE_PATH)
|
||||
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 $(BINARY_PATH)
|
||||
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME)-linux-armv7 $(BACKUP_PATH)
|
||||
|
||||
build-darwin:
|
||||
@echo "Building for macOS..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 $(BINARY_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 $(BINARY_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME)-darwin-amd64 $(SERVICE_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME)-darwin-arm64 $(SERVICE_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 $(BINARY_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 $(BINARY_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-darwin-amd64 $(SERVICE_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-darwin-arm64 $(SERVICE_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME)-darwin-amd64 $(BACKUP_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME)-darwin-arm64 $(BACKUP_PATH)
|
||||
|
||||
build-windows:
|
||||
@echo "Building for Windows..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe $(BINARY_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME)-windows-amd64.exe $(SERVICE_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe $(BINARY_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-windows-amd64.exe $(SERVICE_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME)-windows-amd64.exe $(BACKUP_PATH)
|
||||
|
||||
build-examples-all:
|
||||
@echo "Building examples for all platforms..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-linux-amd64 $(EXAMPLE_MDNS_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-darwin-amd64 $(EXAMPLE_MDNS_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-darwin-arm64 $(EXAMPLE_MDNS_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-windows-amd64.exe $(EXAMPLE_MDNS_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-linux-amd64 $(EXAMPLE_UPNP_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-darwin-amd64 $(EXAMPLE_UPNP_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-darwin-arm64 $(EXAMPLE_UPNP_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-windows-amd64.exe $(EXAMPLE_UPNP_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME)-linux-amd64 $(SCANNER_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME)-darwin-amd64 $(SCANNER_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME)-darwin-arm64 $(SCANNER_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME)-windows-amd64.exe $(SCANNER_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-linux-amd64 $(EXAMPLE_MDNS_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-darwin-amd64 $(EXAMPLE_MDNS_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-darwin-arm64 $(EXAMPLE_MDNS_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-windows-amd64.exe $(EXAMPLE_MDNS_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-linux-amd64 $(EXAMPLE_UPNP_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-darwin-amd64 $(EXAMPLE_UPNP_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-darwin-arm64 $(EXAMPLE_UPNP_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-windows-amd64.exe $(EXAMPLE_UPNP_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME)-linux-amd64 $(SCANNER_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME)-darwin-amd64 $(SCANNER_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME)-darwin-arm64 $(SCANNER_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME)-windows-amd64.exe $(SCANNER_PATH)
|
||||
|
||||
test:
|
||||
@echo "Running tests..."
|
||||
@@ -96,7 +128,56 @@ test-coverage:
|
||||
$(GOCMD) tool cover -html=coverage.out -o coverage.html
|
||||
@echo "Coverage report generated: coverage.html"
|
||||
|
||||
check: fmt vet test
|
||||
check: fmt vet test test-http-client
|
||||
|
||||
test-http-client:
|
||||
@echo "Starting services with docker compose..."
|
||||
@docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d --build
|
||||
@echo "Waiting for services to start..."
|
||||
@sleep 10
|
||||
@echo "Running .http tests..."
|
||||
@docker run --rm --network soundtouch-test-net \
|
||||
-v "$(PWD)/tests/integration/http-client:/workdir" \
|
||||
jetbrains/intellij-http-client:2026.1 \
|
||||
--env-file /workdir/http-client.env.json \
|
||||
--env ci \
|
||||
/workdir/spotify_registration.http \
|
||||
/workdir/amazon_registration.http \
|
||||
/workdir/create_account.http \
|
||||
/workdir/register_device.http \
|
||||
/workdir/spotify_full_flow.http \
|
||||
/workdir/customer_support.http \
|
||||
/workdir/power_on.http \
|
||||
/workdir/get_bmx_services.http \
|
||||
/workdir/get_sourceproviders.http \
|
||||
/workdir/get_software_update.http \
|
||||
/workdir/get_soundtouch_updates.http \
|
||||
/workdir/get_streaming_token.http \
|
||||
/workdir/post_oauth_token.http \
|
||||
/workdir/post_oauth_token_amazon.http \
|
||||
/workdir/get_provider_settings.http \
|
||||
/workdir/tunein_playback_station.http \
|
||||
/workdir/set_preset_6.http \
|
||||
/workdir/get_presets.http \
|
||||
/workdir/delete_preset_6.http \
|
||||
/workdir/set_preset_5.http \
|
||||
/workdir/post_recent.http \
|
||||
/workdir/get_recents.http \
|
||||
/workdir/get_account_presets.http \
|
||||
/workdir/get_account_devices.http \
|
||||
/workdir/get_account_sources.http \
|
||||
/workdir/get_api_versions.http \
|
||||
/workdir/post_musicprovider_is_eligible.http \
|
||||
/workdir/get_full_account.http \
|
||||
/workdir/get_group.http \
|
||||
/workdir/unregister_device.http \
|
||||
--report; \
|
||||
EXIT_CODE=$$?; \
|
||||
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs soundtouch-service; \
|
||||
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs spotify-mock; \
|
||||
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs amazon-mock; \
|
||||
docker compose -f docker-compose.yml -f docker-compose.ci.yml down; \
|
||||
exit $$EXIT_CODE
|
||||
|
||||
fmt:
|
||||
@echo "Formatting code..."
|
||||
@@ -187,10 +268,44 @@ dev-scan-http: build-examples
|
||||
@echo "Scanning for HTTP mDNS services..."
|
||||
$(BUILD_DIR)/$(SCANNER_NAME) -service _http._tcp -v
|
||||
|
||||
install: build-cli build-service
|
||||
dev-web: build-web
|
||||
@echo "Starting web UI (default port 8080)..."
|
||||
cd cmd/soundtouch-web && ../../$(BUILD_DIR)/$(WEB_NAME)
|
||||
|
||||
dev-web-port: build-web
|
||||
@echo "Starting web UI on custom port..."
|
||||
@if [ -z "$(PORT)" ]; then \
|
||||
echo "Usage: make dev-web-port PORT=8888"; \
|
||||
exit 1; \
|
||||
fi
|
||||
cd cmd/soundtouch-web && ../../$(BUILD_DIR)/$(WEB_NAME) -port $(PORT)
|
||||
|
||||
dev-backup: build-backup
|
||||
@echo "Running backup tool..."
|
||||
$(BUILD_DIR)/$(BACKUP_NAME) --help
|
||||
|
||||
dev-backup-cloud: build-backup
|
||||
@echo "Running cloud backup..."
|
||||
$(BUILD_DIR)/$(BACKUP_NAME) cloud
|
||||
|
||||
dev-backup-local: build-backup
|
||||
@echo "Running local backup (auto-discover)..."
|
||||
$(BUILD_DIR)/$(BACKUP_NAME) local --discover
|
||||
|
||||
dev-web-host: build-web
|
||||
@echo "Starting web UI with specific host..."
|
||||
@if [ -z "$(HOST)" ]; then \
|
||||
echo "Usage: make dev-web-host HOST=192.168.1.10"; \
|
||||
exit 1; \
|
||||
fi
|
||||
cd cmd/soundtouch-web && ../../$(BUILD_DIR)/$(WEB_NAME) -host $(HOST)
|
||||
|
||||
install: build-cli build-service build-web build-backup
|
||||
@echo "Installing binaries to $(GOPATH)/bin..."
|
||||
cp $(BUILD_DIR)/$(BINARY_NAME) $(GOPATH)/bin/
|
||||
cp $(BUILD_DIR)/$(SERVICE_NAME) $(GOPATH)/bin/
|
||||
cp $(BUILD_DIR)/$(WEB_NAME) $(GOPATH)/bin/
|
||||
cp $(BUILD_DIR)/$(BACKUP_NAME) $(GOPATH)/bin/
|
||||
|
||||
clean:
|
||||
@echo "Cleaning..."
|
||||
@@ -210,7 +325,7 @@ release: clean check build-all
|
||||
|
||||
docker-build:
|
||||
@echo "Building Docker image..."
|
||||
docker build -t soundtouch-service .
|
||||
docker build --target soundtouch-service -t soundtouch-service .
|
||||
|
||||
docker-run-host:
|
||||
@echo "Running Docker container..."
|
||||
@@ -226,8 +341,11 @@ help:
|
||||
@echo " build - Build the CLI tool, service, and examples"
|
||||
@echo " build-cli - Build only the CLI tool"
|
||||
@echo " build-service - Build only the service"
|
||||
@echo " build-backup - Build only the backup tool"
|
||||
@echo " build-favicon-gen - Build the favicon generator"
|
||||
@echo " build-examples - Build only the example programs"
|
||||
@echo " build-all - Build for all platforms"
|
||||
@echo " build-linux-armv7 - Build for Linux ARMv7 (kernel 3.14+ compatible, CGO_ENABLED=0)"
|
||||
@echo " test - Run tests"
|
||||
@echo " test-coverage - Run tests with coverage report"
|
||||
@echo " check - Run fmt, vet, and tests"
|
||||
@@ -249,6 +367,12 @@ help:
|
||||
@echo " dev-scan-all - Scan all mDNS services on network"
|
||||
@echo " dev-scan-soundtouch - Scan specifically for SoundTouch mDNS services"
|
||||
@echo " dev-scan-http - Scan for HTTP mDNS services"
|
||||
@echo " dev-backup - Build and show backup tool help"
|
||||
@echo " dev-backup-cloud - Build and run cloud backup (prompts for credentials)"
|
||||
@echo " dev-backup-local - Build and run local backup (auto-discover speakers)"
|
||||
@echo " dev-web - Build and run web UI (default port 8080)"
|
||||
@echo " dev-web-port - Build and run web UI on custom port (PORT=8888)"
|
||||
@echo " dev-web-host - Build and run web UI with specific device (HOST=ip)"
|
||||
@echo " install - Install binaries to GOPATH/bin"
|
||||
@echo " clean - Clean build artifacts"
|
||||
@echo " release - Create release binaries"
|
||||
@@ -270,5 +394,8 @@ help:
|
||||
@echo " make dev-upnp-timeout TIMEOUT=10s"
|
||||
@echo " make dev-scan-all"
|
||||
@echo " make dev-scan-soundtouch"
|
||||
@echo " make dev-web"
|
||||
@echo " make dev-web-port PORT=8888"
|
||||
@echo " make dev-web-host HOST=192.168.1.10"
|
||||
@echo " make test"
|
||||
@echo " make build-all"
|
||||
|
||||
@@ -1,548 +1,127 @@
|
||||
# Bose SoundTouch Toolkit
|
||||
|
||||
A comprehensive solution for controlling and preserving Bose SoundTouch devices, including a Go library, CLI tool, and a local service for cloud emulation.
|
||||
|
||||
[](https://pkg.go.dev/github.com/gesellix/bose-soundtouch)
|
||||
[](https://goreportcard.com/report/github.com/gesellix/bose-soundtouch)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
> **Note**: This is an independent project based on the [official Bose SoundTouch Web API documentation](https://assets.bosecreative.com/m/496577402d128874/original/SoundTouch-Web-API.pdf). Not affiliated with or endorsed by Bose Corporation.
|
||||
> Independent project. Not affiliated with or endorsed by Bose Corporation.
|
||||
|
||||
## Features
|
||||
## Context: Cloud Shutdown
|
||||
|
||||
- ✅ **Complete API Coverage**: All available SoundTouch Web API endpoints implemented
|
||||
- 🎵 **Media Control**: Play, pause, stop, volume, bass, balance, source selection
|
||||
- 🔔 **Smart Notifications**: TTS messages, URL audio content, notification beeps (ST-10)
|
||||
- 🏠 **Multiroom Support**: Create and manage zones across multiple speakers
|
||||
- ⚡ **Real-time Events**: WebSocket connection for live device state monitoring
|
||||
- 🔍 **Device Discovery**: Automatic discovery via UPnP/SSDP and mDNS
|
||||
- 📻 **Content Navigation**: Browse and search TuneIn, Pandora, Spotify, local music
|
||||
- 📻 **RadioBrowser**: Access thousands of internet radio stations via [radio-browser.info](docs/reference/radio-browser.md)
|
||||
- 🎙️ **Station Management**: Add and play radio stations without presets
|
||||
- 🖥️ **CLI Tool**: Comprehensive command-line interface
|
||||
- 🌐 **SoundTouch Service**: Emulate Bose cloud services for offline device operation
|
||||
- 🔧 **Service Migration**: Migrate devices to use local services instead of Bose cloud (XML, Hosts, or DNS redirection)
|
||||
- 🔍 **DNS Discovery & Interception**: Dynamic DNS server for intercepting and logging Bose service queries (requires port 53)
|
||||
- 📊 **DNS Discovery Analysis**: Track and deduplicate all device DNS queries to discover hidden hostnames
|
||||
- 📊 **Traffic Analysis**: Proxy and log device communications
|
||||
- 📝 **HTTP Recording**: Persist interactions as re-playable `.http` files
|
||||
- 🔄 **Endpoint Mirroring**: Asynchronously mirror local requests to Bose cloud for parity testing
|
||||
- ⚖️ **Parity Logging**: Detect and record discrepancies between local and official Bose responses
|
||||
- 🧹 **Session Management**: Manage and cleanup recorded interaction sessions
|
||||
- 🔒 **Production Ready**: Extensive testing with real SoundTouch hardware
|
||||
- 🌐 **Cross-Platform**: Windows, macOS, Linux support
|
||||
Bose is shutting down SoundTouch cloud services on **May 6, 2026**. After that, music service browsing, preset sync, and the official SoundTouch app stop working. This toolkit lets you keep your speakers fully functional.
|
||||
|
||||
## Quick Start
|
||||
See the [Survival Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SURVIVAL-GUIDE.html) for the full picture.
|
||||
|
||||
### Installation
|
||||
---
|
||||
|
||||
## Tools
|
||||
|
||||
### soundtouch-service — AfterTouch
|
||||
|
||||
A local server that replaces the Bose cloud ("AfterTouch"). Once your speaker is redirected to it, you have full control without any Bose cloud dependency. The built-in web UI at `http://localhost:8000` handles all setup — no config files needed to get started.
|
||||
|
||||
**Two scenarios:**
|
||||
|
||||
**Before shutdown — migrate your existing setup**
|
||||
While the Bose cloud is still running, use `soundtouch-backup` to save your account data. The local service web UI then helps with the migration so your speaker keeps its presets and credentials.
|
||||
|
||||
**After shutdown or factory reset — start fresh**
|
||||
Create a local account, configure your speakers, and start using them immediately. No Bose infrastructure required.
|
||||
|
||||
**Redirecting your speaker**
|
||||
|
||||
The service needs a stable address on your local network (e.g. `soundtouch.fritz.box` or `soundtouch.local`). The speaker must then be redirected to resolve the Bose cloud hostnames to that address. Two supported methods:
|
||||
|
||||
| Method | How it works | Notes |
|
||||
|--------------|-------------------------------------|--------------------------------------------------------------|
|
||||
| XML redirect | Upload a config XML via the Web API | Surgical; covers only registered endpoints; best for testing |
|
||||
| DNS/DHCP | Serve custom DNS on your network | Covers all devices at once; requires port 53 and TLS |
|
||||
|
||||
The web UI walks you through each method. DNS redirect requires HTTPS — the service manages its own CA certificate and the web UI guides you through trusting it on each speaker.
|
||||
|
||||
> **Note:** A hosts-file method (direct SSH edits to `/etc/hosts`) also exists in the codebase but is deprecated and not exposed in the web UI.
|
||||
|
||||
**Enabling SSH via USB stick**
|
||||
|
||||
Some setup steps require SSH access to the speaker. Enable it once per device: create a file named `remote_services` on a FAT-formatted USB drive (the drive may need its bootable flag set — see [SoundCork issue #172](https://github.com/deborahgu/soundcork/issues/172)), and insert it while the speaker is powered on. After reboot, root SSH is available with no password.
|
||||
|
||||
See [Device Initial Setup](https://gesellix.github.io/Bose-SoundTouch/guides/DEVICE-INITIAL-SETUP.html) and [Migration Guide](https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-GUIDE.html) for step-by-step instructions.
|
||||
|
||||
---
|
||||
|
||||
### soundtouch-backup
|
||||
|
||||
Backs up your Bose cloud account (presets, paired devices, music sources) and each speaker's local state before the shutdown. Run `soundtouch-backup all` to capture everything in one step; it authenticates with the Bose cloud, then polls each paired speaker over the local network.
|
||||
|
||||
See the [soundtouch-backup README](cmd/soundtouch-backup/README.md) for usage.
|
||||
|
||||
---
|
||||
|
||||
### soundtouch-cli
|
||||
|
||||
Command-line control of any SoundTouch device: play/pause/volume, presets, source selection, multiroom zones, device discovery, and more. Works entirely over the local network — no cloud dependency. Well-suited for scripting and home automation.
|
||||
|
||||
See the [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html) for full usage.
|
||||
|
||||
---
|
||||
|
||||
### soundtouch-web
|
||||
|
||||
A standalone web UI for device control — play, pause, volume, preset selection, real-time status — served from a local Go binary. Complements `soundtouch-service` when you want a dedicated device-control interface separate from the setup/admin UI.
|
||||
|
||||
See the [soundtouch-web README](cmd/soundtouch-web/README.md) for usage.
|
||||
|
||||
---
|
||||
|
||||
### Go library
|
||||
|
||||
`pkg/client` provides a Go API for all SoundTouch device endpoints: media control, volume, presets, sources, zones, real-time WebSocket events, and device discovery. Use it to build your own integrations.
|
||||
|
||||
#### Install CLI and Service Tools
|
||||
```bash
|
||||
go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-cli@latest
|
||||
go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-service@latest
|
||||
```
|
||||
|
||||
#### Add Library to Your Project
|
||||
```bash
|
||||
go get github.com/gesellix/bose-soundtouch
|
||||
```
|
||||
|
||||
### CLI Usage
|
||||
See the [API Reference](https://gesellix.github.io/Bose-SoundTouch/reference/API-ENDPOINTS.html) and [pkg.go.dev](https://pkg.go.dev/github.com/gesellix/bose-soundtouch) for documentation.
|
||||
|
||||
Find SoundTouch devices on your network:
|
||||
```bash
|
||||
soundtouch-cli discover devices
|
||||
```
|
||||
|
||||
Control a device (replace `192.168.1.100` with your speaker's IP):
|
||||
```bash
|
||||
# Basic information
|
||||
soundtouch-cli --host 192.168.1.100 info
|
||||
|
||||
# Media controls
|
||||
soundtouch-cli --host 192.168.1.100 play start
|
||||
soundtouch-cli --host 192.168.1.100 volume set --level 50
|
||||
|
||||
# Preset management
|
||||
soundtouch-cli --host 192.168.1.100 preset list
|
||||
```
|
||||
|
||||
For full CLI documentation, see the [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html).
|
||||
|
||||
### SoundTouch Service (Cloud Shutdown Protection)
|
||||
|
||||
The `soundtouch-service` is a local server that emulates Bose's cloud services. This is critical for keeping your speakers functional after the **Bose Cloud Shutdown in May 2026**.
|
||||
|
||||
#### Key Features:
|
||||
- **🏠 Local Emulation**: BMX and Marge service implementation
|
||||
- **🔌 Easy Setup**: Activate SSH via USB stick (`remote_services` file)
|
||||
- **🔧 Device Migration**: Seamlessly transition devices to local control
|
||||
- **🌐 Web Management UI**: Easy browser-based setup and management
|
||||
- **💾 Persistent Data**: Store presets, recents, and sources locally
|
||||
- **🔄 Endpoint Mirroring**: Asynchronously mirror local requests to Bose cloud for parity testing
|
||||
- **⚖️ Parity Logging**: Detect and record discrepancies between local and official Bose responses
|
||||
- **📝 HTTP Recording**: Persist all interactions as re-playable `.http` files
|
||||
- **🧹 Session Management**: Manage and cleanup recorded interaction sessions
|
||||
|
||||
#### Quick Start:
|
||||
```bash
|
||||
# Start the service
|
||||
soundtouch-service
|
||||
```
|
||||
Open `http://localhost:8000` in your browser to manage your devices. Documentation is also available directly through the web interface.
|
||||
|
||||
For a comprehensive guide on transitioning your system, see the [Bose Cloud Shutdown: Survival Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SURVIVAL-GUIDE.html).
|
||||
|
||||
Detailed service configuration and Docker instructions can be found in [SoundTouch Service Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SOUNDTOUCH-SERVICE.html).
|
||||
|
||||
For professional migration tips and safety measures, see the [Migration & Safety Guide](https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-SAFETY.html).
|
||||
|
||||
### Library Usage
|
||||
|
||||
#### Basic Control
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Connect to your SoundTouch device
|
||||
c := client.NewClient(&client.Config{
|
||||
Host: "192.168.1.100",
|
||||
Port: 8090,
|
||||
})
|
||||
|
||||
// Get device information
|
||||
info, err := c.GetDeviceInfo()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("Device: %s\n", info.Name)
|
||||
|
||||
// Control playback
|
||||
err = c.Play()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Set volume
|
||||
err = c.SetVolume(50)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Device Discovery
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Discover SoundTouch devices
|
||||
service := discovery.NewService(5 * time.Second)
|
||||
devices, err := service.DiscoverDevices(context.Background())
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
for _, device := range devices {
|
||||
fmt.Printf("Found: %s at %s:%d\n",
|
||||
device.Name, device.Host, device.Port)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Real-time Events
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func main() {
|
||||
c := client.NewClient(&client.Config{
|
||||
Host: "192.168.1.100",
|
||||
Port: 8090,
|
||||
})
|
||||
|
||||
// Subscribe to device events
|
||||
events, err := c.SubscribeToEvents(context.Background())
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
for event := range events {
|
||||
switch e := event.(type) {
|
||||
case *models.NowPlayingUpdated:
|
||||
fmt.Printf("Now playing: %s by %s\n", e.Track, e.Artist)
|
||||
case *models.VolumeUpdated:
|
||||
fmt.Printf("Volume changed to: %d\n", e.ActualVolume)
|
||||
case *models.ConnectionStateUpdated:
|
||||
fmt.Printf("Connection state: %s\n", e.State)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Preset Management
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func main() {
|
||||
c := client.NewClient(&client.Config{
|
||||
Host: "192.168.1.100",
|
||||
Port: 8090,
|
||||
})
|
||||
|
||||
// Get current presets
|
||||
presets, err := c.GetPresets()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d presets\n", len(presets.Preset))
|
||||
|
||||
// Store currently playing content as preset 1
|
||||
err = c.StoreCurrentAsPreset(1)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Store Spotify playlist as preset 2
|
||||
spotifyContent := &models.ContentItem{
|
||||
Source: "SPOTIFY",
|
||||
Type: "uri",
|
||||
Location: "spotify:playlist:37i9dQZF1DXcBWIGoYBM5M",
|
||||
SourceAccount: "your_username",
|
||||
IsPresetable: true,
|
||||
ItemName: "Today's Top Hits",
|
||||
}
|
||||
err = c.StorePreset(2, spotifyContent)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Store radio station as preset 3
|
||||
radioContent := &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
Type: "stationurl",
|
||||
Location: "/v1/playbook/station/s33828",
|
||||
IsPresetable: true,
|
||||
ItemName: "K-LOVE Radio",
|
||||
}
|
||||
err = c.StorePreset(3, radioContent)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Select preset 1
|
||||
err = c.SelectPreset(1)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Println("Preset management complete!")
|
||||
}
|
||||
```
|
||||
|
||||
#### Multiroom Zones
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func main() {
|
||||
master := client.NewClient(&client.Config{
|
||||
Host: "192.168.1.100", // Master speaker
|
||||
Port: 8090,
|
||||
})
|
||||
|
||||
// Create a multiroom zone
|
||||
zone := &models.Zone{
|
||||
Master: "192.168.1.100",
|
||||
Members: []models.ZoneMember{
|
||||
{IPAddress: "192.168.1.101"}, // Living room
|
||||
{IPAddress: "192.168.1.102"}, // Kitchen
|
||||
},
|
||||
}
|
||||
|
||||
err := master.SetZone(zone)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Println("Multiroom zone created!")
|
||||
}
|
||||
```
|
||||
|
||||
#### Speaker Notifications (ST-10 only)
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
)
|
||||
|
||||
func main() {
|
||||
c := client.NewClient(&client.Config{
|
||||
Host: "192.168.1.100",
|
||||
Port: 8090,
|
||||
})
|
||||
|
||||
// Play Text-to-Speech message (language code "EN", "DE", etc.)
|
||||
err := c.PlayTTS("Welcome home!", "your-app-key", "EN", 70)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Play audio content from URL
|
||||
err = c.PlayURL(
|
||||
"https://example.com/doorbell.mp3",
|
||||
"your-app-key",
|
||||
"Doorbell",
|
||||
"Front Door",
|
||||
"Visitor Alert",
|
||||
80,
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Play notification beep
|
||||
err = c.PlayNotificationBeep()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Println("Notifications sent!")
|
||||
}
|
||||
```
|
||||
|
||||
## Supported Devices
|
||||
|
||||
This library supports all Bose SoundTouch-compatible devices, including:
|
||||
|
||||
- SoundTouch 10, 20, 30 series
|
||||
- SoundTouch Portable
|
||||
- Wave SoundTouch music system
|
||||
- SoundTouch-enabled Bose speakers
|
||||
|
||||
**Tested Hardware**:
|
||||
- ✅ SoundTouch 10
|
||||
- ✅ SoundTouch 20
|
||||
|
||||
## API Coverage
|
||||
|
||||
| Feature | Status | Description |
|
||||
|---------|--------|-------------|
|
||||
| Device Info | ✅ Complete | Device details, name, capabilities |
|
||||
| Media Control | ✅ Complete | Play/pause/stop, track navigation |
|
||||
| Volume & Audio | ✅ Complete | Volume, bass, balance control |
|
||||
| Source Selection | ✅ Complete | Spotify, Bluetooth, AUX, etc. |
|
||||
| Content Navigation | ✅ Complete | Browse music libraries, radio stations |
|
||||
| Station Management | ✅ Complete | Search, add, remove stations |
|
||||
| Preset Management | ✅ Complete | Store, select, remove presets |
|
||||
| Real-time Events | ✅ Complete | WebSocket event streaming |
|
||||
| Multiroom Zones | ✅ Complete | Zone creation and management |
|
||||
| Speaker Notifications | ✅ Complete | TTS, URL audio, beep alerts (ST-10) |
|
||||
| System Settings | ✅ Complete | Clock, display, network info |
|
||||
| Advanced Audio | ✅ Complete | DSP controls, tone controls |
|
||||
|
||||
**API Limitations**: None - all documented SoundTouch Web API functionality is implemented, including endpoints discovered via the comprehensive [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API).
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
- 📖 [Contributing Guide](CONTRIBUTING.md) - How to contribute to the project
|
||||
- 📚 [API Reference](https://gesellix.github.io/Bose-SoundTouch/reference/API-ENDPOINTS.html) - Complete endpoint documentation
|
||||
- 🔧 [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html) - Command-line tool guide
|
||||
- 🌐 [SoundTouch Service Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SOUNDTOUCH-SERVICE.html) - Local service setup and migration
|
||||
- 🎯 [Getting Started](https://gesellix.github.io/Bose-SoundTouch/guides/GETTING-STARTED.html) - Detailed setup and usage
|
||||
- 📻 [Preset Quick Start](https://gesellix.github.io/Bose-SoundTouch/PRESET-QUICKSTART.md) - Favorite content management
|
||||
- 🧭 [Navigation Guide](https://gesellix.github.io/Bose-SoundTouch/NAVIGATION-GUIDE.md) - Content browsing and station management
|
||||
- 📋 [Navigation API Reference](https://gesellix.github.io/Bose-SoundTouch/API-NAVIGATION-REFERENCE.md) - Navigation API documentation
|
||||
- ⚙️ [Advanced Features](https://gesellix.github.io/Bose-SoundTouch/reference/SYSTEM-ENDPOINTS.html) - Advanced functionality
|
||||
- 🏠 [Multiroom Setup](https://gesellix.github.io/Bose-SoundTouch/reference/ZONE-MANAGEMENT.html) - Zone configuration guide
|
||||
- ⚡ [WebSocket Events](https://gesellix.github.io/Bose-SoundTouch/reference/WEBSOCKET-EVENTS.html) - Real-time event handling
|
||||
- 🔔 [Speaker Notifications](https://gesellix.github.io/Bose-SoundTouch/reference/SPEAKER-ENDPOINT.html) - TTS and audio notifications guide
|
||||
- 🔍 [Device Discovery](https://gesellix.github.io/Bose-SoundTouch/reference/DISCOVERY.html) - Discovery configuration
|
||||
- 🛠️ [Troubleshooting](https://gesellix.github.io/Bose-SoundTouch/guides/TROUBLESHOOTING.html) - Common issues and solutions
|
||||
- [Getting Started](https://gesellix.github.io/Bose-SoundTouch/guides/GETTING-STARTED.html)
|
||||
- [Survival Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SURVIVAL-GUIDE.html)
|
||||
- [Migration Guide](https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-GUIDE.html)
|
||||
- [Device Initial Setup](https://gesellix.github.io/Bose-SoundTouch/guides/DEVICE-INITIAL-SETUP.html)
|
||||
- [Migration & Safety Guide](https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-SAFETY.html)
|
||||
- [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html)
|
||||
- [SoundTouch Service Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SOUNDTOUCH-SERVICE.html)
|
||||
- [HTTPS & CA Setup](https://gesellix.github.io/Bose-SoundTouch/guides/HTTPS-SETUP.html)
|
||||
- [API Reference](https://gesellix.github.io/Bose-SoundTouch/reference/API-ENDPOINTS.html)
|
||||
|
||||
## Development
|
||||
---
|
||||
|
||||
### Prerequisites
|
||||
- Go 1.25.6 or later
|
||||
- Optional: SoundTouch device for testing
|
||||
## Related projects
|
||||
|
||||
### Building from Source
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/gesellix/bose-soundtouch.git
|
||||
cd Bose-SoundTouch
|
||||
- **[SoundCork](https://github.com/deborahgu/soundcork)** (Deborah Kaplan et al.) — Python service interception; pioneered the cloud emulation approach this project builds on
|
||||
- **[SoundCork Stockholm App](https://github.com/krahl/soundcork-stockholm-app)** — Companion app for SoundCork
|
||||
- **[SoundTouch Plus](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus)** (Todd Lucas) — Home Assistant integration; extensive undocumented API documentation
|
||||
- **[ÜberBöse API](https://github.com/julius-d/ueberboese-api)** (Julius) — API research and advanced endpoint discovery
|
||||
- **[Bose SoundTouch Hook](https://github.com/CodeFinder2/bose-soundtouch-hook)** (Adrian Böckenkamp) — `LD_PRELOAD` hooking for reverse engineering device internals
|
||||
|
||||
# Install dependencies
|
||||
go mod download
|
||||
|
||||
# Build CLI tool
|
||||
make build
|
||||
|
||||
# Run tests
|
||||
make test
|
||||
|
||||
# Install CLI locally
|
||||
go install ./cmd/soundtouch-cli
|
||||
```
|
||||
|
||||
### Contributing
|
||||
|
||||
We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details on:
|
||||
|
||||
- Setting up your development environment
|
||||
- Coding guidelines and best practices
|
||||
- Testing with real devices
|
||||
- Submitting pull requests
|
||||
|
||||
## Examples
|
||||
|
||||
Check out the [examples/](examples/) directory for more usage patterns:
|
||||
|
||||
- **Basic HTTP Client**: Simple device control
|
||||
- **Preset Management**: Store and manage favorite content
|
||||
- **Navigation & Stations**: Browse content and manage radio stations
|
||||
- **WebSocket Events**: Real-time monitoring
|
||||
- **Device Discovery**: Finding devices on your network
|
||||
- **Multiroom Management**: Zone operations
|
||||
- **Advanced Audio**: DSP and tone controls
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This is an independent project based on the official Bose SoundTouch Web API documentation provided by Bose Corporation. It is not affiliated with, endorsed by, or supported by Bose Corporation. Use at your own risk.
|
||||
|
||||
SoundTouch is a trademark of Bose Corporation.
|
||||
|
||||
## SoundTouch End of Life Notice
|
||||
|
||||
**Important:** Bose has announced that [SoundTouch cloud support will end on May 6, 2026](https://www.bose.com/soundtouch-end-of-life).
|
||||
|
||||
**What will continue to work:**
|
||||
- ✅ Local API control (this library's primary functionality)
|
||||
- ✅ Bluetooth, AirPlay, Spotify Connect, and AUX streaming
|
||||
- ✅ Remote control features (Play, Pause, Skip, Volume)
|
||||
- ✅ Multiroom grouping
|
||||
|
||||
**What will stop working:**
|
||||
- ❌ Cloud-based preset sync between devices and SoundTouch app
|
||||
- ❌ Browsing music services directly from the SoundTouch app
|
||||
- ❌ Cloud-based features and updates
|
||||
|
||||
**What continues to work:**
|
||||
- ✅ Local preset management via this API client (store, select, remove)
|
||||
- ✅ Direct content playback (stations, playlists, etc.)
|
||||
|
||||
This Go library will continue to work as it uses the local Web API for direct device control, which is unaffected by the cloud service discontinuation. The local preset management functionality implemented in this library (discovered through the [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)) provides an alternative to the cloud-based preset features that will be discontinued.
|
||||
|
||||
**Community Alternatives**: See the [Related Projects](#related-projects) section below for additional tools like SoundCork that provide cloud service alternatives and the SoundTouch Plus project that offers comprehensive Home Assistant integration.
|
||||
|
||||
## Related Projects & Credits
|
||||
|
||||
This project builds upon the excellent work of several community projects:
|
||||
|
||||
### SoundCork 🍾
|
||||
- **Project**: [SoundCork - SoundTouch API Intercept](https://github.com/deborahgu/soundcork)
|
||||
- **Authors**: Deborah Kaplan and contributors
|
||||
- **Our Implementation**: The `soundtouch-service` in this project is heavily inspired by SoundCork's Python implementation. SoundCork pioneered the approach of intercepting and emulating Bose's cloud services, providing the foundation for offline SoundTouch operation.
|
||||
- **Key Contributions**: Service emulation architecture, BMX/Marge endpoint discovery, device migration strategies
|
||||
- **License**: MIT License
|
||||
|
||||
### ÜberBöse API 🎵
|
||||
- **Project**: [ÜberBöse API](https://github.com/julius-d/ueberboese-api)
|
||||
- **Author**: Julius
|
||||
- **Our Implementation**: This project provided valuable insights into advanced SoundTouch API endpoints and helped make our implementation more complete, particularly for content navigation and advanced device features.
|
||||
- **Key Contributions**: Extended API endpoint documentation, advanced feature discovery
|
||||
- **License**: MIT License
|
||||
|
||||
### SoundTouch Plus 🏠
|
||||
- **Project**: [SoundTouch Plus Home Assistant Component](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus)
|
||||
- **Wiki**: [SoundTouch WebServices API Documentation](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)
|
||||
- **Author**: Todd Lucas
|
||||
- **Our Implementation**: The comprehensive API documentation in the SoundTouch Plus Wiki provided invaluable insights into undocumented endpoints beyond the official API, enabling our preset management and content navigation features.
|
||||
- **Key Contributions**: Extensive API endpoint documentation, real-world usage patterns
|
||||
- **License**: MIT License
|
||||
|
||||
### SoundTouch Hook 🪝
|
||||
- **Project**: [Bose SoundTouch Hook](https://github.com/CodeFinder2/bose-soundtouch-hook)
|
||||
- **Author**: Adrian Böckenkamp
|
||||
- **Our Implementation**: This project provides a powerful framework for intercepting and hooking into internal device processes using `LD_PRELOAD`. It was instrumental in verifying internal function calls and understanding how the device validates cloud domains.
|
||||
- **Key Contributions**: Reverse engineering framework, process hooking, cross-compilation toolchain
|
||||
- **License**: GPL-3.0 License
|
||||
|
||||
### Community Ecosystem
|
||||
|
||||
These projects together form a comprehensive ecosystem for SoundTouch device management:
|
||||
|
||||
- **This Project**: Go library + CLI + service for programmatic control and offline operation
|
||||
- **SoundCork**: Python-based service interception and cloud replacement
|
||||
- **SoundTouch Plus**: Home Assistant integration with extensive device support
|
||||
- **ÜberBöse**: API research and advanced endpoint discovery
|
||||
- **SoundTouch Hook**: Advanced reverse engineering and process instrumentation
|
||||
|
||||
We are grateful to these projects and their maintainers for paving the way and providing the foundation that made this comprehensive Go implementation possible. The SoundTouch community's collaborative approach to reverse engineering and documentation has been invaluable.
|
||||
|
||||
### Contributing Back
|
||||
|
||||
If you discover new endpoints, features, or improvements through this library, please consider contributing back to these projects as well. The stronger our community ecosystem becomes, the better we can support SoundTouch devices beyond Bose's official support timeline.
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
- 🐛 **Bug Reports**: [Create an issue](https://github.com/gesellix/bose-soundtouch/issues/new)
|
||||
- 💡 **Feature Requests**: [Start a discussion](https://github.com/gesellix/bose-soundtouch/discussions)
|
||||
- ❓ **Questions**: Check [existing discussions](https://github.com/gesellix/bose-soundtouch/discussions)
|
||||
- 📖 **Documentation**: [Online Documentation](https://gesellix.github.io/Bose-SoundTouch/)
|
||||
- 🔍 **New Discoveries**: [Undocumented Community Features](https://gesellix.github.io/Bose-SoundTouch/UNDOCUMENTED-COMMUNITY-FEATURES.md)
|
||||
- 🌐 **Upstream Analysis**: [Upstream URLs & Domains](https://gesellix.github.io/Bose-SoundTouch/analysis/UPSTREAM-URLS.html)
|
||||
- 🔧 **Redirection Guide**: [Device Redirect Methods](https://gesellix.github.io/Bose-SoundTouch/analysis/DEVICE-REDIRECT-METHODS.html)
|
||||
- 🐣 **Initial Setup**: [Device Initial Setup Variants](https://gesellix.github.io/Bose-SoundTouch/guides/DEVICE-INITIAL-SETUP.html)
|
||||
- 📜 **Logging & Debugging**: [Device Logging Guide](https://gesellix.github.io/Bose-SoundTouch/DEVICE-LOGGING.md)
|
||||
- 🔒 **HTTPS & CA Setup**: [HTTPS & Custom CA Guide](https://gesellix.github.io/Bose-SoundTouch/guides/HTTPS-SETUP.html)
|
||||
- Bug reports: [GitHub Issues](https://github.com/gesellix/bose-soundtouch/issues/new)
|
||||
- Questions & discussions: [GitHub Discussions](https://github.com/gesellix/bose-soundtouch/discussions)
|
||||
|
||||
---
|
||||
|
||||
**Star this project** ⭐ if you find it useful!
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
SoundTouch is a trademark of Bose Corporation.
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
// Package main provides a debug tool for analyzing device consolidation and migration scenarios.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Println("Usage: debug-consolidation <data-directory>")
|
||||
fmt.Println("Example: debug-consolidation /var/lib/soundtouch-service")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
dataDir := os.Args[1]
|
||||
|
||||
fmt.Printf("🔍 Analyzing device consolidation in: %s\n", dataDir)
|
||||
|
||||
// Initialize datastore
|
||||
ds := datastore.NewDataStore(dataDir)
|
||||
|
||||
// List all devices
|
||||
devices, err := ds.ListAllDevices()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to list devices: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("📱 Found %d device entries:\n", len(devices))
|
||||
|
||||
for i := range devices {
|
||||
device := &devices[i]
|
||||
fmt.Printf(" %d. %s (Account: %s)\n", i+1, device.DeviceID, device.AccountID)
|
||||
fmt.Printf(" Name: %s\n", device.Name)
|
||||
fmt.Printf(" IP: %s, MAC: %s, Serial: %s\n",
|
||||
device.IPAddress, device.MacAddress, device.DeviceSerialNumber)
|
||||
|
||||
// Check directory contents
|
||||
deviceDir := ds.AccountDeviceDir(device.AccountID, device.DeviceID)
|
||||
analyzeDeviceDirectory(deviceDir, device.DeviceID)
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Group devices by potential physical device
|
||||
fmt.Println("🔄 Analyzing potential consolidation opportunities:")
|
||||
|
||||
deviceGroups := groupDevicesByIdentity(devices)
|
||||
|
||||
for i, group := range deviceGroups {
|
||||
if len(group) <= 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf(" Group %d - %d entries for same physical device:\n", i+1, len(group))
|
||||
|
||||
for i := range group {
|
||||
device := &group[i]
|
||||
deviceDir := ds.AccountDeviceDir(device.AccountID, device.DeviceID)
|
||||
fileCount := countFiles(deviceDir)
|
||||
fmt.Printf(" - %s (%d files)\n", device.DeviceID, fileCount)
|
||||
}
|
||||
|
||||
// Recommend consolidation target
|
||||
macDevice := findMACBasedDevice(group)
|
||||
if macDevice != nil {
|
||||
fmt.Printf(" → Recommend keeping: %s (MAC-based)\n", macDevice.DeviceID)
|
||||
} else {
|
||||
fmt.Printf(" → No clear MAC-based target found\n")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
func analyzeDeviceDirectory(dirPath, deviceID string) {
|
||||
entries, err := os.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
fmt.Printf(" Directory: %s (Error: %v)\n", dirPath, err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf(" Directory: %s (%d files)\n", dirPath, len(entries))
|
||||
|
||||
// Check for important files
|
||||
importantFiles := []string{"DeviceInfo.xml", "Presets.xml", "Recents.xml", "Sources.xml"}
|
||||
for _, fileName := range importantFiles {
|
||||
filePath := filepath.Join(dirPath, fileName)
|
||||
if stat, err := os.Stat(filePath); err == nil {
|
||||
status := "✓"
|
||||
if stat.Size() == 0 {
|
||||
status = "⚠️ (empty)"
|
||||
} else if stat.Size() < 100 {
|
||||
status = "⚠️ (very small)"
|
||||
}
|
||||
|
||||
fmt.Printf(" %s %s (%d bytes)\n", status, fileName, stat.Size())
|
||||
} else {
|
||||
fmt.Printf(" ❌ %s (missing)\n", fileName)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if deviceID looks like MAC address
|
||||
if isLikelyMACAddress(deviceID) {
|
||||
fmt.Printf(" 📍 Device ID appears to be MAC address format\n")
|
||||
} else {
|
||||
fmt.Printf(" 📍 Device ID appears to be %s format\n", guessIDType(deviceID))
|
||||
}
|
||||
}
|
||||
|
||||
func countFiles(dirPath string) int {
|
||||
entries, err := os.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
count := 0
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
func groupDevicesByIdentity(devices []models.ServiceDeviceInfo) [][]models.ServiceDeviceInfo {
|
||||
var groups [][]models.ServiceDeviceInfo
|
||||
|
||||
// Simple grouping by MAC address and serial number
|
||||
macGroups := make(map[string][]models.ServiceDeviceInfo)
|
||||
serialGroups := make(map[string][]models.ServiceDeviceInfo)
|
||||
ipGroups := make(map[string][]models.ServiceDeviceInfo)
|
||||
|
||||
for i := range devices {
|
||||
device := &devices[i]
|
||||
// Group by MAC address
|
||||
if device.MacAddress != "" {
|
||||
macGroups[device.MacAddress] = append(macGroups[device.MacAddress], *device)
|
||||
}
|
||||
|
||||
// Group by serial number
|
||||
if device.DeviceSerialNumber != "" {
|
||||
serialGroups[device.DeviceSerialNumber] = append(serialGroups[device.DeviceSerialNumber], *device)
|
||||
}
|
||||
|
||||
// Group by IP address
|
||||
if device.IPAddress != "" {
|
||||
ipGroups[device.IPAddress] = append(ipGroups[device.IPAddress], *device)
|
||||
}
|
||||
}
|
||||
|
||||
// Merge groups - prioritize MAC address grouping
|
||||
processed := make(map[string]bool)
|
||||
|
||||
for _, macDevices := range macGroups {
|
||||
if len(macDevices) > 1 {
|
||||
groups = append(groups, macDevices)
|
||||
for i := range macDevices {
|
||||
processed[macDevices[i].DeviceID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for serial number groups not already processed
|
||||
for _, serialDevices := range serialGroups {
|
||||
if len(serialDevices) > 1 {
|
||||
unprocessed := []models.ServiceDeviceInfo{}
|
||||
|
||||
for i := range serialDevices {
|
||||
if !processed[serialDevices[i].DeviceID] {
|
||||
unprocessed = append(unprocessed, serialDevices[i])
|
||||
}
|
||||
}
|
||||
|
||||
if len(unprocessed) > 1 {
|
||||
groups = append(groups, unprocessed)
|
||||
for i := range unprocessed {
|
||||
processed[unprocessed[i].DeviceID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
func findMACBasedDevice(devices []models.ServiceDeviceInfo) *models.ServiceDeviceInfo {
|
||||
for i := range devices {
|
||||
if isLikelyMACAddress(devices[i].DeviceID) {
|
||||
return &devices[i]
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isLikelyMACAddress(id string) bool {
|
||||
// MAC addresses are typically 12 hex characters without separators
|
||||
// or 17 characters with separators (XX:XX:XX:XX:XX:XX)
|
||||
if len(id) == 12 {
|
||||
for _, c := range id {
|
||||
if (c < '0' || c > '9') && (c < 'A' || c > 'F') && (c < 'a' || c > 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func guessIDType(id string) string {
|
||||
if len(id) > 15 && (id[0] == 'I' || id[0] == 'K') {
|
||||
return "serial number"
|
||||
}
|
||||
|
||||
// Check if it looks like an IP address
|
||||
if len(id) >= 7 && len(id) <= 15 {
|
||||
dotCount := 0
|
||||
|
||||
for _, c := range id {
|
||||
if c == '.' {
|
||||
dotCount++
|
||||
} else if c < '0' || c > '9' {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if dotCount == 3 {
|
||||
return "IP address"
|
||||
}
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Package main provides a utility to generate PNG and ICO favicons from SVG source files.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/srwiley/oksvg"
|
||||
"github.com/srwiley/rasterx"
|
||||
)
|
||||
|
||||
func main() {
|
||||
mediaDir := "pkg/service/handlers/web/img"
|
||||
files := []string{"favicon-braille", "favicon-morse"}
|
||||
|
||||
for _, name := range files {
|
||||
svgPath := filepath.Join(mediaDir, name+".svg")
|
||||
pngPath := filepath.Join(mediaDir, name+".png")
|
||||
icoPath := filepath.Join(mediaDir, name+".ico")
|
||||
|
||||
fmt.Printf("Processing %s...\n", name)
|
||||
|
||||
// 1. Render SVG to PNG
|
||||
img, err := renderSVG(svgPath, 32, 32)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to render %s: %v", svgPath, err)
|
||||
}
|
||||
|
||||
f, err := os.Create(pngPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create %s: %v", pngPath, err)
|
||||
}
|
||||
|
||||
if err := png.Encode(f, img); err != nil {
|
||||
f.Close()
|
||||
log.Fatalf("Failed to encode PNG %s: %v", pngPath, err)
|
||||
}
|
||||
|
||||
f.Close()
|
||||
fmt.Printf("Created %s\n", pngPath)
|
||||
|
||||
// 2. Create ICO (containing multiple sizes)
|
||||
sizes := []int{16, 32, 48}
|
||||
|
||||
var images []image.Image
|
||||
|
||||
for _, s := range sizes {
|
||||
m, err := renderSVG(svgPath, s, s)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to render %s at size %d: %v", svgPath, s, err)
|
||||
}
|
||||
|
||||
images = append(images, m)
|
||||
}
|
||||
|
||||
if err := writeICO(icoPath, images); err != nil {
|
||||
log.Fatalf("Failed to write ICO %s: %v", icoPath, err)
|
||||
}
|
||||
|
||||
fmt.Printf("Created %s\n", icoPath)
|
||||
}
|
||||
}
|
||||
|
||||
func renderSVG(path string, w, h int) (image.Image, error) {
|
||||
in, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
icon, err := oksvg.ReadIconStream(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
icon.SetTarget(0, 0, float64(w), float64(h))
|
||||
rgba := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
gv := rasterx.NewScannerGV(w, h, rgba, rgba.Bounds())
|
||||
dasher := rasterx.NewDasher(w, h, gv)
|
||||
icon.Draw(dasher, 1.0)
|
||||
|
||||
return rgba, nil
|
||||
}
|
||||
|
||||
// Simple ICO encoder that wraps PNGs
|
||||
func writeICO(path string, images []image.Image) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
bw := bufio.NewWriter(f)
|
||||
defer bw.Flush()
|
||||
|
||||
// ICONDIR header
|
||||
// Reserved (2), Type (2), Count (2)
|
||||
binary.Write(bw, binary.LittleEndian, uint16(0))
|
||||
binary.Write(bw, binary.LittleEndian, uint16(1)) // 1 = ICO
|
||||
binary.Write(bw, binary.LittleEndian, uint16(len(images)))
|
||||
|
||||
var pngData [][]byte
|
||||
|
||||
for _, img := range images {
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pngData = append(pngData, buf.Bytes())
|
||||
}
|
||||
|
||||
offset := uint32(6 + len(images)*16)
|
||||
for i, img := range images {
|
||||
b := img.Bounds()
|
||||
|
||||
width := uint8(b.Dx())
|
||||
if b.Dx() >= 256 {
|
||||
width = 0
|
||||
}
|
||||
|
||||
height := uint8(b.Dy())
|
||||
if b.Dy() >= 256 {
|
||||
height = 0
|
||||
}
|
||||
|
||||
// ICONDIRENTRY
|
||||
bw.WriteByte(width)
|
||||
bw.WriteByte(height)
|
||||
bw.WriteByte(0) // Color count
|
||||
bw.WriteByte(0) // Reserved
|
||||
binary.Write(bw, binary.LittleEndian, uint16(1)) // Planes (1)
|
||||
binary.Write(bw, binary.LittleEndian, uint16(32)) // Bits per pixel (32)
|
||||
binary.Write(bw, binary.LittleEndian, uint32(len(pngData[i])))
|
||||
binary.Write(bw, binary.LittleEndian, offset)
|
||||
|
||||
offset += uint32(len(pngData[i]))
|
||||
}
|
||||
|
||||
for _, data := range pngData {
|
||||
bw.Write(data)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Package main provides a mock Amazon LWA server for testing purposes.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/testutils/amazon"
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := flag.Int("port", 8080, "Port to listen on")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
log.Printf("Starting mock Amazon LWA server on port %d", *port)
|
||||
|
||||
if err := http.ListenAndServe(fmt.Sprintf(":%d", *port), amazon.NewAmazonHandler()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Package main provides a mock Spotify server for testing purposes.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/testutils/spotify"
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := flag.Int("port", 8080, "Port to listen on")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
log.Printf("Starting mock Spotify server on port %d", *port)
|
||||
|
||||
if err := http.ListenAndServe(fmt.Sprintf(":%d", *port), spotify.NewSpotifyHandler()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
# soundtouch-backup
|
||||
|
||||
A standalone tool for backing up Bose SoundTouch data — both your **cloud account** (presets, devices, sources) and the **local filesystem** of each speaker — before the Bose cloud services shut down on May 6, 2026.
|
||||
|
||||
## Overview
|
||||
|
||||
| Subcommand | What it backs up |
|
||||
|------------|----------------------------------------------------------------------------------------------------|
|
||||
| `all` | Cloud account **and** all paired speakers in one step — the recommended starting point |
|
||||
| `cloud` | Bose account profile, paired devices, cloud presets, music service sources |
|
||||
| `local` | Speaker HTTP API data (presets, sources, volume, …) and optionally device filesystem files via SSH |
|
||||
|
||||
Output is a single `.tar.gz` archive (or `.zip`) with a dated root directory.
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
make build-backup
|
||||
# binary: ./build/soundtouch-backup
|
||||
```
|
||||
|
||||
Or install alongside the other tools:
|
||||
|
||||
```bash
|
||||
make install
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Combined backup (recommended)
|
||||
|
||||
The `all` command is the simplest way to capture everything: it authenticates with the Bose cloud, backs up your account data, then reads the IP addresses from `devices.xml` and backs up each reachable speaker over HTTP.
|
||||
|
||||
```bash
|
||||
# Interactive — prompts for email and password
|
||||
soundtouch-backup all
|
||||
|
||||
# Non-interactive
|
||||
soundtouch-backup all --email you@example.com --password secret
|
||||
|
||||
# Include SSH filesystem backup for each speaker
|
||||
soundtouch-backup all --ssh
|
||||
|
||||
# Environment variables
|
||||
BOSE_EMAIL=you@example.com BOSE_PASSWORD=secret soundtouch-backup all --ssh
|
||||
```
|
||||
|
||||
**Flags**
|
||||
|
||||
| Flag | Short | Default | Description |
|
||||
|--------------|--------|---------------------------------------|--------------------------------------------------------|
|
||||
| `--email` | `-e` | — | Bose account email (`$BOSE_EMAIL`) |
|
||||
| `--password` | `--pw` | — | Bose account password (`$BOSE_PASSWORD`) |
|
||||
| `--ssh` | | on | Also capture filesystem files via SSH for each speaker |
|
||||
| `--output` | `-o` | `soundtouch-backup-YYYY-MM-DD.tar.gz` | Output archive path |
|
||||
| `--format` | | `tar.gz` | Archive format: `tar.gz` or `zip` |
|
||||
|
||||
Speakers that are offline or unreachable at the time of backup are skipped with a `✗` warning; the cloud data is still saved.
|
||||
|
||||
---
|
||||
|
||||
### Cloud backup
|
||||
|
||||
Backs up data from your Bose account at `streaming.bose.com`. Credentials are prompted interactively if not supplied as flags.
|
||||
|
||||
```bash
|
||||
# Interactive — prompts for email, masked password input
|
||||
soundtouch-backup cloud
|
||||
|
||||
# Non-interactive
|
||||
soundtouch-backup cloud --email you@example.com --password secret
|
||||
|
||||
# Environment variables (avoids secrets in shell history)
|
||||
BOSE_EMAIL=you@example.com BOSE_PASSWORD=secret soundtouch-backup cloud
|
||||
|
||||
# Zip output
|
||||
soundtouch-backup cloud --format zip --output my-bose-cloud.zip
|
||||
```
|
||||
|
||||
**Flags**
|
||||
|
||||
| Flag | Short | Default | Description |
|
||||
|--------------|--------|---------------------------------------|---------------------------------------------------|
|
||||
| `--email` | `-e` | — | Bose account email (`$BOSE_EMAIL`) |
|
||||
| `--password` | `--pw` | — | Bose account password (`$BOSE_PASSWORD`) |
|
||||
| `--output` | `-o` | `soundtouch-backup-YYYY-MM-DD.tar.gz` | Output archive path (`$SOUNDTOUCH_BACKUP_OUTPUT`) |
|
||||
| `--format` | | `tar.gz` | Archive format: `tar.gz` or `zip` |
|
||||
|
||||
**What gets fetched**
|
||||
|
||||
| File in archive | Source endpoint |
|
||||
|--------------------------|---------------------------------------------------------------------------------|
|
||||
| `cloud/emailaddress.xml` | `GET /streaming/account/{id}/emailaddress` |
|
||||
| `cloud/devices.xml` | `GET /streaming/account/{id}/devices` |
|
||||
| `cloud/sources.xml` | `GET /streaming/account/{id}/sources` |
|
||||
| `cloud/presets.xml` | `GET /streaming/account/{id}/presets/all` |
|
||||
| `cloud/full.xml` | `GET /streaming/account/{id}/full` (may overlap with the above; skipped if 4xx) |
|
||||
|
||||
---
|
||||
|
||||
### Local backup
|
||||
|
||||
Backs up each speaker over its HTTP API on port 8090. With `--ssh`, also captures key filesystem files via SSH.
|
||||
|
||||
```bash
|
||||
# Auto-discover all speakers on the local network
|
||||
soundtouch-backup local
|
||||
|
||||
# Specific speaker
|
||||
soundtouch-backup local --host 192.168.178.28
|
||||
|
||||
# Multiple speakers
|
||||
soundtouch-backup local --host 192.168.178.28 --host 192.168.178.35
|
||||
|
||||
# Include SSH filesystem backup
|
||||
soundtouch-backup local --ssh
|
||||
|
||||
# Longer discovery window on busy networks
|
||||
soundtouch-backup local --discover-timeout 10s
|
||||
```
|
||||
|
||||
**Flags**
|
||||
|
||||
| Flag | Short | Default | Description |
|
||||
|----------------------|-------|---------------------------------------|--------------------------------------------------|
|
||||
| `--host` | `-H` | — | Speaker host/IP, repeatable (`$SOUNDTOUCH_HOST`) |
|
||||
| `--port` | `-p` | `8090` | Speaker HTTP port (`$SOUNDTOUCH_PORT`) |
|
||||
| `--discover` | `-d` | auto | Force mDNS/UPnP discovery |
|
||||
| `--discover-timeout` | | `5s` | Discovery timeout |
|
||||
| `--ssh` | | on | Also capture filesystem files via SSH |
|
||||
| `--output` | `-o` | `soundtouch-backup-YYYY-MM-DD.tar.gz` | Output archive path |
|
||||
| `--format` | | `tar.gz` | Archive format: `tar.gz` or `zip` |
|
||||
|
||||
**What gets fetched via HTTP**
|
||||
|
||||
| File | Device endpoint |
|
||||
|---------------------|-----------------|
|
||||
| `info.xml` | `/info` |
|
||||
| `name.xml` | `/name` |
|
||||
| `presets.xml` | `/presets` |
|
||||
| `sources.xml` | `/sources` |
|
||||
| `now_playing.xml` | `/now_playing` |
|
||||
| `volume.xml` | `/volume` |
|
||||
| `bass.xml` | `/bass` |
|
||||
| `balance.xml` | `/balance` |
|
||||
| `capabilities.xml` | `/capabilities` |
|
||||
| `network_info.xml` | `/networkInfo` |
|
||||
| `clock_display.xml` | `/clockDisplay` |
|
||||
| `zone.xml` | `/getZone` |
|
||||
|
||||
Endpoints that return HTTP 4xx (not supported on the device model) are silently skipped.
|
||||
|
||||
**What gets fetched via SSH** (`--ssh`)
|
||||
|
||||
SSH connects as `root@<host>:22` with an empty password, which is the default for SoundTouch firmware.
|
||||
|
||||
Individual files:
|
||||
|
||||
| Remote path | Notes |
|
||||
|---------------------------|--------------------------------------------|
|
||||
| `/etc/hosts` | DNS redirect state |
|
||||
| `/etc/resolv.conf` | DNS resolver configuration |
|
||||
| `/etc/remote_services` | Service registration (post-migration only) |
|
||||
| `/mnt/nv/remote_services` | Alternative location for remote services |
|
||||
|
||||
Directories (all regular files recursively):
|
||||
|
||||
| Remote path | Contents |
|
||||
|----------------------------------|----------------------------------------------------------------------------|
|
||||
| `/opt/Bose/etc/` | Full Bose configuration directory, including `SoundTouchSdkPrivateCfg.xml` |
|
||||
| `/mnt/nv/BoseApp-Persistence/1/` | Persisted app state |
|
||||
|
||||
Missing files and directories are silently skipped with a `⚠` warning.
|
||||
|
||||
---
|
||||
|
||||
## Archive structure
|
||||
|
||||
Both subcommands write into a single dated archive:
|
||||
|
||||
```
|
||||
soundtouch-backup-2026-05-02/
|
||||
├── cloud/
|
||||
│ ├── emailaddress.xml
|
||||
│ ├── devices.xml
|
||||
│ ├── sources.xml
|
||||
│ └── presets.xml
|
||||
└── local/
|
||||
├── A_Sound_Machine/
|
||||
│ ├── info.xml
|
||||
│ ├── presets.xml
|
||||
│ ├── sources.xml
|
||||
│ ├── volume.xml
|
||||
│ ├── …
|
||||
│ └── ssh/
|
||||
│ ├── etc/
|
||||
│ │ ├── hosts
|
||||
│ │ └── resolv.conf
|
||||
│ ├── opt/Bose/etc/
|
||||
│ │ └── SoundTouchSdkPrivateCfg.xml
|
||||
│ └── mnt/nv/BoseApp-Persistence/1/
|
||||
└── Sound_Machinechen/
|
||||
└── …
|
||||
```
|
||||
|
||||
Running `cloud` and `local` separately produces two archives. To combine them, use the same `--output` path for both invocations — each adds its own subdirectory so they won't collide (`.tar.gz` does not support appending; use `--format zip` if you need a single archive from two runs, or just keep them separate).
|
||||
|
||||
## See also
|
||||
|
||||
- [Cloud Shutdown Survival Guide](../../docs/guides/SURVIVAL-GUIDE.md) — full migration context
|
||||
- [`soundtouch-cli`](../soundtouch-cli/) — live device control
|
||||
- [`soundtouch-service`](../soundtouch-service/) — local cloud replacement
|
||||
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func allCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "all",
|
||||
Usage: "Back up cloud account then all paired speakers in one go",
|
||||
Description: "Authenticates with the Bose cloud, backs up account data, then reads" +
|
||||
" the device IP addresses from the cloud device list and backs up each reachable" +
|
||||
" speaker over HTTP (and optionally SSH).",
|
||||
Flags: append(outputFlags,
|
||||
&cli.StringFlag{
|
||||
Name: "email",
|
||||
Aliases: []string{"e"},
|
||||
Usage: "Bose account email",
|
||||
EnvVars: []string{"BOSE_EMAIL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "password",
|
||||
Aliases: []string{"pw"},
|
||||
Usage: "Bose account password",
|
||||
EnvVars: []string{"BOSE_PASSWORD"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "ssh",
|
||||
Usage: "Also back up device filesystem files via SSH (root@host:22, no password required)",
|
||||
Value: true,
|
||||
},
|
||||
),
|
||||
Action: runAllBackup,
|
||||
}
|
||||
}
|
||||
|
||||
func runAllBackup(c *cli.Context) error {
|
||||
doSSH := c.Bool("ssh")
|
||||
output := resolveOutputPath(c.String("output"), c.String("format"))
|
||||
format := c.String("format")
|
||||
|
||||
// 1. Cloud backup
|
||||
client, err := setupCloudClient(c.String("email"), c.String("password"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
root := archiveRoot()
|
||||
files := collectCloudFiles(client, root)
|
||||
|
||||
if len(files) == 0 {
|
||||
return fmt.Errorf("no cloud data fetched")
|
||||
}
|
||||
|
||||
// 2. Resolve speakers from devices.xml, then back each one up
|
||||
devicesData := files[root+"/cloud/devices.xml"]
|
||||
if devicesData == nil {
|
||||
printWarn("devices.xml not available — skipping local backup")
|
||||
} else {
|
||||
targets := parseDevicesXML(devicesData)
|
||||
if len(targets) == 0 {
|
||||
printWarn("no device IP addresses found in devices.xml")
|
||||
} else {
|
||||
fmt.Printf("Found %d device(s) in cloud account, attempting local backup...\n", len(targets))
|
||||
}
|
||||
|
||||
hc := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
for k, v := range collectLocalFiles(hc, targets, root, doSSH) {
|
||||
files[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeArchive(output, format, files); err != nil {
|
||||
return fmt.Errorf("writing archive: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Archive written: %s (%d files)\n", output, len(files))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type xmlDevice struct {
|
||||
Name string `xml:"name"`
|
||||
IPAddress string `xml:"ipaddress"`
|
||||
}
|
||||
|
||||
type xmlDevices struct {
|
||||
XMLName xml.Name `xml:"devices"`
|
||||
Devices []xmlDevice `xml:"device"`
|
||||
}
|
||||
|
||||
// parseDevicesXML extracts speaker targets from a devices.xml cloud response.
|
||||
func parseDevicesXML(data []byte) []speakerTarget {
|
||||
var d xmlDevices
|
||||
|
||||
if err := xml.Unmarshal(data, &d); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var targets []speakerTarget
|
||||
|
||||
for _, dev := range d.Devices {
|
||||
if dev.IPAddress == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Pass name as a hint for error messages; backupSpeakerHTTP re-fetches
|
||||
// from /info to get the current name and include info.xml in the archive.
|
||||
targets = append(targets, speakerTarget{host: dev.IPAddress, port: 8090, name: dev.Name})
|
||||
}
|
||||
|
||||
return targets
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
streamingBase = "https://streaming.bose.com"
|
||||
streamingCT = "application/vnd.bose.streaming-v1.1+xml"
|
||||
stockholmVer = "27.0.13-4277+8963611.epdbuild.develop.hepdswbld04.2025-10-02T13:17:00"
|
||||
nativeFrameVer = "27.0.2 -3353+4ae7c78.epdbuild.HEAD.ssgbld02.2023-10-12T15:10Z"
|
||||
protocolVer = "67"
|
||||
appGUID = "b94dedd1-a61b-492b-b86b-2bc32c9261f4"
|
||||
appUserAgent = "Mozilla/5.0 (Linux; Android 13; Android SDK built for arm64 Build/TE1A.220922.034; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/101.0.4951.61 Mobile Safari/537.36 Manufacturer/unknown DeviceModel/Android-SDK-built-for-arm64 SOUNDTOUCH_MOBILE_APP/" + appGUID
|
||||
)
|
||||
|
||||
func cloudCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "cloud",
|
||||
Usage: "Back up your Bose SoundTouch cloud account (devices, presets, sources)",
|
||||
Flags: append(outputFlags,
|
||||
&cli.StringFlag{
|
||||
Name: "email",
|
||||
Aliases: []string{"e"},
|
||||
Usage: "Bose account email",
|
||||
EnvVars: []string{"BOSE_EMAIL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "password",
|
||||
Aliases: []string{"pw"},
|
||||
Usage: "Bose account password",
|
||||
EnvVars: []string{"BOSE_PASSWORD"},
|
||||
},
|
||||
),
|
||||
Action: runCloudBackup,
|
||||
}
|
||||
}
|
||||
|
||||
func runCloudBackup(c *cli.Context) error {
|
||||
output := resolveOutputPath(c.String("output"), c.String("format"))
|
||||
format := c.String("format")
|
||||
|
||||
client, err := setupCloudClient(c.String("email"), c.String("password"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
root := archiveRoot()
|
||||
files := collectCloudFiles(client, root)
|
||||
|
||||
if len(files) == 0 {
|
||||
return fmt.Errorf("no data fetched")
|
||||
}
|
||||
|
||||
if err := writeArchive(output, format, files); err != nil {
|
||||
return fmt.Errorf("writing archive: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Archive written: %s (%d files)\n", output, len(files))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setupCloudClient prompts for missing credentials, then authenticates with the Bose cloud.
|
||||
func setupCloudClient(email, password string) (*cloudClient, error) {
|
||||
if email == "" || password == "" {
|
||||
var err error
|
||||
|
||||
email, password, err = promptCredentials(email)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("credentials: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if email == "" || password == "" {
|
||||
return nil, fmt.Errorf("email and password are required")
|
||||
}
|
||||
|
||||
fmt.Printf("Authenticating as %s...\n", email)
|
||||
|
||||
client, err := loginToCloud(email, password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("authentication failed: %w", err)
|
||||
}
|
||||
|
||||
printOK(fmt.Sprintf("Authenticated (account ID: %s)", client.accountID))
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// collectCloudFiles fetches all cloud account data and returns a files map ready for
|
||||
// archiving. Keys are prefixed with root (e.g. "soundtouch-backup-2026-05-02/cloud/").
|
||||
func collectCloudFiles(client *cloudClient, root string) map[string][]byte {
|
||||
type cloudEndpoint struct {
|
||||
label string
|
||||
filename string
|
||||
fetch func(*cloudClient) ([]byte, error)
|
||||
}
|
||||
|
||||
endpoints := []cloudEndpoint{
|
||||
{"email address", "emailaddress.xml", fetchEmailAddress},
|
||||
{"devices", "devices.xml", fetchDevices},
|
||||
{"sources", "sources.xml", fetchSources},
|
||||
{"presets", "presets.xml", fetchPresets},
|
||||
{"full account", "full.xml", fetchFull},
|
||||
}
|
||||
|
||||
files := make(map[string][]byte)
|
||||
|
||||
for _, ep := range endpoints {
|
||||
data, err := ep.fetch(client)
|
||||
if err != nil {
|
||||
printFail(fmt.Sprintf("%s: %v", ep.label, err))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
files[root+"/cloud/"+ep.filename] = data
|
||||
printOK(fmt.Sprintf("%s (%d bytes)", ep.label, len(data)))
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
type cloudClient struct {
|
||||
http *http.Client
|
||||
accountID string
|
||||
token string
|
||||
}
|
||||
|
||||
type loginXML struct {
|
||||
XMLName xml.Name `xml:"login"`
|
||||
Username string `xml:"username"`
|
||||
Password string `xml:"password"`
|
||||
}
|
||||
|
||||
var accountIDRe = regexp.MustCompile(`<account\s+id="([^"]+)"`)
|
||||
|
||||
func loginToCloud(email, password string) (*cloudClient, error) {
|
||||
loginBody, err := xml.Marshal(loginXML{Username: email, Password: password})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body := []byte(`<?xml version="1.0" encoding="UTF-8"?>`)
|
||||
body = append(body, loginBody...)
|
||||
|
||||
req, err := http.NewRequest("POST", streamingBase+"/streaming/account/login", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
setStreamingHeaders(req, "")
|
||||
|
||||
hc := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
token := resp.Header.Get("credentials")
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("no credentials in response — check your email and password")
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m := accountIDRe.FindSubmatch(data)
|
||||
if len(m) < 2 {
|
||||
return nil, fmt.Errorf("could not extract account ID from login response")
|
||||
}
|
||||
|
||||
return &cloudClient{http: hc, accountID: string(m[1]), token: token}, nil
|
||||
}
|
||||
|
||||
func setStreamingHeaders(req *http.Request, token string) {
|
||||
req.Header.Set("content-type", streamingCT)
|
||||
req.Header.Set("accept", streamingCT)
|
||||
req.Header.Set("clienttype", "SOUNDTOUCH_MOBILE_APP")
|
||||
req.Header.Set("version_stockholmversion", stockholmVer)
|
||||
req.Header.Set("version_nativeframeversion", nativeFrameVer)
|
||||
req.Header.Set("version_protocolversion", protocolVer)
|
||||
req.Header.Set("user-agent", appUserAgent)
|
||||
req.Header.Set("guid", appGUID)
|
||||
req.Header.Set("x-requested-with", "com.bose.soundtouch")
|
||||
req.Header.Set("pragma", "no-cache")
|
||||
req.Header.Set("cache-control", "no-cache")
|
||||
|
||||
if token != "" {
|
||||
req.Header.Set("authorization", token)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *cloudClient) get(path string) ([]byte, error) {
|
||||
url := fmt.Sprintf("%s%s?_=%d", streamingBase, path, time.Now().UnixMilli())
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
setStreamingHeaders(req, c.token)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
|
||||
}
|
||||
|
||||
func fetchEmailAddress(c *cloudClient) ([]byte, error) {
|
||||
return c.get("/streaming/account/" + c.accountID + "/emailaddress")
|
||||
}
|
||||
|
||||
func fetchDevices(c *cloudClient) ([]byte, error) {
|
||||
return c.get("/streaming/account/" + c.accountID + "/devices")
|
||||
}
|
||||
|
||||
func fetchSources(c *cloudClient) ([]byte, error) {
|
||||
return c.get("/streaming/account/" + c.accountID + "/sources")
|
||||
}
|
||||
|
||||
func fetchPresets(c *cloudClient) ([]byte, error) {
|
||||
return c.get("/streaming/account/" + c.accountID + "/presets/all")
|
||||
}
|
||||
|
||||
func fetchFull(c *cloudClient) ([]byte, error) {
|
||||
return c.get("/streaming/account/" + c.accountID + "/full")
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/config"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/ssh"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
var localEndpoints = []struct {
|
||||
path string
|
||||
file string
|
||||
}{
|
||||
{"/info", "info.xml"},
|
||||
{"/name", "name.xml"},
|
||||
{"/presets", "presets.xml"},
|
||||
{"/sources", "sources.xml"},
|
||||
{"/now_playing", "now_playing.xml"},
|
||||
{"/volume", "volume.xml"},
|
||||
{"/bass", "bass.xml"},
|
||||
{"/balance", "balance.xml"},
|
||||
{"/capabilities", "capabilities.xml"},
|
||||
{"/networkInfo", "network_info.xml"},
|
||||
{"/clockDisplay", "clock_display.xml"},
|
||||
{"/getZone", "zone.xml"},
|
||||
}
|
||||
|
||||
// sshFiles lists individual device filesystem paths captured via SSH.
|
||||
// Paths that may not exist on all devices are silently skipped.
|
||||
var sshFiles = []string{
|
||||
"/etc/hosts",
|
||||
"/etc/resolv.conf",
|
||||
"/etc/remote_services",
|
||||
"/mnt/nv/remote_services",
|
||||
}
|
||||
|
||||
// sshDirs lists device directories whose contents are recursively captured via SSH.
|
||||
var sshDirs = []string{
|
||||
"/opt/Bose/etc",
|
||||
"/mnt/nv/BoseApp-Persistence/1",
|
||||
}
|
||||
|
||||
func localCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "local",
|
||||
Usage: "Back up one or more SoundTouch speakers on your local network",
|
||||
Flags: append(outputFlags,
|
||||
&cli.StringSliceFlag{
|
||||
Name: "host",
|
||||
Aliases: []string{"H"},
|
||||
Usage: "Speaker host/IP (repeatable for multiple speakers)",
|
||||
EnvVars: []string{"SOUNDTOUCH_HOST"},
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "port",
|
||||
Aliases: []string{"p"},
|
||||
Usage: "Speaker HTTP port",
|
||||
Value: 8090,
|
||||
EnvVars: []string{"SOUNDTOUCH_PORT"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "discover",
|
||||
Aliases: []string{"d"},
|
||||
Usage: "Auto-discover speakers on the local network",
|
||||
},
|
||||
&cli.DurationFlag{
|
||||
Name: "discover-timeout",
|
||||
Usage: "Discovery timeout",
|
||||
Value: 5 * time.Second,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "ssh",
|
||||
Usage: "Also back up device filesystem files via SSH (root@host:22, no password required)",
|
||||
Value: true,
|
||||
},
|
||||
),
|
||||
Action: runLocalBackup,
|
||||
}
|
||||
}
|
||||
|
||||
type speakerTarget struct {
|
||||
host string
|
||||
port int
|
||||
name string
|
||||
}
|
||||
|
||||
func runLocalBackup(c *cli.Context) error {
|
||||
hosts := c.StringSlice("host")
|
||||
port := c.Int("port")
|
||||
doDiscover := c.Bool("discover") || len(hosts) == 0
|
||||
discoverTimeout := c.Duration("discover-timeout")
|
||||
doSSH := c.Bool("ssh")
|
||||
output := resolveOutputPath(c.String("output"), c.String("format"))
|
||||
format := c.String("format")
|
||||
|
||||
var targets []speakerTarget
|
||||
|
||||
if doDiscover {
|
||||
fmt.Printf("Discovering speakers (timeout: %s)...\n", discoverTimeout)
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Context, discoverTimeout)
|
||||
defer cancel()
|
||||
|
||||
cfg, _ := config.LoadFromEnv()
|
||||
svc := discovery.NewUnifiedDiscoveryService(cfg)
|
||||
|
||||
found, discErr := svc.DiscoverDevices(ctx)
|
||||
if discErr != nil {
|
||||
printWarn(fmt.Sprintf("Discovery failed: %v", discErr))
|
||||
}
|
||||
|
||||
for _, d := range found {
|
||||
targets = append(targets, speakerTarget{host: d.Host, port: d.Port, name: d.Name})
|
||||
printOK(fmt.Sprintf("Found: %s (%s:%d)", d.Name, d.Host, d.Port))
|
||||
}
|
||||
}
|
||||
|
||||
for _, h := range hosts {
|
||||
targets = append(targets, speakerTarget{host: h, port: port})
|
||||
}
|
||||
|
||||
if len(targets) == 0 {
|
||||
return fmt.Errorf("no speakers found — use --host <ip> or --discover")
|
||||
}
|
||||
|
||||
hc := &http.Client{Timeout: 10 * time.Second}
|
||||
root := archiveRoot()
|
||||
files := collectLocalFiles(hc, targets, root, doSSH)
|
||||
|
||||
if len(files) == 0 {
|
||||
return fmt.Errorf("no data collected")
|
||||
}
|
||||
|
||||
if err := writeArchive(output, format, files); err != nil {
|
||||
return fmt.Errorf("writing archive: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Archive written: %s (%d files)\n", output, len(files))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// collectLocalFiles backs up all targets over HTTP (and optionally SSH) and returns
|
||||
// a files map ready for archiving. Keys are prefixed with root.
|
||||
func collectLocalFiles(hc *http.Client, targets []speakerTarget, root string, doSSH bool) map[string][]byte {
|
||||
files := make(map[string][]byte)
|
||||
|
||||
for _, t := range targets {
|
||||
name, entries, err := backupSpeakerHTTP(hc, t)
|
||||
if err != nil {
|
||||
printFail(fmt.Sprintf("%s:%d — %v", t.host, t.port, err))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
dir := root + "/local/" + sanitizeName(name) + "/"
|
||||
|
||||
for filename, data := range entries {
|
||||
files[dir+filename] = data
|
||||
}
|
||||
|
||||
printOK(fmt.Sprintf("%s: %d files via HTTP", name, len(entries)))
|
||||
|
||||
if doSSH {
|
||||
sshEntries := backupSpeakerSSH(t.host, name)
|
||||
|
||||
for filename, data := range sshEntries {
|
||||
files[dir+filename] = data
|
||||
}
|
||||
|
||||
if len(sshEntries) > 0 {
|
||||
printOK(fmt.Sprintf("%s: %d files via SSH", name, len(sshEntries)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
func backupSpeakerHTTP(hc *http.Client, t speakerTarget) (name string, files map[string][]byte, err error) {
|
||||
base := fmt.Sprintf("http://%s:%d", t.host, t.port)
|
||||
files = make(map[string][]byte)
|
||||
name = t.name
|
||||
infoFetched := false
|
||||
|
||||
if name == "" {
|
||||
data, ferr := fetchRaw(hc, base+"/info")
|
||||
if ferr != nil {
|
||||
return "", nil, fmt.Errorf("cannot reach %s: %w", base, ferr)
|
||||
}
|
||||
|
||||
files["info.xml"] = data
|
||||
infoFetched = true
|
||||
|
||||
if extracted := xmlFirst(data, "name"); extracted != "" {
|
||||
name = extracted
|
||||
} else {
|
||||
name = t.host
|
||||
}
|
||||
}
|
||||
|
||||
for _, ep := range localEndpoints {
|
||||
if ep.path == "/info" && infoFetched {
|
||||
continue
|
||||
}
|
||||
|
||||
data, ferr := fetchRaw(hc, base+ep.path)
|
||||
if ferr != nil {
|
||||
printWarn(fmt.Sprintf("%s: skipped %s (%v)", name, ep.file, ferr))
|
||||
continue
|
||||
}
|
||||
|
||||
files[ep.file] = data
|
||||
}
|
||||
|
||||
return name, files, nil
|
||||
}
|
||||
|
||||
// backupSpeakerSSH connects to the device via SSH and reads the key filesystem paths.
|
||||
// Files that don't exist on the device are silently skipped.
|
||||
// Returned map keys are relative paths within the device backup directory (e.g. "ssh/etc/hosts").
|
||||
func backupSpeakerSSH(host, deviceName string) map[string][]byte {
|
||||
client := ssh.NewClient(host)
|
||||
files := make(map[string][]byte)
|
||||
|
||||
for _, remotePath := range sshFiles {
|
||||
data, err := client.ReadFile(remotePath)
|
||||
if err != nil {
|
||||
// Most missing files are expected (e.g. /etc/remote_services only exists post-migration)
|
||||
printWarn(fmt.Sprintf("%s: SSH skipped %s (%v)", deviceName, remotePath, err))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
printWarn(fmt.Sprintf("%s: SSH empty file %s", deviceName, remotePath))
|
||||
}
|
||||
|
||||
files["ssh"+remotePath] = data
|
||||
}
|
||||
|
||||
for _, remoteDir := range sshDirs {
|
||||
dirFiles, err := client.ReadDir(remoteDir)
|
||||
if err != nil {
|
||||
printWarn(fmt.Sprintf("%s: SSH skipped dir %s (%v)", deviceName, remoteDir, err))
|
||||
continue
|
||||
}
|
||||
|
||||
for path, data := range dirFiles {
|
||||
files["ssh"+path] = data
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
func fetchRaw(hc *http.Client, url string) ([]byte, error) {
|
||||
resp, err := hc.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
|
||||
}
|
||||
|
||||
func xmlFirst(data []byte, field string) string {
|
||||
re := regexp.MustCompile(`<` + regexp.QuoteMeta(field) + `[^>]*>([^<]+)</` + regexp.QuoteMeta(field) + `>`)
|
||||
|
||||
m := re.FindSubmatch(data)
|
||||
if len(m) >= 2 {
|
||||
return strings.TrimSpace(string(m[1]))
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
const (
|
||||
FormatTarGz = "tar.gz"
|
||||
FormatZip = "zip"
|
||||
)
|
||||
|
||||
var outputFlags = []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "output",
|
||||
Aliases: []string{"o"},
|
||||
Usage: "Output archive file (default: soundtouch-backup-YYYY-MM-DD.tar.gz)",
|
||||
EnvVars: []string{"SOUNDTOUCH_BACKUP_OUTPUT"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "format",
|
||||
Usage: "Archive format: tar.gz or zip",
|
||||
Value: FormatTarGz,
|
||||
},
|
||||
}
|
||||
|
||||
func resolveOutputPath(output, format string) string {
|
||||
date := time.Now().Format("2006-01-02")
|
||||
|
||||
ext := ".tar.gz"
|
||||
if format == FormatZip {
|
||||
ext = ".zip"
|
||||
}
|
||||
|
||||
filename := "soundtouch-backup-" + date + ext
|
||||
|
||||
if output == "" {
|
||||
return filename
|
||||
}
|
||||
|
||||
if info, err := os.Stat(output); err == nil && info.IsDir() {
|
||||
return output + string(os.PathSeparator) + filename
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
func archiveRoot() string {
|
||||
return "soundtouch-backup-" + time.Now().Format("2006-01-02")
|
||||
}
|
||||
|
||||
func writeArchive(outputPath, format string, files map[string][]byte) error {
|
||||
if format == FormatZip {
|
||||
return writeZip(outputPath, files)
|
||||
}
|
||||
|
||||
return writeTarGz(outputPath, files)
|
||||
}
|
||||
|
||||
func writeTarGz(outputPath string, files map[string][]byte) error {
|
||||
f, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
gz := gzip.NewWriter(f)
|
||||
defer gz.Close()
|
||||
|
||||
tw := tar.NewWriter(gz)
|
||||
defer tw.Close()
|
||||
|
||||
now := time.Now()
|
||||
for name, data := range files {
|
||||
hdr := &tar.Header{
|
||||
Name: name,
|
||||
Mode: 0644,
|
||||
Size: int64(len(data)),
|
||||
ModTime: now,
|
||||
Typeflag: tar.TypeReg,
|
||||
}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
return fmt.Errorf("tar header %s: %w", name, err)
|
||||
}
|
||||
|
||||
if _, err := tw.Write(data); err != nil {
|
||||
return fmt.Errorf("tar write %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeZip(outputPath string, files map[string][]byte) error {
|
||||
f, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
zw := zip.NewWriter(f)
|
||||
defer zw.Close()
|
||||
|
||||
for name, data := range files {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("zip entry %s: %w", name, err)
|
||||
}
|
||||
|
||||
if _, err := w.Write(data); err != nil {
|
||||
return fmt.Errorf("zip write %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func promptCredentials(emailHint string) (email, password string, err error) {
|
||||
r := bufio.NewReader(os.Stdin)
|
||||
|
||||
if emailHint != "" {
|
||||
email = emailHint
|
||||
} else {
|
||||
fmt.Print("Bose account email: ")
|
||||
|
||||
email, err = r.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
email = strings.TrimSpace(email)
|
||||
}
|
||||
|
||||
fmt.Print("Password: ")
|
||||
|
||||
raw, termErr := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
|
||||
fmt.Println()
|
||||
|
||||
if termErr != nil {
|
||||
err = fmt.Errorf("reading password: %w (tip: use --password flag or BOSE_PASSWORD env var)", termErr)
|
||||
return
|
||||
}
|
||||
|
||||
password = string(raw)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func sanitizeName(name string) string {
|
||||
r := strings.NewReplacer(
|
||||
"/", "_", "\\", "_", ":", "_",
|
||||
"*", "_", "?", "_", "\"", "_",
|
||||
"<", "_", ">", "_", "|", "_",
|
||||
" ", "_",
|
||||
)
|
||||
|
||||
return r.Replace(name)
|
||||
}
|
||||
|
||||
func printOK(msg string) { fmt.Printf(" ✓ %s\n", msg) }
|
||||
func printFail(msg string) { fmt.Printf(" ✗ %s\n", msg) }
|
||||
func printWarn(msg string) { fmt.Printf(" ⚠ %s\n", msg) }
|
||||
@@ -0,0 +1,37 @@
|
||||
// Package main implements the soundtouch-backup tool for backing up Bose SoundTouch
|
||||
// cloud account data and local speaker filesystem files.
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
func init() {
|
||||
if info, ok := debug.ReadBuildInfo(); ok {
|
||||
if info.Main.Version != "" && info.Main.Version != "(devel)" {
|
||||
version = info.Main.Version
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
app := &cli.App{
|
||||
Name: "soundtouch-backup",
|
||||
Usage: "Back up Bose SoundTouch account and speaker data",
|
||||
Version: version,
|
||||
Commands: []*cli.Command{
|
||||
allCommand(),
|
||||
cloudCommand(),
|
||||
localCommand(),
|
||||
},
|
||||
}
|
||||
if err := app.Run(os.Args); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -652,6 +652,73 @@ func listMusicServiceAccounts(c *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// pairDevice triggers the Stockholm registration flow via WebSocket
|
||||
func pairDevice(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
accountID := c.String("id")
|
||||
token := c.String("token")
|
||||
|
||||
PrintDeviceHeader("Pairing device with Marge account", clientConfig.Host, clientConfig.Port)
|
||||
fmt.Printf(" Account ID: %s\n", accountID)
|
||||
|
||||
// We need a WebSocket client for this
|
||||
ws := client.NewWebSocketClient(nil)
|
||||
|
||||
err = ws.Connect()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to device WebSocket: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = ws.Disconnect() }()
|
||||
|
||||
err = ws.PairWithAccount(accountID, token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send pairing request: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Pairing request sent successfully")
|
||||
fmt.Println("💡 The device will now register itself with the cloud service.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// unpairDevice triggers the Stockholm unregistration flow via WebSocket
|
||||
func unpairDevice(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Unpairing device from Marge account", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
// We need a WebSocket client for this
|
||||
ws := client.NewWebSocketClient(nil)
|
||||
|
||||
err = ws.Connect()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to device WebSocket: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = ws.Disconnect() }()
|
||||
|
||||
err = ws.UnPairFromAccount()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send unpairing request: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Unpairing request sent successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getServiceDisplayName returns a user-friendly display name for a service
|
||||
func getServiceDisplayName(source string) string {
|
||||
switch source {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
@@ -251,6 +253,61 @@ func selectLocalInternetRadio(c *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// selectCustomRadio handles selecting custom radio stream via soundtouch-service
|
||||
func selectCustomRadio(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
streamURL := c.String("url")
|
||||
itemName := c.String("name")
|
||||
containerArt := c.String("artwork")
|
||||
serviceURL := c.String("service-url")
|
||||
|
||||
encodedURL := base64.URLEncoding.EncodeToString([]byte(streamURL))
|
||||
location := fmt.Sprintf("%s/custom/v1/playback/%s", serviceURL, encodedURL)
|
||||
|
||||
params := url.Values{}
|
||||
if itemName != "" {
|
||||
params.Add("name", itemName)
|
||||
}
|
||||
|
||||
if containerArt != "" {
|
||||
params.Add("imageUrl", containerArt)
|
||||
}
|
||||
|
||||
if len(params) > 0 {
|
||||
location += "?" + params.Encode()
|
||||
}
|
||||
|
||||
// Check LOCAL_INTERNET_RADIO availability
|
||||
checker := NewServiceAvailabilityChecker(client)
|
||||
if !checker.CheckSourceAvailable("LOCAL_INTERNET_RADIO", "select custom radio") {
|
||||
return fmt.Errorf("LOCAL_INTERNET_RADIO is not available")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Selecting custom radio stream", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
if itemName != "" {
|
||||
fmt.Printf(" Station: %s\n", itemName)
|
||||
}
|
||||
|
||||
fmt.Printf(" URL: %s\n", streamURL)
|
||||
fmt.Printf(" Proxy: %s\n", location)
|
||||
|
||||
err = client.SelectLocalInternetRadio(location, "", itemName, containerArt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to select custom radio: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Custom radio stream selected")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// selectLocalMusic handles selecting LOCAL_MUSIC source
|
||||
func selectLocalMusic(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
@@ -196,7 +196,7 @@ var httpClient = &http.Client{
|
||||
}
|
||||
|
||||
func fetchTuneInMetadata(url string) (*Metadata, error) {
|
||||
if !strings.Contains(url, "tunein.com/radio/") {
|
||||
if !strings.Contains(url, "tunein.com/radio/") && !strings.Contains(url, "127.0.0.1") && !strings.Contains(url, "localhost") {
|
||||
return nil, fmt.Errorf("url is not a TuneIn radio URL")
|
||||
}
|
||||
|
||||
@@ -256,7 +256,7 @@ func fetchTuneInMetadata(url string) (*Metadata, error) {
|
||||
}
|
||||
|
||||
func fetchSpotifyMetadata(url string) (*Metadata, error) {
|
||||
if !strings.Contains(url, "open.spotify.com/") {
|
||||
if !strings.Contains(url, "open.spotify.com/") && !strings.Contains(url, "127.0.0.1") && !strings.Contains(url, "localhost") {
|
||||
return nil, fmt.Errorf("url is not a Spotify URL")
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
func TestFetchTuneInMetadata(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
html := `
|
||||
<!doctype html>
|
||||
<html>
|
||||
@@ -30,23 +30,23 @@ func TestFetchTuneInMetadata(t *testing.T) {
|
||||
|
||||
defer func() { httpClient = oldClient }()
|
||||
|
||||
metadata, err := fetchTuneInMetadata("https://tunein.com/radio/WDR-2-Rheinland-1004-s213886/")
|
||||
metadata, err := fetchTuneInMetadata(ts.URL + "/radio/WDR-2-Rheinland-1004-s213886/")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchTuneInMetadata() error = %v", err)
|
||||
}
|
||||
|
||||
if metadata == nil {
|
||||
t.Fatal("fetchTuneInMetadata() returned nil metadata")
|
||||
}
|
||||
} else {
|
||||
expectedName := "WDR 2 Rheinland"
|
||||
if metadata.Name != expectedName {
|
||||
t.Errorf("metadata.Name = %v, want %v", metadata.Name, expectedName)
|
||||
}
|
||||
|
||||
expectedName := "WDR 2 Rheinland"
|
||||
if metadata.Name != expectedName {
|
||||
t.Errorf("metadata.Name = %v, want %v", metadata.Name, expectedName)
|
||||
}
|
||||
|
||||
expectedArtwork := "https://cdn-radiotime-logos.tunein.com/s213886g.png"
|
||||
if metadata.Artwork != expectedArtwork {
|
||||
t.Errorf("metadata.Artwork = %v, want %v", metadata.Artwork, expectedArtwork)
|
||||
expectedArtwork := "https://cdn-radiotime-logos.tunein.com/s213886g.png"
|
||||
if metadata.Artwork != expectedArtwork {
|
||||
t.Errorf("metadata.Artwork = %v, want %v", metadata.Artwork, expectedArtwork)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ func TestResolveLocationSpotify(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFetchSpotifyMetadata(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
html := `
|
||||
<!doctype html>
|
||||
<html>
|
||||
@@ -185,22 +185,22 @@ func TestFetchSpotifyMetadata(t *testing.T) {
|
||||
|
||||
defer func() { httpClient = oldClient }()
|
||||
|
||||
metadata, err := fetchSpotifyMetadata("https://open.spotify.com/album/7F50uh7oGitmAEScRKV6pD")
|
||||
metadata, err := fetchSpotifyMetadata(ts.URL + "/album/7F50uh7oGitmAEScRKV6pD")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchSpotifyMetadata() error = %v", err)
|
||||
}
|
||||
|
||||
if metadata == nil {
|
||||
t.Fatal("fetchSpotifyMetadata() returned nil metadata")
|
||||
}
|
||||
} else {
|
||||
expectedName := "Terminal Caribe - Album by Santi & Tuğçe"
|
||||
if metadata.Name != expectedName {
|
||||
t.Errorf("metadata.Name = %v, want %v", metadata.Name, expectedName)
|
||||
}
|
||||
|
||||
expectedName := "Terminal Caribe - Album by Santi & Tuğçe"
|
||||
if metadata.Name != expectedName {
|
||||
t.Errorf("metadata.Name = %v, want %v", metadata.Name, expectedName)
|
||||
}
|
||||
|
||||
expectedArtwork := "https://i.scdn.co/image/ab67616d0000b273f0e55478f4a15182405bcb47"
|
||||
if metadata.Artwork != expectedArtwork {
|
||||
t.Errorf("metadata.Artwork = %v, want %v", metadata.Artwork, expectedArtwork)
|
||||
expectedArtwork := "https://i.scdn.co/image/ab67616d0000b273f0e55478f4a15182405bcb47"
|
||||
if metadata.Artwork != expectedArtwork {
|
||||
t.Errorf("metadata.Artwork = %v, want %v", metadata.Artwork, expectedArtwork)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -924,6 +924,34 @@ func main() {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "custom-radio",
|
||||
Usage: "Select custom radio stream via soundtouch-service",
|
||||
Action: selectCustomRadio,
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "url",
|
||||
Aliases: []string{"u"},
|
||||
Usage: "Stream URL",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "name",
|
||||
Aliases: []string{"n"},
|
||||
Usage: "Station name",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "artwork",
|
||||
Usage: "Station artwork URL",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "service-url",
|
||||
Usage: "URL of the soundtouch-service (default: http://localhost:8080)",
|
||||
Value: "http://localhost:8080",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "local-music",
|
||||
Usage: "Select local music content (LOCAL_MUSIC)",
|
||||
@@ -2010,6 +2038,30 @@ func main() {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "pair",
|
||||
Usage: "Pair the device with a Marge cloud account (Stockholm registration)",
|
||||
Action: pairDevice,
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "id",
|
||||
Usage: "Marge account ID (e.g., 1234567)",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "token",
|
||||
Usage: "User authorization token",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "unpair",
|
||||
Usage: "Unpair the device from its Marge cloud account",
|
||||
Action: unpairDevice,
|
||||
Before: RequireHost,
|
||||
},
|
||||
},
|
||||
},
|
||||
// Token commands
|
||||
|
||||
+625
-179
File diff suppressed because it is too large
Load Diff
@@ -18,10 +18,9 @@ func TestApplyPersistedSettings(t *testing.T) {
|
||||
|
||||
t.Run("overrides true with false", func(t *testing.T) {
|
||||
config := &serviceConfig{
|
||||
redact: true,
|
||||
logBody: true,
|
||||
record: true,
|
||||
enableSoundcorkProxy: true,
|
||||
redact: true,
|
||||
logBody: true,
|
||||
record: true,
|
||||
}
|
||||
|
||||
// Simulate the bug by using the old bitwise OR logic in the test,
|
||||
@@ -29,10 +28,9 @@ func TestApplyPersistedSettings(t *testing.T) {
|
||||
// config.redact = config.redact || false -> stays true
|
||||
|
||||
settings := datastore.Settings{
|
||||
RedactLogs: false,
|
||||
LogBodies: false,
|
||||
RecordInteractions: false,
|
||||
EnableSoundcorkProxy: false,
|
||||
RedactLogs: false,
|
||||
LogBodies: false,
|
||||
RecordInteractions: false,
|
||||
}
|
||||
err := ds.SaveSettings(settings)
|
||||
if err != nil {
|
||||
@@ -50,9 +48,6 @@ func TestApplyPersistedSettings(t *testing.T) {
|
||||
if config.record != false {
|
||||
t.Errorf("Expected record to be false, got true")
|
||||
}
|
||||
if config.enableSoundcorkProxy != false {
|
||||
t.Errorf("Expected enableSoundcorkProxy to be false, got true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("retains false when settings are false", func(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func TestPrintRoutes(t *testing.T) {
|
||||
// Initialize a minimal server to get the router
|
||||
server := handlers.NewServer(nil, nil, "http://localhost:8000", true, true, true)
|
||||
r := setupRouter(server)
|
||||
|
||||
var routes []string
|
||||
walkFunc := func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
|
||||
route = strings.ReplaceAll(route, "/*/", "/")
|
||||
handlerName := runtime.FuncForPC(reflect.ValueOf(handler).Pointer()).Name()
|
||||
// Clean up the handler name (remove package path)
|
||||
// For example, "github.com/gesellix/bose-soundtouch/cmd/soundtouch-service.setupRouter.func1"
|
||||
// or "command-line-arguments.setupRouter.func1"
|
||||
// or "main.setupRouter.func1"
|
||||
parts := strings.Split(handlerName, "/")
|
||||
if len(parts) > 0 {
|
||||
handlerName = parts[len(parts)-1]
|
||||
}
|
||||
// Now we might have "soundtouch-service.setupRouter.func1"
|
||||
// or "command-line-arguments.setupRouter.func1"
|
||||
// or "main.setupRouter.func1"
|
||||
// Let's remove the first part if it's a known varying package name
|
||||
if idx := strings.Index(handlerName, "setupRouter"); idx != -1 {
|
||||
handlerName = handlerName[idx:]
|
||||
}
|
||||
// In case it's not setupRouter but still has a package prefix
|
||||
for {
|
||||
dotIdx := strings.Index(handlerName, ".")
|
||||
if dotIdx == -1 {
|
||||
break
|
||||
}
|
||||
prefix := handlerName[:dotIdx]
|
||||
if prefix == "main" || prefix == "command-line-arguments" || strings.Contains(prefix, "soundtouch-service") {
|
||||
handlerName = handlerName[dotIdx+1:]
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Also remove any ".funcN" suffix if it's an anonymous function
|
||||
if idx := strings.Index(handlerName, ".func"); idx != -1 {
|
||||
handlerName = handlerName[:idx]
|
||||
}
|
||||
|
||||
routes = append(routes, fmt.Sprintf("%-8s %-60s %s", method, route, handlerName))
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := chi.Walk(r, walkFunc); err != nil {
|
||||
t.Fatalf("Failed to walk routes: %v", err)
|
||||
}
|
||||
|
||||
sort.Strings(routes)
|
||||
|
||||
output := strings.Join(routes, "\n") + "\n"
|
||||
|
||||
// Define snapshot path
|
||||
snapshotPath := "testdata/router_routes.txt"
|
||||
actualPath := "testdata/router_routes.actual.txt"
|
||||
|
||||
// Always write the current (actual) routes to a file
|
||||
if err := os.WriteFile(actualPath, []byte(output), 0644); err != nil {
|
||||
t.Fatalf("Failed to write actual routes: %v", err)
|
||||
}
|
||||
|
||||
// Check if snapshot exists
|
||||
if _, err := os.Stat(snapshotPath); os.IsNotExist(err) {
|
||||
// Create testdata directory if it doesn't exist
|
||||
if err := os.MkdirAll("testdata", 0755); err != nil {
|
||||
t.Fatalf("Failed to create testdata directory: %v", err)
|
||||
}
|
||||
// Initial snapshot creation
|
||||
if err := os.WriteFile(snapshotPath, []byte(output), 0644); err != nil {
|
||||
t.Fatalf("Failed to write snapshot: %v", err)
|
||||
}
|
||||
t.Logf("Initial snapshot created at %s", snapshotPath)
|
||||
return
|
||||
}
|
||||
|
||||
// Read existing snapshot
|
||||
existingOutput, err := os.ReadFile(snapshotPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read snapshot: %v", err)
|
||||
}
|
||||
|
||||
if string(existingOutput) != output {
|
||||
t.Errorf("Router routes changed! Diff the snapshot at %s with %s", snapshotPath, actualPath)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
*.actual.txt
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
CONNECT /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
DELETE /accounts/{account}/devices/{device} handlers.(*Server).HandleMargeRemoveDevice-fm
|
||||
DELETE /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
|
||||
DELETE /bmx/tunein/v1/favorite/{stationID} handlers.(*Server).HandleTuneInDeleteFavorite-fm
|
||||
DELETE /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
DELETE /setup/devices/{deviceId} handlers.(*Server).HandleRemoveDevice-fm
|
||||
DELETE /setup/dns-discoveries handlers.(*Server).HandleClearDNSDiscoveries-fm
|
||||
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}/preset/{presetNumber} handlers.(*Server).HandleMargeRemovePreset-fm
|
||||
DELETE /streaming/account/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
|
||||
GET / handlers.(*Server).HandleRoot-fm
|
||||
GET /accounts/{account}/devices handlers.(*Server).HandleMargeAccountDevices-fm
|
||||
GET /accounts/{account}/devices/{device}/group handlers.(*Server).HandleMargeDeviceGroup-fm
|
||||
GET /accounts/{account}/devices/{device}/group/ handlers.(*Server).HandleMargeDeviceGroup-fm
|
||||
GET /accounts/{account}/devices/{device}/group/member handlers.(*Server).HandleMargeDeviceGroupMember-fm
|
||||
GET /accounts/{account}/devices/{device}/group/server handlers.(*Server).HandleMargeDeviceGroupServer-fm
|
||||
GET /accounts/{account}/devices/{device}/presets handlers.(*Server).HandleMargePresets-fm
|
||||
GET /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleMargeRecents-fm
|
||||
GET /accounts/{account}/full handlers.(*Server).HandleMargeAccountFull-fm
|
||||
GET /accounts/{account}/sources handlers.(*Server).HandleMargeAccountSources-fm
|
||||
GET /bmx-icons/* handlers.(*Server).HandleBmxIcons
|
||||
GET /bmx/registry/v1/services handlers.(*Server).HandleBMXRegistry-fm
|
||||
GET /bmx/registry/v1/servicesAvailability handlers.(*Server).HandleBMXServicesAvailability-fm
|
||||
GET /bmx/tunein/v1/navigate handlers.(*Server).HandleTuneInNavigate-fm
|
||||
GET /bmx/tunein/v1/navigate/* handlers.(*Server).HandleTuneInNavigate-fm
|
||||
GET /bmx/tunein/v1/playback/episode/{podcastID} handlers.(*Server).HandleTuneInPlaybackPodcast-fm
|
||||
GET /bmx/tunein/v1/playback/episodes/{podcastID} handlers.(*Server).HandleTuneInPodcastInfo-fm
|
||||
GET /bmx/tunein/v1/playback/station/{stationID} handlers.(*Server).HandleTuneInPlayback-fm
|
||||
GET /bmx/tunein/v1/search handlers.(*Server).HandleTuneInSearch-fm
|
||||
GET /ced/* handlers.(*Server).HandleCedStatic
|
||||
GET /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 /mgmt/accounts/ handlers.(*Server).HandleMgmtListAccounts-fm
|
||||
GET /mgmt/accounts/{accountId} handlers.(*Server).HandleMgmtAccountDetails-fm
|
||||
GET /mgmt/accounts/{accountId}/speakers handlers.(*Server).HandleMgmtListSpeakers-fm
|
||||
GET /mgmt/amazon/accounts handlers.(*Server).HandleMgmtAmazonAccounts-fm
|
||||
GET /mgmt/amazon/callback handlers.(*Server).HandleMgmtAmazonCallback-fm
|
||||
GET /mgmt/amazon/token handlers.(*Server).HandleMgmtAmazonToken-fm
|
||||
GET /mgmt/devices/{deviceId}/events handlers.(*Server).HandleMgmtDeviceEvents-fm
|
||||
GET /mgmt/spotify/accounts handlers.(*Server).HandleMgmtSpotifyAccounts-fm
|
||||
GET /mgmt/spotify/callback handlers.(*Server).HandleMgmtSpotifyCallback-fm
|
||||
GET /mgmt/spotify/token handlers.(*Server).HandleMgmtSpotifyToken-fm
|
||||
GET /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
GET /proxy/* handlers.(*Server).HandleProxyRequest-fm
|
||||
GET /setup/ca.crt handlers.(*Server).HandleGetCACert-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/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/settings handlers.(*Server).HandleGetSettings-fm
|
||||
GET /setup/summary/{deviceId} handlers.(*Server).HandleGetMigrationSummary-fm
|
||||
GET /setup/version handlers.(*Server).HandleGetVersionInfo-fm
|
||||
GET /streaming/account/{account}/device/{device}/group handlers.(*Server).HandleMargeDeviceGroup-fm
|
||||
GET /streaming/account/{account}/device/{device}/group/ handlers.(*Server).HandleMargeDeviceGroup-fm
|
||||
GET /streaming/account/{account}/device/{device}/group/member handlers.(*Server).HandleMargeDeviceGroupMember-fm
|
||||
GET /streaming/account/{account}/device/{device}/group/server handlers.(*Server).HandleMargeDeviceGroupServer-fm
|
||||
GET /streaming/account/{account}/device/{device}/presets handlers.(*Server).HandleMargePresets-fm
|
||||
GET /streaming/account/{account}/device/{device}/recent handlers.(*Server).HandleMargeRecents-fm
|
||||
GET /streaming/account/{account}/device/{device}/recents handlers.(*Server).HandleMargeRecents-fm
|
||||
GET /streaming/account/{account}/devices handlers.(*Server).HandleMargeAccountDevices-fm
|
||||
GET /streaming/account/{account}/emailaddress handlers.(*Server).HandleMargeGetEmailAddress-fm
|
||||
GET /streaming/account/{account}/full handlers.(*Server).HandleMargeAccountFull-fm
|
||||
GET /streaming/account/{account}/presets handlers.(*Server).HandleMargeAccountPresets-fm
|
||||
GET /streaming/account/{account}/presets/all handlers.(*Server).HandleMargeAccountPresets-fm
|
||||
GET /streaming/account/{account}/provider_settings handlers.(*Server).HandleMargeProviderSettings-fm
|
||||
GET /streaming/account/{account}/sources handlers.(*Server).HandleMargeAccountSources-fm
|
||||
GET /streaming/device/{device}/streaming_token handlers.(*Server).HandleMargeStreamingToken-fm
|
||||
GET /streaming/device_setting/account/{account}/device/{device}/device_settings handlers.(*Server).HandleMargeGetDeviceSettings-fm
|
||||
GET /streaming/resources/api_versions.xml handlers.(*Server).HandleMargeAPIVersions-fm
|
||||
GET /streaming/software/update/account/{account} handlers.(*Server).HandleMargeSoftwareUpdate-fm
|
||||
GET /streaming/sourceproviders handlers.(*Server).HandleMargeSourceProviders-fm
|
||||
GET /updates/soundtouch handlers.(*Server).HandleMargeSoftwareUpdate-fm
|
||||
GET /v1/blacklist/{deviceId} setupRouter
|
||||
GET /web/* setupRouter.(*Server).HandleWeb
|
||||
HEAD /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
OPTIONS /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
PATCH /oauth/* handlers.(*Server).HandleBoseProxy-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/{groupId} handlers.(*Server).HandleMargeModifyGroup-fm
|
||||
POST /alexa/certificate handlers.(*Server).HandleAlexaCertificate-fm
|
||||
POST /bmx/core02/svc-bmx-adapter-orion/prod/orion/token handlers.(*Server).HandleOrionToken-fm
|
||||
POST /bmx/orion/v1/playback/station/{data} handlers.(*Server).HandleOrionPlayback-fm
|
||||
POST /bmx/tunein/v1/favorite/{stationID} handlers.(*Server).HandleTuneInFavorite-fm
|
||||
POST /bmx/tunein/v1/report handlers.(*Server).HandleTuneInReport-fm
|
||||
POST /bmx/tunein/v1/token handlers.(*Server).HandleTuneInToken-fm
|
||||
POST /customer/account/{account} handlers.(*Server).HandleMargeUpdateAccountProfile-fm
|
||||
POST /customer/account/{account}/password handlers.(*Server).HandleMargeChangePassword-fm
|
||||
POST /mgmt/accounts/{accountId}/language handlers.(*Server).HandleMgmtUpdateAccountLanguage-fm
|
||||
POST /mgmt/accounts/{accountId}/provider-settings handlers.(*Server).HandleMgmtUpdateAccountProviderSetting-fm
|
||||
POST /mgmt/amazon/confirm handlers.(*Server).HandleMgmtAmazonConfirm-fm
|
||||
POST /mgmt/amazon/init handlers.(*Server).HandleMgmtAmazonInit-fm
|
||||
POST /mgmt/amazon/prime handlers.(*Server).HandleMgmtPrimeDeviceAmazon-fm
|
||||
POST /mgmt/spotify/confirm handlers.(*Server).HandleMgmtSpotifyConfirm-fm
|
||||
POST /mgmt/spotify/entity handlers.(*Server).HandleMgmtSpotifyEntity-fm
|
||||
POST /mgmt/spotify/init handlers.(*Server).HandleMgmtSpotifyInit-fm
|
||||
POST /mgmt/spotify/prime handlers.(*Server).HandleMgmtPrimeDevice-fm
|
||||
POST /oauth/* 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
|
||||
POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token/cs3 handlers.(*Server).HandleBoseToken-fm
|
||||
POST /setup/backup/{deviceId} handlers.(*Server).HandleBackupConfig-fm
|
||||
POST /setup/devices handlers.(*Server).HandleAddManualDevice-fm
|
||||
POST /setup/discover handlers.(*Server).HandleTriggerDiscovery-fm
|
||||
POST /setup/ensure-remote-services/{deviceId} handlers.(*Server).HandleEnsureRemoteServices-fm
|
||||
POST /setup/migrate/{deviceId} handlers.(*Server).HandleMigrateDevice-fm
|
||||
POST /setup/proxy-settings handlers.(*Server).HandleUpdateProxySettings-fm
|
||||
POST /setup/reboot/{deviceId} handlers.(*Server).HandleRebootDevice-fm
|
||||
POST /setup/remove-remote-services/{deviceId} handlers.(*Server).HandleRemoveRemoteServices-fm
|
||||
POST /setup/revert/{deviceId} handlers.(*Server).HandleRevertMigration-fm
|
||||
POST /setup/settings handlers.(*Server).HandleUpdateSettings-fm
|
||||
POST /setup/sync/{deviceId} handlers.(*Server).HandleInitialSync-fm
|
||||
POST /setup/test-connection/{deviceId} handlers.(*Server).HandleTestConnection-fm
|
||||
POST /setup/test-dns/{deviceId} handlers.(*Server).HandleTestDNSRedirection-fm
|
||||
POST /setup/test-hosts/{deviceId} handlers.(*Server).HandleTestHostsRedirection-fm
|
||||
POST /setup/trust-ca/{deviceId} handlers.(*Server).HandleTrustCACert-fm
|
||||
POST /streaming/account handlers.(*Server).HandleMargeCreateAccount-fm
|
||||
POST /streaming/account/login handlers.(*Server).HandleMargeLogin-fm
|
||||
POST /streaming/account/{account}/device/ handlers.(*Server).HandleMargeAddDevice-fm
|
||||
POST /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeAddDevice-fm
|
||||
POST /streaming/account/{account}/device/{device}/presets/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
|
||||
POST /streaming/account/{account}/device/{device}/recent handlers.(*Server).HandleMargeAddRecent-fm
|
||||
POST /streaming/account/{account}/group handlers.(*Server).HandleMargeAddGroup-fm
|
||||
POST /streaming/account/{account}/group/{groupId} handlers.(*Server).HandleMargeModifyGroup-fm
|
||||
POST /streaming/account/{account}/source handlers.(*Server).HandleMargeAddSource-fm
|
||||
POST /streaming/device_setting/account/{account}/device/{device}/device_settings handlers.(*Server).HandleMargeUpdateDeviceSettings-fm
|
||||
POST /streaming/music/musicprovider/{providerID}/is_eligible handlers.(*Server).HandleMusicProviderIsEligible-fm
|
||||
POST /streaming/music/musicprovider/{providerID}/trial/is_eligible handlers.(*Server).HandleMusicProviderIsEligible-fm
|
||||
POST /streaming/stats/error handlers.(*Server).HandleErrorStats-fm
|
||||
POST /streaming/stats/usage handlers.(*Server).HandleUsageStats-fm
|
||||
POST /streaming/support/customersupport handlers.(*Server).HandleMargeCustomerSupport-fm
|
||||
POST /streaming/support/power_on handlers.(*Server).HandleMargePowerOn-fm
|
||||
POST /v1/scmudc/{deviceId} handlers.(*Server).HandleAppEvents-fm
|
||||
POST /v1/stapp/{deviceId} handlers.(*Server).HandleAppEvents-fm
|
||||
PUT /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
PUT /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
|
||||
TRACE /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
@@ -0,0 +1,2 @@
|
||||
soundtouch-web
|
||||
soundtouch-web-test
|
||||
@@ -0,0 +1,276 @@
|
||||
# SoundTouch Web Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
The `soundtouch-web` tool provides a modern single-page application (SPA) for controlling Bose SoundTouch devices. Built with a JSON API backend and client-side JavaScript rendering, it offers superior performance and eliminates template rendering issues.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Single-Page Application Design
|
||||
|
||||
The architecture eliminates Go template dependencies and provides:
|
||||
- **JSON API Backend**: Pure Go server returning only JSON responses
|
||||
- **Client-Side Rendering**: JavaScript handles all HTML generation
|
||||
- **WebSocket Real-time**: Bi-directional communication for live updates
|
||||
- **Better Performance**: No server-side template processing
|
||||
- **Easier Development**: Clear separation of frontend/backend concerns
|
||||
|
||||
### Core Components
|
||||
|
||||
#### 1. Main Application (`main.go`)
|
||||
- **Entry Point**: Handles command-line arguments and application initialization
|
||||
- **SPA Routing**: Serves static HTML file for all non-API routes
|
||||
- **Device Discovery**: Automatic discovery of SoundTouch devices using unified discovery service
|
||||
- **JSON API Server**: Configures API routes and serves the SPA
|
||||
- **Context Management**: Proper context handling for timeouts and cancellation
|
||||
|
||||
#### 2. HTTP Handlers (`handlers/handlers.go`)
|
||||
- **WebApp Structure**: Central application state management
|
||||
- **JSON API Endpoints**: RESTful API returning only JSON responses
|
||||
- **Device Control**: Device control with proper validation and error handling
|
||||
- **Modular Design**: Separated control actions into focused functions
|
||||
|
||||
#### 3. WebSocket Support (`handlers/websocket.go`)
|
||||
- **Real-time Updates**: Live device status streaming to web clients
|
||||
- **Device WebSocket Connections**: Maintains persistent connections to SoundTouch devices
|
||||
- **Event Handling**: Processes nowPlaying, volume, and connection state updates
|
||||
- **Status Synchronization**: Keeps device status current across all connected clients
|
||||
|
||||
#### 4. Type Definitions (`webtypes/types.go`)
|
||||
- **Device Management**: Structures for device connections and status
|
||||
- **API Responses**: Standardized JSON response format
|
||||
- **WebSocket Messages**: Real-time message types
|
||||
- **Template Data**: HTML template data structures
|
||||
|
||||
### Key Features Implemented
|
||||
|
||||
#### Device Discovery & Management
|
||||
- **Auto-discovery**: Finds SoundTouch devices on local network using mDNS/UPnP
|
||||
- **Multi-device Support**: Manages multiple devices simultaneously
|
||||
- **Connection Tracking**: Monitors device availability and connection status
|
||||
- **Device Information**: Displays device details (name, type, IP address)
|
||||
|
||||
#### Real-time Control Interface
|
||||
- **Now Playing**: Live track information with artwork display
|
||||
- **Playback Controls**: Play/pause/stop/next/previous with visual feedback
|
||||
- **Volume Control**: Real-time volume slider with mute functionality
|
||||
- **Bass Adjustment**: Bass level control for supported devices
|
||||
- **Preset Management**: Quick access to saved presets (1-6)
|
||||
- **Source Selection**: Input switching (Spotify, TuneIn, Bluetooth, AUX, etc.)
|
||||
|
||||
#### Web Interface
|
||||
- **Single-Page Application**: Self-contained HTML file with embedded CSS and JavaScript
|
||||
- **Responsive Design**: Bootstrap 5-based UI optimized for desktop and mobile
|
||||
- **Client-Side Routing**: JavaScript handles page navigation without page reloads
|
||||
- **Dynamic Rendering**: All HTML generated client-side from JSON data
|
||||
- **Real-time Updates**: WebSocket-powered live status updates
|
||||
- **Performance Optimized**: Fast loading and no template rendering delays
|
||||
|
||||
#### API Endpoints
|
||||
```
|
||||
GET / # SPA - serves static/index.html
|
||||
GET /api/devices # List all devices (JSON)
|
||||
GET /api/device/{id} # Get device info (JSON)
|
||||
POST /api/discover # Trigger device discovery
|
||||
GET /api/control/{id}/play # Playback control
|
||||
GET /api/control/{id}/pause # Pause playback
|
||||
GET /api/control/{id}/stop # Stop playback
|
||||
GET /api/control/{id}/next # Next track
|
||||
GET /api/control/{id}/previous # Previous track
|
||||
POST /api/control/{id}/volume # Set volume (JSON body)
|
||||
GET /api/control/{id}/mute # Toggle mute
|
||||
POST /api/control/{id}/bass # Set bass level (JSON body)
|
||||
GET /api/control/{id}/preset?id=N # Select preset
|
||||
GET /api/control/{id}/source?name=X # Select source
|
||||
```
|
||||
|
||||
#### WebSocket Events
|
||||
- **Connection**: `ws://localhost:8080/ws`
|
||||
- **Device Updates**: Real-time device list changes
|
||||
- **Status Updates**: Live playback and volume changes
|
||||
- **Connection Monitoring**: Device availability status
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Frontend Architecture
|
||||
- **Single HTML File**: Complete application in `static/index.html`
|
||||
- **Embedded CSS**: Bootstrap 5 with custom Bose-inspired styling
|
||||
- **Vanilla JavaScript**: No framework dependencies, fast performance
|
||||
- **Client-Side Routing**: JavaScript manages page state without reloads
|
||||
- **Dynamic Components**: HTML elements generated from JSON API responses
|
||||
|
||||
### Error Handling & Validation
|
||||
- **Input Validation**: Proper bounds checking for volume (0-100) and bass (-9 to 9)
|
||||
- **HTTP Status Codes**: Appropriate response codes for different error conditions
|
||||
- **JSON Error Responses**: Structured error messages for API consumers
|
||||
- **Client-Side Error Display**: JavaScript toast notifications for user feedback
|
||||
|
||||
### Code Quality
|
||||
- **golangci-lint Compliance**: Passes all configured lint checks
|
||||
- **Context Handling**: Proper context propagation and timeout management
|
||||
- **Error Checking**: All JSON encoding/decoding operations checked
|
||||
- **Type Safety**: Strong typing with dedicated type package
|
||||
- **Test Coverage**: Comprehensive unit tests for handlers and types
|
||||
|
||||
### WebSocket Integration
|
||||
- **Gabbo Protocol**: Native SoundTouch WebSocket protocol implementation
|
||||
- **Event Processing**: Handles all documented SoundTouch WebSocket events
|
||||
- **Connection Management**: Automatic reconnection and health monitoring
|
||||
- **Bi-directional Communication**: Both status monitoring and device control
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Core Libraries
|
||||
- **chi v5**: HTTP router (inherited from existing codebase)
|
||||
- **gorilla/websocket**: WebSocket implementation
|
||||
- **Go standard library**: html/template, net/http, encoding/json
|
||||
|
||||
### Project Dependencies
|
||||
- **pkg/client**: SoundTouch HTTP and WebSocket client library
|
||||
- **pkg/discovery**: Device discovery service (mDNS/UPnP)
|
||||
- **pkg/models**: XML/JSON data structures for SoundTouch API
|
||||
- **pkg/config**: Configuration management
|
||||
|
||||
### Frontend Dependencies
|
||||
- **Bootstrap 5**: CSS framework for responsive design
|
||||
- **Bootstrap Icons**: Icon library for UI elements
|
||||
- **Vanilla JavaScript**: No external JS frameworks, pure WebSocket implementation
|
||||
|
||||
## Build & Testing
|
||||
|
||||
### Build Commands
|
||||
```bash
|
||||
# Build the web application
|
||||
cd cmd/soundtouch-web
|
||||
go build -o soundtouch-web
|
||||
|
||||
# Build all project components (includes soundtouch-web)
|
||||
make build
|
||||
|
||||
# Cross-platform builds
|
||||
make build-all
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Run unit tests
|
||||
go test ./cmd/soundtouch-web/...
|
||||
|
||||
# Run with coverage
|
||||
go test -cover ./cmd/soundtouch-web/...
|
||||
|
||||
# Lint checking
|
||||
golangci-lint run cmd/soundtouch-web/...
|
||||
```
|
||||
|
||||
### Development Server
|
||||
```bash
|
||||
# Run development server
|
||||
cd cmd/soundtouch-web
|
||||
go run main.go -port 8080
|
||||
|
||||
# Access the web interface
|
||||
open http://localhost:8080
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Command Line Options
|
||||
```bash
|
||||
soundtouch-web [options]
|
||||
|
||||
Options:
|
||||
-port string Web server port (default "8080")
|
||||
-host string Specific device host for single-device mode (optional)
|
||||
```
|
||||
|
||||
### File Structure
|
||||
```
|
||||
cmd/soundtouch-web/
|
||||
├── main.go # Application entry point
|
||||
├── soundtouch-web # Built binary
|
||||
├── handlers/
|
||||
│ ├── handlers.go # HTTP request handlers
|
||||
│ ├── handlers_test.go # Handler tests
|
||||
│ └── websocket.go # WebSocket functionality
|
||||
├── webtypes/
|
||||
│ ├── types.go # Type definitions
|
||||
│ └── types_test.go # Type tests
|
||||
├── templates/
|
||||
│ ├── layout.html # Base HTML layout
|
||||
│ ├── index.html # Device list page
|
||||
│ └── device.html # Device control page
|
||||
├── static/
|
||||
│ └── style.css # Additional CSS styles
|
||||
└── README.md # User documentation
|
||||
```
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
### Supported Browsers
|
||||
- **Chrome 80+** (recommended)
|
||||
- **Firefox 75+**
|
||||
- **Safari 13+**
|
||||
- **Edge 80+**
|
||||
|
||||
### Required Features
|
||||
- WebSocket support
|
||||
- CSS Grid and Flexbox
|
||||
- ES6 JavaScript features
|
||||
- JSON API support
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Design Principles
|
||||
- **Local Network Only**: Designed for trusted local network environments
|
||||
- **No Authentication**: Assumes local network security
|
||||
- **CORS Policy**: Restricted to same-origin requests
|
||||
- **Input Validation**: All user inputs validated on server side
|
||||
|
||||
### Network Security
|
||||
- **Port Usage**: Uses standard HTTP port (configurable)
|
||||
- **WebSocket Security**: Same-origin WebSocket connections only
|
||||
- **No External Dependencies**: All resources served locally
|
||||
|
||||
## Performance Characteristics
|
||||
|
||||
### Resource Usage
|
||||
- **Memory**: Minimal footprint, scales with number of discovered devices
|
||||
- **CPU**: Low usage, event-driven architecture
|
||||
- **Network**: Efficient WebSocket connections, HTTP REST for control
|
||||
|
||||
### Scalability
|
||||
- **Device Limits**: Designed for typical home networks (5-20 devices)
|
||||
- **Concurrent Users**: Multiple browser sessions supported
|
||||
- **Update Frequency**: Real-time updates without polling
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Potential Features
|
||||
- **Zone Management**: Multi-room audio control
|
||||
- **Preset Programming**: Advanced preset configuration
|
||||
- **Mobile PWA**: Progressive Web App for mobile installation
|
||||
- **Theme Support**: Additional UI themes
|
||||
- **Device Grouping**: Logical device organization
|
||||
|
||||
### Technical Improvements
|
||||
- **Caching**: Enhanced device status caching
|
||||
- **Compression**: WebSocket message compression
|
||||
- **Persistence**: Device settings persistence
|
||||
- **Metrics**: Usage analytics and performance monitoring
|
||||
|
||||
## Integration with Main Project
|
||||
|
||||
### Project Alignment
|
||||
- **Consistent Architecture**: Follows established project patterns
|
||||
- **Shared Libraries**: Leverages existing pkg/ modules
|
||||
- **Build Integration**: Included in main Makefile targets
|
||||
- **Documentation**: Consistent with project documentation standards
|
||||
|
||||
### Migration Path
|
||||
- **Cloud Replacement**: Serves as local alternative to Bose cloud services
|
||||
- **API Compatibility**: Maintains compatibility with existing SoundTouch APIs
|
||||
- **User Experience**: Familiar interface for existing SoundTouch app users
|
||||
- **Long-term Support**: Designed for continued operation post-2026
|
||||
|
||||
This implementation provides a robust, feature-complete web interface for SoundTouch device control, ensuring continued functionality beyond the official app's lifecycle while maintaining high code quality and user experience standards.
|
||||
@@ -0,0 +1,330 @@
|
||||
# SoundTouch Web UI
|
||||
|
||||
A modern single-page web application (SPA) for controlling Bose SoundTouch devices. Built with a JSON API backend and client-side JavaScript rendering for superior performance and maintainability.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser → Static HTML → JavaScript → JSON API → Go Server
|
||||
↓
|
||||
Client-Side Rendering
|
||||
```
|
||||
|
||||
### Key Benefits
|
||||
- **Better Performance**: No server-side template processing overhead
|
||||
- **Improved Maintainability**: Clear separation between frontend (JavaScript) and backend (Go)
|
||||
- **Real-time Experience**: Smooth client-side updates without page reloads
|
||||
- **Mobile Ready**: The JSON API can power both this web interface and mobile applications
|
||||
|
||||
## Features
|
||||
|
||||
Based on captured WebSocket interactions and device API capabilities, this web UI provides:
|
||||
|
||||
### Device Management
|
||||
- **Auto-discovery** of SoundTouch devices on the network
|
||||
- **Real-time status monitoring** via WebSocket connections
|
||||
- **Multi-device support** with centralized control
|
||||
- **Connection status** indicators and health monitoring
|
||||
|
||||
### Playback Control
|
||||
- **Play/Pause/Stop/Next/Previous** controls
|
||||
- **Now playing information** with artwork, track details, and progress
|
||||
- **Real-time updates** of playback state changes
|
||||
- **Source selection** from available inputs (Spotify, TuneIn, Bluetooth, AUX, etc.)
|
||||
|
||||
### Audio Controls
|
||||
- **Volume control** with real-time slider updates
|
||||
- **Mute/Unmute** functionality
|
||||
- **Bass adjustment** (on supported models)
|
||||
- **Audio level monitoring** and statistics
|
||||
|
||||
### Preset Management
|
||||
- **6 preset buttons** with visual feedback
|
||||
- **Preset content display** showing station/playlist names
|
||||
- **One-click preset selection**
|
||||
|
||||
### Advanced Features
|
||||
- **WebSocket real-time updates** for instant state synchronization
|
||||
- **Responsive design** optimized for desktop and mobile
|
||||
- **Dark mode support** (auto-detects system preference)
|
||||
- **Accessibility features** (keyboard navigation, screen reader support)
|
||||
- **Network statistics** and device health monitoring
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Main Device Overview
|
||||
The main page shows all discovered devices with their current status, now-playing information, and quick controls.
|
||||
|
||||
### Detailed Device Control
|
||||
Individual device pages provide full control over:
|
||||
- Detailed now-playing information with artwork
|
||||
- Comprehensive audio controls (volume, bass)
|
||||
- Full preset and source selection
|
||||
- Real-time status updates
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
- Go 1.21 or later
|
||||
- Access to SoundTouch devices on the same network
|
||||
- Modern web browser with WebSocket support
|
||||
|
||||
### Building
|
||||
```bash
|
||||
# From project root
|
||||
make build
|
||||
|
||||
# Or manually
|
||||
cd cmd/soundtouch-web
|
||||
go build -o soundtouch-web
|
||||
```
|
||||
|
||||
### Running
|
||||
```bash
|
||||
# Run with default settings (port 8080)
|
||||
./soundtouch-web
|
||||
|
||||
# Specify custom port
|
||||
./soundtouch-web -port 8888
|
||||
|
||||
# Connect to specific device
|
||||
./soundtouch-web -host 192.168.1.100
|
||||
```
|
||||
|
||||
### Command Line Options
|
||||
```
|
||||
-port string Web server port (default "8080")
|
||||
-host string Specific SoundTouch device host (optional, enables single-device mode)
|
||||
-help Show help information
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Accessing the Interface
|
||||
1. Start the application
|
||||
2. Open your web browser and navigate to `http://localhost:8080`
|
||||
3. Click "Discover Devices" to find SoundTouch devices on your network
|
||||
4. Click on any device for detailed control, or use quick controls from the main page
|
||||
|
||||
### Device Discovery
|
||||
The application automatically discovers SoundTouch devices using:
|
||||
- **mDNS discovery** for local network devices
|
||||
- **UPnP/SSDP discovery** as fallback
|
||||
- **Manual device addition** via IP address
|
||||
|
||||
### Real-time Updates
|
||||
The interface maintains WebSocket connections to each device for instant updates of:
|
||||
- Now playing information and artwork
|
||||
- Volume and audio settings changes
|
||||
- Playback status (play/pause/stop)
|
||||
- Connection status and device health
|
||||
|
||||
### Responsive Design
|
||||
- **Desktop**: Full-featured interface with side-by-side panels
|
||||
- **Tablet**: Optimized layout with touch-friendly controls
|
||||
- **Mobile**: Stacked interface with gesture support
|
||||
|
||||
## API Endpoints
|
||||
|
||||
The web UI exposes a REST API for programmatic control:
|
||||
|
||||
### Device Management
|
||||
```
|
||||
GET /api/devices # List all discovered devices
|
||||
GET /api/device/{id} # Get specific device info
|
||||
POST /api/discover # Trigger device discovery
|
||||
```
|
||||
|
||||
### Device Control
|
||||
```
|
||||
GET /api/control/{id}/play # Start playback
|
||||
GET /api/control/{id}/pause # Pause playback
|
||||
GET /api/control/{id}/stop # Stop playback
|
||||
GET /api/control/{id}/next # Next track
|
||||
GET /api/control/{id}/previous # Previous track
|
||||
POST /api/control/{id}/volume # Set volume (body: {"level": 50})
|
||||
GET /api/control/{id}/mute # Mute audio
|
||||
GET /api/control/{id}/unmute # Unmute audio
|
||||
POST /api/control/{id}/bass # Set bass (body: {"level": 0})
|
||||
GET /api/control/{id}/preset?id=1 # Select preset
|
||||
GET /api/control/{id}/source?name=SPOTIFY # Select source
|
||||
```
|
||||
|
||||
### WebSocket Events
|
||||
Connect to `/ws` for real-time updates:
|
||||
```javascript
|
||||
const ws = new WebSocket('ws://localhost:8080/ws');
|
||||
ws.onmessage = function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
// Handle device updates, status changes, etc.
|
||||
};
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Single-Page Application Architecture
|
||||
- **JSON API Backend**: Go server providing RESTful endpoints
|
||||
- **Client-Side Rendering**: JavaScript handles all UI rendering
|
||||
- **WebSocket Real-time**: Bi-directional real-time communication
|
||||
- **No Template Dependencies**: Eliminates server-side template issues
|
||||
|
||||
### Backend Components
|
||||
- **Discovery Service**: Finds and manages SoundTouch devices
|
||||
- **WebSocket Manager**: Maintains real-time connections to devices
|
||||
- **JSON API Server**: RESTful interface returning only JSON
|
||||
- **Device Manager**: Tracks device state and health
|
||||
|
||||
### Frontend Components
|
||||
- **Bootstrap 5**: Modern responsive UI framework
|
||||
- **Vanilla JavaScript**: No framework dependencies, fast loading
|
||||
- **WebSocket Client**: Real-time bidirectional communication
|
||||
- **Dynamic Rendering**: Client-side HTML generation from JSON
|
||||
|
||||
### Communication Flow
|
||||
1. **SPA Loading**: Single HTML file with embedded CSS and JavaScript
|
||||
2. **JSON API**: Device discovery and control via REST endpoints
|
||||
3. **WebSocket (Device)**: Real-time status updates from SoundTouch devices
|
||||
4. **WebSocket (Browser)**: Real-time UI updates to web clients
|
||||
5. **Client Rendering**: JavaScript dynamically creates all UI elements
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
```
|
||||
cmd/soundtouch-web/
|
||||
├── main.go # Application entry point and SPA routing
|
||||
├── handlers/ # HTTP and WebSocket handlers
|
||||
│ ├── handlers.go # JSON API endpoints
|
||||
│ └── websocket.go # WebSocket management
|
||||
├── webtypes/ # Type definitions
|
||||
│ └── types.go # Request/response types
|
||||
├── static/ # Static assets
|
||||
│ ├── index.html # Single-page application
|
||||
│ └── js/ # Legacy JS files (reference)
|
||||
├── templates/ # Legacy templates (unused in SPA)
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
### Adding New Features
|
||||
1. **API Endpoints**: Add new JSON routes in `setupRoutes()` and `handlers.go`
|
||||
2. **WebSocket Events**: Extend event handlers in WebSocket client
|
||||
3. **UI Components**: Add JavaScript rendering functions in `static/index.html`
|
||||
4. **Device Controls**: Implement new control commands and update client-side handlers
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Unit tests
|
||||
go test ./...
|
||||
|
||||
# Manual testing with multiple devices
|
||||
./soundtouch-web -port 8080
|
||||
|
||||
# API testing
|
||||
curl http://localhost:8080/api/devices
|
||||
```
|
||||
|
||||
## WebSocket Protocol Analysis
|
||||
|
||||
This UI is based on extensive analysis of captured SoundTouch WebSocket interactions, including:
|
||||
|
||||
### Message Types Implemented
|
||||
- **SoundTouchSdkInfo**: Initial handshake and version info
|
||||
- **nowPlayingUpdated**: Real-time track information
|
||||
- **volumeUpdated**: Audio level changes
|
||||
- **recentsUpdated**: Recently played items
|
||||
- **userActivityUpdate**: User interaction notifications
|
||||
|
||||
### Request/Response Patterns
|
||||
- **Device Information**: System details and capabilities
|
||||
- **Audio Controls**: Volume, bass, mute controls
|
||||
- **Playback Control**: Play/pause/stop/skip commands
|
||||
- **Source Selection**: Input switching (Spotify, TuneIn, etc.)
|
||||
- **Preset Management**: Saved station/playlist access
|
||||
|
||||
### Gabbo Protocol Features
|
||||
- **Persistent Connections**: Maintains long-lived WebSocket connections
|
||||
- **Request Correlation**: Uses request IDs for response matching
|
||||
- **Real-time Events**: Instant updates for all device state changes
|
||||
- **Bi-directional Control**: Both status monitoring and device control
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
### Supported Browsers
|
||||
- **Chrome 80+** (recommended)
|
||||
- **Firefox 75+**
|
||||
- **Safari 13+**
|
||||
- **Edge 80+**
|
||||
|
||||
### Required Features
|
||||
- WebSocket support
|
||||
- CSS Grid and Flexbox
|
||||
- ES6 JavaScript features
|
||||
- Responsive CSS media queries
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Local Network Only**: Designed for local network device control
|
||||
- **No Authentication**: Assumes trusted local network environment
|
||||
- **CORS Policy**: Restricted to same-origin requests
|
||||
- **WebSocket Security**: Uses same-origin WebSocket connections
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Devices Not Found**
|
||||
- Ensure devices are on the same network
|
||||
- Check firewall settings (ports 8090, 8080)
|
||||
- Click "Discover Devices" button to trigger discovery
|
||||
|
||||
**WebSocket Connection Failed**
|
||||
- Verify device supports WebSocket connections
|
||||
- Check browser console for connection errors
|
||||
- Refresh the page to reconnect WebSocket
|
||||
|
||||
**Control Commands Not Working**
|
||||
- Check device is powered on and connected
|
||||
- Verify device is not in exclusive mode (e.g., Spotify Connect active)
|
||||
- Look for error notifications in the UI
|
||||
|
||||
**Page Shows Template Errors**
|
||||
- This has been fixed in the SPA implementation
|
||||
- Ensure you're accessing the correct URL (localhost:8080)
|
||||
- Clear browser cache if you see old template-based content
|
||||
|
||||
### Debug Mode
|
||||
Add verbose logging by setting environment variable:
|
||||
```bash
|
||||
export DEBUG=true
|
||||
./soundtouch-web
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
This web UI is part of the larger SoundTouch Go library project. See the main project README for contribution guidelines.
|
||||
|
||||
### Architecture Benefits
|
||||
The new SPA approach provides:
|
||||
- **Better Performance**: No server-side template rendering
|
||||
- **Easier Development**: Clear separation of frontend/backend
|
||||
- **Mobile Ready**: Same JSON API can power mobile apps
|
||||
- **Scalable**: Single-page app architecture
|
||||
|
||||
### Feature Requests
|
||||
Based on WebSocket interaction analysis, potential future features:
|
||||
- Zone/multi-room management
|
||||
- Clock display control
|
||||
- Software update management
|
||||
- Advanced preset programming
|
||||
- Progressive Web App (PWA) features
|
||||
|
||||
## License
|
||||
|
||||
Same as the parent project - see main repository LICENSE file.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- Built on the comprehensive SoundTouch Go library
|
||||
- UI design inspired by modern audio control interfaces
|
||||
- WebSocket protocol reverse-engineered from captured device interactions
|
||||
- Bootstrap and Bootstrap Icons for responsive design components
|
||||
@@ -0,0 +1,55 @@
|
||||
// Package main provides a web UI for controlling Bose SoundTouch devices.
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app := &cli.App{
|
||||
Name: "soundtouch-web",
|
||||
Usage: "Web UI for controlling Bose SoundTouch devices",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "port",
|
||||
Aliases: []string{"p"},
|
||||
Usage: "HTTP port to listen on",
|
||||
Value: "8080",
|
||||
EnvVars: []string{"PORT"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "bind",
|
||||
Usage: "Network interface to bind to",
|
||||
EnvVars: []string{"BIND_ADDR"},
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
port := c.String("port")
|
||||
bindAddr := c.String("bind")
|
||||
|
||||
addr := ":" + port
|
||||
if bindAddr != "" {
|
||||
addr = bindAddr + ":" + port
|
||||
}
|
||||
|
||||
webApp := soundtouchweb.New()
|
||||
|
||||
r := chi.NewRouter()
|
||||
webApp.Mount(r)
|
||||
|
||||
log.Printf("SoundTouch Web UI starting on http://%s", addr)
|
||||
|
||||
return http.ListenAndServe(addr, r)
|
||||
},
|
||||
}
|
||||
|
||||
if err := app.Run(os.Args); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func withChiParams(r *http.Request, params map[string]string) *http.Request {
|
||||
rctx := chi.NewRouteContext()
|
||||
for k, v := range params {
|
||||
rctx.URLParams.Add(k, v)
|
||||
}
|
||||
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
|
||||
}
|
||||
|
||||
func TestSPARouting(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
expectedStatus int
|
||||
expectedHTML bool
|
||||
}{
|
||||
{
|
||||
name: "root path serves HTML",
|
||||
path: "/",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedHTML: true,
|
||||
},
|
||||
{
|
||||
name: "device path serves HTML",
|
||||
path: "/device/test-device",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedHTML: true,
|
||||
},
|
||||
{
|
||||
name: "arbitrary path serves HTML",
|
||||
path: "/some/random/path",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedHTML: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", tt.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
spaHandler := func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/static/") || strings.HasPrefix(r.URL.Path, "/ws") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">SPA Content</div>
|
||||
</body>
|
||||
</html>`))
|
||||
}
|
||||
|
||||
spaHandler(w, req)
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
|
||||
}
|
||||
|
||||
if tt.expectedHTML {
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "text/html") {
|
||||
t.Errorf("Expected HTML content type, got %s", contentType)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "<!doctype html>") {
|
||||
t.Errorf("Expected HTML content, got: %s", body)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIEndpoints(t *testing.T) {
|
||||
app := soundtouchweb.NewWebApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
method string
|
||||
expectedStatus int
|
||||
expectedJSON bool
|
||||
}{
|
||||
{
|
||||
name: "devices API returns JSON",
|
||||
path: "/api/devices",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedJSON: true,
|
||||
},
|
||||
{
|
||||
name: "discover API accepts POST",
|
||||
path: "/api/discover",
|
||||
method: "POST",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedJSON: true,
|
||||
},
|
||||
{
|
||||
name: "device API with ID",
|
||||
path: "/api/device/test-device",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusNotFound,
|
||||
expectedJSON: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tt.method, tt.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
switch tt.path {
|
||||
case "/api/devices":
|
||||
app.HandleAPIDevices(w, req)
|
||||
case "/api/discover":
|
||||
app.HandleAPIDiscover(w, req)
|
||||
default:
|
||||
if strings.HasPrefix(tt.path, "/api/device/") {
|
||||
deviceID := strings.TrimPrefix(tt.path, "/api/device/")
|
||||
req = withChiParams(req, map[string]string{"id": deviceID})
|
||||
app.HandleAPIDevice(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
|
||||
}
|
||||
|
||||
if tt.expectedJSON {
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "application/json") {
|
||||
t.Errorf("Expected JSON content type, got %s", contentType)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Errorf("Invalid JSON response: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIResponseFormat(t *testing.T) {
|
||||
app := soundtouchweb.NewWebApp()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/devices", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleAPIDevices(w, req)
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode JSON response: %v", err)
|
||||
}
|
||||
|
||||
if !response.Success {
|
||||
t.Errorf("Expected success=true, got success=%v", response.Success)
|
||||
}
|
||||
|
||||
if response.Data == nil {
|
||||
t.Errorf("Expected data field to be present")
|
||||
}
|
||||
|
||||
dataMap, ok := response.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Errorf("Expected data to be a map, got %T", response.Data)
|
||||
}
|
||||
|
||||
if len(dataMap) != 0 {
|
||||
t.Errorf("Expected empty device map, got %d devices", len(dataMap))
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlAPIValidation(t *testing.T) {
|
||||
app := soundtouchweb.NewWebApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
method string
|
||||
body string
|
||||
expectedStatus int
|
||||
chiParams map[string]string
|
||||
}{
|
||||
{
|
||||
name: "missing device ID",
|
||||
path: "/api/control//play",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "invalid control path",
|
||||
path: "/api/control/device",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "unknown action",
|
||||
path: "/api/control/nonexistent/invalid",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusNotFound,
|
||||
chiParams: map[string]string{"id": "nonexistent", "action": "invalid"},
|
||||
},
|
||||
{
|
||||
name: "nonexistent device",
|
||||
path: "/api/control/nonexistent/play",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusNotFound,
|
||||
chiParams: map[string]string{"id": "nonexistent", "action": "play"},
|
||||
},
|
||||
{
|
||||
name: "unknown action with valid device",
|
||||
path: "/api/control/testdevice/unknownaction",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
chiParams: map[string]string{"id": "testdevice", "action": "unknownaction"},
|
||||
},
|
||||
}
|
||||
|
||||
mockDevice := &webtypes.DeviceConnection{
|
||||
Client: nil,
|
||||
DeviceInfo: &models.DeviceInfo{Name: "Test Device"},
|
||||
LastSeen: time.Now(),
|
||||
Status: webtypes.DeviceStatus{IsConnected: true},
|
||||
}
|
||||
app.Devices["testdevice"] = mockDevice
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var req *http.Request
|
||||
if tt.body != "" {
|
||||
req = httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req = httptest.NewRequest(tt.method, tt.path, nil)
|
||||
}
|
||||
if tt.chiParams != nil {
|
||||
req = withChiParams(req, tt.chiParams)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
app.HandleAPIControl(w, req)
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("Test %s: Expected status %d, got %d. Response: %s", tt.name, tt.expectedStatus, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "application/json") {
|
||||
t.Errorf("Expected JSON content type, got %s", contentType)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Errorf("Invalid JSON response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success {
|
||||
t.Errorf("Expected success=false for error case, got success=true")
|
||||
}
|
||||
|
||||
if response.Error == "" {
|
||||
t.Errorf("Expected error message, got empty string")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSocketUpgrade(t *testing.T) {
|
||||
app := soundtouchweb.NewWebApp()
|
||||
|
||||
req := httptest.NewRequest("GET", "/ws", nil)
|
||||
req.Header.Set("Connection", "upgrade")
|
||||
req.Header.Set("Upgrade", "websocket")
|
||||
req.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
|
||||
req.Header.Set("Sec-WebSocket-Version", "13")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleWebSocket(w, req)
|
||||
}
|
||||
|
||||
func TestJSONAPIConsistency(t *testing.T) {
|
||||
app := soundtouchweb.NewWebApp()
|
||||
|
||||
endpoints := []string{
|
||||
"/api/devices",
|
||||
"/api/device/test",
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
t.Run("JSON consistency for "+endpoint, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", endpoint, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
switch endpoint {
|
||||
case "/api/devices":
|
||||
app.HandleAPIDevices(w, req)
|
||||
default:
|
||||
if strings.HasPrefix(endpoint, "/api/device/") {
|
||||
deviceID := strings.TrimPrefix(endpoint, "/api/device/")
|
||||
req = withChiParams(req, map[string]string{"id": deviceID})
|
||||
app.HandleAPIDevice(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "application/json") {
|
||||
t.Errorf("Endpoint %s should return JSON, got %s", endpoint, contentType)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Errorf("Endpoint %s returned invalid JSON: %v", endpoint, err)
|
||||
}
|
||||
|
||||
if response.Success && response.Data == nil {
|
||||
t.Errorf("Endpoint %s: success response should have data", endpoint)
|
||||
}
|
||||
if !response.Success && response.Error == "" {
|
||||
t.Errorf("Endpoint %s: error response should have error message", endpoint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
accounts/
|
||||
backend/
|
||||
certs/
|
||||
default/
|
||||
dns/
|
||||
interactions/
|
||||
parity_mismatches/
|
||||
patterns.json
|
||||
settings.json
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
services:
|
||||
soundtouch-service:
|
||||
build:
|
||||
context: .
|
||||
target: soundtouch-service
|
||||
networks:
|
||||
- soundtouch-test-net
|
||||
volumes:
|
||||
- ./tests/integration/testdata:/app/data
|
||||
environment:
|
||||
- SPOTIFY_CLIENT_ID=mock-id
|
||||
- SPOTIFY_CLIENT_SECRET=mock-secret
|
||||
- SPOTIFY_TOKEN_URL=http://spotify-mock:8080/api/token
|
||||
- SPOTIFY_API_BASE=http://spotify-mock:8080
|
||||
- AMAZON_CLIENT_ID=mock-amazon-id
|
||||
- AMAZON_CLIENT_SECRET=mock-amazon-secret
|
||||
- AMAZON_TOKEN_URL=http://amazon-mock:8080/auth/o2/token
|
||||
- AMAZON_PROFILE_URL=http://amazon-mock:8080/user/profile
|
||||
|
||||
spotify-mock:
|
||||
image: golang:1.26.3-alpine
|
||||
container_name: spotify-mock
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
command: go run ./cmd/mock-spotify/main.go -port 8080
|
||||
ports:
|
||||
- "8081:8080"
|
||||
networks:
|
||||
- soundtouch-test-net
|
||||
|
||||
amazon-mock:
|
||||
image: golang:1.26.3-alpine
|
||||
container_name: amazon-mock
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
command: go run ./cmd/mock-amazon/main.go -port 8080
|
||||
ports:
|
||||
- "8082:8080"
|
||||
networks:
|
||||
- soundtouch-test-net
|
||||
|
||||
networks:
|
||||
soundtouch-test-net:
|
||||
name: soundtouch-test-net
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
soundtouch-service:
|
||||
image: ghcr.io/gesellix/bose-soundtouch:latest
|
||||
image: ghcr.io/gesellix/bose-soundtouch:${SOUNDTOUCH_VERSION:-latest}
|
||||
# build: .
|
||||
container_name: soundtouch-service
|
||||
# Linux only, required for discovery. Swarm requires host network at the task level.
|
||||
|
||||
Binary file not shown.
@@ -23,6 +23,14 @@ All content selection features from the [SoundTouch WebServices API Wiki](https:
|
||||
- Automatic defaults for missing parameters
|
||||
- **Use Cases**: Internet radio streams, proxy-based radio services
|
||||
|
||||
#### `SelectLocalInternetRadio(location, ...)` via `soundtouch-service`
|
||||
- **Purpose**: Select custom radio stream via local `soundtouch-service` proxy
|
||||
- **Features**:
|
||||
- Flexible stream URL encoding (Base64 or URL-escaped)
|
||||
- Dynamic generation of Bose-compatible playback JSON
|
||||
- Seamless integration with existing `LOCAL_INTERNET_RADIO` source
|
||||
- **Use Case**: Playing any internet radio URL without external proxy dependencies
|
||||
|
||||
#### `SelectLocalMusic(location, sourceAccount, itemName, containerArt string) error`
|
||||
- **Purpose**: Select LOCAL_MUSIC content from SoundTouch App Media Server
|
||||
- **Requirements**: SoundTouch App Media Server running on a computer
|
||||
@@ -47,6 +55,15 @@ soundtouch-cli --host <device> source internet-radio \
|
||||
--artwork "https://example.com/art.png"
|
||||
```
|
||||
|
||||
#### `soundtouch-cli source custom-radio`
|
||||
```bash
|
||||
soundtouch-cli --host <device> source custom-radio \
|
||||
--url "https://stream.example.com/radio" \
|
||||
--name "My Station" \
|
||||
--artwork "https://example.com/art.png" \
|
||||
--service-url "http://localhost:8080"
|
||||
```
|
||||
|
||||
#### `soundtouch-cli source local-music`
|
||||
```bash
|
||||
soundtouch-cli --host <device> source local-music \
|
||||
@@ -101,7 +118,7 @@ Comprehensive test suites implemented for all new functionality:
|
||||
### Example Code
|
||||
Complete working example demonstrating:
|
||||
- LOCAL_INTERNET_RADIO with streamUrl proxy format
|
||||
- LOCAL_INTERNET_RADIO with direct streams
|
||||
- LOCAL_INTERNET_RADIO with direct streams
|
||||
- LOCAL_MUSIC content selection
|
||||
- STORED_MUSIC content selection
|
||||
- Generic ContentItem usage
|
||||
@@ -152,7 +169,7 @@ All convenience methods create properly structured `ContentItem` objects:
|
||||
Based on the wiki structure, these related features are also supported:
|
||||
|
||||
1. **LOCAL_MUSIC**: ✅ Fully implemented
|
||||
2. **STORED_MUSIC**: ✅ Fully implemented
|
||||
2. **STORED_MUSIC**: ✅ Fully implemented
|
||||
3. **SPOTIFY**: ✅ Previously implemented
|
||||
4. **TUNEIN**: ✅ Previously implemented
|
||||
5. **BLUETOOTH**: ✅ Previously implemented
|
||||
@@ -169,7 +186,7 @@ err := client.SelectLocalInternetRadio(location, "", "My Station", "")
|
||||
// Direct ContentItem
|
||||
contentItem := &models.ContentItem{
|
||||
Source: "LOCAL_INTERNET_RADIO",
|
||||
Type: "stationurl",
|
||||
Type: "stationurl",
|
||||
Location: location,
|
||||
ItemName: "My Station",
|
||||
IsPresetable: true,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# Bose SoundTouch Device Setup Flow
|
||||
|
||||
This document details the multi-step process required to fully set up a Bose SoundTouch device, as derived from the Stockholm firmware (`setup/js/`) analysis.
|
||||
|
||||
A complete setup flow involves a sequence of local (WebSocket) and cloud (HTTP) actions that move the device from a factory-reset state to a fully registered, functional system.
|
||||
|
||||
## 1. Local Coordination Stage (WebSocket)
|
||||
|
||||
Before a device can be controlled, it must be configured on the local network and named. These actions occur via a WebSocket connection to the device on port 8080.
|
||||
|
||||
### 1.1 Language Configuration (Optional)
|
||||
If the device is in a factory-reset state, the UI typically ensures the device language matches the user's choice.
|
||||
- **WebSocket Action**: `set_language`
|
||||
- **Internal Logic**: `SetupWizard.js` handles this via `set_device_language`.
|
||||
|
||||
### 1.2 Network Configuration (WiFi)
|
||||
Configures the device to connect to a specific wireless access point.
|
||||
- **File Reference**: `setup/js/workflow_wifi_setup.js`
|
||||
- **Logic**: Triggers a site survey, then sends SSID and credentials.
|
||||
- **WebSocket Command**: `set_WIFI_OLED` or similar internal method calls to configure the network profile.
|
||||
|
||||
### 1.3 Device Naming (Rename Step)
|
||||
Assigns a user-friendly name (e.g., "Living Room") to the device.
|
||||
- **File Reference**: `setup/js/workflow_rename.js`
|
||||
- **WebSocket Action**: `name`
|
||||
- **XML Payload**:
|
||||
```xml
|
||||
<name>Living Room</name>
|
||||
```
|
||||
- **Implementation**: The `RenameDevices.do_rename_devices()` function sends this to the device. The device then updates its local name and mDNS/SSDP broadcasts.
|
||||
|
||||
## 2. Cloud Interaction Stage (HTTP)
|
||||
|
||||
The device needs to be linked to a Bose "Marge" account to enable cloud-based features and music services.
|
||||
|
||||
### 2.1 Account Creation (Registration)
|
||||
If a user doesn't have an account, the setup client creates one.
|
||||
- **File Reference**: `setup/js/workflow_marge.js`
|
||||
- **Cloud Endpoint**: `POST https://streaming.bose.com/streaming/account`
|
||||
- **Payload**: XML containing name, email, password, and country.
|
||||
- **Content-Type**: `application/vnd.bose.customer-v1.0+xml`
|
||||
|
||||
### 2.2 Cloud Authentication (Login)
|
||||
The setup client must obtain a valid `accountId` and `userAuthToken` to pair the device.
|
||||
- **File Reference**: `setup/js/workflow_marge.js`
|
||||
- **Cloud Endpoint**: `POST https://streaming.bose.com/streaming/account/login`
|
||||
- **Payload**: XML containing username and password.
|
||||
- **Content-Type**: `application/vnd.bose.streaming-v1.2+xml`
|
||||
- **Result**: Returns a session token in the `Credentials` response header and the user's `account ID` in the XML body.
|
||||
|
||||
## 3. Registration Bridge (WebSocket to Cloud)
|
||||
|
||||
This is the final "pairing" step where the client tells the device which account it belongs to.
|
||||
|
||||
### 3.1 Device Registration (The "Pair" Step)
|
||||
The client sends the user's credentials to the device, which then registers itself with the cloud.
|
||||
- **File Reference**: `setup/js/workflow_add_devices.js`
|
||||
- **WebSocket Action**: `setMargeAccount`
|
||||
- **XML Payload**:
|
||||
```xml
|
||||
<PairDeviceWithAccount>
|
||||
<accountId>12345</accountId>
|
||||
<userAuthToken>jGwE... (truncated)</userAuthToken>
|
||||
</PairDeviceWithAccount>
|
||||
```
|
||||
- **Device Reaction**: Upon receiving this, the device makes its own outbound HTTP POST to the Marge service:
|
||||
`POST https://streaming.bose.com/{accountId}/devices`
|
||||
|
||||
## 4. Finalization
|
||||
|
||||
Once the registration is complete, the setup application (Stockholm) performs final cleanup. It's important to distinguish between **App State** (the Stockholm UI's persistent settings) and **Device State** (the physical speaker's configuration).
|
||||
|
||||
### 4.1 Exiting Setup Mode (App Settings)
|
||||
The Stockholm app communicates with its "native container" (the WebView bridge on iOS/Android/Windows/macOS) using a `setData` command in **JSON format**. This is an internal message to the application's persistent storage, **not a network command sent to the physical speaker**.
|
||||
|
||||
This command tells the Stockholm app which page to load on startup, effectively marking the setup as complete in the UI.
|
||||
|
||||
- **Internal Command**: `setData`
|
||||
- **Parameter**: `startupPage`
|
||||
- **Normal Value**: `index.html` (Normal mode)
|
||||
- **Setup Value**: `setup/index.html` (Setup mode)
|
||||
|
||||
**JSON Payload (Internal to Stockholm App)**:
|
||||
```json
|
||||
{
|
||||
"method": "setData",
|
||||
"params": {
|
||||
"name": "startupPage",
|
||||
"value": "index.html"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Other Common Internal Parameters**:
|
||||
- `changeStartupPage`: Set to `false` after a successful setup or update.
|
||||
- `tipsEnabled`: Set to `false` to suppress the "Getting Started" tutorials.
|
||||
- `promptUpdate`: Set to `true` if a firmware update was deferred during setup.
|
||||
|
||||
### 4.2 Device Finalization
|
||||
The physical speaker considers the setup "done" once it successfully processes the `<PairDeviceWithAccount>` XML message and completes its own handshake with the Marge cloud. There is no specific "Finalize" XML command sent to the speaker; the successful registration is the signal.
|
||||
|
||||
The `SetupWizard.js` calls `single_device_setup_done()` to trigger the internal `setData` updates described above. If these are not saved in the app's local storage, the Stockholm UI may return to the setup flow on next launch, even if the speaker is already paired.
|
||||
|
||||
---
|
||||
|
||||
## Summary of Scriptable Requirements
|
||||
|
||||
To automate a device setup using a custom tool (like `soundtouch-cli`), you must perform the following:
|
||||
1. **Configure WiFi**: (Assumed if device is reachable over IP).
|
||||
2. **Set Name**: Send the `<name>` WebSocket message (XML) to update the device identity.
|
||||
3. **Obtain Token**: Authenticate against the cloud service (Marge) via HTTP.
|
||||
4. **Pair Device**: Send the `<PairDeviceWithAccount>` WebSocket message (XML) with the account ID and token.
|
||||
|
||||
**Note**: The JSON `setData` commands are only necessary if you are building/controlling a version of the Stockholm UI itself. They are not required to configure the physical hardware.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Technical Proposal: External Service Provider Abstraction
|
||||
|
||||
This document outlines a strategy to refactor the SoundTouch Service's content handling into a modular provider-based system.
|
||||
|
||||
## 1. Problem Statement
|
||||
Currently, content handling for BMX (Bose Media Exchange) services like TuneIn or RadioBrowser is deeply intertwined with the HTTP handlers and XML models. Adding a new content provider (e.g., Local Media, Podcast RSS) requires modifying several files and duplicating boilerplate code for HTTP requests and error handling.
|
||||
|
||||
## 2. Proposed Architecture
|
||||
|
||||
### 2.1 The Provider Interface
|
||||
We define a generic `ContentProvider` interface that abstracts away the source-specific logic (API calls, data parsing).
|
||||
|
||||
```go
|
||||
package provider
|
||||
|
||||
import "github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
|
||||
type ContentProvider interface {
|
||||
// ID returns the unique identifier for this provider (e.g. "RADIO_BROWSER")
|
||||
ID() string
|
||||
|
||||
// Resolve returns playback details for a given content identifier
|
||||
Resolve(id string) (*models.BmxPlaybackResponse, error)
|
||||
|
||||
// Search allows finding content within this provider
|
||||
Search(query string) ([]models.ContentItem, error)
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Provider Registry
|
||||
A central registry in `soundtouch-service` manages the lifecycle and selection of providers.
|
||||
|
||||
```go
|
||||
type Registry struct {
|
||||
providers map[string]ContentProvider
|
||||
}
|
||||
|
||||
func (r *Registry) Register(p ContentProvider) { ... }
|
||||
func (r *Registry) Get(id string) ContentProvider { ... }
|
||||
```
|
||||
|
||||
## 3. Implementation Plan
|
||||
|
||||
### 3.1 Phase 1: Modularize RadioBrowser
|
||||
1. **Extract Logic**: Move current RadioBrowser logic from `bmx.go` into a new package `pkg/service/providers/radiobrowser`.
|
||||
2. **Add Failover**: Implement the **API Failover** logic inspired by OpenCloudTouch.
|
||||
- Maintain a list of active RadioBrowser mirrors (e.g., `de1.api.radio-browser.info`, `nl1.api.radio-browser.info`).
|
||||
- Implement a round-robin or health-based selection strategy.
|
||||
3. **Implements Interface**: Ensure the new package satisfies the `ContentProvider` interface.
|
||||
|
||||
### 3.2 Phase 2: Refactor BMX Handlers
|
||||
- Update `HandleTuneInPlayback` and `HandleOrionPlayback` to use the registry.
|
||||
- The handlers will look up the provider based on the request context or URL parameters and delegate the resolution.
|
||||
|
||||
### 3.3 Phase 3: Dynamic Service Advertising
|
||||
- Modify `HandleBMXRegistry` to dynamically generate the `bmx_services.json` content based on the currently registered and enabled providers.
|
||||
|
||||
## 4. Benefits
|
||||
- **Resilience**: Centralized error handling and failover strategies for all external APIs.
|
||||
- **Extensibility**: New services can be added by simply implementing the interface and registering them at startup.
|
||||
- **Testability**: Providers can be unit-tested in isolation without mocking the entire HTTP server stack.
|
||||
- **Unified UI**: A future Web UI can query the registry to show available content sources and their statuses.
|
||||
|
||||
## 5. Next Steps
|
||||
1. Refine the `ContentProvider` interface to include metadata (icons, user-friendly names).
|
||||
2. Create a prototype for the `radiobrowser` provider with failover support.
|
||||
@@ -0,0 +1,88 @@
|
||||
### Overview of Recent Improvements and Next Steps
|
||||
|
||||
This document summarizes the improvements made to the **Marge service** to improve parity with the upstream Bose SoundTouch service, along with open issues and proposed next steps.
|
||||
|
||||
#### ✅ Completed Improvements (Marge Service)
|
||||
* **Mapped Preset `buttonNumber`**: Correctly mapped the internal `ServicePreset.ID` or `ButtonNumber` to the `buttonNumber` XML attribute in the `/full` response and ensured it is persisted in the local datastore.
|
||||
* **High-Fidelity Device Metadata**: Improved the datastore to correctly extract, persist, and report detailed device `<components>` (e.g., `LIGHTSWITCH`, `SMSC`) and their firmware versions from upstream responses.
|
||||
* **Standardized Preferred Language**: Updated the default `preferredLanguage` to `de` in the `/full` response and added synchronization to persist it from upstream responses.
|
||||
* **Persisted Provider Settings**: Added support for persisting and echoing back `providerSettings` (e.g., `STREAMING_QUALITY`, `ELIGIBLE_FOR_TRIAL`) from the `/full` response.
|
||||
* **Populated `contentItemType`**: The `contentItemType` (e.g., `tracklisturl`) is now correctly synchronized from upstream, persisted in the local datastore, and returned in the `/full` response for both presets and recents.
|
||||
* **Standardized Credential Types**: Adjusted the logic for Spotify to use the correct `token_version_3` type when a token is present in the `/full` response, improving parity with the upstream service. The service now respects existing `credential_type` values from `Sources.xml` (e.g., `token_version_3` for Spotify) while providing sensible defaults for new or incomplete sources.
|
||||
* **Structured Sources (Sources.xml)**: Refactored `Sources.xml` to use an attribute-based structure (`sourceid`, `source`, `status`, `sourceAccount`, etc.) matching the real device's output. Removed redundant nested tags like `<sourcename>`, `<username>`, and `<name>`.
|
||||
* **Nested Recents (Recents.xml)**: Implemented a nested `<contentItem>` structure within `<recent>` entries in `Recents.xml`, maintaining exact parity with the device's persistence format while supporting legacy flat formats for backward compatibility.
|
||||
* **Inconsistent `serialNumber` Casing**: Fixed the casing mismatch in the `/full` response where the upstream uses camelCase `<serialNumber>` in the top-level `<device>` and lowercase `<serialnumber>` in the nested `<attachedProduct>`. Local responses now correctly mirror this inconsistency.
|
||||
* **Attribute-level Parity**:
|
||||
* Ensured `sourceAccount=""` is preserved in XML even when empty, matching device behavior for sources like TUNEIN.
|
||||
* Fixed casing for attributes like `deviceID` and `utcTime` in `Recents.xml`.
|
||||
* Correctly mapped and persisted preset and recent `id` attributes during "Initial Data Sync".
|
||||
* **Device Name Consistency**: Fixed an issue where the device `<name>` was empty in some local `/full` responses by ensuring it is correctly populated from the datastore and synchronized from upstream.
|
||||
* **Improved XML Parity**: Empty `<name>` tags in the `/full` response are now self-closing (`<name/>`), matching upstream behavior.
|
||||
* **Timestamp-based ID Generation**: Implemented a 9-digit ID schema (`YYMMDD` + 3-digit counter) for `recent` items, ensuring IDs are large, unique, and stay within the 32-bit integer range.
|
||||
* **Automatic Source Learning**: The service now extracts and persists full metadata (credentials, provider IDs, and custom names) from incoming `POST /recent` requests. This improves parity for subsequent `GET /recents` calls.
|
||||
* **Source Provider Mapping**: Synchronized local source provider IDs and timestamps with upstream data. The `RADIO_BROWSER` provider is included in the public `/streaming/sourceproviders` list to maintain internal functionality while acknowledging it as a parity gap.
|
||||
* **Credential Preservation**: Improved `AddRecent` to correctly extract and echo back base64 tokens/credentials provided in the incoming request, improving source learning.
|
||||
* **XML Formatting Parity**:
|
||||
* Added `standalone="yes"` to the XML declaration for all Marge responses, including `recent`, `presets`, `full account`, `software update`, and `sourceproviders`.
|
||||
* Enforced self-closing `<sourceSettings/>` tags for parity.
|
||||
* Standardized date formatting to UTC with milliseconds (`.000+00:00`).
|
||||
* Fixed casing for `/streaming/sourceproviders`: Root element is `<sourceProviders>`, but child elements are `<sourceprovider>` (all lowercase), matching upstream behavior.
|
||||
* Implemented structured XML marshaling with consistent 2-space indentation for recents and source providers.
|
||||
* **Improved TuneIn Parity**: Fixed TuneIn source mapping to use ID `25` and ensuring `sourcename` is empty in responses, matching upstream behavior for station playback.
|
||||
* **High-Fidelity Full Account Sync**: Refactored the `/streaming/account/{accountId}/full` response to match the upstream structure. This includes:
|
||||
* **Mapped Preset `buttonNumber`**: Correctly mapped the internal `ServicePreset.ID` to the `buttonNumber` XML attribute in the `/full` response.
|
||||
* **Structured XML Marshaling**: Replaced manual string concatenation with structured Go models and `xml.Marshal` for the entire response.
|
||||
* **Specific Response Models**: Introduced `FullResponseSource`, `FullResponsePreset`, and `FullResponseRecent` to accurately reflect the upstream structure where `<source>` is a child element, rather than a set of attributes.
|
||||
* **Correct Nesting**: Ensured that `<presets>` and `<recents>` correctly nest their associated `<source>` details, resolving previous data omissions.
|
||||
* **Device Identity**: Added `<serialNumber>` and `<updatedOn>` to both the top-level `<device>` and its `<attachedProduct>`, ensuring consistent device identification.
|
||||
* **Field-Level Parity**: Mapped missing fields like `<contentItemType>` and `<productlabel>` to match upstream expectations.
|
||||
* **Improved Source Matching**: Enhanced internal logic to correctly link presets and recents to their configured sources based on multiple identifiers (ID, Key, or Type).
|
||||
* **Verified Parity Mismatch Fixes**: Comprehensive reproduction tests (`TestParityMismatchReproduction_V2` and `TestParityMismatchReproduction_V3`) now confirm parity for identified mismatches in `POST /recent` and `GET /recents`, including credentials and source-specific metadata.
|
||||
* **Unified Response Logic**: Refactored the code so that both `POST /recent` and `GET /recents` use the same formatting functions, guaranteeing consistency.
|
||||
* **Robust Parity Detection**: Updated the local parity checker to be whitespace-insensitive for XML bodies, significantly reducing noise from minor indentation or newline differences.
|
||||
* **Maintainable XML Generation**: Reduced cyclomatic complexity and code duplication in `marge.go` by extracting focused helper functions for mapping internal data to response-specific XML models.
|
||||
|
||||
---
|
||||
|
||||
#### 🛠️ Open Issues and Next Steps
|
||||
|
||||
Based on the latest `parity_mismatches` and the high-fidelity `/full` account response comparison (diff14), here are the recommended areas for further work:
|
||||
|
||||
#### 1. BMX / TuneIn Playback Parity (Medium)
|
||||
Current mismatches in `/bmx/tunein/v1/playback/station/...` show differences in reporting URLs and missing links:
|
||||
* **Mismatched Parameters**: Local reporting URLs use `listen_id=1234567890`, while upstream uses a different session-based ID.
|
||||
* **Missing Links**: Some upstream responses include additional `_links` or metadata that are currently omitted in local responses.
|
||||
* **Action**: Improve the `HandleTuneInPlayback` logic to better mirror the upstream response structure and parameter generation.
|
||||
|
||||
#### 2. `/full` Account Response Data Gaps (Medium)
|
||||
While structural parity for the `/full` response is high, several value-level gaps remain as shown in `diff14`:
|
||||
* **Timestamp Formats**: Upstream uses ISO-8601 with milliseconds (e.g., `2024-06-23T07:40:36.000+00:00`), whereas some local fields still use Unix epoch integers (e.g., `1234567890`).
|
||||
* **Provider Settings**: The `providerSettings` block in the local response currently lacks crucial values like `keyName`, `providerId`, and `boseId` (appearing as empty tags).
|
||||
* **Component Metadata**: Local component types are sometimes empty (`type=""`) compared to upstream values like `LIGHTSWITCH` or `SMSC`.
|
||||
* **Source/Preset Identifiers**: Local IDs (e.g., `100004`) differ from upstream IDs (e.g., `1234567`), though this may be expected due to different account/device environments.
|
||||
* **Action**: Update the mapping logic in `marge.go` and `setup.go` to ensure all fields in the `/full` response are correctly populated with high-fidelity values and standard ISO-8601 timestamps.
|
||||
|
||||
#### 3. OAuth / Spotify Token Noise (Low/Medium)
|
||||
The `/oauth/device/.../token` endpoint frequently reports mismatches because tokens are naturally different between local and upstream.
|
||||
* **The Issue**: This creates "noise" in your parity reports that isn't actually a bug.
|
||||
* **Action**: Update the parity detection logic (or the handler) to selectively ignore the `access_token` field while still verifying that the rest of the JSON structure (expires_in, scope, token_type) matches.
|
||||
|
||||
#### 4. Large IDs for Other Models (Medium)
|
||||
While we fixed IDs for `recents`, other models like `presets` or `sources` might still use small auto-incrementing integers.
|
||||
* **Action**: Evaluate if other endpoints should also transition to the timestamp-based ID schema to further reduce diff noise.
|
||||
|
||||
#### 5. Improved Data Persistence (Continuous)
|
||||
Continue the "learning" approach for other services. For example, if we see a new `sourceproviderid` in a Spotify or TuneIn request, we should ensure it is stored and reused.
|
||||
|
||||
#### 6. Local Reboot & Device State Management (Continuous)
|
||||
Analysis of device reboot logs revealed several data requirements:
|
||||
* **Power-On Details Tracking**: Implemented extraction and persistence of detailed device information (serial numbers, firmware version, product details, and MAC addresses) from the `POST /streaming/support/power_on` request. This data is now stored in the local datastore, improving our ability to respond accurately to subsequent management requests.
|
||||
* **Source Provider Mapping**: Synchronized local source provider IDs and timestamps with upstream data. The `RADIO_BROWSER` provider is included in the public `/streaming/sourceproviders` list to maintain internal functionality while acknowledging it as a parity gap.
|
||||
|
||||
#### 7. Account Full Response (/full) Structural & Value Parity (Completed)
|
||||
Structural and value gaps in the `/full` account response have been addressed:
|
||||
|
||||
**Key Fixes:**
|
||||
* **Structural**:
|
||||
* **Nested Source Association**: Improved the matching logic in `mapRecentsToFullResponse` to correctly link recents to their specific `ConfiguredSource` (e.g., by matching `sourceid` attribute).
|
||||
* **XML Tag Formatting**: Standardized self-closing tags and element formatting to match upstream's multi-line or empty-element formatting in various contexts.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Parity Analysis: Bose-SoundTouch (Go) vs. OpenCloudTouch (Python)
|
||||
|
||||
This document provides a comparative analysis of the current Go implementation and the `scheilch/opencloudtouch` project, identifying functional gaps and potential improvements.
|
||||
|
||||
## 1. Core Architecture and Language
|
||||
- **Bose-SoundTouch (Go)**: A high-performance, strongly typed backend with a CLI and background service. Focuses on full API coverage, parity testing, and robust hardware control (DSP, zones).
|
||||
- **OpenCloudTouch (OCT)**: A modern full-stack application (FastAPI + React/TypeScript). Prioritizes user experience with a web-based setup wizard and a clean abstraction for internet radio.
|
||||
|
||||
## 2. Functional Comparison
|
||||
|
||||
| Feature | Bose-SoundTouch (Go) | OpenCloudTouch (Python) |
|
||||
|:------------------------|:-----------------------------------------------------------|:------------------------------------------------------------------------|
|
||||
| **Setup Experience** | CLI-driven or manual API calls for migration (SSH, XML). | Web-based **Setup Wizard** guides through SSH, backup, and redirection. |
|
||||
| **Radio Support** | Static integration of **RadioBrowser** and TuneIn. | Dynamic **RadioBrowserAdapter** with automatic **API Failover**. |
|
||||
| **Commercial Services** | Deep integration (Spotify priming, Pandora, Deezer, etc.). | Basic support, focus is on local content and radio. |
|
||||
| **Hardware Control** | Extensive (Bass, Treble, Soundbar levels, Clock display). | Basic playback and zone controls. |
|
||||
| **Cloud Emulation** | High-fidelity parity (mirroring, discrepancy logging). | Functional emulation for local preset/recent persistence. |
|
||||
| **Notifications** | Built-in **TTS** and custom URL audio alerts. | Not a primary focus. |
|
||||
|
||||
## 3. Key Strengths of OpenCloudTouch
|
||||
- **Guided Onboarding**: The setup wizard reduces the entry barrier for non-technical users significantly.
|
||||
- **Resilient Radio**: The API failover for RadioBrowser ensures continuous service even if specific community-hosted API instances go offline.
|
||||
- **Modern API Stack**: Uses OpenAPI and generated TypeScript types for a seamless frontend integration.
|
||||
- **Provider Abstraction**: A cleaner internal separation between the "Bose World" (XML/BMX) and external content providers (RadioBrowser).
|
||||
|
||||
## 4. Suggested Improvements for Bose-SoundTouch
|
||||
|
||||
### A. Web-based Setup Wizard (High Priority)
|
||||
- Implement a state-driven wizard in the `soundtouch-service` to handle:
|
||||
- SSH activation (checking `/remote_services` via USB).
|
||||
- Automated backup of speaker configuration.
|
||||
- Verification of DNS/Hosts redirection.
|
||||
- Expose this via a simple embedded Web UI (using Go's `embed` package).
|
||||
|
||||
### B. RadioBrowser Failover (Medium Priority)
|
||||
- Adapt the failover logic from OCT:
|
||||
- Periodically refresh the list of available RadioBrowser API servers.
|
||||
- Implement a retry mechanism that switches servers on 5xx errors or timeouts.
|
||||
|
||||
### C. External Service Abstraction (Medium Priority)
|
||||
- Refactor the hardcoded BMX logic into a more modular **Provider System** (see `EXTERNAL-SERVICES-ABSTRACTION.md`).
|
||||
- This will allow easier addition of new sources (e.g., local DLNA, generic M3U playlists) without touching the core BMX handlers.
|
||||
|
||||
## 5. Summary
|
||||
While our Go project provides the most complete technical coverage of SoundTouch hardware and commercial services, OpenCloudTouch sets a higher standard for **user onboarding** and **service resilience** for community-driven content. Integrating a setup wizard and a more robust radio backend would make our project significantly more accessible and reliable.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Parity Analysis: Bose-SoundTouch (Go) vs. SoundCork (Python)
|
||||
|
||||
This document provides a comparative analysis of the current Go implementation and the `deborahgu/soundcork` project, identifying functional gaps and potential improvements.
|
||||
|
||||
## 1. Core Architecture and Language
|
||||
- **Bose-SoundTouch (Go)**: Uses `chi` for routing and `encoding/xml` for data. High performance, strong typing, and precise MIME type handling (`application/vnd.bose.streaming-v1.2+xml`).
|
||||
- **SoundCork (Python)**: Uses `FastAPI` and `xml.etree.ElementTree`. Prioritizes flexibility and rapid prototyping of streaming service mocks.
|
||||
|
||||
## 2. Functional Comparison
|
||||
|
||||
| Feature | Bose-SoundTouch (Go) | SoundCork (Python) |
|
||||
|:---------------------|:----------------------------------------------------------------------------------------------------------------------|:----------------------------------------------------------------------------------------|
|
||||
| **Group Management** | Full CRUD: `POST /group`, `POST /group/{id}`, `DELETE /group/{id}` with XML datastore persistence (`Group_{id}.xml`). | Active group management (`groups.py`), supporting `/addGroup` and stereo pairing logic. |
|
||||
| **ZeroConf Priming** | Full DH key exchange + encrypted blob; fallback to `tokenType=accesstoken` for older firmware. | Simple `tokenType=accesstoken` push only; token expires after ~60 minutes. |
|
||||
| **BMX Services** | Supports TuneIn, Orion, and custom streams. | More modular `bmx_services.json` registry with broader mock support. |
|
||||
| **Persistence** | Mixed JSON/XML datastore. | Pure XML-based persistence per device/account. |
|
||||
| **Admin UI** | CLI-based (`soundtouch-cli`) or API-driven. | Draft Web UI for device discovery and account management (`admin.py`). |
|
||||
| **Discovery** | Integrated setup tools and SSDP/MDNS awareness. | Leverages `bosesoundtouchapi` Python library for active discovery. |
|
||||
|
||||
## 3. Key Strengths of SoundCork
|
||||
- **Group Pairing Logic**: Includes logic to manage master/slave relationships for SoundTouch 10 stereo pairs.
|
||||
- **Service Extensibility**: JSON-based registry for BMX services makes it easier to mock multiple providers (SiriusXM, Spotify) without code changes.
|
||||
- ~~**Mock Coverage**: Better coverage of "dummy" endpoints that respond with plausible XML (e.g., `customerSupport`).~~ **Addressed**: AfterTouch's `HandleNotFound` (registered via `r.NotFound`) logs every unimplemented endpoint as `[UNHANDLED]` and forwards the request to the Bose upstream via `HandleBoseProxy`. This provides at least the same coverage as static dummy responses, while also aiding discovery of new endpoints.
|
||||
|
||||
## 4. Suggested Implementation Steps for Bose-SoundTouch
|
||||
|
||||
### ✅ A. Implement Full Group Support (Completed)
|
||||
- `POST /group`, `POST /group/{id}`, `DELETE /group/{id}` implemented in `pkg/service/handlers/handlers_marge.go`.
|
||||
- Group CRUD persisted in XML datastore (`Group_{id}.xml`) via `pkg/service/datastore/datastore.go`.
|
||||
- `GET /group` on device registration reads the group the device belongs to.
|
||||
|
||||
### ✅ B. Proper ZeroConf Spotify Blob (Completed)
|
||||
- Full Spotify Connect ZeroConf protocol implemented in `pkg/service/spotify/zeroconf.go`.
|
||||
- Flow: `getInfo` (fetch speaker DH public key) → 768-bit DH key exchange → AES-128-CTR encrypted `LoginCredentials` protobuf blob → `addUser`.
|
||||
- Speaker can self-refresh credentials independently; no periodic re-priming needed for token expiry.
|
||||
- Automatic fallback to `tokenType=accesstoken` if `getInfo` fails (older firmware without DH support).
|
||||
- See `docs/concepts/spotify-priming-strategy.md` for full protocol details.
|
||||
- **Remaining gap**: Background watchdog to re-prime devices that lose their session (reboot / power loss). Not required for token expiry on modern firmware; only needed for the "speaker rebooted and lost state" recovery path and for older firmware on the fallback path (~45 min token expiry).
|
||||
|
||||
### C. Modularize BMX Registry (Medium Priority)
|
||||
- Extract the hardcoded service list in `HandleBMXRegistry` into an external `bmx-services.json` file.
|
||||
- Allow users to customize which mocked services are advertised to the speaker.
|
||||
|
||||
### D. Enhanced Source Management (Medium Priority)
|
||||
- Refine source learning logic to ensure all `sourceAccount` and `sourceName` metadata is correctly captured during synchronization, using patterns from `soundcork`'s `learnSource`.
|
||||
|
||||
### E. Basic Admin Web UI (Low Priority)
|
||||
- Develop a minimal internal status page to list active accounts and connected devices, improving usability over raw API calls.
|
||||
|
||||
## 5. Summary
|
||||
|
||||
Group support and ZeroConf Spotify priming are now feature-complete in AfterTouch. The Go implementation is structurally more consistent with recent reference recordings (e.g., `buttonNumber`, detailed `components`). SoundCork's remaining functional advantages are:
|
||||
|
||||
- **BMX service extensibility**: the `bmx_services.json` registry makes it trivial to add or mock new streaming providers without code changes (step C above).
|
||||
- **Group pairing logic**: master/slave relationship management for SoundTouch 10 stereo pairs goes beyond the CRUD AfterTouch implements.
|
||||
|
||||
For the broader ecosystem context (feature matrix across all community projects, AfterTouch open tasks, and cross-project observations) see [docs/analysis/bose-soundtouch-community-tools.md](analysis/bose-soundtouch-community-tools.md).
|
||||
+44
-44
@@ -21,7 +21,7 @@ This document describes the most important patterns for the Bose SoundTouch API
|
||||
|
||||
**Key Aspects:**
|
||||
- **Native Builds**: Full API functionality for CLI and server
|
||||
- **WASM Builds**: Browser-compatible subset functionality
|
||||
- **WASM Builds**: Browser-compatible subset functionality
|
||||
- **Cross-Platform**: Linux, macOS, Windows support
|
||||
- **Embedded Assets**: Web UI directly embedded in binary
|
||||
|
||||
@@ -66,7 +66,7 @@ func (c *Client) GetNowPlaying() (*models.NowPlaying, error) {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
|
||||
var nowPlaying models.NowPlaying
|
||||
err = xml.NewDecoder(resp.Body).Decode(&nowPlaying)
|
||||
return &nowPlaying, err
|
||||
@@ -77,7 +77,7 @@ func (c *Client) GetNowPlaying() (*models.NowPlaying, error) {
|
||||
```go
|
||||
func (c *Client) SendKey(key models.Key) error {
|
||||
keyXML := fmt.Sprintf(`<key state="press" sender="GoClient">%s</key>`, key)
|
||||
|
||||
|
||||
resp, err := c.httpClient.Post(
|
||||
c.baseURL+"/key",
|
||||
"application/xml",
|
||||
@@ -117,14 +117,14 @@ func (d *DiscoveryService) DiscoverDevices() ([]Device, error) {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
|
||||
// Send M-SEARCH request
|
||||
searchRequest := "M-SEARCH * HTTP/1.1\r\n" +
|
||||
"HOST: 239.255.255.250:1900\r\n" +
|
||||
"MAN: \"ssdp:discover\"\r\n" +
|
||||
"ST: urn:schemas-upnp-org:device:MediaRenderer:1\r\n" +
|
||||
"MX: 3\r\n\r\n"
|
||||
|
||||
|
||||
// Implementation details...
|
||||
return devices, nil
|
||||
}
|
||||
@@ -158,13 +158,13 @@ func (e *EventClient) Subscribe(eventType string, handler EventHandler) {
|
||||
|
||||
func (e *EventClient) Start() error {
|
||||
u := url.URL{Scheme: "ws", Host: e.client.host + ":8090", Path: "/"}
|
||||
|
||||
|
||||
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.conn = conn
|
||||
|
||||
|
||||
go e.eventLoop()
|
||||
return nil
|
||||
}
|
||||
@@ -184,7 +184,7 @@ func (e *EventClient) eventLoop() {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if handler, exists := e.handlers[event.Type]; exists {
|
||||
go handler(event)
|
||||
}
|
||||
@@ -220,7 +220,7 @@ func wasmDiscoverDevices(this js.Value, args []js.Value) interface{} {
|
||||
handler := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||
go func() {
|
||||
devices, err := discovery.NewDiscoveryService(5*time.Second).DiscoverDevices()
|
||||
|
||||
|
||||
result := make(map[string]interface{})
|
||||
if err != nil {
|
||||
result["error"] = err.Error()
|
||||
@@ -228,13 +228,13 @@ func wasmDiscoverDevices(this js.Value, args []js.Value) interface{} {
|
||||
devicesJSON, _ := json.Marshal(devices)
|
||||
result["devices"] = string(devicesJSON)
|
||||
}
|
||||
|
||||
|
||||
// Call JavaScript callback
|
||||
args[0].Invoke(js.ValueOf(result))
|
||||
}()
|
||||
return nil
|
||||
})
|
||||
|
||||
|
||||
return handler
|
||||
}
|
||||
```
|
||||
@@ -280,7 +280,7 @@ func main() {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
for i, device := range devices {
|
||||
fmt.Printf("%d: %s (%s)\n", i+1, device.Name, device.Host)
|
||||
}
|
||||
@@ -300,7 +300,7 @@ func main() {
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
app.Run(os.Args)
|
||||
}
|
||||
|
||||
@@ -311,7 +311,7 @@ func getClientFromContext(c *cli.Context) *client.Client {
|
||||
devices, _ := discovery.DiscoverDevices()
|
||||
deviceHost = selectDeviceInteractive(devices)
|
||||
}
|
||||
|
||||
|
||||
return client.NewClient(deviceHost, 8090)
|
||||
}
|
||||
```
|
||||
@@ -327,34 +327,34 @@ var webAssets embed.FS
|
||||
|
||||
func main() {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
|
||||
// Embedded web assets
|
||||
webFS, err := fs.Sub(webAssets, "web")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
// SPA routing
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.FileServer(http.FS(webFS)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
data, err := webAssets.ReadFile("web/index.html")
|
||||
if err != nil {
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write(data)
|
||||
})
|
||||
|
||||
|
||||
// API endpoints
|
||||
mux.HandleFunc("/api/devices", handleDeviceDiscovery)
|
||||
mux.HandleFunc("/api/client/", handleClientProxy)
|
||||
|
||||
|
||||
log.Println("SoundTouch Web UI starting on :8080")
|
||||
log.Fatal(http.ListenAndServe(":8080", mux))
|
||||
}
|
||||
@@ -370,36 +370,36 @@ func handleClientProxy(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
deviceIP := pathParts[3]
|
||||
apiPath := "/" + strings.Join(pathParts[4:], "/")
|
||||
|
||||
|
||||
// Proxy request to SoundTouch device
|
||||
targetURL := fmt.Sprintf("http://%s:8090%s", deviceIP, apiPath)
|
||||
|
||||
|
||||
proxyReq, err := http.NewRequest(r.Method, targetURL, r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Copy headers
|
||||
for k, v := range r.Header {
|
||||
proxyReq.Header[k] = v
|
||||
}
|
||||
|
||||
|
||||
resp, err := http.DefaultClient.Do(proxyReq)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
|
||||
// Enable CORS
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
|
||||
// Copy response
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
io.Copy(w, resp.Body)
|
||||
@@ -448,7 +448,7 @@ func (p *PlayStatus) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error
|
||||
if err := d.DecodeElement(&s, &start); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
switch s {
|
||||
case string(PlayStatusPlaying), string(PlayStatusPaused), string(PlayStatusStopped):
|
||||
*p = PlayStatus(s)
|
||||
@@ -469,41 +469,41 @@ type Config struct {
|
||||
// Server configuration
|
||||
WebPort int `env:"WEB_PORT" default:"8080"`
|
||||
APITimeout time.Duration `env:"API_TIMEOUT" default:"10s"`
|
||||
|
||||
|
||||
// Discovery configuration
|
||||
DiscoveryTimeout time.Duration `env:"DISCOVERY_TIMEOUT" default:"5s"`
|
||||
CacheDevices bool `env:"CACHE_DEVICES" default:"true"`
|
||||
|
||||
|
||||
// CORS configuration (for web proxy)
|
||||
CORSOrigins []string `env:"CORS_ORIGINS" default:"*"`
|
||||
|
||||
|
||||
// Logging
|
||||
LogLevel string `env:"LOG_LEVEL" default:"info"`
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
var cfg Config
|
||||
|
||||
|
||||
// Load from .env file
|
||||
loadDotEnv()
|
||||
|
||||
|
||||
// Parse environment variables with reflection
|
||||
parseEnvVars(&cfg)
|
||||
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func parseEnvVars(cfg interface{}) {
|
||||
v := reflect.ValueOf(cfg).Elem()
|
||||
t := v.Type()
|
||||
|
||||
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
field := v.Field(i)
|
||||
fieldType := t.Field(i)
|
||||
|
||||
|
||||
envTag := fieldType.Tag.Get("env")
|
||||
defaultTag := fieldType.Tag.Get("default")
|
||||
|
||||
|
||||
if envTag != "" {
|
||||
if envValue := os.Getenv(envTag); envValue != "" {
|
||||
setFieldValue(field, envValue)
|
||||
@@ -545,11 +545,11 @@ func (m *MockClient) GetNowPlaying() (*models.NowPlaying, error) {
|
||||
if err, exists := m.errors["now_playing"]; exists {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
if resp, exists := m.responses["now_playing"]; exists {
|
||||
return resp.(*models.NowPlaying), nil
|
||||
}
|
||||
|
||||
|
||||
return &models.NowPlaying{
|
||||
Track: "Mock Track",
|
||||
Artist: "Mock Artist",
|
||||
@@ -577,8 +577,8 @@ CMD ["go", "test", "-v", "./..."]
|
||||
```bash
|
||||
# Makefile test target
|
||||
test-integration:
|
||||
docker-compose -f test/docker-compose.yml up --build --abort-on-container-exit
|
||||
docker-compose -f test/docker-compose.yml down
|
||||
docker compose -f test/docker-compose.yml up --build --abort-on-container-exit
|
||||
docker compose -f test/docker-compose.yml down
|
||||
```
|
||||
|
||||
## Recommended Project Structure
|
||||
@@ -741,7 +741,7 @@ type APIError struct {
|
||||
Message string `xml:",innerxml"`
|
||||
}
|
||||
|
||||
// pkg/models/device.go
|
||||
// pkg/models/device.go
|
||||
type DeviceInfo struct {
|
||||
XMLResponse
|
||||
Name string `xml:"name"`
|
||||
@@ -773,7 +773,7 @@ func main() {
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
app.Run(os.Args)
|
||||
}
|
||||
```
|
||||
@@ -802,4 +802,4 @@ func main() {
|
||||
|
||||
## Conclusion
|
||||
|
||||
This pattern collection enables the development of robust API clients for hardware devices that function both as native tools and as web applications. The combination of Go's type safety, WASM support, and a structured build system makes it possible to use a single codebase for various deployment scenarios.
|
||||
This pattern collection enables the development of robust API clients for hardware devices that function both as native tools and as web applications. The combination of Go's type safety, WASM support, and a structured build system makes it possible to use a single codebase for various deployment scenarios.
|
||||
|
||||
+80
-20
@@ -1,32 +1,92 @@
|
||||
# Bose SoundTouch Toolkit Documentation
|
||||
|
||||
Welcome to the documentation for the Bose SoundTouch Toolkit. This toolkit helps you keep your Bose SoundTouch speakers functional even after the Bose Cloud shutdown in May 2026.
|
||||
Welcome to the documentation for the Bose SoundTouch Toolkit. This comprehensive toolkit helps you keep your Bose SoundTouch speakers functional even after the Bose Cloud shutdown in May 2026, with enhanced local management and monitoring capabilities.
|
||||
|
||||
## 📖 Quick Links
|
||||
## 🚀 Start Here
|
||||
|
||||
- [Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)
|
||||
- [Migration & Safety Guide](guides/MIGRATION-SAFETY.md)
|
||||
- [CLI Reference](guides/CLI-REFERENCE.md)
|
||||
- [Getting Started](guides/GETTING-STARTED.md)
|
||||
- [SoundTouch Service Guide](guides/SOUNDTOUCH-SERVICE.md)
|
||||
### For New Users
|
||||
- **[Complete Migration Guide](guides/MIGRATION-GUIDE.md)** - Step-by-step guide from Bose Cloud to local control
|
||||
- **[Getting Started](guides/GETTING-STARTED.md)** - Quick introduction to the toolkit
|
||||
|
||||
### For Existing Users
|
||||
- **[Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)** - Prepare for the May 2026 shutdown
|
||||
- **[Backup Tool](../cmd/soundtouch-backup/README.md)** - Back up your cloud account and speaker data before shutdown
|
||||
- **[SoundTouch Service Guide](guides/SOUNDTOUCH-SERVICE.md)** - Advanced service configuration
|
||||
|
||||
## 📋 Essential Documentation
|
||||
|
||||
The documentation is organized into three main categories:
|
||||
|
||||
### 1. **User Guides** - For everyday users migrating and managing devices
|
||||
### 2. **Technical Reference** - For developers and advanced configuration
|
||||
### 3. **Concept Documentation** - For contributors and system architects
|
||||
|
||||
## 🗂 Documentation Structure
|
||||
|
||||
### User Guides
|
||||
- [Initial Device Setup](guides/DEVICE-INITIAL-SETUP.md)
|
||||
- [HTTPS Setup](guides/HTTPS-SETUP.md)
|
||||
- [Deployment Guide](guides/DEPLOYMENT.md)
|
||||
- [Raspberry Pi Setup](guides/RASPBERRY-PI.md)
|
||||
- [Troubleshooting](guides/TROUBLESHOOTING.md)
|
||||
## 🗂 User Guides
|
||||
|
||||
### Technical Reference
|
||||
- [API Endpoints](reference/API-ENDPOINTS.md)
|
||||
- [WebSocket Events](reference/WEBSOCKET-EVENTS.md)
|
||||
- [Zone Management](reference/ZONE-MANAGEMENT.md)
|
||||
- [Preset Management](reference/PRESET-MANAGEMENT.md)
|
||||
### Migration & Setup
|
||||
- **[Complete Migration Guide](guides/MIGRATION-GUIDE.md)** - 📖 **Main guide** for migrating from Bose Cloud
|
||||
- [Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md) - Prepare for service shutdown
|
||||
- [Migration & Safety Guide](guides/MIGRATION-SAFETY.md) - Advanced migration strategies
|
||||
- [Initial Device Setup](guides/DEVICE-INITIAL-SETUP.md) - First-time device configuration
|
||||
- [Raspberry Pi Setup](guides/RASPBERRY-PI.md) - Installing on Raspberry Pi
|
||||
|
||||
### Daily Management
|
||||
- [SoundTouch Service Guide](guides/SOUNDTOUCH-SERVICE.md) - Service operation and maintenance
|
||||
- [Troubleshooting](guides/TROUBLESHOOTING.md) - Common issues and solutions
|
||||
- [HTTPS Setup](guides/HTTPS-SETUP.md) - Secure connections
|
||||
- [Deployment Guide](guides/DEPLOYMENT.md) - Production deployments
|
||||
|
||||
### Advanced Features
|
||||
- [MAC Address Mapping](guides/MAC-ADDRESS-MAPPING.md) - Device identification
|
||||
- [CLI Reference](guides/CLI-REFERENCE.md) - Command-line tools
|
||||
- [Backup Tool](../cmd/soundtouch-backup/README.md) - Cloud account and speaker data backup
|
||||
- [IoT Implementation Guide](guides/IOT-IMPLEMENTATION-GUIDE.md) - IoT integrations
|
||||
- [MQTT Integration Design](guides/MQTT-INTEGRATION-DESIGN.md) - MQTT setup
|
||||
|
||||
## 📚 Technical Reference
|
||||
|
||||
### API Documentation
|
||||
- [API Endpoints](reference/API-ENDPOINTS.md) - REST API reference
|
||||
- [Spotify Account Addition](reference/spotify-account-addition.md) - Technical requests for Spotify
|
||||
- [WebSocket Events](reference/WEBSOCKET-EVENTS.md) - Real-time events
|
||||
- [Zone Management](reference/ZONE-MANAGEMENT.md) - Multi-room control
|
||||
- [Preset Management](reference/PRESET-MANAGEMENT.md) - Preset operations
|
||||
|
||||
### Analysis & Research
|
||||
- [Upstream URLs](analysis/UPSTREAM-URLS.md)
|
||||
- [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md)
|
||||
- [Upstream URLs](analysis/UPSTREAM-URLS.md) - Bose service endpoints
|
||||
- [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md) - Migration techniques
|
||||
- [IoT Configuration Analysis](analysis/IOT-CONFIGURATION-ANALYSIS.md) - Device configurations
|
||||
- [IoT Config Summary](analysis/IOT-CONFIG-SUMMARY.md) - Configuration summaries
|
||||
|
||||
### Device Lifecycle & Network Independence
|
||||
- **[Device Lifecycle and /power_on Enhancement](device-lifecycle-and-power-on-enhancement.md)** - Complete analysis of device registration and network independence improvements
|
||||
- [/power_on Implementation Guide](power-on-implementation-guide.md) - Technical implementation details for enhanced device management
|
||||
|
||||
## 🏗 Concept Documentation
|
||||
|
||||
### Enhanced Service Architecture
|
||||
- **[Concept Overview](concepts/README.md)** - High-level architecture vision
|
||||
- [Upstream Service Simulation](concepts/upstream-service-simulation.md) - Complete concept design
|
||||
- [Implementation Plan](concepts/implementation-plan.md) - Development roadmap
|
||||
- [Technical Specification](concepts/technical-specification.md) - Detailed specifications
|
||||
|
||||
### Development Planning
|
||||
- [Implementation Roadmap](concepts/implementation-roadmap.md) - Project phases and milestones
|
||||
|
||||
## 💡 Quick Reference
|
||||
|
||||
### Common Tasks
|
||||
- **Migrate first device**: Follow [Migration Guide Step 5](guides/MIGRATION-GUIDE.md#step-5-migrate-individual-devices)
|
||||
- **Check device health**: Dashboard → Devices → [Device Name] → Health Status
|
||||
- **Backup configuration**: Dashboard → Settings → Backup → Create Backup
|
||||
- **Add new device**: Dashboard → Devices → Discover Devices → Register
|
||||
|
||||
### Getting Help
|
||||
- **Issues & Bugs**: [GitHub Issues](https://github.com/gesellix/Bose-SoundTouch/issues)
|
||||
- **Questions & Discussion**: [GitHub Discussions](https://github.com/gesellix/Bose-SoundTouch/discussions)
|
||||
- **Documentation**: Check troubleshooting guides first
|
||||
- **Community**: Share experiences and help others
|
||||
|
||||
For a complete list of all documents, see the [Summary](SUMMARY.md).
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
# Request Recording Concept
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The current request recording system has fundamental issues when dealing with request cloning, body consumption, and multiple response scenarios. Specifically:
|
||||
|
||||
1. **Body Consumption**: HTTP request bodies can only be read once, leading to missing bodies in recordings
|
||||
2. **Request Cloning**: A single original request may be cloned multiple times for different purposes (local handling, mirroring, recording)
|
||||
3. **Multiple Responses**: The same logical request may generate different responses (local vs upstream mirror)
|
||||
4. **Data Integrity**: No guarantee that recorded requests are identical across different execution paths
|
||||
|
||||
## Current Issues (Examples)
|
||||
|
||||
### Issue 1: Missing Request Bodies in Mirror Recordings
|
||||
|
||||
**Local Recording** (complete):
|
||||
```http
|
||||
### POST /v1/scmudc/A81B6A536A98
|
||||
POST /v1/scmudc/A81B6A536A98
|
||||
Host: events.api.bosecm.com
|
||||
Content-Type: text/json; charset=utf-8
|
||||
Content-Length: 587
|
||||
Authorization: Bearer jGwEmFWr...
|
||||
|
||||
{"envelope":{"monoTime":234906,"payloadProtocolVersion":"3.1","payloadType":"scmudc","protocolVersion":"1.0","time":"2026-02-25T23:03:14.976349+00:00","uniqueId":"A81B6A536A98"},"payload":{"deviceInfo":{"boseID":"3230304","deviceID":"A81B6A536A98","deviceType":"SoundTouch 10","serialNumber":"I6332527703739342000020","softwareVersion":"27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29","systemSerialNumber":"069231P63364828AE"},"events":[{"data":{"play-state":"PAUSE_STATE"},"monoTime":234904,"time":"2026-02-25T23:03:14.973466+00:00","type":"play-state-changed"}]}}
|
||||
|
||||
{% raw %}
|
||||
> {%
|
||||
// Response: 200 OK
|
||||
%}
|
||||
{% endraw %}
|
||||
```
|
||||
|
||||
**Mirror Recording** (missing body):
|
||||
```http
|
||||
### POST /v1/scmudc/A81B6A536A98
|
||||
POST /v1/scmudc/A81B6A536A98
|
||||
Host: events.api.bosecm.com
|
||||
Content-Type: text/json; charset=utf-8
|
||||
Content-Length: 587
|
||||
Authorization: Bearer jGwEmFWr...
|
||||
|
||||
|
||||
|
||||
{% raw %}
|
||||
> {%
|
||||
// Response: 200 OK
|
||||
// Headers:
|
||||
// X-Proxy-Origin: upstream-mirror
|
||||
%}
|
||||
{% endraw %}
|
||||
```
|
||||
|
||||
### Issue 2: Request Flow Complexity
|
||||
|
||||
Current middleware execution order:
|
||||
```
|
||||
1. MirrorMiddleware - Buffers body, creates clones
|
||||
2. RecordMiddleware - Also buffers body
|
||||
3. Application Handler - Processes request
|
||||
4. Mirror Execution - Async/sync mirror to upstream
|
||||
5. Recording - Multiple recording points
|
||||
```
|
||||
|
||||
Problems:
|
||||
- Multiple body reads across middleware chain
|
||||
- Inconsistent request state between clones
|
||||
- Race conditions in async scenarios
|
||||
- No guarantee of request equivalence
|
||||
|
||||
## Proposed Solution: Context-Bound Request Snapshots
|
||||
|
||||
### Core Concept
|
||||
|
||||
Create **immutable request snapshots** early in the request lifecycle and propagate them through the **Request Context**. This ensures all downstream consumers (Mirroring, Recording, Parity Check) use identical data without re-reading the request body.
|
||||
|
||||
### Architecture (Context-Only)
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Original Request│
|
||||
└─────────┬───────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐ ┌──────────────────┐
|
||||
│ Snapshot Creator│───▶│ Request Context │
|
||||
│ (Middleware) │ │ (Pointer-based) │
|
||||
└─────────┬───────┘ └──────────────────┘
|
||||
│ │
|
||||
▼ │ (Safe for async)
|
||||
┌─────────────────┐ │
|
||||
│ Middleware │◀─────────────┘
|
||||
│ Chain │
|
||||
└─────────┬───────┘
|
||||
│
|
||||
┌───▼────┐ ┌─────────┐ ┌──────────────┐
|
||||
│ Local │ │ Mirror │ │ Recording │
|
||||
│Handler │ │Execution│ │ System │
|
||||
└────────┘ └─────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
### Request Snapshot Structure
|
||||
|
||||
```go
|
||||
type RequestSnapshot struct {
|
||||
Method string
|
||||
URL *url.URL
|
||||
Headers http.Header
|
||||
Body []byte
|
||||
Host string
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
// Typed key for context safety
|
||||
type contextKey struct{ name string }
|
||||
var SnapshotKey = &contextKey{"request_snapshot"}
|
||||
```
|
||||
|
||||
### Implementation Strategy
|
||||
|
||||
#### Phase 1: Snapshot Middleware
|
||||
|
||||
```go
|
||||
func (s *Server) SnapshotMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 1. Capture body once with size limit (e.g. 2MB)
|
||||
body, _ := io.ReadAll(io.LimitReader(r.Body, 2*1024*1024))
|
||||
r.Body.Close()
|
||||
|
||||
// 2. Create snapshot
|
||||
snapshot := &RequestSnapshot{
|
||||
Method: r.Method,
|
||||
URL: cloneURL(r.URL),
|
||||
Headers: r.Header.Clone(),
|
||||
Body: body,
|
||||
Host: r.Host,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
// 3. Inject pointer into context
|
||||
ctx := context.WithValue(r.Context(), SnapshotKey, snapshot)
|
||||
|
||||
// 4. Restore r.Body for downstream compatibility
|
||||
r = r.WithContext(ctx)
|
||||
r.Body = io.NopCloser(bytes.NewReader(snapshot.Body))
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
#### Phase 2: Downstream Consumption
|
||||
|
||||
Consumers (Mirror/Record) retrieve the snapshot directly from context:
|
||||
|
||||
```go
|
||||
snapshot, ok := r.Context().Value(SnapshotKey).(*RequestSnapshot)
|
||||
if ok {
|
||||
// Use snapshot.Body directly instead of io.ReadAll(r.Body)
|
||||
}
|
||||
```
|
||||
|
||||
## Hardware Considerations (Raspberry Pi Zero 2W)
|
||||
|
||||
To protect MicroSD health and optimize for limited memory:
|
||||
|
||||
1. **No Intermediate Disk Storage**: Snapshots exist only in memory; they are never written to disk until the final `.http` recording is generated.
|
||||
2. **Memory Management**: Use `sync.Pool` for temporary buffers to reduce GC churn on the single-core/low-memory SoC.
|
||||
3. **Automatic Cleanup**: Snapshots are naturally garbage collected once the Request Context and all child goroutines (detached mirrors/recordings) finish.
|
||||
4. **Body Capping**: Strict limits on snapshot size prevent OOM (Out-of-Memory) conditions.
|
||||
|
||||
#### Phase 2: Response Capture System
|
||||
|
||||
```go
|
||||
type ResponseRecorder struct {
|
||||
http.ResponseWriter
|
||||
snapshot *ResponseSnapshot
|
||||
snapshotID string
|
||||
source string
|
||||
startTime time.Time
|
||||
}
|
||||
|
||||
func (r *ResponseRecorder) WriteHeader(statusCode int) {
|
||||
r.snapshot.StatusCode = statusCode
|
||||
r.snapshot.Headers = r.Header().Clone()
|
||||
r.ResponseWriter.WriteHeader(statusCode)
|
||||
}
|
||||
|
||||
func (r *ResponseRecorder) Write(data []byte) (int, error) {
|
||||
r.snapshot.Body = append(r.snapshot.Body, data...)
|
||||
return r.ResponseWriter.Write(data)
|
||||
}
|
||||
|
||||
func (r *ResponseRecorder) finalize() {
|
||||
r.snapshot.Duration = time.Since(r.startTime)
|
||||
r.snapshot.Timestamp = time.Now()
|
||||
}
|
||||
```
|
||||
|
||||
#### Phase 3: Recording System Integration
|
||||
|
||||
```go
|
||||
type RecordingManager struct {
|
||||
storage SnapshotStorage
|
||||
recorder *Recorder
|
||||
patterns []string
|
||||
}
|
||||
|
||||
func (rm *RecordingManager) RecordInteraction(snapshotID string, response *ResponseSnapshot) {
|
||||
// Retrieve immutable request snapshot
|
||||
request, exists := rm.storage.Get(snapshotID)
|
||||
if !exists {
|
||||
log.Printf("Request snapshot not found: %s", snapshotID)
|
||||
return
|
||||
}
|
||||
|
||||
// Record with guaranteed data integrity
|
||||
rm.recorder.RecordInteraction(request, response)
|
||||
}
|
||||
|
||||
func (r *Recorder) RecordInteraction(req *RequestSnapshot, res *ResponseSnapshot) error {
|
||||
// Generate .http file with complete data
|
||||
var buf bytes.Buffer
|
||||
|
||||
// Write request
|
||||
fmt.Fprintf(&buf, "### %s %s\n", req.Method, req.URL.String())
|
||||
fmt.Fprintf(&buf, "%s %s\n", req.Method, req.URL.String())
|
||||
fmt.Fprintf(&buf, "Host: %s\n", req.Host)
|
||||
|
||||
for k, vv := range req.Headers {
|
||||
for _, v := range vv {
|
||||
fmt.Fprintf(&buf, "%s: %s\n", k, v)
|
||||
}
|
||||
}
|
||||
|
||||
buf.WriteString("\n")
|
||||
buf.Write(req.Body)
|
||||
buf.WriteString("\n\n")
|
||||
|
||||
// Write response
|
||||
{% raw %}
|
||||
buf.WriteString("> {% \n")
|
||||
{% endraw %}
|
||||
fmt.Fprintf(&buf, " // Response: %d %s\n", res.StatusCode, http.StatusText(res.StatusCode))
|
||||
buf.WriteString(" // Headers:\n")
|
||||
|
||||
for k, vv := range res.Headers {
|
||||
for _, v := range vv {
|
||||
fmt.Fprintf(&buf, " // %s: %s\n", k, v)
|
||||
}
|
||||
}
|
||||
|
||||
{% raw %}
|
||||
buf.WriteString("%}\n\n")
|
||||
{% endraw %}
|
||||
|
||||
if len(res.Body) > 0 {
|
||||
buf.WriteString("/*\n")
|
||||
buf.Write(res.Body)
|
||||
buf.WriteString("\n*/\n")
|
||||
} else {
|
||||
buf.WriteString("// [Binary response body: 0 bytes]\n")
|
||||
}
|
||||
|
||||
// Write to file
|
||||
return r.writeToFile(buf.Bytes(), req, res)
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Introduce Snapshot System
|
||||
- Add SnapshotMiddleware as first middleware
|
||||
- Maintain existing recording system for compatibility
|
||||
- Gradual migration of recording points
|
||||
|
||||
### Phase 2: Update Mirror System
|
||||
- Modify MirrorMiddleware to use snapshots
|
||||
- Ensure mirror requests use snapshot data
|
||||
- Test parity between old and new systems
|
||||
|
||||
### Phase 3: Consolidate Recording
|
||||
- Replace existing recording middleware
|
||||
- Unified recording system using context-bound snapshots
|
||||
- Remove duplicate body reading code
|
||||
|
||||
### Phase 4: Cleanup
|
||||
- Remove legacy recording code
|
||||
- Optimize memory usage with sync.Pool
|
||||
- Performance validation on target hardware (Pi Zero)
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Zero Extra Disk IO**: Protecs MicroSD by avoiding snapshot disk persistence
|
||||
2. **Memory Efficiency**: Natural lifecycle tied to Request Context
|
||||
3. **Data Integrity**: Request data is captured once and remains immutable
|
||||
4. **Consistency**: All consumers use identical request data
|
||||
5. **Traceability**: Clear lineage from original request to all recordings
|
||||
6. **Performance**: Reduces duplicate body reads and re-cloning
|
||||
|
||||
## Implementation Considerations
|
||||
|
||||
### Memory Management
|
||||
- Use `sync.Pool` for byte buffers
|
||||
- Strict size limits on captured bodies
|
||||
- Rely on GC for snapshot cleanup
|
||||
|
||||
### Performance Impact
|
||||
- Single body read vs multiple reads (net positive)
|
||||
- Memory overhead for snapshot storage (manageable)
|
||||
- Context propagation overhead (minimal)
|
||||
|
||||
### Backward Compatibility
|
||||
- Maintain existing .http file format
|
||||
- Preserve existing API contracts
|
||||
- Gradual migration path
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- Snapshot creation and immutability
|
||||
- Response recording accuracy
|
||||
- Memory cleanup verification
|
||||
|
||||
### Integration Tests
|
||||
- End-to-end request/response recording
|
||||
- Mirror functionality with snapshots
|
||||
- Parity validation between old/new systems
|
||||
|
||||
### Performance Tests
|
||||
- Memory usage comparison
|
||||
- Throughput impact analysis
|
||||
- Large request body handling
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Compression**: Compress stored snapshots for memory efficiency
|
||||
2. **Streaming**: Support for streaming request/response bodies
|
||||
3. **Filtering**: Selective snapshot creation based on patterns
|
||||
4. **Analytics**: Request/response analysis and metrics
|
||||
5. **Export**: Snapshot export for debugging and analysis
|
||||
|
||||
## Conclusion
|
||||
|
||||
This snapshot-based approach provides a robust foundation for reliable request recording while solving the current issues with body consumption and data inconsistency. The phased implementation ensures minimal disruption while delivering immediate benefits.
|
||||
@@ -0,0 +1,192 @@
|
||||
# SCMUDC Enrichment Implementation Summary
|
||||
|
||||
## Overview
|
||||
|
||||
This document summarizes the implementation of SCMUDC (Sound Control Management Usage Data Collection) event enrichment in the AfterTouch toolkit. The enhancement provides human-readable analysis of device telemetry data to improve usability and debugging capabilities.
|
||||
|
||||
## Problem Solved
|
||||
|
||||
Previously, SCMUDC telemetry events were stored as raw JSON with Base64-encoded XML content, making them difficult to analyze. Users had to manually decode content to understand what device interactions were being recorded.
|
||||
|
||||
## Solution Implemented
|
||||
|
||||
### 1. Backend Enrichment (`pkg/service/proxy/`)
|
||||
|
||||
#### New File: `scmudc.go`
|
||||
- **SCMUDCRequest/SCMUDCEvent Structs**: Parse incoming telemetry JSON
|
||||
- **EnrichedSCMUDCEvent Struct**: Human-readable analysis with decoded content
|
||||
- **DecodedContent Struct**: Parsed XML metadata (track names, artwork URLs, etc.)
|
||||
- **enrichSCMUDCRequest()**: Main enrichment function that:
|
||||
- Identifies event origin (app, hardware, or internal system)
|
||||
- Decodes Base64 XML content for device events
|
||||
- Creates human-readable summaries
|
||||
- **Helper Functions**: Button formatting, content summarization, origin descriptions
|
||||
|
||||
#### Enhanced File: `recorder.go`
|
||||
- **Updated save() method**: Extracts SCMUDC data during recording
|
||||
- **New writeRequestWithEnrichment()**: Adds enriched comments to .http files
|
||||
- **New writeResponseWithEnrichment()**: Includes SCMUDC analysis in response section
|
||||
- **Updated Interaction struct**: Added `SCMUDCData` field for API responses
|
||||
- **New extractSCMUDCFromFile()**: Parses enrichment data from existing .http files
|
||||
- **Enhanced parseInteractionFile()**: Populates SCMUDC data when listing interactions
|
||||
|
||||
### 2. Frontend Enhancement
|
||||
|
||||
#### Updated HTML (`pkg/service/handlers/web/index.html`)
|
||||
- **New Column**: Added "Event Details" to interactions table
|
||||
- **Table Structure**: Updated to accommodate SCMUDC enrichment display
|
||||
|
||||
#### Enhanced JavaScript (`pkg/service/handlers/web/js/script.js`)
|
||||
- **Updated fetchInteractions()**: Displays enriched SCMUDC data with icons
|
||||
- **New Helper Functions**:
|
||||
- `getOriginIcon()`: Maps origins to emojis (📱 App, 🎛️ Hardware, 🔄 Internal)
|
||||
- `getActionIcon()`: Maps actions to emojis (▶️ Play, ⏸️ Pause, etc.)
|
||||
- `showSCMUDCDetails()`: Detailed popover for complex events
|
||||
- `displaySCMUDCPopover()`: Modal dialog with full decoded content
|
||||
- **Truncation Logic**: Long content shows "(...)" with click-to-expand
|
||||
|
||||
## Event Origin Clarification
|
||||
|
||||
Based on analysis of recorded data:
|
||||
|
||||
| Origin | Source | Description | Example Events |
|
||||
|--------|--------|-------------|----------------|
|
||||
| `gabbo` | **SoundTouch App** | Mobile/desktop app UI interactions | Play, Pause, Power via app |
|
||||
| `console` | **Device Hardware** | Physical buttons on speaker | Preset buttons, hardware power |
|
||||
| `device` | **Internal System** | Automatic device responses | Content playback, system actions |
|
||||
|
||||
## Enhanced .http File Format
|
||||
|
||||
### Before (Raw)
|
||||
```http
|
||||
### POST /v1/scmudc/A81B6A536A98
|
||||
POST /v1/scmudc/A81B6A536A98
|
||||
Host: events.api.bosecm.com
|
||||
...
|
||||
|
||||
{"envelope":...,"payload":{"events":[{"data":{"contentItem":"PD94bWw..."}}]}}
|
||||
```
|
||||
|
||||
### After (Enriched)
|
||||
```http
|
||||
### POST /v1/scmudc/A81B6A536A98
|
||||
// Origin: Internal System (device)
|
||||
// Action: play-item
|
||||
// Command: Billie Eilish - bad guy (instrumental version)
|
||||
// Summary: Device: Spotify: Billie Eilish - bad guy (instrumental version)
|
||||
//
|
||||
// Decoded Content:
|
||||
// - Source: SPOTIFY
|
||||
// - Item: Billie Eilish - bad guy (instrumental version)
|
||||
// - Account: gesellix
|
||||
// - Artwork: https://i.scdn.co/image/ab67616d0000b273...
|
||||
//
|
||||
// Full XML Content:
|
||||
// <?xml version="1.0" encoding="UTF-8"?>
|
||||
// <ContentItem source="SPOTIFY" type="tracklisturl" ...>
|
||||
// <itemName>Billie Eilish - bad guy (instrumental version)</itemName>
|
||||
// <containerArt>https://i.scdn.co/image/ab67616d0000b273...</containerArt>
|
||||
// </ContentItem>
|
||||
POST /v1/scmudc/A81B6A536A98
|
||||
...
|
||||
|
||||
{% raw %}
|
||||
> {%
|
||||
// Response: 200 OK
|
||||
// SCMUDC Event Analysis:
|
||||
// - Origin: Internal System (device)
|
||||
// - Action: play-item
|
||||
// - Summary: Device: Spotify: Billie Eilish - bad guy (instrumental version)
|
||||
// - Content: Billie Eilish - bad guy (instrumental version)
|
||||
// - Account: gesellix
|
||||
%}
|
||||
{% endraw %}
|
||||
```
|
||||
|
||||
## Web UI Enhancement
|
||||
|
||||
### Interactions Table
|
||||
- **New Column**: "Event Details" shows enriched summaries
|
||||
- **Visual Icons**: Origin and action type indicators
|
||||
- **Truncation**: Long content abbreviated with "(...)" expansion
|
||||
- **Backward Compatibility**: Works with existing recordings
|
||||
|
||||
### Event Details Display
|
||||
```
|
||||
📱 ▶️ Play Button (Simple app action)
|
||||
🔄 🎵 Billie Eilish - bad guy... (...) (Complex device event with details)
|
||||
🎛️ ⭐ Preset 5 (Hardware preset button)
|
||||
```
|
||||
|
||||
### Detailed Popover
|
||||
For complex events, clicking "(...)" shows:
|
||||
- **Origin Description**: "SoundTouch App" instead of "gabbo"
|
||||
- **Full Content Information**: Track names, artwork URLs, account details
|
||||
- **Complete XML**: Formatted and readable content item data
|
||||
|
||||
## Implementation Benefits
|
||||
|
||||
### For Users
|
||||
- **Immediate Recognition**: See what actions were performed without decoding
|
||||
- **Better Debugging**: Quick identification of app vs. hardware vs. system events
|
||||
- **Rich Context**: Track names, accounts, and content sources visible at a glance
|
||||
|
||||
### For Developers
|
||||
- **Structured Data**: Consistent parsing and enrichment pipeline
|
||||
- **Extensible**: Easy to add new event types and origins
|
||||
- **Backward Compatible**: Existing recordings work without re-processing
|
||||
|
||||
### For Analysis
|
||||
- **Pattern Recognition**: Quickly identify user behavior patterns
|
||||
- **Service Integration**: See which music services are being used
|
||||
- **Device Usage**: Understand app vs. hardware control preferences
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
pkg/service/proxy/
|
||||
├── scmudc.go # New: SCMUDC enrichment logic
|
||||
├── recorder.go # Enhanced: Enrichment integration
|
||||
│
|
||||
pkg/service/handlers/web/
|
||||
├── index.html # Enhanced: New table column
|
||||
├── js/script.js # Enhanced: SCMUDC display logic
|
||||
│
|
||||
docs/
|
||||
├── scmudc-events-analysis.md # New: Analysis documentation
|
||||
├── SCMUDC-ENRICHMENT-IMPLEMENTATION.md # This file
|
||||
```
|
||||
|
||||
## Technical Decisions
|
||||
|
||||
### Base64 Decoding Strategy
|
||||
- **When**: During recording (not on-demand) for performance
|
||||
- **Fallback**: Parse from .http files if enrichment missing
|
||||
- **Storage**: Both enriched comments and structured data in API responses
|
||||
|
||||
### Icon Selection
|
||||
- **Emoji Usage**: Universal, colorful, intuitive recognition
|
||||
- **Semantic Mapping**: Icons match function (📱 for app, 🎛️ for hardware)
|
||||
- **Fallback**: Generic icons (❓, 🔘) for unknown types
|
||||
|
||||
### Backward Compatibility
|
||||
- **Graceful Degradation**: Missing enrichment data doesn't break UI
|
||||
- **File Parsing**: Extract enrichment from existing .http files
|
||||
- **API Enhancement**: New fields optional in Interaction struct
|
||||
|
||||
## Future Enhancement Opportunities
|
||||
|
||||
1. **Event Correlation**: Link device events to user actions
|
||||
2. **Statistics Dashboard**: Origin-based usage analytics
|
||||
3. **Content Recommendations**: Track listening patterns
|
||||
4. **Device Health**: Monitor interaction frequency and patterns
|
||||
5. **Export Features**: CSV/JSON export of enriched event data
|
||||
|
||||
## Testing Considerations
|
||||
|
||||
- **Edge Cases**: Malformed Base64, missing XML elements
|
||||
- **Performance**: Large numbers of SCMUDC events
|
||||
- **Browser Compatibility**: Emoji display across different browsers
|
||||
- **Data Validation**: Ensure enrichment doesn't introduce errors
|
||||
|
||||
This implementation significantly improves the usability of SCMUDC telemetry data while maintaining full backward compatibility and raw data access for advanced users.
|
||||
@@ -4,15 +4,25 @@
|
||||
|
||||
## User Guides
|
||||
* [Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)
|
||||
* [Self-Hosting AfterTouch](guides/SELF-HOSTING.md)
|
||||
* [Connecting Music Services](guides/MUSIC-SERVICES.md)
|
||||
* [Migration & Safety Guide](guides/MIGRATION-SAFETY.md)
|
||||
* [CLI Reference](guides/CLI-REFERENCE.md)
|
||||
* [Backup Tool](../cmd/soundtouch-backup/README.md)
|
||||
* [Getting Started](guides/GETTING-STARTED.md)
|
||||
* [SoundTouch Service](guides/SOUNDTOUCH-SERVICE.md)
|
||||
* [Initial Device Setup](guides/DEVICE-INITIAL-SETUP.md)
|
||||
* [Capture Device Pairing Traffic](guides/CAPTURE-DEVICE-PAIRING.md)
|
||||
* [Capture Migration Traffic](guides/CAPTURE-MIGRATION-TRAFFIC.md)
|
||||
* [Device Setup Flow](DEVICE-SETUP.md)
|
||||
* [MAC Address Mapping](guides/MAC-ADDRESS-MAPPING.md)
|
||||
* [HTTPS Setup](guides/HTTPS-SETUP.md)
|
||||
* [Deployment](guides/DEPLOYMENT.md)
|
||||
* [Raspberry Pi Guide](guides/RASPBERRY-PI.md)
|
||||
* [Troubleshooting](guides/TROUBLESHOOTING.md)
|
||||
* [IoT Implementation Guide](guides/IOT-IMPLEMENTATION-GUIDE.md)
|
||||
* [Migration Guide](guides/MIGRATION-GUIDE.md)
|
||||
* [MQTT Integration Design](guides/MQTT-INTEGRATION-DESIGN.md)
|
||||
* [Useful Links](#useful-links)
|
||||
|
||||
### Useful Links
|
||||
@@ -24,10 +34,12 @@
|
||||
## Technical Reference
|
||||
* [API Cookbook](reference/API-COOKBOOK.md)
|
||||
* [API Endpoints](reference/API-ENDPOINTS.md)
|
||||
* [Spotify Account Addition](reference/spotify-account-addition.md)
|
||||
* [Cloud API Emulation](reference/CLOUD-API.md)
|
||||
* [System Endpoints](reference/SYSTEM-ENDPOINTS.md)
|
||||
* [Speaker Endpoint](reference/SPEAKER-ENDPOINT.md)
|
||||
* [WebSocket Events](reference/WEBSOCKET-EVENTS.md)
|
||||
* [Device Pairing Flow](reference/DEVICE-PAIRING-FLOW.md)
|
||||
* [Discovery](reference/DISCOVERY.md)
|
||||
* [Zone Management](reference/ZONE-MANAGEMENT.md)
|
||||
* [Preset Management](reference/PRESET-MANAGEMENT.md)
|
||||
@@ -39,6 +51,7 @@
|
||||
* [Feature Mapping](reference/FEATURE-MAPPING.md)
|
||||
|
||||
## Concepts
|
||||
* [Request Recording](REQUEST_RECORDING_CONCEPT.md)
|
||||
* [Spotify Priming Strategy](concepts/spotify-priming-strategy.md)
|
||||
* [Spotify OAuth](concepts/spotify-oauth.md)
|
||||
|
||||
@@ -49,8 +62,20 @@
|
||||
* [Anonymization Summary](analysis/ANONYMIZATION-SUMMARY.md)
|
||||
* [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md)
|
||||
* [Wiki API Comparison](analysis/WIKI-COMPARISON.md)
|
||||
* [IoT Config Summary](analysis/IOT-CONFIG-SUMMARY.md)
|
||||
* [IoT Configuration Analysis](analysis/IOT-CONFIGURATION-ANALYSIS.md)
|
||||
* [Bose Lab Runbook](analysis/BOSE-LAB-RUNBOOK.md)
|
||||
* [Missing Routes Spotify](analysis/MISSING-ROUTES-SPOTIFY.md)
|
||||
* [Bose App ADB Emulator](analysis/BOSE-APP-ADB-Emulator.md)
|
||||
* [Community Tools](analysis/bose-soundtouch-community-tools.md)
|
||||
|
||||
## Parity Analysis
|
||||
* [Parity Improvements](PARITY-IMPROVEMENTS.md)
|
||||
* [Parity SoundCork](PARITY-SOUNDCORK.md)
|
||||
* [Parity OpenCloudTouch](PARITY-OPENCLOUDTOUCH.md)
|
||||
|
||||
## Appendix (Other Documents)
|
||||
* [External Services Abstraction](EXTERNAL-SERVICES-ABSTRACTION.md)
|
||||
* [API Navigation Reference](API-NAVIGATION-REFERENCE.md)
|
||||
* [Claude Instructions](CLAUDE.md)
|
||||
* [Content Selection Implementation](CONTENT-SELECTION-IMPLEMENTATION.md)
|
||||
@@ -68,3 +93,10 @@
|
||||
* [Undocumented Community Features](UNDOCUMENTED-COMMUNITY-FEATURES.md)
|
||||
* [Unimplemented Endpoints](UNIMPLEMENTED-ENDPOINTS.md)
|
||||
* [Preset Store](preset-store.md)
|
||||
* [SCMUDC Enrichment Implementation](SCMUDC-ENRICHMENT-IMPLEMENTATION.md)
|
||||
* [Device Lifecycle and Power On Enhancement](device-lifecycle-and-power-on-enhancement.md)
|
||||
* [Device Lifecycle Summary](device-lifecycle-summary.md)
|
||||
* [Power On Implementation Guide](power-on-implementation-guide.md)
|
||||
* [SCMUDC Events Analysis](scmudc-events-analysis.md)
|
||||
* [Parity Improvements](PARITY-IMPROVEMENTS.md)
|
||||
* [Parity SoundCork](PARITY-SOUNDCORK.md)
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
# Bose SoundTouch Traffic Interception Runbook
|
||||
|
||||
Intercept HTTPS/WebSocket traffic from the Bose SoundTouch Android app using an Android emulator, mitmproxy, and Frida. Tested on Apple Silicon (ARM64) Mac.
|
||||
|
||||
## Automated Setup
|
||||
|
||||
The steps in this document are scripted for reproducibility:
|
||||
|
||||
```bash
|
||||
scripts/android/setup-mitm-avd.sh # one-time: create AVD, install cert & APK, save snapshot
|
||||
scripts/android/start-mitm-session.sh # per-session: restore snapshot, refresh proxy, start frida-server
|
||||
```
|
||||
|
||||
Read on for the full manual walkthrough and the rationale behind each step.
|
||||
|
||||
> **Note:** The manual steps below use `/tmp/` for intermediate files and reflect the original approach. The automated scripts supersede them — use the scripts for day-to-day use and refer here only to understand how things work.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Android Studio installed (for SDK tools and emulator)
|
||||
- Docker installed
|
||||
- mitmproxy installed (`pip install mitmproxy` or via your preferred method)
|
||||
- The Bose SoundTouch APK (extracted from a real device, see below)
|
||||
|
||||
> **BLE limitation**: Android emulators do not expose Bluetooth hardware. The Bose app's default setup path (BLE Wi-Fi provisioning) therefore cannot be used to configure a factory-reset speaker from the emulator. Use **AP mode** instead: provision the speaker's Wi-Fi credentials via the Mac command line first (see [DEVICE-INITIAL-SETUP.md § 6](../guides/DEVICE-INITIAL-SETUP.md)), then the app can discover the already-networked speaker via mDNS/SSDP without BLE.
|
||||
|
||||
> **Emulator ↔ local network**: The emulator routes all traffic through the Mac's active network interface. Once the speaker is on the same LAN as the Mac, the emulator can reach it at its normal LAN IP (e.g. `192.168.1.50`) — no extra routing is needed. Use `adb shell ping 192.168.1.50` to confirm reachability.
|
||||
|
||||
Add Android SDK tools to your PATH (add to `~/.zshrc`):
|
||||
|
||||
```bash
|
||||
export PATH=$PATH:~/Library/Android/sdk/emulator
|
||||
export PATH=$PATH:~/Library/Android/sdk/platform-tools
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Extract APK from Real Device
|
||||
|
||||
Connect your Android device via USB with USB debugging enabled.
|
||||
|
||||
```bash
|
||||
adb devices
|
||||
# note your device ID, e.g. "ABC123"
|
||||
|
||||
adb -s ABC123 shell pm path com.bose.soundtouch
|
||||
# output e.g.: package:/data/app/~~xyz/com.bose.soundtouch-abc/base.apk
|
||||
|
||||
adb -s ABC123 pull /data/app/~~xyz/com.bose.soundtouch-abc/base.apk bose.apk
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Create Android Emulator (ARM64, API 33)
|
||||
|
||||
On Apple Silicon you need an ARM64 image. Use the `avdmanager` and `sdkmanager` CLI tools.
|
||||
|
||||
```bash
|
||||
# Install the system image
|
||||
~/Library/Android/sdk/cmdline-tools/latest/bin/sdkmanager \
|
||||
"system-images;android-33;google_apis;arm64-v8a"
|
||||
|
||||
# Create the AVD
|
||||
~/Library/Android/sdk/cmdline-tools/latest/bin/avdmanager create avd \
|
||||
-n Pixel_6_API33 \
|
||||
-k "system-images;android-33;google_apis;arm64-v8a" \
|
||||
-d "pixel_6"
|
||||
```
|
||||
|
||||
Alternatively create the AVD via Android Studio Device Manager (choose "Google APIs", arm64-v8a, API 33).
|
||||
|
||||
---
|
||||
|
||||
## 3. Start Emulator with Writable System
|
||||
|
||||
```bash
|
||||
# List available AVDs
|
||||
~/Library/Android/sdk/emulator/emulator -list-avds
|
||||
|
||||
# Start with writable system partition
|
||||
~/Library/Android/sdk/emulator/emulator -avd Pixel_6_API33 -writable-system
|
||||
```
|
||||
|
||||
Wait until the emulator has fully booted, then:
|
||||
|
||||
```bash
|
||||
adb -s emulator-5554 root
|
||||
adb -s emulator-5554 shell avbctl disable-verification
|
||||
adb -s emulator-5554 reboot
|
||||
|
||||
# After reboot:
|
||||
adb -s emulator-5554 root
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Install Bose APK
|
||||
|
||||
```bash
|
||||
adb -s emulator-5554 install bose.apk
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Set Up mitmproxy
|
||||
|
||||
```bash
|
||||
# Start mitmproxy (generates CA cert on first run)
|
||||
# Use the native macOS app — Docker mitmproxy does not work (NAT blocks emulator traffic)
|
||||
mitmweb --listen-port 8080 --mode regular -w bose_traffic.mitm
|
||||
```
|
||||
|
||||
Extract the CA certificate (without private key):
|
||||
|
||||
```bash
|
||||
openssl x509 -in ~/.mitmproxy/mitmproxy-ca.pem -out ~/.mitmproxy/mitmproxy-ca-cert.pem
|
||||
|
||||
# Verify it's the mitmproxy cert, not another cert:
|
||||
openssl x509 -in ~/.mitmproxy/mitmproxy-ca-cert.pem -noout -issuer
|
||||
# should show: issuer= /CN=mitmproxy/O=mitmproxy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Install mitmproxy CA Certificate in Emulator
|
||||
|
||||
```bash
|
||||
HASH=$(openssl x509 -inform PEM -subject_hash_old \
|
||||
-in ~/.mitmproxy/mitmproxy-ca-cert.pem | head -1)
|
||||
|
||||
adb -s emulator-5554 push ~/.mitmproxy/mitmproxy-ca-cert.pem /data/local/tmp/mitmproxy.pem
|
||||
|
||||
adb -s emulator-5554 shell su 0 mkdir -p /data/misc/user/0/cacerts-added
|
||||
|
||||
adb -s emulator-5554 shell su 0 \
|
||||
cp /data/local/tmp/mitmproxy.pem /data/misc/user/0/cacerts-added/${HASH}.0
|
||||
|
||||
adb -s emulator-5554 shell su 0 \
|
||||
chmod 644 /data/misc/user/0/cacerts-added/${HASH}.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Set System Proxy in Emulator
|
||||
|
||||
Find your Mac's local IP:
|
||||
|
||||
```bash
|
||||
ipconfig getifaddr en0
|
||||
# e.g. 192.168.1.123
|
||||
```
|
||||
|
||||
Set the proxy:
|
||||
|
||||
```bash
|
||||
adb -s emulator-5554 shell settings put global http_proxy 192.168.1.123:8080
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Set Up Frida (via Python venv)
|
||||
|
||||
```bash
|
||||
python3 -m venv /tmp/frida-venv
|
||||
/tmp/frida-venv/bin/pip install frida==17.9.1 frida-tools==14.8.1
|
||||
```
|
||||
|
||||
Download the frida-server binary for ARM64 Android:
|
||||
|
||||
```bash
|
||||
FRIDA_VERSION=17.9.1
|
||||
|
||||
curl -L "https://github.com/frida/frida/releases/download/${FRIDA_VERSION}/frida-server-${FRIDA_VERSION}-android-arm64.xz" \
|
||||
-o /tmp/frida-server.xz
|
||||
|
||||
unxz /tmp/frida-server.xz
|
||||
mv /tmp/frida-server-${FRIDA_VERSION}-android-arm64 /tmp/frida-server
|
||||
```
|
||||
|
||||
Push to emulator and start:
|
||||
|
||||
```bash
|
||||
adb -s emulator-5554 push /tmp/frida-server /data/local/tmp/frida-server
|
||||
adb -s emulator-5554 shell su 0 chmod 755 /data/local/tmp/frida-server
|
||||
adb -s emulator-5554 shell su 0 /data/local/tmp/frida-server &
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Download SSL Bypass Scripts
|
||||
|
||||
```bash
|
||||
BASE=https://raw.githubusercontent.com/httptoolkit/frida-interception-and-unpinning/main
|
||||
|
||||
curl -L "${BASE}/config.js" -o /tmp/config.js
|
||||
curl -L "${BASE}/android/android-system-certificate-injection.js" \
|
||||
-o /tmp/android-system-certificate-injection.js
|
||||
curl -L "${BASE}/android/android-proxy-override.js" \
|
||||
-o /tmp/android-proxy-override.js
|
||||
curl -L "${BASE}/android/android-certificate-unpinning.js" \
|
||||
-o /tmp/android-certificate-unpinning.js
|
||||
curl -L "${BASE}/android/android-certificate-unpinning-fallback.js" \
|
||||
-o /tmp/android-certificate-unpinning-fallback.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Configure config.js
|
||||
|
||||
Edit `/tmp/config.js` and set:
|
||||
|
||||
```javascript
|
||||
const CERT_PEM = `<contents of ~/.mitmproxy/mitmproxy-ca-cert.pem>`;
|
||||
|
||||
const PROXY_HOST = '192.168.1.123'; // your Mac IP
|
||||
const PROXY_PORT = 8080;
|
||||
```
|
||||
|
||||
Insert the full PEM content (from `-----BEGIN CERTIFICATE-----` to `-----END CERTIFICATE-----`) between the backticks.
|
||||
|
||||
Quick check that the right cert is in place:
|
||||
|
||||
```bash
|
||||
# The issuer inside config.js should be mitmproxy, not SoundTouch
|
||||
grep -A3 "CERT_PEM" /tmp/config.js | head -5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Start Interception
|
||||
|
||||
Make sure mitmweb is running, then:
|
||||
|
||||
```bash
|
||||
scripts/android/frida-venv/bin/frida \
|
||||
-U \
|
||||
-f com.bose.soundtouch \
|
||||
-l scripts/android/frida/config.js \
|
||||
-l scripts/android/frida/native-connect-hook.js \
|
||||
-l scripts/android/frida/android/android-system-certificate-injection.js \
|
||||
-l scripts/android/frida/android/android-proxy-override.js \
|
||||
-l scripts/android/frida/android/android-certificate-unpinning.js \
|
||||
-l scripts/android/frida/android/android-certificate-unpinning-fallback.js
|
||||
```
|
||||
|
||||
> `native-connect-hook.js` is required — the Bose app uses native networking that bypasses Java proxy settings.
|
||||
|
||||
Expected output in the Frida REPL:
|
||||
|
||||
```
|
||||
== System certificate trust injected ==
|
||||
== Proxy system configuration overridden to 192.168.1.123:8080 ==
|
||||
== Proxy configuration overridden to 192.168.1.123:8080 ==
|
||||
== Certificate unpinning completed ==
|
||||
== Unpinning fallback auto-patcher installed ==
|
||||
```
|
||||
|
||||
Open mitmweb at `http://127.0.0.1:8081` to observe traffic live.
|
||||
|
||||
---
|
||||
|
||||
## 12. Save & Replay Recordings
|
||||
|
||||
Traffic is saved to `bose_traffic.mitm` (set via `-w` flag in step 5).
|
||||
|
||||
```bash
|
||||
# Replay/analyse a saved recording:
|
||||
mitmweb -r bose_traffic.mitm
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cleanup
|
||||
|
||||
```bash
|
||||
# Remove proxy setting from emulator
|
||||
adb -s emulator-5554 shell settings delete global http_proxy
|
||||
|
||||
# Remove venv
|
||||
rm -rf /tmp/frida-venv /tmp/frida-server /tmp/frida-server.xz
|
||||
rm /tmp/config.js /tmp/android-*.js
|
||||
|
||||
# Stop emulator
|
||||
adb -s emulator-5554 emu kill
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|-----------------------------------------|--------------------------------------------------|--------------------------------------------------------------------------------|
|
||||
| `remount failed` | ARM64 emulator doesn't support overlayfs remount | Use `/data/misc/user/0/cacerts-added/` method instead |
|
||||
| `TLS: Trust anchor not found` | Wrong certificate in config.js | Check issuer: must be mitmproxy, not SoundTouch |
|
||||
| `Chain validation failed` | Private key included in cert | Re-extract with `openssl x509 -in mitmproxy-ca.pem -out mitmproxy-ca-cert.pem` |
|
||||
| `frida-server: connection refused` | frida-server not running | Re-run `adb shell su 0 /data/local/tmp/frida-server &` |
|
||||
| frida and frida-server version mismatch | Versions must be identical | Pin both to same version (e.g. `17.9.1`) |
|
||||
| `emulator: multiple AVDs` error | Emulator already running | Kill first: `adb emu kill`, then restart with `-writable-system` |
|
||||
|
||||
---
|
||||
|
||||
## App Automation Options
|
||||
|
||||
For most traffic-recording purposes, manually operating the app while mitmproxy captures is sufficient. If you need to automate specific interactions (e.g. to repeatably capture the requests triggered by startup or a particular action), the following tools are available.
|
||||
|
||||
### Starting the App
|
||||
|
||||
```bash
|
||||
# Via app drawer: swipe up on the home screen and tap "Bose SoundTouch"
|
||||
|
||||
# Via adb monkey (simplest)
|
||||
adb -s emulator-5554 shell monkey -p com.bose.soundtouch 1
|
||||
|
||||
# Via explicit intent (if the activity name is known)
|
||||
adb -s emulator-5554 shell am start -n com.bose.soundtouch/.MainActivity
|
||||
|
||||
# Look up all activities if the name is unknown
|
||||
adb -s emulator-5554 shell dumpsys package com.bose.soundtouch | grep Activity
|
||||
```
|
||||
|
||||
### adb — sufficient for simple cases
|
||||
|
||||
```bash
|
||||
# Tap at screen coordinates
|
||||
adb shell input tap 540 960
|
||||
|
||||
# Swipe
|
||||
adb shell input swipe 540 1500 540 500
|
||||
|
||||
# Type text
|
||||
adb shell input text "mytext"
|
||||
|
||||
# Take a screenshot
|
||||
adb shell screencap /sdcard/screen.png && adb pull /sdcard/screen.png
|
||||
```
|
||||
|
||||
### UIAutomator2 — inspect UI elements
|
||||
|
||||
```bash
|
||||
# Dump the current UI hierarchy to find element IDs
|
||||
adb shell uiautomator dump /sdcard/ui.xml
|
||||
adb pull /sdcard/ui.xml
|
||||
```
|
||||
|
||||
Open `ui.xml` to find element resource IDs, then target them precisely in scripts.
|
||||
|
||||
### Appium — full scripted automation
|
||||
|
||||
```python
|
||||
from appium import webdriver
|
||||
|
||||
driver = webdriver.Remote('http://localhost:4723/wd/hub', {
|
||||
'platformName': 'Android',
|
||||
'appPackage': 'com.bose.soundtouch',
|
||||
'appActivity': '.MainActivity',
|
||||
})
|
||||
|
||||
# Find an element by resource ID and tap it
|
||||
driver.find_element('id', 'com.bose.soundtouch:id/play_button').click()
|
||||
```
|
||||
|
||||
> **Note:** `monkey` is a stress-test tool that sends random events — use it only to launch the app, not to drive specific interactions.
|
||||
@@ -0,0 +1,892 @@
|
||||
# Bose SoundTouch – Traffic Analysis Runbook
|
||||
|
||||
> **Goal:** Set up a Raspberry Pi as a transparent access point to fully observe the traffic of the Bose SoundTouch app – specifically the pairing flow with the Bose Cloud. This serves as a basis for later reverse engineering / simulation of the cloud endpoints.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Component | Details |
|
||||
|--------------------|------------------------------------------------------------------|
|
||||
| Raspberry Pi | Pi 3 or newer, Raspberry Pi OS (Bullseye, Bookworm, Trixie) |
|
||||
| Network interfaces | `eth0` → LAN cable to FritzBox, `wlan0` → own Access Point |
|
||||
| FritzBox | Unchanged, assigns an IP to the Pi via DHCP on eth0 |
|
||||
| Custom DNS Server | Already present (or see Appendix A), incl. custom CA certificate |
|
||||
| Phone | Android, connects to the Pi's Wi-Fi |
|
||||
|
||||
### Network Architecture
|
||||
|
||||
```
|
||||
Internet
|
||||
↓
|
||||
FritzBox (existing, unchanged)
|
||||
↓ LAN cable (eth0)
|
||||
Raspberry Pi
|
||||
├── DNS Server → selective logging / redirection
|
||||
├── hostapd → custom Wi-Fi Access Point ("Bose-Lab")
|
||||
├── dnsmasq → DHCP for clients, DNS to custom server
|
||||
├── iptables → NAT, Forwarding eth0 ↔ wlan0
|
||||
├── tcpdump → full traffic capture
|
||||
└── (optional) mitmproxy → HTTPS decryption
|
||||
↓ Wi-Fi ("Bose-Lab")
|
||||
Android Phone
|
||||
└── Bose SoundTouch App
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 1 – Install Packages
|
||||
|
||||
```bash
|
||||
sudo apt update && sudo apt install -y \
|
||||
hostapd \ # Wi-Fi Access Point daemon
|
||||
dnsmasq \ # DHCP + DNS forwarding
|
||||
nftables \ # Modern NAT / firewall / forwarding
|
||||
tcpdump \ # Packet capture at all levels
|
||||
wireshark-common # tshark CLI (optional, for live analysis)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2 – Enable IP Forwarding
|
||||
|
||||
The Pi must forward packets between `wlan0` (phone) and `eth0` (FritzBox).
|
||||
|
||||
```bash
|
||||
# Active immediately (no reboot required)
|
||||
sudo sysctl -w net.ipv4.ip_forward=1
|
||||
|
||||
# Permanent (survives reboots)
|
||||
# On modern Debian, using a dedicated file in sysctl.d/ is more reliable:
|
||||
echo "net.ipv4.ip_forward=1" | sudo tee /etc/sysctl.d/99-ip-forward.conf
|
||||
|
||||
# Apply changes immediately
|
||||
sudo sysctl --system
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
```bash
|
||||
# After a reboot, ensure it is still '1'
|
||||
cat /proc/sys/net/ipv4/ip_forward
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3 – Static IP on wlan0 (systemd-networkd)
|
||||
|
||||
On modern Debian (Bookworm/Trixie), `dhcpcd` is replaced by `systemd-networkd`.
|
||||
|
||||
```bash
|
||||
# Create network configuration
|
||||
sudo tee /etc/systemd/network/08-wlan0.network << 'EOF'
|
||||
[Match]
|
||||
Name=wlan0
|
||||
|
||||
[Network]
|
||||
Address=192.168.10.1/24
|
||||
IPForward=yes
|
||||
ConfigureWithoutCarrier=yes
|
||||
DHCP=no
|
||||
IPv6AcceptRA=no
|
||||
EOF
|
||||
|
||||
# Restart service
|
||||
sudo systemctl enable systemd-networkd
|
||||
sudo systemctl restart systemd-networkd
|
||||
|
||||
# Ensure wpa_supplicant and NetworkManager don't interfere
|
||||
sudo nmcli device set wlan0 managed no
|
||||
sudo systemctl stop wpa_supplicant@wlan0
|
||||
sudo systemctl mask wpa_supplicant@wlan0
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
```bash
|
||||
ip addr show wlan0
|
||||
# Expected: ONLY inet 192.168.10.1/24 (NO second DHCP IP)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4 – hostapd (Access Point)
|
||||
|
||||
```bash
|
||||
sudo tee /etc/hostapd/hostapd.conf << 'EOF'
|
||||
interface=wlan0
|
||||
driver=nl80211
|
||||
ssid=Bose-Lab
|
||||
hw_mode=b
|
||||
#hw_mode=g
|
||||
channel=1
|
||||
#channel=6
|
||||
wmm_enabled=0
|
||||
auth_algs=1
|
||||
wpa=2
|
||||
wpa_passphrase=secret123
|
||||
wpa_key_mgmt=WPA-PSK
|
||||
wpa_pairwise=CCMP
|
||||
EOF
|
||||
|
||||
# The modern way is to just use hostapd.service which defaults to /etc/hostapd/hostapd.conf
|
||||
sudo systemctl unmask hostapd
|
||||
sudo systemctl enable --now hostapd
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
```bash
|
||||
sudo systemctl status hostapd
|
||||
# Expected: active (running)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5 – dnsmasq (DHCP + DNS)
|
||||
|
||||
dnsmasq gives the phone an IP and forwards DNS queries to the custom DNS server.
|
||||
|
||||
```bash
|
||||
# Back up original config
|
||||
sudo mv /etc/dnsmasq.conf /etc/dnsmasq.conf.bak
|
||||
|
||||
sudo tee /etc/dnsmasq.conf << 'EOF'
|
||||
interface=wlan0
|
||||
dhcp-range=192.168.10.100,192.168.10.200,24h
|
||||
dhcp-option=3,192.168.10.1
|
||||
dhcp-option=6,192.168.10.1
|
||||
|
||||
# DNS Upstream: custom server on localhost (adjust port if necessary)
|
||||
server=127.0.0.1#5353 # Example: custom server on port 5353
|
||||
# Alternatively: server=1.1.1.1 if DNS server runs directly on port 53
|
||||
|
||||
# Log all DNS queries (for initial analysis)
|
||||
log-queries
|
||||
log-facility=/var/log/dnsmasq.log
|
||||
EOF
|
||||
|
||||
sudo systemctl restart dnsmasq
|
||||
```
|
||||
|
||||
**Observe DNS log live:**
|
||||
```bash
|
||||
sudo tail -f /var/log/dnsmasq.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 6 – NAT and Forwarding (nftables)
|
||||
|
||||
On modern Debian (Bookworm/Trixie), `nftables` is the default and recommended way to manage NAT and traffic forwarding.
|
||||
|
||||
```bash
|
||||
# Define the NAT and Forwarding rules
|
||||
sudo tee /etc/nftables.conf << 'EOF'
|
||||
#!/usr/sbin/nft -f
|
||||
|
||||
flush ruleset
|
||||
|
||||
table inet filter {
|
||||
chain forward {
|
||||
type filter hook forward priority 0; policy drop;
|
||||
|
||||
# Allow traffic from phone (wlan0) to internet (eth0)
|
||||
iifname "wlan0" oifname "eth0" accept
|
||||
|
||||
# Allow established/related traffic back to the phone
|
||||
iifname "eth0" oifname "wlan0" ct state established,related accept
|
||||
}
|
||||
}
|
||||
|
||||
table ip nat {
|
||||
chain posterouting {
|
||||
type nat hook postrouting priority 100; policy accept;
|
||||
|
||||
# MASQUERADE outgoing packets on eth0
|
||||
oifname "eth0" masquerade
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Enable and start nftables
|
||||
sudo systemctl enable nftables
|
||||
sudo systemctl restart nftables
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
```bash
|
||||
sudo nft list ruleset
|
||||
# Expected: ruleset showing the forward and nat chains
|
||||
```
|
||||
|
||||
### WiFi "Bose-Lab" not visible?
|
||||
|
||||
If you cannot see the `Bose-Lab` SSID on your phone:
|
||||
|
||||
1. **Check hostapd status:** `sudo systemctl status hostapd`. If it failed with "nl80211: Driver does not support configured mode", try changing `hw_mode=g` to `hw_mode=b`.
|
||||
2. **Interface blocking:** Ensure `rfkill` hasn't blocked WiFi: `sudo rfkill unblock wlan`.
|
||||
3. **Country Code:** Some systems require a country code in `hostapd.conf` to enable the radio. Add `country_code=DE` (or your country) to the top of `/etc/hostapd/hostapd.conf` and restart hostapd: `sudo systemctl restart hostapd`.
|
||||
4. **Local Radio Check:** You can verify that the radio is actually configured as an AP: `iw dev wlan0 info`. Look for `type AP` and your SSID.
|
||||
> **Note:** Do NOT rely on `iw dev wlan0 scan` for your own SSID; many WiFi drivers cannot "scan" and "broadcast" simultaneously.
|
||||
5. **Debug Mode:** If the scan still returns nothing, stop the service and run hostapd in the foreground to see real-time errors:
|
||||
```bash
|
||||
sudo systemctl stop hostapd
|
||||
sudo hostapd -dd /etc/hostapd/hostapd.conf
|
||||
```
|
||||
Look for messages like `nl80211: Failed to set interface wlan0 into AP mode`. This usually means the hardware is busy or doesn't support the current `hw_mode` / `channel` combination.
|
||||
6. **Conflicting Services:** Ensure nothing else is managing `wlan0`. NetworkManager is common on modern Debian:
|
||||
```bash
|
||||
sudo nmcli device set wlan0 managed no
|
||||
```
|
||||
7. **Ghost IP Conflict:** If `ip addr show wlan0` shows both `192.168.10.1` and another IP (like `192.168.178.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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 7 – Install Custom CA Certificate on the Phone
|
||||
|
||||
Since a custom DNS server with a custom CA certificate is used, it must be trusted on the phone – otherwise, the app will block HTTPS connections to redirected domains.
|
||||
|
||||
### Copy CA Certificate to the Pi (if not already there)
|
||||
|
||||
If you haven't created a CA yet, follow **Appendix A** first.
|
||||
|
||||
```bash
|
||||
# Certificate is located e.g. at /etc/my-dns-ca/ca.crt
|
||||
# Temporarily make reachable via HTTP for easy download:
|
||||
cd /etc/my-dns-ca/
|
||||
python3 -m http.server 8080
|
||||
# → Reachable at http://192.168.10.1:8080/ca.crt
|
||||
```
|
||||
|
||||
### Install on Android
|
||||
|
||||
1. Connect phone to `Bose-Lab`
|
||||
2. Open browser → `http://192.168.10.1:8080/ca.crt`
|
||||
3. Download certificate
|
||||
4. **Settings → Security → Credentials → Install CA Certificate**
|
||||
5. Select certificate and confirm
|
||||
|
||||
> **Note:** Android distinguishes between system CAs and user CAs. User-installed CAs are accepted by many apps, but apps with certificate pinning (hardcoded certificate hashes) ignore them. Whether Bose uses pinning will be visible in the capture (Connection Reset after TLS ClientHello).
|
||||
|
||||
### Android 14+ Special Case
|
||||
|
||||
From Android 14 onwards, apps do not trust user CAs by default unless explicitly declared in the manifest. If the Bose app rejects the CA certificate:
|
||||
|
||||
```bash
|
||||
# Option A: Root + Magisk module "MagiskTrustUserCerts"
|
||||
# → moves user CAs to the system store
|
||||
|
||||
# Option B: Root + manually copy to system CA directory
|
||||
adb push ca.crt /system/etc/security/cacerts/
|
||||
adb shell chmod 644 /system/etc/security/cacerts/ca.crt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 8 – Capture Traffic
|
||||
|
||||
### All at once (recommended)
|
||||
|
||||
```bash
|
||||
# Full capture of all protocols on wlan0
|
||||
# Filename with timestamp for multiple sessions
|
||||
sudo tcpdump -i wlan0 \
|
||||
-w /tmp/bose-$(date +%Y%m%d-%H%M%S).pcap \
|
||||
-s 0 # full packet length (no truncation)
|
||||
|
||||
# End session: Ctrl+C
|
||||
```
|
||||
|
||||
### Targeted by protocol
|
||||
|
||||
```bash
|
||||
# DNS only (Port 53) – shows if app uses standard DNS
|
||||
sudo tcpdump -i wlan0 -n port 53
|
||||
|
||||
# HTTPS only – TLS connections to Bose Cloud
|
||||
sudo tcpdump -i wlan0 -n 'tcp port 443'
|
||||
|
||||
# mDNS (ZeroConf) – device discovery in LAN
|
||||
# Multicast group 224.0.0.1, Port 5353
|
||||
sudo tcpdump -i wlan0 -n 'udp port 5353'
|
||||
|
||||
# SSDP/UPnP – alternative device discovery
|
||||
sudo tcpdump -i wlan0 -n 'udp port 1900'
|
||||
|
||||
# Everything except DNS (reduces noise)
|
||||
sudo tcpdump -i wlan0 -n 'not port 53' -w /tmp/bose-nodns.pcap
|
||||
|
||||
# Traffic of a specific host only (filter by phone IP)
|
||||
# Read phone IP from dnsmasq.leases beforehand (see below)
|
||||
sudo tcpdump -i wlan0 -n host 192.168.10.101
|
||||
```
|
||||
|
||||
### Read SNI from TLS Traffic (without decryption)
|
||||
|
||||
```bash
|
||||
# Extract domains from TLS ClientHello (SNI is unencrypted)
|
||||
sudo tcpdump -i wlan0 -n 'tcp port 443' -A 2>/dev/null \
|
||||
| grep -oP '(?<=\x00)([a-zA-Z0-9.-]+\.(?:com|net|io|cloud|bose\.com))'
|
||||
```
|
||||
|
||||
### Readable mDNS Announcements output
|
||||
|
||||
```bash
|
||||
# tshark decodes mDNS directly
|
||||
sudo tshark -i wlan0 -f 'udp port 5353' -T fields \
|
||||
-e dns.qry.name \
|
||||
-e dns.resp.name \
|
||||
-e dns.a
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 9 – Analysis with Wireshark (on PC)
|
||||
|
||||
Transfer `.pcap` files from the Pi to the PC:
|
||||
|
||||
```bash
|
||||
# From the PC (scp)
|
||||
scp pi@192.168.10.1:/tmp/bose-*.pcap ~/Desktop/
|
||||
```
|
||||
|
||||
**Important Wireshark Filters:**
|
||||
|
||||
```
|
||||
# DNS only
|
||||
dns
|
||||
|
||||
# HTTPS only
|
||||
tcp.port == 443
|
||||
|
||||
# WebSocket connections (HTTP Upgrade)
|
||||
websocket
|
||||
|
||||
# mDNS
|
||||
mdns
|
||||
|
||||
# TLS Handshakes (SNI visible)
|
||||
tls.handshake.extensions_server_name
|
||||
|
||||
# Traffic of a specific domain (resolve by IP)
|
||||
http.host contains "bose"
|
||||
|
||||
# WebSocket frames
|
||||
websocket.payload
|
||||
```
|
||||
|
||||
> **Tip:** Wireshark decodes WebSocket frames automatically if it sees the HTTP Upgrade handshake in the same capture. For the pairing flow: filtering for `tls.handshake.extensions_server_name` shows all domains the app contacts, even without decryption.
|
||||
|
||||
---
|
||||
|
||||
## Step 10 – mitmproxy (optional, for HTTPS content)
|
||||
|
||||
Only useful if the CA certificate on the phone is trusted and no certificate pinning is active. `mitmproxy` acts as a Man-in-the-Middle by generating fake, on-the-fly certificates for any domain (e.g., `global.api.bose.io`) using your custom CA.
|
||||
|
||||
### 1. Configure mitmproxy to use your Custom CA
|
||||
|
||||
By default, `mitmproxy` creates its own CA in `~/.mitmproxy/`. To ensure the phone (which already trusts your `ca.crt`) accepts the traffic, you must tell `mitmproxy` to use your existing CA:
|
||||
|
||||
```bash
|
||||
# mitmproxy expects the CA in a specific PEM format (cert + key in one file)
|
||||
sudo mkdir -p ~/.mitmproxy
|
||||
sudo cat /etc/my-dns-ca/ca.crt /etc/my-dns-ca/ca.key | sudo tee ~/.mitmproxy/mitmproxy-ca.pem > /dev/null
|
||||
```
|
||||
|
||||
### 2. Install and Start mitmproxy
|
||||
|
||||
```bash
|
||||
# Install mitmproxy binary (stable version for aarch64)
|
||||
cd /tmp
|
||||
wget https://downloads.mitmproxy.org/12.2.1/mitmproxy-12.2.1-linux-aarch64.tar.gz
|
||||
tar -xzf mitmproxy-12.2.1-linux-aarch64.tar.gz
|
||||
sudo mv mitmproxy mitmdump mitmweb /usr/local/bin/
|
||||
rm mitmproxy-12.2.1-linux-aarch64.tar.gz
|
||||
|
||||
mitmproxy --version
|
||||
|
||||
# Transparent proxy on port 8080
|
||||
# It will now use the CA from ~/.mitmproxy/mitmproxy-ca.pem
|
||||
mitmproxy --mode transparent --listen-port 8080
|
||||
|
||||
# Alternatively: mitmdump for automatic logging to file
|
||||
# mitmdump --mode transparent --listen-port 8080 -w /tmp/bose-https.mitm
|
||||
```
|
||||
|
||||
### 3. Troubleshooting: TLS Handshake Failures
|
||||
|
||||
If you see `Client TLS handshake failed. The client does not trust the proxy's certificate for www.google.com` (or other domains) in the `mitmproxy` logs:
|
||||
|
||||
1. **HSTS and Pre-installed Pinning:** High-security sites like `www.google.com` use **HSTS (HTTP Strict Transport Security)** and have their certificates hardcoded (pinned) into browsers like Chrome and the Android system. **These will always fail with a User-installed CA.**
|
||||
2. **User vs. System CA Store:** On Android 7.0+, apps **do not trust User-installed CAs by default**. They only trust the "System" store.
|
||||
* **The Bose app:** If it fails, it's because it only trusts the System store or uses its own certificate pinning.
|
||||
* **The Fix (Rooted Phone):** Use a Magisk module like `AlwaysTrustUserCerts` or manually move your `ca.crt` to `/system/etc/security/cacerts/` (see Step 7).
|
||||
3. **The "Golden Rule" - Verify the Proxy is Working:**
|
||||
To confirm your CA and `mitmproxy` are correctly configured, test with a non-HSTS site on the phone's browser (e.g., `http://neverssl.com`). Once redirected to HTTPS, **inspect the certificate**. It should say it was issued by your "Bose-Lab Root CA" (or "SoundTouch Root CA").
|
||||
|
||||
* **If this works:** Your "factory" (mitmproxy + CA) is 100% correct. Any failure in the Bose app is due to its own security policy (ignore User Store or Pinning).
|
||||
* **If this fails:** Your CA is not trusted by the browser or `mitmproxy` is not using your PEM file.
|
||||
|
||||
Alternatively, use `curl` from a terminal emulator on the phone:
|
||||
```bash
|
||||
# This should work if the CA is in the user store and curl is told to use it
|
||||
curl -v --cacert /path/to/ca.crt https://example.com
|
||||
```
|
||||
4. **Check mitmproxy CA:** Ensure `mitmproxy` is actually using your CA. When it starts, it should NOT generate a new CA in `~/.mitmproxy/mitmproxy-ca.pem` if you've already placed yours there.
|
||||
|
||||
---
|
||||
|
||||
**nftables rule: redirect HTTPS traffic to mitmproxy**
|
||||
|
||||
```bash
|
||||
# Create a temporary file for the redirection rule
|
||||
sudo nft add table ip mitm
|
||||
sudo nft add chain ip mitm prerouting { type nat hook prerouting priority -100 \; }
|
||||
sudo nft add rule ip mitm prerouting iifname "wlan0" tcp dport 443 redirect to :8080
|
||||
```
|
||||
|
||||
**Remove rule when no longer needed:**
|
||||
|
||||
```bash
|
||||
sudo nft delete table ip mitm
|
||||
```
|
||||
|
||||
> **Detecting Certificate Pinning:** If the app immediately disconnects after mitmproxy redirection (connection reset directly after TLS ClientHello), pinning is active. In this case, Frida + root is needed to patch the pinning.
|
||||
|
||||
---
|
||||
|
||||
## Step 11 – Bypassing Android Trust Restrictions
|
||||
|
||||
If `neverssl.com` works in the browser but the Bose app shows `TLS handshake failed` in `mitmproxy`, the app is either ignoring the **User CA store** (common on Android 7+) or using **Certificate Pinning**.
|
||||
|
||||
### Option A: Move CA to System Store (Requires Root/Magisk)
|
||||
|
||||
This is the most reliable way to make apps trust your CA without modifying the app itself.
|
||||
|
||||
1. **Using Magisk (Recommended):**
|
||||
Install the **"AlwaysTrustUserCerts"** or **"Move Certificates"** module in Magisk. It automatically mirrors all certificates from the User store to the System store on every boot.
|
||||
|
||||
2. **Manual Move (via ADB):**
|
||||
Android system certificates are stored in `/system/etc/security/cacerts/` and must be named using the hash of the certificate.
|
||||
|
||||
```bash
|
||||
# 1. Get the hash of your certificate
|
||||
hash=$(openssl x509 -inform PEM -subject_hash_old -in ca.crt | head -1)
|
||||
|
||||
# 2. Rename the certificate locally
|
||||
cp ca.crt ${hash}.0
|
||||
|
||||
# 3. Push to the phone (requires remounting /system as read-write)
|
||||
adb push ${hash}.0 /sdcard/
|
||||
adb shell
|
||||
su
|
||||
mount -o rw,remount /
|
||||
cp /sdcard/${hash}.0 /system/etc/security/cacerts/
|
||||
chmod 644 /system/etc/security/cacerts/${hash}.0
|
||||
chown root:root /system/etc/security/cacerts/${hash}.0
|
||||
reboot
|
||||
```
|
||||
|
||||
### Option B: Patching the App (No Root Required)
|
||||
|
||||
If you cannot root your phone, you can modify the app's APK to trust user-installed certificates. This involves obtaining the APK, decompiling it, adding a network security configuration, and then repackaging and signing it.
|
||||
|
||||
#### 0. How to get the .apk file?
|
||||
|
||||
You have two main ways to get the official Bose SoundTouch APK:
|
||||
|
||||
**Method 1: Extract from your phone (Safest)**
|
||||
If the app is already installed on your phone, you can pull it using `adb`:
|
||||
```bash
|
||||
# 1. Find the package name (usually com.bose.soundtouch)
|
||||
adb shell pm list packages | grep bose
|
||||
|
||||
# 2. Get the full path to the APK on the phone
|
||||
adb shell pm path com.bose.soundtouch
|
||||
# Output: package:/data/app/~~...==/com.bose.soundtouch-.../base.apk
|
||||
|
||||
# 3. Pull the file to your computer
|
||||
adb pull /data/app/~~...==/com.bose.soundtouch-.../base.apk Bose-SoundTouch.apk
|
||||
```
|
||||
|
||||
**Method 2: Download from a Mirror (Easiest)**
|
||||
You can download the APK from reputable third-party sites.
|
||||
> **Warning:** Always verify the site's reputation.
|
||||
* [APKMirror](https://www.apkmirror.com/apk/bose-corporation/bose-soundtouch/)
|
||||
* [APKPure](https://apkpure.com/bose-soundtouch/com.bose.soundtouch)
|
||||
|
||||
#### 1. Automated Method: apk-mitm (Recommended)
|
||||
The easiest way is to use `apk-mitm`, which automates the entire process including fixing common certificate pinning libraries.
|
||||
|
||||
```bash
|
||||
# Requires Node.js installed on your PC
|
||||
npx apk-mitm Bose-SoundTouch.apk
|
||||
```
|
||||
This will produce a `Bose-SoundTouch-patched.apk` which you can install on your phone.
|
||||
|
||||
#### 2. Manual Method: Network Security Config
|
||||
If you prefer to do it manually:
|
||||
|
||||
1. **Decompile the APK:**
|
||||
```bash
|
||||
apktool d Bose-SoundTouch.apk
|
||||
```
|
||||
2. **Create/Modify `res/xml/network_security_config.xml`:**
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config>
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates src="user" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
```
|
||||
3. **Update `AndroidManifest.xml`:**
|
||||
Ensure the `<application>` tag includes: `android:networkSecurityConfig="@xml/network_security_config"`.
|
||||
4. **Repackage and Sign:**
|
||||
```bash
|
||||
apktool b Bose-SoundTouch -o Bose-SoundTouch-patched.apk
|
||||
# Sign with your own key
|
||||
# 1. Generate a keystore (if you don't have one)
|
||||
# Note: You can use ANY name/values here. The phone does not need to "know" or "trust" this key beforehand.
|
||||
# It only needs the APK to be digitally signed so the Android installer accepts it.
|
||||
keytool -genkey -v -keystore my-release-key.keystore -alias alias_name -keyalg RSA -keysize 2048 -validity 10000
|
||||
|
||||
# 2. Sign the APK
|
||||
apksigner sign --ks my-release-key.keystore --out Bose-SoundTouch-patched-signed.apk Bose-SoundTouch-patched.apk
|
||||
|
||||
# Alternatively, use uber-apk-signer (recommended for simplicity)
|
||||
# It handles zipalign and signing automatically.
|
||||
java -jar uber-apk-signer.jar --apk Bose-SoundTouch-patched.apk
|
||||
```
|
||||
|
||||
#### 3. Install the Patched APK
|
||||
|
||||
Once you have your `Bose-SoundTouch-patched.apk` (and it is signed), you need to install it on your phone.
|
||||
|
||||
**Important:** You must **uninstall the original Bose app first**. Android will not allow you to "update" the official app with your patched version because the digital signatures won't match.
|
||||
|
||||
**Method 1: via ADB (Recommended)**
|
||||
```bash
|
||||
# 1. Uninstall the original app
|
||||
adb uninstall com.bose.soundtouch
|
||||
|
||||
# 2. Install your patched version
|
||||
adb install Bose-SoundTouch-patched.apk
|
||||
```
|
||||
|
||||
**Method 2: Manual Transfer**
|
||||
1. Copy the `Bose-SoundTouch-patched.apk` to your phone's storage (via USB, Google Drive, or the Pi's HTTP server).
|
||||
2. On the phone, use a File Manager to open the APK.
|
||||
3. If prompted, allow "Install from Unknown Sources" for your File Manager.
|
||||
|
||||
### Option C: Using the macOS Bose SoundTouch App (No Root/Patching Required)
|
||||
|
||||
If you have a Mac, using the macOS version of the Bose SoundTouch app is often a good alternative. However, because the app is built on an **older version of Qt (5.7.0)**, it has specific trust and TLS compatibility issues that require extra steps.
|
||||
|
||||
#### 1. Install the Custom CA in macOS Keychain
|
||||
|
||||
1. Open **Keychain Access** on your Mac.
|
||||
2. Select the **System** keychain (or **login** if System is locked).
|
||||
3. Drag and drop your `ca.crt` file into the list.
|
||||
4. Double-click the newly added certificate (e.g., "Bose-Lab Root CA").
|
||||
5. Expand the **Trust** section.
|
||||
6. Set "When using this certificate" to **Always Trust**.
|
||||
7. Close the window and authenticate with your Mac password.
|
||||
|
||||
#### 2. Configure the Proxy
|
||||
|
||||
You can either configure the macOS system proxy manually or use `mitmproxy`'s automatic interception.
|
||||
|
||||
**Method 1: System Proxy (Manual)**
|
||||
1. Go to **System Settings → Network → Wi-Fi → Details... → Proxies**.
|
||||
2. Enable **HTTP Proxy** and **HTTPS Proxy**.
|
||||
3. Set Server to your Pi's IP (`192.168.10.1`) and Port to `8080`.
|
||||
4. Click **OK** and **Apply**.
|
||||
|
||||
**Method 2: mitmproxy Local Redirect (Automatic)**
|
||||
If you are running `mitmproxy` directly on your Mac (instead of the Pi), you can use the modern "Local Redirect" mode which doesn't require proxy settings:
|
||||
```bash
|
||||
# Install mitmproxy via Homebrew
|
||||
brew install mitmproxy
|
||||
|
||||
# Start mitmproxy in local redirect mode
|
||||
# This uses a macOS Network Extension to intercept traffic from specific apps
|
||||
mitmproxy --mode local
|
||||
```
|
||||
|
||||
#### 3. Special Troubleshooting: Legacy Qt 5.7.0 SSL Failures
|
||||
|
||||
If you see `SSL handshake failed` in the `mitmproxy` logs or the app's internal log (`log.txt`), the app's older networking stack is rejecting the connection. This is common because Qt 5.7.0 (2016) lacks support for **TLS 1.3** and many modern root certificates (like Let's Encrypt's **ISRG Root X1**).
|
||||
|
||||
**The Solution: Launch with SSL Bypass Flags**
|
||||
|
||||
Since the Bose macOS app is a hybrid of **Qt/Chromium** and **Node.js**, you must bypass the trust checks for both engines by launching the app from the terminal:
|
||||
|
||||
```bash
|
||||
# 1. Bypass QtWebEngine/Chromium (Qt 5.7) trust
|
||||
export QTWEBENGINE_CHROMIUM_FLAGS="--ignore-certificate-errors"
|
||||
|
||||
# 2. Bypass Node.js (SoundTouch Music Server) trust
|
||||
export NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
|
||||
# 3. (Optional) Provide your custom CA directly to Node.js
|
||||
export NODE_EXTRA_CA_CERTS="/path/to/your/ca.crt"
|
||||
|
||||
# 4. Launch the application
|
||||
"/Applications/SoundTouch/SoundTouch.app/Contents/MacOS/SoundTouch"
|
||||
```
|
||||
|
||||
#### 4. Verify and Capture
|
||||
|
||||
1. Open Safari and visit `https://neverssl.com`. Verify the certificate is issued by your custom CA.
|
||||
2. Launch the Bose app using the terminal command above.
|
||||
3. Watch the traffic flow in `mitmproxy`.
|
||||
|
||||
> **Note:** Even on macOS, **Certificate Pinning** is still possible if Bose implemented it specifically in the desktop app code. However, it is much less common on desktop apps than on mobile apps. If it works, you've saved yourself hours of Android patching!
|
||||
|
||||
### Option D: Patching the App with Frida (Requires Root)
|
||||
|
||||
If the app uses **Certificate Pinning** (hardcoded hashes), even moving the CA to the System store won't work. You must disable the pinning check in the app's code.
|
||||
|
||||
1. **Install Frida** on your PC and `frida-server` on the rooted phone.
|
||||
2. **Use a universal bypass script:**
|
||||
```bash
|
||||
frida -U -f com.bose.soundtouch -l https://codeshare.frida.re/@pcipolloni/universal-android-ssl-pinning-bypass-with-frida/ --no-pause
|
||||
```
|
||||
*(Replace `com.bose.soundtouch` with the actual package name if different).*
|
||||
|
||||
## Step 12 – Alternative: Regular HTTP Proxy Mode
|
||||
|
||||
If the **Transparent AP** setup (Steps 1–6) is too complex or you are experiencing routing issues, you can use `mitmproxy` as a **Regular HTTP Proxy**.
|
||||
|
||||
### 1. How it works
|
||||
In this mode, the Pi acts as a simple server on port 8080. You tell your phone's Wi-Fi settings to send all traffic to `192.168.10.1:8080`.
|
||||
|
||||
* **Pros:** No complex `nftables` or NAT rules required.
|
||||
* **Cons:** Many Android apps (and background processes) ignore system-wide proxy settings. **HTTPS still requires a trusted CA for decryption.**
|
||||
|
||||
### 2. Start mitmproxy in Regular Mode
|
||||
```bash
|
||||
# Stop transparent mode first if it's running
|
||||
# No special flags needed for regular mode
|
||||
mitmproxy --listen-port 8080
|
||||
```
|
||||
|
||||
### 3. Configure the Phone
|
||||
1. Go to **Settings → Wi-Fi → Bose-Lab**.
|
||||
2. Select **Modify Network** (or the "i" icon).
|
||||
3. Set **Proxy** to **Manual**.
|
||||
4. **Proxy hostname:** `192.168.10.1`
|
||||
5. **Proxy port:** `8080`
|
||||
6. Save and try to browse a site.
|
||||
|
||||
---
|
||||
|
||||
## Step 13 – Extracting for soundtouch-service
|
||||
|
||||
You can extract interactions (especially unencrypted WebSockets on port 8090) from a `.pcap` and format them for use in `soundtouch-service`.
|
||||
|
||||
### 1. Extract Traffic using Go
|
||||
|
||||
A helper script is provided in `scripts/extract-ws.go`. It automatically detects, unmasks, and decompresses (GZIP) WebSocket frames, and also extracts DNS, MDNS, and SSDP traffic.
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
go get github.com/google/gopacket
|
||||
|
||||
# Run extraction (outputs multiple files: .ws.http, .dns.txt, .mdns.txt, .ssdp.txt)
|
||||
# The results will be saved beside your .pcap file
|
||||
go run scripts/extract-ws.go your_capture.pcap [filter_ip]
|
||||
|
||||
# Example: Filter for a specific speaker's IP in WebSocket messages
|
||||
go run scripts/extract-ws.go capture.pcap 192.168.100.1
|
||||
```
|
||||
|
||||
### 2. Manual Extraction with tshark
|
||||
|
||||
If you only need a quick look at the payloads:
|
||||
|
||||
```bash
|
||||
# Extract all WebSocket text payloads
|
||||
tshark -r your_capture.pcap -Y "websocket.payload.text" -T fields -e websocket.payload.text
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 14 – Extracting from Internal App Logs (macOS)
|
||||
|
||||
If you are using the macOS app and cannot decrypt the cloud traffic due to pinning, you can still extract the JSON/XML messages from the app's internal communication log.
|
||||
|
||||
A helper script is provided in `scripts/extract-log-interactions.go`. It parses the interleaved "Native" and "Network" calls to reconstruct the application's internal state and cloud requests.
|
||||
|
||||
```bash
|
||||
# Run extraction from the log file
|
||||
# Outputs a chronological record of internal events and network URLs
|
||||
go run scripts/extract-log-interactions.go path/to/log.txt > extracted-interactions.http
|
||||
```
|
||||
|
||||
**What this shows:**
|
||||
- **TO NETWORK:** The URLs the app is about to call (intercepted before encryption).
|
||||
- **FROM NATIVE:** Data being returned from the OS or Cloud to the UI.
|
||||
- **TO NATIVE:** Commands being sent from the UI to the underlying engines.
|
||||
|
||||
This is a powerful "Plan B" when HTTPS decryption is blocked, as the app essentially logs its own decrypted data for you.
|
||||
|
||||
---
|
||||
|
||||
## Helper Commands / Troubleshooting
|
||||
|
||||
After a Pi reboot, everything should come up automatically. If not:
|
||||
|
||||
```bash
|
||||
# Restart and enable all core services
|
||||
sudo systemctl restart systemd-networkd
|
||||
sudo systemctl enable --now hostapd
|
||||
sudo systemctl enable --now dnsmasq
|
||||
sudo systemctl restart nftables
|
||||
|
||||
# Verify the unmanaged state of wlan0 (nmcli)
|
||||
sudo nmcli device set wlan0 managed no
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What to Expect
|
||||
|
||||
| Protocol | Port | Tool | Visibility |
|
||||
|----------------------|------------|--------------------------|------------------------------------------------|
|
||||
| DNS (Standard) | UDP 53 | tcpdump, dnsmasq log | Full, plaintext |
|
||||
| HTTPS / REST | TCP 443 | tcpdump (SNI), mitmproxy | SNI without decryption, content with mitmproxy |
|
||||
| WebSockets | TCP 443/80 | Wireshark | Frames decoded if TLS is broken |
|
||||
| mDNS / ZeroConf | UDP 5353 | tcpdump, tshark | Full, plaintext |
|
||||
| SSDP / UPnP | UDP 1900 | tcpdump | Full, plaintext |
|
||||
| SoundTouch local API | TCP 8090 | tcpdump | Full, plaintext (no TLS) |
|
||||
|
||||
> **Expectation for Bose SoundTouch:** The app likely uses standard DNS (older app generation), REST/HTTPS for the pairing flow with the cloud, WebSockets for push events from the device, and mDNS for local device discovery. The local device API on port 8090 is HTTP without TLS – this traffic is always readable.
|
||||
|
||||
---
|
||||
|
||||
## Next Steps After Analysis
|
||||
|
||||
1. Extract domains from DNS log and SNI → List of all Bose endpoints
|
||||
2. HTTP methods and paths from mitmproxy log → Reconstruct API structure
|
||||
3. Document auth flow (OAuth2? Proprietary? Token format?)
|
||||
4. Build a minimal mock server simulating the critical endpoints
|
||||
5. Testing: App against mock server → does pairing work offline?
|
||||
|
||||
---
|
||||
|
||||
## Appendix A – Generating a Custom CA Certificate
|
||||
|
||||
If you don't have a custom DNS server with a CA yet, you can create one directly on the Pi. Alternatively, if you are already using the `soundtouch-service` from this repository, you can reuse its CA certificate located in the `data/certs/` directory.
|
||||
|
||||
### 0. (Optional) Copy an Existing CA from another host
|
||||
|
||||
If you are already using the `soundtouch-service` on another machine (e.g., your notebook), you can copy the existing CA to the Pi instead of generating a new one:
|
||||
|
||||
```bash
|
||||
# On your Pi:
|
||||
sudo mkdir -p /etc/my-dns-ca
|
||||
sudo chown $USER:$USER /etc/my-dns-ca
|
||||
|
||||
# Run this on your notebook (replace hostnames and paths):
|
||||
# Note: This is easiest if your SSH key is added to the Pi and soundtouch-service host.
|
||||
# If you run into permission issues with sudo, ensure the source user has passwordless sudo for 'cat'.
|
||||
|
||||
# Step A: Download from source to your notebook
|
||||
ssh soundtouch-service "sudo cat /var/lib/soundtouch-service/certs/ca.crt" > ca.crt
|
||||
ssh soundtouch-service "sudo cat /var/lib/soundtouch-service/certs/ca.key" > ca.key
|
||||
|
||||
# Step B: Upload from notebook to the Pi
|
||||
scp ca.crt ca.key soundtouch-access-point:/tmp/
|
||||
ssh soundtouch-access-point "sudo mv /tmp/ca.crt /tmp/ca.key /etc/my-dns-ca/ && sudo chown root:root /etc/my-dns-ca/ca.*"
|
||||
rm ca.crt ca.key
|
||||
```
|
||||
|
||||
### 1. Create CA Key and Certificate
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/my-dns-ca
|
||||
cd /etc/my-dns-ca
|
||||
|
||||
# Generate CA private key
|
||||
sudo openssl genrsa -out ca.key 4096
|
||||
|
||||
# Generate Root CA certificate
|
||||
# Note: we explicitly add basicConstraints=CA:TRUE for modern TLS clients
|
||||
sudo openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 \
|
||||
-out ca.crt \
|
||||
-subj "/C=DE/O=Bose-Lab/CN=Bose-Lab Root CA" \
|
||||
-addext "basicConstraints=critical,CA:TRUE" \
|
||||
-addext "keyUsage=critical,keyCertSign,cRLSign"
|
||||
```
|
||||
|
||||
### 2. Generate a Certificate for Interception (Example)
|
||||
|
||||
To intercept `global.api.bose.io`, you need a certificate for it, signed by your CA:
|
||||
|
||||
```bash
|
||||
# Generate server key
|
||||
sudo openssl genrsa -out bose.key 2048
|
||||
|
||||
# Create CSR (Certificate Signing Request) configuration
|
||||
sudo tee bose.ext << 'EOF'
|
||||
authorityKeyIdentifier=keyid,issuer
|
||||
basicConstraints=CA:FALSE
|
||||
keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
|
||||
subjectAltName = @alt_names
|
||||
|
||||
[alt_names]
|
||||
DNS.1 = global.api.bose.io
|
||||
DNS.2 = *.bose.io
|
||||
EOF
|
||||
|
||||
# Generate CSR
|
||||
sudo openssl req -new -key bose.key -out bose.csr \
|
||||
-subj "/C=DE/O=Bose-Lab/CN=global.api.bose.io"
|
||||
|
||||
# Sign the certificate with your CA
|
||||
sudo openssl x509 -req -in bose.csr -CA ca.crt -CAkey ca.key \
|
||||
-CAcreateserial -out bose.crt -days 365 -sha256 -extfile bose.ext
|
||||
```
|
||||
|
||||
### 3. Usage in your DNS/HTTPS Server
|
||||
|
||||
Your custom server (e.g., a small Go or Python script) would then use `bose.crt` and `bose.key` to serve HTTPS traffic for those domains.
|
||||
|
||||
## Appendix B – Helpful Commands
|
||||
|
||||
```bash
|
||||
# Which IPs did the phone receive?
|
||||
cat /var/lib/misc/dnsmasq.leases
|
||||
|
||||
# Is the access point active?
|
||||
sudo systemctl status hostapd
|
||||
|
||||
# Is dnsmasq active?
|
||||
sudo systemctl status dnsmasq
|
||||
|
||||
# Check interfaces and IPs
|
||||
ip addr show
|
||||
|
||||
# Check routing table
|
||||
ip route show
|
||||
|
||||
# Show active nftables rules
|
||||
sudo nft list ruleset
|
||||
|
||||
# All running tcpdump processes
|
||||
pgrep -a tcpdump
|
||||
|
||||
# Test the Pi's own DNS resolution
|
||||
dig @127.0.0.1 -p 5353 global.api.bose.io
|
||||
|
||||
# Check network connectivity from the phone (from the Pi)
|
||||
ping 192.168.10.101 # Phone IP from dnsmasq.leases
|
||||
```
|
||||
@@ -0,0 +1,189 @@
|
||||
# IoT Configuration Quick Reference
|
||||
|
||||
## Key Files and Locations
|
||||
|
||||
| File/Location | Purpose | Notes |
|
||||
|-----------------------------------------|------------------------|-----------------------------------------|
|
||||
| `/mnt/nv/BoseApp-Persistence/1/IoT.xml` | Main IoT configuration | Contains clientID, endpoint, deployment |
|
||||
| `/opt/Bose/IoT` | IoT service binary | ARM executable, AWS IoT SDK |
|
||||
| `/mnt/nv/IoTCerts/` | Certificate storage | Device certs and private keys |
|
||||
| `/etc/init.d/SoundTouch` | System startup script | Creates directory structure |
|
||||
| `/opt/Bose/etc/Shepherd-noncore.xml` | Service configuration | Defines IoT daemon startup |
|
||||
|
||||
## Configuration Parameters
|
||||
|
||||
### IoT.xml Structure
|
||||
```xml
|
||||
<Configuration
|
||||
clientID="[UUID]"
|
||||
iotEndpoint="[AWS_IOT_ENDPOINT]"
|
||||
deployment="PROD" />
|
||||
```
|
||||
|
||||
### Device-Specific Values
|
||||
- **ST20**: `clientID="577ecfcc-2db3-4989-92c9-76d7704f9fb3"`
|
||||
- **ST10**: `clientID="eb1a6d8f-0bb1-4aa7-9113-ea673fcef96e"`
|
||||
- **Endpoint**: `a2bhvr9c4wn4ya.iot.us-east-1.amazonaws.com` (XML)
|
||||
- **Backup Endpoint**: `amqmidtcohfms.iot.us-east-1.amazonaws.com` (hardcoded)
|
||||
|
||||
## Protocol Stack
|
||||
|
||||
```
|
||||
Application Layer: AWS IoT Device Shadows (JSON)
|
||||
Presentation Layer: RapidJSON parsing/serialization
|
||||
Session Layer: MQTT v3.1.1
|
||||
Transport Layer: TLS v1.2
|
||||
Network Layer: TCP/IP
|
||||
```
|
||||
|
||||
## Certificate Files
|
||||
|
||||
| File | Location | Purpose |
|
||||
|-----------------------|---------------------|---------------------------|
|
||||
| `iot-cert.pem.crt` | `/mnt/nv/IoTCerts/` | Device client certificate |
|
||||
| `iot-private.pem.key` | `/mnt/nv/IoTCerts/` | Device private key |
|
||||
| `rootCA.crt` | `/var/lib/iot/` | AWS IoT Root CA |
|
||||
|
||||
## MQTT Topics
|
||||
|
||||
### Shadow Operations
|
||||
```
|
||||
$aws/things/{clientID}/shadow/update
|
||||
$aws/things/{clientID}/shadow/update/accepted
|
||||
$aws/things/{clientID}/shadow/update/rejected
|
||||
$aws/things/{clientID}/shadow/delete
|
||||
```
|
||||
|
||||
### JSON Payload Examples
|
||||
|
||||
#### Device State Report
|
||||
```json
|
||||
{
|
||||
"state": {
|
||||
"reported": {
|
||||
"deviceState": "CONNECTED",
|
||||
"powerState": "ON",
|
||||
"zoneState": "...",
|
||||
"groupState": "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Disconnection Message
|
||||
```json
|
||||
{
|
||||
"state": {
|
||||
"reported": {
|
||||
"deviceState": "DISCONNECTED"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Process Information
|
||||
|
||||
- **IoT Service PID**: 1837
|
||||
- **BoseApp PID**: 1846
|
||||
- **Daemon Manager**: Shepherd
|
||||
- **Service Type**: Non-core (stopped during updates)
|
||||
|
||||
## Registration Flow
|
||||
|
||||
1. Device generates X.509 CSR
|
||||
2. Calls `https://voice.api.bose.io/alexa/certificate`
|
||||
3. Receives device certificate
|
||||
4. Stores cert/key in `/mnt/nv/IoTCerts/`
|
||||
5. Connects to AWS IoT using certificate auth
|
||||
|
||||
## Directory Creation (Init Script)
|
||||
|
||||
```bash
|
||||
mkdir -p /mnt/nv/BoseLog /mnt/nv/IoTCerts /mnt/nv/BoseApp-Persistence/1
|
||||
mkdir -m 700 -p /mnt/nv/BoseApp-Persistence/1/Keys
|
||||
```
|
||||
|
||||
## Error Messages and Debugging
|
||||
|
||||
### Common Log Messages
|
||||
- `"Connection attempt %u to MQTT port at host %s"`
|
||||
- `"MQTT port not available. Retrying in %u seconds"`
|
||||
- `"Device connected with MQTT"`
|
||||
- `"got shadow response: accepted. Payload: %s"`
|
||||
- `"Failed to register device and get certificate, retrying"`
|
||||
|
||||
### Connection States
|
||||
- `"MQTT port is open"`
|
||||
- `"Successfully connected to MQTT server"`
|
||||
- `"Disconnecting from IoT server"`
|
||||
- `"UpdateShadow called when network is not ready"`
|
||||
|
||||
## Integration Points
|
||||
|
||||
### AWS Services
|
||||
- AWS IoT Core (MQTT broker)
|
||||
- AWS IoT Device Management (certificates)
|
||||
- AWS IoT Device Shadows (state sync)
|
||||
|
||||
### Bose Ecosystem
|
||||
- Mobile apps (remote control)
|
||||
- Alexa integration (voice commands)
|
||||
- Multi-room audio (zone coordination)
|
||||
- OTA updates (firmware management)
|
||||
|
||||
## Quick Troubleshooting
|
||||
|
||||
1. **No IoT connectivity**: Check certificate files in `/mnt/nv/IoTCerts/`
|
||||
2. **Certificate errors**: Verify registration endpoint accessibility
|
||||
3. **MQTT failures**: Check both primary and backup endpoints
|
||||
4. **Config issues**: Validate IoT.xml format and clientID uniqueness
|
||||
5. **Service not starting**: Check Shepherd configuration and process status
|
||||
|
||||
## MQTT Monitoring Capabilities
|
||||
|
||||
### Direct Access with Device Credentials
|
||||
```bash
|
||||
# Subscribe to device shadow events (own device only)
|
||||
mosquitto_sub -h a2bhvr9c4wn4ya.iot.us-east-1.amazonaws.com \
|
||||
-p 8883 --cafile /var/lib/iot/rootCA.crt \
|
||||
--cert /mnt/nv/IoTCerts/iot-cert.pem.crt \
|
||||
--key /mnt/nv/IoTCerts/iot-private.pem.key \
|
||||
-t '$aws/things/577ecfcc-2db3-4989-92c9-76d7704f9fb3/shadow/#'
|
||||
```
|
||||
|
||||
### AWS IoT Policy Restrictions
|
||||
- Device certificates limited to own clientID topics only
|
||||
- No wildcard subscriptions across devices
|
||||
- IP/location restrictions may apply
|
||||
- Certificate revocation for unusual activity
|
||||
|
||||
### Alternative Monitoring Methods
|
||||
```bash
|
||||
# Network traffic capture (less intrusive)
|
||||
tcpdump -i eth0 -s0 -w soundtouch_iot.pcap host a2bhvr9c4wn4ya.iot.us-east-1.amazonaws.com
|
||||
|
||||
# Monitor connection patterns
|
||||
tcpdump -i eth0 -n "host a2bhvr9c4wn4ya.iot.us-east-1.amazonaws.com and port 8883"
|
||||
```
|
||||
|
||||
### Expected Message Examples
|
||||
```json
|
||||
// Power state change
|
||||
{"state":{"reported":{"powerState":"ON","deviceState":"CONNECTED"}}}
|
||||
|
||||
// Volume adjustment
|
||||
{"state":{"reported":{"volume":25,"muted":false}}}
|
||||
|
||||
// Zone configuration
|
||||
{"state":{"reported":{"zoneState":"master","groupMembers":["device1"]}}}
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
- TLS 1.2 encryption for all communications
|
||||
- X.509 mutual authentication
|
||||
- Private keys stored with 700 permissions
|
||||
- No hardcoded credentials in binaries
|
||||
- Automatic certificate lifecycle management
|
||||
- **Monitoring Constraints**: Device credentials restricted to own device topics
|
||||
- **Ethical Consideration**: Only monitor devices you own
|
||||
@@ -0,0 +1,370 @@
|
||||
# IoT Configuration Analysis
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides a detailed analysis of the AWS IoT configuration system used by Bose SoundTouch devices, based on firmware backup analysis from ST10 and ST20 models.
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### IoT.xml Location and Content
|
||||
|
||||
The IoT configuration is stored in XML format at:
|
||||
- **Path**: `/mnt/nv/BoseApp-Persistence/1/IoT.xml`
|
||||
- **Purpose**: Contains AWS IoT Core connection parameters
|
||||
|
||||
#### ST20 Configuration
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<Configuration clientID="uuid1"
|
||||
iotEndpoint="a2bhvr9c4wn4ya.iot.us-east-1.amazonaws.com"
|
||||
deployment="PROD" />
|
||||
```
|
||||
|
||||
#### ST10 Configuration
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<Configuration clientID="uuid2"
|
||||
iotEndpoint="a2bhvr9c4wn4ya.iot.us-east-1.amazonaws.com"
|
||||
deployment="PROD" />
|
||||
```
|
||||
|
||||
### Key Observations
|
||||
- Each device has a unique `clientID` (UUID format)
|
||||
- Both devices use the same AWS IoT endpoint
|
||||
- Both are configured for production deployment (`PROD`)
|
||||
|
||||
## Binary Analysis
|
||||
|
||||
### Primary IoT Service Binary
|
||||
|
||||
**Location**: `/opt/Bose/IoT`
|
||||
- **Type**: ARM ELF 32-bit executable
|
||||
- **Purpose**: Main IoT daemon process
|
||||
- **Framework**: AWS IoT SDK for C++
|
||||
|
||||
### Certificate and Key Management
|
||||
|
||||
The IoT binary manages the following certificate files:
|
||||
|
||||
| File | Location | Purpose |
|
||||
|-----------------------|---------------------|-----------------------------|
|
||||
| `iot-cert.pem.crt` | `/mnt/nv/IoTCerts/` | Device client certificate |
|
||||
| `iot-private.pem.key` | `/mnt/nv/IoTCerts/` | Device private key |
|
||||
| `rootCA.crt` | `/var/lib/iot/` | AWS IoT Root CA certificate |
|
||||
|
||||
### Certificate Registration Process
|
||||
|
||||
1. **CSR Generation**: Device generates X.509 certificate signing request
|
||||
2. **Registration Endpoint**: `https://voice.api.bose.io/alexa/certificate`
|
||||
3. **Certificate Storage**: Certificates stored in `/mnt/nv/IoTCerts/`
|
||||
4. **Automatic Provisioning**: Process appears to be automated during device setup
|
||||
|
||||
## Protocol Analysis
|
||||
|
||||
### Connection Details
|
||||
|
||||
- **Protocol**: MQTT over TLS 1.2
|
||||
- **Port**: Standard MQTT over SSL (likely 8883)
|
||||
- **Authentication**: X.509 client certificate mutual authentication
|
||||
- **Endpoint Redundancy**:
|
||||
- Primary (hardcoded): `amqmidtcohfms.iot.us-east-1.amazonaws.com`
|
||||
- Fallback (XML config): `a2bhvr9c4wn4ya.iot.us-east-1.amazonaws.com`
|
||||
|
||||
### AWS IoT Device Shadow Integration
|
||||
|
||||
The system uses AWS IoT Device Shadows for state management:
|
||||
|
||||
#### Topic Structure
|
||||
```
|
||||
$aws/things/{thing_name}/shadow/update
|
||||
$aws/things/{thing_name}/shadow/update/accepted
|
||||
$aws/things/{thing_name}/shadow/update/rejected
|
||||
$aws/things/{thing_name}/shadow/delete
|
||||
```
|
||||
|
||||
#### Shadow JSON Format
|
||||
```json
|
||||
{
|
||||
"state": {
|
||||
"desired": {},
|
||||
"reported": {
|
||||
"deviceState": "CONNECTED|DISCONNECTED",
|
||||
"powerState": "ON|OFF",
|
||||
"zoneState": "...",
|
||||
"groupState": "..."
|
||||
}
|
||||
},
|
||||
"version": 0,
|
||||
"clientToken": "...",
|
||||
"timestamp": 0
|
||||
}
|
||||
```
|
||||
|
||||
### Message Types
|
||||
|
||||
1. **Device State Updates**
|
||||
- Connection status (`CONNECTED`/`DISCONNECTED`)
|
||||
- Power state changes
|
||||
- Audio zone configuration
|
||||
- Multi-room grouping status
|
||||
|
||||
2. **Shadow Delta Processing**
|
||||
- Receives desired state changes
|
||||
- Updates device configuration
|
||||
- Reports new state back to shadow
|
||||
|
||||
## System Integration
|
||||
|
||||
### Service Management
|
||||
|
||||
The IoT service is managed by the Shepherd daemon system:
|
||||
|
||||
**Configuration**: `/opt/Bose/etc/Shepherd-noncore.xml`
|
||||
```xml
|
||||
<ShepherdConfig>
|
||||
<daemon name="STSCertified"/>
|
||||
<daemon name="IoT"/>
|
||||
<daemon name="TPDA">
|
||||
<arg>-c</arg>
|
||||
<arg>/opt/Bose/etc/Voice.xml</arg>
|
||||
</daemon>
|
||||
</ShepherdConfig>
|
||||
```
|
||||
|
||||
### Directory Structure Creation
|
||||
|
||||
The SoundTouch init script (`/etc/init.d/SoundTouch`) ensures proper directory structure:
|
||||
|
||||
```bash
|
||||
mkdir -p /mnt/nv/BoseLog /mnt/nv/IoTCerts /mnt/nv/BoseApp-Persistence/1
|
||||
mkdir -m 700 -p /mnt/nv/BoseApp-Persistence/1/Keys
|
||||
```
|
||||
|
||||
### Process Information
|
||||
|
||||
From runtime analysis (`/var/run/shepherd/pids`):
|
||||
- IoT service runs as PID 1837
|
||||
- BoseApp service runs as PID 1846
|
||||
- Both services are active during normal operation
|
||||
|
||||
## Configuration Dependencies
|
||||
|
||||
### Files That Reference IoT Configuration
|
||||
|
||||
1. **IoT Binary** (`/opt/Bose/IoT`)
|
||||
- Primary consumer of IoT.xml configuration
|
||||
- Contains hardcoded backup endpoints
|
||||
- Manages certificate lifecycle
|
||||
|
||||
2. **BoseApp Binary** (`/opt/Bose/BoseApp`)
|
||||
- References BoseApp-Persistence directory structure
|
||||
- May trigger IoT updates based on device state changes
|
||||
|
||||
3. **SoundTouch Init Script** (`/etc/init.d/SoundTouch`)
|
||||
- Creates necessary directory structure
|
||||
- Ensures proper permissions for certificate storage
|
||||
|
||||
4. **Shepherd Configuration** (`/opt/Bose/etc/Shepherd-noncore.xml`)
|
||||
- Defines IoT service startup parameters
|
||||
- Manages service lifecycle
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Certificate Management
|
||||
- Private keys stored with 700 permissions
|
||||
- Certificates managed automatically by the device
|
||||
- Registration process appears to use device-specific authentication
|
||||
|
||||
### Network Security
|
||||
- All communication over TLS 1.2
|
||||
- Mutual authentication using X.509 certificates
|
||||
- AWS IoT Core provides additional access controls
|
||||
|
||||
### Configuration Protection
|
||||
- Configuration files stored in persistent storage
|
||||
- Directory structure created with appropriate permissions
|
||||
- No hardcoded credentials in binaries (uses certificate-based auth)
|
||||
|
||||
## Integration Points
|
||||
|
||||
### AWS Services
|
||||
- **AWS IoT Core**: Primary messaging and device management
|
||||
- **AWS IoT Device Management**: Certificate provisioning
|
||||
- **AWS IoT Device Shadows**: State synchronization
|
||||
|
||||
### Bose Services
|
||||
- **Mobile Applications**: Remote control and monitoring
|
||||
- **Alexa Integration**: Voice control capabilities
|
||||
- **Multi-room Audio**: Zone and group coordination
|
||||
|
||||
### Device Functions
|
||||
- **Power Management**: Remote power on/off
|
||||
- **Audio Control**: Volume, source selection
|
||||
- **Network Configuration**: WiFi and connectivity settings
|
||||
- **Firmware Updates**: OTA update coordination
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Certificate Problems**
|
||||
- Check `/mnt/nv/IoTCerts/` for valid certificates
|
||||
- Verify certificate registration endpoint accessibility
|
||||
- Ensure proper file permissions (600 for keys)
|
||||
|
||||
2. **Connection Issues**
|
||||
- Verify both primary and fallback endpoints
|
||||
- Check TLS 1.2 support and cipher suites
|
||||
- Validate clientID uniqueness
|
||||
|
||||
3. **Configuration Issues**
|
||||
- Ensure IoT.xml has proper XML format
|
||||
- Verify clientID is valid UUID format
|
||||
- Check deployment parameter matches environment
|
||||
|
||||
### Debug Information
|
||||
|
||||
The IoT binary provides extensive logging for:
|
||||
- MQTT connection attempts and status
|
||||
- Certificate loading and validation
|
||||
- Shadow message processing
|
||||
- Network state changes
|
||||
|
||||
## MQTT Monitoring and Security Considerations
|
||||
|
||||
### Direct MQTT Access with Device Credentials
|
||||
|
||||
With access to the device's private key and certificate, it's technically possible to subscribe to MQTT events:
|
||||
|
||||
```bash
|
||||
# Subscribe to device shadow events
|
||||
mosquitto_sub -h a2bhvr9c4wn4ya.iot.us-east-1.amazonaws.com \
|
||||
-p 8883 --cafile /var/lib/iot/rootCA.crt \
|
||||
--cert /mnt/nv/IoTCerts/iot-cert.pem.crt \
|
||||
--key /mnt/nv/IoTCerts/iot-private.pem.key \
|
||||
-t '$aws/things/_uuid_/shadow/#'
|
||||
```
|
||||
|
||||
### Security Constraints and Limitations
|
||||
|
||||
#### AWS IoT Policy Restrictions
|
||||
Device certificates are bound to specific policies that typically restrict:
|
||||
- Access to device-specific topics only (`$aws/things/{clientID}/shadow/*`)
|
||||
- No wildcard subscriptions across multiple devices
|
||||
- Limited publish/subscribe permissions
|
||||
- Possible IP geolocation restrictions
|
||||
|
||||
#### Example Policy Structure
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": "iot:Connect",
|
||||
"Resource": "arn:aws:iot:us-east-1:*:client/${iot:ClientId}"
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["iot:Publish", "iot:Subscribe", "iot:Receive"],
|
||||
"Resource": [
|
||||
"arn:aws:iot:us-east-1:*:topic/$aws/things/${iot:ClientId}/shadow/*",
|
||||
"arn:aws:iot:us-east-1:*:topicfilter/$aws/things/${iot:ClientId}/shadow/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Additional Security Measures
|
||||
- Certificate revocation for unusual activity
|
||||
- Device fingerprinting and connection frequency limits
|
||||
- Service shutdown timeline (May 2026) affecting endpoint availability
|
||||
|
||||
### Alternative Monitoring Approaches
|
||||
|
||||
#### Network Traffic Capture
|
||||
A less intrusive method to analyze MQTT communication patterns:
|
||||
|
||||
```bash
|
||||
# Capture encrypted MQTT traffic from the actual device
|
||||
tcpdump -i eth0 -s0 -w soundtouch_iot.pcap host a2bhvr9c4wn4ya.iot.us-east-1.amazonaws.com
|
||||
|
||||
# Monitor connection patterns
|
||||
tcpdump -i eth0 -n "host a2bhvr9c4wn4ya.iot.us-east-1.amazonaws.com and port 8883"
|
||||
```
|
||||
|
||||
#### Local MQTT Broker Setup
|
||||
For development and testing, create a local MQTT broker that mimics AWS IoT behavior:
|
||||
|
||||
```bash
|
||||
# Install and configure Mosquitto
|
||||
sudo apt-get install mosquitto mosquitto-clients
|
||||
|
||||
# Create test shadow topics
|
||||
mosquitto_pub -h localhost -t '$aws/things/test-device/shadow/update' \
|
||||
-m '{"state":{"reported":{"deviceState":"CONNECTED"}}}'
|
||||
```
|
||||
|
||||
### Ethical and Legal Considerations
|
||||
|
||||
- **Device Ownership**: Only monitor devices you own
|
||||
- **Terms of Service**: Using credentials outside device context may violate Bose ToS
|
||||
- **Unauthorized Access**: Accessing Bose's AWS infrastructure could be considered inappropriate
|
||||
- **Research Purpose**: Limit monitoring to understanding message formats for local alternatives
|
||||
|
||||
### Expected Message Examples
|
||||
|
||||
If monitoring is successful, typical shadow messages include:
|
||||
|
||||
```json
|
||||
// Power state change
|
||||
{
|
||||
"state": {
|
||||
"reported": {
|
||||
"powerState": "ON",
|
||||
"deviceState": "CONNECTED",
|
||||
"timestamp": 1703875200
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Volume adjustment
|
||||
{
|
||||
"state": {
|
||||
"reported": {
|
||||
"volume": 25,
|
||||
"muted": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Zone configuration
|
||||
{
|
||||
"state": {
|
||||
"reported": {
|
||||
"zoneState": "master",
|
||||
"groupMembers": ["device1", "device2"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Recommended Research Approach
|
||||
|
||||
1. **Document Message Formats**: Capture and analyze JSON structures
|
||||
2. **Understand State Transitions**: Map device actions to shadow updates
|
||||
3. **Build Local Alternative**: Use insights to create local MQTT shadow service
|
||||
4. **Prepare for Service Shutdown**: Develop migration strategy before May 2026
|
||||
|
||||
## Conclusion
|
||||
|
||||
The Bose SoundTouch IoT configuration system is a sophisticated implementation using AWS IoT Core for real-time device management. The system provides:
|
||||
|
||||
- Secure, certificate-based authentication
|
||||
- Reliable bi-directional communication
|
||||
- Comprehensive device state management
|
||||
- Integration with voice assistants and mobile applications
|
||||
- Robust error handling and retry mechanisms
|
||||
|
||||
This architecture enables seamless remote control, monitoring, and coordination of SoundTouch devices across multiple platforms and services.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Spotify Account Addition Implementation Status
|
||||
|
||||
To fully replace Bose cloud services for the Spotify account addition flow in the "Stockholm" SoundTouch application, the following routes have been implemented in the `soundtouch-service`:
|
||||
|
||||
## 1. OAuth Token Exchange (Bose Cloud)
|
||||
|
||||
The Stockholm background worker (in `worker_common.js` and `spotify_worker.js`) performs a token exchange using an authorization code.
|
||||
|
||||
* **Route**: `POST /oauth/account/{account}/music/musicprovider/{sourceID}/token/cs`
|
||||
* **Purpose**: To exchange the Spotify authorization code for a Bose-mediated token.
|
||||
* **Implementation**: `HandleBoseAccountToken` in `pkg/service/handlers/handlers_oauth.go`.
|
||||
* **Registration**: Registered in `cmd/soundtouch-service/main.go` under the `/oauth` route group.
|
||||
|
||||
## 2. Cloud Source Registration (Marge Service)
|
||||
|
||||
The SoundTouch application registers a new music source (e.g., Spotify) with the Bose cloud profile.
|
||||
|
||||
* **Route**: `POST /streaming/account/{account}/source`
|
||||
* **Purpose**: To add the new source (username, credentials, display name) to the user's emulated cloud profile.
|
||||
* **Implementation**: `HandleMargeAddSource` in `pkg/service/handlers/handlers_marge.go`.
|
||||
* **Registration**: Registered in `cmd/soundtouch-service/main.go` under the `/streaming` route group.
|
||||
* **Payload Format**: XML `application/vnd.bose.streaming-v1.1+xml` containing `<source>` with `<username>`, `<sourceproviderid>`, and `<credential type="token_version_3">`.
|
||||
|
||||
## 3. Redirect Handling (Browser to App)
|
||||
|
||||
The `soundtouch://` deep link redirect URI is handled by the management interface which provides the OAuth callback.
|
||||
|
||||
* **Callback Route**: `GET /mgmt/spotify/callback`
|
||||
* **Implementation**: `HandleMgmtSpotifyCallback` in `pkg/service/handlers/handlers_mgmt.go`.
|
||||
* **Confirmation Route**: `POST /mgmt/spotify/confirm` (used by mobile apps for deep-link codes).
|
||||
* **Implementation**: `HandleMgmtSpotifyConfirm` in `pkg/service/handlers/handlers_mgmt.go`.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
1. **Marge Add Source**:
|
||||
* `HandleMargeAddSource` in `pkg/service/handlers/handlers_marge.go` parses the incoming XML and persists the new source to the `DataStore` for the corresponding account.
|
||||
|
||||
2. **OAuth Account Token Exchange**:
|
||||
* `HandleBoseAccountToken` in `pkg/service/handlers/handlers_oauth.go` supports the `/oauth/account/.../token/cs` path.
|
||||
* It responds with a JSON payload including `access_token` and `token_type` "Bearer" after exchanging the code via `ExchangeCodeAndStore`.
|
||||
|
||||
3. **Router Registration**:
|
||||
* These paths are registered in `cmd/soundtouch-service/main.go` within the `/streaming`, `/oauth`, and `/mgmt` route blocks.
|
||||
@@ -6,13 +6,13 @@ This document provides a comprehensive overview of the upstream Bose cloud servi
|
||||
|
||||
SoundTouch devices use a set of primary domains for their operation. These are often configurable via the `SoundTouchSdkPrivateCfg.xml` file.
|
||||
|
||||
| Service | Primary Domain | Purpose |
|
||||
| :--- | :--- | :--- |
|
||||
| **Marge** | `streaming.bose.com` | Account management, streaming source providers, and preset sync. |
|
||||
| **BMX Registry** | `content.api.bose.io` | Bose Media eXchange service discovery and registry. |
|
||||
| **Stats/Analytics** | `events.api.bosecm.com` | Telemetry, device events, and usage statistics. |
|
||||
| **Software Update** | `worldwide.bose.com` | Firmware update checks and downloads (path: `/updates/soundtouch`). |
|
||||
| **Voice/Alexa** | `voice.api.bose.io` | Token management for Amazon Alexa integration. |
|
||||
| Service | Primary Domain | Purpose |
|
||||
|:--------------------|:------------------------|:--------------------------------------------------------------------|
|
||||
| **Marge** | `streaming.bose.com` | Account management, streaming source providers, and preset sync. |
|
||||
| **BMX Registry** | `content.api.bose.io` | Bose Media eXchange service discovery and registry. |
|
||||
| **Stats/Analytics** | `events.api.bosecm.com` | Telemetry, device events, and usage statistics. |
|
||||
| **Software Update** | `worldwide.bose.com` | Firmware update checks and downloads (path: `/updates/soundtouch`). |
|
||||
| **Voice/Alexa** | `voice.api.bose.io` | Token management for Amazon Alexa integration. |
|
||||
|
||||
## Internal & Development Domains
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# SoundTouch API Comparison: Community Wiki vs Current Implementation
|
||||
|
||||
**Date:** January 2026
|
||||
**Source:** [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)
|
||||
**Source:** [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)
|
||||
**Our Implementation:** Bose-SoundTouch Go Library v1.0
|
||||
|
||||
## Executive Summary
|
||||
@@ -20,84 +20,84 @@ The SoundTouch Plus community wiki documents **87 distinct API endpoints** with
|
||||
|
||||
### ✅ Already Implemented (23 endpoints)
|
||||
|
||||
| Endpoint | Wiki Status | Our Status | Notes |
|
||||
|----------|-------------|------------|-------|
|
||||
| `/info` | ✅ Documented | ✅ Complete | Device information |
|
||||
| `/now_playing` | ✅ Documented | ✅ Complete | Current playback status |
|
||||
| `/key` | ✅ Documented | ✅ Complete | Key press/release simulation |
|
||||
| `/volume` | ✅ Documented | ✅ Complete | Volume and mute control |
|
||||
| `/bass` | ✅ Documented | ✅ Complete | Bass level control |
|
||||
| `/bassCapabilities` | ✅ Documented | ✅ Complete | Bass capability detection |
|
||||
| `/sources` | ✅ Documented | ✅ Complete | Available audio sources |
|
||||
| `/select` | ✅ Documented | ✅ Complete | Source selection |
|
||||
| `/presets` | ✅ Documented | ✅ Complete | Preset configurations (read-only) |
|
||||
| `/getZone` | ✅ Documented | ✅ Complete | Zone status and membership |
|
||||
| `/setZone` | ✅ Documented | ✅ Complete | Zone creation and management |
|
||||
| `/addZoneSlave` | ✅ Documented | ✅ Complete | Add device to zone |
|
||||
| `/removeZoneSlave` | ✅ Documented | ✅ Complete | Remove device from zone |
|
||||
| `/capabilities` | ✅ Documented | ✅ Complete | Device feature capabilities |
|
||||
| `/audiodspcontrols` | ✅ Documented | ✅ Complete | Audio DSP modes and video sync |
|
||||
| `/audioproducttonecontrols` | ✅ Documented | ✅ Complete | Advanced bass/treble controls |
|
||||
| `/audioproductlevelcontrols` | ✅ Documented | ✅ Complete | Speaker level controls |
|
||||
| `/name` (GET/POST) | ✅ Documented | ✅ Complete | Device name management |
|
||||
| `/balance` | ✅ Documented | ✅ Complete | Stereo balance control |
|
||||
| `/clockTime` | ✅ Documented | ✅ Complete | Device time management |
|
||||
| `/clockDisplay` | ✅ Documented | ✅ Complete | Clock display settings |
|
||||
| `/networkInfo` | ✅ Documented | ✅ Complete | Network connectivity info |
|
||||
| `/requestToken` | ✅ Documented | ✅ Complete | Bearer token generation |
|
||||
| Endpoint | Wiki Status | Our Status | Notes |
|
||||
|------------------------------|--------------|------------|-----------------------------------|
|
||||
| `/info` | ✅ Documented | ✅ Complete | Device information |
|
||||
| `/now_playing` | ✅ Documented | ✅ Complete | Current playback status |
|
||||
| `/key` | ✅ Documented | ✅ Complete | Key press/release simulation |
|
||||
| `/volume` | ✅ Documented | ✅ Complete | Volume and mute control |
|
||||
| `/bass` | ✅ Documented | ✅ Complete | Bass level control |
|
||||
| `/bassCapabilities` | ✅ Documented | ✅ Complete | Bass capability detection |
|
||||
| `/sources` | ✅ Documented | ✅ Complete | Available audio sources |
|
||||
| `/select` | ✅ Documented | ✅ Complete | Source selection |
|
||||
| `/presets` | ✅ Documented | ✅ Complete | Preset configurations (read-only) |
|
||||
| `/getZone` | ✅ Documented | ✅ Complete | Zone status and membership |
|
||||
| `/setZone` | ✅ Documented | ✅ Complete | Zone creation and management |
|
||||
| `/addZoneSlave` | ✅ Documented | ✅ Complete | Add device to zone |
|
||||
| `/removeZoneSlave` | ✅ Documented | ✅ Complete | Remove device from zone |
|
||||
| `/capabilities` | ✅ Documented | ✅ Complete | Device feature capabilities |
|
||||
| `/audiodspcontrols` | ✅ Documented | ✅ Complete | Audio DSP modes and video sync |
|
||||
| `/audioproducttonecontrols` | ✅ Documented | ✅ Complete | Advanced bass/treble controls |
|
||||
| `/audioproductlevelcontrols` | ✅ Documented | ✅ Complete | Speaker level controls |
|
||||
| `/name` (GET/POST) | ✅ Documented | ✅ Complete | Device name management |
|
||||
| `/balance` | ✅ Documented | ✅ Complete | Stereo balance control |
|
||||
| `/clockTime` | ✅ Documented | ✅ Complete | Device time management |
|
||||
| `/clockDisplay` | ✅ Documented | ✅ Complete | Clock display settings |
|
||||
| `/networkInfo` | ✅ Documented | ✅ Complete | Network connectivity info |
|
||||
| `/requestToken` | ✅ Documented | ✅ Complete | Bearer token generation |
|
||||
|
||||
### 🔥 High Priority Missing (20 endpoints)
|
||||
|
||||
| Endpoint | Wiki Status | Priority | Use Case |
|
||||
|----------|-------------|----------|----------|
|
||||
| `/storePreset` | ✅ Detailed | **HIGH** | Save stations/playlists to presets |
|
||||
| `/removePreset` | ✅ Detailed | **HIGH** | Delete saved presets |
|
||||
| `/selectPreset` | ✅ Detailed | **HIGH** | Play preset by ID |
|
||||
| `/setMusicServiceAccount` | ✅ Detailed | **HIGH** | Add Spotify/Pandora accounts |
|
||||
| `/removeMusicServiceAccount` | ✅ Detailed | **HIGH** | Remove music service accounts |
|
||||
| `/searchStation` | ✅ Detailed | **HIGH** | Find Pandora/Spotify content |
|
||||
| `/addStation` | ✅ Detailed | **HIGH** | Add stations to favorites |
|
||||
| `/removeStation` | ✅ Detailed | **HIGH** | Remove stations from favorites |
|
||||
| `/navigate` | ✅ Detailed | **HIGH** | Browse music libraries/services |
|
||||
| `/search` | ✅ Detailed | **HIGH** | Search music content |
|
||||
| `/userPlayControl` | ✅ Detailed | **HIGH** | Play/pause/stop controls |
|
||||
| `/userRating` | ✅ Detailed | **HIGH** | Thumbs up/down ratings |
|
||||
| `/recents` | ✅ Detailed | **HIGH** | Recently played content |
|
||||
| `/standby` | ✅ Detailed | **HIGH** | Power management |
|
||||
| `/powerManagement` | ✅ Detailed | **HIGH** | Power state information |
|
||||
| `/lowPowerStandby` | ✅ Detailed | **HIGH** | Low-power mode |
|
||||
| `/listMediaServers` | ✅ Detailed | **HIGH** | UPnP/DLNA server discovery |
|
||||
| `/serviceAvailability` | ✅ Detailed | **HIGH** | Source availability status |
|
||||
| `/introspect` | ✅ Detailed | **HIGH** | Music service account status |
|
||||
| `/language` | ✅ Detailed | **HIGH** | Device language settings |
|
||||
| Endpoint | Wiki Status | Priority | Use Case |
|
||||
|------------------------------|-------------|----------|------------------------------------|
|
||||
| `/storePreset` | ✅ Detailed | **HIGH** | Save stations/playlists to presets |
|
||||
| `/removePreset` | ✅ Detailed | **HIGH** | Delete saved presets |
|
||||
| `/selectPreset` | ✅ Detailed | **HIGH** | Play preset by ID |
|
||||
| `/setMusicServiceAccount` | ✅ Detailed | **HIGH** | Add Spotify/Pandora accounts |
|
||||
| `/removeMusicServiceAccount` | ✅ Detailed | **HIGH** | Remove music service accounts |
|
||||
| `/searchStation` | ✅ Detailed | **HIGH** | Find Pandora/Spotify content |
|
||||
| `/addStation` | ✅ Detailed | **HIGH** | Add stations to favorites |
|
||||
| `/removeStation` | ✅ Detailed | **HIGH** | Remove stations from favorites |
|
||||
| `/navigate` | ✅ Detailed | **HIGH** | Browse music libraries/services |
|
||||
| `/search` | ✅ Detailed | **HIGH** | Search music content |
|
||||
| `/userPlayControl` | ✅ Detailed | **HIGH** | Play/pause/stop controls |
|
||||
| `/userRating` | ✅ Detailed | **HIGH** | Thumbs up/down ratings |
|
||||
| `/recents` | ✅ Detailed | **HIGH** | Recently played content |
|
||||
| `/standby` | ✅ Detailed | **HIGH** | Power management |
|
||||
| `/powerManagement` | ✅ Detailed | **HIGH** | Power state information |
|
||||
| `/lowPowerStandby` | ✅ Detailed | **HIGH** | Low-power mode |
|
||||
| `/listMediaServers` | ✅ Detailed | **HIGH** | UPnP/DLNA server discovery |
|
||||
| `/serviceAvailability` | ✅ Detailed | **HIGH** | Source availability status |
|
||||
| `/introspect` | ✅ Detailed | **HIGH** | Music service account status |
|
||||
| `/language` | ✅ Detailed | **HIGH** | Device language settings |
|
||||
|
||||
### 🎵 Music Service Management (12 endpoints)
|
||||
|
||||
| Category | Endpoints | Wiki Coverage | Notes |
|
||||
|----------|-----------|---------------|-------|
|
||||
| **Account Management** | `/setMusicServiceAccount`, `/removeMusicServiceAccount` | ✅ Full XML examples | Pandora, Spotify, NAS setup |
|
||||
| **Station Management** | `/searchStation`, `/addStation`, `/removeStation` | ✅ Pandora tested | Station discovery and favorites |
|
||||
| **Content Navigation** | `/navigate`, `/search` | ✅ Detailed examples | Music library browsing |
|
||||
| **Track Information** | `/trackInfo`, `/introspect` | ✅ Service-specific | Extended metadata |
|
||||
| Category | Endpoints | Wiki Coverage | Notes |
|
||||
|------------------------|---------------------------------------------------------|---------------------|---------------------------------|
|
||||
| **Account Management** | `/setMusicServiceAccount`, `/removeMusicServiceAccount` | ✅ Full XML examples | Pandora, Spotify, NAS setup |
|
||||
| **Station Management** | `/searchStation`, `/addStation`, `/removeStation` | ✅ Pandora tested | Station discovery and favorites |
|
||||
| **Content Navigation** | `/navigate`, `/search` | ✅ Detailed examples | Music library browsing |
|
||||
| **Track Information** | `/trackInfo`, `/introspect` | ✅ Service-specific | Extended metadata |
|
||||
|
||||
### 🏠 Smart Home Integration (15 endpoints)
|
||||
|
||||
| Category | Endpoints | Wiki Coverage | Notes |
|
||||
|----------|-----------|---------------|-------|
|
||||
| **Notifications** | `/speaker`, `/playNotification` | ✅ TTS examples | Text-to-speech, URL playback |
|
||||
| **Power Management** | `/standby`, `/powerManagement`, `/lowPowerStandby` | ✅ Complete | Smart home automation |
|
||||
| **Network Management** | `/performWirelessSiteSurvey`, `/addWirelessProfile`, `/getActiveWirelessProfile` | ✅ WiFi setup | Network configuration |
|
||||
| **Bluetooth** | `/enterBluetoothPairing`, `/clearBluetoothPaired`, `/bluetoothInfo` | ✅ Pairing control | Bluetooth management |
|
||||
| **Source Control** | `/selectLastSource`, `/selectLastSoundTouchSource`, `/selectLocalSource` | ✅ Source switching | Quick source access |
|
||||
| Category | Endpoints | Wiki Coverage | Notes |
|
||||
|------------------------|----------------------------------------------------------------------------------|--------------------|------------------------------|
|
||||
| **Notifications** | `/speaker`, `/playNotification` | ✅ TTS examples | Text-to-speech, URL playback |
|
||||
| **Power Management** | `/standby`, `/powerManagement`, `/lowPowerStandby` | ✅ Complete | Smart home automation |
|
||||
| **Network Management** | `/performWirelessSiteSurvey`, `/addWirelessProfile`, `/getActiveWirelessProfile` | ✅ WiFi setup | Network configuration |
|
||||
| **Bluetooth** | `/enterBluetoothPairing`, `/clearBluetoothPaired`, `/bluetoothInfo` | ✅ Pairing control | Bluetooth management |
|
||||
| **Source Control** | `/selectLastSource`, `/selectLastSoundTouchSource`, `/selectLocalSource` | ✅ Source switching | Quick source access |
|
||||
|
||||
### 📱 Advanced Device Features (19 endpoints)
|
||||
|
||||
| Category | Endpoints | Wiki Coverage | Notes |
|
||||
|----------|-----------|---------------|-------|
|
||||
| **Stereo Pairs** | `/getGroup`, `/addGroup`, `/removeGroup`, `/updateGroup` | ✅ ST-10 specific | L/R speaker pairing |
|
||||
| **System Info** | `/soundTouchConfigurationStatus`, `/systemtimeout`, `/rebroadcastlatencymode` | ✅ Configuration | Device state management |
|
||||
| **Software Updates** | `/swUpdateCheck`, `/swUpdateQuery`, `/swUpdateAbort`, `/swUpdateStart` | ✅ Update process | Firmware management |
|
||||
| **Audio Processing** | `/DSPMonoStereo`, `/audiospeakerattributeandsetting` | ✅ Hardware-specific | Advanced audio features |
|
||||
| Category | Endpoints | Wiki Coverage | Notes |
|
||||
|----------------------|-------------------------------------------------------------------------------|---------------------|-------------------------|
|
||||
| **Stereo Pairs** | `/getGroup`, `/addGroup`, `/removeGroup`, `/updateGroup` | ✅ ST-10 specific | L/R speaker pairing |
|
||||
| **System Info** | `/soundTouchConfigurationStatus`, `/systemtimeout`, `/rebroadcastlatencymode` | ✅ Configuration | Device state management |
|
||||
| **Software Updates** | `/swUpdateCheck`, `/swUpdateQuery`, `/swUpdateAbort`, `/swUpdateStart` | ✅ Update process | Firmware management |
|
||||
| **Audio Processing** | `/DSPMonoStereo`, `/audiospeakerattributeandsetting` | ✅ Hardware-specific | Advanced audio features |
|
||||
|
||||
---
|
||||
|
||||
@@ -137,7 +137,7 @@ The SoundTouch Plus community wiki documents **87 distinct API endpoints** with
|
||||
|
||||
**WebSocket Events Documented:**
|
||||
- `presetsUpdated` - Preset changes
|
||||
- `groupUpdated` - Stereo pair changes
|
||||
- `groupUpdated` - Stereo pair changes
|
||||
- `zoneUpdated` - Multi-room changes
|
||||
- `nowPlayingUpdated` - Source/playback changes
|
||||
- `volumeUpdated` - Volume/mute changes
|
||||
@@ -194,7 +194,7 @@ func (c *Client) RateCurrentTrack(rating RatingValue) error
|
||||
func (c *Client) CreateStereoPair(leftIP, rightIP string, name string) error
|
||||
func (c *Client) GetStereoPairStatus() (*StereoPair, error)
|
||||
|
||||
// System Management
|
||||
// System Management
|
||||
func (c *Client) CheckSoftwareUpdate() (*UpdateInfo, error)
|
||||
func (c *Client) GetSystemTimeout() (*TimeoutConfig, error)
|
||||
```
|
||||
@@ -283,7 +283,7 @@ The SoundTouch Plus Wiki represents a **treasure trove** of production-ready API
|
||||
### Key Opportunities:
|
||||
- 🎯 **3x Coverage Expansion**: From 23 to 87+ endpoints
|
||||
- 🏠 **Smart Home Ready**: Complete automation integration
|
||||
- 🎵 **Music Service Integration**: Full streaming service support
|
||||
- 🎵 **Music Service Integration**: Full streaming service support
|
||||
- 📱 **Professional Features**: Advanced audio and system control
|
||||
- ✅ **Production Ready**: Real-world tested examples and error handling
|
||||
|
||||
@@ -297,4 +297,4 @@ The SoundTouch Plus Wiki represents a **treasure trove** of production-ready API
|
||||
|
||||
---
|
||||
|
||||
*Note: All endpoints documented in the wiki are tested against real hardware. Device-specific limitations are clearly documented with compatibility matrices for ST-10, ST-300, and other SoundTouch models.*
|
||||
*Note: All endpoints documented in the wiki are tested against real hardware. Device-specific limitations are clearly documented with compatibility matrices for ST-10, ST-300, and other SoundTouch models.*
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
# Bose SoundTouch — Community Tools for Post-EOL Preservation
|
||||
|
||||
> **Context:** Bose announced the shutdown of SoundTouch cloud services, extended to **May 6, 2026**. On that date the official SoundTouch app will update to a local-only version. Bose has released the [SoundTouch Web API documentation](https://assets.bosecreative.com/m/496577402d128874/original/SoundTouch-Web-API.pdf) as open-source to enable community-driven development. This document surveys the active community projects, their feature coverage, and open development opportunities.
|
||||
|
||||
---
|
||||
|
||||
## What Bose Is Doing
|
||||
|
||||
After the May 6, 2026 shutdown, the following will **continue to work**:
|
||||
|
||||
- Streaming via Bluetooth, AirPlay, Spotify Connect, and AUX
|
||||
- Local device control and grouping via an updated SoundTouch app
|
||||
- Remote control features (play, pause, skip, volume)
|
||||
- HDMI/optical connections on soundbars
|
||||
|
||||
The following will **stop working**:
|
||||
|
||||
- Physical and app-based presets
|
||||
- In-app music service browsing (TuneIn, Pandora, etc.)
|
||||
- Stereo pairing for SoundTouch 10
|
||||
- Security and firmware updates
|
||||
|
||||
---
|
||||
|
||||
## Community Projects
|
||||
|
||||
### 1. soundcork
|
||||
**[github.com/deborahgu/soundcork](https://github.com/deborahgu/soundcork)**
|
||||
| | |
|
||||
|---|---|
|
||||
| Language | Python |
|
||||
| License | MIT |
|
||||
| Stars | 111 |
|
||||
| Contributors | 8 |
|
||||
| Commits | 287 |
|
||||
| Status | Pre-alpha, actively developed |
|
||||
|
||||
A reverse-engineered intercept API that replaces the Bose cloud servers locally. Works by redirecting the speaker's internal `SoundTouchSdkPrivateCfg.xml` to a self-hosted FastAPI server, emulating the `marge` server (required for basic network functionality) and the `bmx` server (required for TuneIn). Deployable as a Docker container or systemd daemon. The most community-engaged project, with a dedicated discussion thread tracking Bose cloud service status.
|
||||
|
||||
---
|
||||
|
||||
### 2. Überböse API
|
||||
**[github.com/julius-d/ueberboese-api](https://github.com/julius-d/ueberboese-api)**
|
||||
| | |
|
||||
|---|---|
|
||||
| Language | Java (Spring Boot) |
|
||||
| License | MIT |
|
||||
| Stars | 10 |
|
||||
| Contributors | 1 |
|
||||
| Commits | 224 |
|
||||
| Tags/Releases | 163 |
|
||||
| Documentation | [julius-d.github.io/ueberboese-api](https://julius-d.github.io/ueberboese-api/) |
|
||||
|
||||
Reverse-engineers and rebuilds the Bose streaming HTTP API. Unique in publishing a machine-readable OpenAPI specification (`ueberboese-api.yaml`) and comprehensive request logging — making it the best research instrument for understanding what speakers actually call upstream. Implements Spotify OAuth integration and TuneIn. Companion to the Überböse App.
|
||||
|
||||
---
|
||||
|
||||
### 3. Überböse App
|
||||
**[github.com/julius-d/ueberboese-app](https://github.com/julius-d/ueberboese-app)**
|
||||
| | |
|
||||
|---|---|
|
||||
| Language | Flutter (Dart) |
|
||||
| License | MIT |
|
||||
| Latest version | 0.26.0 (March 2026) |
|
||||
| Distribution | [F-Droid](https://f-droid.org/en/packages/io.github.juliusd.ueberboese.app/) |
|
||||
| Platform | Android |
|
||||
|
||||
The only native installable phone app in the ecosystem. Pairs with the Überböse API server. Features: preset view/play/reprogram, multi-room zone management, volume control, now-playing display, Spotify authentication setup. Controls speakers directly via the local SoundTouch WebServices API (no server required for basic control).
|
||||
|
||||
---
|
||||
|
||||
### 4. SoundTouch Hybrid 2026
|
||||
**[github.com/TJGigs/Bose-SoundTouch-Hybrid-2026](https://github.com/TJGigs/Bose-SoundTouch-Hybrid-2026)**
|
||||
| | |
|
||||
|---|---|
|
||||
| Language | Node.js (JavaScript) |
|
||||
| License | — |
|
||||
| Stars | 1 |
|
||||
| Commits | 3 (V1) / 12 (V3) |
|
||||
| Status | Experimental / testing |
|
||||
|
||||
A self-hosted private cloud that emulates and replaces the Bose Cloud Service. Runs locally on a NAS or PC, intercepts the complex server handshakes needed to keep the SoundTouch infrastructure functional. Relies on **Music Assistant** for backend audio routing and provider aggregation. Features a setup wizard including USB config generation (`OverrideSdkPrivateCfg.xml`) and an on-screen Bose Cloud Emulation Setup guide. Targets users who want the broadest streaming provider support via Music Assistant's ecosystem.
|
||||
|
||||
---
|
||||
|
||||
### 5. OpenCloudTouch (OCT)
|
||||
**[github.com/scheilch/opencloudtouch](https://github.com/scheilch/opencloudtouch)**
|
||||
| | |
|
||||
|---|---|
|
||||
| Language | Python (FastAPI) + TypeScript (React) |
|
||||
| License | Apache 2.0 |
|
||||
| Stars | 9 |
|
||||
| Commits | 313 |
|
||||
| Latest release | v1.1.1 (April 12, 2026) |
|
||||
| Documentation | GitHub Wiki (EN/DE) |
|
||||
|
||||
A single Docker container combining a FastAPI backend and React frontend. The most production-ready project in the ecosystem in terms of release discipline and deployment accessibility. Features: internet radio with full hardware preset support (buttons 1–6), responsive web UI, device discovery via SSDP/UPnP, multi-room zone management, BMX-compatible endpoints, TuneIn stream resolver, RadioBrowser as a built-in first-class search provider, and pre-built Raspberry Pi SD card images for Pi 3/4/5. Deployable on amd64, arm64, and arm/v7. Documented in English and German. Spotify and Music Assistant integration are on the roadmap.
|
||||
|
||||
---
|
||||
|
||||
### 6. AfterTouch
|
||||
**[github.com/gesellix/Bose-SoundTouch](https://github.com/gesellix/Bose-SoundTouch)** by gesellix
|
||||
| | |
|
||||
|---|---|
|
||||
| Language | Go |
|
||||
| License | MIT |
|
||||
| Stars | 16 |
|
||||
| Contributors | 2 |
|
||||
| Commits | 217 |
|
||||
| Releases | 51 (latest: v0.28.0, Feb 15, 2026) |
|
||||
| Documentation | [gesellix.github.io/Bose-SoundTouch](https://gesellix.github.io/Bose-SoundTouch/) |
|
||||
|
||||
The most comprehensive single toolkit in the ecosystem. Comprises three components: a Go library (importable package), a CLI (`soundtouch-cli`), and a local cloud emulation service (`soundtouch-service`). Covers the widest range of dimensions of any single project. Implements the complete Bose Spotify OAuth relay including surrogate secret generation and token refresh proxy. Includes a built-in DNS server for device redirection without SSH, HTTPS/custom CA injection, HTTP session recording, traffic proxy/logging, and a web management UI. Tested on real SoundTouch 10 and 20 hardware. Has a Patreon for ongoing support.
|
||||
|
||||
---
|
||||
|
||||
### 7. soundcork-stockholm-app
|
||||
**[github.com/krahl/soundcork-stockholm-app](https://github.com/krahl/soundcork-stockholm-app)**
|
||||
| | |
|
||||
|---|---|
|
||||
| Language | Java |
|
||||
| License | — |
|
||||
| Stars | 2 |
|
||||
| Commits | 21 |
|
||||
| Status | Active development, bugs expected |
|
||||
|
||||
A Java-based middleware that hosts the original Bose Stockholm frontend (extracted from the APK) in a local web browser at `http://127.0.0.1:8088/`. Bridges the Stockholm UI to local speakers via an HTTP proxy that resolves cross-origin issues, with SSDP-based device discovery and JSON state persistence. Unlike every other tool in the ecosystem, it runs the **official Bose UI** rather than a custom replacement — preserving the familiar Bose UX at the cost of requiring the Stockholm APK. Notable limitations: OAuth flows are unreliable, and WebSocket connections to speakers over HTTPS have blocking issues. Works alongside soundcork's backend for full cloud emulation.
|
||||
|
||||
---
|
||||
|
||||
### 8. jaas666/bose-soundtouch-web-api (Reference)
|
||||
**[github.com/jaas666/bose-soundtouch-web-api](https://github.com/jaas666/bose-soundtouch-web-api)**
|
||||
|
||||
Community-maintained Markdown conversion of the official Bose SoundTouch Web API PDF (v1.0, January 7, 2026). Useful as a developer reference. Not a deployable tool.
|
||||
|
||||
---
|
||||
|
||||
## Feature Coverage Matrix
|
||||
|
||||
Legend: ● Yes/complete · ◑ Partial/planned · ○ No
|
||||
|
||||
| Dimension | soundcork | Überböse API | Überböse App | ST Hybrid 2026 | OpenCloudTouch | AfterTouch | Stockholm App |
|
||||
|----------------------------------------------------|:---------:|:------------:|:------------:|:--------------:|:--------------:|:----------:|:-------------:|
|
||||
| **① App layer — local HTTP/WS control** | | | | | | | |
|
||||
| Playback control (play/pause/vol) | ○ | ○ | ● | ● | ● | ● | ● |
|
||||
| Preset view & trigger | ○ | ○ | ● | ● | ● | ● | ● |
|
||||
| Preset write / reprogram | ○ | ○ | ● | ● | ◑ | ● | ● |
|
||||
| Multi-room zone management | ○ | ○ | ● | ● | ● | ● | ● |
|
||||
| Now playing / status display | ○ | ○ | ● | ● | ● | ● | ● |
|
||||
| Device discovery (SSDP/mDNS) | ○ | ○ | ● | ○ | ● | ● | ● |
|
||||
| WebSocket real-time events | ○ | ○ | ◑ | ● | ◑ | ● | ◑ |
|
||||
| **② Cloud/service layer — replaces Bose upstream** | | | | | | | |
|
||||
| Marge server emulation | ● | ● | ○ | ● | ○ | ● | ○ |
|
||||
| BMX / content registry | ◑ | ◑ | ○ | ● | ● | ● | ○ |
|
||||
| Account / OAuth token relay | ○ | ● | ○ | ◑ | ○ | ● | ◑ |
|
||||
| Preset sync (cloud-side) | ● | ● | ○ | ● | ○ | ● | ○ |
|
||||
| Recents sync | ● | ◑ | ○ | ◑ | ○ | ● | ○ |
|
||||
| Sources / device info persistence | ● | ● | ○ | ● | ○ | ● | ○ |
|
||||
| Stereo group CRUD (ST10 pairs) | ● | ○ | ○ | ○ | ○ | ● | ○ |
|
||||
| **③ Device redirection — USB/SSH setup** | | | | | | | |
|
||||
| Setup wizard / guided redirect | ◑ | ◑ | ○ | ● | ● | ● | ○ |
|
||||
| USB image / config generation | ○ | ○ | ○ | ● | ○ | ◑ | ○ |
|
||||
| HTTPS / custom CA support | ○ | ○ | ○ | ○ | ○ | ● | ○ |
|
||||
| **④ Streaming provider integration** | | | | | | | |
|
||||
| Internet radio (RadioBrowser) | ○ | ◑ | ◑ | ◑ | ● | ◑ | ○ |
|
||||
| TuneIn stream resolver | ● | ● | ● | ● | ● | ● | ● |
|
||||
| Spotify OAuth / Connect | ○ | ● | ● | ◑ | ◑ | ● | ◑ |
|
||||
| Pandora | ○ | ○ | ○ | ○ | ○ | ● | ◑ |
|
||||
| Music Assistant backend | ○ | ○ | ○ | ● | ◑ | ○ | ○ |
|
||||
| **⑤ Mobile / native app** | | | | | | | |
|
||||
| Android app (installable) | ○ | ○ | ● | ○ | ○ | ○ | ○ |
|
||||
| iOS app | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
|
||||
| Mobile-responsive web UI | ○ | ○ | ○ | ● | ● | ● | ● |
|
||||
| **⑥ Smart home / ecosystem integration** | | | | | | | |
|
||||
| Home Assistant integration | ○ | ○ | ○ | ○ | ○ | ◑ | ○ |
|
||||
| Music Assistant integration | ○ | ○ | ○ | ● | ◑ | ○ | ○ |
|
||||
| **⑦ CLI / automation tools** | | | | | | | |
|
||||
| CLI for scripting / automation | ○ | ○ | ○ | ○ | ○ | ● | ○ |
|
||||
| Traffic proxy / API logging | ◑ | ● | ○ | ○ | ○ | ● | ◑ |
|
||||
| HTTP session recording | ○ | ○ | ○ | ○ | ○ | ● | ○ |
|
||||
| **⑧ Library / SDK** | | | | | | | |
|
||||
| Importable library / package | ○ | ○ | ○ | ○ | ○ | ● | ○ |
|
||||
| Published API spec / docs | ○ | ● | ○ | ○ | ○ | ● | ○ |
|
||||
| Docker deployment | ● | ● | ○ | ● | ● | ● | ● |
|
||||
| Raspberry Pi SD card image | ○ | ○ | ○ | ○ | ● | ○ | ○ |
|
||||
|
||||
---
|
||||
|
||||
## Making AfterTouch the One-Stop Solution — Open Tasks
|
||||
|
||||
AfterTouch is the strongest single project across the service and developer layers. Its remaining gaps are on the consumer-facing and ecosystem-integration sides.
|
||||
|
||||
### Priority 1 — PWA installability
|
||||
|
||||
The web UI is already fully responsive — it has Bootstrap grid columns, `@media (max-width: 768px)` and `@media (max-width: 576px)` breakpoints, and a proper viewport meta tag. It works on iPhone and Android browsers today. What's missing is **installability**: no `manifest.json` and no service worker, so it cannot be added to the home screen as a standalone app. Adding these would close the iOS app gap ecosystem-wide (no project has an iOS app) at minimal effort.
|
||||
|
||||
### Priority 2 — RadioBrowser as a first-class provider
|
||||
|
||||
AfterTouch can proxy and play any stream URL, but there is no built-in station search. OpenCloudTouch's RadioBrowser integration is the reference. Tasks:
|
||||
- Wire the [RadioBrowser API](https://www.radio-browser.info/) into the `soundtouch-web` web UI as a browsable/searchable source.
|
||||
- Make discovered stations directly presetable to hardware buttons.
|
||||
- This is the most common replacement for TuneIn for users who listened to internet radio via presets.
|
||||
|
||||
### Priority 3 — Raspberry Pi SD card image
|
||||
|
||||
OpenCloudTouch ships a flashable Pi image and it dramatically lowers the barrier for the most common "always-on local server" deployment. AfterTouch already has Docker and a web management UI; this is largely a CI/packaging task:
|
||||
- Build a Pi image (using e.g. `pi-gen` or `rpi-imager`-compatible tooling) that boots directly into `soundtouch-service`.
|
||||
- Auto-starts on boot, auto-discovers devices, opens the web UI on a known port.
|
||||
- Target Pi 3/4/5 with amd64/arm64/arm/v7 variants (mirroring OCT's approach).
|
||||
|
||||
### Priority 4 — USB config generation in the web UI
|
||||
|
||||
AfterTouch modifies `SoundTouchSdkPrivateCfg.xml` via SSH (`pkg/service/setup/setup.go`) and documents the redirect process thoroughly, but does not yet generate the USB stick content for users without SSH access. A "prepare USB stick" button in the web UI would remove the last manual step:
|
||||
- Generate `OverrideSdkPrivateCfg.xml` pre-populated with the running server's URL.
|
||||
- Optionally include the custom CA certificate for HTTPS-capable devices.
|
||||
- Surface alongside the existing guided migration wizard.
|
||||
|
||||
### Priority 5 — Music Assistant integration
|
||||
|
||||
SoundTouch Hybrid 2026 uses Music Assistant as its streaming backend, giving access to Apple Music, Deezer, local libraries, and many other providers. A formal Music Assistant **player provider** for AfterTouch would give power users a path to sources beyond Spotify, TuneIn, Pandora, and RadioBrowser. The Music Assistant community has an open discussion thread on this ([#4766](https://github.com/orgs/music-assistant/discussions/4766)).
|
||||
|
||||
### Priority 6 — DNS-based migration documentation
|
||||
|
||||
AfterTouch includes a built-in DNS server (`ENABLE_DNS_DISCOVERY`, `DNS_BIND_ADDR`, `DNS_UPSTREAM`) that intercepts `*.bose.com` queries and forwards everything else upstream — no Pi-hole, AdGuard, or any other external tool required. The ResolvConf migration path already treats DNS as a first-class option. The remaining gap is awareness: users unfamiliar with the project may not realise no external DNS infrastructure is needed. Tasks:
|
||||
- Surface the built-in DNS server more prominently in the getting-started documentation.
|
||||
- Document Pi-hole / AdGuard Home as an *alternative* for users who already run those, not a requirement.
|
||||
|
||||
### Priority 7 — MQTT integration
|
||||
|
||||
A design document exists (`docs/guides/MQTT-INTEGRATION-DESIGN.md`) but no code has been written. Implementing it would unlock home automation use cases without requiring the full Home Assistant stack — enabling triggers like "play preset 1 when front door opens" via any MQTT-capable automation platform.
|
||||
|
||||
---
|
||||
|
||||
## soundcork ↔ AfterTouch
|
||||
|
||||
soundcork and AfterTouch share the most functional overlap of any two projects in the ecosystem. For the implementation-level parity analysis and remaining tasks see [docs/PARITY-SOUNDCORK.md](../PARITY-SOUNDCORK.md).
|
||||
|
||||
### Architectural differences (not gaps)
|
||||
|
||||
These exist in soundcork but are deliberate architectural choices in AfterTouch, not missing features:
|
||||
|
||||
| Area | soundcork | AfterTouch |
|
||||
|--------------------------|---------------------------------------|-----------------------------------------------------------|
|
||||
| Web UI | FastAPI + Jinja2 miniapp and admin UI | Separate `soundtouch-web` component (Go + plain HTML/JS) |
|
||||
| Direct device management | SSH/SCP access into speakers | HTTP API only; no SSH |
|
||||
| Device discovery client | Python `upnpclient` library | mDNS + UPnP in Go, with dedicated DNS interception server |
|
||||
| Token delivery | Push (ZeroConf priming to port 8200) | Pull (device calls back to fetch) |
|
||||
| Persistence format | Flat files | XML flat files + atomic writes |
|
||||
|
||||
### AfterTouch capabilities soundcork lacks
|
||||
|
||||
| Feature | Notes |
|
||||
|-------------------------------------------|-----------------------------------------------------------------|
|
||||
| DNS server for device redirect | Intercepts Bose domain queries; no Pi-hole required |
|
||||
| HTTPS / custom CA injection | Full TLS with certificate generation and trust workflow |
|
||||
| HTTP interaction recording & replay | Captures real device traffic for debugging and regression tests |
|
||||
| Device migration (serial → MAC path) | Handles legacy device ID formats automatically |
|
||||
| Transparent proxy mode with upstream sync | Can mirror to real Bose cloud while running locally |
|
||||
| CLI (`soundtouch-cli`) | Scriptable control of speakers |
|
||||
| Importable Go library | `github.com/gesellix/bose-soundtouch/pkg/client` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
### Ecosystem fragmentation vs. convergence
|
||||
|
||||
The community is currently covering different parts of the problem in parallel rather than converging. AfterTouch explicitly credits soundcork, Überböse, and SoundTouch Plus in its README and describes its `soundtouch-service` as "heavily inspired by SoundCork". There is an opportunity — and arguably a need — for these projects to formally coordinate: shared test fixtures, a common compatibility matrix against specific firmware versions, and agreed-on API contracts would all reduce duplicated effort.
|
||||
|
||||
### Firmware version sensitivity
|
||||
|
||||
The SoundTouch 10 is most dependent on Marge for basic network functionality; the 20 and 30 are somewhat more tolerant. Compatibility across firmware versions is not systematically documented anywhere. A community firmware compatibility matrix (model × firmware version × which emulation features work) would be high value and is currently missing.
|
||||
|
||||
### Security posture
|
||||
|
||||
All projects warn that speakers should only be used on a private, firewalled network after cloud shutdown. AfterTouch is the only project to implement HTTPS/custom CA, which matters if devices are ever on a network where traffic could be inspected. soundcork's SECURITY.md explicitly warns against running on open networks.
|
||||
|
||||
### No iOS app — a structural gap
|
||||
|
||||
The original SoundTouch app was iOS-first. Every community replacement is Android-only (Überböse App) or browser-based. This is the largest unaddressed user segment in the ecosystem.
|
||||
|
||||
### Bose's open-source move as a precedent
|
||||
|
||||
Bose's decision to release API documentation rather than simply shutting down is notable — it mirrors what Pebble users did themselves with Rebble after that shutdown, but here the manufacturer initiated it. This sets a useful precedent and gives the community a solid legal and technical foundation to build on.
|
||||
|
||||
### Related community resources
|
||||
|
||||
- [Bose SoundTouch Plus (Home Assistant component)](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus) — comprehensive HA integration by Todd Lucas, extensive API wiki
|
||||
- [Bose SoundTouch Hook](https://github.com/CodeFinder2/bose-soundtouch-hook) — `LD_PRELOAD`-based reverse engineering framework used by AfterTouch for protocol research
|
||||
- [Bose SoundTouch Web API (community Markdown)](https://github.com/jaas666/bose-soundtouch-web-api) — official API PDF converted to Markdown
|
||||
- [Bose Wiki — SoundTouch App Alternatives](https://bose.fandom.com/wiki/SoundTouch_app_alternatives) — community-maintained living list of workarounds and projects
|
||||
- [Reddit megathread — Bose alternatives](https://www.reddit.com/r/bose) — ongoing community discussion
|
||||
- [Radio Browser](https://www.radio-browser.info/) — the free, community-maintained internet radio directory used as a TuneIn replacement
|
||||
|
||||
---
|
||||
|
||||
*Document compiled April 2026. Project details sourced directly from GitHub repositories and official documentation. Star counts, commit counts, and release dates reflect the state at time of writing and will change as projects evolve. soundcork-stockholm-app added April 2026.*
|
||||
+19
-19
@@ -185,7 +185,7 @@ type NowPlaying struct {
|
||||
type PlayStatus string
|
||||
const (
|
||||
PlayStatusPlaying PlayStatus = "PLAY_STATE"
|
||||
PlayStatusPaused PlayStatus = "PAUSE_STATE"
|
||||
PlayStatusPaused PlayStatus = "PAUSE_STATE"
|
||||
PlayStatusStopped PlayStatus = "STOP_STATE"
|
||||
)
|
||||
|
||||
@@ -277,19 +277,19 @@ type Config struct {
|
||||
// Server configuration
|
||||
WebPort int `env:"WEB_PORT" default:"8080"`
|
||||
APITimeout time.Duration `env:"API_TIMEOUT" default:"10s"`
|
||||
|
||||
// Discovery configuration
|
||||
|
||||
// Discovery configuration
|
||||
DiscoveryTimeout time.Duration `env:"DISCOVERY_TIMEOUT" default:"5s"`
|
||||
CacheDevices bool `env:"CACHE_DEVICES" default:"true"`
|
||||
CacheTTL time.Duration `env:"CACHE_TTL" default:"5m"`
|
||||
|
||||
|
||||
// CORS configuration (for web proxy)
|
||||
CORSOrigins []string `env:"CORS_ORIGINS" default:"*"`
|
||||
|
||||
|
||||
// Logging
|
||||
LogLevel string `env:"LOG_LEVEL" default:"info"`
|
||||
LogFormat string `env:"LOG_FORMAT" default:"json"`
|
||||
|
||||
|
||||
// Development
|
||||
DevMode bool `env:"DEV_MODE" default:"false"`
|
||||
}
|
||||
@@ -537,7 +537,7 @@ build-all: build-linux build-darwin build-windows
|
||||
dev-cli:
|
||||
air -c .air-cli.toml
|
||||
|
||||
dev-webapp:
|
||||
dev-webapp:
|
||||
air -c .air-webapp.toml
|
||||
|
||||
dev-wasm:
|
||||
@@ -556,7 +556,7 @@ check: fmt vet lint test
|
||||
|
||||
# Docker development environment
|
||||
docker-dev:
|
||||
docker-compose up --build
|
||||
docker compose up --build
|
||||
|
||||
# Release packaging
|
||||
release: build-all
|
||||
@@ -596,7 +596,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
@@ -609,36 +609,36 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
if len(devices) == 0 {
|
||||
log.Fatal("No SoundTouch devices found")
|
||||
}
|
||||
|
||||
|
||||
// Create client for first device
|
||||
client := client.NewClient(client.ClientConfig{
|
||||
Host: devices[0].Host,
|
||||
Port: 8090,
|
||||
Timeout: 10 * time.Second,
|
||||
})
|
||||
|
||||
|
||||
// Get device info
|
||||
info, err := client.GetDeviceInfo()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("Connected to: %s\n", info.Name)
|
||||
|
||||
|
||||
// Get current playback
|
||||
nowPlaying, err := client.GetNowPlaying()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
if nowPlaying.PlayStatus == models.PlayStatusPlaying {
|
||||
fmt.Printf("Playing: %s - %s (%s)\n",
|
||||
fmt.Printf("Playing: %s - %s (%s)\n",
|
||||
nowPlaying.Artist, nowPlaying.Track, nowPlaying.Album)
|
||||
}
|
||||
|
||||
|
||||
// Control playback
|
||||
if nowPlaying.PlayStatus == models.PlayStatusPlaying {
|
||||
client.SendKey(models.KeyPause)
|
||||
@@ -737,11 +737,11 @@ docker run -p 8080:8080 soundtouch-webapp
|
||||
```bash
|
||||
# Local development with hot reload
|
||||
make dev-webapp # Web app development
|
||||
make dev-wasm # WASM development
|
||||
make dev-wasm # WASM development
|
||||
make dev-cli # CLI development
|
||||
|
||||
# Full development environment
|
||||
docker-compose up # Mock devices + web app
|
||||
docker compose up # Mock devices + web app
|
||||
```
|
||||
|
||||
## Success Criteria
|
||||
@@ -781,7 +781,7 @@ docker-compose up # Mock devices + web app
|
||||
|
||||
- [Bose SoundTouch Web API Documentation](https://assets.bosecreative.com/m/496577402d128874/original/SoundTouch-Web-API.pdf)
|
||||
- [Go WebAssembly](https://github.com/golang/go/wiki/WebAssembly)
|
||||
- [UPnP Device Architecture](http://upnp.org/specs/arch/UPnP-arch-DeviceArchitecture-v1.0.pdf)
|
||||
- [UPnP Device Architecture](http://upnp.org/specs/arch/UPnP-arch-DeviceArchitecture-v1.0.pdf)
|
||||
- [Go Embed Directive](https://pkg.go.dev/embed)
|
||||
- [Gorilla WebSocket](https://github.com/gorilla/websocket)
|
||||
- [PROJECT-PATTERNS.md](../PROJECT-PATTERNS.md) - Detailed pattern documentation
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# Upstream Bose Service Simulation - Concept Overview
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document serves as the entry point for understanding the comprehensive plan to enhance the SoundTouch service with advanced state management capabilities, preparing for the eventual shutdown of Bose's upstream services while providing a superior local management experience.
|
||||
|
||||
## 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.
|
||||
|
||||
### Key Outcomes
|
||||
- **Zero-downtime transition** from Bose services to local management
|
||||
- **Enhanced visibility** into device states, health, and system operations
|
||||
- **Data preservation** during migrations with full rollback capabilities
|
||||
- **Improved reliability** through local control and reduced external dependencies
|
||||
- **Future-proof architecture** that can evolve beyond Bose's original design
|
||||
|
||||
## Architecture Vision
|
||||
|
||||
### Current State
|
||||
The existing SoundTouch service provides:
|
||||
- BMX service for TuneIn integration
|
||||
- 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
|
||||
|
||||
### Enhanced State (This Project)
|
||||
The enhanced system will add:
|
||||
- **Comprehensive Account Management** with explicit creation and migration tracking
|
||||
- **Device Lifecycle Management** with full state machine and event processing
|
||||
- **Advanced Mirroring** with disparity detection and analysis
|
||||
- **Dual-Source Data Management** supporting gradual migration strategies
|
||||
- **Real-time Monitoring** with health checks and performance metrics
|
||||
- **Text-based Storage** optimized for debugging and small hardware deployments
|
||||
|
||||
## Use Case Coverage
|
||||
|
||||
### Case 0: Account Management
|
||||
- **Explicit Account Creation**: Accounts created through deliberate user action
|
||||
- **Mirror-Enhanced Setup**: Use upstream data to enrich account creation
|
||||
- **Passive Data Collection**: Record account information during normal operations
|
||||
|
||||
### 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
|
||||
|
||||
### Case 1b: Bose Account Migration
|
||||
- **Data Preservation**: Maintain existing presets, recents, and sources
|
||||
- **Gradual Migration**: Support partial migration while maintaining upstream compatibility
|
||||
- **Rollback Capability**: Revert to Bose services if needed
|
||||
|
||||
### Case 2: Lifecycle and State Management
|
||||
- **Real-time State Tracking**: Monitor device states and health continuously
|
||||
- **Event-Driven Updates**: Process device events asynchronously
|
||||
- **Disparity Detection**: Identify differences between local and upstream behavior
|
||||
- **Comprehensive Logging**: Maintain detailed audit trails for troubleshooting
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Design Principles
|
||||
1. **Text-First Storage**: Human-readable formats (JSON, XML, logs) for easy debugging
|
||||
2. **Small Hardware Optimization**: Designed for Raspberry Pi Zero 2W deployments
|
||||
3. **Mirror-First Strategy**: Keep upstream mirroring active until migration complete
|
||||
4. **Event-Driven Architecture**: Asynchronous processing with comprehensive event tracking
|
||||
5. **Backward Compatibility**: Seamless integration with existing installations
|
||||
|
||||
### Data Structure
|
||||
```
|
||||
data/
|
||||
├── accounts/{account-id}/
|
||||
│ ├── account.json # Account metadata and settings
|
||||
│ ├── account-events.log # High-level account behavior tracking
|
||||
│ ├── devices/{device-id}/
|
||||
│ │ ├── lifecycle.json # Device state and history
|
||||
│ │ ├── info.xml # Device information (existing)
|
||||
│ │ ├── presets.xml # Device presets (existing)
|
||||
│ │ ├── recents.xml # Recent plays (existing)
|
||||
│ │ ├── sources.xml # Configured sources (existing)
|
||||
│ │ └── events.log # Device event history
|
||||
│ └── sessions/ # Recorded interaction sessions (existing)
|
||||
└── system/
|
||||
├── discovery.log # Device discovery events
|
||||
└── migration.log # Migration activities
|
||||
```
|
||||
|
||||
### Development Targets
|
||||
- **Simplicity**: Keep It Simple, Stupid (KISS) principle over optimization
|
||||
- **Quality**: 100% test pass rate and lint-clean code for every change
|
||||
- **Compatibility**: Zero breaking changes to existing functionality
|
||||
- **Leveraging**: Reuse existing systems (interaction recording, parity detection)
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Phase 1: Foundation (2-3 weeks) - Small, Testable Steps
|
||||
- Account management foundation with basic create/read operations
|
||||
- Device lifecycle data models and simple state tracking
|
||||
- Basic API endpoints with comprehensive testing
|
||||
- Integration with existing datastore patterns
|
||||
|
||||
### 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
|
||||
- Simple state machine with thorough testing
|
||||
|
||||
### Phase 3: Enhanced Features (2-3 weeks) - Leverage Current Systems
|
||||
- Improve existing parity mismatch detection with better categorization
|
||||
- Smart data source routing with fallback mechanisms
|
||||
- Basic monitoring using existing health check patterns
|
||||
- Reuse interaction recording for request/response tracking
|
||||
|
||||
## Key Benefits
|
||||
|
||||
### For Users
|
||||
- **Continuity**: Seamless operation when Bose services shut down
|
||||
- **Reliability**: Local control reduces dependency on external services
|
||||
- **Visibility**: Clear insight into device states and system health
|
||||
- **Control**: Full management of device data and configurations
|
||||
|
||||
### For Developers
|
||||
- **Simplicity**: KISS principle makes code easy to understand and maintain
|
||||
- **Quality**: Comprehensive testing and linting ensures reliable code
|
||||
- **Debugging**: Text-based storage enables easy troubleshooting
|
||||
- **Testing**: Every change requires full test suite pass and lint compliance
|
||||
|
||||
### For Community
|
||||
- **Open Source**: Transparent implementation available for community contributions
|
||||
- **Standards**: Well-documented APIs and data formats
|
||||
- **Collaboration**: Disparity detection helps improve implementation accuracy
|
||||
- **Future-Proof**: Architecture designed to outlast original Bose services
|
||||
|
||||
### Technical Risks
|
||||
- **Data Loss Prevention**: Atomic file operations and comprehensive testing
|
||||
- **Complexity Creep**: KISS principle and simple-first approach
|
||||
- **Compatibility Issues**: Extensive regression testing and existing system reuse
|
||||
- **Code Quality**: Mandatory linting and test coverage for every change
|
||||
|
||||
### Operational Risks
|
||||
- **Service Disruption**: Small, incremental changes with rollback capability
|
||||
- **Testing Overhead**: Automated quality gates (`golangci-lint run --fix` + `go test ./...`)
|
||||
- **Migration Challenges**: Leverage existing migration system and patterns
|
||||
- **Maintenance Burden**: Simple, well-tested code is easier to maintain
|
||||
|
||||
### Technical
|
||||
- All tests pass consistently (100%)
|
||||
- Zero linting issues in codebase
|
||||
- No breaking changes to existing functionality
|
||||
- Code coverage maintained or improved
|
||||
|
||||
### Quality Assurance
|
||||
- Every commit passes `golangci-lint run --fix`
|
||||
- Every milestone passes `go test ./...`
|
||||
- Integration tests verify existing functionality
|
||||
- Simple, maintainable code that follows Go idioms
|
||||
|
||||
## Documentation Structure
|
||||
|
||||
This concept is detailed across several documents:
|
||||
|
||||
- **[upstream-service-simulation.md](./upstream-service-simulation.md)**: Complete architectural concept with detailed use cases and implementation guidelines
|
||||
- **[implementation-roadmap.md](./implementation-roadmap.md)**: Detailed project phases, milestones, and delivery timeline
|
||||
- **[technical-specification.md](./technical-specification.md)**: Comprehensive technical details including APIs, data models, and performance requirements
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Review the Concept**: Read through the main concept document to understand the full scope
|
||||
2. **Examine Technical Details**: Review the technical specification for implementation details
|
||||
3. **Follow the Roadmap**: Use the implementation roadmap for project planning and execution
|
||||
4. **Integration Planning**: Consider how the enhanced features will integrate with existing deployments
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Stakeholder Review**: Gather feedback on the concept and approach
|
||||
2. **Technical Validation**: Prototype key components to validate technical assumptions
|
||||
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.
|
||||
@@ -0,0 +1,369 @@
|
||||
# Amazon Music OAuth Integration
|
||||
|
||||
This document describes the plan and specification for adding Amazon Music OAuth support to the SoundTouch service, enabling continued Amazon Music playback after the Bose cloud shutdown (May 2026).
|
||||
|
||||
The implementation mirrors the [Spotify OAuth integration](spotify-oauth.md) closely. Read that document first — this one calls out only the differences.
|
||||
|
||||
## Status
|
||||
|
||||
**Infrastructure complete — streaming blocked by API access.**
|
||||
|
||||
All eight implementation steps are done. The OAuth flow (account linking, token storage, token refresh) works end-to-end with a standard Login with Amazon app. Token exchange (`/oauth/device/.../token/cs1`) succeeds and the speaker receives a valid `Atza|` access token.
|
||||
|
||||
However, real-world testing shows that the speaker then calls `https://music-api.amazon.com/` with that token and receives a `401 Unauthorized` (no redirect to a regional endpoint). This means the token does not carry the scopes required to access the Amazon Music streaming API.
|
||||
|
||||
**Root cause (confirmed):** Amazon Music streaming requires the `amazon_music:access` scope, which is only available to **device client IDs** — a separate credential type obtained through Amazon's Music partner programme. Standard Login with Amazon application client IDs (`amzn1.application-oa2-client.*`) cannot request this scope: attempting to include it in the authorization URL returns `lwa-invalid-parameter-bad-scope` (HTTP 400) from the LWA authorization endpoint. Bose would have held a device client ID as a registered Amazon Music partner.
|
||||
|
||||
**What still works:**
|
||||
- Account linking and token storage
|
||||
- Token refresh (the service correctly exchanges the refresh token for a fresh access token)
|
||||
- Marge source registration (the speaker sees Amazon Music as a configured source)
|
||||
|
||||
**What does not work:**
|
||||
- Actual music playback — the speaker's `AmazonClient` cannot authenticate to `music-api.amazon.com` with a standard LWA token
|
||||
|
||||
**Path forward:** Obtaining a device client ID requires registering with Amazon's Music partner programme. If such a credential is obtained, the only code change needed is swapping the `client_id`/`client_secret` for the device credentials and adding `amazon_music:access` to `AmazonScopes` in `pkg/service/amazon/service.go` — everything else is already in place. The `site_id` field is a secondary open question that may also affect regional routing once the scope issue is resolved.
|
||||
|
||||
---
|
||||
|
||||
## Secret Format (confirmed from a live Bose system)
|
||||
|
||||
A real Amazon source entry from a migrated device's `Sources.xml`:
|
||||
|
||||
```xml
|
||||
<source secretType="token">
|
||||
<credential type="token">{"AmazonSecret":{"refresh_token":"Atzr|...","site_id":"1464855981"}}</credential>
|
||||
<sourceKey type="AMAZON" account="user@example.com"/>
|
||||
</source>
|
||||
```
|
||||
|
||||
Key observations:
|
||||
|
||||
- **Secret envelope**: `{"AmazonSecret":{"refresh_token":"...","site_id":"..."}}` — JSON-encoded, HTML-entity-escaped in XML attributes, stored as the credential value.
|
||||
- **`Atzr|` prefix**: This is the standard Amazon LWA (Login with Amazon) refresh token prefix from the **authorization code grant** — confirming that Web OAuth is the correct flow, not CBL.
|
||||
- **`site_id`**: A numeric string (`"1464855981"`). Origin is not yet fully confirmed; candidates are:
|
||||
- A static Bose partner identifier baked into the Bose app/firmware (same value for all users), or
|
||||
- A per-user Amazon Music identifier returned by a Music API device registration call.
|
||||
- Needs verification — possibly obtained by calling the Amazon Music API after initial authentication.
|
||||
- **`account` field**: The user's Amazon email address (obtained from the LWA `/user/profile` endpoint).
|
||||
|
||||
When `HandleBoseAmazonToken` receives a refresh request from the speaker, it must:
|
||||
1. Parse the `AmazonSecret` JSON from the stored credential to extract `refresh_token`.
|
||||
2. Call the LWA token endpoint with a `refresh_token` grant.
|
||||
3. Return the fresh `access_token` to the speaker.
|
||||
4. Persist the rotated `refresh_token` back into the `AmazonSecret` envelope.
|
||||
|
||||
---
|
||||
|
||||
## How the Speaker Uses This
|
||||
|
||||
When the SoundTouch firmware tries to play Amazon Music after migration, it sends a token refresh request to the local service:
|
||||
|
||||
```
|
||||
POST /oauth/device/{deviceID}/music/musicprovider/20/token/cs1
|
||||
```
|
||||
|
||||
The service must respond with a fresh Amazon access token. The speaker then uses that token directly with Amazon's playback infrastructure.
|
||||
|
||||
The `cs1` suffix (credential schema 1) is Amazon-specific; Spotify uses `cs3`. This route is already registered.
|
||||
|
||||
> **DNS note:** The speaker constructs the OAuth hostname by appending `oauth` to the streaming service subdomain. If the service is reachable at `myhost.local`, the speaker will call `myhostoauth.local`. A DNS alias pointing `myhostoauth.<domain>` to the same IP as the service is required.
|
||||
|
||||
---
|
||||
|
||||
## OAuth Flows
|
||||
|
||||
### 1. Browser-based Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client as Client (curl/app)
|
||||
participant Service as Service
|
||||
participant Amazon as Amazon Auth Server (LWA)
|
||||
participant Browser as User's Browser
|
||||
|
||||
Client->>Service: POST /mgmt/amazon/init [Basic Auth]
|
||||
Service-->>Client: {"redirectUrl": "https://www.amazon.com/ap/oa?..."}
|
||||
|
||||
Client->>Browser: User opens URL
|
||||
Browser->>Amazon: User logs in & grants access
|
||||
Amazon-->>Browser: Redirect to /mgmt/amazon/callback?code=abc
|
||||
|
||||
Browser->>Service: GET /mgmt/amazon/callback?code=abc
|
||||
Note over Service: No auth needed for callback
|
||||
|
||||
Service->>Amazon: POST /auth/o2/token (exchange code)
|
||||
Amazon-->>Service: {access_token, refresh_token}
|
||||
|
||||
Service->>Amazon: GET /user/profile (fetch profile)
|
||||
Amazon-->>Service: {user_id, name, email}
|
||||
|
||||
Note over Service: Store account to disk
|
||||
|
||||
Service-->>Browser: HTML: "Amazon Music Connected. You can close this window."
|
||||
```
|
||||
|
||||
### 2. Mobile App Flow (ueberboese)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App as ueberboese Flutter App
|
||||
participant Service as Service
|
||||
participant Amazon as Amazon Auth Server (LWA)
|
||||
|
||||
App->>Service: POST /mgmt/amazon/init [Basic Auth]
|
||||
Service-->>App: {"redirectUrl": "https://www.amazon.com/ap/oa?..."}
|
||||
|
||||
App->>Amazon: Open in-app browser (User authorizes)
|
||||
Amazon-->>App: Deep link redirect: ueberboese-login://amazon?code=abc
|
||||
|
||||
App->>Service: POST /mgmt/amazon/confirm?code=abc [Basic Auth]
|
||||
|
||||
Service->>Amazon: POST /auth/o2/token (exchange code)
|
||||
Amazon-->>Service: {access_token, refresh_token}
|
||||
|
||||
Service->>Amazon: GET /user/profile (fetch profile)
|
||||
Amazon-->>Service: {profile}
|
||||
|
||||
Service-->>App: {"ok": true}
|
||||
```
|
||||
|
||||
### 3. Token Retrieval (Speaker Token Refresh)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Speaker as SoundTouch Speaker
|
||||
participant Service as Service
|
||||
participant Amazon as Amazon Token API (LWA)
|
||||
|
||||
Speaker->>Service: POST /oauth/device/{deviceID}/music/musicprovider/20/token/cs1
|
||||
Note over Service: Body contains stored AmazonSecret JSON;<br/>extract refresh_token from {"AmazonSecret":{...}}
|
||||
|
||||
alt Token expired or near expiry
|
||||
Service->>Amazon: POST /auth/o2/token (refresh_token grant, body credentials)
|
||||
Amazon-->>Service: {access_token, refresh_token, expires_in}
|
||||
Note over Service: Persist rotated refresh_token back into AmazonSecret envelope
|
||||
end
|
||||
|
||||
Service-->>Speaker: {"access_token": "...", "token_type": "Bearer", "expires_in": 3600}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1 — Extract ZeroConf into a shared package
|
||||
|
||||
**Why first:** The DH-blob encryption in `pkg/service/spotify/zeroconf.go` is entirely provider-agnostic. Extracting it to `pkg/service/zeroconf/` before adding Amazon avoids duplicating ~200 lines of crypto code.
|
||||
|
||||
**What changes:**
|
||||
- Create `pkg/service/zeroconf/zeroconf.go` — move `generateDHKeyPair`, `computeSharedSecret`, `deriveKeys`, `buildCredentialsBlob`, `encryptBlob` and helpers. Expose `authType` as a parameter (Spotify and Amazon both use `AuthTypeOAuthToken = 4`, but this makes it explicit).
|
||||
- Update `pkg/service/spotify/zeroconf.go` — delete moved code; `PushSpotifyCredentials` becomes a one-line wrapper calling `zeroconf.PushCredentials(...)`.
|
||||
|
||||
### Step 2 — Create `pkg/service/amazon/service.go`
|
||||
|
||||
Mirror `pkg/service/spotify/service.go`. The `Account` struct is identical; copy it unchanged.
|
||||
|
||||
**Amazon-specific differences:**
|
||||
|
||||
| Item | Spotify | Amazon |
|
||||
|---------------------------|------------------------------------------|-------------------------------------------------|
|
||||
| Authorization URL | `https://accounts.spotify.com/authorize` | `https://www.amazon.com/ap/oa` |
|
||||
| Token endpoint | `https://accounts.spotify.com/api/token` | `https://api.amazon.com/auth/o2/token` |
|
||||
| Profile endpoint | `https://api.spotify.com/v1/me` | `https://api.amazon.com/user/profile` |
|
||||
| Token request credentials | HTTP Basic Auth (clientID:clientSecret) | POST body fields `client_id` / `client_secret` |
|
||||
| Profile fields | `id`, `display_name`, `email` | `user_id`, `name`, `email` |
|
||||
| Scopes | `streaming user-read-private ...` | `profile` (expand to `music::*` when available) |
|
||||
| Entity resolution | `ResolveEntity()` via Spotify API | Not implemented (API in closed beta) |
|
||||
|
||||
Accounts persist to `{dataDir}/amazon/accounts.json`.
|
||||
|
||||
The token request credential difference (body vs. Basic Auth) is the most important implementation detail.
|
||||
|
||||
### Step 3 — Create `pkg/service/amazon/zeroconf.go`
|
||||
|
||||
A single exported function `PushAmazonCredentials(zcBaseURL, username, accessToken string) error` delegating to the shared `zeroconf.PushCredentials(...)`.
|
||||
|
||||
### Step 4 — Implement `HandleBoseAmazonToken`
|
||||
|
||||
Replace the 501 stub in `pkg/service/handlers/handlers_oauth.go` with the full mirror of `HandleBoseSpotifyToken`:
|
||||
- Parse body for `refresh_token` / `code`
|
||||
- Look up account by BoseSecret; refresh and return token
|
||||
- Fall back to first account via `GetFreshToken()` if no matching account
|
||||
- Fall back to `HandleBoseProxy` if no Amazon service is configured
|
||||
- **Omit `scope` from the response** — Amazon Music scopes are undocumented; sending invented values risks firmware rejection
|
||||
|
||||
### Step 5 — Add Amazon fields to `Server`
|
||||
|
||||
In `pkg/service/handlers/server.go`, add alongside the Spotify fields:
|
||||
|
||||
```go
|
||||
amazonClientID string
|
||||
amazonClientSecret string
|
||||
amazonRedirectURI string
|
||||
amazonService *amazon.Service
|
||||
```
|
||||
|
||||
Add methods: `SetAmazonConfig`, `SetAmazonService`, `IsAmazonConfigured`, `PrimeDeviceWithAmazon`.
|
||||
|
||||
### Step 6 — Add management handlers
|
||||
|
||||
In `pkg/service/handlers/handlers_mgmt.go`, add six handlers mirroring Spotify:
|
||||
|
||||
| Handler | Notes |
|
||||
|-------------------------------|-----------------------------------------|
|
||||
| `HandleMgmtAmazonInit` | Returns LWA authorize URL |
|
||||
| `HandleMgmtAmazonCallback` | No auth; calls `bridgeAmazonToMarge` |
|
||||
| `HandleMgmtAmazonConfirm` | Basic Auth; calls `bridgeAmazonToMarge` |
|
||||
| `HandleMgmtAmazonAccounts` | Returns account list (tokens stripped) |
|
||||
| `HandleMgmtAmazonToken` | Returns fresh access token |
|
||||
| `HandleMgmtPrimeDeviceAmazon` | Pushes token to speaker via ZeroConf |
|
||||
|
||||
`bridgeAmazonToMarge` must encode the stored secret as `{"AmazonSecret":{"refresh_token":"<token>","site_id":"<id>"}}` and use `CredentialTypeToken` ("token") — **not** `CredentialTypeTokenV3`. Amazon uses `cs1` semantics.
|
||||
|
||||
### Step 7 — Wire CLI flags and router
|
||||
|
||||
**`main.go` flags** (env vars in parentheses):
|
||||
- `--amazon-client-id` (`AMAZON_CLIENT_ID`)
|
||||
- `--amazon-client-secret` (`AMAZON_CLIENT_SECRET`)
|
||||
- `--amazon-redirect-uri` (`AMAZON_REDIRECT_URI`, default: `ueberboese-login://amazon`)
|
||||
- `--amazon-token-url` (`AMAZON_TOKEN_URL`, for testing overrides)
|
||||
- `--amazon-profile-url` (`AMAZON_PROFILE_URL`, for testing overrides)
|
||||
|
||||
**Router** (`setupRouter`): Add `/mgmt/amazon/*` sub-routes next to the Spotify block. The `/oauth/.../token/cs1` route is already registered and dispatches to `HandleBoseAmazonToken`.
|
||||
|
||||
**`pkg/service/marge/marge.go`**: Extend the `AddSource` provider-label branch to map `AmazonProviderID (20) → "AMAZON"` so stored sources carry the correct type string rather than the raw numeric ID.
|
||||
|
||||
**`pkg/models/account.go`**: Add `NewAmazonOAuthCredentials` with `Source: "AMAZON"`, `Version: "token"`.
|
||||
|
||||
### Step 8 — Tests
|
||||
|
||||
Mirror the Spotify test suite for the Amazon package:
|
||||
|
||||
- `TestBuildAuthorizeURL` — verify LWA URL structure
|
||||
- `TestExchangeCodeAndStore` — mock token + profile servers; assert POST body credentials (not Basic Auth)
|
||||
- `TestRefreshAccessToken` — verify body credentials, token rotation
|
||||
- `TestGetFreshToken*` — copy Spotify variants verbatim
|
||||
- `TestSaveAndLoad` — verify persistence under `amazon/accounts.json`
|
||||
|
||||
Add `pkg/testutils/amazon/handlers.go` and `tests/integration/mocks/amazon.go` mock servers mirroring the Spotify equivalents.
|
||||
|
||||
Update `cmd/soundtouch-service/testdata/router_routes.txt` snapshot after wiring.
|
||||
|
||||
---
|
||||
|
||||
## Trying It Out
|
||||
|
||||
### 1. Create a Login with Amazon (LWA) app
|
||||
|
||||
Go to [developer.amazon.com](https://developer.amazon.com) → **Login with Amazon** → **Create a New Security Profile**.
|
||||
|
||||
You will receive a **Client ID** and **Client Secret**. Under *Web Settings*, add an **Allowed Return URL** that matches `--amazon-redirect-uri`:
|
||||
|
||||
- **Browser flow** (easiest to test): `http://<your-host>:8000/mgmt/amazon/callback`
|
||||
- **Mobile deep-link flow**: `ueberboese-login://amazon` (the default)
|
||||
|
||||
The `profile` scope is sufficient — `music::` scopes are in closed beta and not required. The service only brokers tokens; the speaker communicates with Amazon's playback infrastructure directly.
|
||||
|
||||
### 2. Start the service
|
||||
|
||||
```bash
|
||||
./soundtouch-service \
|
||||
--amazon-client-id amzn1.application-oa2-client.xxx \
|
||||
--amazon-client-secret yyy \
|
||||
--amazon-redirect-uri http://<your-host>:8000/mgmt/amazon/callback
|
||||
```
|
||||
|
||||
Or set the equivalent environment variables: `AMAZON_CLIENT_ID`, `AMAZON_CLIENT_SECRET`, `AMAZON_REDIRECT_URI`.
|
||||
|
||||
### 3. Trigger the OAuth flow
|
||||
|
||||
```bash
|
||||
# Get the LWA authorization URL
|
||||
curl -u admin:change_me! -X POST http://localhost:8000/mgmt/amazon/init
|
||||
# → {"redirectUrl":"https://www.amazon.com/ap/oa?client_id=...&scope=profile&..."}
|
||||
```
|
||||
|
||||
Open the `redirectUrl` in a browser, log in with your Amazon account, and authorize the app. Amazon redirects back to `/mgmt/amazon/callback`, which responds with an HTML page saying "Amazon Music Connected".
|
||||
|
||||
### 4. Verify the account is linked
|
||||
|
||||
```bash
|
||||
curl -u admin:change_me! http://localhost:8000/mgmt/amazon/accounts
|
||||
# → {"accounts":[{"user_id":"amzn1.account.xxx","display_name":"Your Name","email":"you@example.com",...}]}
|
||||
```
|
||||
|
||||
### 5. Prime a speaker
|
||||
|
||||
```bash
|
||||
# Discover device IDs first
|
||||
curl -u admin:change_me! http://localhost:8000/mgmt/accounts/default/speakers
|
||||
|
||||
# Push the token to a specific speaker via ZeroConf
|
||||
curl -u admin:change_me! -X POST \
|
||||
"http://localhost:8000/mgmt/amazon/prime?deviceId=<deviceId>"
|
||||
# → {"status":"Priming triggered"}
|
||||
```
|
||||
|
||||
### 6. Verify token refresh from the speaker
|
||||
|
||||
Once a speaker has Amazon Music as a source, it will periodically POST to:
|
||||
|
||||
```
|
||||
POST /oauth/device/{deviceID}/music/musicprovider/20/token/cs1
|
||||
```
|
||||
|
||||
The service looks up the account by refresh token, refreshes it via LWA, and returns a fresh `access_token`. Check the service logs for `[Amazon]` entries confirming this flow.
|
||||
|
||||
### DNS requirement
|
||||
|
||||
The speaker derives the OAuth hostname by appending `oauth` to its configured streaming subdomain. If the service is at `myhost.local`, the speaker calls `myhostoauth.local`. A DNS alias pointing `myhostoauth.<domain>` to the same IP is required — the built-in DNS discovery server handles this automatically when `--dns-discovery` is enabled.
|
||||
|
||||
### Open question: `site_id`
|
||||
|
||||
The `AmazonSecret` credential envelope contains a `site_id` field (e.g. `"1464855981"` seen in a real migrated device). Its origin is unconfirmed — it may be a static Bose partner ID or a per-user Amazon Music identifier. The service currently stores an empty string.
|
||||
|
||||
Real-world testing shows the device's `AmazonClient` calls `CheckBaseUrlRedirect` with empty `data` (the `site_id`) and then tries `https://music-api.amazon.com/` directly, receiving a 401 with no redirect to a regional endpoint (e.g. `music-api.amazon.de` for a German account). This suggests that:
|
||||
|
||||
1. A correct `site_id` might cause the device to use the right regional endpoint instead of the US default.
|
||||
2. Even so, the token scope issue (see Status above) would still block playback — resolving `site_id` alone is not sufficient.
|
||||
|
||||
`site_id` is likely secondary to the partner scope problem. It remains an open question for the post-scope-resolution phase.
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|--------|-------------------------------------------------------------|-------|----------------------------------------------------|
|
||||
| `POST` | `/oauth/device/{deviceID}/music/musicprovider/20/token/cs1` | None | Token refresh from speaker |
|
||||
| `GET` | `/mgmt/amazon/callback` | None | Browser OAuth callback (redirect from Amazon LWA) |
|
||||
| `POST` | `/mgmt/amazon/init` | Basic | Start OAuth flow, returns authorization URL |
|
||||
| `POST` | `/mgmt/amazon/confirm` | Basic | Mobile app confirm (deep link delivers code) |
|
||||
| `GET` | `/mgmt/amazon/accounts` | Basic | List linked Amazon accounts (tokens stripped) |
|
||||
| `GET` | `/mgmt/amazon/token` | Basic | Get fresh access token (auto-refreshes if expired) |
|
||||
| `POST` | `/mgmt/amazon/prime` | Basic | Push token to speaker via ZeroConf |
|
||||
|
||||
## Security
|
||||
|
||||
Same model as Spotify:
|
||||
- `/mgmt/amazon/callback` is intentionally outside Basic Auth to allow direct redirects from Amazon's authorization server.
|
||||
- All other `/mgmt/*` endpoints require Basic Auth.
|
||||
- Accounts persist to `{dataDir}/amazon/accounts.json` with `0600` permissions.
|
||||
- `GetAccounts` strips `AccessToken` and `RefreshToken` from responses.
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
**Secret is a JSON envelope, not a bare token.** The stored credential is `{"AmazonSecret":{"refresh_token":"Atzr|...","site_id":"..."}}`, HTML-entity-escaped when written to XML attributes. This was confirmed from a real migrated device's `Sources.xml`. `HandleBoseAmazonToken` must parse this structure to extract the `refresh_token`, and `bridgeAmazonToMarge` must produce it when storing after OAuth.
|
||||
|
||||
**`site_id` origin is unconfirmed.** It may be a static Bose partner ID or a per-user Amazon Music identifier. Needs verification — likely obtained by calling the Amazon Music API or the LWA profile endpoint post-authentication.
|
||||
|
||||
**Credential type is `token`, not `token_version_3`.** `CredentialTypeTokenV3` is Spotify-specific (`cs3`). Amazon uses `cs1`, which maps to the plain `CredentialTypeToken` ("token") constant. Do not upgrade Amazon credentials to v3 in `marge.go`.
|
||||
|
||||
**POST body credentials, not Basic Auth.** The Amazon LWA token endpoint (`/auth/o2/token`) expects `client_id` and `client_secret` as POST body fields, not as an HTTP Basic Auth header. This is the single most important difference from the Spotify implementation.
|
||||
|
||||
**No entity resolution.** `ResolveEntity()` is not implemented for Amazon — the Amazon Music API is in closed beta. Return HTTP 501 if an entity endpoint is ever requested.
|
||||
|
||||
**`scope` omitted from token response.** The Spotify handler returns a hardcoded scope string. Amazon Music scopes for playback are undocumented; returning an empty or absent `scope` is safer than inventing values.
|
||||
|
||||
**ZeroConf extraction is a prerequisite.** The DH-blob crypto in `spotify/zeroconf.go` should be extracted to a shared package before Amazon is added to avoid duplicating cryptographic code.
|
||||
@@ -0,0 +1,355 @@
|
||||
# Implementation Plan - Enhanced State Management System
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides a detailed, step-by-step implementation plan for the enhanced state management system. Each step is designed to be small, testable, and independently valuable while maintaining backward compatibility.
|
||||
|
||||
## Development Principles
|
||||
|
||||
### Quality Gates
|
||||
Every step must pass these checks before proceeding:
|
||||
1. `golangci-lint run --fix` - no linting issues
|
||||
2. `go test ./...` - all tests pass
|
||||
3. Existing functionality remains intact
|
||||
4. New functionality has appropriate test coverage
|
||||
|
||||
### KISS Principle
|
||||
- Write the simplest code that works
|
||||
- Avoid premature optimization
|
||||
- Use straightforward algorithms
|
||||
- Build incrementally with small changes
|
||||
|
||||
### Leverage Existing Systems
|
||||
- Reuse interaction recording for request/response tracking
|
||||
- Build upon current parity mismatch detection
|
||||
- Extend existing datastore patterns
|
||||
- Integrate with established workflows
|
||||
|
||||
## Phase 1: Foundation Preparation (2-3 weeks)
|
||||
|
||||
### Step 1.1: Code Organization Preparation
|
||||
**Duration**: 2-3 days
|
||||
**Goal**: Prepare package structure without changing behavior
|
||||
|
||||
#### Mini-milestone 1.1.1: Create account package structure
|
||||
- Create `pkg/service/account/` directory
|
||||
- Add basic `account.go` with placeholder structs
|
||||
- Add `account_test.go` with basic test structure
|
||||
- **Quality Check**: Lint + test all packages
|
||||
|
||||
#### Mini-milestone 1.1.2: Create lifecycle package structure
|
||||
- Create `pkg/service/lifecycle/` directory
|
||||
- Add basic `lifecycle.go` with placeholder structs
|
||||
- Add `lifecycle_test.go` with basic test structure
|
||||
- **Quality Check**: Lint + test all packages
|
||||
|
||||
#### Mini-milestone 1.1.3: Extend datastore interface preparation
|
||||
- Add placeholder methods to existing datastore for account operations
|
||||
- Ensure all existing functionality still works
|
||||
- Add tests for new placeholder methods
|
||||
- **Quality Check**: Lint + test all packages
|
||||
|
||||
### Step 1.2: Account Management Foundation
|
||||
**Duration**: 3-4 days
|
||||
**Goal**: Basic account creation and retrieval
|
||||
|
||||
#### Mini-milestone 1.2.1: Account data model
|
||||
- Define `Account` struct with basic fields
|
||||
- Add validation functions
|
||||
- Add comprehensive unit tests
|
||||
- **Quality Check**: Lint + test all packages
|
||||
|
||||
#### Mini-milestone 1.2.2: Account persistence
|
||||
- Implement account.json file read/write
|
||||
- Add atomic file operations
|
||||
- Test file operations thoroughly
|
||||
- **Quality Check**: Lint + test all packages
|
||||
|
||||
#### Mini-milestone 1.2.3: Account manager basic operations
|
||||
- Implement `CreateAccount()` function
|
||||
- Implement `GetAccount()` function
|
||||
- Add error handling and validation
|
||||
- **Quality Check**: Lint + test all packages
|
||||
|
||||
#### Mini-milestone 1.2.4: Integration with existing datastore
|
||||
- Modify datastore to use account manager
|
||||
- Ensure backward compatibility with existing accounts
|
||||
- Test migration of existing data structure
|
||||
- **Quality Check**: Lint + test all packages
|
||||
|
||||
### Step 1.3: Basic API Endpoints
|
||||
**Duration**: 2-3 days
|
||||
**Goal**: Add REST endpoints for account management
|
||||
|
||||
#### Mini-milestone 1.3.1: Account creation endpoint
|
||||
- Add `POST /api/v1/accounts` handler
|
||||
- Integrate with existing HTTP router
|
||||
- Add input validation and error responses
|
||||
- **Quality Check**: Lint + test all packages
|
||||
|
||||
#### Mini-milestone 1.3.2: Account retrieval endpoint
|
||||
- Add `GET /api/v1/accounts/{id}` handler
|
||||
- Add proper JSON serialization
|
||||
- Test endpoint functionality
|
||||
- **Quality Check**: Lint + test all packages
|
||||
|
||||
#### Mini-milestone 1.3.3: Integration testing
|
||||
- Test new endpoints with existing functionality
|
||||
- Ensure XML endpoints still work
|
||||
- Verify no breaking changes
|
||||
- **Quality Check**: Lint + test all packages
|
||||
|
||||
## Phase 2: Device Lifecycle Foundation (2-3 weeks)
|
||||
|
||||
### Step 2.1: Device State Model
|
||||
**Duration**: 3-4 days
|
||||
**Goal**: Basic device lifecycle tracking
|
||||
|
||||
#### Mini-milestone 2.1.1: Device lifecycle data model
|
||||
- Define `DeviceLifecycle` struct
|
||||
- Define device states and transitions
|
||||
- Add validation and helper functions
|
||||
- **Quality Check**: Lint + test all packages
|
||||
|
||||
#### Mini-milestone 2.1.2: State transition logic
|
||||
- Implement basic state machine
|
||||
- Add transition validation
|
||||
- Create comprehensive tests for all transitions
|
||||
- **Quality Check**: Lint + test all packages
|
||||
|
||||
#### Mini-milestone 2.1.3: Lifecycle persistence
|
||||
- Implement lifecycle.json file operations
|
||||
- Add atomic updates and error handling
|
||||
- Test persistence thoroughly
|
||||
- **Quality Check**: Lint + test all packages
|
||||
|
||||
### Step 2.2: Event Processing Foundation
|
||||
**Duration**: 3-4 days
|
||||
**Goal**: Basic event handling and logging
|
||||
|
||||
#### Mini-milestone 2.2.1: Event data model
|
||||
- Define `DeviceEvent` struct
|
||||
- Add event types and validation
|
||||
- Create event builder helpers
|
||||
- **Quality Check**: Lint + test all packages
|
||||
|
||||
#### Mini-milestone 2.2.2: Simple event logging
|
||||
- Implement append-only event log writing
|
||||
- Add structured log format
|
||||
- Test log operations and rotation
|
||||
- **Quality Check**: Lint + test all packages
|
||||
|
||||
#### Mini-milestone 2.2.3: Event processing pipeline
|
||||
- Create basic synchronous event processor
|
||||
- Add event validation and filtering
|
||||
- Integrate with existing WebSocket events
|
||||
- **Quality Check**: Lint + test all packages
|
||||
|
||||
### Step 2.3: Lifecycle Integration
|
||||
**Duration**: 2-3 days
|
||||
**Goal**: Connect lifecycle to existing systems
|
||||
|
||||
#### Mini-milestone 2.3.1: Discovery integration
|
||||
- Trigger lifecycle events on device discovery
|
||||
- Update device state on discovery
|
||||
- Test discovery workflow with lifecycle
|
||||
- **Quality Check**: Lint + test all packages
|
||||
|
||||
#### Mini-milestone 2.3.2: WebSocket integration
|
||||
- Process WebSocket events through lifecycle
|
||||
- Update device state based on events
|
||||
- Log significant state changes
|
||||
- **Quality Check**: Lint + test all packages
|
||||
|
||||
#### Mini-milestone 2.3.3: Migration integration
|
||||
- Integrate lifecycle with existing migration system
|
||||
- Track migration events and state changes
|
||||
- Ensure existing migration still works
|
||||
- **Quality Check**: Lint + test all packages
|
||||
|
||||
## Phase 3: Enhanced Features (2-3 weeks)
|
||||
|
||||
### Step 3.1: Enhanced Mirroring
|
||||
**Duration**: 3-4 days
|
||||
**Goal**: Improve existing parity detection
|
||||
|
||||
#### Mini-milestone 3.1.1: Extended disparity logging
|
||||
- Enhance existing parity mismatch logging
|
||||
- Add more detailed disparity information
|
||||
- Improve log format for analysis
|
||||
- **Quality Check**: Lint + test all packages
|
||||
|
||||
#### Mini-milestone 3.1.2: Disparity categorization
|
||||
- Add severity levels to disparities
|
||||
- Categorize different types of mismatches
|
||||
- Add filtering and search capabilities
|
||||
- **Quality Check**: Lint + test all packages
|
||||
|
||||
#### Mini-milestone 3.1.3: Enhanced mirror middleware
|
||||
- Extend existing mirror functionality
|
||||
- Add better response comparison
|
||||
- Integrate with lifecycle events
|
||||
- **Quality Check**: Lint + test all packages
|
||||
|
||||
### Step 3.2: Data Source Management
|
||||
**Duration**: 3-4 days
|
||||
**Goal**: Smart routing between local and upstream
|
||||
|
||||
#### Mini-milestone 3.2.1: Data source configuration
|
||||
- Add per-device source preferences
|
||||
- Implement source switching logic
|
||||
- Add configuration persistence
|
||||
- **Quality Check**: Lint + test all packages
|
||||
|
||||
#### Mini-milestone 3.2.2: Fallback mechanisms
|
||||
- Add graceful fallback on source failure
|
||||
- Implement simple health checking
|
||||
- Test fallback scenarios
|
||||
- **Quality Check**: Lint + test all packages
|
||||
|
||||
#### Mini-milestone 3.2.3: Migration orchestration
|
||||
- Add device-by-device migration control
|
||||
- Track migration progress
|
||||
- Add rollback capabilities
|
||||
- **Quality Check**: Lint + test all packages
|
||||
|
||||
### Step 3.3: Monitoring and Health
|
||||
**Duration**: 2-3 days
|
||||
**Goal**: Basic system monitoring
|
||||
|
||||
#### Mini-milestone 3.3.1: Health check endpoints
|
||||
- Add system health endpoints
|
||||
- Report service status
|
||||
- Add basic metrics collection
|
||||
- **Quality Check**: Lint + test all packages
|
||||
|
||||
#### Mini-milestone 3.3.2: Device health tracking
|
||||
- Track device connectivity
|
||||
- Monitor response times
|
||||
- Log health status changes
|
||||
- **Quality Check**: Lint + test all packages
|
||||
|
||||
#### Mini-milestone 3.3.3: System metrics
|
||||
- Add basic performance metrics
|
||||
- Track resource usage
|
||||
- Add metrics endpoints
|
||||
- **Quality Check**: Lint + test all packages
|
||||
|
||||
## Quality Assurance Strategy
|
||||
|
||||
### Testing Requirements
|
||||
Each mini-milestone must include:
|
||||
- Unit tests for new functions
|
||||
- Integration tests for modified workflows
|
||||
- Regression tests for existing functionality
|
||||
- Performance tests for critical paths
|
||||
|
||||
### Test Categories
|
||||
|
||||
#### Unit Tests
|
||||
- Test individual functions and methods
|
||||
- Mock external dependencies
|
||||
- Cover error conditions and edge cases
|
||||
- Aim for >90% code coverage on new code
|
||||
|
||||
#### Integration Tests
|
||||
- Test component interactions
|
||||
- Use real file operations in test environment
|
||||
- Test HTTP endpoints end-to-end
|
||||
- Verify existing functionality unchanged
|
||||
|
||||
#### Regression Tests
|
||||
- Ensure existing XML endpoints work
|
||||
- Verify device discovery still functions
|
||||
- Check migration compatibility
|
||||
- Test WebSocket event processing
|
||||
|
||||
### Continuous Quality Checks
|
||||
|
||||
#### Pre-commit Checks
|
||||
```bash
|
||||
# Before each commit
|
||||
golangci-lint run --fix
|
||||
go test ./...
|
||||
go test -race ./...
|
||||
```
|
||||
|
||||
#### Milestone Validation
|
||||
```bash
|
||||
# Before marking milestone complete
|
||||
golangci-lint run --fix
|
||||
go test ./... -v
|
||||
go test -race ./... -v
|
||||
go test ./... -bench=.
|
||||
```
|
||||
|
||||
#### Integration Validation
|
||||
```bash
|
||||
# Test with real soundtouch-service
|
||||
make build
|
||||
./soundtouch-service &
|
||||
# Run integration test suite
|
||||
make integration-test
|
||||
```
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
### Backward Compatibility
|
||||
- All existing APIs must continue working
|
||||
- File structure changes must be additive
|
||||
- Configuration changes must have defaults
|
||||
- Migration paths for existing data
|
||||
|
||||
### Rollback Strategy
|
||||
- Each step can be independently reverted
|
||||
- Configuration flags for new features
|
||||
- Graceful degradation when features disabled
|
||||
- Clear rollback documentation
|
||||
|
||||
### Performance Impact
|
||||
- Monitor memory usage during development
|
||||
- Profile critical paths before and after changes
|
||||
- Set performance regression alerts
|
||||
- Simple before complex solutions
|
||||
|
||||
## Documentation Requirements
|
||||
|
||||
### Code Documentation
|
||||
- Comprehensive godoc comments
|
||||
- Example usage in comments
|
||||
- Error conditions documented
|
||||
- Performance characteristics noted
|
||||
|
||||
### User Documentation
|
||||
- Update existing guides for new features
|
||||
- Add migration guides for new functionality
|
||||
- Create troubleshooting documentation
|
||||
- Update API documentation
|
||||
|
||||
### Development Documentation
|
||||
- Architecture decision records
|
||||
- Testing strategy documentation
|
||||
- Deployment and rollback procedures
|
||||
- Performance benchmarking results
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Technical Metrics
|
||||
- All tests pass consistently
|
||||
- No linting issues
|
||||
- Memory usage increase <50MB
|
||||
- Response time degradation <10%
|
||||
|
||||
### Functional Metrics
|
||||
- All existing functionality preserved
|
||||
- New account management works reliably
|
||||
- Device lifecycle tracking is accurate
|
||||
- Enhanced monitoring provides value
|
||||
|
||||
### Quality Metrics
|
||||
- Code coverage maintained >85%
|
||||
- No critical security issues
|
||||
- Documentation completeness >95%
|
||||
- Community feedback positive
|
||||
|
||||
This implementation plan ensures steady, reliable progress while maintaining the quality and simplicity principles essential for the project's success.
|
||||
@@ -0,0 +1,403 @@
|
||||
# Implementation Roadmap for Upstream Service Simulation
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides a detailed implementation roadmap for the upstream Bose service simulation concept. It breaks down the implementation into manageable phases with specific deliverables, technical requirements, and integration points.
|
||||
|
||||
## Phase 1: Foundation and Enhanced State Tracking (4-6 weeks)
|
||||
|
||||
### Milestone 1.1: Account Management Service (1-2 weeks)
|
||||
|
||||
#### Deliverables
|
||||
- `pkg/service/account/` package with core account management
|
||||
- Account creation, retrieval, and status management APIs
|
||||
- Text-based account persistence in JSON format
|
||||
- Integration with existing datastore structure
|
||||
|
||||
#### Implementation Tasks
|
||||
1. **Create Account Manager**
|
||||
```
|
||||
pkg/service/account/
|
||||
├── account.go # Core account management
|
||||
├── manager.go # Account manager implementation
|
||||
├── persistence.go # File-based persistence
|
||||
└── account_test.go # Comprehensive tests
|
||||
```
|
||||
|
||||
2. **Account Data Structure**
|
||||
- JSON-based account metadata storage
|
||||
- Integration with existing `data/accounts/{id}/` structure
|
||||
- Account status tracking (active, migrating, suspended)
|
||||
- Migration metadata tracking
|
||||
|
||||
3. **API Integration**
|
||||
- Add account management endpoints to existing HTTP router
|
||||
- RESTful API alongside existing XML endpoints
|
||||
- Account creation validation and error handling
|
||||
|
||||
#### Technical Requirements
|
||||
- Maintain backward compatibility with existing account structure
|
||||
- Thread-safe account operations
|
||||
- Atomic file operations for account metadata
|
||||
- Comprehensive error handling and logging
|
||||
|
||||
### Milestone 1.2: Device Lifecycle Manager (2-3 weeks)
|
||||
|
||||
#### Deliverables
|
||||
- `pkg/service/lifecycle/` package for device state management
|
||||
- Device state machine with comprehensive state tracking
|
||||
- Event-driven state transitions
|
||||
- Integration with existing device discovery and migration
|
||||
|
||||
#### Implementation Tasks
|
||||
1. **Lifecycle Core**
|
||||
```
|
||||
pkg/service/lifecycle/
|
||||
├── lifecycle.go # Device lifecycle management
|
||||
├── states.go # State definitions and transitions
|
||||
├── events.go # Event processing
|
||||
├── persistence.go # Lifecycle persistence
|
||||
└── lifecycle_test.go # State machine tests
|
||||
```
|
||||
|
||||
2. **State Machine Implementation**
|
||||
- Define device states: unregistered → registering → active → migrating → offline → retired
|
||||
- Implement state transition rules and validation
|
||||
- Event-driven state changes with history tracking
|
||||
- Integration with existing migration system
|
||||
|
||||
3. **Event Processing**
|
||||
- Asynchronous event queue for device events
|
||||
- Event categorization and filtering
|
||||
- Text-based event logging with structured format
|
||||
- Event replay capabilities for debugging
|
||||
|
||||
#### Technical Requirements
|
||||
- Non-blocking event processing
|
||||
- Persistent state across service restarts
|
||||
- Integration with existing WebSocket event system
|
||||
- Memory-efficient event storage
|
||||
|
||||
### Milestone 1.3: Enhanced Mirror System (1-2 weeks)
|
||||
|
||||
#### Deliverables
|
||||
- Extended mirroring with disparity detection
|
||||
- Parity analysis logging and reporting
|
||||
- Selective data source switching
|
||||
- Integration with existing mirror middleware
|
||||
|
||||
#### Implementation Tasks
|
||||
1. **Disparity Detection**
|
||||
```
|
||||
pkg/service/mirror/
|
||||
├── disparity.go # Disparity detection logic
|
||||
├── analyzer.go # Response analysis and comparison
|
||||
├── logger.go # Structured disparity logging
|
||||
└── disparity_test.go # Analysis tests
|
||||
```
|
||||
|
||||
2. **Enhanced Mirror Middleware**
|
||||
- Extend existing mirror functionality
|
||||
- Add response comparison and hash calculation
|
||||
- Structured logging of disparities
|
||||
- Configurable disparity sensitivity
|
||||
|
||||
3. **Data Source Management**
|
||||
- Smart routing between local and upstream sources
|
||||
- Per-endpoint source preference configuration
|
||||
- Fallback mechanisms for upstream unavailability
|
||||
- Source switching with history tracking
|
||||
|
||||
#### Technical Requirements
|
||||
- Minimal performance impact on request processing
|
||||
- Configurable disparity detection sensitivity
|
||||
- Structured logging for analysis tools
|
||||
- Integration with existing mirror configuration
|
||||
|
||||
## Phase 2: Migration and Dual-Source Management (3-4 weeks)
|
||||
|
||||
### Milestone 2.1: Migration Controller (2-3 weeks)
|
||||
|
||||
#### Deliverables
|
||||
- Device-by-device migration orchestration
|
||||
- Migration progress tracking and status reporting
|
||||
- Rollback capabilities with state preservation
|
||||
- Integration with existing setup manager
|
||||
|
||||
#### Implementation Tasks
|
||||
1. **Migration Orchestration**
|
||||
```
|
||||
pkg/service/migration/
|
||||
├── controller.go # Migration orchestration
|
||||
├── strategy.go # Migration strategies
|
||||
├── rollback.go # Rollback functionality
|
||||
├── progress.go # Progress tracking
|
||||
└── migration_integration_test.go
|
||||
```
|
||||
|
||||
2. **Migration Strategies**
|
||||
- Fresh device registration flow
|
||||
- Bose account data migration flow
|
||||
- Gradual migration with dual-source support
|
||||
- Emergency migration for service outages
|
||||
|
||||
3. **Progress Tracking**
|
||||
- Real-time migration status updates
|
||||
- Migration timeline and milestone tracking
|
||||
- Error handling and recovery procedures
|
||||
- Migration completion verification
|
||||
|
||||
#### Technical Requirements
|
||||
- Integration with existing migration system
|
||||
- Atomic migration operations with rollback
|
||||
- Progress persistence across service restarts
|
||||
- Comprehensive migration logging
|
||||
|
||||
### Milestone 2.2: Dual-Source Data Management (1-2 weeks)
|
||||
|
||||
#### Deliverables
|
||||
- Smart data routing between local and upstream sources
|
||||
- Graceful fallback mechanisms
|
||||
- Data source preference management
|
||||
- Conflict resolution strategies
|
||||
|
||||
#### Implementation Tasks
|
||||
1. **Data Source Router**
|
||||
```
|
||||
pkg/service/datasource/
|
||||
├── router.go # Smart routing logic
|
||||
├── preferences.go # Source preference management
|
||||
├── fallback.go # Fallback mechanisms
|
||||
└── conflict.go # Conflict resolution
|
||||
```
|
||||
|
||||
2. **Source Management**
|
||||
- Per-device, per-endpoint source preferences
|
||||
- Dynamic source switching based on availability
|
||||
- Conflict detection and resolution
|
||||
- Source health monitoring
|
||||
|
||||
3. **Integration Points**
|
||||
- Marge service integration for account data
|
||||
- BMX service integration for content data
|
||||
- Preset and recent management integration
|
||||
- Source configuration management
|
||||
|
||||
#### Technical Requirements
|
||||
- Zero-downtime source switching
|
||||
- Conflict resolution without data loss
|
||||
- Health check integration
|
||||
- Performance monitoring and metrics
|
||||
|
||||
## Phase 3: Advanced Features and Analytics (2-3 weeks)
|
||||
|
||||
### Milestone 3.1: System Monitoring and Health Checks (1-2 weeks)
|
||||
|
||||
#### Deliverables
|
||||
- Comprehensive system health monitoring
|
||||
- Device connectivity and availability tracking
|
||||
- Performance metrics collection
|
||||
- Health check endpoints and dashboards
|
||||
|
||||
#### Implementation Tasks
|
||||
1. **Health Monitoring**
|
||||
```
|
||||
pkg/service/health/
|
||||
├── monitor.go # System health monitoring
|
||||
├── metrics.go # Performance metrics
|
||||
├── connectivity.go # Device connectivity tracking
|
||||
└── alerts.go # Health alerting
|
||||
```
|
||||
|
||||
2. **Metrics Collection**
|
||||
- Device availability tracking
|
||||
- Response time monitoring
|
||||
- Error rate tracking
|
||||
- Migration success rates
|
||||
|
||||
3. **Dashboard Integration**
|
||||
- Health status endpoints
|
||||
- Metrics export for monitoring tools
|
||||
- Real-time status updates
|
||||
- Historical trend analysis
|
||||
|
||||
#### Technical Requirements
|
||||
- Minimal performance overhead
|
||||
- Configurable monitoring intervals
|
||||
- Integration with existing health checks
|
||||
- Memory-efficient metrics storage
|
||||
|
||||
### Milestone 3.2: Data Export and Backup (1 week)
|
||||
|
||||
#### Deliverables
|
||||
- Account data export functionality
|
||||
- Incremental backup strategies
|
||||
- Data integrity verification
|
||||
- Migration-ready data formats
|
||||
|
||||
#### Implementation Tasks
|
||||
1. **Export Functionality**
|
||||
```
|
||||
pkg/service/export/
|
||||
├── exporter.go # Data export logic
|
||||
├── formats.go # Export format definitions
|
||||
├── validation.go # Data integrity checks
|
||||
└── backup.go # Backup strategies
|
||||
```
|
||||
|
||||
2. **Backup Management**
|
||||
- Incremental backup creation
|
||||
- Backup validation and verification
|
||||
- Automated backup scheduling
|
||||
- Restore functionality
|
||||
|
||||
3. **Data Formats**
|
||||
- Migration-ready JSON exports
|
||||
- XML compatibility for device imports
|
||||
- Compressed archive support
|
||||
- Selective export capabilities
|
||||
|
||||
#### Technical Requirements
|
||||
- Consistent data export across all account types
|
||||
- Backup integrity verification
|
||||
- Configurable export scheduling
|
||||
- Resource-efficient backup operations
|
||||
|
||||
## Integration Strategy
|
||||
|
||||
### Existing Service Integration Points
|
||||
|
||||
#### 1. Datastore Integration
|
||||
- Extend existing datastore with lifecycle and account management
|
||||
- Maintain backward compatibility with current file structure
|
||||
- Add new persistence methods for enhanced state tracking
|
||||
- Implement migration for existing data to new formats
|
||||
|
||||
#### 2. Handler Integration
|
||||
- Integrate account management into existing HTTP handlers
|
||||
- Add lifecycle information to device responses
|
||||
- Extend mirror middleware with disparity detection
|
||||
- Add new management endpoints alongside existing XML APIs
|
||||
|
||||
#### 3. Discovery Integration
|
||||
- Link device discovery to lifecycle state transitions
|
||||
- Integrate migration triggers with discovery events
|
||||
- Add account association during discovery
|
||||
- Maintain existing discovery functionality
|
||||
|
||||
#### 4. Migration System Integration
|
||||
- Extend existing migration manager with new capabilities
|
||||
- Integrate lifecycle management with device migrations
|
||||
- Add rollback functionality to existing migration flows
|
||||
- Maintain compatibility with current migration methods
|
||||
|
||||
### Configuration Management
|
||||
|
||||
#### New Configuration Options
|
||||
```yaml
|
||||
accounts:
|
||||
auto_create: false
|
||||
mirror_enhanced_creation: true
|
||||
default_migration_strategy: "gradual"
|
||||
|
||||
lifecycle:
|
||||
event_retention_days: 30
|
||||
state_transition_timeout: "5m"
|
||||
async_processing: true
|
||||
|
||||
mirror:
|
||||
disparity_detection: true
|
||||
disparity_sensitivity: "medium"
|
||||
source_switching_enabled: true
|
||||
fallback_timeout: "10s"
|
||||
|
||||
migration:
|
||||
batch_size: 1
|
||||
progress_reporting: true
|
||||
rollback_enabled: true
|
||||
verification_required: true
|
||||
```
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
#### Resource Usage
|
||||
- Target: <100MB additional memory usage on Raspberry Pi Zero 2W
|
||||
- CPU usage: <5% additional overhead during normal operations
|
||||
- Storage: Text-based logs with configurable rotation
|
||||
- Network: Minimal additional upstream requests
|
||||
|
||||
#### Optimization Strategies
|
||||
- Lazy loading of historical data
|
||||
- Configurable log retention policies
|
||||
- Memory-efficient event processing
|
||||
- Background cleanup processes
|
||||
- Efficient file I/O operations
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Testing
|
||||
- Comprehensive test coverage for all new packages
|
||||
- State machine transition testing
|
||||
- Data persistence and integrity tests
|
||||
- Mock integration tests for external dependencies
|
||||
|
||||
### Integration Testing
|
||||
- End-to-end migration flow testing
|
||||
- Multi-device scenario testing
|
||||
- Disparity detection accuracy testing
|
||||
- Performance impact testing
|
||||
|
||||
### Compatibility Testing
|
||||
- Backward compatibility with existing installations
|
||||
- Device compatibility across SoundTouch models
|
||||
- Migration from various existing configurations
|
||||
- Stress testing with multiple concurrent devices
|
||||
|
||||
## Deployment Strategy
|
||||
|
||||
### Rollout Plan
|
||||
1. **Alpha Release**: Core functionality with limited device support
|
||||
2. **Beta Release**: Full feature set with extensive testing
|
||||
3. **Stable Release**: Production-ready with documentation
|
||||
|
||||
### Migration Path
|
||||
1. Existing installations can upgrade incrementally
|
||||
2. New features are opt-in with configuration flags
|
||||
3. Existing data structures are preserved and extended
|
||||
4. Rollback capability for critical issues
|
||||
|
||||
### Documentation Requirements
|
||||
- Updated API documentation with new endpoints
|
||||
- Migration guide for existing users
|
||||
- Configuration reference for new options
|
||||
- Troubleshooting guide for common issues
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
### Technical Risks
|
||||
- **Data Loss**: Atomic operations and rollback capabilities
|
||||
- **Performance Impact**: Gradual rollout and monitoring
|
||||
- **Compatibility Issues**: Comprehensive testing and fallback options
|
||||
- **Resource Constraints**: Efficient algorithms and configurable limits
|
||||
|
||||
### Operational Risks
|
||||
- **Service Disruption**: Zero-downtime deployment strategies
|
||||
- **Configuration Complexity**: Sensible defaults and validation
|
||||
- **User Adoption**: Clear documentation and migration assistance
|
||||
- **Support Burden**: Comprehensive logging and diagnostic tools
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### Technical Metrics
|
||||
- Migration success rate >95%
|
||||
- Disparity detection accuracy >90%
|
||||
- Performance overhead <5%
|
||||
- System availability >99.5%
|
||||
|
||||
### User Experience Metrics
|
||||
- Reduced support requests
|
||||
- Improved device reliability
|
||||
- Faster problem resolution
|
||||
- Enhanced system visibility
|
||||
|
||||
This roadmap provides a structured approach to implementing the upstream service simulation concept while maintaining compatibility with existing deployments and ensuring smooth migration paths for users.
|
||||
@@ -4,7 +4,31 @@ This document outlines the strategy for ensuring Bose SoundTouch devices are cor
|
||||
|
||||
## Overview
|
||||
|
||||
To enable Spotify Connect for SoundTouch devices, especially for remote availability outside the local network, the speaker must be associated with a Spotify account via a process called "priming." This involves sending an `addUser` command to the speaker's ZeroConf API (port 8200) containing a valid Spotify username and OAuth access token.
|
||||
To enable Spotify Connect for SoundTouch devices, especially for remote availability outside the local network, the speaker must be associated with a Spotify account via a process called "priming." This involves a two-step exchange with the speaker's ZeroConf API (port 8200):
|
||||
|
||||
1. **`getInfo`** — retrieve the speaker's Diffie-Hellman public key and device metadata.
|
||||
2. **`addUser`** — push encrypted Spotify credentials using the shared DH secret.
|
||||
|
||||
This is the standard Spotify Connect ZeroConf protocol. Once the speaker holds a properly encrypted credential blob it can independently authenticate with Spotify's servers and refresh its own session without any further involvement from AfterTouch.
|
||||
|
||||
### ZeroConf Protocol
|
||||
|
||||
The current implementation follows the full Spotify Connect ZeroConf protocol (`pkg/service/spotify/zeroconf.go`):
|
||||
|
||||
1. `GET http://{ip}:8200/zc?action=getInfo` → parse `publicKey` (base64 DH key, 768-bit Oakley Group 1 prime) from the response.
|
||||
2. Generate a client DH key pair using the same group parameters.
|
||||
3. Compute `sharedSecret = DH(clientPrivate, speakerPublicKey)`.
|
||||
4. Derive keys: `baseKey = SHA1(sharedSecret)[:16]`, then HMAC-SHA1 with labels `"encryption"` and `"checksum"`.
|
||||
5. Encrypt a protobuf-encoded `LoginCredentials` blob (username, `AUTHENTICATION_SPOTIFY_TOKEN=4`, access token) using AES-128-CTR + HMAC-SHA1 checksum.
|
||||
6. `POST http://{ip}:8200/zc?action=addUser` with `blob={encryptedBlob}`, `clientKey={clientPublicKeyBase64}`.
|
||||
|
||||
The speaker decrypts the blob, stores long-lived credentials, and can handle token refresh with Spotify independently. No periodic re-priming is required for token expiry.
|
||||
|
||||
The algorithm is based on [librespot](https://github.com/librespot-org/librespot) (Rust reference implementation).
|
||||
|
||||
### Fallback for Older Firmware
|
||||
|
||||
If `getInfo` fails (e.g. firmware that does not implement the DH exchange), `PushSpotifyCredentials` automatically falls back to the simplified `tokenType=accesstoken` approach: the raw OAuth access token is sent as the `blob` with an empty `clientKey`. This token expires after ~60 minutes and the speaker cannot self-refresh, so periodic re-priming is required in that case.
|
||||
|
||||
AfterTouch adopts a **Server-Centric Hybrid Model** that prioritizes device cleanliness and user intent while providing automated self-healing.
|
||||
|
||||
@@ -14,7 +38,7 @@ AfterTouch adopts a **Server-Centric Hybrid Model** that prioritizes device clea
|
||||
AfterTouch replicates the native Bose "Add Source" experience. No Spotify priming occurs until a user explicitly links their Spotify account through the AfterTouch Management Dashboard. This ensures privacy and respects users who do not wish to use Spotify.
|
||||
|
||||
### 2. Device Cleanliness (Minimalist Footprint)
|
||||
We avoid invasive modifications to the speaker's filesystem.
|
||||
We avoid invasive modifications to the speaker's filesystem.
|
||||
- **No On-Device Scripts:** We deprecate the use of internal boot-primer scripts.
|
||||
- **Native Communication:** We rely on the speaker's native ability to talk to Bose services, which are intercepted via DNS to point to the AfterTouch server.
|
||||
|
||||
@@ -53,6 +77,8 @@ The logic for account management and device interaction remains decoupled:
|
||||
4. AfterTouch pushes a fresh token from the Spotify Service.
|
||||
5. UI reflects that the device is "Managed by AfterTouch" and healthy.
|
||||
|
||||
> **Note:** With the proper encrypted-blob flow now in place, the watchdog is only needed for the "speaker reboots and loses state" case — not for token expiry. Speakers running older firmware that trigger the `tokenType=accesstoken` fallback still require periodic re-priming (~45 min) because the raw access token expires.
|
||||
|
||||
### Manual Override
|
||||
Users can manually trigger a "Re-prime" or "Refresh Link" from the device list in the UI if they suspect the automated self-healing is delayed or if they want to force a specific account onto a device.
|
||||
|
||||
@@ -76,9 +102,11 @@ As AfterTouch moves to the Server-Centric model, we will:
|
||||
2. **Consolidated Directory:** We maintain the `/mnt/nv/soundtouch-service/` base directory for other configuration needs (e.g., `aftertouch.resolv.conf`), but it will no longer contain Spotify-specific credentials or scripts.
|
||||
3. **No On-Device Credentials:** The `/mnt/nv/soundtouch-service/spotify-primer.conf` will be removed, ensuring that no sensitive AfterTouch login details are stored on the speaker in plain text.
|
||||
|
||||
## Implementation Roadmap (Conceptual)
|
||||
## Implementation Roadmap
|
||||
|
||||
1. **Revert On-Device Migration:** Update the Setup Manager to remove legacy scripts and `rc.local` hooks.
|
||||
2. **Server-Side Priming Logic:** Implement a `PrimeDevice(ip)` method in the server that fetches a fresh token and calls the ZeroConf API.
|
||||
3. **Discovery Hook:** Integrate `PrimeDevice` into the discovery handler (`handleDiscoveredDevice`) with a check for unprimed state.
|
||||
4. **UI Enhancements:** Update the Speaker List to show "Spotify Linked" status and provide manual refresh buttons.
|
||||
1. ✅ **Server-Side Priming Logic:** `PrimeDeviceWithSpotify(ip)` and `pushSpotifyTokenToDevice` in `pkg/service/handlers/server.go`. Triggered on device registration (marge handlers) and via the manual `HandleMgmtPrimeDevice` endpoint.
|
||||
2. ✅ **Discovery Hook:** `handleDiscoveredDevice` calls `PrimeDeviceWithSpotify` when a speaker is found.
|
||||
3. ✅ **Proper ZeroConf Blob:** Full DH key exchange + AES-128-CTR encrypted `LoginCredentials` blob implemented in `pkg/service/spotify/zeroconf.go`. Automatically falls back to `tokenType=accesstoken` if `getInfo` fails (older firmware).
|
||||
4. ⬜ **Watchdog / Session Refresh:** Background timer to re-prime all known devices on a schedule. Only strictly needed for older firmware (fallback path) or "speaker lost state" recovery; not required for token expiry on modern firmware.
|
||||
5. ⬜ **Revert On-Device Migration:** Update the Setup Manager to remove legacy `spotify-boot-primer` scripts and `rc.local` hooks from the speakers.
|
||||
6. ⬜ **UI Enhancements:** Update the Speaker List to show "Spotify Linked" status and provide manual refresh buttons.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,393 @@
|
||||
# Upstream Bose Service Simulation - State Management Concept
|
||||
|
||||
## 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.
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Case 0: Account Management
|
||||
- **Explicit Account Creation**: Accounts must be created through deliberate action (web UI, API call)
|
||||
- **Mirror-Enhanced Creation**: Account creation can be enriched using mirrored data from upstream Bose endpoints when devices make requests
|
||||
- **Data Recording**: Passively record account information during normal device operations for future use
|
||||
|
||||
### Case 1a: Fresh Device Registration
|
||||
- Initial setup/registration of a factory-reset or new device
|
||||
- Device has no prior Bose account association
|
||||
- Full local initialization with default configurations
|
||||
|
||||
### Case 1b: Device Migration from Bose Account
|
||||
- Migrate existing registered device from Bose services to local management
|
||||
- Preserve existing device data (presets, recents, sources)
|
||||
- Support gradual migration while maintaining Bose compatibility
|
||||
- Mirror Bose account data for seamless transition
|
||||
|
||||
### Case 2: Device Lifecycle and State Management
|
||||
- Track and manage device lifecycle states and activities
|
||||
- Maintain internal state based on incoming events from devices
|
||||
- Detect disparities between local and upstream behavior
|
||||
- Provide visibility into state changes and system health
|
||||
|
||||
## Architecture Principles
|
||||
|
||||
### 1. **Text-Based Storage for Debugging**
|
||||
- Maintain all state in human-readable text formats (XML, JSON, plain text)
|
||||
- Use small, focused files for each data aspect
|
||||
- Enable easy debugging and manual inspection
|
||||
- Optimize for small hardware deployments (Raspberry Pi Zero 2W)
|
||||
|
||||
### 2. **Mirror-First Strategy**
|
||||
- Keep mirror functionality active as long as possible
|
||||
- Primary source switches from upstream to local only during:
|
||||
- Explicit migration
|
||||
- Sufficient local data accumulation
|
||||
- Upstream service unavailability
|
||||
- Record and mirror as much data as possible, even if not immediately used
|
||||
|
||||
### 3. **Disparity Detection**
|
||||
- Track differences between local and upstream responses
|
||||
- Log discrepancies for analysis and improvement
|
||||
- Provide visibility into implementation gaps
|
||||
- Support parity testing and validation
|
||||
|
||||
### 4. **Event-Driven State Management**
|
||||
- Process device events asynchronously
|
||||
- Track comprehensive event history in text files
|
||||
- Support event replay and analysis
|
||||
- Minimize noise while capturing important state changes
|
||||
|
||||
## Enhanced Data Structure
|
||||
|
||||
### Account Management
|
||||
|
||||
```
|
||||
data/
|
||||
├── accounts/
|
||||
│ ├── {account-id}/
|
||||
│ │ ├── account.json # Account metadata
|
||||
│ ├── account-events.log # High-level account behavior tracking
|
||||
│ │ ├── devices/
|
||||
│ │ │ └── {device-id}/
|
||||
│ │ │ ├── lifecycle.json # Device state and history
|
||||
│ │ │ ├── info.xml # Device information
|
||||
│ │ │ ├── presets.xml # Device presets
|
||||
│ │ │ ├── recents.xml # Recent plays
|
||||
│ │ │ ├── sources.xml # Configured sources
|
||||
│ │ │ └── events.log # Device event history
|
||||
│ │ └── sessions/
|
||||
│ │ └── {session-id}/ # Recorded interaction sessions
|
||||
└── system/
|
||||
├── discovery.log # Device discovery events
|
||||
└── migration.log # Migration activities
|
||||
```
|
||||
|
||||
### Account Metadata Format
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "account-12345",
|
||||
"name": "User Account",
|
||||
"email": "user@example.com",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-20T15:45:00Z",
|
||||
"status": "active",
|
||||
"device_count": 3,
|
||||
"migration_status": {
|
||||
"started_at": "2024-01-18T09:00:00Z",
|
||||
"devices_migrated": 1,
|
||||
"devices_pending": 2,
|
||||
"mirror_active": true
|
||||
},
|
||||
"bose_account_id": "bose-original-id",
|
||||
"data_sources": {
|
||||
"local": true,
|
||||
"bose_mirror": true,
|
||||
"primary": "bose"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Device Lifecycle Format
|
||||
|
||||
```json
|
||||
{
|
||||
"device_id": "A81B6A536A98",
|
||||
"account_id": "account-12345",
|
||||
"state": "active",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-20T16:22:00Z",
|
||||
"state_history": [
|
||||
{
|
||||
"from": "unregistered",
|
||||
"to": "registering",
|
||||
"timestamp": "2024-01-15T10:30:00Z",
|
||||
"reason": "fresh_device_setup",
|
||||
"source": "discovery"
|
||||
},
|
||||
{
|
||||
"from": "registering",
|
||||
"to": "active",
|
||||
"timestamp": "2024-01-15T10:35:00Z",
|
||||
"reason": "registration_complete",
|
||||
"source": "system"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"name": "Living Room Speaker",
|
||||
"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",
|
||||
"last_seen": "2024-01-20T16:20:00Z",
|
||||
"is_legacy_id": false
|
||||
},
|
||||
"data_sources": {
|
||||
"presets": "local",
|
||||
"recents": "mirror_primary",
|
||||
"sources": "local"
|
||||
},
|
||||
"migration": {
|
||||
"from_bose_account": "bose-account-xyz",
|
||||
"migrated_at": "2024-01-18T14:30:00Z",
|
||||
"method": "gradual",
|
||||
"rollback_available": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Event Log Format
|
||||
|
||||
```
|
||||
# Device Events Log - A81B6A536A98
|
||||
# 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"}
|
||||
```
|
||||
|
||||
### Disparity Log Format
|
||||
|
||||
```
|
||||
# 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
|
||||
```
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Phase 1: Enhanced State Tracking
|
||||
|
||||
1. **Account Management Service**
|
||||
- Explicit account creation API
|
||||
- Mirror-enhanced account initialization
|
||||
- Account status and migration tracking
|
||||
|
||||
2. **Device Lifecycle Manager**
|
||||
- Comprehensive state machine for device lifecycle
|
||||
- Event-driven state transitions
|
||||
- Text-based state persistence
|
||||
|
||||
3. **Enhanced Mirror System**
|
||||
- Extended mirroring with disparity detection
|
||||
- Selective data source switching
|
||||
- Parity analysis and logging
|
||||
|
||||
### Phase 2: Gradual Migration Support
|
||||
|
||||
1. **Migration Controller**
|
||||
- Device-by-device migration orchestration
|
||||
- Rollback capability with state preservation
|
||||
- Migration progress tracking
|
||||
|
||||
2. **Dual-Source Data Management**
|
||||
- Smart routing between local and upstream data
|
||||
- Graceful fallback mechanisms
|
||||
- Data source preference management
|
||||
|
||||
3. **State Synchronization**
|
||||
- Bidirectional sync capabilities
|
||||
- Conflict resolution strategies
|
||||
- Sync status monitoring
|
||||
|
||||
### Phase 3: Advanced Analytics
|
||||
|
||||
1. **Disparity Analysis Engine**
|
||||
- Automated disparity detection and classification
|
||||
- Trend analysis and reporting
|
||||
- Implementation gap identification
|
||||
|
||||
2. **System Health Monitoring**
|
||||
- Device connectivity monitoring
|
||||
- Service availability tracking
|
||||
- Performance metrics collection
|
||||
|
||||
3. **Data Export and Backup**
|
||||
- Account data export for migration
|
||||
- Incremental backup strategies
|
||||
- Data integrity verification
|
||||
|
||||
## API Enhancements
|
||||
|
||||
### Account Management APIs
|
||||
|
||||
```http
|
||||
# Create account explicitly
|
||||
POST /api/v1/accounts
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "User Account",
|
||||
"email": "user@example.com"
|
||||
}
|
||||
|
||||
# Get account with migration status
|
||||
GET /api/v1/accounts/{account-id}
|
||||
|
||||
# Initiate account migration from Bose
|
||||
POST /api/v1/accounts/{account-id}/migrate
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"bose_account_id": "bose-original-id",
|
||||
"strategy": "gradual"
|
||||
}
|
||||
```
|
||||
|
||||
### Device Lifecycle APIs
|
||||
|
||||
```http
|
||||
# Register fresh device
|
||||
POST /api/v1/accounts/{account-id}/devices
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"device_id": "A81B6A536A98",
|
||||
"name": "Living Room Speaker",
|
||||
"registration_type": "fresh"
|
||||
}
|
||||
|
||||
# Get device state and lifecycle
|
||||
GET /api/v1/accounts/{account-id}/devices/{device-id}/state
|
||||
|
||||
# Migrate device from Bose account
|
||||
POST /api/v1/accounts/{account-id}/devices/{device-id}/migrate
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"from_bose_account": "bose-account-xyz",
|
||||
"preserve_data": true
|
||||
}
|
||||
```
|
||||
|
||||
### Monitoring and Analysis APIs
|
||||
|
||||
```http
|
||||
# Get disparity analysis
|
||||
GET /api/v1/system/disparities?since=2024-01-20T00:00:00Z
|
||||
|
||||
# Get migration status
|
||||
GET /api/v1/system/migration/status
|
||||
|
||||
# Export account data
|
||||
GET /api/v1/accounts/{account-id}/export
|
||||
```
|
||||
|
||||
## Integration with Existing Services
|
||||
|
||||
### Enhanced Marge Service
|
||||
|
||||
- Integrate lifecycle information into account responses
|
||||
- Add migration status to device listings
|
||||
- Support dual-source data routing
|
||||
- Include disparity metadata in responses
|
||||
|
||||
### Enhanced BMX Service
|
||||
|
||||
- Track content source preferences by account
|
||||
- Mirror and compare content recommendations
|
||||
- Log streaming behavior for analysis
|
||||
- Support gradual source migration
|
||||
|
||||
### Discovery Service Integration
|
||||
|
||||
- Link discovered devices to lifecycle manager
|
||||
- Trigger lifecycle state transitions on discovery events
|
||||
- Support both fresh registration and migration flows
|
||||
- Handle legacy device ID migration automatically
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Simplicity First (KISS Principle)
|
||||
|
||||
- Favor simple, readable code over premature optimization
|
||||
- Use straightforward algorithms and data structures
|
||||
- Minimize complexity in favor of maintainability
|
||||
- Build incrementally with small, testable changes
|
||||
|
||||
### Quality Assurance
|
||||
|
||||
- Complete test coverage for all new functionality
|
||||
- Comprehensive linting with `golangci-lint run --fix`
|
||||
- Full test suite execution `go test ./...` for each milestone
|
||||
- Integration tests with existing functionality
|
||||
|
||||
### File Management
|
||||
|
||||
- Simple line-based append operations for logs
|
||||
- Basic log rotation when needed
|
||||
- Direct file operations without complex caching
|
||||
- Straightforward data persistence
|
||||
|
||||
## Development Principles
|
||||
|
||||
### KISS (Keep It Simple, Stupid)
|
||||
- Prioritize simplicity and readability over performance optimization
|
||||
- Use standard Go idioms and patterns
|
||||
- Avoid premature abstraction and optimization
|
||||
- Build the simplest thing that works first
|
||||
|
||||
### Quality First
|
||||
- Every milestone must pass `golangci-lint run --fix` without issues
|
||||
- Complete test suite must pass `go test ./...` before proceeding
|
||||
- Integration tests ensure existing functionality remains intact
|
||||
- Code coverage should be maintained or improved
|
||||
|
||||
### Incremental Development
|
||||
- Make small, focused changes that can be easily reviewed
|
||||
- Each step should be independently testable and valuable
|
||||
- Maintain backward compatibility throughout development
|
||||
- Enable rollback at any point in the process
|
||||
|
||||
### Leverage Existing Systems
|
||||
- Reuse existing interaction recording for request/response tracking
|
||||
- Build upon current parity mismatch detection system
|
||||
- Extend existing datastore and handler patterns
|
||||
- Integrate with established discovery and migration workflows
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Future improvements should maintain the simplicity-first approach:
|
||||
|
||||
1. **Enhanced Web Interface**
|
||||
- Simple dashboard for account and device management
|
||||
- Basic migration progress tracking
|
||||
- Straightforward device health monitoring
|
||||
|
||||
2. **Extended Logging**
|
||||
- Additional high-level behavior tracking
|
||||
- Simple analytics based on existing parity data
|
||||
- Enhanced debugging information
|
||||
|
||||
3. **Community Integration**
|
||||
- Standardized data export formats
|
||||
- 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.
|
||||
@@ -0,0 +1,391 @@
|
||||
# Device Lifecycle and /power_on Enhancement
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides a comprehensive analysis of the current SoundTouch device registration and lifecycle management implementation, and proposes enhancements using the `/power_on` endpoint to reduce dependency on local network connectivity.
|
||||
|
||||
## Current Implementation Assessment
|
||||
|
||||
### Device Information Sources
|
||||
|
||||
The current system uses multiple data collection methods to build a complete device profile:
|
||||
|
||||
#### 1. UPnP/SSDP Discovery
|
||||
- **Protocol**: Multicast UDP discovery for `urn:schemas-upnp-org:service:SoundTouch:1`
|
||||
- **Network Scope**: Limited to same network segment
|
||||
- **Data Collected**:
|
||||
```go
|
||||
type DiscoveredDevice struct {
|
||||
Name string // From UPnP friendlyName
|
||||
Host string // IP address
|
||||
Port int // Usually 8090
|
||||
ModelID string // From UPnP modelName
|
||||
SerialNo string // MAC address from UPnP
|
||||
UPnPLocation string // Device description URL
|
||||
UPnPUSN string // Unique service name
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. mDNS/Bonjour Discovery
|
||||
- **Protocol**: Multicast DNS for `_soundtouch._tcp` services
|
||||
- **Network Scope**: Limited to same network segment
|
||||
- **Purpose**: Complements UPnP discovery with hostname resolution
|
||||
|
||||
#### 3. `/info` Endpoint Enrichment
|
||||
- **Protocol**: HTTP GET to `http://device:8090/info`
|
||||
- **Network Scope**: Requires direct connectivity to device
|
||||
- **Data Collected**:
|
||||
```xml
|
||||
<info deviceID="ABCD1234EFGH">
|
||||
<name>My SoundTouch Device</name>
|
||||
<type>SoundTouch 10</type>
|
||||
<margeAccountUUID>3230304</margeAccountUUID>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<softwareVersion>27.0.6.46330.5043500...</softwareVersion>
|
||||
<serialNumber>I6332527703739342000020</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
<margeURL>https://streaming.bose.com</margeURL>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>AA:BB:CC:DD:EE:FF</macAddress>
|
||||
<ipAddress>192.168.1.10</ipAddress>
|
||||
</networkInfo>
|
||||
<moduleType>sm2</moduleType>
|
||||
<variant>rhino</variant>
|
||||
<countryCode>GB</countryCode>
|
||||
</info>
|
||||
```
|
||||
|
||||
### Current Data Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Service as SoundTouch Service
|
||||
participant UPnP as UPnP Discovery
|
||||
participant mDNS as mDNS Discovery
|
||||
participant Device as SoundTouch Device
|
||||
participant DataStore as Data Store
|
||||
participant User as User/App
|
||||
|
||||
Note over Service,User: Current Device Registration Flow
|
||||
|
||||
Service->>UPnP: Start SSDP Discovery
|
||||
Service->>mDNS: Start mDNS Discovery
|
||||
|
||||
UPnP->>UPnP: Send M-SEARCH multicast
|
||||
Device->>UPnP: Respond with location URL
|
||||
UPnP->>Device: Fetch device description XML
|
||||
Device->>UPnP: Return basic device info
|
||||
|
||||
mDNS->>mDNS: Query _soundtouch._tcp
|
||||
Device->>mDNS: Respond with service info
|
||||
|
||||
Service->>Service: Merge discovery results
|
||||
Service->>Device: GET /info (enrich data)
|
||||
Device->>Service: Return detailed device info
|
||||
Service->>DataStore: Store discovered device
|
||||
|
||||
Note over User,DataStore: User Registration
|
||||
User->>Service: POST /account/{id}/devices
|
||||
Note right of User: deviceId + user-friendly name
|
||||
Service->>DataStore: Link device to account
|
||||
|
||||
Note over Service,DataStore: Migration Process
|
||||
Service->>Device: GET /info (device identification)
|
||||
Device->>Service: Return device details
|
||||
Service->>Service: Build migration summary
|
||||
Service->>Device: Apply configuration changes
|
||||
```
|
||||
|
||||
### Device Registration Points
|
||||
|
||||
The system has distinct phases where device information is collected and enhanced:
|
||||
|
||||
#### Phase 1: Discovery (Network-Dependent)
|
||||
**Trigger**: Automatic network scanning
|
||||
**Data Sources**: UPnP + mDNS + `/info` endpoint
|
||||
**Limitations**: ❌ Requires same network segment
|
||||
|
||||
#### Phase 2: User Registration (User-Controlled)
|
||||
**Trigger**: User adds device to account
|
||||
**Endpoint**: `POST /streaming/account/{accountId}/devices`
|
||||
**Request Format**:
|
||||
```xml
|
||||
<device deviceid="08DF1F0BA325">
|
||||
<name>Living Room Speaker</name>
|
||||
</device>
|
||||
```
|
||||
**Data Added**: ✅ User-friendly name, Account association
|
||||
|
||||
#### Phase 3: Ongoing Updates (Mixed)
|
||||
**Triggers**: Device state changes, firmware updates, network changes
|
||||
**Methods**: Periodic `/info` polling, Discovery refresh, User configuration
|
||||
|
||||
### Current Data Model
|
||||
|
||||
The system maintains comprehensive device information:
|
||||
|
||||
```go
|
||||
type ServiceDeviceInfo struct {
|
||||
DeviceID string `json:"device_id"` // MAC or UUID
|
||||
Name string `json:"name"` // User-friendly name
|
||||
ProductCode string `json:"product_code"` // Device model
|
||||
DeviceSerialNumber string `json:"device_serial_number"` // Hardware serial
|
||||
ProductSerialNumber string `json:"product_serial_number"` // Product serial
|
||||
FirmwareVersion string `json:"firmware_version"` // Software version
|
||||
IPAddress string `json:"ip_address"` // Current IP
|
||||
MacAddress string `json:"mac_address"` // MAC address
|
||||
AccountID string `json:"account_id"` // Account association
|
||||
DiscoveryMethod string `json:"discovery_method"` // How discovered
|
||||
}
|
||||
```
|
||||
|
||||
## Limitations of Current Approach
|
||||
|
||||
### Network Dependency Issues
|
||||
|
||||
| Issue | Impact | Affected Operations |
|
||||
|-------|--------|-------------------|
|
||||
| **Same Network Requirement** | High | Device discovery, Initial setup |
|
||||
| **Direct Connectivity Need** | High | Device enrichment, Migration |
|
||||
| **Firewall/NAT Restrictions** | Medium | Corporate networks, Complex setups |
|
||||
| **Multi-VLAN Environments** | High | Enterprise deployments |
|
||||
| **Remote Management** | Critical | Off-site device support |
|
||||
|
||||
### Service Architecture Limitations
|
||||
|
||||
1. **Geographic Constraints**: Service must be deployed on same network as speakers
|
||||
2. **Scalability Issues**: Cannot centralize device management across multiple locations
|
||||
3. **Discovery Reliability**: Multicast protocols can be unreliable in complex networks
|
||||
4. **Real-time Updates**: No device-initiated communication for state changes
|
||||
|
||||
## /power_on Enhancement Proposal
|
||||
|
||||
### Current /power_on Request Analysis
|
||||
|
||||
The `/power_on` endpoint receives comprehensive device data that could replace many network-dependent operations:
|
||||
|
||||
```xml
|
||||
<device-data>
|
||||
<device id="A81B6A536A98">
|
||||
<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">
|
||||
<serialnumber>069231P63364828AE</serialnumber>
|
||||
</product>
|
||||
</device>
|
||||
<diagnostic-data>
|
||||
<device-landscape>
|
||||
<rssi>Excellent</rssi>
|
||||
<gateway-ip-address>192.168.178.1</gateway-ip-address>
|
||||
<macaddresses>
|
||||
<macaddress>A81B6A536A98</macaddress>
|
||||
<macaddress>A81B6A849D99</macaddress>
|
||||
</macaddresses>
|
||||
<ip-address>192.168.178.35</ip-address>
|
||||
<network-connection-type>Wireless</network-connection-type>
|
||||
</device-landscape>
|
||||
<network-landscape>
|
||||
<network-data xmlns="http://www.Bose.com/Schemas/2012-12/NetworkMonitor/"/>
|
||||
</network-landscape>
|
||||
</diagnostic-data>
|
||||
</device-data>
|
||||
```
|
||||
|
||||
### Data Completeness Comparison
|
||||
|
||||
| Data Field | Current `/info` | `/power_on` | Gap Assessment |
|
||||
|------------|----------------|-------------|----------------|
|
||||
| **Device ID** | ✅ UUID format | ✅ MAC format | Different format |
|
||||
| **Device Name** | ✅ Internal name | ❌ Missing | **Critical Gap** |
|
||||
| **Device Type** | ✅ Model string | ✅ Product code | ✅ Available |
|
||||
| **Account ID** | ✅ marge UUID | ❌ Missing | **Critical Gap** |
|
||||
| **Service URL** | ✅ marge URL | ❌ Missing | **Important Gap** |
|
||||
| **Firmware Version** | ✅ Full version | ✅ Full version | ✅ Available |
|
||||
| **Serial Numbers** | ✅ Component serials | ✅ Device + Product | ✅ Available |
|
||||
| **MAC Addresses** | ✅ Interface-specific | ✅ Multiple MACs | ✅ Enhanced |
|
||||
| **IP Address** | ✅ Interface IPs | ✅ Current IP | ✅ Available |
|
||||
| **Network Status** | ❌ Basic | ✅ Rich diagnostics | ✅ **Enhanced** |
|
||||
| **Regional Settings** | ✅ Country/Region | ❌ Missing | **Important Gap** |
|
||||
|
||||
### Enhancement Benefits
|
||||
|
||||
#### 1. Network Independence
|
||||
- ✅ Works across internet/WAN connections
|
||||
- ✅ No multicast/broadcast requirements
|
||||
- ✅ Firewall/NAT friendly
|
||||
- ✅ Supports remote device management
|
||||
|
||||
#### 2. Real-time Device State
|
||||
- ✅ Device-initiated communication
|
||||
- ✅ Power-on event notifications
|
||||
- ✅ Network status updates
|
||||
- ✅ Firmware change detection
|
||||
|
||||
#### 3. Enhanced Diagnostics
|
||||
- ✅ Signal strength (RSSI)
|
||||
- ✅ Gateway information
|
||||
- ✅ Connection type details
|
||||
- ✅ Real-time network status
|
||||
|
||||
### Implementation Strategy
|
||||
|
||||
#### Phase 1: Hybrid Approach
|
||||
Implement `/power_on` processing while maintaining existing discovery methods:
|
||||
|
||||
```go
|
||||
func (s *Server) HandleMargePowerOn(w http.ResponseWriter, r *http.Request) {
|
||||
// Parse power_on request
|
||||
var powerOnData models.CustomerSupportRequest
|
||||
if err := xml.Unmarshal(body, &powerOnData); err != nil {
|
||||
// Fallback to existing discovery
|
||||
return s.fallbackToDiscovery(r.RemoteAddr)
|
||||
}
|
||||
|
||||
// Extract device information
|
||||
deviceMAC := powerOnData.Device.ID
|
||||
deviceIP := powerOnData.DiagnosticData.DeviceLandscape.IPAddress
|
||||
|
||||
// Lookup existing device data
|
||||
deviceInfo := s.lookupDeviceByMAC(deviceMAC)
|
||||
if deviceInfo == nil {
|
||||
// New device - trigger registration flow
|
||||
deviceInfo = s.createDeviceFromPowerOn(powerOnData)
|
||||
}
|
||||
|
||||
// Update with power_on data
|
||||
s.updateDeviceFromPowerOn(deviceInfo, powerOnData)
|
||||
|
||||
// Determine response actions
|
||||
response := s.buildPowerOnResponse(deviceInfo)
|
||||
s.sendResponse(w, response)
|
||||
}
|
||||
```
|
||||
|
||||
#### Phase 2: Gap Resolution
|
||||
Address missing data through complementary mechanisms:
|
||||
|
||||
1. **User-Friendly Names**: Maintain registration process for name assignment
|
||||
2. **Account Association**: Enhance registration to link MAC addresses to accounts
|
||||
3. **Service URLs**: Implement account-based service URL resolution
|
||||
4. **Regional Settings**: Use IP geolocation or account preferences
|
||||
|
||||
#### Phase 3: Enhanced Device Lifecycle
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Device as SoundTouch Device
|
||||
participant Service as SoundTouch Service
|
||||
participant DataStore as Data Store
|
||||
participant User as User/App
|
||||
|
||||
Note over Device,User: Enhanced Device Lifecycle
|
||||
|
||||
rect rgb(248, 255, 248)
|
||||
Note over Device,DataStore: 1. Power-On Registration
|
||||
Device->>Service: POST /power_on (rich device data)
|
||||
Service->>DataStore: Lookup device by MAC
|
||||
alt Device Unknown
|
||||
Service->>DataStore: Create device record
|
||||
Service->>User: Notify new device found
|
||||
else Device Known
|
||||
Service->>DataStore: Update device status
|
||||
end
|
||||
Service->>Device: Configuration response
|
||||
end
|
||||
|
||||
rect rgb(255, 248, 240)
|
||||
Note over User,DataStore: 2. User Registration (Optional)
|
||||
User->>Service: POST /setup/devices (name + preferences)
|
||||
Service->>DataStore: Add user metadata to device
|
||||
Service->>Device: Updated configuration (on next power_on)
|
||||
end
|
||||
|
||||
rect rgb(240, 248, 255)
|
||||
Note over Device,DataStore: 3. Ongoing Updates
|
||||
Device->>Service: POST /power_on (status changes)
|
||||
Service->>Service: Detect firmware/network changes
|
||||
Service->>DataStore: Update device record
|
||||
alt Migration Needed
|
||||
Service->>Device: Migration instructions
|
||||
Device->>Device: Apply configuration
|
||||
Device->>Service: POST /power_on (confirm changes)
|
||||
end
|
||||
end
|
||||
|
||||
rect rgb(255, 248, 255)
|
||||
Note over Service,User: 4. Remote Management
|
||||
User->>Service: Management request (any location)
|
||||
Service->>DataStore: Lookup device status
|
||||
Service->>User: Current device state
|
||||
Note over Service: No local network required
|
||||
end
|
||||
```
|
||||
|
||||
### Migration Strategy
|
||||
|
||||
#### Current Migration Flow Issues
|
||||
- Requires `/info` endpoint access for device identification
|
||||
- Must be on same network for configuration changes
|
||||
- Limited to devices discoverable via UPnP/mDNS
|
||||
|
||||
#### Enhanced Migration with /power_on
|
||||
|
||||
1. **Device Identification**: Use MAC address from `/power_on` instead of IP-based `/info`
|
||||
2. **Configuration Delivery**: Send migration instructions in `/power_on` response
|
||||
3. **Status Confirmation**: Device confirms changes via subsequent `/power_on` requests
|
||||
4. **Remote Capability**: Manage devices from any network location
|
||||
|
||||
```go
|
||||
type PowerOnResponse struct {
|
||||
ConfigurationUpdates []ConfigUpdate `json:"configuration_updates,omitempty"`
|
||||
MigrationInstructions *Migration `json:"migration,omitempty"`
|
||||
RegistrationRequired bool `json:"registration_required,omitempty"`
|
||||
}
|
||||
|
||||
type Migration struct {
|
||||
Method string `json:"method"` // xml, hosts, resolv_conf
|
||||
TargetURL string `json:"target_url"`
|
||||
ProxyURL string `json:"proxy_url,omitempty"`
|
||||
Options map[string]string `json:"options"`
|
||||
}
|
||||
```
|
||||
|
||||
## Recommendations
|
||||
|
||||
### Immediate Actions (Phase 1)
|
||||
1. **Enhance `/power_on` handler** to extract and store comprehensive device data
|
||||
2. **Implement device lookup by MAC address** as primary identification method
|
||||
3. **Create hybrid discovery system** using both `/power_on` and existing methods
|
||||
4. **Add network-independent device management** capabilities
|
||||
|
||||
### Medium-term Improvements (Phase 2)
|
||||
1. **Implement account-device MAC mapping** for automatic association
|
||||
2. **Add IP geolocation** for regional settings inference
|
||||
3. **Create device registration UI** optimized for `/power_on` discovered devices
|
||||
4. **Enhance migration system** to use `/power_on` response mechanism
|
||||
|
||||
### Long-term Enhancements (Phase 3)
|
||||
1. **Request firmware enhancement** to include missing data in `/power_on`
|
||||
2. **Implement real-time device monitoring** via `/power_on` events
|
||||
3. **Create centralized device management** independent of network topology
|
||||
4. **Add predictive migration** based on device status patterns
|
||||
|
||||
### Risk Mitigation
|
||||
- **Maintain backward compatibility** with existing discovery methods
|
||||
- **Implement graceful fallbacks** when `/power_on` data is incomplete
|
||||
- **Preserve existing user workflows** while adding enhanced capabilities
|
||||
- **Add comprehensive logging** for troubleshooting hybrid approach
|
||||
|
||||
## Conclusion
|
||||
|
||||
The `/power_on` endpoint provides a significant opportunity to reduce network dependencies while enhancing device management capabilities. By implementing a hybrid approach that leverages `/power_on` data for primary device identification and status updates while maintaining existing registration workflows for user-controlled metadata, the system can achieve:
|
||||
|
||||
- **Network independence** for core device management
|
||||
- **Enhanced real-time capabilities** through device-initiated communication
|
||||
- **Improved scalability** across diverse network topologies
|
||||
- **Better user experience** with automatic device discovery and status updates
|
||||
|
||||
The proposed implementation strategy provides a clear path to achieve these benefits while maintaining system reliability and user workflow compatibility.
|
||||
@@ -0,0 +1,150 @@
|
||||
# Device Lifecycle Analysis - Executive Summary
|
||||
|
||||
## Current State Assessment
|
||||
|
||||
The SoundTouch service currently relies heavily on local network connectivity for device discovery and management:
|
||||
|
||||
### ✅ Strengths
|
||||
- **Comprehensive device data** through `/info` endpoint
|
||||
- **Robust discovery** via UPnP/SSDP + mDNS
|
||||
- **User-controlled registration** with friendly names
|
||||
- **Complete device lifecycle management**
|
||||
|
||||
### ❌ Limitations
|
||||
- **Network dependency**: Requires same network segment for discovery
|
||||
- **Geographic constraints**: Service must be co-located with devices
|
||||
- **Firewall/NAT issues**: Multicast protocols unreliable in complex networks
|
||||
- **No remote management**: Cannot manage devices from external networks
|
||||
|
||||
## /power_on Enhancement Opportunity
|
||||
|
||||
The `/power_on` endpoint provides rich device data that could eliminate network dependencies:
|
||||
|
||||
### Current /power_on Data
|
||||
```xml
|
||||
<device-data>
|
||||
<device id="A81B6A536A98"> <!-- ✅ 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 -->
|
||||
<serialnumber>069231P63364828AE</serialnumber> <!-- ✅ Product Serial -->
|
||||
</product>
|
||||
</device>
|
||||
<diagnostic-data>
|
||||
<device-landscape>
|
||||
<rssi>Excellent</rssi> <!-- ✅ Signal -->
|
||||
<gateway-ip-address>192.168.178.1</gateway-ip-address> <!-- ✅ Network -->
|
||||
<macaddresses> <!-- ✅ All MACs -->
|
||||
<macaddress>A81B6A536A98</macaddress>
|
||||
<macaddress>A81B6A849D99</macaddress>
|
||||
</macaddresses>
|
||||
<ip-address>192.168.178.35</ip-address> <!-- ✅ Current IP -->
|
||||
<network-connection-type>Wireless</network-connection-type> <!-- ✅ Connection -->
|
||||
</device-landscape>
|
||||
</diagnostic-data>
|
||||
</device-data>
|
||||
```
|
||||
|
||||
### Missing Data Gaps
|
||||
| Data | Current Source | Available in /power_on | Impact |
|
||||
|------|----------------|----------------------|---------|
|
||||
| **User-friendly name** | Registration | ❌ Missing | **High** - UI/UX |
|
||||
| **Account association** | Registration | ❌ Missing | **Critical** - Authorization |
|
||||
| **Service URLs** | `/info` | ❌ Missing | **High** - Migration |
|
||||
| **Regional settings** | `/info` | ❌ Missing | **Medium** - Localization |
|
||||
|
||||
## Recommended Implementation Strategy
|
||||
|
||||
### Phase 1: Hybrid Enhancement (Immediate)
|
||||
- **Enhance `/power_on` handler** to process full device data
|
||||
- **Implement MAC-based device lookup** for identification
|
||||
- **Maintain existing registration flow** for user metadata
|
||||
- **Add network-independent capabilities** as primary features
|
||||
|
||||
```go
|
||||
// Enhanced flow
|
||||
Device -> POST /power_on -> Service identifies by MAC -> Update/Create device record
|
||||
```
|
||||
|
||||
### Phase 2: Gap Resolution (Short-term)
|
||||
- **Account-device MAC mapping** for automatic association
|
||||
- **IP geolocation** for regional settings inference
|
||||
- **Registration UI optimization** for /power_on discovered devices
|
||||
- **Migration via response payload** instead of direct device access
|
||||
|
||||
### Phase 3: Full Network Independence (Medium-term)
|
||||
- **Centralized device management** across multiple networks
|
||||
- **Real-time device monitoring** via /power_on events
|
||||
- **Predictive migration** based on device status patterns
|
||||
- **Enhanced firmware integration** with additional /power_on data
|
||||
|
||||
## Key Benefits
|
||||
|
||||
### ✅ Immediate Gains
|
||||
- **Network independence**: Manage devices from any location
|
||||
- **Real-time updates**: Device-initiated status reporting
|
||||
- **Enhanced diagnostics**: Signal strength, connection type, network status
|
||||
- **Simplified deployment**: No multicast/broadcast requirements
|
||||
|
||||
### ✅ Long-term Advantages
|
||||
- **Scalable architecture**: Centralized management across sites
|
||||
- **Improved reliability**: Eliminates discovery protocol dependencies
|
||||
- **Better user experience**: Automatic device detection and status
|
||||
- **Future-proof design**: Device-driven communication model
|
||||
|
||||
## Implementation Approach
|
||||
|
||||
### Hybrid Strategy
|
||||
```mermaid
|
||||
graph TD
|
||||
PowerOn[Device /power_on] --> Identify[MAC-based Identification]
|
||||
Identify --> New{New Device?}
|
||||
|
||||
New -->|Yes| Create[Create Device Record]
|
||||
New -->|No| Update[Update Existing Record]
|
||||
|
||||
Create --> CheckAccount{Account Known?}
|
||||
CheckAccount -->|No| RegisterFlow[Trigger Registration]
|
||||
CheckAccount -->|Yes| LinkAccount[Link to Account]
|
||||
|
||||
Update --> DetectChanges[Detect Changes]
|
||||
DetectChanges --> Migration{Migration Needed?}
|
||||
Migration -->|Yes| SendInstructions[Send Migration Instructions]
|
||||
|
||||
LinkAccount --> Response[Send Configuration Response]
|
||||
SendInstructions --> Response
|
||||
RegisterFlow --> Response
|
||||
```
|
||||
|
||||
### Risk Mitigation
|
||||
- **Maintain backward compatibility** with existing discovery
|
||||
- **Graceful fallbacks** when /power_on data incomplete
|
||||
- **Preserve user workflows** while adding enhanced capabilities
|
||||
- **Comprehensive logging** for troubleshooting
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### Technical Metrics
|
||||
- **Network independence**: % of operations not requiring local network
|
||||
- **Real-time capability**: Power-on event processing latency < 2s
|
||||
- **Data completeness**: % of devices with full metadata via /power_on
|
||||
- **Migration success**: % of successful remote migrations
|
||||
|
||||
### User Experience Metrics
|
||||
- **Discovery reliability**: % of devices automatically detected
|
||||
- **Setup time**: Time from device power-on to full management
|
||||
- **Management accessibility**: % of operations available remotely
|
||||
- **Error reduction**: Decrease in network-related issues
|
||||
|
||||
## Conclusion
|
||||
|
||||
The `/power_on` enhancement represents a strategic opportunity to:
|
||||
|
||||
1. **Eliminate network dependencies** while maintaining full functionality
|
||||
2. **Enable remote device management** across diverse network topologies
|
||||
3. **Improve user experience** through automatic device detection
|
||||
4. **Future-proof the architecture** for scalable device management
|
||||
|
||||
**Recommendation**: Proceed with hybrid implementation approach, prioritizing network independence while preserving existing user workflows and system reliability.
|
||||
|
||||
**Timeline**: Phase 1 implementation feasible within 2-3 sprints, with Phases 2-3 extending capabilities based on user feedback and firmware enhancement opportunities.
|
||||
@@ -0,0 +1,222 @@
|
||||
# Migration Flow Diagrams
|
||||
|
||||
This document specifies the diagrams needed for the migration guide, with descriptions that can be used to create actual visual diagrams.
|
||||
|
||||
## 1. Overall Migration Process Flow
|
||||
|
||||
### Description
|
||||
A flowchart showing the complete migration journey from start to finish.
|
||||
|
||||
### Elements
|
||||
```
|
||||
[Start] → [Install SoundTouch Service] → [Create Account] → [Prepare Devices]
|
||||
↓
|
||||
[Enable Remote Services] → [Discover Devices] → [Register Devices]
|
||||
↓
|
||||
[Start Migration] → [Data Collection Phase] → [Testing Phase] → [Full Local Phase]
|
||||
↓
|
||||
[Verify Migration] → [Complete] → [Post-Migration Setup]
|
||||
```
|
||||
|
||||
### Decision Points
|
||||
- Multiple devices? → Repeat device steps
|
||||
- Migration issues? → Rollback option
|
||||
- All devices complete? → Account fully migrated
|
||||
|
||||
### Color Coding
|
||||
- **Blue**: Service setup steps
|
||||
- **Green**: Successful completion states
|
||||
- **Orange**: In-progress/testing states
|
||||
- **Red**: Error handling/rollback paths
|
||||
- **Gray**: Optional steps
|
||||
|
||||
## 2. Network Topology Diagram
|
||||
|
||||
### Description
|
||||
Shows the network layout with Raspberry Pi, router, and SoundTouch devices.
|
||||
|
||||
### Components
|
||||
```
|
||||
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)
|
||||
```
|
||||
|
||||
### Connections
|
||||
- **Solid lines**: Active connections
|
||||
- **Dashed lines**: Migration-phase connections to Bose cloud
|
||||
- **Thick lines**: Primary data flow to local service
|
||||
|
||||
## 3. Device State Lifecycle
|
||||
|
||||
### Description
|
||||
State machine showing device progression through migration phases.
|
||||
|
||||
### States and Transitions
|
||||
```
|
||||
[Unregistered] → [Discovered] → [Registered] → [Migrating]
|
||||
↓
|
||||
[Active - Local Only] ← [Active - Testing] ← [Active - Data Collection]
|
||||
↑ ↓
|
||||
[Error/Rollback] ← ← ← ← ← ← ← ← ← ← ← ← ← ← ← ← ← [Migration Failed]
|
||||
```
|
||||
|
||||
### State Descriptions
|
||||
- **Unregistered**: Device not known to service
|
||||
- **Discovered**: Found on network, remote services enabled
|
||||
- **Registered**: Added to account, ready for migration
|
||||
- **Migrating - Data Collection**: Building local database
|
||||
- **Migrating - Testing**: Using local service with fallback
|
||||
- **Active - Local Only**: Full independence achieved
|
||||
- **Error/Rollback**: Issues detected, can revert to Bose
|
||||
|
||||
## 4. Data Flow During Migration
|
||||
|
||||
### Description
|
||||
Shows how data flows between components during different migration phases.
|
||||
|
||||
### Phase 1 - Data Collection
|
||||
```
|
||||
SoundTouch Device → Bose Cloud Services
|
||||
↓ (mirror)
|
||||
Local Service (collecting data)
|
||||
```
|
||||
|
||||
### Phase 2 - Testing
|
||||
```
|
||||
SoundTouch Device ↔ Local Service (primary)
|
||||
↕ (fallback when needed)
|
||||
Bose Cloud Services
|
||||
```
|
||||
|
||||
### Phase 3 - Full Local
|
||||
```
|
||||
SoundTouch Device ↔ Local Service (only)
|
||||
|
||||
Bose Cloud Services (disconnected)
|
||||
```
|
||||
|
||||
### Data Types
|
||||
- **Presets**: Station favorites and custom sources
|
||||
- **Recents**: Play history and recently accessed content
|
||||
- **Sources**: Configured music services (Spotify, etc.)
|
||||
- **Device Config**: Network settings, capabilities, metadata
|
||||
|
||||
## 5. Migration Timeline Visualization
|
||||
|
||||
### Description
|
||||
Gantt-chart style timeline showing typical migration schedule.
|
||||
|
||||
### Timeline (7-day example)
|
||||
```
|
||||
Day 1-2: Data Collection Phase
|
||||
████████████████████████████████████████
|
||||
|
||||
Day 3-4: Data Validation
|
||||
████████████████████████████
|
||||
|
||||
Day 5-6: Testing Phase
|
||||
████████████████████████
|
||||
|
||||
Day 7+: Full Local Operation
|
||||
████████████████████████→
|
||||
```
|
||||
|
||||
### Parallel Activities
|
||||
- Multiple devices can be in different phases
|
||||
- Service continues operating throughout
|
||||
- User can interact normally during process
|
||||
|
||||
## 6. Service Architecture Overview
|
||||
|
||||
### Description
|
||||
High-level architecture showing enhanced SoundTouch service components.
|
||||
|
||||
### Components
|
||||
```
|
||||
Web Dashboard ← → HTTP API ← → REST Endpoints
|
||||
↑ ↑ ↑
|
||||
└─── User ──────┼──── Devices ─┘
|
||||
↓
|
||||
Service Core
|
||||
├── Account Manager
|
||||
├── Device Lifecycle
|
||||
├── Event Processor
|
||||
├── Migration Controller
|
||||
└── Data Store
|
||||
↓
|
||||
File System Storage
|
||||
├── accounts/
|
||||
├── devices/
|
||||
├── sessions/ (existing)
|
||||
└── system/
|
||||
```
|
||||
|
||||
### External Integrations
|
||||
- **Bose Cloud** (during migration)
|
||||
- **Music Services** (Spotify, TuneIn, etc.)
|
||||
- **Discovery Services** (mDNS, UPnP)
|
||||
|
||||
## 7. Error Handling and Rollback Flow
|
||||
|
||||
### Description
|
||||
Decision tree for handling migration issues and rollback scenarios.
|
||||
|
||||
### Error Detection
|
||||
```
|
||||
Migration Issue Detected
|
||||
├── Device Unresponsive → Retry → Success/Rollback
|
||||
├── Data Corruption → Restore from Backup → Continue/Rollback
|
||||
├── Service Unavailable → Wait/Restart → Continue/Rollback
|
||||
└── User Dissatisfaction → Manual Rollback → Restore Bose Config
|
||||
```
|
||||
|
||||
### Rollback Process
|
||||
```
|
||||
[Rollback Initiated]
|
||||
↓
|
||||
[Disable Local Services]
|
||||
↓
|
||||
[Restore Original Device Config]
|
||||
↓
|
||||
[Re-enable Bose Services]
|
||||
↓
|
||||
[Verify Functionality]
|
||||
↓
|
||||
[Rollback Complete]
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### For Diagram Creation
|
||||
1. Use consistent colors as specified in main color scheme
|
||||
2. Include clear labels for all components
|
||||
3. Show directional flow with appropriate arrows
|
||||
4. Use standard flowchart symbols where applicable
|
||||
5. Ensure text is readable at various sizes
|
||||
|
||||
### Tools Recommended
|
||||
- **Lucidchart**: Professional flowcharts and network diagrams
|
||||
- **Draw.io**: Free online diagram tool
|
||||
- **Miro**: Collaborative whiteboarding
|
||||
- **PlantUML**: Code-based diagram generation
|
||||
|
||||
### File Naming Convention
|
||||
- `migration-flow-overview.svg` - Overall process flow
|
||||
- `network-topology.svg` - Network layout
|
||||
- `device-lifecycle.svg` - State machine
|
||||
- `data-flow-phases.svg` - Data flow during migration
|
||||
- `migration-timeline.svg` - Timeline visualization
|
||||
- `service-architecture.svg` - System architecture
|
||||
- `error-rollback-flow.svg` - Error handling
|
||||
|
||||
### Accessibility
|
||||
- Include alt-text descriptions
|
||||
- Use patterns/textures in addition to colors
|
||||
- Ensure sufficient contrast
|
||||
- Provide text-based versions for screen readers
|
||||
@@ -0,0 +1,432 @@
|
||||
# Capture Device Pairing Traffic
|
||||
|
||||
Step-by-step runbook for factory-resetting a SoundTouch speaker, pairing it to a Bose cloud account, and capturing every cloud request via mitmproxy. Tested on Apple Silicon Mac.
|
||||
|
||||
**Goal:** obtain a full `.mitm` recording of the account-pairing flow (streaming.bose.com) triggered by the official Android app.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
```
|
||||
Phase 0 Pre-flight checks
|
||||
Phase 1 Factory reset speaker
|
||||
Phase 2 Provision speaker Wi-Fi (AP mode, console)
|
||||
Phase 3 Start mitmproxy + emulator + Frida
|
||||
Phase 4 Pair speaker via Bose app (adb-driven)
|
||||
Phase 5 Save & inspect recording
|
||||
```
|
||||
|
||||
The Android emulator does not have Bluetooth, so the standard BLE setup path is unavailable. Instead:
|
||||
|
||||
1. Provision Wi-Fi directly over the speaker's AP web server (Phase 2).
|
||||
2. Once the speaker is on the LAN, the Bose app discovers it via mDNS — no BLE needed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Pre-flight
|
||||
|
||||
The emulator setup is fully scripted. Run once per machine:
|
||||
|
||||
```bash
|
||||
# Place the Bose APK at scripts/android/bose.apk first (see BOSE-APP-ADB-Emulator.md § 1)
|
||||
# Run mitmweb once to generate the CA: mitmweb --listen-port 8080 (Ctrl-C after it starts)
|
||||
|
||||
scripts/android/setup-mitm-avd.sh
|
||||
```
|
||||
|
||||
This installs the system image, creates an AVD named `bose-mitm`, installs the
|
||||
mitmproxy cert and Bose APK, and saves an emulator snapshot `mitm-ready` — so
|
||||
subsequent sessions never repeat the cert/reboot cycle.
|
||||
|
||||
For subsequent sessions, Phase 3 below is replaced by a single command:
|
||||
|
||||
```bash
|
||||
scripts/android/start-mitm-session.sh
|
||||
```
|
||||
|
||||
Manual steps are only needed if you want to understand the internals; see
|
||||
[BOSE-APP-ADB-Emulator.md](../analysis/BOSE-APP-ADB-Emulator.md) for the
|
||||
full manual walkthrough.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Factory Reset Speaker
|
||||
|
||||
Perform the reset for your model (see
|
||||
[DEVICE-INITIAL-SETUP.md § 5](DEVICE-INITIAL-SETUP.md) for full table):
|
||||
|
||||
| Model | Sequence |
|
||||
|-----------------------------|-------------------------------------------------------------------------|
|
||||
| SoundTouch 10 | Power on; hold **Preset 1** + **Vol −** ~10 s → solid amber Wi-Fi LED |
|
||||
| SoundTouch 20 | Power on; hold **Preset 1** + **Vol −** ~10 s → lights blink L→R, amber |
|
||||
| SoundTouch 20/30 Series III | Hold **Preset 1** + **Preset 6** ~10 s |
|
||||
| SoundTouch 300 | Hold **Vol −** ~15 s until light bar blinks |
|
||||
|
||||
Wait until the white LED sweep / restart animation completes (~30 s). The speaker
|
||||
is now in setup mode and broadcasting its own Wi-Fi AP.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Provision Speaker Wi-Fi (AP Mode)
|
||||
|
||||
### 2.1 Connect Mac to Speaker AP
|
||||
|
||||
```bash
|
||||
# Find the SSID — use System Settings → Wi-Fi or:
|
||||
# sudo wdutil info (macOS Sequoia+, airport command removed)
|
||||
|
||||
# Connect (replace SSID with actual value)
|
||||
SPEAKER_SSID="Bose SoundTouch XXXX"
|
||||
networksetup -setairportnetwork en0 "$SPEAKER_SSID"
|
||||
|
||||
# Confirm: speaker web UI reachable at 192.0.2.1 (client gets 192.0.2.2)
|
||||
curl -s --connect-timeout 5 http://192.0.2.1/ | head -3
|
||||
```
|
||||
|
||||
### 2.2 Push Home Wi-Fi Credentials
|
||||
|
||||
The setup UI at `http://192.0.2.1/` uses the SoundTouch API on **port 8090**. Push credentials directly:
|
||||
|
||||
```bash
|
||||
HOME_SSID="MyHomeNetwork"
|
||||
HOME_PASS="MyPassword"
|
||||
|
||||
# Optional: trigger a site survey first so the speaker finds your SSID
|
||||
curl -s -X POST http://192.0.2.1:8090/performWirelessSiteSurvey \
|
||||
-H 'Content-Type: text/xml' \
|
||||
--data-raw '<PerformWirelessSiteSurvey timeout="5"/>'
|
||||
|
||||
curl -s -X POST http://192.0.2.1:8090/addWirelessProfile \
|
||||
-H 'Content-Type: text/xml' \
|
||||
--data-raw "<AddWirelessProfile><profile ssid=\"${HOME_SSID}\" password=\"${HOME_PASS}\" securityType=\"wpa_or_wpa2\" /></AddWirelessProfile>"
|
||||
```
|
||||
|
||||
Expected response: `<AddWirelessProfileResponse />`
|
||||
|
||||
### 2.3 Reconnect Mac to Home Network
|
||||
|
||||
```bash
|
||||
HOME_SSID="MyHomeNetwork"
|
||||
HOME_PASS="MyPassword"
|
||||
|
||||
networksetup -setairportnetwork en0 "$HOME_SSID" "$HOME_PASS"
|
||||
```
|
||||
|
||||
### 2.4 Wait for Speaker to Join LAN
|
||||
|
||||
```bash
|
||||
# Poll mDNS until the speaker appears (~15-30 s)
|
||||
echo "Waiting for speaker on LAN..."
|
||||
until dns-sd -B _soundtouch._tcp local 2>&1 | grep -m1 "Add"; do sleep 2; done
|
||||
echo "Speaker is online"
|
||||
|
||||
# Resolve its IP
|
||||
dns-sd -L "$(dns-sd -B _soundtouch._tcp local 2>&1 | grep Add | awk '{print $7}')" \
|
||||
_soundtouch._tcp local 2>&1 | grep -o '[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}'
|
||||
```
|
||||
|
||||
Or just scan for open port 8090:
|
||||
|
||||
```bash
|
||||
# Quick scan of your /24 subnet for port 8090
|
||||
SUBNET="192.168.1" # adjust to your local subnet
|
||||
for i in $(seq 1 254); do
|
||||
(ping -c1 -W1 ${SUBNET}.$i &>/dev/null && \
|
||||
nc -z -w1 ${SUBNET}.$i 8090 2>/dev/null && \
|
||||
echo "${SUBNET}.$i") &
|
||||
done
|
||||
wait
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Start mitmproxy, Emulator, Frida
|
||||
|
||||
Run each block in a separate terminal tab.
|
||||
|
||||
### 3.1 Start mitmweb (native macOS app)
|
||||
|
||||
> **Note:** Docker mitmproxy does not work here — its NAT layer prevents the emulator from reaching it. Use the native macOS app instead (download: `https://downloads.mitmproxy.org/12.2.2/mitmproxy-12.2.2-macos-arm64.tar.gz`).
|
||||
|
||||
```bash
|
||||
CAPTURE="bose-pairing-$(date +%Y%m%d-%H%M%S).mitm"
|
||||
/Applications/mitmproxy.app/Contents/MacOS/mitmweb \
|
||||
--web-host 0.0.0.0 --listen-port 8080 --mode regular \
|
||||
--set web_password=bose \
|
||||
-w "scripts/android/captures/${CAPTURE}"
|
||||
# Captures → scripts/android/captures/
|
||||
# Web UI → http://127.0.0.1:8081/?token=bose
|
||||
```
|
||||
|
||||
### 3.2 Start Emulator
|
||||
|
||||
```bash
|
||||
~/Library/Android/sdk/emulator/emulator -avd Pixel_6_API33 -writable-system &
|
||||
echo "Waiting for emulator boot..."
|
||||
adb wait-for-device
|
||||
adb -s emulator-5554 wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done'
|
||||
echo "Emulator ready"
|
||||
```
|
||||
|
||||
Enable root and install certificate (only needed once per emulator session):
|
||||
|
||||
```bash
|
||||
adb -s emulator-5554 root
|
||||
adb -s emulator-5554 shell avbctl disable-verification
|
||||
adb -s emulator-5554 reboot
|
||||
adb -s emulator-5554 wait-for-device
|
||||
adb -s emulator-5554 root
|
||||
|
||||
HASH=$(openssl x509 -inform PEM -subject_hash_old \
|
||||
-in ~/.mitmproxy/mitmproxy-ca-cert.pem | head -1)
|
||||
|
||||
adb -s emulator-5554 push ~/.mitmproxy/mitmproxy-ca-cert.pem /data/local/tmp/mitmproxy.pem
|
||||
adb -s emulator-5554 shell su 0 mkdir -p /data/misc/user/0/cacerts-added
|
||||
adb -s emulator-5554 shell su 0 \
|
||||
cp /data/local/tmp/mitmproxy.pem /data/misc/user/0/cacerts-added/${HASH}.0
|
||||
adb -s emulator-5554 shell su 0 \
|
||||
chmod 644 /data/misc/user/0/cacerts-added/${HASH}.0
|
||||
```
|
||||
|
||||
Set proxy to Mac IP:
|
||||
|
||||
```bash
|
||||
MAC_IP=$(ipconfig getifaddr en0)
|
||||
adb -s emulator-5554 shell settings put global http_proxy "${MAC_IP}:8080"
|
||||
echo "Proxy set to ${MAC_IP}:8080"
|
||||
```
|
||||
|
||||
Confirm emulator can reach the speaker:
|
||||
|
||||
```bash
|
||||
SPEAKER_IP=192.168.1.50 # adjust to your speaker's LAN IP
|
||||
adb -s emulator-5554 shell ping -c 3 "$SPEAKER_IP"
|
||||
```
|
||||
|
||||
### 3.3 Start frida-server
|
||||
|
||||
```bash
|
||||
adb -s emulator-5554 push scripts/android/frida-server /data/local/tmp/frida-server
|
||||
adb -s emulator-5554 shell su 0 chmod 755 /data/local/tmp/frida-server
|
||||
adb -s emulator-5554 shell su 0 "nohup /data/local/tmp/frida-server > /dev/null 2>&1 &"
|
||||
sleep 2
|
||||
echo "frida-server running"
|
||||
```
|
||||
|
||||
### 3.4 Configure config.js
|
||||
|
||||
`start-mitm-session.sh` patches `scripts/android/frida/config.js` automatically with the current Mac IP and mitmproxy cert. No manual step needed.
|
||||
|
||||
### 3.5 Launch App with SSL Unpinning
|
||||
|
||||
```bash
|
||||
scripts/android/frida-venv/bin/frida \
|
||||
-U \
|
||||
-f com.bose.soundtouch \
|
||||
-l scripts/android/frida/config.js \
|
||||
-l scripts/android/frida/native-connect-hook.js \
|
||||
-l scripts/android/frida/android/android-system-certificate-injection.js \
|
||||
-l scripts/android/frida/android/android-proxy-override.js \
|
||||
-l scripts/android/frida/android/android-certificate-unpinning.js \
|
||||
-l scripts/android/frida/android/android-certificate-unpinning-fallback.js
|
||||
```
|
||||
|
||||
> `native-connect-hook.js` is required — the Bose app uses native networking that bypasses Java proxy settings.
|
||||
|
||||
Expected Frida output:
|
||||
|
||||
```
|
||||
== System certificate trust injected ==
|
||||
== Proxy system configuration overridden to <IP>:8080 ==
|
||||
== Proxy configuration overridden to <IP>:8080 ==
|
||||
== Certificate unpinning completed ==
|
||||
== Unpinning fallback auto-patcher installed ==
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Pair Speaker via App
|
||||
|
||||
The Bose app should now be running in the emulator with all traffic going through mitmproxy.
|
||||
|
||||
### 4.1 Inspect UI to Find Interactive Elements
|
||||
|
||||
```bash
|
||||
# Dump current screen
|
||||
adb -s emulator-5554 shell uiautomator dump /sdcard/ui.xml
|
||||
adb -s emulator-5554 pull /sdcard/ui.xml /tmp/ui.xml
|
||||
|
||||
# Helper: list all clickable elements with their text + bounds
|
||||
grep -o 'text="[^"]*" resource-id="[^"]*" \.\.\. clickable="true"[^/]*' /tmp/ui.xml \
|
||||
|| python3 -c "
|
||||
import xml.etree.ElementTree as ET
|
||||
tree = ET.parse('/tmp/ui.xml')
|
||||
for n in tree.iter('node'):
|
||||
if n.get('clickable') == 'true' and n.get('text'):
|
||||
print(n.get('bounds'), n.get('resource-id'), repr(n.get('text')))
|
||||
"
|
||||
```
|
||||
|
||||
### 4.2 Navigate Setup Flow (adb)
|
||||
|
||||
```bash
|
||||
# Take a screenshot at any point to see current state
|
||||
adb -s emulator-5554 shell screencap /sdcard/screen.png
|
||||
adb -s emulator-5554 pull /sdcard/screen.png /tmp/screen.png
|
||||
open /tmp/screen.png
|
||||
|
||||
# Tap by resource-id (find IDs from ui.xml dump)
|
||||
adb -s emulator-5554 shell uiautomator runtest ... # or input tap
|
||||
|
||||
# Tap by screen coordinates
|
||||
adb -s emulator-5554 shell input tap X Y
|
||||
|
||||
# Type into the focused field
|
||||
adb -s emulator-5554 shell input text "your@email.com"
|
||||
|
||||
# Press Enter / Next
|
||||
adb -s emulator-5554 shell input keyevent 66
|
||||
```
|
||||
|
||||
### 4.3 Expected Setup Steps in the App
|
||||
|
||||
Follow the on-screen flow; mitmproxy captures everything automatically.
|
||||
|
||||
1. **Sign in** — enter email + password → triggers `POST /streaming/account/login`
|
||||
2. **Add speaker** — tap "Set Up a New Speaker" or equivalent
|
||||
3. **App discovers speaker** via mDNS on LAN (no BLE required)
|
||||
4. **Wi-Fi already configured** — app skips the Wi-Fi step since speaker is online
|
||||
5. **Name speaker** — type a name → WebSocket `name` message to speaker port 8080
|
||||
6. **Pairing** — app sends `setMargeAccount` WebSocket to speaker → speaker POSTs to `streaming.bose.com/{accountId}/devices`
|
||||
|
||||
All cloud requests (steps 1, 6) will appear in mitmweb at `http://127.0.0.1:8081`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Save & Inspect Recording
|
||||
|
||||
```bash
|
||||
# Stop mitmweb (Ctrl-C in its terminal) — file is already written continuously
|
||||
|
||||
# Inspect offline in the web UI
|
||||
mitmweb -r "$CAPTURE"
|
||||
|
||||
# Filter to streaming.bose.com only
|
||||
mitmdump -r "$CAPTURE" --flow-filter '~u streaming.bose.com' -w bose-cloud-only.mitm
|
||||
|
||||
# Quick text summary
|
||||
mitmdump -r "$CAPTURE" --flow-filter '~u streaming.bose.com' 2>/dev/null \
|
||||
| grep -E "POST|GET" | head -30
|
||||
```
|
||||
|
||||
### Convert to .http files (IntelliJ-compatible)
|
||||
|
||||
Use `scripts/convert_mitm_script.py` to extract each flow as a `.http` file, organized by path:
|
||||
|
||||
```bash
|
||||
NAME=$(basename "$CAPTURE" .mitm)
|
||||
OUT="scripts/android/mitm/${NAME}"
|
||||
|
||||
/Applications/mitmproxy.app/Contents/MacOS/mitmdump \
|
||||
-n -r "$CAPTURE" \
|
||||
-s scripts/convert_mitm_script.py \
|
||||
--set out_dir="${OUT}"
|
||||
```
|
||||
|
||||
Output lands in `scripts/android/mitm/<name>/mirror/` as numbered `.http` files
|
||||
plus `*-websocket/` subdirectories for WebSocket frames. The directory is gitignored.
|
||||
|
||||
---
|
||||
|
||||
## Cleanup
|
||||
|
||||
```bash
|
||||
# Remove proxy from emulator
|
||||
adb -s emulator-5554 shell settings delete global http_proxy
|
||||
|
||||
# Kill emulator
|
||||
adb -s emulator-5554 emu kill
|
||||
```
|
||||
|
||||
Frida artefacts live in `scripts/android/` (gitignored) and persist between sessions — no cleanup needed unless you want to force a fresh setup.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|-------------------------------------|--------------------------------------------------|-----------------------------------------------------------------------------------|
|
||||
| `curl http://192.0.2.1` times out | Mac not on speaker AP | Re-run `networksetup -setairportnetwork`; speaker AP gateway is `192.0.2.1` |
|
||||
| `addWirelessProfile` returns error | Speaker not in AP mode or wrong IP | Confirm speaker AP is active; use `http://192.0.2.1:8090/addWirelessProfile` |
|
||||
| Speaker not found via mDNS | Speaker still on AP (not home LAN yet) | Wait ~30 s, retry; check router DHCP leases |
|
||||
| Emulator can't ping speaker | Different subnet or emulator proxy misconfigured | `adb shell ping` the Mac IP first; check proxy setting |
|
||||
| App shows "No speakers found" | App not detecting mDNS | Ensure emulator is on same `/24` as speaker; disable emulator Wi-Fi and re-enable |
|
||||
| No traffic in mitmweb | Frida not running or cert mismatch | Check Frida output for `== Certificate unpinning ==`; verify issuer in config.js |
|
||||
|
||||
## See Also
|
||||
|
||||
- [BOSE-APP-ADB-Emulator.md](../analysis/BOSE-APP-ADB-Emulator.md) — full MITM + Frida setup
|
||||
- [DEVICE-INITIAL-SETUP.md](DEVICE-INITIAL-SETUP.md) — factory reset sequences + AP mode detail
|
||||
- [DEVICE-SETUP.md](../DEVICE-SETUP.md) — WebSocket and cloud pairing protocol reference
|
||||
|
||||
---
|
||||
|
||||
## Session Trace (2026-05-02, ST10)
|
||||
|
||||
Raw log of the first interactive run. To be cleaned up into the runbook above.
|
||||
|
||||
### Setup
|
||||
- Ran `scripts/android/setup-mitm-avd.sh` — all 9 steps completed:
|
||||
- Steps 1–5 were already done from a prior session (idempotent skips)
|
||||
- Step 6: Docker image built, `frida-server` extracted to `scripts/android/frida-server`
|
||||
- Steps 7–9: Emulator started fresh (`-no-snapshot-load`), AVB disabled, rebooted, cert + APK + frida-server installed, snapshot `mitm-ready` saved
|
||||
- Emulator running at `emulator-5554`
|
||||
|
||||
### Factory Reset (ST10)
|
||||
- Correct sequence confirmed from official Bose guide (`firmware/FirmwareUpdateGuide/`):
|
||||
**Power on → hold Preset 1 + Volume − for 10 s → Wi-Fi LED glows solid amber**
|
||||
- Note: original docs said "Vol− + Mute" — corrected in DEVICE-INITIAL-SETUP.md and this file
|
||||
|
||||
### Pre-reset note
|
||||
- User inserted USB device containing `remote_services` before rebooting — device needs to be on home Wi-Fi before the USB config takes effect
|
||||
|
||||
### Wi-Fi Provisioning (AP mode)
|
||||
- `airport` command not available on this macOS version (removed in recent releases)
|
||||
- Connect Mac to speaker AP via **System Settings → Wi-Fi** (SSID: "Bose SoundTouch XXXX")
|
||||
- Speaker AP gateway confirmed: `192.0.2.1` (client gets `192.0.2.2`), not `192.168.1.1` as previously assumed
|
||||
- `/gabbo_wifi` endpoint was hallucinated — actual endpoint verified from browser network capture (`_/device-reset/wifi-setup.txt`):
|
||||
- Site survey: `POST http://192.0.2.1:8090/performWirelessSiteSurvey` with `<PerformWirelessSiteSurvey timeout="5"/>`
|
||||
- Add profile: `POST http://192.0.2.1:8090/addWirelessProfile` with XML body, `securityType="wpa_or_wpa2"`
|
||||
- Both use the standard SoundTouch API port 8090, same as normal device operation
|
||||
- Response: `<AddWirelessProfileResponse />`
|
||||
- Reconnect Mac to home Wi-Fi; emulator stays running throughout (routes through Mac interface)
|
||||
|
||||
### Wi-Fi Provisioning Result
|
||||
- `POST http://192.0.2.1:8090/addWirelessProfile` succeeded → `<AddWirelessProfileResponse />`
|
||||
- Speaker joined home LAN at `192.168.x.y`
|
||||
- SSH access confirmed: `ssh -oHostKeyAlgorithms=ssh-rsa root@192.168.x.y`
|
||||
- Device name: `Bose SoundTouch XXXXXX`
|
||||
- Network interfaces on device:
|
||||
- `wlan0` → `192.168.x.y` (home LAN)
|
||||
- `wlan1` → `192.0.2.1` (AP mode interface, stays up after provisioning)
|
||||
- `usb0` → `203.0.113.1` (USB gadget — remote_services USB device inserted before reboot)
|
||||
|
||||
### MITM Session Start
|
||||
- `start-mitm-session.sh` run: snapshot restored, proxy set, frida-server started, config.js patched
|
||||
- Issues encountered and fixed:
|
||||
- frida-server start command hung (`adb shell su 0 ... &`) → fixed with `nohup ... > /dev/null 2>&1 &`
|
||||
- Frida SSL scripts download via `curl` from GitHub timed out → moved to Dockerfile (extracted alongside frida-server)
|
||||
- Python heredoc cert injection syntax error (multiline cert in string) → fixed using env vars + single-quoted `'PYEOF'` heredoc
|
||||
- `mitmweb --port` deprecated → `--listen-port`
|
||||
- frida script paths missing `android/` subdirectory → fixed in session script
|
||||
- Docker mitmproxy (`-p 8080:8080`) received no traffic from emulator — Docker NAT layer prevented the emulator reaching it
|
||||
- **Fix: use native macOS mitmproxy app** (`/Users/gesellix/Downloads/mitmproxy.app`) — binds directly to Mac's real network interfaces, traffic flows immediately
|
||||
- Android system traffic visible (connectivity checks to gstatic.com, www.google.com) — TLS failures for system processes expected since only Bose app has Frida cert injection
|
||||
|
||||
### Capture Working
|
||||
- Full flow confirmed working with:
|
||||
- Native mitmproxy app (`~/Downloads/mitmproxy.app`, v12.2.2)
|
||||
- `native-connect-hook.js` added to Frida launch (required for Bose app's native networking)
|
||||
- All 5 Frida scripts loaded: config.js, native-connect-hook.js, android-system-certificate-injection.js, android-proxy-override.js, android-certificate-unpinning.js, android-certificate-unpinning-fallback.js
|
||||
- Bose app traffic visible in mitmweb — pairing flow captured successfully
|
||||
@@ -0,0 +1,378 @@
|
||||
# Capture Speaker Migration Traffic
|
||||
|
||||
Runbook for migrating a SoundTouch speaker to `soundtouch-service` and capturing
|
||||
all traffic (App→Service and Speaker→Service) to identify unimplemented endpoints.
|
||||
|
||||
**Goal:** obtain a complete picture of every cloud request a speaker and the Bose
|
||||
app make after migration, so missing endpoint implementations can be tracked down.
|
||||
|
||||
**Pre-requisites:** the MITM pipeline is already set up and working. See
|
||||
[CAPTURE-DEVICE-PAIRING.md](CAPTURE-DEVICE-PAIRING.md) for the one-time AVD setup
|
||||
and the pairing capture runbook.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
```
|
||||
Step 1 Start soundtouch-service locally (with interaction recording)
|
||||
Step 2 Start a fresh mitmproxy + Frida session (captures App traffic)
|
||||
Step 3 Discover or register the speaker in the service UI
|
||||
Step 4 Migrate the speaker (modifies SoundTouchSdkPrivateCfg.xml via SSH)
|
||||
Step 5 Operate the Bose app — everything now flows through local service
|
||||
Step 6 Inspect captured interactions for unimplemented endpoints
|
||||
Step 7 Revert (optional) / clean up
|
||||
```
|
||||
|
||||
Traffic sources:
|
||||
- **App → Service** — captured by mitmproxy + Frida (same as pairing capture)
|
||||
- **Speaker → Service** — captured by the service's built-in interaction recorder
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Start soundtouch-service
|
||||
|
||||
Build and start the service. Recording is on by default; add `--server-url` so the
|
||||
service knows its own public address (the speaker needs it for redirections).
|
||||
|
||||
```bash
|
||||
# Determine Mac LAN IP first
|
||||
MAC_IP=$(ipconfig getifaddr en0)
|
||||
echo "Mac IP: ${MAC_IP}"
|
||||
|
||||
# Build + run with explicit server-url so the service embeds the correct address
|
||||
make build-service
|
||||
./build/soundtouch-service \
|
||||
--server-url "http://${MAC_IP}:8000" \
|
||||
--record-interactions \
|
||||
--log-bodies
|
||||
```
|
||||
|
||||
Service listens on `:8000` by default. Web UI: `http://localhost:8000`
|
||||
|
||||
> To also enable mirror mode (forward unhandled requests to official Bose servers
|
||||
> for comparison), add: `--mirror-enabled --mirror-endpoints /streaming/`
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Start mitmproxy + Frida (new capture)
|
||||
|
||||
In a separate terminal:
|
||||
|
||||
```bash
|
||||
scripts/android/start-mitm-session.sh
|
||||
```
|
||||
|
||||
The script prints ready-to-run commands for mitmweb and Frida. Run each in its own
|
||||
terminal tab as instructed.
|
||||
|
||||
New capture file lands in `scripts/android/captures/`.
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Discover the Speaker
|
||||
|
||||
Open the service web UI at `http://localhost:8000`.
|
||||
|
||||
The service discovers speakers via mDNS automatically on startup. If the speaker
|
||||
does not appear within ~30 s, add it manually:
|
||||
|
||||
```bash
|
||||
# Via API (replace IP with speaker's current LAN IP)
|
||||
curl -s -X POST http://localhost:8000/setup/devices \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"ip": "192.168.x.y"}'
|
||||
|
||||
# Confirm it's registered
|
||||
curl -s http://localhost:8000/setup/devices | python3 -m json.tool
|
||||
```
|
||||
|
||||
Note the `device_id` from the response — you need it for migration.
|
||||
|
||||
```bash
|
||||
# List all known devices and their IDs
|
||||
curl -s http://localhost:8000/setup/devices | python3 -m json.tool
|
||||
|
||||
# Extract device_id for the speaker by matching its IP
|
||||
DEVICE_ID=$(curl -s http://localhost:8000/setup/devices \
|
||||
| python3 -c "import sys,json; devs=json.load(sys.stdin); \
|
||||
[print(d['device_id']) for d in devs if '35' in d.get('ip_address','')]")
|
||||
echo "Device ID: ${DEVICE_ID}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Migrate the Speaker
|
||||
|
||||
The migration modifies `SoundTouchSdkPrivateCfg.xml` on the speaker via SSH,
|
||||
redirecting `margeServerUrl` (and optionally other service URLs) to the local
|
||||
service.
|
||||
|
||||
### 4.1 Review the Migration Plan
|
||||
|
||||
```bash
|
||||
# Dry-run: see what will be changed
|
||||
curl -s "http://localhost:8000/setup/summary/${DEVICE_ID}" | python3 -m json.tool
|
||||
```
|
||||
|
||||
Key fields to check:
|
||||
- `margeServerUrl` — should become `http://<MAC_IP>:8000/streaming`
|
||||
- `remoteServicesEnabled` — must be `true` for the speaker to make cloud calls
|
||||
- `is_migrated` — `false` before, `true` after
|
||||
|
||||
### 4.2 Run Migration
|
||||
|
||||
```bash
|
||||
MAC_IP=$(ipconfig getifaddr en0)
|
||||
TARGET_URL="http://${MAC_IP}:8000"
|
||||
|
||||
curl -s -X POST \
|
||||
"http://localhost:8000/setup/migrate/${DEVICE_ID}" \
|
||||
-G --data-urlencode "target_url=${TARGET_URL}" \
|
||||
| python3 -m json.tool
|
||||
```
|
||||
|
||||
Expected response: `{"ok": true, "message": "Migration started", "output": "..."}`.
|
||||
The output field contains the SSH transcript of the changes made.
|
||||
|
||||
### 4.3 Reboot the Speaker
|
||||
|
||||
A reboot applies the new config:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "http://localhost:8000/setup/reboot/${DEVICE_ID}"
|
||||
```
|
||||
|
||||
Wait ~30 s for the speaker to come back online. Verify it's back:
|
||||
|
||||
```bash
|
||||
dns-sd -B _soundtouch._tcp local 2>&1 | grep Add
|
||||
# or
|
||||
curl -s http://192.168.x.y:8090/info | head -5
|
||||
```
|
||||
|
||||
### 4.4 Verify Migration
|
||||
|
||||
```bash
|
||||
# Check migration summary again — is_migrated should now be true
|
||||
curl -s "http://localhost:8000/setup/summary/${DEVICE_ID}" \
|
||||
| python3 -c "import sys,json; s=json.load(sys.stdin); print('migrated:', s.get('is_migrated'))"
|
||||
```
|
||||
|
||||
You should also see incoming connections from the speaker in the service logs once
|
||||
it resumes normal operation.
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Operate the Bose App
|
||||
|
||||
With the speaker migrated and Frida running, every app action triggers traffic
|
||||
through the service:
|
||||
|
||||
1. **Sign in** — `POST /streaming/account/login`
|
||||
2. **Speaker shows as linked** — speaker has called the service to register/sync
|
||||
3. **Play music** — BMX registry lookup, playback control
|
||||
4. **Set presets** — `POST /streaming/account/{id}/device/{id}/presets/{n}`
|
||||
5. **Adjust volume, switch source** — direct speaker API (port 8090, not cloud)
|
||||
6. **Check "Now Playing"** — speaker WebSocket events + marge sync
|
||||
|
||||
For each action, both mitmweb and the service's recorder capture the request.
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Inspect Captured Interactions
|
||||
|
||||
### 6.1 Service Interaction Recorder
|
||||
|
||||
The service records all incoming requests to `data/interactions/` (configurable via
|
||||
`--data-dir`). Browse them via:
|
||||
|
||||
```bash
|
||||
# List recorded sessions
|
||||
curl -s http://localhost:8000/setup/interactions | python3 -m json.tool
|
||||
|
||||
# Download a session as HAR
|
||||
curl -s "http://localhost:8000/setup/interactions/sessions/<session>/download" \
|
||||
-o session.har
|
||||
|
||||
# Find 404/500 responses (unimplemented endpoints)
|
||||
curl -s "http://localhost:8000/setup/interaction-content" \
|
||||
| python3 -c "
|
||||
import sys, json
|
||||
for entry in json.load(sys.stdin).get('entries', []):
|
||||
status = entry.get('response', {}).get('status', 0)
|
||||
if status >= 400:
|
||||
print(status, entry.get('request', {}).get('method'), entry.get('request', {}).get('url'))
|
||||
"
|
||||
```
|
||||
|
||||
### 6.2 mitmproxy Recording
|
||||
|
||||
```bash
|
||||
# Inspect app→service traffic offline
|
||||
CAPTURE="scripts/android/captures/<filename>.mitm"
|
||||
mitmweb -r "${CAPTURE}"
|
||||
|
||||
# Filter to local service only
|
||||
mitmdump -r "${CAPTURE}" \
|
||||
--flow-filter "~u ${MAC_IP}:8000" \
|
||||
2>/dev/null | grep -E "POST|GET"
|
||||
|
||||
# Convert to .http files (IntelliJ-compatible, organized by path)
|
||||
NAME=$(basename "${CAPTURE}" .mitm)
|
||||
OUT="scripts/android/mitm/${NAME}"
|
||||
|
||||
/Applications/mitmproxy.app/Contents/MacOS/mitmdump \
|
||||
-n -r "${CAPTURE}" \
|
||||
-s scripts/convert_mitm_script.py \
|
||||
--set out_dir="${OUT}"
|
||||
# Output → scripts/android/mitm/<name>/mirror/
|
||||
```
|
||||
|
||||
### 6.3 Identify Unimplemented Endpoints
|
||||
|
||||
Endpoints the service doesn't handle return `404 Not Found`. Check:
|
||||
|
||||
```bash
|
||||
# From service stats
|
||||
curl -s http://localhost:8000/setup/interaction-stats | python3 -m json.tool
|
||||
|
||||
# List parity mismatches (local vs upstream divergence, if mirror enabled)
|
||||
curl -s http://localhost:8000/setup/parity-mismatches | python3 -m json.tool
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Revert Migration (Optional)
|
||||
|
||||
To restore the speaker to its original config (pointing back to Bose cloud):
|
||||
|
||||
```bash
|
||||
curl -s -X POST "http://localhost:8000/setup/revert/${DEVICE_ID}" | python3 -m json.tool
|
||||
```
|
||||
|
||||
Then reboot the speaker:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "http://localhost:8000/setup/reboot/${DEVICE_ID}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cleanup
|
||||
|
||||
```bash
|
||||
# Stop mitmweb (Ctrl-C in its terminal)
|
||||
# Stop Frida (Ctrl-C in its terminal)
|
||||
# Stop soundtouch-service (Ctrl-C in its terminal)
|
||||
|
||||
# Remove emulator proxy (if not running another session)
|
||||
adb -s emulator-5554 shell settings delete global http_proxy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|-------------------------------------------|--------------------------------------------|---------------------------------------------------------------------------|
|
||||
| Speaker not in service device list | mDNS discovery hasn't fired yet | Trigger manually: `POST /setup/discover` or add via `POST /setup/devices` |
|
||||
| Migration fails with SSH error | Speaker SSH key not trusted | Run `POST /setup/trust-ca/{deviceId}` first, or check SSH connectivity |
|
||||
| Speaker can't reach service after reboot | Firewall blocking port 8000 from LAN | Allow inbound TCP 8000 on Mac firewall |
|
||||
| `is_migrated: false` after migration | Wrong `target_url` or config not written | Check SSH output in migration response; re-run with `--method xml` |
|
||||
| Service logs show no speaker requests | `remote_services` not enabled on speaker | Run `POST /setup/ensure-remote-services/{deviceId}` and reboot |
|
||||
| App shows speaker offline after migration | Speaker config not pointing to correct URL | Check `margeServerUrl` via `GET /setup/summary/{deviceId}` |
|
||||
|
||||
---
|
||||
|
||||
## Session Trace (2026-05-02, ST10)
|
||||
|
||||
Raw log of the first interactive migration run.
|
||||
|
||||
### Service Configuration
|
||||
|
||||
Settings applied in the web UI before migration:
|
||||
|
||||
| Setting | Value |
|
||||
|---------------------|---------------------------------------------------------------------|
|
||||
| Target Domain | `soundtouch.local` (resolvable from speaker to `192.168.x.z`) |
|
||||
| DNS Discovery | enabled |
|
||||
| Upstream DNS | home Wi-Fi gateway |
|
||||
| Mirroring | enabled (for tracing while Bose cloud is still up) |
|
||||
| Mirrored endpoints | `/bmx/*`, `/streaming/*`, `/accounts/*`, `/v1/scmudc/*`, `/oauth/*` |
|
||||
| Proxy logging | enabled, including bodies |
|
||||
| Record interactions | enabled |
|
||||
| Skip recording | `/setup/*`, `/web/*` |
|
||||
|
||||
Settings saved and service restarted.
|
||||
|
||||
### Navigation Flow
|
||||
|
||||
1. **Tab 1 — Settings**: entered all settings above, clicked **Save Settings**, restarted service
|
||||
2. **Tab 2 — Devices**: speaker appeared via mDNS discovery; clicked **Sync Data**
|
||||
3. **Tab 3 — Data Sync**: clicked **Start Sync** to pull account/device data from Bose cloud
|
||||
4. **Tab 2 — Devices**: clicked **Migrate** on the speaker entry
|
||||
5. In the Migrate panel: selected **Migration Method → `/etc/resolv.conf`**
|
||||
6. Ran pre-migration checks (see below)
|
||||
7. Ran migration steps (see below)
|
||||
8. Rebooted speaker
|
||||
9. Paired and configured speaker via the Bose app
|
||||
|
||||
### Pre-Migration Checks
|
||||
|
||||
All tests run from the **Devices → Migrate** panel after selecting the speaker (`192.168.x.y`, SoundTouch 10):
|
||||
|
||||
- **HTTPS test (explicit CA.crt)**: ✅ passed (result not recorded in detail)
|
||||
- **HTTPS test (shared trust store)**: ✅ passed
|
||||
- Speaker connected to `soundtouch.local:443` → `192.168.x.z`
|
||||
- TLS: TLSv1.2 / ECDHE-RSA-AES128-GCM-SHA256, cert issued by `SoundTouch Local Root CA`
|
||||
- CA already in speaker's system trust store (`/etc/pki/tls/certs/ca-bundle.crt`)
|
||||
- **Preliminary DNS Test**: ✅ passed
|
||||
- Raw DNS query for `aftertouch.test` returned `192.168.x.z` via the service DNS at `192.168.x.z:53`
|
||||
- **Planned `/etc/resolv.conf`**:
|
||||
```
|
||||
# Created by Aftertouch/SoundTouch-Service
|
||||
# Priority nameserver for Bose service redirection
|
||||
nameserver 192.168.x.z
|
||||
```
|
||||
|
||||
### Migration Steps
|
||||
|
||||
1. **Enable Persistent Remote Services** → `Successfully ensured remote services for SoundTouch 10 (192.168.x.y)`
|
||||
- Note: `touch /etc/remote_services (with rw): sh: rw: command not found` — safe to ignore, `touch` succeeded
|
||||
2. Reloaded migration view by deselecting and reselecting the speaker in the dropdown
|
||||
3. **Backup Config Now** → `✅ Found .original config at /opt/Bose/etc/SoundTouchSdkPrivateCfg.xml.original`
|
||||
4. **Confirm Migration** → `Successfully started migration for SoundTouch 10 (192.168.x.y). Please reboot the device to activate the changes.`
|
||||
|
||||
Command output:
|
||||
- Off-device backup created ✅
|
||||
- Write access verified ✅
|
||||
- `soundtouch.local` resolved to `192.168.x.z` ✅
|
||||
- `/mnt/nv/soundtouch-service/aftertouch.resolv.conf` uploaded ✅
|
||||
- `rc.local` already contains Aftertouch hook logic ✅
|
||||
- `(rw || mount -o remount,rw /): sh: rw: command not found` — safe to ignore (same shell quirk as above)
|
||||
- `/etc/udhcpc.d/50default` patched and verified ✅
|
||||
- `/opt/Bose/udhcpc.script` patched and verified ✅
|
||||
- CA certificate already trusted, skipping injection ✅
|
||||
|
||||
5. **Reboot Speaker** → speaker came back online after ~30 s
|
||||
|
||||
### Post-Migration
|
||||
|
||||
- Paired speaker to Bose account via app — succeeded ✅
|
||||
- Set presets via app — worked ✅
|
||||
- Mirroring active and functional during session ✅
|
||||
- No visible errors in app behaviour; service logs and interaction recordings not yet reviewed in detail
|
||||
|
||||
### Known Shell Warning (safe to ignore)
|
||||
|
||||
Two commands produced `sh: rw: command not found`. This occurs because the service wraps commands with `(rw || ...)` as a fallback pattern, but the shell on the ST10 interprets `rw` as a bare command rather than a shell variable/flag. The primary command (`touch`, `mount`) still succeeds. This is a known cosmetic issue in the migration output.
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [CAPTURE-DEVICE-PAIRING.md](CAPTURE-DEVICE-PAIRING.md) — MITM setup and pairing capture
|
||||
- [MIGRATION-GUIDE.md](MIGRATION-GUIDE.md) — full migration reference
|
||||
- [SOUNDTOUCH-SERVICE.md](SOUNDTOUCH-SERVICE.md) — service architecture and configuration
|
||||
- [BOSE-APP-ADB-Emulator.md](../analysis/BOSE-APP-ADB-Emulator.md) — Frida + mitmproxy setup
|
||||
@@ -394,6 +394,9 @@ soundtouch-cli --host <device> source spotify
|
||||
soundtouch-cli --host <device> source bluetooth
|
||||
soundtouch-cli --host <device> source aux
|
||||
|
||||
# Custom radio selection (via soundtouch-service)
|
||||
soundtouch-cli --host <device> source custom-radio --url <STREAM_URL> [--name <NAME>] [--artwork <ARTWORK>] [--service-url <SERVICE_URL>]
|
||||
|
||||
# Advanced content selection
|
||||
soundtouch-cli --host <device> source internet-radio --location <URL> [--name <NAME>]
|
||||
soundtouch-cli --host <device> source local-music --location <LOCATION> --account <ACCOUNT>
|
||||
@@ -482,6 +485,7 @@ soundtouch-cli --host 192.168.1.10 source compare
|
||||
| Command | Description | Requirements |
|
||||
|---------|-------------|--------------|
|
||||
| `internet-radio` | Select internet radio stream (LOCAL_INTERNET_RADIO) | Stream URL |
|
||||
| `custom-radio` | Select custom radio stream via soundtouch-service | Stream URL and service URL |
|
||||
| `local-music` | Select local music content (LOCAL_MUSIC) | SoundTouch App Media Server |
|
||||
| `stored-music` | Select stored music content (STORED_MUSIC) | UPnP/DLNA media server |
|
||||
| `content` | Generic content selection (advanced) | Source and location |
|
||||
@@ -495,6 +499,12 @@ The `internet-radio` command supports the streamUrl proxy format from the [Sound
|
||||
soundtouch-cli --host 192.168.1.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 \
|
||||
--url "https://stream.antenne.de/chillout/stream/aacp" \
|
||||
--name "Antenne Chillout" \
|
||||
--service-url "http://localhost:8080"
|
||||
```
|
||||
|
||||
#### Service Introspection
|
||||
@@ -1044,7 +1054,7 @@ soundtouch-cli --host 192.168.1.10 speaker beep
|
||||
|
||||
**Supported Languages for TTS:**
|
||||
- `EN` - English (default)
|
||||
- `DE` - German
|
||||
- `DE` - German
|
||||
- `ES` - Spanish
|
||||
- `FR` - French
|
||||
- `IT` - Italian
|
||||
@@ -1085,7 +1095,7 @@ soundtouch-cli --host <device> events subscribe [flags]
|
||||
|
||||
**Event Types:**
|
||||
- `nowPlaying` - Track changes, playback status
|
||||
- `volume` - Volume and mute changes
|
||||
- `volume` - Volume and mute changes
|
||||
- `connection` - Network connectivity status
|
||||
- `preset` - Preset configuration changes
|
||||
- `zone` - Multiroom zone changes
|
||||
|
||||
@@ -25,13 +25,17 @@ Used by most modern SoundTouch devices (ST-10, ST-20/30 Series III, SoundTouch 3
|
||||
The classic "failover" or "alternate" setup method.
|
||||
|
||||
- **Mechanism**: The device creates its own Wi-Fi network (SSID: `Bose SoundTouch ...` or `Bose Home Speaker ...`).
|
||||
- **IP Address**: Typically `192.168.1.1` or `10.0.0.1` (device-side).
|
||||
- **IP Address**: Typically `192.0.2.1` (device-side, verified on ST10).
|
||||
- **Web Interface**: The device hosts a web server on port 80.
|
||||
- **Process**:
|
||||
1. Connect a PC/Phone to the device's Wi-Fi.
|
||||
2. Open a browser to `http://192.168.1.1`.
|
||||
3. The device serves `setup.html`, which redirects to a setup wizard (`setup/index.html`).
|
||||
4. Use the `gabbo_wifi` form to select a network and enter credentials.
|
||||
2. Open a browser to `http://192.0.2.1`.
|
||||
3. The device serves a Wi-Fi setup form — enter your home network SSID and password and click Submit.
|
||||
4. The device disconnects from AP mode and joins your home network within ~15–30 seconds.
|
||||
|
||||

|
||||
|
||||
For command-line provisioning (without a browser), see §6 below.
|
||||
|
||||
---
|
||||
|
||||
@@ -69,12 +73,96 @@ While the `soundtouch-service` focuses on migrating existing devices, a truly "c
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 5. Factory Reset Button Sequences
|
||||
|
||||
A factory reset wipes Wi-Fi credentials, account pairing, and all presets, returning the device to out-of-box state. The exact sequence varies by hardware generation.
|
||||
|
||||
> Sequences verified against official Bose reset guides in `firmware/FirmwareUpdateGuide/`. Confirm the reset succeeded by watching the status LEDs and by verifying the Wi-Fi indicator glows solid amber (setup mode).
|
||||
|
||||
| Model | Factory Restore Sequence | Confirm |
|
||||
|--------------------------|---------------------------------------------------------------------|------------------------------------|
|
||||
| SoundTouch 10 | Power on; hold **Preset 1** + **Volume −** for 10 s | Wi-Fi indicator glows solid amber |
|
||||
| SoundTouch 20 | Power on; hold **Preset 1** + **Volume −** for 10 s | Lights blink L→R, then solid amber |
|
||||
| SoundTouch 20 Series III | Hold **Preset 1** + **Preset 6** simultaneously for ~10 s | White LED sweep |
|
||||
| SoundTouch 30 Series III | Hold **Preset 1** + **Preset 6** simultaneously for ~10 s | White LED sweep |
|
||||
| SoundTouch 300 | Hold **Volume −** until light bar blinks rapidly (~15 s) | Rapid blink → off → on |
|
||||
| SoundTouch 10 (alt) | Press and hold the back recessed **Reset** pinhole for 10 s | Status LED restarts |
|
||||
| SoundTouch 20 (soft) | Hold **AUX** for 15 s until display goes blank (settings preserved) | Display blanks |
|
||||
|
||||
After factory restore the speaker enters setup mode automatically; no power-cycle is needed.
|
||||
|
||||
---
|
||||
|
||||
## 6. AP Mode Wi-Fi Provisioning via Console
|
||||
|
||||
When BLE is unavailable (e.g. when using an Android emulator), use AP mode to push Wi-Fi credentials from the Mac command line.
|
||||
|
||||
### 6.1 Connect Mac to Speaker AP
|
||||
|
||||
After factory reset the speaker broadcasts an SSID like `Bose SoundTouch XXXX`. Connect the Mac to it:
|
||||
|
||||
```bash
|
||||
# List nearby SSIDs — use System Settings → Wi-Fi (the airport command was removed in macOS Sequoia+)
|
||||
# Connect (replace with actual SSID)
|
||||
networksetup -setairportnetwork en0 "Bose SoundTouch XXXX"
|
||||
```
|
||||
|
||||
The speaker's web UI gateway is at `192.0.2.1` (verified: ST10 assigns `192.0.2.2` to the client via DHCP).
|
||||
|
||||
```bash
|
||||
# Confirm reachability
|
||||
curl -sv http://192.0.2.1/ 2>&1 | head -40
|
||||
```
|
||||
|
||||
### 6.2 Trigger Wi-Fi Site Survey (Optional)
|
||||
|
||||
The setup web UI at `http://192.0.2.1/` uses the SoundTouch API on **port 8090** — the same API as normal device operation. Trigger a network scan first so the speaker finds your SSID:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://192.0.2.1:8090/performWirelessSiteSurvey \
|
||||
-H 'Content-Type: text/xml' \
|
||||
--data-raw '<PerformWirelessSiteSurvey timeout="5"/>'
|
||||
```
|
||||
|
||||
### 6.3 Push Home Wi-Fi Credentials
|
||||
|
||||
```bash
|
||||
HOME_SSID="MyHomeNetwork"
|
||||
HOME_PASS="MyPassword"
|
||||
|
||||
curl -s -X POST http://192.0.2.1:8090/addWirelessProfile \
|
||||
-H 'Content-Type: text/xml' \
|
||||
--data-raw "<AddWirelessProfile><profile ssid=\"${HOME_SSID}\" password=\"${HOME_PASS}\" securityType=\"wpa_or_wpa2\" /></AddWirelessProfile>"
|
||||
```
|
||||
|
||||
Expected response: `<?xml version="1.0" encoding="UTF-8" ?><AddWirelessProfileResponse />`
|
||||
|
||||
The speaker will disconnect from AP mode and join the home network within ~15–30 s.
|
||||
|
||||
### 6.4 Reconnect Mac to Home Network
|
||||
|
||||
```bash
|
||||
networksetup -setairportnetwork en0 "MyHomeNetwork" "MyPassword"
|
||||
```
|
||||
|
||||
Wait ~15 s for the speaker to join the home network, then verify:
|
||||
|
||||
```bash
|
||||
# Discover the speaker's new IP via mDNS
|
||||
dns-sd -B _soundtouch._tcp local &
|
||||
sleep 5 ; kill %1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comparison: Initial Setup vs. Migration
|
||||
|
||||
| Feature | Initial Setup | Migration (soundtouch-service) |
|
||||
| :--- | :--- | :--- |
|
||||
| **Connectivity** | BLE, AP Mode, USB, WAC | Ethernet/Wi-Fi (existing) |
|
||||
| **Credentials** | Required (SSID/Pass) | Not required (uses existing) |
|
||||
| **Access** | Web UI / App protocol | SSH (root) |
|
||||
| **Primary File** | `setup/index.html` | `SoundTouchSdkPrivateCfg.xml` |
|
||||
| **Use Case** | Out-of-the-box / Reset | Redirecting active devices |
|
||||
| Feature | Initial Setup | Migration (soundtouch-service) |
|
||||
|:-----------------|:-----------------------|:-------------------------------|
|
||||
| **Connectivity** | BLE, AP Mode, USB, WAC | Ethernet/Wi-Fi (existing) |
|
||||
| **Credentials** | Required (SSID/Pass) | Not required (uses existing) |
|
||||
| **Access** | Web UI / App protocol | SSH (root) |
|
||||
| **Primary File** | `setup/index.html` | `SoundTouchSdkPrivateCfg.xml` |
|
||||
| **Use Case** | Out-of-the-box / Reset | Redirecting active devices |
|
||||
|
||||
+60
-67
@@ -1,74 +1,64 @@
|
||||
# HTTPS Setup & Custom CA Certificate
|
||||
# HTTPS & Custom CA Certificate
|
||||
|
||||
To use the `/etc/hosts` redirection method safely, SoundTouch devices must communicate over HTTPS. This requires the device to trust the AfterTouch Root CA certificate used by the local service.
|
||||
SoundTouch speakers communicate with cloud services over HTTPS. For the local service to work over HTTPS, speakers must trust the AfterTouch Root CA. The service manages this automatically — it generates a CA on first start and the web UI guides you through installing it on each speaker as part of the migration flow.
|
||||
|
||||
## 1. Automated Migration (Hosts Method)
|
||||
---
|
||||
|
||||
The `soundtouch-service` can automatically configure a device to use the `/etc/hosts` method:
|
||||
## How TLS works in AfterTouch
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8000/setup/migrate/{deviceIP}?method=hosts"
|
||||
The service includes a built-in HTTPS listener (default port `8443`) that presents a certificate covering all Bose cloud hostnames. The certificate is signed by the AfterTouch Root CA, which is generated automatically on first start and stored in `data/certs/`.
|
||||
|
||||
**Domain coverage** — the certificate covers:
|
||||
- Wildcard: `*.api.bose.io`, `*.api.bosecm.com`
|
||||
- Specific: `streaming.bose.com`, `bmx.bose.com`, `stats.bose.com`, `updates.bose.com`, `worldwide.bose.com`, `bose-prod.apigee.net`, `media.bose.io`, `downloads.bose.com`, `voice.api.bose.io`, and more
|
||||
|
||||
> **Note**: The hostname you configure as `HTTPS_SERVER_URL` (e.g. `https://soundtouch.fritz.box:8443`) is also added as a Subject Alternative Name, ensuring valid TLS for direct browser or API access.
|
||||
|
||||
---
|
||||
|
||||
## CA trust installation (via web UI)
|
||||
|
||||
The migration flow in the web UI includes a CA trust step that:
|
||||
1. Uploads the Root CA to the speaker via SSH
|
||||
2. Appends it to the speaker's shared trust store (`/etc/pki/tls/certs/ca-bundle.crt`)
|
||||
3. Verifies connectivity over HTTPS
|
||||
|
||||
This is handled automatically — you don't need to manage CA files manually unless you're doing an advanced or manual setup.
|
||||
|
||||
---
|
||||
|
||||
## Downloading the CA certificate
|
||||
|
||||
You can download the Root CA for manual installation on other devices (phones, PCs, additional speakers):
|
||||
|
||||
```
|
||||
http://<server>:8000/setup/ca.crt
|
||||
```
|
||||
|
||||
This command will:
|
||||
1. Connect to the device via SSH.
|
||||
2. Update `/etc/hosts` to point Bose domains to the service IP.
|
||||
3. Inject the auto-generated AfterTouch Root CA into the device's trust store (`/etc/pki/tls/certs/ca-bundle.crt`).
|
||||
4. Reboot the device.
|
||||
---
|
||||
|
||||
## 2. Managing the Root CA
|
||||
## Binding to port 443
|
||||
|
||||
The AfterTouch service automatically generates a Root CA when it first starts.
|
||||
Speakers expect HTTPS on the default port 443. Since binding to port 443 requires elevated privileges, you have three options:
|
||||
|
||||
- **CA Certificate**: `data/certs/ca.crt`
|
||||
- **CA Private Key**: `data/certs/ca.key`
|
||||
1. **Port forwarding (recommended)**: Run the service on port 8443 and forward port 443 to it using `iptables` or your firewall/router.
|
||||
2. **Capabilities**: Grant the binary permission to bind low ports: `sudo setcap 'cap_net_bind_service=+ep' ./soundtouch-service`
|
||||
3. **Reverse proxy**: Use Nginx or Caddy in front of the service (see below).
|
||||
|
||||
### Downloading the CA Certificate
|
||||
You can download the CA certificate for manual installation on other devices (like your phone or PC) from:
|
||||
`http://<server-ip>:8000/setup/ca.crt`
|
||||
---
|
||||
|
||||
### 3. Built-in HTTPS Support
|
||||
## Reverse proxy (optional)
|
||||
|
||||
The `soundtouch-service` now includes a built-in HTTPS listener. This simplifies the `/etc/hosts` redirection method by automatically presenting the correct certificates for Bose domains.
|
||||
|
||||
- **HTTPS Port**: Configurable via `HTTPS_PORT` environment variable (defaults to `8443`).
|
||||
- **HTTPS Server URL**: Configurable via `HTTPS_SERVER_URL` (e.g., `https://mysoundtouch.local:8443`). If not set, the service attempts to guess it using the system hostname.
|
||||
- **Domain Coverage**: Automatically presents a certificate for `streaming.bose.com`, `updates.bose.com`, `stats.bose.com`, `bmx.bose.com`, and `content.api.bose.io`.
|
||||
- **Automatic Setup**: On first start, it generates a server certificate signed by your AfterTouch local Root CA.
|
||||
|
||||
#### TLS Security
|
||||
|
||||
The built-in HTTPS listener is configured to use modern and secure TLS settings while maintaining compatibility with SoundTouch devices (which support up to TLS 1.2 with OpenSSL 1.0.2).
|
||||
|
||||
- **Minimum TLS Version**: TLS 1.2
|
||||
- **Preferred Cipher Suites**:
|
||||
- `ECDHE-RSA-AES128-GCM-SHA256`
|
||||
- `ECDHE-RSA-AES256-GCM-SHA384`
|
||||
- `ECDHE-RSA-CHACHA20-POLY1305`
|
||||
- `RSA-AES128-GCM-SHA256` (Legacy support)
|
||||
- `RSA-AES256-GCM-SHA384` (Legacy support)
|
||||
|
||||
#### Binding to Port 443
|
||||
SoundTouch devices expect HTTPS on the default port 443. Since binding to port 443 usually requires root privileges, you have two options:
|
||||
|
||||
1. **Port Forwarding (Recommended)**: Run the service on a high port (e.g., 8443) and use `iptables` or your firewall to forward traffic from 443 to 8443.
|
||||
2. **Capabilities**: Grant the binary permission to bind to low ports: `sudo setcap 'cap_net_bind_service=+ep' ./soundtouch-service`.
|
||||
3. **Reverse Proxy**: Use Nginx or Caddy as described below.
|
||||
|
||||
### 4. Reverse Proxy (Optional)
|
||||
|
||||
1. **Generate a certificate** for the Bose domains signed by your Root CA.
|
||||
2. **Configure Nginx** to use this certificate and proxy requests to `soundtouch-service`.
|
||||
If you prefer to use Nginx or another proxy for TLS termination:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name streaming.bose.com bmx.bose.com stats.bose.com updates.bose.com;
|
||||
|
||||
ssl_certificate /path/to/generated-cert.crt;
|
||||
ssl_certificate_key /path/to/generated-cert.key;
|
||||
ssl_certificate /path/to/data/certs/server.crt;
|
||||
ssl_certificate_key /path/to/data/certs/server.key;
|
||||
|
||||
# Secure TLS configuration (matches soundtouch-service defaults)
|
||||
ssl_protocols TLSv1.2;
|
||||
ssl_ciphers 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-CHACHA20-POLY1305:AES128-GCM-SHA256:AES256-GCM-SHA384';
|
||||
|
||||
@@ -80,23 +70,26 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Manual CA Injection (Legacy/Manual)
|
||||
---
|
||||
|
||||
If you prefer to inject the CA certificate manually:
|
||||
## Manual CA injection (advanced)
|
||||
|
||||
1. Copy `ca.crt` to the device:
|
||||
```bash
|
||||
scp data/certs/ca.crt root@{deviceIP}:/tmp/
|
||||
```
|
||||
2. Append it to the trust store on the device:
|
||||
```bash
|
||||
ssh root@{deviceIP} "(rw || mount -o remount,rw /) && cat /tmp/ca.crt >> /etc/pki/tls/certs/ca-bundle.crt"
|
||||
```
|
||||
If you need to inject the CA manually (e.g. without the web UI migration flow):
|
||||
|
||||
## 6. Verifying Connectivity
|
||||
```bash
|
||||
# Copy the CA to the speaker
|
||||
scp data/certs/ca.crt root@<SPEAKER-IP>:/tmp/
|
||||
|
||||
You can verify that your device can correctly reach the `soundtouch-service` over HTTPS using the management web UI.
|
||||
# Make the filesystem writable and append the CA to the trust store
|
||||
ssh root@<SPEAKER-IP> "(rw || mount -o remount,rw /) && cat /tmp/ca.crt >> /etc/pki/tls/certs/ca-bundle.crt"
|
||||
```
|
||||
|
||||
In the **Migration Summary** for a device, you will find an **HTTPS Connection Test** section:
|
||||
- **Test with Explicit CA.crt**: Uploads a temporary copy of the Root CA to the device and uses `curl --cacert` to verify the connection. Use this to verify your HTTPS setup *before* modifying the device's shared trust store.
|
||||
- **Test with Shared Trust Store**: Uses the device's default trust store. Use this to verify that your CA injection was successful and the device now natively trusts your local server.
|
||||
---
|
||||
|
||||
## TLS compatibility
|
||||
|
||||
SoundTouch speakers run OpenSSL 1.0.2, supporting up to TLS 1.2. The service is configured accordingly:
|
||||
|
||||
- **Minimum TLS version**: TLS 1.2
|
||||
- **Preferred cipher suites**: `ECDHE-RSA-AES128-GCM-SHA256`, `ECDHE-RSA-AES256-GCM-SHA384`, `ECDHE-RSA-CHACHA20-POLY1305`
|
||||
- **Legacy support**: `RSA-AES128-GCM-SHA256`, `RSA-AES256-GCM-SHA384`
|
||||
@@ -0,0 +1,753 @@
|
||||
# IoT Implementation Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This guide provides technical implementation details for integrating with the Bose SoundTouch IoT configuration system. It covers the AWS IoT Core integration, certificate management, and device shadow operations.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- AWS IoT Core account and permissions
|
||||
- Understanding of MQTT protocol
|
||||
- Knowledge of X.509 certificate management
|
||||
- Familiarity with JSON and protobuf serialization
|
||||
|
||||
## Architecture Components
|
||||
|
||||
### Core System Design
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ Mobile App │ │ Alexa Voice │ │ Web Interface │
|
||||
│ │ │ Assistant │ │ │
|
||||
└─────────┬───────┘ └─────────┬────────┘ └─────────┬───────┘
|
||||
│ │ │
|
||||
└──────────────────────┼───────────────────────┘
|
||||
│
|
||||
┌────────────▼──────────────┐
|
||||
│ AWS IoT Core │
|
||||
│ (MQTT Broker + │
|
||||
│ Device Shadows) │
|
||||
└────────────┬──────────────┘
|
||||
│ MQTT/TLS
|
||||
┌────────────▼──────────────┐
|
||||
│ SoundTouch Device │
|
||||
│ │
|
||||
│ ┌─────────────────────┐ │
|
||||
│ │ IoT Service │ │
|
||||
│ │ (/opt/Bose/IoT) │ │
|
||||
│ └─────────────────────┘ │
|
||||
│ ┌─────────────────────┐ │
|
||||
│ │ BoseApp Service │ │
|
||||
│ │ (/opt/Bose/BoseApp) │ │
|
||||
│ └─────────────────────┘ │
|
||||
└───────────────────────────┘
|
||||
```
|
||||
|
||||
### Configuration Flow
|
||||
|
||||
```
|
||||
1. Device Boot
|
||||
│
|
||||
▼
|
||||
2. Read IoT.xml (/mnt/nv/BoseApp-Persistence/1/IoT.xml)
|
||||
│
|
||||
▼
|
||||
3. Load Certificates (/mnt/nv/IoTCerts/)
|
||||
│
|
||||
▼
|
||||
4. Establish MQTT/TLS Connection
|
||||
│
|
||||
▼
|
||||
5. Subscribe to Device Shadow Topics
|
||||
│
|
||||
▼
|
||||
6. Publish Current Device State
|
||||
│
|
||||
▼
|
||||
7. Listen for Delta Messages
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Configuration File Management
|
||||
|
||||
#### IoT.xml Structure
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<Configuration
|
||||
clientID="{device-unique-uuid}"
|
||||
iotEndpoint="{aws-iot-endpoint}"
|
||||
deployment="{PROD|DEV|TEST}" />
|
||||
```
|
||||
|
||||
#### Loading Configuration (C++ Implementation)
|
||||
```cpp
|
||||
#include <rapidxml/rapidxml.hpp>
|
||||
#include <fstream>
|
||||
|
||||
struct IoTConfig {
|
||||
std::string clientID;
|
||||
std::string iotEndpoint;
|
||||
std::string deployment;
|
||||
};
|
||||
|
||||
IoTConfig loadIoTConfig(const std::string& configPath) {
|
||||
std::ifstream file(configPath);
|
||||
std::string content((std::istreambuf_iterator<char>(file)),
|
||||
std::istreambuf_iterator<char>());
|
||||
|
||||
rapidxml::xml_document<> doc;
|
||||
doc.parse<0>(&content[0]);
|
||||
|
||||
auto configNode = doc.first_node("Configuration");
|
||||
|
||||
IoTConfig config;
|
||||
config.clientID = configNode->first_attribute("clientID")->value();
|
||||
config.iotEndpoint = configNode->first_attribute("iotEndpoint")->value();
|
||||
config.deployment = configNode->first_attribute("deployment")->value();
|
||||
|
||||
return config;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Certificate Management
|
||||
|
||||
#### Certificate Files Structure
|
||||
```
|
||||
/mnt/nv/IoTCerts/
|
||||
├── iot-cert.pem.crt # Device client certificate
|
||||
├── iot-private.pem.key # Device private key
|
||||
└── default.pem # Additional cert data
|
||||
|
||||
/var/lib/iot/
|
||||
└── rootCA.crt # AWS IoT Root CA
|
||||
```
|
||||
|
||||
#### Certificate Registration Process
|
||||
```cpp
|
||||
#include <openssl/x509.h>
|
||||
#include <openssl/rsa.h>
|
||||
#include <openssl/pem.h>
|
||||
|
||||
class IoTCertificateManager {
|
||||
private:
|
||||
static const std::string CERT_ENDPOINT;
|
||||
static const std::string CERT_PATH;
|
||||
static const std::string KEY_PATH;
|
||||
|
||||
public:
|
||||
bool generateCSR() {
|
||||
// Generate EC key pair
|
||||
EC_KEY* eckey = EC_KEY_new_by_curve_name(NID_X9_62_prime256v1);
|
||||
EC_KEY_generate_key(eckey);
|
||||
|
||||
// Create certificate request
|
||||
X509_REQ* req = X509_REQ_new();
|
||||
X509_REQ_set_version(req, 0);
|
||||
|
||||
// Set subject name
|
||||
X509_NAME* name = X509_NAME_new();
|
||||
X509_NAME_add_entry_by_txt(name, "CN", MBSTRING_ASC,
|
||||
(unsigned char*)clientID.c_str(), -1, -1, 0);
|
||||
X509_REQ_set_subject_name(req, name);
|
||||
|
||||
// Set public key
|
||||
EVP_PKEY* pkey = EVP_PKEY_new();
|
||||
EVP_PKEY_set1_EC_KEY(pkey, eckey);
|
||||
X509_REQ_set_pubkey(req, pkey);
|
||||
|
||||
// Sign request
|
||||
X509_REQ_sign(req, pkey, EVP_sha256());
|
||||
|
||||
return sendCSRToEndpoint(req, pkey);
|
||||
}
|
||||
|
||||
bool sendCSRToEndpoint(X509_REQ* req, EVP_PKEY* pkey) {
|
||||
// Send CSR to voice.api.bose.io/alexa/certificate
|
||||
// Receive certificate response
|
||||
// Store certificate and private key
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const std::string IoTCertificateManager::CERT_ENDPOINT =
|
||||
"https://voice.api.bose.io/alexa/certificate";
|
||||
const std::string IoTCertificateManager::CERT_PATH =
|
||||
"/mnt/nv/IoTCerts/iot-cert.pem.crt";
|
||||
const std::string IoTCertificateManager::KEY_PATH =
|
||||
"/mnt/nv/IoTCerts/iot-private.pem.key";
|
||||
```
|
||||
|
||||
### 3. MQTT Connection Implementation
|
||||
|
||||
#### AWS IoT SDK Integration
|
||||
```cpp
|
||||
#include <aws/iot/MqttClient.h>
|
||||
#include <aws/iot/ShadowClient.h>
|
||||
|
||||
class IoTConnectionManager {
|
||||
private:
|
||||
std::unique_ptr<awsiotsdk::MqttClient> mqttClient;
|
||||
std::unique_ptr<awsiotsdk::Shadow> shadowClient;
|
||||
IoTConfig config;
|
||||
|
||||
public:
|
||||
awsiotsdk::ResponseCode connect() {
|
||||
// Setup connection parameters
|
||||
std::string endpoint = config.iotEndpoint;
|
||||
uint16_t port = 8883; // MQTT over SSL
|
||||
|
||||
// Load certificates
|
||||
std::string certPath = "/mnt/nv/IoTCerts/iot-cert.pem.crt";
|
||||
std::string keyPath = "/mnt/nv/IoTCerts/iot-private.pem.key";
|
||||
std::string rootCaPath = "/var/lib/iot/rootCA.crt";
|
||||
|
||||
// Create network connection
|
||||
auto networkConnection = std::make_shared<awsiotsdk::network::MbedTLSConnection>(
|
||||
endpoint, port, rootCaPath, certPath, keyPath
|
||||
);
|
||||
|
||||
// Create MQTT client
|
||||
mqttClient = awsiotsdk::MqttClient::Create(networkConnection);
|
||||
if (!mqttClient) {
|
||||
return awsiotsdk::ResponseCode::FAILURE;
|
||||
}
|
||||
|
||||
// Connect with client ID
|
||||
auto connectPacket = awsiotsdk::mqtt::ConnectPacket::Create(
|
||||
config.clientID,
|
||||
true, // cleanSession
|
||||
awsiotsdk::mqtt::QoS::QOS0,
|
||||
nullptr // will options
|
||||
);
|
||||
|
||||
return mqttClient->Connect(std::chrono::milliseconds(5000), connectPacket);
|
||||
}
|
||||
|
||||
awsiotsdk::ResponseCode initializeShadow() {
|
||||
shadowClient = awsiotsdk::Shadow::Create(mqttClient);
|
||||
if (!shadowClient) {
|
||||
return awsiotsdk::ResponseCode::FAILURE;
|
||||
}
|
||||
|
||||
// Subscribe to shadow delta
|
||||
auto deltaHandler = [this](const std::string& thingName,
|
||||
const std::string& payload) {
|
||||
handleShadowDelta(thingName, payload);
|
||||
};
|
||||
|
||||
return shadowClient->PerformUpdateAsync(
|
||||
config.clientID,
|
||||
"", // jsonString
|
||||
deltaHandler,
|
||||
std::chrono::seconds(10)
|
||||
);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 4. Device Shadow Operations
|
||||
|
||||
#### Shadow Message Structures
|
||||
```cpp
|
||||
#include <rapidjson/document.h>
|
||||
#include <rapidjson/writer.h>
|
||||
#include <rapidjson/stringbuffer.h>
|
||||
|
||||
struct DeviceState {
|
||||
std::string deviceState; // "CONNECTED" | "DISCONNECTED"
|
||||
std::string powerState; // "ON" | "OFF"
|
||||
std::string zoneState; // Zone configuration
|
||||
std::string groupState; // Multi-room group info
|
||||
};
|
||||
|
||||
class ShadowMessageBuilder {
|
||||
public:
|
||||
static std::string createReportedState(const DeviceState& state) {
|
||||
rapidjson::Document doc;
|
||||
doc.SetObject();
|
||||
auto& allocator = doc.GetAllocator();
|
||||
|
||||
// Create state object
|
||||
rapidjson::Value stateObj(rapidjson::kObjectType);
|
||||
rapidjson::Value reportedObj(rapidjson::kObjectType);
|
||||
|
||||
// Add reported state fields
|
||||
reportedObj.AddMember("deviceState",
|
||||
rapidjson::Value(state.deviceState.c_str(), allocator),
|
||||
allocator);
|
||||
reportedObj.AddMember("powerState",
|
||||
rapidjson::Value(state.powerState.c_str(), allocator),
|
||||
allocator);
|
||||
reportedObj.AddMember("zoneState",
|
||||
rapidjson::Value(state.zoneState.c_str(), allocator),
|
||||
allocator);
|
||||
reportedObj.AddMember("groupState",
|
||||
rapidjson::Value(state.groupState.c_str(), allocator),
|
||||
allocator);
|
||||
|
||||
stateObj.AddMember("reported", reportedObj, allocator);
|
||||
doc.AddMember("state", stateObj, allocator);
|
||||
|
||||
// Serialize to string
|
||||
rapidjson::StringBuffer buffer;
|
||||
rapidjson::Writer<rapidjson::StringBuffer> writer(buffer);
|
||||
doc.Accept(writer);
|
||||
|
||||
return buffer.GetString();
|
||||
}
|
||||
|
||||
static DeviceState parseDesiredState(const std::string& json) {
|
||||
rapidjson::Document doc;
|
||||
doc.Parse(json.c_str());
|
||||
|
||||
DeviceState state;
|
||||
if (doc.HasMember("state") && doc["state"].HasMember("desired")) {
|
||||
auto& desired = doc["state"]["desired"];
|
||||
|
||||
if (desired.HasMember("powerState")) {
|
||||
state.powerState = desired["powerState"].GetString();
|
||||
}
|
||||
if (desired.HasMember("zoneState")) {
|
||||
state.zoneState = desired["zoneState"].GetString();
|
||||
}
|
||||
if (desired.HasMember("groupState")) {
|
||||
state.groupState = desired["groupState"].GetString();
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
#### Shadow Update Implementation
|
||||
```cpp
|
||||
class IoTShadowManager {
|
||||
private:
|
||||
std::shared_ptr<awsiotsdk::Shadow> shadowClient;
|
||||
std::string thingName;
|
||||
DeviceState currentState;
|
||||
|
||||
public:
|
||||
awsiotsdk::ResponseCode updateDeviceState(const DeviceState& newState) {
|
||||
currentState = newState;
|
||||
|
||||
std::string payload = ShadowMessageBuilder::createReportedState(newState);
|
||||
|
||||
auto responseHandler = [](const std::string& thingName,
|
||||
awsiotsdk::ShadowRequestType requestType,
|
||||
awsiotsdk::ShadowResponseType responseType,
|
||||
rapidjson::Document& payload) {
|
||||
if (responseType == awsiotsdk::ShadowResponseType::Accepted) {
|
||||
// Shadow update successful
|
||||
std::cout << "Shadow updated successfully" << std::endl;
|
||||
} else {
|
||||
// Handle rejection
|
||||
std::cout << "Shadow update rejected" << std::endl;
|
||||
}
|
||||
};
|
||||
|
||||
return shadowClient->PerformUpdateAsync(
|
||||
thingName,
|
||||
payload,
|
||||
responseHandler,
|
||||
std::chrono::seconds(10)
|
||||
);
|
||||
}
|
||||
|
||||
void handleShadowDelta(const std::string& thingName,
|
||||
const std::string& payload) {
|
||||
DeviceState desiredState = ShadowMessageBuilder::parseDesiredState(payload);
|
||||
|
||||
// Apply desired state changes to device
|
||||
if (!desiredState.powerState.empty()) {
|
||||
applyPowerStateChange(desiredState.powerState);
|
||||
}
|
||||
|
||||
if (!desiredState.zoneState.empty()) {
|
||||
applyZoneStateChange(desiredState.zoneState);
|
||||
}
|
||||
|
||||
if (!desiredState.groupState.empty()) {
|
||||
applyGroupStateChange(desiredState.groupState);
|
||||
}
|
||||
|
||||
// Report updated state back to shadow
|
||||
updateDeviceState(currentState);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 5. Service Integration
|
||||
|
||||
#### Shepherd Service Configuration
|
||||
```xml
|
||||
<!-- /opt/Bose/etc/Shepherd-noncore.xml -->
|
||||
<ShepherdConfig>
|
||||
<daemon name="STSCertified"/>
|
||||
<daemon name="IoT">
|
||||
<env name="IOT_CONFIG_PATH">/mnt/nv/BoseApp-Persistence/1/IoT.xml</env>
|
||||
<env name="IOT_CERT_PATH">/mnt/nv/IoTCerts</env>
|
||||
</daemon>
|
||||
<daemon name="TPDA">
|
||||
<arg>-c</arg>
|
||||
<arg>/opt/Bose/etc/Voice.xml</arg>
|
||||
</daemon>
|
||||
</ShepherdConfig>
|
||||
```
|
||||
|
||||
#### System Startup Integration
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# /etc/init.d/SoundTouch fragment
|
||||
|
||||
# Create IoT directories
|
||||
mkdir -p /mnt/nv/BoseLog /mnt/nv/IoTCerts /mnt/nv/BoseApp-Persistence/1
|
||||
mkdir -m 700 -p /mnt/nv/BoseApp-Persistence/1/Keys
|
||||
|
||||
# Set proper permissions for certificate storage
|
||||
chmod 700 /mnt/nv/IoTCerts
|
||||
chown iot:iot /mnt/nv/IoTCerts
|
||||
|
||||
# Start shepherd daemon manager
|
||||
shepherdd --config-dir /opt/Bose/etc --run-dir /var/run/shepherd
|
||||
```
|
||||
|
||||
## Error Handling and Debugging
|
||||
|
||||
### Connection Retry Logic
|
||||
```cpp
|
||||
class ConnectionRetryManager {
|
||||
private:
|
||||
int maxRetries = 10;
|
||||
int retryDelaySeconds = 5;
|
||||
|
||||
public:
|
||||
awsiotsdk::ResponseCode connectWithRetry(IoTConnectionManager& manager) {
|
||||
for (int attempt = 1; attempt <= maxRetries; ++attempt) {
|
||||
std::cout << "Connection attempt " << attempt
|
||||
<< " to MQTT port at host " << config.iotEndpoint << std::endl;
|
||||
|
||||
auto result = manager.connect();
|
||||
if (result == awsiotsdk::ResponseCode::SUCCESS) {
|
||||
std::cout << "Successfully connected to MQTT server" << std::endl;
|
||||
return result;
|
||||
}
|
||||
|
||||
std::cout << "MQTT port not available. Retrying in "
|
||||
<< retryDelaySeconds << " seconds" << std::endl;
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::seconds(retryDelaySeconds));
|
||||
retryDelaySeconds *= 2; // Exponential backoff
|
||||
}
|
||||
|
||||
std::cerr << "Failed to connect after " << maxRetries << " attempts" << std::endl;
|
||||
return awsiotsdk::ResponseCode::FAILURE;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Logging and Monitoring
|
||||
```cpp
|
||||
class IoTLogger {
|
||||
public:
|
||||
static void logConnectionStatus(const std::string& status) {
|
||||
std::cout << "[IoT] Connection status: " << status << std::endl;
|
||||
}
|
||||
|
||||
static void logShadowResponse(awsiotsdk::ShadowResponseType response,
|
||||
const std::string& payload) {
|
||||
if (response == awsiotsdk::ShadowResponseType::Accepted) {
|
||||
std::cout << "[IoT] Shadow response: accepted. Payload: " << payload << std::endl;
|
||||
} else {
|
||||
std::cout << "[IoT] Shadow response: rejected" << std::endl;
|
||||
}
|
||||
}
|
||||
|
||||
static void logCertificateStatus(bool success) {
|
||||
if (success) {
|
||||
std::cout << "[IoT] Certificate generated successfully" << std::endl;
|
||||
} else {
|
||||
std::cerr << "[IoT] Failed to generate iot certificate" << std::endl;
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Testing and Validation
|
||||
|
||||
### Unit Test Example
|
||||
```cpp
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
class IoTConfigTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
// Create test configuration file
|
||||
std::ofstream file("/tmp/test_iot.xml");
|
||||
file << R"(<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<Configuration clientID="test-client-id"
|
||||
iotEndpoint="test.iot.amazonaws.com"
|
||||
deployment="TEST" />)";
|
||||
file.close();
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(IoTConfigTest, LoadConfiguration) {
|
||||
auto config = loadIoTConfig("/tmp/test_iot.xml");
|
||||
|
||||
EXPECT_EQ(config.clientID, "test-client-id");
|
||||
EXPECT_EQ(config.iotEndpoint, "test.iot.amazonaws.com");
|
||||
EXPECT_EQ(config.deployment, "TEST");
|
||||
}
|
||||
|
||||
TEST_F(IoTConfigTest, ShadowMessageBuilder) {
|
||||
DeviceState state;
|
||||
state.deviceState = "CONNECTED";
|
||||
state.powerState = "ON";
|
||||
|
||||
std::string json = ShadowMessageBuilder::createReportedState(state);
|
||||
|
||||
// Verify JSON contains expected fields
|
||||
EXPECT_TRUE(json.find("\"deviceState\":\"CONNECTED\"") != std::string::npos);
|
||||
EXPECT_TRUE(json.find("\"powerState\":\"ON\"") != std::string::npos);
|
||||
}
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Certificate Management**
|
||||
- Store private keys with 600 permissions
|
||||
- Rotate certificates regularly
|
||||
- Use hardware security modules when available
|
||||
|
||||
2. **Network Security**
|
||||
- Always use TLS 1.2 or higher
|
||||
- Validate certificate chains
|
||||
- Implement certificate pinning
|
||||
|
||||
3. **Configuration Security**
|
||||
- Encrypt sensitive configuration data
|
||||
- Use secure storage for credentials
|
||||
- Implement configuration validation
|
||||
|
||||
## Troubleshooting Common Issues
|
||||
|
||||
### Certificate Problems
|
||||
```bash
|
||||
# Check certificate validity
|
||||
openssl x509 -in /mnt/nv/IoTCerts/iot-cert.pem.crt -text -noout
|
||||
|
||||
# Verify private key matches certificate
|
||||
openssl x509 -noout -modulus -in /mnt/nv/IoTCerts/iot-cert.pem.crt | openssl md5
|
||||
openssl rsa -noout -modulus -in /mnt/nv/IoTCerts/iot-private.pem.key | openssl md5
|
||||
```
|
||||
|
||||
### Connection Issues
|
||||
```bash
|
||||
# Test MQTT connectivity
|
||||
mosquitto_pub -h a2bhvr9c4wn4ya.iot.us-east-1.amazonaws.com \
|
||||
-p 8883 --cafile /var/lib/iot/rootCA.crt \
|
||||
--cert /mnt/nv/IoTCerts/iot-cert.pem.crt \
|
||||
--key /mnt/nv/IoTCerts/iot-private.pem.key \
|
||||
-t '$aws/things/test/shadow/update' \
|
||||
-m '{"state":{"reported":{"test":"value"}}}'
|
||||
```
|
||||
|
||||
### Service Debugging
|
||||
```bash
|
||||
# Check service status
|
||||
ps aux | grep IoT
|
||||
|
||||
# Monitor system logs
|
||||
tail -f /mnt/nv/BoseLog/IoT.log
|
||||
|
||||
# Check Shepherd status
|
||||
shepherdd --status
|
||||
```
|
||||
|
||||
## MQTT Monitoring and Research
|
||||
|
||||
### Direct Device Credential Access
|
||||
|
||||
With device certificates and private keys available from firmware backups, it's technically possible to monitor MQTT traffic:
|
||||
|
||||
```bash
|
||||
# Subscribe to your device's shadow events only
|
||||
CLIENT_ID="577ecfcc-2db3-4989-92c9-76d7704f9fb3" # Your device's UUID
|
||||
mosquitto_sub -h a2bhvr9c4wn4ya.iot.us-east-1.amazonaws.com \
|
||||
-p 8883 --cafile /var/lib/iot/rootCA.crt \
|
||||
--cert /mnt/nv/IoTCerts/iot-cert.pem.crt \
|
||||
--key /mnt/nv/IoTCerts/iot-private.pem.key \
|
||||
-t "\$aws/things/$CLIENT_ID/shadow/update/accepted"
|
||||
```
|
||||
|
||||
### Security Constraints and Limitations
|
||||
|
||||
#### AWS IoT Policy Restrictions
|
||||
Device certificates are bound to restrictive policies:
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": "iot:Connect",
|
||||
"Resource": "arn:aws:iot:us-east-1:*:client/${iot:ClientId}"
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["iot:Publish", "iot:Subscribe", "iot:Receive"],
|
||||
"Resource": [
|
||||
"arn:aws:iot:us-east-1:*:topic/$aws/things/${iot:ClientId}/shadow/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Limitations:**
|
||||
- Access only to your specific device topics
|
||||
- No wildcard subscriptions (`+` or `#`)
|
||||
- No cross-device monitoring
|
||||
- Potential IP geolocation restrictions
|
||||
- Certificate revocation for unusual activity
|
||||
|
||||
### Alternative Monitoring Approaches
|
||||
|
||||
#### Network Traffic Capture (Recommended)
|
||||
```bash
|
||||
# Capture MQTT traffic patterns without authentication
|
||||
tcpdump -i eth0 -s0 -w soundtouch_iot.pcap host a2bhvr9c4wn4ya.iot.us-east-1.amazonaws.com
|
||||
|
||||
# Monitor connection patterns in real-time
|
||||
tcpdump -i eth0 -n -A "host a2bhvr9c4wn4ya.iot.us-east-1.amazonaws.com and port 8883"
|
||||
|
||||
# Extract timing and packet size information
|
||||
tcpdump -i eth0 -ttt -s0 "host a2bhvr9c4wn4ya.iot.us-east-1.amazonaws.com"
|
||||
```
|
||||
|
||||
#### Local MQTT Broker for Testing
|
||||
```bash
|
||||
# Set up local Mosquitto broker
|
||||
sudo apt-get install mosquitto mosquitto-clients
|
||||
|
||||
# Configure TLS (optional)
|
||||
cat > /etc/mosquitto/conf.d/tls.conf << EOF
|
||||
port 8883
|
||||
cafile /path/to/ca.crt
|
||||
certfile /path/to/server.crt
|
||||
keyfile /path/to/server.key
|
||||
require_certificate true
|
||||
use_identity_as_username true
|
||||
EOF
|
||||
|
||||
# Test local shadow operations
|
||||
mosquitto_pub -h localhost -p 8883 \
|
||||
-t '$aws/things/test-device/shadow/update' \
|
||||
-m '{"state":{"reported":{"deviceState":"CONNECTED"}}}'
|
||||
```
|
||||
|
||||
### Message Analysis and Documentation
|
||||
|
||||
Expected shadow message patterns:
|
||||
|
||||
```cpp
|
||||
// Power state transitions
|
||||
{
|
||||
"state": {
|
||||
"reported": {
|
||||
"deviceState": "CONNECTED",
|
||||
"powerState": "ON|OFF"
|
||||
}
|
||||
},
|
||||
"timestamp": 1703875200
|
||||
}
|
||||
|
||||
// Audio control updates
|
||||
{
|
||||
"state": {
|
||||
"reported": {
|
||||
"volume": 25,
|
||||
"muted": false,
|
||||
"source": "SPOTIFY"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-room coordination
|
||||
{
|
||||
"state": {
|
||||
"reported": {
|
||||
"zoneState": "master|slave",
|
||||
"groupMembers": ["device1", "device2"],
|
||||
"groupName": "Living Room"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Legal and Ethical Guidelines
|
||||
|
||||
**Important Warnings:**
|
||||
- Only monitor devices you personally own
|
||||
- Using device credentials outside the device may violate Bose Terms of Service
|
||||
- Accessing Bose's AWS infrastructure could be considered unauthorized
|
||||
- Certificate abuse may result in device blacklisting
|
||||
- Service shutdown in May 2026 makes this a temporary research opportunity
|
||||
|
||||
**Recommended Usage:**
|
||||
- Document message formats for local alternative development
|
||||
- Understand state transition patterns
|
||||
- Test compatibility with local MQTT brokers
|
||||
- Prepare migration strategies before cloud shutdown
|
||||
|
||||
### Research Implementation Example
|
||||
|
||||
```cpp
|
||||
class IoTResearchMonitor {
|
||||
private:
|
||||
std::string deviceClientId;
|
||||
std::ofstream messageLog;
|
||||
|
||||
public:
|
||||
void captureMessagePatterns() {
|
||||
// Subscribe only to owned device topics
|
||||
std::string topic = "$aws/things/" + deviceClientId + "/shadow/update/accepted";
|
||||
|
||||
auto messageHandler = [this](const std::string& topic, const std::string& payload) {
|
||||
// Log message structure for analysis
|
||||
messageLog << "Topic: " << topic << std::endl;
|
||||
messageLog << "Payload: " << payload << std::endl;
|
||||
messageLog << "Timestamp: " << getCurrentTimestamp() << std::endl;
|
||||
messageLog << "---" << std::endl;
|
||||
|
||||
// Parse and document state transitions
|
||||
documentStateTransition(payload);
|
||||
};
|
||||
|
||||
// WARNING: Only use with your own device certificates
|
||||
connectToAWSIoT(messageHandler);
|
||||
}
|
||||
|
||||
void documentStateTransition(const std::string& json) {
|
||||
// Analyze JSON structure for local implementation
|
||||
rapidjson::Document doc;
|
||||
doc.Parse(json.c_str());
|
||||
|
||||
if (doc.HasMember("state") && doc["state"].HasMember("reported")) {
|
||||
// Document field types and value ranges
|
||||
auto& reported = doc["state"]["reported"];
|
||||
|
||||
for (auto& field : reported.GetObject()) {
|
||||
std::cout << "Field: " << field.name.GetString()
|
||||
<< ", Type: " << getJSONType(field.value) << std::endl;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
This implementation guide provides the foundation for integrating with the Bose SoundTouch IoT system using AWS IoT Core, certificate-based authentication, and device shadow operations. The monitoring capabilities should be used responsibly and only for research purposes to develop local alternatives.
|
||||
@@ -0,0 +1,218 @@
|
||||
# MAC Address to Serial Number Mapping
|
||||
|
||||
**Understanding and troubleshooting device identification in SoundTouch service**
|
||||
|
||||
This guide explains how the SoundTouch service handles device identification through MAC address to serial number mapping, and how to troubleshoot related issues.
|
||||
|
||||
## 📋 **Overview**
|
||||
|
||||
The SoundTouch service uses two different identifiers for devices:
|
||||
|
||||
- **MAC Address** (`A81B6A536A98`) - 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.
|
||||
|
||||
## 🔍 **How It Works**
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
### 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 -->
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<device>
|
||||
<friendlyName>Sound Machinery</friendlyName>
|
||||
<modelName>SoundTouch 10</modelName>
|
||||
<serialNumber>A81B6A536A98</serialNumber> <!-- MAC address here -->
|
||||
</device>
|
||||
</root>
|
||||
```
|
||||
|
||||
## ⚙️ **Automatic Setup**
|
||||
|
||||
The mapping is created automatically when the service starts:
|
||||
|
||||
1. **Directory Scan**: Service scans `data/accounts/{account}/devices/{serial}/`
|
||||
2. **DeviceInfo.xml**: Reads MAC address from each device's info file
|
||||
3. **Mapping Creation**: Creates MAC → Serial mapping in memory
|
||||
4. **Normalization**: Handles different MAC address formats automatically
|
||||
|
||||
## 🛠️ **Supported MAC Address Formats**
|
||||
|
||||
The service handles all common MAC address formats automatically:
|
||||
|
||||
| Format | Example | Status |
|
||||
|-------------|---------------------|-------------|
|
||||
| Standard | `A81B6A536A98` | ✅ Supported |
|
||||
| Lowercase | `a81b6a536a98` | ✅ Supported |
|
||||
| With Colons | `A8:1B:6A:53:6A:98` | ✅ Supported |
|
||||
| With Dashes | `A8-1B-6A-53-6A-98` | ✅ Supported |
|
||||
| Mixed Case | `a81B6a536A98` | ✅ Supported |
|
||||
| With Spaces | ` A81B6A536A98 ` | ✅ Supported |
|
||||
|
||||
## 🔧 **Troubleshooting**
|
||||
|
||||
### Problem: API requests fail with "file not found" errors
|
||||
|
||||
**Symptoms:**
|
||||
```
|
||||
GET /streaming/account/3230304/device/A81B6A536A98/presets
|
||||
→ 500 Internal Server Error
|
||||
→ Log: "open .../devices/A81B6A536A98/Presets.xml: no such file or directory"
|
||||
```
|
||||
|
||||
**Diagnosis:**
|
||||
1. Check if mapping exists:
|
||||
```bash
|
||||
# Look for device directory
|
||||
ls data/accounts/3230304/devices/
|
||||
# Should show serial numbers like: I6332527703739342000020
|
||||
```
|
||||
|
||||
2. Check DeviceInfo.xml:
|
||||
```bash
|
||||
cat data/accounts/3230304/devices/I6332527703739342000020/DeviceInfo.xml
|
||||
# Look for <macAddress> field
|
||||
```
|
||||
|
||||
**Solutions:**
|
||||
|
||||
#### Solution 1: Restart the Service
|
||||
The mapping is created at startup. Simply restart:
|
||||
```bash
|
||||
sudo systemctl restart soundtouch-service
|
||||
```
|
||||
|
||||
#### Solution 2: Check DeviceInfo.xml Format
|
||||
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>
|
||||
</networkInfo>
|
||||
</info>
|
||||
```
|
||||
|
||||
#### Solution 3: Manual Device Addition
|
||||
If the device was added manually, ensure proper structure:
|
||||
```bash
|
||||
# Create device directory using serial number
|
||||
mkdir -p data/accounts/3230304/devices/I6332527703739342000020
|
||||
|
||||
# Create DeviceInfo.xml with MAC address
|
||||
cat > data/accounts/3230304/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>
|
||||
</networkInfo>
|
||||
</info>
|
||||
EOF
|
||||
```
|
||||
|
||||
### Problem: UPnP discovery not creating mappings
|
||||
|
||||
**Check UPnP accessibility:**
|
||||
```bash
|
||||
# Test UPnP endpoint directly
|
||||
curl http://192.168.1.100:8091/XD/BO5EBO5E-F00D-F00D-FEED-A81B6A536A98.xml
|
||||
|
||||
# Should return XML with <serialNumber> field
|
||||
```
|
||||
|
||||
**Enable debug logging:**
|
||||
```bash
|
||||
# Check service logs for UPnP activity
|
||||
journalctl -u soundtouch-service -f | grep UPnP
|
||||
```
|
||||
|
||||
### Problem: Case or format mismatches
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
## 📊 **Monitoring and Diagnostics**
|
||||
|
||||
### Check Current Mappings
|
||||
The service logs mapping creation at startup:
|
||||
```bash
|
||||
journalctl -u soundtouch-service | grep "MAC.*serial"
|
||||
```
|
||||
|
||||
### Verify File Structure
|
||||
Ensure proper directory organization:
|
||||
```
|
||||
data/
|
||||
└── accounts/
|
||||
└── 3230304/
|
||||
└── devices/
|
||||
└── I6332527703739342000020/ # Serial number directory
|
||||
├── DeviceInfo.xml # Contains MAC address
|
||||
├── Presets.xml
|
||||
└── Sources.xml
|
||||
```
|
||||
|
||||
## 🔗 **Related Documentation**
|
||||
|
||||
- [Device Initial Setup](DEVICE-INITIAL-SETUP.md) - Setting up new devices
|
||||
- [Troubleshooting Guide](TROUBLESHOOTING.md) - General troubleshooting steps
|
||||
- [SoundTouch Service](SOUNDTOUCH-SERVICE.md) - Service configuration and management
|
||||
|
||||
## 🏗️ **Technical Implementation**
|
||||
|
||||
For developers interested in the technical details:
|
||||
|
||||
### Normalization Algorithm
|
||||
```go
|
||||
// MAC addresses are normalized by:
|
||||
// 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"
|
||||
```
|
||||
|
||||
### Lookup Process
|
||||
```go
|
||||
// 1. Try exact match first
|
||||
// 2. If not found, try normalized version
|
||||
// 3. Return serial number for file access
|
||||
```
|
||||
|
||||
### Performance
|
||||
- **Lookup Time**: O(1) - Hash map lookup
|
||||
- **Memory Usage**: ~40 bytes per device mapping
|
||||
- **Initialization**: Scans all devices once at startup
|
||||
|
||||
## 📝 **Best Practices**
|
||||
|
||||
1. **Use Discovery**: Let UPnP discovery create mappings automatically
|
||||
2. **Consistent Format**: Store MAC addresses consistently in DeviceInfo.xml
|
||||
3. **Service Restart**: Restart service after manual device additions
|
||||
4. **Monitoring**: Check logs for mapping creation during startup
|
||||
5. **Backup**: Keep DeviceInfo.xml files backed up
|
||||
|
||||
## ⚠️ **Known Limitations**
|
||||
|
||||
- Mappings are created only at service startup
|
||||
- Manual device additions require service restart
|
||||
- MAC addresses must be present in DeviceInfo.xml
|
||||
- No automatic cleanup of stale mappings (restart required)
|
||||
@@ -0,0 +1,193 @@
|
||||
# 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.
|
||||
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
## What you need
|
||||
|
||||
- A machine that is **always on** (Raspberry Pi, NAS, home server, or similar) to run the service
|
||||
- A **USB drive** (FAT-formatted) to enable SSH on each speaker
|
||||
- Your speakers must be on the **same network** as the service host
|
||||
- About **15–30 minutes per speaker**
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Install and start the service
|
||||
|
||||
Choose the option that fits your setup.
|
||||
|
||||
### Binary (go install)
|
||||
|
||||
```bash
|
||||
go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-service@latest
|
||||
soundtouch-service
|
||||
```
|
||||
|
||||
The service starts on port 8000. Open `http://localhost:8000` in your browser.
|
||||
|
||||
### Docker Compose (recommended for home servers and VMs)
|
||||
|
||||
The repository ships a `docker-compose.yml` ready for this use case. Clone or download it, copy the example config, then edit `.env` before starting:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env:
|
||||
# SOUNDTOUCH_HOSTNAME=192.168.1.100 ← your server's address
|
||||
# SOUNDTOUCH_VERSION=v0.70.0 ← pin to a release tag instead of 'latest'
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
`SOUNDTOUCH_HOSTNAME` is the address your speakers will use to reach the service — use a hostname or IP reachable from the speaker, not `localhost`.
|
||||
|
||||
On **Linux** (Debian, Proxmox VE, Raspberry Pi OS, etc.) you can enable host networking for automatic speaker discovery. Uncomment the `network_mode: host` line in `docker-compose.yml` and remove the `ports:` section (they conflict with host networking). Without host networking, add your speakers by IP address in Step 4 instead.
|
||||
|
||||
For local overrides (e.g. switching to `build: .` during development), create a `docker-compose.override.yml` — Docker Compose picks it up automatically and it is not tracked in version control.
|
||||
|
||||
> **Note on `docker-compose.ci.yml`**: this file contains mock services used only for automated integration tests. It is not needed for your own deployment.
|
||||
|
||||
### Docker run (Linux — with host networking for device discovery)
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name soundtouch-service \
|
||||
--network host \
|
||||
-v $(pwd)/data:/app/data \
|
||||
ghcr.io/gesellix/bose-soundtouch:latest
|
||||
```
|
||||
|
||||
### Docker run (macOS / Windows — manual device IP required)
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name soundtouch-service \
|
||||
-p 8000:8000 -p 8443:8443 \
|
||||
-v $(pwd)/data:/app/data \
|
||||
--env SERVER_URL=http://soundtouch.local:8000 \
|
||||
--env HTTPS_SERVER_URL=https://soundtouch.local:8443 \
|
||||
ghcr.io/gesellix/bose-soundtouch:latest
|
||||
```
|
||||
|
||||
On macOS/Windows, device discovery via mDNS won't work inside the container — you'll add devices by IP address in Step 4.
|
||||
|
||||
See [Raspberry Pi Setup](RASPBERRY-PI.md) and the [SoundTouch Service Guide](SOUNDTOUCH-SERVICE.md) for more deployment options.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Configure the service URL
|
||||
|
||||
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`.
|
||||
|
||||
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.
|
||||
|
||||
> **Tip**: If you change settings and they don't seem to take effect, check `data/settings.json` — settings saved in the UI take precedence over environment variables.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Enable SSH on each speaker
|
||||
|
||||
The migration writes updated configuration to the speaker's filesystem, which requires SSH access. Enable it once per device:
|
||||
|
||||
1. Format a USB drive as FAT (FAT32). Some speakers require the **bootable flag** to be set on the partition — see [SoundCork issue #172](https://github.com/deborahgu/soundcork/issues/172) for details.
|
||||
2. Create an empty file named **`remote_services`** (no extension) in the root of the drive.
|
||||
3. Insert the drive into the speaker's USB port while it is powered on.
|
||||
4. Power-cycle the speaker (unplug the power cable, wait 10 seconds, reconnect).
|
||||
5. After boot, root SSH is available with no password: `ssh -oHostKeyAlgorithms=+ssh-rsa root@<SPEAKER-IP>`
|
||||
|
||||
You only need to do this once per speaker. SSH can remain enabled for future maintenance or be disabled after migration — your choice.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Add and sync your speaker
|
||||
|
||||
### Discover
|
||||
|
||||
The service scans for SoundTouch devices automatically every few minutes. Check the **Devices** tab in the web UI. If your speaker doesn't appear, click **Scan Again** to trigger an immediate scan, or enter the IP address manually and click **Add Device**.
|
||||
|
||||

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

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

|
||||
|
||||
Two redirect methods are available:
|
||||
|
||||
### XML redirect (recommended for first-time / testing)
|
||||
|
||||
Uploads a configuration file to the speaker via the SoundTouch Web API. This changes the application-level service URLs without touching the speaker's network configuration. It's the least invasive option.
|
||||
|
||||
The web UI guides you through:
|
||||
1. Previewing the config change (current vs. planned XML)
|
||||
2. Optionally installing the AfterTouch CA certificate on the speaker (requires SSH; needed for HTTPS)
|
||||
3. Applying the XML redirect
|
||||
4. Verifying the speaker can reach the local service
|
||||
|
||||
### DNS/DHCP redirect (recommended for permanent / all-device setup)
|
||||
|
||||
Configures the speaker to use a custom DNS server that resolves Bose cloud hostnames to the local service. This is the most robust method — it covers all Bose endpoints automatically and survives reboots.
|
||||
|
||||
Requirements:
|
||||
- The AfterTouch DNS server must be running and bound to **port 53** on your network. Enable it in the **Settings** tab (`DNS Discovery` → enabled).
|
||||
- HTTPS is required. The web UI walks you through trusting the CA certificate on the speaker (via SSH).
|
||||
|
||||
The web UI guides you through:
|
||||
1. Verifying the DNS server is running and reachable
|
||||
2. Installing the CA certificate on the speaker
|
||||
3. Configuring the speaker to use the AfterTouch DNS server
|
||||
4. Verifying DNS resolution and HTTPS connectivity
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Reboot and verify
|
||||
|
||||
After migration, **power-cycle the speaker** (unplug and replug). This applies all configuration changes.
|
||||
|
||||
After reboot:
|
||||
- The speaker should appear as **migrated** in the Devices tab
|
||||
- Presets should load and play (served from the local service)
|
||||
- TuneIn browsing should work
|
||||
- Recently played items should appear
|
||||
|
||||
If something doesn't work, check the **Interactions** tab in the web UI for failed requests, and the **Troubleshooting** section in the [SoundTouch Service Guide](SOUNDTOUCH-SERVICE.md).
|
||||
|
||||
---
|
||||
|
||||
## Repeat for each speaker
|
||||
|
||||
Each speaker is migrated independently. You can run multiple migrations in parallel, but migrating one at a time makes it easier to diagnose issues.
|
||||
|
||||
---
|
||||
|
||||
## Rollback
|
||||
|
||||
If you need to undo a migration:
|
||||
|
||||
- **From the web UI**: Use the **Revert** action on the device — this restores the `.original` backup files created on the speaker during migration.
|
||||
- **Via SSH**: The original config is backed up on the speaker with a `.original` suffix. Restore it manually if the UI is unreachable.
|
||||
- **Factory reset**: As a last resort, perform a factory reset (see [Device Initial Setup](DEVICE-INITIAL-SETUP.md) for button sequences). This wipes all configuration and returns the speaker to out-of-box state.
|
||||
|
||||
---
|
||||
|
||||
## Post-migration
|
||||
|
||||
Once all speakers are migrated, the `data/` directory is the source of truth for your presets, recents, and device state. Back it up periodically. The web UI at `http://<server>:8000` is your management interface from this point on.
|
||||
|
||||
For the Bose cloud backup you created in Step 4, keep the `.tar.gz` archive in case you need to restore credentials or presets later.
|
||||
@@ -1,4 +1,4 @@
|
||||
### Professional Migration & Safety Guide
|
||||
# Migration & Safety Guide
|
||||
|
||||
Starting a migration on real hardware requires a "Safety First" approach. This guide outlines the safety features implemented in the `soundtouch-service` and provides a checklist for a successful migration.
|
||||
|
||||
@@ -14,8 +14,8 @@ The following features are built into the `soundtouch-service` to ensure stabili
|
||||
|
||||
Before you proceed with the actual migration, follow these steps:
|
||||
|
||||
1. **Enable SSH Access (Prerequisite)**: This toolkit requires SSH access to your speakers, which is not enabled by default.
|
||||
- Create an empty file named `remote_services` on a USB stick.
|
||||
1. **Enable SSH Access (Prerequisite)**: This toolkit requires SSH access to your speakers, which is not enabled by default.
|
||||
- Create a file named `remote_services` on a FAT-formatted USB drive. The drive may need its bootable flag set — see [SoundCork issue #172](https://github.com/deborahgu/soundcork/issues/172) for details.
|
||||
- Insert the USB stick into the SoundTouch speaker's **SERVICE** port.
|
||||
- Reboot the speaker (unplug and replug).
|
||||
- The speaker will now allow SSH connections as `root` with no password.
|
||||
@@ -27,10 +27,11 @@ Before you proceed with the actual migration, follow these steps:
|
||||
4. **Validate SSH Access**: Confirm the device responds to SSH without a password.
|
||||
- In the Web UI **Migration** tab, select your speaker and verify that the "SSH Connection" status shows ✅ Success.
|
||||
- This toolkit automatically handles the necessary SSH parameters (ciphers and key exchanges) required by older Bose firmware.
|
||||
5. **Migration Methods**:
|
||||
- **XML Migration (Default)**: Less invasive, only changes the application config. Best for simple redirection.
|
||||
- **Hosts Migration**: Modifies `/etc/hosts` on the device. Good for system-wide redirection of specific domains.
|
||||
- **ResolvConf Migration**: Points the device to the AfterTouch DNS server. Best for discovering unknown Bose endpoints and dynamic interception. **Note**: This method requires the DNS Discovery Server to be running on port 53. The service includes a pre-flight check to ensure the server is properly bound before allowing this migration.
|
||||
5. **Migration Methods**:
|
||||
- **XML redirect (default)**: Uploads a config file to the speaker via the Web API. Less invasive — only changes the application-level service URLs. Best for testing or single-device migration.
|
||||
- **DNS/DHCP redirect**: Configures the speaker to use a custom DNS server that resolves Bose hostnames to the local service. Best for all-device coverage; requires the AfterTouch DNS server running on port 53. The service includes a pre-flight check before applying this method.
|
||||
|
||||
The web UI walks you through both methods. Both require the CA certificate to be trusted on the speaker for HTTPS to work — the web UI handles this as part of the migration flow.
|
||||
6. **Monitor Logs**: Run the `soundtouch-service` with `DEBUG` or `INFO` logging to see the step-by-step progress of the migration.
|
||||
|
||||
#### 🔄 Rollback Strategy
|
||||
|
||||
@@ -0,0 +1,764 @@
|
||||
# MQTT Integration Design for SoundTouch Service
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the design for integrating MQTT support into the existing SoundTouch service to simulate AWS IoT Core functionality. The integration will provide real-time device communication, shadow state management, and prepare for the AWS IoT service shutdown in May 2026.
|
||||
|
||||
## Current Architecture Analysis
|
||||
|
||||
### Existing Service Structure
|
||||
```
|
||||
Bose-SoundTouch/
|
||||
├── cmd/soundtouch-service/main.go # Main service entry point
|
||||
├── pkg/
|
||||
│ ├── client/ # HTTP client for devices
|
||||
│ ├── config/ # Configuration management
|
||||
│ ├── discovery/ # Device discovery (UPnP, mDNS)
|
||||
│ ├── models/ # Data structures
|
||||
│ └── service/
|
||||
│ ├── handlers/ # HTTP request handlers
|
||||
│ │ └── server.go # Main server struct
|
||||
│ ├── datastore/ # Data persistence
|
||||
│ ├── proxy/ # HTTP proxying
|
||||
│ └── [other services]
|
||||
```
|
||||
|
||||
### Key Components
|
||||
- **Server Struct**: Central HTTP handler in `pkg/service/handlers/server.go`
|
||||
- **Discovery Service**: UPnP/mDNS device discovery in `pkg/discovery/`
|
||||
- **DataStore**: Device state persistence in `pkg/service/datastore/`
|
||||
- **Device Models**: Data structures in `pkg/models/`
|
||||
|
||||
## MQTT Integration Design
|
||||
|
||||
### 1. New Package Structure
|
||||
|
||||
```
|
||||
pkg/service/mqtt/
|
||||
├── broker.go # MQTT broker implementation
|
||||
├── shadow.go # AWS IoT Shadow simulation
|
||||
├── auth.go # Certificate-based authentication
|
||||
├── topics.go # Topic routing and handlers
|
||||
├── bridge.go # HTTP ↔ MQTT state bridging
|
||||
├── config.go # MQTT configuration
|
||||
└── client.go # MQTT client utilities
|
||||
```
|
||||
|
||||
### 2. Core Components
|
||||
|
||||
#### A. MQTT Broker (`pkg/service/mqtt/broker.go`)
|
||||
```go
|
||||
package mqtt
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/mochi-co/mqtt/v2"
|
||||
"github.com/mochi-co/mqtt/v2/hooks/auth"
|
||||
"github.com/mochi-co/mqtt/v2/listeners"
|
||||
)
|
||||
|
||||
type Broker struct {
|
||||
server *mqtt.Server
|
||||
shadowStore *ShadowStore
|
||||
bridge *HTTPBridge
|
||||
authHook *AuthHook
|
||||
config *Config
|
||||
running bool
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Port int `json:"port"`
|
||||
TLSEnabled bool `json:"tls_enabled"`
|
||||
CertFile string `json:"cert_file"`
|
||||
KeyFile string `json:"key_file"`
|
||||
DeviceCertPath string `json:"device_cert_path"`
|
||||
ShadowPersist bool `json:"shadow_persist"`
|
||||
}
|
||||
|
||||
func NewBroker(config *Config) (*Broker, error) {
|
||||
server := mqtt.New(nil)
|
||||
|
||||
shadowStore := NewShadowStore()
|
||||
authHook := NewAuthHook(config.DeviceCertPath)
|
||||
|
||||
return &Broker{
|
||||
server: server,
|
||||
shadowStore: shadowStore,
|
||||
authHook: authHook,
|
||||
config: config,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *Broker) Start() error {
|
||||
// Add TLS listener
|
||||
tlsConfig := &tls.Config{
|
||||
Certificates: []tls.Certificate{b.loadServerCert()},
|
||||
ClientAuth: tls.RequireAndVerifyClientCert,
|
||||
ClientCAs: b.loadDeviceCAs(),
|
||||
}
|
||||
|
||||
tcp := listeners.NewTCP("mqtt-tls", fmt.Sprintf(":%d", b.config.Port), &listeners.Config{
|
||||
TLSConfig: tlsConfig,
|
||||
})
|
||||
|
||||
b.server.AddListener(tcp)
|
||||
|
||||
// Add hooks
|
||||
b.server.AddHook(b.authHook, nil)
|
||||
b.server.AddHook(NewShadowHook(b.shadowStore), nil)
|
||||
|
||||
return b.server.Serve()
|
||||
}
|
||||
```
|
||||
|
||||
#### B. Shadow State Management (`pkg/service/mqtt/shadow.go`)
|
||||
```go
|
||||
package mqtt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ShadowStore struct {
|
||||
shadows map[string]*DeviceShadow
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type DeviceShadow struct {
|
||||
State struct {
|
||||
Desired map[string]interface{} `json:"desired"`
|
||||
Reported map[string]interface{} `json:"reported"`
|
||||
Delta map[string]interface{} `json:"delta,omitempty"`
|
||||
} `json:"state"`
|
||||
Version int `json:"version"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
ClientToken string `json:"clientToken,omitempty"`
|
||||
}
|
||||
|
||||
func NewShadowStore() *ShadowStore {
|
||||
return &ShadowStore{
|
||||
shadows: make(map[string]*DeviceShadow),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ShadowStore) UpdateShadow(clientID string, payload []byte) (*DeviceShadow, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
var update DeviceShadow
|
||||
if err := json.Unmarshal(payload, &update); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
shadow := s.shadows[clientID]
|
||||
if shadow == nil {
|
||||
shadow = &DeviceShadow{
|
||||
State: struct {
|
||||
Desired map[string]interface{} `json:"desired"`
|
||||
Reported map[string]interface{} `json:"reported"`
|
||||
Delta map[string]interface{} `json:"delta,omitempty"`
|
||||
}{
|
||||
Desired: make(map[string]interface{}),
|
||||
Reported: make(map[string]interface{}),
|
||||
Delta: make(map[string]interface{}),
|
||||
},
|
||||
}
|
||||
s.shadows[clientID] = shadow
|
||||
}
|
||||
|
||||
// Update reported state
|
||||
if update.State.Reported != nil {
|
||||
for key, value := range update.State.Reported {
|
||||
shadow.State.Reported[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
// Update desired state
|
||||
if update.State.Desired != nil {
|
||||
for key, value := range update.State.Desired {
|
||||
shadow.State.Desired[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate delta
|
||||
shadow.calculateDelta()
|
||||
shadow.Version++
|
||||
shadow.Timestamp = time.Now().Unix()
|
||||
shadow.ClientToken = update.ClientToken
|
||||
|
||||
return shadow, nil
|
||||
}
|
||||
|
||||
func (s *DeviceShadow) calculateDelta() {
|
||||
s.State.Delta = make(map[string]interface{})
|
||||
|
||||
for key, desired := range s.State.Desired {
|
||||
if reported, exists := s.State.Reported[key]; !exists || reported != desired {
|
||||
s.State.Delta[key] = desired
|
||||
}
|
||||
}
|
||||
|
||||
if len(s.State.Delta) == 0 {
|
||||
s.State.Delta = nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### C. HTTP ↔ MQTT Bridge (`pkg/service/mqtt/bridge.go`)
|
||||
```go
|
||||
package mqtt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
type HTTPBridge struct {
|
||||
shadowStore *ShadowStore
|
||||
dataStore *datastore.DataStore
|
||||
deviceMap map[string]string // clientID -> deviceID mapping
|
||||
}
|
||||
|
||||
func NewHTTPBridge(shadowStore *ShadowStore, dataStore *datastore.DataStore) *HTTPBridge {
|
||||
return &HTTPBridge{
|
||||
shadowStore: shadowStore,
|
||||
dataStore: dataStore,
|
||||
deviceMap: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// ShadowToHTTP converts MQTT shadow updates to HTTP API calls
|
||||
func (b *HTTPBridge) ShadowToHTTP(clientID string, shadow *DeviceShadow) error {
|
||||
deviceID, exists := b.deviceMap[clientID]
|
||||
if !exists {
|
||||
log.Printf("Unknown device clientID: %s", clientID)
|
||||
return fmt.Errorf("unknown device: %s", clientID)
|
||||
}
|
||||
|
||||
// Handle power state changes
|
||||
if powerState, ok := shadow.State.Reported["powerState"].(string); ok {
|
||||
if err := b.updateDevicePower(deviceID, powerState == "ON"); err != nil {
|
||||
return fmt.Errorf("power update failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle volume changes
|
||||
if volume, ok := shadow.State.Reported["volume"].(float64); ok {
|
||||
if err := b.updateDeviceVolume(deviceID, int(volume)); err != nil {
|
||||
return fmt.Errorf("volume update failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle source changes
|
||||
if source, ok := shadow.State.Reported["source"].(string); ok {
|
||||
if err := b.updateDeviceSource(deviceID, source); err != nil {
|
||||
return fmt.Errorf("source update failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HTTPToShadow converts HTTP device state to MQTT shadow updates
|
||||
func (b *HTTPBridge) HTTPToShadow(deviceID string, deviceInfo *models.DeviceInfo) error {
|
||||
clientID, exists := b.getClientIDForDevice(deviceID)
|
||||
if !exists {
|
||||
return nil // Device not connected via MQTT
|
||||
}
|
||||
|
||||
// Create shadow state from device info
|
||||
shadowState := map[string]interface{}{
|
||||
"deviceState": "CONNECTED",
|
||||
"deviceID": deviceInfo.DeviceID,
|
||||
"name": deviceInfo.Name,
|
||||
"type": deviceInfo.Type,
|
||||
}
|
||||
|
||||
// Add additional state if available
|
||||
if status := b.getDeviceStatus(deviceID); status != nil {
|
||||
shadowState["powerState"] = status.PowerState
|
||||
shadowState["volume"] = status.Volume
|
||||
shadowState["source"] = status.Source
|
||||
}
|
||||
|
||||
// Update shadow
|
||||
shadowUpdate := DeviceShadow{
|
||||
State: struct {
|
||||
Desired map[string]interface{} `json:"desired"`
|
||||
Reported map[string]interface{} `json:"reported"`
|
||||
Delta map[string]interface{} `json:"delta,omitempty"`
|
||||
}{
|
||||
Reported: shadowState,
|
||||
},
|
||||
}
|
||||
|
||||
payload, _ := json.Marshal(shadowUpdate)
|
||||
_, err := b.shadowStore.UpdateShadow(clientID, payload)
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Integration with Existing Server
|
||||
|
||||
#### A. Extend Server Struct (`pkg/service/handlers/server.go`)
|
||||
```go
|
||||
// Add to existing Server struct
|
||||
type Server struct {
|
||||
// ... existing fields ...
|
||||
|
||||
// New MQTT fields
|
||||
mqttBroker *mqtt.Broker
|
||||
mqttEnabled bool
|
||||
mqttConfig *mqtt.Config
|
||||
deviceClientIDs map[string]string // deviceID -> clientID mapping
|
||||
}
|
||||
|
||||
// New initialization method
|
||||
func (s *Server) initMQTTBroker(config *mqtt.Config) error {
|
||||
if !config.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
broker, err := mqtt.NewBroker(config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create MQTT broker: %w", err)
|
||||
}
|
||||
|
||||
// Set up HTTP ↔ MQTT bridge
|
||||
bridge := mqtt.NewHTTPBridge(broker.ShadowStore(), s.ds)
|
||||
broker.SetBridge(bridge)
|
||||
|
||||
s.mqttBroker = broker
|
||||
s.mqttEnabled = true
|
||||
s.mqttConfig = config
|
||||
s.deviceClientIDs = make(map[string]string)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start MQTT broker alongside HTTP server
|
||||
func (s *Server) StartMQTT() error {
|
||||
if !s.mqttEnabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := s.mqttBroker.Start(); err != nil {
|
||||
log.Printf("MQTT broker error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
#### B. Configuration Integration (`cmd/soundtouch-service/main.go`)
|
||||
```go
|
||||
// Add to serviceConfig struct
|
||||
type serviceConfig struct {
|
||||
// ... existing fields ...
|
||||
|
||||
// New MQTT configuration fields
|
||||
mqttEnabled bool `mapstructure:"mqtt_enabled"`
|
||||
mqttPort int `mapstructure:"mqtt_port"`
|
||||
mqttTLSCert string `mapstructure:"mqtt_tls_cert"`
|
||||
mqttTLSKey string `mapstructure:"mqtt_tls_key"`
|
||||
mqttDeviceCertPath string `mapstructure:"mqtt_device_cert_path"`
|
||||
mqttShadowPersist bool `mapstructure:"mqtt_shadow_persist"`
|
||||
}
|
||||
|
||||
// Update main function to initialize MQTT
|
||||
func main() {
|
||||
// ... existing initialization ...
|
||||
|
||||
// Initialize MQTT if enabled
|
||||
if cfg.mqttEnabled {
|
||||
mqttConfig := &mqtt.Config{
|
||||
Enabled: cfg.mqttEnabled,
|
||||
Port: cfg.mqttPort,
|
||||
TLSEnabled: true,
|
||||
CertFile: cfg.mqttTLSCert,
|
||||
KeyFile: cfg.mqttTLSKey,
|
||||
DeviceCertPath: cfg.mqttDeviceCertPath,
|
||||
ShadowPersist: cfg.mqttShadowPersist,
|
||||
}
|
||||
|
||||
if err := server.InitMQTTBroker(mqttConfig); err != nil {
|
||||
log.Fatalf("Failed to initialize MQTT broker: %v", err)
|
||||
}
|
||||
|
||||
if err := server.StartMQTT(); err != nil {
|
||||
log.Fatalf("Failed to start MQTT broker: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("MQTT broker started on port %d", cfg.mqttPort)
|
||||
}
|
||||
|
||||
// ... rest of existing main function ...
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Enhanced Device Discovery
|
||||
|
||||
#### A. MQTT Device Discovery (`pkg/service/mqtt/discovery.go`)
|
||||
```go
|
||||
package mqtt
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/mochi-co/mqtt/v2/packets"
|
||||
)
|
||||
|
||||
type DeviceDiscoveryHook struct {
|
||||
deviceRegistry map[string]*models.Device
|
||||
onDeviceFound func(*models.Device)
|
||||
}
|
||||
|
||||
func NewDeviceDiscoveryHook() *DeviceDiscoveryHook {
|
||||
return &DeviceDiscoveryHook{
|
||||
deviceRegistry: make(map[string]*models.Device),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DeviceDiscoveryHook) ID() string {
|
||||
return "device-discovery"
|
||||
}
|
||||
|
||||
func (h *DeviceDiscoveryHook) OnConnect(cl *packets.Client, pk packets.Packet) error {
|
||||
clientID := pk.Connect.ClientIdentifier
|
||||
|
||||
log.Printf("MQTT device connected: %s", clientID)
|
||||
|
||||
// Create device entry
|
||||
device := &models.Device{
|
||||
ID: clientID,
|
||||
ClientID: clientID,
|
||||
Name: "MQTT Device",
|
||||
LastSeen: time.Now(),
|
||||
MQTTOnline: true,
|
||||
Source: "mqtt",
|
||||
}
|
||||
|
||||
h.deviceRegistry[clientID] = device
|
||||
|
||||
if h.onDeviceFound != nil {
|
||||
h.onDeviceFound(device)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *DeviceDiscoveryHook) OnDisconnect(cl *packets.Client, err error) {
|
||||
clientID := cl.ID
|
||||
|
||||
log.Printf("MQTT device disconnected: %s", clientID)
|
||||
|
||||
if device, exists := h.deviceRegistry[clientID]; exists {
|
||||
device.MQTTOnline = false
|
||||
device.LastSeen = time.Now()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### B. Integration with Existing Discovery (`pkg/discovery/mqtt.go`)
|
||||
```go
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
type MQTTDiscovery struct {
|
||||
deviceRegistry map[string]*models.Device
|
||||
enabled bool
|
||||
}
|
||||
|
||||
func NewMQTTDiscovery() *MQTTDiscovery {
|
||||
return &MQTTDiscovery{
|
||||
deviceRegistry: make(map[string]*models.Device),
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *MQTTDiscovery) DiscoverDevices(ctx context.Context, timeout time.Duration) ([]*models.Device, error) {
|
||||
if !d.enabled {
|
||||
return []*models.Device{}, nil
|
||||
}
|
||||
|
||||
var devices []*models.Device
|
||||
for _, device := range d.deviceRegistry {
|
||||
if device.MQTTOnline {
|
||||
devices = append(devices, device)
|
||||
}
|
||||
}
|
||||
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
func (d *MQTTDiscovery) AddDevice(device *models.Device) {
|
||||
d.deviceRegistry[device.ClientID] = device
|
||||
}
|
||||
|
||||
func (d *MQTTDiscovery) RemoveDevice(clientID string) {
|
||||
delete(d.deviceRegistry, clientID)
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Configuration File Extensions
|
||||
|
||||
#### A. Default Configuration (`config.yaml`)
|
||||
```yaml
|
||||
# Existing configuration...
|
||||
|
||||
# MQTT Configuration
|
||||
mqtt:
|
||||
enabled: false
|
||||
port: 8883
|
||||
tls:
|
||||
cert_file: "/etc/ssl/certs/soundtouch-mqtt.crt"
|
||||
key_file: "/etc/ssl/private/soundtouch-mqtt.key"
|
||||
|
||||
# Device certificate validation
|
||||
device_certs:
|
||||
path: "/etc/soundtouch/device-certs"
|
||||
auto_load: true
|
||||
|
||||
# Shadow state management
|
||||
shadow:
|
||||
persist: true
|
||||
ttl: 86400 # 24 hours
|
||||
|
||||
# Bridge configuration
|
||||
bridge:
|
||||
enabled: true
|
||||
sync_interval: 30s
|
||||
```
|
||||
|
||||
#### B. Environment Variable Support
|
||||
```bash
|
||||
# MQTT configuration via environment variables
|
||||
SOUNDTOUCH_MQTT_ENABLED=true
|
||||
SOUNDTOUCH_MQTT_PORT=8883
|
||||
SOUNDTOUCH_MQTT_TLS_CERT=/path/to/cert.pem
|
||||
SOUNDTOUCH_MQTT_TLS_KEY=/path/to/key.pem
|
||||
SOUNDTOUCH_MQTT_DEVICE_CERT_PATH=/path/to/device/certs
|
||||
SOUNDTOUCH_MQTT_SHADOW_PERSIST=true
|
||||
```
|
||||
|
||||
### 6. API Extensions
|
||||
|
||||
#### A. MQTT Status Endpoints
|
||||
```go
|
||||
// Add to handlers
|
||||
func (s *Server) handleMQTTStatus(c *gin.Context) {
|
||||
if !s.mqttEnabled {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{
|
||||
"error": "MQTT not enabled",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
status := gin.H{
|
||||
"enabled": s.mqttEnabled,
|
||||
"port": s.mqttConfig.Port,
|
||||
"connected_devices": len(s.deviceClientIDs),
|
||||
"shadow_count": s.mqttBroker.ShadowStore().Count(),
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, status)
|
||||
}
|
||||
|
||||
// Device shadow endpoint
|
||||
func (s *Server) handleDeviceShadow(c *gin.Context) {
|
||||
deviceID := c.Param("deviceId")
|
||||
clientID, exists := s.deviceClientIDs[deviceID]
|
||||
if !exists {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Device not connected via MQTT",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
shadow := s.mqttBroker.ShadowStore().GetShadow(clientID)
|
||||
if shadow == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Shadow not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, shadow)
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Testing Strategy
|
||||
|
||||
#### A. Unit Tests
|
||||
```go
|
||||
// pkg/service/mqtt/shadow_test.go
|
||||
func TestShadowStore_UpdateShadow(t *testing.T) {
|
||||
store := NewShadowStore()
|
||||
|
||||
payload := []byte(`{
|
||||
"state": {
|
||||
"reported": {
|
||||
"powerState": "ON",
|
||||
"volume": 25
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
shadow, err := store.UpdateShadow("test-client", payload)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "ON", shadow.State.Reported["powerState"])
|
||||
assert.Equal(t, 25.0, shadow.State.Reported["volume"])
|
||||
assert.Equal(t, 1, shadow.Version)
|
||||
}
|
||||
```
|
||||
|
||||
#### B. Integration Tests
|
||||
```go
|
||||
// pkg/service/mqtt/integration_test.go
|
||||
func TestMQTTBrokerIntegration(t *testing.T) {
|
||||
// Start test broker
|
||||
broker := setupTestBroker(t)
|
||||
go broker.Start()
|
||||
defer broker.Stop()
|
||||
|
||||
// Connect test client
|
||||
client := mqtt.NewClient(mqtt.NewClientOptions().
|
||||
AddBroker("tls://localhost:8883").
|
||||
SetClientID("test-device"))
|
||||
|
||||
// Test shadow operations
|
||||
testShadowUpdate(t, client)
|
||||
testShadowGet(t, client)
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Migration Path
|
||||
|
||||
#### A. Gradual Rollout
|
||||
1. **Phase 1**: Deploy MQTT broker alongside existing HTTP service (disabled by default)
|
||||
2. **Phase 2**: Enable MQTT for testing with specific devices
|
||||
3. **Phase 3**: Enable bidirectional HTTP ↔ MQTT bridging
|
||||
4. **Phase 4**: Full MQTT support for all discovered devices
|
||||
5. **Phase 5**: Prepare for AWS IoT shutdown (May 2026)
|
||||
|
||||
#### B. Backward Compatibility
|
||||
- All existing HTTP API endpoints continue to work
|
||||
- MQTT is purely additive functionality
|
||||
- Devices can be discovered via HTTP even with MQTT enabled
|
||||
- Configuration remains optional
|
||||
|
||||
### 9. Monitoring and Logging
|
||||
|
||||
#### A. MQTT Metrics
|
||||
```go
|
||||
type MQTTMetrics struct {
|
||||
ConnectedDevices int64
|
||||
MessagesReceived int64
|
||||
MessagesSent int64
|
||||
ShadowUpdates int64
|
||||
AuthenticationFails int64
|
||||
Uptime time.Duration
|
||||
}
|
||||
|
||||
func (b *Broker) GetMetrics() *MQTTMetrics {
|
||||
return &MQTTMetrics{
|
||||
ConnectedDevices: int64(len(b.server.Clients)),
|
||||
MessagesReceived: b.server.Stats.MessagesReceived,
|
||||
MessagesSent: b.server.Stats.MessagesSent,
|
||||
ShadowUpdates: b.shadowStore.UpdateCount(),
|
||||
AuthenticationFails: b.authHook.FailCount(),
|
||||
Uptime: time.Since(b.startTime),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### B. Logging Integration
|
||||
```go
|
||||
import "github.com/sirupsen/logrus"
|
||||
|
||||
func (b *Broker) setupLogging() {
|
||||
log := logrus.WithFields(logrus.Fields{
|
||||
"component": "mqtt-broker",
|
||||
"port": b.config.Port,
|
||||
})
|
||||
|
||||
b.server.AddHook(&LoggingHook{logger: log}, nil)
|
||||
}
|
||||
```
|
||||
|
||||
### 10. Security Considerations
|
||||
|
||||
#### A. Certificate Validation
|
||||
- Validate device certificates against known device list
|
||||
- Implement certificate revocation checking
|
||||
- Support certificate rotation
|
||||
|
||||
#### B. Access Control
|
||||
- Restrict topic access per device certificate
|
||||
- Implement rate limiting per client
|
||||
- Monitor for unusual connection patterns
|
||||
|
||||
#### C. Data Protection
|
||||
- Encrypt shadow data at rest
|
||||
- Implement secure certificate storage
|
||||
- Audit logging for security events
|
||||
|
||||
## Implementation Timeline
|
||||
|
||||
### Week 1: Core Infrastructure
|
||||
- [ ] Create MQTT package structure
|
||||
- [ ] Implement basic MQTT broker
|
||||
- [ ] Add TLS configuration
|
||||
- [ ] Basic shadow state management
|
||||
|
||||
### Week 2: Integration & Bridging
|
||||
- [ ] Integrate with existing Server struct
|
||||
- [ ] Implement HTTP ↔ MQTT bridge
|
||||
- [ ] Device discovery integration
|
||||
- [ ] Configuration management
|
||||
|
||||
### Week 3: Testing & Polish
|
||||
- [ ] Unit test coverage
|
||||
- [ ] Integration testing
|
||||
- [ ] Documentation updates
|
||||
- [ ] Performance optimization
|
||||
|
||||
### Week 4: Deployment & Monitoring
|
||||
- [ ] Docker container updates
|
||||
- [ ] Monitoring and metrics
|
||||
- [ ] Security hardening
|
||||
- [ ] Production readiness
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. **Functional**: MQTT broker accepts device connections using extracted certificates
|
||||
2. **Compatible**: All existing HTTP functionality continues to work unchanged
|
||||
3. **Performant**: MQTT operations don't impact HTTP API performance
|
||||
4. **Secure**: Device authentication and authorization properly implemented
|
||||
5. **Observable**: Comprehensive logging and metrics for MQTT operations
|
||||
6. **Maintainable**: Clean separation of MQTT code from existing HTTP logic
|
||||
|
||||
This design provides a comprehensive path to add MQTT support while maintaining the existing architecture and ensuring smooth integration with current functionality.
|
||||
@@ -0,0 +1,130 @@
|
||||
# Connecting Music Services (Spotify & Amazon Music)
|
||||
|
||||
This guide explains how to link your Spotify or Amazon Music account to AfterTouch so your speakers can stream music from those services.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
Connecting a music service happens in three separate steps, each done once:
|
||||
|
||||
1. **Register a developer app** with Spotify or Amazon (one-time setup by the person running AfterTouch).
|
||||
2. **Authorize your personal account** so AfterTouch can access your music library.
|
||||
3. **Prime your speaker** so the speaker itself learns about the source.
|
||||
|
||||
Each step is described below. If someone else is hosting AfterTouch for you, step 1 may already be done — ask them.
|
||||
|
||||
---
|
||||
|
||||
## Who registers the developer app?
|
||||
|
||||
The developer app is what allows AfterTouch to talk to Spotify's or Amazon's servers on behalf of users. It requires registering an account on a developer portal.
|
||||
|
||||
**If you run AfterTouch for yourself only:** You register one app and use it yourself.
|
||||
|
||||
**If you run AfterTouch for a group** (e.g., your household): You register one app, configure it in AfterTouch, and everyone who uses your AfterTouch instance shares it. They never see your app credentials — those stay on your server. However, they do need to trust you, since their music account tokens are stored by your AfterTouch installation.
|
||||
|
||||
**If you don't trust the AfterTouch operator:** Run your own AfterTouch instance and register your own app. That way everything stays under your control.
|
||||
|
||||
---
|
||||
|
||||
## Spotify
|
||||
|
||||
### Step 1: Register a Spotify developer app
|
||||
|
||||
1. Go to [developer.spotify.com/dashboard](https://developer.spotify.com/dashboard) and log in with your Spotify account.
|
||||
2. Click **Create app**.
|
||||
3. Give it any name and description (e.g., "My AfterTouch").
|
||||
4. Under **Redirect URIs**, add:
|
||||
```
|
||||
http://<your-aftertouch-ip>:8000/mgmt/spotify/callback
|
||||
```
|
||||
Replace `<your-aftertouch-ip>:8000` with the address of your AfterTouch server.
|
||||
5. Save the app.
|
||||
6. Open the app's settings and note down the **Client ID** and **Client Secret**.
|
||||
|
||||
### Step 2: Enter credentials in AfterTouch
|
||||
|
||||
1. Open the AfterTouch web interface and go to the **Settings** tab.
|
||||
2. Scroll to **Spotify Integration**.
|
||||
3. Enter your **Client ID**, **Client Secret**, and the **Redirect URI** you registered above.
|
||||
4. Click **Save Settings**.
|
||||
|
||||
The status should change to **Active**.
|
||||
|
||||
### Step 3: Authorize your Spotify account
|
||||
|
||||
1. Go to the **Local Account** tab (tab 7).
|
||||
2. Click **Connect Spotify to this Account**.
|
||||
3. A Spotify login window opens. Log in and grant permission.
|
||||
4. When the window closes, your account is linked. You should see your Spotify username appear.
|
||||
|
||||
### Step 4: Prime your speaker
|
||||
|
||||
After authorizing, each speaker needs to be told about the Spotify source.
|
||||
|
||||
1. Go to the **Devices** tab (tab 2).
|
||||
2. Find your speaker and click **Prime Spotify**.
|
||||
3. The speaker will now show Spotify as an available source.
|
||||
|
||||
Repeat step 4 for each speaker.
|
||||
|
||||
---
|
||||
|
||||
## Amazon Music
|
||||
|
||||
> **Current status: account linking works, streaming does not.**
|
||||
>
|
||||
> The OAuth flow and token storage are fully functional. However, the speaker's `AmazonClient` contacts `music-api.amazon.com` directly with the access token and receives a 401. Amazon Music's streaming API requires scopes that are only available to registered Amazon Music partners — a standard Login with Amazon app does not qualify. The infrastructure is in place and will work if those scopes ever become available, but following these steps will not result in working Amazon Music playback today.
|
||||
|
||||
### Step 1: Register an Amazon developer app (LWA)
|
||||
|
||||
1. Go to [developer.amazon.com/loginwithamazon/console/site/lwa/overview.html](https://developer.amazon.com/loginwithamazon/console/site/lwa/overview.html) and log in with your Amazon account.
|
||||
2. Click **Create a New Security Profile**.
|
||||
3. Give it any name and description (e.g., "My AfterTouch").
|
||||
4. In the security profile's **Web Settings**, add under **Allowed Return URLs**:
|
||||
```
|
||||
http://<your-aftertouch-ip>:8000/mgmt/amazon/callback
|
||||
```
|
||||
Replace `<your-aftertouch-ip>:8000` with the address of your AfterTouch server.
|
||||
5. Save and note down the **Client ID** and **Client Secret**.
|
||||
|
||||
### Step 2: Enter credentials in AfterTouch
|
||||
|
||||
1. Open the AfterTouch web interface and go to the **Settings** tab.
|
||||
2. Scroll to **Amazon Music Integration**.
|
||||
3. Enter your **Client ID**, **Client Secret**, and the **Redirect URI** you registered above.
|
||||
4. Click **Save Settings**.
|
||||
|
||||
The status should change to **Active**.
|
||||
|
||||
### Step 3: Authorize your Amazon account
|
||||
|
||||
1. Go to the **Local Account** tab (tab 7).
|
||||
2. Click **Connect Amazon Music to this Account**.
|
||||
3. An Amazon login window opens. Log in and grant permission.
|
||||
4. When the window closes, your account is linked.
|
||||
|
||||
### Step 4: Prime your speaker
|
||||
|
||||
1. Go to the **Devices** tab (tab 2).
|
||||
2. Find your speaker and click **Prime Amazon**.
|
||||
3. The speaker will now show Amazon Music as an available source.
|
||||
|
||||
Repeat step 4 for each speaker.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"Failed to initialize" when clicking Connect:**
|
||||
The app credentials in Settings are missing or incorrect. Double-check the Client ID, Client Secret, and Redirect URI. The Redirect URI in AfterTouch must exactly match the one registered in the developer portal.
|
||||
|
||||
**The login window opens but redirects to an error page:**
|
||||
The Redirect URI registered with Spotify/Amazon does not match what AfterTouch is sending. Make sure the address (including the port) is identical in both places.
|
||||
|
||||
**The speaker doesn't show the new source after priming:**
|
||||
Try rebooting the speaker. It may take a minute to update its source list after priming.
|
||||
|
||||
**The login window doesn't open (popup blocked):**
|
||||
Allow popups from the AfterTouch address in your browser settings, then try again. Alternatively, the status message will show a direct link you can click.
|
||||
@@ -0,0 +1,132 @@
|
||||
# Self-Hosting AfterTouch
|
||||
|
||||
This guide walks you through running AfterTouch on your own computer or server. No programming knowledge required.
|
||||
|
||||
---
|
||||
|
||||
## What is self-hosting?
|
||||
|
||||
AfterTouch is software that runs on a computer in your home and takes over the role of Bose's cloud servers. Your speakers talk to it instead of Bose.
|
||||
|
||||
For this to work, the computer running AfterTouch must be:
|
||||
|
||||
- **Always on** (or at least on whenever you want to use your speakers)
|
||||
- **On the same local network** as your speakers
|
||||
- **Reachable by a stable IP address** (see [Stable IP Address](#stable-ip-address) below)
|
||||
|
||||
Good choices: a Raspberry Pi, a NAS (like Synology or QNAP), an always-on PC or Mac, or a small server. A laptop that you close and put away is not ideal.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Get the software
|
||||
|
||||
Go to the [AfterTouch releases page](https://github.com/gesellix/Bose-SoundTouch/releases) and download the latest release for your operating system:
|
||||
|
||||
| Your system | File to download |
|
||||
|-----------------------|------------------------------------------|
|
||||
| Raspberry Pi (64-bit) | `soundtouch-service_linux_arm64.tar.gz` |
|
||||
| Raspberry Pi (32-bit) | `soundtouch-service_linux_arm.tar.gz` |
|
||||
| Linux (64-bit PC) | `soundtouch-service_linux_amd64.tar.gz` |
|
||||
| macOS (Apple Silicon) | `soundtouch-service_darwin_arm64.tar.gz` |
|
||||
| macOS (Intel) | `soundtouch-service_darwin_amd64.tar.gz` |
|
||||
| Windows | `soundtouch-service_windows_amd64.zip` |
|
||||
|
||||
Extract the archive. You will find a single file called `soundtouch-service` (or `soundtouch-service.exe` on Windows).
|
||||
|
||||
### Alternative: Docker
|
||||
|
||||
If you already use Docker, you can run AfterTouch as a container instead. See the [Deployment Guide](DEPLOYMENT.md) for Docker instructions.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Run it
|
||||
|
||||
Open a terminal (or Command Prompt on Windows), navigate to the folder where you extracted the file, and run:
|
||||
|
||||
```
|
||||
./soundtouch-service
|
||||
```
|
||||
|
||||
On Windows:
|
||||
```
|
||||
soundtouch-service.exe
|
||||
```
|
||||
|
||||
You should see log output like:
|
||||
```
|
||||
Starting AfterTouch service on :8000
|
||||
```
|
||||
|
||||
AfterTouch is now running on port 8000.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Open the web interface
|
||||
|
||||
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`.
|
||||
|
||||
If you are on the same computer that is running AfterTouch, you can use `http://localhost:8000`.
|
||||
|
||||
You should see the AfterTouch web interface with tabs: Overview, Settings, Devices, and so on.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Configure the server URL
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
Use the IP address of your server, **not** `localhost`. Your speakers need to reach this address over the network, and they cannot resolve `localhost`.
|
||||
|
||||
Click **Save Settings**.
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Proceed with migration
|
||||
|
||||
You are now ready to migrate your speakers. Follow the main [Migration Guide](MIGRATION-GUIDE.md) for the remaining steps (discovering devices, syncing data, and redirecting your speakers to AfterTouch).
|
||||
|
||||
---
|
||||
|
||||
## Keeping AfterTouch running
|
||||
|
||||
By default, AfterTouch stops when you close the terminal. To keep it running permanently:
|
||||
|
||||
**Raspberry Pi / Linux:** See the [Raspberry Pi Guide](RASPBERRY-PI.md) for instructions on running AfterTouch as a background service using `systemd`.
|
||||
|
||||
**NAS devices:** Most NAS systems support Docker. Use the Docker instructions in the [Deployment Guide](DEPLOYMENT.md).
|
||||
|
||||
**macOS:** You can use `launchd` to run AfterTouch at login. Creating a `launchd` plist is beyond this guide, but the [Deployment Guide](DEPLOYMENT.md) has a systemd example you can adapt.
|
||||
|
||||
**Windows:** You can use Task Scheduler to run AfterTouch at startup.
|
||||
|
||||
---
|
||||
|
||||
## Stable IP address
|
||||
|
||||
AfterTouch must always be reachable at the same address, because your speakers will be configured to point to it. If the IP changes, your speakers will stop working until you reconfigure them.
|
||||
|
||||
The easiest solution is to assign a **static (fixed) IP address** to the computer running AfterTouch in your router's settings. Look for "DHCP reservation" or "static IP" in your router's administration interface, and bind the server's MAC address to a fixed IP.
|
||||
|
||||
---
|
||||
|
||||
## Security note
|
||||
|
||||
AfterTouch's web interface and management API have no login by default. On a typical home network this is fine, since only devices on your local network can reach it.
|
||||
|
||||
If you want to restrict access — for example, on a shared network — start the service with a username and password:
|
||||
|
||||
```
|
||||
./soundtouch-service --mgmt-username admin --mgmt-password yourpassword
|
||||
```
|
||||
|
||||
This protects the Settings tab (where your Spotify and Amazon credentials are stored) from being read or changed by others on the network.
|
||||
@@ -7,7 +7,7 @@ The `soundtouch-service` is a comprehensive local server that emulates Bose's cl
|
||||
The service provides:
|
||||
|
||||
- **🏠 Local Service Emulation**: Complete BMX (Bose Media eXchange) and Marge service implementation
|
||||
- **🔧 Device Migration**: Seamlessly migrate devices from Bose cloud to local services via XML config, `/etc/hosts`, or `/etc/resolv.conf`
|
||||
- **🔧 Device Migration**: Migrate devices from Bose cloud to local services via XML redirect or DNS/DHCP redirect
|
||||
- **🔍 DNS Discovery & Interception**: Built-in DNS server to discover unknown Bose endpoints and selectively intercept cloud traffic
|
||||
- **📊 Traffic Proxying**: Inspect and log all device communications for debugging
|
||||
- **🌐 Web Management UI**: Browser-based interface for device management
|
||||
@@ -26,10 +26,11 @@ The service consists of several key components:
|
||||
|
||||
### BMX Services (Bose Media eXchange)
|
||||
- **TuneIn Integration**: Direct playback of radio stations and podcasts
|
||||
- **Custom Streams**: Flexible playback of any internet radio URL via dynamic proxy
|
||||
- **Service Registry**: Media service discovery and configuration
|
||||
- **Playback Control**: Stream URL resolution and audio metadata
|
||||
|
||||
### Marge Services (Account & Device Management)
|
||||
### Marge Services (Account & Device Management)
|
||||
- **Account Management**: User account simulation and device association
|
||||
- **Preset Synchronization**: Cross-device preset storage and sync
|
||||
- **Recent Items**: Playback history tracking and management
|
||||
@@ -57,7 +58,7 @@ go build -o soundtouch-service ./cmd/soundtouch-service
|
||||
|
||||
### Docker Support
|
||||
|
||||
You can run the SoundTouch service using Docker or Docker Compose.
|
||||
You can run the SoundTouch service using Docker or Docker Compose.
|
||||
|
||||
> **Note for macOS and Windows users**: The `--net host` option is only supported on Linux. On macOS and Windows, service discovery (mDNS, UPnP) will not work automatically within the container. You will need to manually enter your device's IP address in the management UI, and the service will communicate with it directly.
|
||||
|
||||
@@ -115,7 +116,7 @@ volumes:
|
||||
And run:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
@@ -168,7 +169,7 @@ The service supports multiple ways to configure its behavior. When multiple sour
|
||||
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
|
||||
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
|
||||
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
|
||||
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for `resolv.conf` migration) | `:53` |
|
||||
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for DNS/DHCP migration) | `:53` |
|
||||
| `MIRROR_ENABLED` | | Enable background mirroring of specific endpoints to Bose cloud | `false` |
|
||||
| `MIRROR_ENDPOINTS` | | Comma-separated list of path patterns to mirror (e.g., `/streaming/account/*/device/*/recent`) | `[]` |
|
||||
| `INTERNAL_PATHS` | `--internal-paths` | Paths for internal requests to exclude from recording (e.g., `/setup/*`, `/web/*`) | `[]` |
|
||||
@@ -246,7 +247,7 @@ curl "http://192.168.1.100:8090/presets"
|
||||
curl "http://localhost:8000/events/192.168.1.100"
|
||||
```
|
||||
|
||||
#### ResolvConf Migration (DHCP-Aware DNS Redirection)
|
||||
#### DNS/DHCP Migration (DHCP-Aware DNS Redirection)
|
||||
|
||||
The most robust and flexible DNS-based migration method. It utilizes the device's persistent `/mnt/nv/rc.local` script to inject a priority DNS hook into the system's DHCP configuration.
|
||||
|
||||
@@ -393,7 +394,7 @@ Migrates device to use local services.
|
||||
|
||||
**Query Parameters:**
|
||||
- `target_url`: Custom service URL (optional)
|
||||
- `proxy_url`: Proxy URL for fallback (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)
|
||||
@@ -664,7 +665,7 @@ find data/stats/ -name "*.json" -mtime +90 -delete
|
||||
- `GET /setup/discovery-status`: Check if a scan is currently in progress.
|
||||
- `POST /setup/sync/{deviceIP}`: Fetch presets, recents, and sources from a device.
|
||||
- `GET /setup/summary/{deviceIP}`: Get a detailed migration readiness summary.
|
||||
- `POST /setup/migrate/{deviceIP}`: Migrate a device using the specified method (XML/Hosts).
|
||||
- `POST /setup/migrate/{deviceIP}`: Migrate a device using the specified method (XML or DNS).
|
||||
- `GET /setup/ca.crt`: Download the Root CA certificate for manual installation.
|
||||
|
||||
#### `GET /setup/interactions`
|
||||
@@ -764,7 +765,7 @@ ls -la data/events/
|
||||
This service implementation is based on and inspired by several excellent community projects:
|
||||
|
||||
### SoundCork
|
||||
- **Project**: [SoundCork](https://github.com/deborahgu/soundcork)
|
||||
- **Project**: [SoundCork](https://github.com/deborahgu/soundcork)
|
||||
- **Authors**: Deborah Gu and contributors
|
||||
- **Contribution**: The architecture and service emulation approach in this Go implementation is heavily based on SoundCork's pioneering Python implementation. SoundCork provided the foundation for understanding Bose's service architecture and migration strategies.
|
||||
|
||||
@@ -796,7 +797,7 @@ func customBMXHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func main() {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/bmx/custom/endpoint", customBMXHandler)
|
||||
r.Get("/custom/endpoint", customBMXHandler)
|
||||
http.ListenAndServe(":8000", r)
|
||||
}
|
||||
```
|
||||
@@ -809,7 +810,7 @@ soundtouch:
|
||||
- host: 192.168.1.100
|
||||
port: 8090
|
||||
name: "Living Room Speaker"
|
||||
|
||||
|
||||
rest:
|
||||
- resource: "http://localhost:8000/setup/devices"
|
||||
scan_interval: 60
|
||||
|
||||
@@ -1,85 +1,107 @@
|
||||
### Bose Cloud Shutdown: Survival Guide for SoundTouch
|
||||
# Bose Cloud Shutdown: Survival Guide
|
||||
|
||||
With Bose's announcement of discontinuing cloud support for SoundTouch devices in May 2026, this project provides the necessary tools to keep your speakers fully functional using a local emulation service.
|
||||
Bose is shutting down SoundTouch cloud services on **May 6, 2026**. After that date, the following stop working:
|
||||
|
||||
This guide explains how to set up the `soundtouch-service` to run your devices independently of Bose's servers.
|
||||
- Music service browsing (TuneIn, Spotify connect via app, etc.)
|
||||
- Preset and recently-played sync
|
||||
- The official SoundTouch app
|
||||
- Software update checks
|
||||
|
||||
What **continues to work** regardless:
|
||||
- Local playback controls via `soundtouch-cli`, `soundtouch-web`, or any app that uses the local Web API
|
||||
- Bluetooth, AUX, and AirPlay inputs
|
||||
- Multiroom zones (local, peer-to-peer)
|
||||
|
||||
**AfterTouch** — the `soundtouch-service` — restores everything in the first list by running a local replacement for the Bose cloud on your own network.
|
||||
|
||||
---
|
||||
|
||||
### Supported Use Cases
|
||||
## How it works
|
||||
|
||||
1. **Local Service Emulation**: The service emulates Bose's BMX (Bose Media eXchange) and Marge services, which handle content registries, presets, recents, and software update checks.
|
||||
2. **Traffic Redirection**: Tools are provided to redirect your speakers to this local service instead of `*.bose.com`.
|
||||
3. **Offline Operation**: Once redirected, the speakers function without needing to reach Bose's servers.
|
||||
4. **Preset & Recent Management**: Captures and stores presets and "recently played" items locally.
|
||||
The service emulates the Bose cloud endpoints that speakers call for music service browsing, device registration, preset sync, and update checks. Once a speaker is redirected to point at the local service instead of Bose's servers, it operates independently. The built-in web UI at `http://<server>:8000` handles all setup steps.
|
||||
|
||||
---
|
||||
|
||||
### Setup Steps
|
||||
## Prerequisites
|
||||
|
||||
To set up your SoundTouch system for local-only operation, follow these steps:
|
||||
### 1. A machine that's always on
|
||||
|
||||
#### 1. Install and Start the Service
|
||||
Run the `soundtouch-service` on a machine that is always on (like a Raspberry Pi or a NAS) within your local network.
|
||||
The service must run on a host that's available whenever your speakers are in use — a Raspberry Pi, NAS, home server, or similar. The host needs a stable local address (e.g. `soundtouch.fritz.box` or a fixed IP) reachable from your speakers.
|
||||
|
||||
```bash
|
||||
# Install the service
|
||||
go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-service@latest
|
||||
See [Raspberry Pi Setup](RASPBERRY-PI.md) and the [SoundTouch Service Guide](SOUNDTOUCH-SERVICE.md) for deployment options, including Docker.
|
||||
|
||||
# Start the service (defaults to http://localhost:8000)
|
||||
soundtouch-service
|
||||
```
|
||||
### 2. SSH access on your speakers (for migration)
|
||||
|
||||
#### 2. Access the Management UI
|
||||
Open your web browser and navigate to the service's web interface:
|
||||
`http://<your-server-ip>:8000/`, e.g. `http://localhost:8000/`
|
||||
Redirecting a speaker's service URLs requires writing to its configuration. This is done via SSH. Enable it once per device:
|
||||
|
||||
*Note: The service also supports a `/web/` path for management.*
|
||||
1. Create a file named `remote_services` on a FAT-formatted USB drive. The drive may need its bootable flag set — see [SoundCork issue #172](https://github.com/deborahgu/soundcork/issues/172) for details.
|
||||
2. Insert the drive into the speaker's USB port while it's powered on.
|
||||
3. Power-cycle the speaker (unplug and replug). After boot, root SSH is available with no password.
|
||||
|
||||
#### 3. Enable SSH on Your Speakers
|
||||
To migrate your speakers, the service needs SSH access. You can enable it by:
|
||||
1. Creating an empty file named `remote_services` on a USB stick.
|
||||
2. Inserting the USB stick into the SoundTouch speaker's service port.
|
||||
3. Rebooting the speaker (unplug/replug).
|
||||
|
||||
**Verify SSH Access:**
|
||||
- Confirm the device responds to SSH without a password: `ssh -o HostKeyAlgorithms=+ssh-rsa -o PubkeyAcceptedAlgorithms=+ssh-rsa root@<IP>`
|
||||
- Or use the **Migration** tab in the Web UI to see if the device shows a "✅ Success" status for SSH.
|
||||
Once enabled, you can log in as `root` (no password).
|
||||
|
||||
#### 4. Setup Through the Web UI
|
||||
The web interface handles the entire process in a guided flow. Before proceeding, we strongly recommend reviewing the [Migration & Safety Guide](MIGRATION-SAFETY.md).
|
||||
|
||||
* **Step 1: Settings**: Configure your server's IP or domain. This ensures the speakers know where to find the local services.
|
||||
* **Step 2: Devices**: The service automatically scans for SoundTouch devices on your network. If a device is not found, you can manually add its IP address.
|
||||
* **Step 3: Data Sync**: Select your device and click "Start Sync". This will automatically fetch your presets, recents, and configured sources from the speaker and store them in the local `data/` directory.
|
||||
* **Step 4: Migration**: Choose your redirection method (XML Recommended) and click "Confirm Migration". After the migration, reboot your speaker to apply the changes.
|
||||
|
||||
#### 5. Verify Your Local Data
|
||||
Once migrated, your speaker will use the data captured during the Sync step.
|
||||
* The service stores data in the `data/` directory, organized by device serial number (e.g., `data/default/devices/<SERIAL>/`).
|
||||
* **Automatic Capture**: As you use the device (changing presets, playing new music), the service continues to "learn" and update your local files.
|
||||
You can leave SSH enabled for future maintenance, or disable it once migration is complete.
|
||||
|
||||
---
|
||||
|
||||
### Comparison with other implementations (soundcork)
|
||||
Our implementation (`soundtouch-service`) is largely compatible with the Python-based `soundcork` project but offers several advantages:
|
||||
- **Web UI**: Integrated management interface for discovery and migration.
|
||||
- **Surgical Migration**: Uses XML-based redirection by default, which is less invasive than `/etc/hosts`.
|
||||
- **Automated SSL**: Handles Root CA injection automatically for secure communication.
|
||||
- **Proxy Support**: Can proxy requests to original Bose servers while "learning" your configuration.
|
||||
## Scenario A: Migrate before the shutdown
|
||||
|
||||
Do this while the Bose cloud is still running. Your existing presets and listening history are preserved.
|
||||
|
||||
**Step 1 — Back up your data.**
|
||||
Run `soundtouch-backup all` to save your Bose account data (presets, paired devices, music sources) and each speaker's local state. See the [soundtouch-backup README](../../cmd/soundtouch-backup/README.md) for usage.
|
||||
|
||||
**Step 2 — Start the service and open the web UI** at `http://<server>:8000`.
|
||||
|
||||
**Step 3 — Configure the server URL.**
|
||||
In the Settings tab, set the server URL to the address your speakers can reach (e.g. `http://soundtouch.fritz.box:8000`). If you plan to use DNS/DHCP redirect, also configure the HTTPS server URL.
|
||||
|
||||
**Step 4 — Add your speaker.**
|
||||
The service discovers devices on your network automatically. If a speaker doesn't appear, add it manually by IP address.
|
||||
|
||||
**Step 5 — Sync device data.**
|
||||
Click "Sync" on the device to pull its current presets, recents, and sources from the Bose cloud into the local service's datastore.
|
||||
|
||||
**Step 6 — Migrate.**
|
||||
The web UI offers two redirect methods and walks you through each step:
|
||||
|
||||
| Method | How it works | When to use |
|
||||
|--------------|--------------------------------------------------------|----------------------------------------------------------|
|
||||
| XML redirect | Uploads a config file to the speaker via the Web API | Testing; simpler setup; covers only registered endpoints |
|
||||
| DNS/DHCP | Custom DNS resolves Bose hostnames to the local server | All devices at once; full coverage |
|
||||
|
||||
Both methods require TLS when the speaker uses HTTPS to contact the service. The web UI guides you through installing the service's CA certificate on the speaker (requires SSH).
|
||||
|
||||
**Step 7 — Reboot the speaker.**
|
||||
Power-cycle the speaker to apply the changes. After reboot it contacts the local service instead of Bose's cloud.
|
||||
|
||||
---
|
||||
|
||||
### Alternative: DNS Redirection (No SSH)
|
||||
If you prefer not to modify your speakers via SSH, you can use a local DNS server (like Pi-hole, AdGuard Home, or Unbound) to point the following domains to your local server's IP:
|
||||
## Scenario B: Set up after the shutdown (or after a factory reset)
|
||||
|
||||
* `bmx.bose.com`
|
||||
* `streaming.bose.com`
|
||||
* `updates.bose.com`
|
||||
* `stats.bose.com`
|
||||
* `content.api.bose.io`
|
||||
If the Bose cloud is gone, or you've factory-reset a speaker, there's no existing account to migrate from. You start fresh with a local account.
|
||||
|
||||
*Note: DNS redirection for HTTPS services requires the speakers to trust your local service's SSL certificate. The SSH-based migration handles this automatically by injecting the CA.*
|
||||
**Step 1 — Set up DNS/DHCP redirect first** (recommended).
|
||||
Configure your network's DNS to resolve the Bose cloud hostnames to the local service's address before the speaker tries to register. This way, when the speaker boots and attempts to register, it reaches AfterTouch automatically instead of failing to reach Bose.
|
||||
|
||||
See the [SoundTouch Service Guide](SOUNDTOUCH-SERVICE.md) for the built-in DNS server configuration and the list of hostnames to redirect.
|
||||
|
||||
**Step 2 — Connect the speaker to Wi-Fi.**
|
||||
Use the speaker's built-in AP mode or BLE setup flow. See [Device Initial Setup](DEVICE-INITIAL-SETUP.md) for factory reset button sequences and Wi-Fi provisioning.
|
||||
|
||||
**Step 3 — Start the service and open the web UI** at `http://<server>:8000`.
|
||||
|
||||
**Step 4 — Add the speaker.**
|
||||
After connecting to Wi-Fi, the speaker should appear in the web UI automatically (or add it manually by IP). If DNS redirect is already in place, the speaker is already communicating with AfterTouch.
|
||||
|
||||
**Step 5 — Migrate** (if not already using DNS redirect).
|
||||
If you didn't set up DNS first, use the XML redirect method from the web UI to update the speaker's service URLs. The web UI walks you through the steps including CA certificate setup.
|
||||
|
||||
**Step 6 — Reboot the speaker.**
|
||||
Power-cycle to ensure all changes take effect.
|
||||
|
||||
---
|
||||
|
||||
## After migration
|
||||
|
||||
Once migrated, your speaker uses the local service for music browsing, preset sync, and device registration. The web UI at `http://<server>:8000` is your management interface going forward. Back up the `data/` directory periodically in case you need to restore.
|
||||
|
||||
For the complete step-by-step walkthrough with commands and troubleshooting, see the [Migration Guide](MIGRATION-GUIDE.md). For safety measures and rollback options, see the [Migration & Safety Guide](MIGRATION-SAFETY.md).
|
||||
|
||||
@@ -817,6 +817,121 @@ Use this checklist to systematically troubleshoot issues:
|
||||
|
||||
---
|
||||
|
||||
## 🆔 **Device Identification & Mapping Issues**
|
||||
|
||||
### ❌ "File not found" errors with MAC addresses
|
||||
|
||||
**Symptoms:**
|
||||
```
|
||||
GET /streaming/account/3230304/device/A81B6A536A98/presets
|
||||
→ 500 Internal Server Error
|
||||
→ Log: "open .../devices/A81B6A536A98/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.
|
||||
|
||||
**Quick Solutions:**
|
||||
|
||||
1. **Restart the service** (mappings are created at startup):
|
||||
```bash
|
||||
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/)
|
||||
```
|
||||
|
||||
3. **Verify DeviceInfo.xml contains MAC address**:
|
||||
```bash
|
||||
cat data/accounts/3230304/devices/*/DeviceInfo.xml | grep macAddress
|
||||
```
|
||||
|
||||
**For detailed diagnosis and solutions**, see: [**MAC Address Mapping Guide**](MAC-ADDRESS-MAPPING.md)
|
||||
|
||||
---
|
||||
|
||||
## 🌐 **Hostname Resolution** {#hostname-resolution}
|
||||
|
||||
### Why the service resolves the hostname from the device
|
||||
|
||||
When you migrate a speaker using the resolv.conf method, the service needs to write a raw IP address into the speaker's network configuration. That IP must be the address the *speaker itself* can reach — which is not necessarily the same address your computer resolves.
|
||||
|
||||
In environments with NAT, split-horizon DNS, or Docker/container networking, `soundtouch.local` (or whatever you set as `SERVER_URL`) may resolve to a different IP depending on who is asking. The service therefore resolves the hostname by running `ping -c 1 <hostname>` over SSH on the speaker and extracting the IP from the output. This is the authoritative result: it is exactly what the speaker would use.
|
||||
|
||||
If that SSH ping fails, migration is aborted. Writing an unresolvable or incorrectly resolved hostname into `aftertouch.resolv.conf` would silently break the speaker's DNS config and prevent it from reaching the service after reboot.
|
||||
|
||||
**The XML migration method is different.** It writes the full URL (e.g. `http://soundtouch.local:8000`) into `SoundTouchSdkPrivateCfg.xml`. The speaker resolves the hostname at connect time, not at migration time. This means migration can proceed even if the hostname is not yet reachable — for example, when the service will be deployed under that hostname but is not running yet. A warning is still shown in the UI so you are aware, but the Confirm Migration button remains enabled.
|
||||
|
||||
### ❌ "Cannot resolve target hostname for migration"
|
||||
|
||||
**Symptoms** (migration log or web UI warning):
|
||||
```
|
||||
cannot resolve target hostname for migration: cannot resolve "soundtouch.local":
|
||||
SSH ping from device failed and service-side DNS lookup also failed
|
||||
```
|
||||
or:
|
||||
```
|
||||
resolved "soundtouch.local" to 192.168.1.100 from service, not from device —
|
||||
result may be wrong if NAT or split-DNS is in use
|
||||
```
|
||||
|
||||
**What this means:**
|
||||
|
||||
The service could not confirm the IP by running `ping` on the speaker via SSH. Either:
|
||||
- the `ping` binary is not available or not in `$PATH` on this firmware, or
|
||||
- the hostname is not resolvable from the speaker's network context.
|
||||
|
||||
**Diagnosis — run manually over SSH:**
|
||||
|
||||
```bash
|
||||
# SSH into the speaker
|
||||
ssh root@<speaker-ip>
|
||||
|
||||
# Try to resolve the service hostname
|
||||
ping -c 1 soundtouch.local
|
||||
# or use the IP directly to verify connectivity
|
||||
ping -c 1 192.168.1.100
|
||||
|
||||
# Check the speaker's current DNS config
|
||||
cat /etc/resolv.conf
|
||||
|
||||
# Check if ping is available
|
||||
which ping
|
||||
busybox ping --help
|
||||
```
|
||||
|
||||
**Solutions:**
|
||||
|
||||
#### 1. Use an IP address as SERVER_URL
|
||||
|
||||
The most reliable fix. If the hostname cannot be resolved from the device, use a raw IP instead. Resolution is skipped entirely when `SERVER_URL` contains an IP.
|
||||
|
||||
```bash
|
||||
# In your .env
|
||||
SERVER_URL=http://192.168.1.100:8000
|
||||
HTTPS_SERVER_URL=https://192.168.1.100:8443
|
||||
```
|
||||
|
||||
HTTPS works correctly with IP addresses — the service certificate includes the IP as a Subject Alternative Name (SAN).
|
||||
|
||||
#### 2. Ensure the hostname resolves on the speaker's network segment
|
||||
|
||||
If you use `soundtouch.local`, verify mDNS is working from another device on the same subnet:
|
||||
|
||||
```bash
|
||||
avahi-resolve -n soundtouch.local # Linux
|
||||
dns-sd -G v4 soundtouch.local # macOS
|
||||
```
|
||||
|
||||
#### 3. Use the XML migration method
|
||||
|
||||
Select the XML method in the migration UI. It writes the full URL and the speaker resolves it at connect time, so hostname resolution is not required during migration. This also allows migrating to a hostname that is not yet live.
|
||||
|
||||
---
|
||||
|
||||
## 🛟 **Getting More Help**
|
||||
|
||||
### Information to Gather
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# docs/images
|
||||
|
||||
Screenshots and diagrams referenced by the documentation.
|
||||
|
||||
## Current screenshots
|
||||
|
||||
| File | Shows | Used in |
|
||||
|------|-------|---------|
|
||||
| `ui-settings.png` | AfterTouch web UI — Settings tab (Target Domain, DNS Discovery, Mirroring) | Migration Guide |
|
||||
| `ui-devices.png` | AfterTouch web UI — Devices tab (discovered speakers with Sync/Migrate actions) | Migration Guide |
|
||||
| `ui-sync.png` | AfterTouch web UI — Data Sync tab (successful sync result) | Migration Guide |
|
||||
| `ui-migration.png` | AfterTouch web UI — Migration tab (HTTPS test, DNS test, method selector) | Migration Guide |
|
||||
| `speaker-ap-wifi-setup.png` | Speaker AP mode Wi-Fi setup page at `http://192.0.2.1` | Device Initial Setup |
|
||||
|
||||
## Adding new screenshots
|
||||
|
||||
PNG format, 1200 px or wider. Use descriptive kebab-case names. Update this README when adding files.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 334 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 544 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 516 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 266 KiB |
@@ -0,0 +1,599 @@
|
||||
# /power_on Implementation Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This guide provides detailed technical specifications for implementing `/power_on` endpoint enhancements to reduce network dependency and improve device lifecycle management in the SoundTouch service.
|
||||
|
||||
## Current /power_on Handler Analysis
|
||||
|
||||
### Existing Implementation
|
||||
Located in `pkg/service/handlers/handlers_marge.go`:
|
||||
|
||||
```go
|
||||
func (s *Server) HandleMargePowerOn(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
log.Printf("[Marge] Failed to read power_on body: %v", err)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
var req models.CustomerSupportRequest
|
||||
if err := xml.Unmarshal(body, &req); err != nil {
|
||||
log.Printf("[Marge] Failed to parse power_on body: %v", err)
|
||||
// Fallback to remote address
|
||||
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||
go s.PrimeDeviceWithSpotify(host)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
deviceID := req.Device.ID
|
||||
deviceIP := req.DiagnosticData.DeviceLandscape.IPAddress
|
||||
|
||||
log.Printf("[Marge] Device %s powered on (IP: %s)", deviceID, deviceIP)
|
||||
|
||||
if deviceIP != "" {
|
||||
go s.PrimeDeviceWithSpotify(deviceIP)
|
||||
} else {
|
||||
// Fallback to remote address
|
||||
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||
go s.PrimeDeviceWithSpotify(host)
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
```
|
||||
|
||||
**Current Limitations:**
|
||||
- Only extracts basic device ID and IP
|
||||
- No device state management
|
||||
- No data persistence
|
||||
- No response payload
|
||||
- Limited to Spotify priming
|
||||
|
||||
## Enhanced Implementation Design
|
||||
|
||||
### 1. Extended Data Models
|
||||
|
||||
#### Enhanced Power-On Request Model
|
||||
```go
|
||||
// PowerOnRequest represents the enhanced power_on request structure
|
||||
type PowerOnRequest struct {
|
||||
XMLName xml.Name `xml:"device-data"`
|
||||
Device PowerOnDevice `xml:"device"`
|
||||
DiagnosticData DiagnosticData `xml:"diagnostic-data"`
|
||||
}
|
||||
|
||||
type PowerOnDevice struct {
|
||||
ID string `xml:"id,attr"`
|
||||
SerialNumber string `xml:"serialnumber"`
|
||||
FirmwareVersion string `xml:"firmware-version"`
|
||||
Product PowerOnProduct `xml:"product"`
|
||||
}
|
||||
|
||||
type PowerOnProduct struct {
|
||||
ProductCode string `xml:"product_code,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
SerialNumber string `xml:"serialnumber"`
|
||||
}
|
||||
|
||||
type DiagnosticData struct {
|
||||
DeviceLandscape DeviceLandscape `xml:"device-landscape"`
|
||||
NetworkData NetworkData `xml:"network-landscape>network-data"`
|
||||
}
|
||||
|
||||
type DeviceLandscape struct {
|
||||
RSSI string `xml:"rssi"`
|
||||
GatewayIP string `xml:"gateway-ip-address"`
|
||||
MacAddresses []string `xml:"macaddresses>macaddress"`
|
||||
IPAddress string `xml:"ip-address"`
|
||||
ConnectionType string `xml:"network-connection-type"`
|
||||
}
|
||||
```
|
||||
|
||||
#### Enhanced Response Model
|
||||
```go
|
||||
// PowerOnResponse represents the response sent back to the device
|
||||
type PowerOnResponse struct {
|
||||
XMLName xml.Name `xml:"power-on-response"`
|
||||
Status string `xml:"status"`
|
||||
DeviceID string `xml:"device-id"`
|
||||
ConfigurationUpdates []ConfigurationUpdate `xml:"configuration-updates>update,omitempty"`
|
||||
MigrationInstructions *MigrationInstruction `xml:"migration,omitempty"`
|
||||
RegistrationRequired bool `xml:"registration-required,omitempty"`
|
||||
Timestamp string `xml:"timestamp"`
|
||||
}
|
||||
|
||||
type ConfigurationUpdate struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Key string `xml:"key"`
|
||||
Value string `xml:"value"`
|
||||
Priority int `xml:"priority,attr"`
|
||||
}
|
||||
|
||||
type MigrationInstruction struct {
|
||||
Method string `xml:"method,attr"`
|
||||
TargetURL string `xml:"target-url"`
|
||||
ProxyURL string `xml:"proxy-url,omitempty"`
|
||||
Options map[string]string `xml:"options>option"`
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Enhanced PowerOn Handler
|
||||
|
||||
```go
|
||||
// HandleMargePowerOnEnhanced processes power_on requests with full device lifecycle management
|
||||
func (s *Server) HandleMargePowerOnEnhanced(w http.ResponseWriter, r *http.Request) {
|
||||
startTime := time.Now()
|
||||
|
||||
// Parse the power_on request
|
||||
powerOnReq, err := s.parsePowerOnRequest(r)
|
||||
if err != nil {
|
||||
s.handlePowerOnError(w, r, "Failed to parse request", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Process device information
|
||||
deviceInfo, isNewDevice, err := s.processDeviceFromPowerOn(powerOnReq)
|
||||
if err != nil {
|
||||
s.handlePowerOnError(w, r, "Failed to process device", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Build response based on device state
|
||||
response := s.buildPowerOnResponse(deviceInfo, isNewDevice, powerOnReq)
|
||||
|
||||
// Log the interaction
|
||||
s.logPowerOnInteraction(deviceInfo, powerOnReq, response, startTime)
|
||||
|
||||
// Send response
|
||||
if err := s.sendPowerOnResponse(w, response); err != nil {
|
||||
log.Printf("[PowerOn] Failed to send response for device %s: %v", deviceInfo.DeviceID, err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Device Processing Logic
|
||||
|
||||
```go
|
||||
// processDeviceFromPowerOn handles device identification and data updates
|
||||
func (s *Server) processDeviceFromPowerOn(req *PowerOnRequest) (*models.ServiceDeviceInfo, bool, error) {
|
||||
deviceMAC := req.Device.ID
|
||||
deviceIP := req.DiagnosticData.DeviceLandscape.IPAddress
|
||||
|
||||
// Try to find existing device by MAC address (primary identifier)
|
||||
existingDevice, err := s.ds.GetDeviceByMAC(deviceMAC)
|
||||
if err != nil && err != datastore.ErrDeviceNotFound {
|
||||
return nil, false, fmt.Errorf("failed to lookup device: %w", err)
|
||||
}
|
||||
|
||||
var deviceInfo *models.ServiceDeviceInfo
|
||||
isNewDevice := existingDevice == nil
|
||||
|
||||
if isNewDevice {
|
||||
// Create new device record from power_on data
|
||||
deviceInfo = s.createDeviceFromPowerOn(req)
|
||||
|
||||
// Store in datastore
|
||||
if err := s.ds.SaveDeviceInfo("", deviceMAC, deviceInfo); err != nil {
|
||||
return nil, false, fmt.Errorf("failed to save new device: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[PowerOn] New device registered: %s (IP: %s, Model: %s)",
|
||||
deviceMAC, deviceIP, deviceInfo.ProductCode)
|
||||
} else {
|
||||
// Update existing device with power_on data
|
||||
deviceInfo = existingDevice
|
||||
s.updateDeviceFromPowerOn(deviceInfo, req)
|
||||
|
||||
// Detect significant changes
|
||||
if s.hasSignificantChanges(existingDevice, deviceInfo) {
|
||||
log.Printf("[PowerOn] Device %s updated: IP %s->%s, FW %s->%s",
|
||||
deviceMAC, existingDevice.IPAddress, deviceInfo.IPAddress,
|
||||
existingDevice.FirmwareVersion, deviceInfo.FirmwareVersion)
|
||||
}
|
||||
|
||||
// Save updated device info
|
||||
if err := s.ds.SaveDeviceInfo(deviceInfo.AccountID, deviceMAC, deviceInfo); err != nil {
|
||||
return nil, false, fmt.Errorf("failed to update device: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update device mappings for lookup optimization
|
||||
s.ds.UpdateDeviceMappings(*deviceInfo)
|
||||
|
||||
return deviceInfo, isNewDevice, nil
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Device Creation from Power-On Data
|
||||
|
||||
```go
|
||||
// createDeviceFromPowerOn creates a new ServiceDeviceInfo from power_on request
|
||||
func (s *Server) createDeviceFromPowerOn(req *PowerOnRequest) *models.ServiceDeviceInfo {
|
||||
now := time.Now()
|
||||
|
||||
deviceInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: req.Device.ID, // MAC address
|
||||
ProductCode: req.Device.Product.ProductCode,
|
||||
DeviceSerialNumber: req.Device.SerialNumber,
|
||||
ProductSerialNumber: req.Device.Product.SerialNumber,
|
||||
FirmwareVersion: req.Device.FirmwareVersion,
|
||||
IPAddress: req.DiagnosticData.DeviceLandscape.IPAddress,
|
||||
MacAddress: req.Device.ID, // Primary MAC
|
||||
DiscoveryMethod: "power_on",
|
||||
LastSeen: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
// Generate default name if not provided
|
||||
if deviceInfo.Name == "" {
|
||||
deviceInfo.Name = s.generateDefaultDeviceName(deviceInfo)
|
||||
}
|
||||
|
||||
// Add power_on specific metadata
|
||||
deviceInfo.Metadata = map[string]string{
|
||||
"rssi": req.DiagnosticData.DeviceLandscape.RSSI,
|
||||
"gateway_ip": req.DiagnosticData.DeviceLandscape.GatewayIP,
|
||||
"connection_type": req.DiagnosticData.DeviceLandscape.ConnectionType,
|
||||
"power_on_count": "1",
|
||||
}
|
||||
|
||||
// Store additional MAC addresses if available
|
||||
if len(req.DiagnosticData.DeviceLandscape.MacAddresses) > 1 {
|
||||
additionalMACs := make([]string, 0, len(req.DiagnosticData.DeviceLandscape.MacAddresses)-1)
|
||||
for _, mac := range req.DiagnosticData.DeviceLandscape.MacAddresses {
|
||||
if mac != req.Device.ID {
|
||||
additionalMACs = append(additionalMACs, mac)
|
||||
}
|
||||
}
|
||||
if len(additionalMACs) > 0 {
|
||||
deviceInfo.Metadata["additional_macs"] = strings.Join(additionalMACs, ",")
|
||||
}
|
||||
}
|
||||
|
||||
return deviceInfo
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Response Generation Logic
|
||||
|
||||
```go
|
||||
// buildPowerOnResponse creates appropriate response based on device state
|
||||
func (s *Server) buildPowerOnResponse(deviceInfo *models.ServiceDeviceInfo, isNewDevice bool, req *PowerOnRequest) *PowerOnResponse {
|
||||
response := &PowerOnResponse{
|
||||
Status: "ok",
|
||||
DeviceID: deviceInfo.DeviceID,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
// Handle new device registration
|
||||
if isNewDevice {
|
||||
response.RegistrationRequired = deviceInfo.AccountID == ""
|
||||
|
||||
// Add welcome configuration for new devices
|
||||
response.ConfigurationUpdates = []ConfigurationUpdate{
|
||||
{
|
||||
Type: "welcome",
|
||||
Key: "device_registered",
|
||||
Value: "true",
|
||||
Priority: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Check if migration is needed
|
||||
if s.needsMigration(deviceInfo) {
|
||||
migration := s.getMigrationInstructions(deviceInfo)
|
||||
response.MigrationInstructions = migration
|
||||
|
||||
log.Printf("[PowerOn] Migration required for device %s: %s",
|
||||
deviceInfo.DeviceID, migration.Method)
|
||||
}
|
||||
|
||||
// Add any pending configuration updates
|
||||
pendingUpdates := s.getPendingConfigurationUpdates(deviceInfo)
|
||||
response.ConfigurationUpdates = append(response.ConfigurationUpdates, pendingUpdates...)
|
||||
|
||||
return response
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Device Lookup Enhancements
|
||||
|
||||
#### Enhanced DataStore Methods
|
||||
```go
|
||||
// GetDeviceByMAC finds a device by MAC address across all accounts
|
||||
func (ds *DataStore) GetDeviceByMAC(macAddress string) (*models.ServiceDeviceInfo, error) {
|
||||
normalizedMAC := normalizeMAC(macAddress)
|
||||
|
||||
// Check device mappings first (for performance)
|
||||
ds.idMutex.RLock()
|
||||
deviceID, exists := ds.deviceMappings[normalizedMAC]
|
||||
ds.idMutex.RUnlock()
|
||||
|
||||
if exists {
|
||||
// Try to find device by mapped ID
|
||||
device, err := ds.findDeviceByID(deviceID)
|
||||
if err == nil {
|
||||
return device, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to full scan
|
||||
devices, err := ds.ListAllDevices()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, device := range devices {
|
||||
if normalizeMAC(device.MacAddress) == normalizedMAC ||
|
||||
normalizeMAC(device.DeviceID) == normalizedMAC {
|
||||
return &device, nil
|
||||
}
|
||||
|
||||
// Check additional MAC addresses in metadata
|
||||
if additionalMACs, exists := device.Metadata["additional_macs"]; exists {
|
||||
for _, mac := range strings.Split(additionalMACs, ",") {
|
||||
if normalizeMAC(mac) == normalizedMAC {
|
||||
return &device, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, datastore.ErrDeviceNotFound
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Migration Integration
|
||||
|
||||
```go
|
||||
// needsMigration determines if device requires configuration migration
|
||||
func (s *Server) needsMigration(deviceInfo *models.ServiceDeviceInfo) bool {
|
||||
if deviceInfo.AccountID == "" {
|
||||
return false // Cannot migrate without account
|
||||
}
|
||||
|
||||
// Check if device is already migrated
|
||||
if s.sm != nil {
|
||||
summary, err := s.sm.GetMigrationSummary(deviceInfo.IPAddress, s.ServerURL, "", nil)
|
||||
if err == nil && summary.IsMigrated {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// getMigrationInstructions creates migration instructions for device
|
||||
func (s *Server) getMigrationInstructions(deviceInfo *models.ServiceDeviceInfo) *MigrationInstruction {
|
||||
return &MigrationInstruction{
|
||||
Method: "xml", // Default to XML-based migration
|
||||
TargetURL: s.ServerURL,
|
||||
Options: map[string]string{
|
||||
"marge": "true",
|
||||
"stats": "true",
|
||||
"sw_update": "true",
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Error Handling and Fallbacks
|
||||
|
||||
```go
|
||||
// handlePowerOnError provides graceful error handling with fallbacks
|
||||
func (s *Server) handlePowerOnError(w http.ResponseWriter, r *http.Request, message string, err error) {
|
||||
log.Printf("[PowerOn] %s: %v", message, err)
|
||||
|
||||
// Try to extract IP from request for fallback processing
|
||||
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||
// Fallback to existing discovery mechanism
|
||||
go s.PrimeDeviceWithSpotify(host)
|
||||
log.Printf("[PowerOn] Falling back to legacy processing for IP %s", host)
|
||||
}
|
||||
|
||||
// Always return 200 OK to avoid device retry loops
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// parsePowerOnRequest safely parses the power_on request with validation
|
||||
func (s *Server) parsePowerOnRequest(r *http.Request) (*PowerOnRequest, error) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read request body: %w", err)
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return nil, fmt.Errorf("empty request body")
|
||||
}
|
||||
|
||||
var req PowerOnRequest
|
||||
if err := xml.Unmarshal(body, &req); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse XML: %w", err)
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if req.Device.ID == "" {
|
||||
return nil, fmt.Errorf("missing device ID")
|
||||
}
|
||||
|
||||
if req.DiagnosticData.DeviceLandscape.IPAddress == "" {
|
||||
return nil, fmt.Errorf("missing device IP address")
|
||||
}
|
||||
|
||||
return &req, nil
|
||||
}
|
||||
```
|
||||
|
||||
### 9. Logging and Monitoring
|
||||
|
||||
```go
|
||||
// logPowerOnInteraction records detailed interaction logs for debugging
|
||||
func (s *Server) logPowerOnInteraction(deviceInfo *models.ServiceDeviceInfo, req *PowerOnRequest, resp *PowerOnResponse, startTime time.Time) {
|
||||
duration := time.Since(startTime)
|
||||
|
||||
log.Printf("[PowerOn] Device: %s, IP: %s, Duration: %v, Status: %s, NewDevice: %t, Migration: %t",
|
||||
deviceInfo.DeviceID,
|
||||
req.DiagnosticData.DeviceLandscape.IPAddress,
|
||||
duration,
|
||||
resp.Status,
|
||||
resp.RegistrationRequired,
|
||||
resp.MigrationInstructions != nil)
|
||||
|
||||
// Store interaction for debugging (if enabled)
|
||||
if s.config.RecordInteractions {
|
||||
interaction := models.DeviceInteraction{
|
||||
Timestamp: startTime,
|
||||
DeviceID: deviceInfo.DeviceID,
|
||||
Type: "power_on",
|
||||
Request: req,
|
||||
Response: resp,
|
||||
Duration: duration,
|
||||
IPAddress: req.DiagnosticData.DeviceLandscape.IPAddress,
|
||||
UserAgent: r.Header.Get("User-Agent"),
|
||||
}
|
||||
|
||||
if err := s.ds.SaveInteraction(interaction); err != nil {
|
||||
log.Printf("[PowerOn] Failed to save interaction: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 10. Configuration and Feature Flags
|
||||
|
||||
```go
|
||||
// PowerOnConfig controls behavior of enhanced power_on processing
|
||||
type PowerOnConfig struct {
|
||||
EnableEnhancedProcessing bool `json:"enable_enhanced_processing"`
|
||||
AutoMigration bool `json:"auto_migration"`
|
||||
RecordInteractions bool `json:"record_interactions"`
|
||||
DefaultResponseTimeout time.Duration `json:"default_response_timeout"`
|
||||
FallbackToLegacy bool `json:"fallback_to_legacy"`
|
||||
}
|
||||
|
||||
// loadPowerOnConfig loads configuration with defaults
|
||||
func loadPowerOnConfig() *PowerOnConfig {
|
||||
return &PowerOnConfig{
|
||||
EnableEnhancedProcessing: true,
|
||||
AutoMigration: false, // Conservative default
|
||||
RecordInteractions: false,
|
||||
DefaultResponseTimeout: 5 * time.Second,
|
||||
FallbackToLegacy: true,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### 1. Unit Tests
|
||||
```go
|
||||
func TestHandleMargePowerOnEnhanced(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
requestBody string
|
||||
existingDevice *models.ServiceDeviceInfo
|
||||
expectedStatus string
|
||||
expectMigration bool
|
||||
}{
|
||||
{
|
||||
name: "new_device_registration",
|
||||
requestBody: `<device-data><device id="A81B6A536A98">...</device></device-data>`,
|
||||
existingDevice: nil,
|
||||
expectedStatus: "ok",
|
||||
expectMigration: false,
|
||||
},
|
||||
{
|
||||
name: "existing_device_update",
|
||||
requestBody: `<device-data><device id="A81B6A536A98">...</device></device-data>`,
|
||||
existingDevice: &models.ServiceDeviceInfo{DeviceID: "A81B6A536A98"},
|
||||
expectedStatus: "ok",
|
||||
expectMigration: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Test implementation
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Integration Tests
|
||||
```go
|
||||
func TestPowerOnDeviceLifecycle(t *testing.T) {
|
||||
// Test complete device lifecycle through power_on events
|
||||
// 1. New device power_on
|
||||
// 2. Device registration
|
||||
// 3. Configuration changes
|
||||
// 4. Migration
|
||||
// 5. Subsequent power_on events
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Load Testing
|
||||
```go
|
||||
func BenchmarkPowerOnProcessing(b *testing.B) {
|
||||
// Benchmark power_on processing performance
|
||||
// Test concurrent device registrations
|
||||
// Measure response times
|
||||
}
|
||||
```
|
||||
|
||||
## Deployment Strategy
|
||||
|
||||
### Phase 1: Parallel Implementation
|
||||
- Implement enhanced handler alongside existing handler
|
||||
- Use feature flag to control which handler processes requests
|
||||
- Maintain full backward compatibility
|
||||
|
||||
### Phase 2: Gradual Rollout
|
||||
- Enable enhanced processing for subset of devices
|
||||
- Monitor performance and error rates
|
||||
- Collect metrics on data completeness
|
||||
|
||||
### Phase 3: Full Migration
|
||||
- Default to enhanced processing for all devices
|
||||
- Remove legacy fallbacks
|
||||
- Optimize performance based on production data
|
||||
|
||||
## Monitoring and Metrics
|
||||
|
||||
### Key Metrics to Track
|
||||
- Power-on event frequency per device
|
||||
- New device registration rate via power_on
|
||||
- Migration success rate via power_on response
|
||||
- Response time distribution
|
||||
- Error rates and types
|
||||
- Data completeness metrics
|
||||
|
||||
### Alerting Thresholds
|
||||
- Power-on processing failures > 5%
|
||||
- Average response time > 2 seconds
|
||||
- New device registration failures > 1%
|
||||
- Migration instruction delivery failures > 2%
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Input Validation
|
||||
- XML parsing security (prevent XXE attacks)
|
||||
- Device ID format validation
|
||||
- IP address validation
|
||||
- Request size limits
|
||||
|
||||
### Authentication
|
||||
- Device authentication via MAC address verification
|
||||
- Request signing (if available)
|
||||
- Rate limiting per device/IP
|
||||
|
||||
### Data Privacy
|
||||
- Sensitive data handling in diagnostic information
|
||||
- Logging data retention policies
|
||||
- Compliance with data protection regulations
|
||||
@@ -0,0 +1,521 @@
|
||||
# SoundTouch Device WebSocket API — Pairing & Operation Flow
|
||||
|
||||
Reference document derived from mitmproxy captures of the Bose SoundTouch Android app
|
||||
(`bose-pairing-20260502-155542`, `bose-pairing-20260502-165549`).
|
||||
|
||||
> **Why this matters:** With Bose cloud services shutting down on 2026-05-06, the original
|
||||
> app may stop working for pairing and playback control. This document captures the exact
|
||||
> WebSocket message sequences needed to replicate those flows independently.
|
||||
|
||||
---
|
||||
|
||||
## Connection
|
||||
|
||||
All interactions use the SoundTouch WebSocket API on the speaker's local IP, port **8090**
|
||||
(the same port as the REST API). Connect with the `Gabbo` sub-protocol:
|
||||
|
||||
```
|
||||
GET ws://192.168.x.y:8090/
|
||||
Upgrade: websocket
|
||||
Sec-WebSocket-Protocol: Gabbo
|
||||
```
|
||||
|
||||
Upon connection the server immediately sends an identification banner:
|
||||
|
||||
```xml
|
||||
<SoundTouchSdkInfo serverVersion="4" serverBuild="trunk r46330 v4 epdbuild hepdswbld04" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Message Envelope
|
||||
|
||||
All subsequent messages (except `selectLastWiFiSource`, see below) use this envelope:
|
||||
|
||||
**Client → Server request:**
|
||||
```xml
|
||||
<msg>
|
||||
<header deviceID="{device_id}" url="{endpoint}" method="{GET|POST}">
|
||||
<request requestID="{n}">
|
||||
<info type="new"/> <!-- or type="update" -->
|
||||
<!-- optional: <sourceItem source="TUNEIN"/> -->
|
||||
</request>
|
||||
</header>
|
||||
<body>
|
||||
<!-- payload, may be empty -->
|
||||
</body>
|
||||
</msg>
|
||||
```
|
||||
|
||||
**Server → Client response:**
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<msg>
|
||||
<header deviceID="{device_id}" url="{endpoint}" method="{GET|POST}">
|
||||
<request requestID="{n}" msgType="RESPONSE">
|
||||
<info type="new"/>
|
||||
</request>
|
||||
</header>
|
||||
<body>
|
||||
<!-- response payload -->
|
||||
</body>
|
||||
</msg>
|
||||
```
|
||||
|
||||
**Server → Client push (unsolicited):**
|
||||
```xml
|
||||
<updates deviceID="{device_id}">
|
||||
<nowPlayingUpdated>...</nowPlayingUpdated>
|
||||
</updates>
|
||||
```
|
||||
|
||||
`requestID` is a monotonically increasing integer per connection (client-side sequence).
|
||||
`{device_id}` is the speaker's MAC address with colons removed (e.g. `08DF1F0BA325`).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Discovery: Is the Speaker Already Paired?
|
||||
|
||||
```xml
|
||||
<!-- C→S: fetch device info -->
|
||||
<msg><header deviceID="{device_id}" url="info" method="GET">
|
||||
<request requestID="1"><info type="new"/></request>
|
||||
</header></msg>
|
||||
|
||||
<!-- S→C: response -->
|
||||
<info deviceID="{device_id}">
|
||||
<name>SoundTouch 10</name>
|
||||
<type>SoundTouch 10</type>
|
||||
<margeAccountUUID>9569497</margeAccountUUID> <!-- empty = unpaired -->
|
||||
<margeURL>https://streaming.bose.com</margeURL>
|
||||
...
|
||||
</info>
|
||||
```
|
||||
|
||||
- **Empty `margeAccountUUID`** → device is unpaired, proceed to Phase 2
|
||||
- **Populated `margeAccountUUID`** → already paired with that account ID
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Pairing a New Speaker
|
||||
|
||||
### 2.1 Setup State Machine
|
||||
|
||||
The pairing flow uses a setup state machine on the device. States must be sent in order.
|
||||
|
||||
```xml
|
||||
<!-- 1. Start setup -->
|
||||
<msg><header deviceID="{device_id}" url="setup" method="POST">
|
||||
<request requestID="21"></request>
|
||||
</header><body><setupState state="SETUP_START"/></body></msg>
|
||||
|
||||
<!-- 2. Enter identify mode — device flashes/beeps; 300 000 ms timeout -->
|
||||
<msg><header deviceID="{device_id}" url="setup" method="POST">
|
||||
<request requestID="22"></request>
|
||||
</header><body><setupState state="SETUP_IDENTIFY_DEVICE_ENTER" timeout="300000"/></body></msg>
|
||||
|
||||
<!-- Server pushes: -->
|
||||
<updates deviceID="{device_id}">
|
||||
<soundTouchConfigurationUpdated>
|
||||
<soundTouchConfigurationStatus status="SOUNDTOUCH_CONFIGURING"/>
|
||||
</soundTouchConfigurationUpdated>
|
||||
</updates>
|
||||
|
||||
<!-- 3. Set language (3 = German; adjust as needed) -->
|
||||
<msg><header deviceID="{device_id}" url="language" method="POST">
|
||||
<request requestID="23"></request>
|
||||
</header><body><sysLanguage>3</sysLanguage></body></msg>
|
||||
|
||||
<!-- 4. Enter setup (user has confirmed identification) -->
|
||||
<msg><header deviceID="{device_id}" url="setup" method="POST">
|
||||
<request requestID="24"></request>
|
||||
</header><body><setupState state="SETUP_ENTER"/></body></msg>
|
||||
|
||||
<!-- 5. Leave identify mode -->
|
||||
<msg><header deviceID="{device_id}" url="setup" method="POST">
|
||||
<request requestID="25"></request>
|
||||
</header><body><setupState state="SETUP_IDENTIFY_DEVICE_LEAVE"/></body></msg>
|
||||
|
||||
<!-- 6. Set device name -->
|
||||
<msg><header deviceID="{device_id}" url="name" method="POST">
|
||||
<request requestID="26"></request>
|
||||
</header><body><name>My SoundTouch 10</name></body></msg>
|
||||
```
|
||||
|
||||
### 2.2 Account Pairing — The Critical Step
|
||||
|
||||
```xml
|
||||
<!-- C→S: pair device with account -->
|
||||
<msg><header deviceID="{device_id}" url="setMargeAccount" method="POST">
|
||||
<request requestID="27"></request>
|
||||
</header><body>
|
||||
<PairDeviceWithAccount>
|
||||
<accountId>{accountId}</accountId>
|
||||
<userAuthToken>Bearer {token}</userAuthToken>
|
||||
</PairDeviceWithAccount>
|
||||
</body></msg>
|
||||
|
||||
<!-- S→C: device info response with margeAccountUUID now set -->
|
||||
<info deviceID="{device_id}">
|
||||
...
|
||||
<margeAccountUUID>{accountId}</margeAccountUUID>
|
||||
...
|
||||
</info>
|
||||
```
|
||||
|
||||
The server also pushes several `sourcesUpdated` events after successful pairing.
|
||||
|
||||
**`{accountId}`** — the numeric Bose account ID (e.g. `9569497`), obtainable from
|
||||
`GET /streaming/account/login` on soundtouch-service.
|
||||
|
||||
**`{token}`** — a Bearer token issued by Bose authentication (or soundtouch-service).
|
||||
The full token from the captures:
|
||||
```
|
||||
Bearer NtJDRbNtY3hDhm5K8FC2JprRhRQNH3QdZjG6aR4ASwYQg4rvZMY6dPLc3Bm6zvWNciWzCpMWZ/dbITRQoVdClOdssgDO+Nlh4ZJWp2w3tZiGzB8Flho0c+ipXnT/0Yg5
|
||||
```
|
||||
(session-specific; obtain a fresh one from the service's account login flow)
|
||||
|
||||
### 2.3 Finish Setup and Telemetry
|
||||
|
||||
```xml
|
||||
<!-- Leave setup state machine -->
|
||||
<msg><header deviceID="{device_id}" url="setup" method="POST">
|
||||
<request requestID="28"></request>
|
||||
</header><body><setupState state="SETUP_LEAVE"/></body></msg>
|
||||
|
||||
<!-- Trigger device to sync customer support info to Marge cloud -->
|
||||
<msg><header deviceID="{device_id}" url="pushCustomerSupportInfoToMarge" method="GET">
|
||||
<request requestID="29"></request>
|
||||
</header></msg>
|
||||
|
||||
<!-- S→C: -->
|
||||
<status>/pushCustomerSupportInfoToMarge</status>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Unpairing
|
||||
|
||||
```xml
|
||||
<!-- C→S: remove device from account -->
|
||||
<msg><header deviceID="{device_id}" url="setMargeAccount" method="POST">
|
||||
<request requestID="24">
|
||||
<info mainNode="removeDevice" type="new"/>
|
||||
<sourceItem source="SETTINGS" sourceAccount="{device_id}"/>
|
||||
</request>
|
||||
</header><body><UnPairDeviceWithAccount/></body></msg>
|
||||
|
||||
<!-- S→C: response with device info showing empty margeAccountUUID -->
|
||||
<!-- Server also pushes: <updates><infoUpdated/></updates> -->
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — App Initialization (Bulk State Fetch)
|
||||
|
||||
When the app connects to an already-paired device it sends these in rapid parallel sequence:
|
||||
|
||||
```
|
||||
info (GET) — device metadata, check pairing
|
||||
sources (GET) — available input sources
|
||||
presets (GET) — saved presets 1–6
|
||||
swUpdateQuery (POST) — check if update is in progress
|
||||
capabilities (GET) — hardware capabilities, network config
|
||||
bassCapabilities (GET) — bass range and defaults
|
||||
now_playing (GET) — current playback state
|
||||
volume (GET) — current volume
|
||||
getZone (GET) — multi-room zone membership
|
||||
clockDisplay (POST) — set clock timezone/format
|
||||
```
|
||||
|
||||
Then a second wave:
|
||||
|
||||
```
|
||||
swUpdateCheck (POST) — check for new firmware
|
||||
systemtimeout (GET) — power-saving timeout
|
||||
rebroadcastlatencymode (GET) — zone latency mode
|
||||
getGroup (GET) — stereo-pair group
|
||||
language (GET, sourceItem source="settings") — UI language
|
||||
bass (GET) — current bass level
|
||||
serviceAvailability (GET, sourceItem source="add_service" or "settings")
|
||||
webserver/pingRequest (GET) — keepalive
|
||||
pushCustomerSupportInfoToMarge (GET) — telemetry
|
||||
netStats (GET, sourceItem source="settings") — network statistics
|
||||
introspect (POST, sourceItem source="AIRPLAY") — AirPlay2 capabilities
|
||||
```
|
||||
|
||||
`clockDisplay` example with timezone:
|
||||
```xml
|
||||
<clockDisplay>
|
||||
<clockConfig timezoneInfo="Europe/Berlin" timeFormat="TIME_FORMAT_12HOUR_ID"/>
|
||||
</clockDisplay>
|
||||
```
|
||||
|
||||
`serviceAvailability` response lists availability of all service types (PANDORA, AIRPLAY,
|
||||
AMAZON, DEEZER, SPOTIFY, TUNEIN, SIRIUSXM_EVEREST, BLUETOOTH, etc.) with `isAvailable`
|
||||
and optional `reason` attributes.
|
||||
|
||||
---
|
||||
|
||||
## Playback Control
|
||||
|
||||
### Start Playback via `playbackRequest` (preferred — bypasses source checks)
|
||||
|
||||
```xml
|
||||
<msg><header deviceID="{device_id}" url="playbackRequest" method="POST">
|
||||
<request requestID="{n}"><info type="new"/></request>
|
||||
</header><body>
|
||||
<playbackRequest source="TUNEIN" sourceAccount="">
|
||||
<container type="stationurl"
|
||||
location="/v1/playback/station/s25260"
|
||||
isPresetable="true"
|
||||
source="TUNEIN"
|
||||
sourceAccount="">
|
||||
<itemName>1LIVE</itemName>
|
||||
</container>
|
||||
</playbackRequest>
|
||||
</body></msg>
|
||||
|
||||
<!-- S→C response: -->
|
||||
<playbackResponse source="TUNEIN" sourceAccount=""/>
|
||||
|
||||
<!-- S→C pushes: nowPlayingUpdated, recentsUpdated -->
|
||||
```
|
||||
|
||||
For a TuneIn podcast episode, use `type="tracklisturl"` and
|
||||
`location="/v1/playback/episodes/{id}?encoded_name={base64}"`.
|
||||
|
||||
### Select Content via `select` (triggers preset/recents UI highlight)
|
||||
|
||||
```xml
|
||||
<msg><header deviceID="{device_id}" url="select" method="POST">
|
||||
<request requestID="{n}"><info type="new"/></request>
|
||||
</header><body>
|
||||
<ContentItem source="TUNEIN"
|
||||
type="stationurl"
|
||||
location="/v1/playback/station/s25260"
|
||||
sourceAccount="TUNEIN"
|
||||
isPresetable="true">
|
||||
<itemName>1LIVE</itemName>
|
||||
</ContentItem>
|
||||
</body></msg>
|
||||
```
|
||||
|
||||
Note: `select` with a TUNEIN item that the device can't resolve directly may return
|
||||
`error value="1005" name="UNKNOWN_SOURCE_ERROR"`. Use `playbackRequest` instead for
|
||||
reliable playback.
|
||||
|
||||
### Special: Select Last Wi-Fi Source
|
||||
|
||||
A plain-text (non-XML) client message:
|
||||
```
|
||||
selectLastWiFiSource
|
||||
```
|
||||
|
||||
Server responds with plain text:
|
||||
```
|
||||
<?xml version="1.0" encoding="UTF-8" ?><status>/selectLastWiFiSource</status>
|
||||
```
|
||||
|
||||
### Key Presses
|
||||
|
||||
```xml
|
||||
<!-- press -->
|
||||
<msg><header deviceID="{device_id}" url="key" method="POST">
|
||||
<request requestID="{n}"><info mainNode="keyPress" type="new"/><sourceItem source="TUNEIN"/></request>
|
||||
</header><body><key state="press" sender="Gabbo">{KEY}</key></body></msg>
|
||||
|
||||
<!-- release (required for POWER — not for STOP/PAUSE) -->
|
||||
<msg><header deviceID="{device_id}" url="key" method="POST">
|
||||
<request requestID="{n}"><info mainNode="keyRelease" type="new"/><sourceItem source="TUNEIN"/></request>
|
||||
</header><body><key state="release" sender="Gabbo">{KEY}</key></body></msg>
|
||||
```
|
||||
|
||||
Key names observed: `POWER`, `STOP`, `PAUSE`, `ADD_FAVORITE`
|
||||
|
||||
`sender="Gabbo"` is the app identifier string used by all Bose mobile apps.
|
||||
|
||||
### Volume
|
||||
|
||||
```xml
|
||||
<!-- Set volume (0–100) -->
|
||||
<msg><header deviceID="{device_id}" url="volume" method="POST">
|
||||
<request requestID="{n}"><info mainNode="volume" type="new"/><sourceItem source="TUNEIN"/></request>
|
||||
</header><body><volume>30</volume></body></msg>
|
||||
|
||||
<!-- S→C push: -->
|
||||
<updates deviceID="{device_id}">
|
||||
<volumeUpdated>
|
||||
<volume><targetvolume>30</targetvolume><actualvolume>30</actualvolume><muteenabled>false</muteenabled></volume>
|
||||
</volumeUpdated>
|
||||
</updates>
|
||||
```
|
||||
|
||||
### Bass
|
||||
|
||||
```xml
|
||||
<!-- Get -->
|
||||
<msg><header deviceID="{device_id}" url="bass" method="GET">
|
||||
<request requestID="{n}"><info type="new"/></request>
|
||||
</header></msg>
|
||||
|
||||
<!-- Set (range: bassMin to bassMax from bassCapabilities, typically -9 to 0) -->
|
||||
<msg><header deviceID="{device_id}" url="bass" method="POST">
|
||||
<request requestID="{n}"><info mainNode="bassSet" type="new"/><sourceItem source="SETTINGS"/></request>
|
||||
</header><body><bass>-2</bass></body></msg>
|
||||
|
||||
<!-- S→C push: <updates><bassUpdated/></updates> -->
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Browse & Navigate
|
||||
|
||||
```xml
|
||||
<!-- Open recents menu -->
|
||||
<msg><header deviceID="{device_id}" url="navigate" method="POST">
|
||||
<request requestID="{n}"><info mainNode="navigateMenu" type="new"/><sourceItem source="RECENTS"/></request>
|
||||
</header><body><navigate menu="recents"/></body></msg>
|
||||
|
||||
<!-- S→C response: -->
|
||||
<navigateResponse menu="recents">
|
||||
<totalItems>4</totalItems>
|
||||
<items>
|
||||
<item type="stationurl" source="TUNEIN" location="/v1/playback/station/s25260"
|
||||
sourceAccount="TUNEIN" isPresetable="true" id="0">
|
||||
<itemName>1LIVE</itemName>
|
||||
</item>
|
||||
...
|
||||
</items>
|
||||
</navigateResponse>
|
||||
```
|
||||
|
||||
Use `type="update"` on `<info>` for subsequent refresh calls on the same menu.
|
||||
|
||||
---
|
||||
|
||||
## Settings
|
||||
|
||||
### System Timeout (Power-Saving)
|
||||
|
||||
```xml
|
||||
<!-- Read -->
|
||||
<msg><header deviceID="{device_id}" url="systemtimeout" method="GET">
|
||||
<request requestID="{n}"><info type="new"/></request>
|
||||
</header></msg>
|
||||
|
||||
<!-- Write: disable auto power-off -->
|
||||
<msg><header deviceID="{device_id}" url="systemtimeout" method="POST">
|
||||
<request requestID="{n}"><info mainNode="systemtimeout" type="new"/><sourceItem source="SETTINGS"/></request>
|
||||
</header><body><systemtimeout><powersaving_enabled>false</powersaving_enabled></systemtimeout></body></msg>
|
||||
```
|
||||
|
||||
### Clock Display
|
||||
|
||||
```xml
|
||||
<msg><header deviceID="{device_id}" url="clockDisplay" method="POST">
|
||||
<request requestID="{n}"><info mainNode="clockDisplayBypass" type="new"/></request>
|
||||
</header><body>
|
||||
<clockDisplay>
|
||||
<clockConfig timezoneInfo="Europe/Berlin" timeFormat="TIME_FORMAT_12HOUR_ID"/>
|
||||
</clockDisplay>
|
||||
</body></msg>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Keepalive
|
||||
|
||||
The app sends a ping roughly every 30 seconds:
|
||||
|
||||
```xml
|
||||
<!-- C→S -->
|
||||
<msg><header deviceID="{device_id}" url="webserver/pingRequest" method="GET">
|
||||
<request requestID="{n}"><info type="new"/></request>
|
||||
</header></msg>
|
||||
|
||||
<!-- S→C -->
|
||||
<pingRequest pong="true"/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Server Push Events (Unsolicited)
|
||||
|
||||
The server wraps push events in `<updates deviceID="{device_id}">`:
|
||||
|
||||
| Event element | Trigger |
|
||||
|----------------------------------|-----------------------------------------------------------------------------------------------------|
|
||||
| `nowPlayingUpdated` | Source/track changed, playback state changed |
|
||||
| `nowSelectionUpdated` | Preset slot highlighted (UI selection changed) |
|
||||
| `recentsUpdated` | Recents list changed |
|
||||
| `presetsUpdated` | Preset saved or modified |
|
||||
| `volumeUpdated` | Volume changed (any source) |
|
||||
| `bassUpdated` | Bass level changed |
|
||||
| `connectionStateUpdated` | Wi-Fi signal strength changed (`EXCELLENT_SIGNAL`, `GOOD_SIGNAL`, `MARGINAL_SIGNAL`, `POOR_SIGNAL`) |
|
||||
| `soundTouchConfigurationUpdated` | Setup state changed (e.g. `SOUNDTOUCH_CONFIGURING`) |
|
||||
| `infoUpdated` | Device info changed (e.g. after un-pairing) |
|
||||
| `sourcesUpdated` | Available sources list changed |
|
||||
|
||||
Separate push (not inside `<updates>`):
|
||||
```xml
|
||||
<userActivityUpdate deviceID="{device_id}"/>
|
||||
```
|
||||
Sent after any physical or app-initiated user action.
|
||||
|
||||
---
|
||||
|
||||
## Notification (Client → Device Push)
|
||||
|
||||
Used by the app to notify the device of data that has changed on the service side
|
||||
(e.g. after syncing presets from cloud). Header uses `propagate="false"`:
|
||||
|
||||
```xml
|
||||
<msg>
|
||||
<header deviceID="{device_id}" url="notification" method="POST" propagate="false">
|
||||
<request requestID="{n}"><info mainNode="presetsUpdated" type="new"/></request>
|
||||
</header>
|
||||
<body>
|
||||
<updates deviceID="{device_id}"><presetsUpdated/></updates>
|
||||
</body>
|
||||
</msg>
|
||||
|
||||
<!-- S→C response: -->
|
||||
<status>/notification</status>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Pairing Sequence (Minimal)
|
||||
|
||||
To pair a freshly factory-reset speaker to a Bose account (soundtouch-service must be
|
||||
running and authenticated):
|
||||
|
||||
```
|
||||
1. Connect WebSocket to ws://{speakerIP}:8090/
|
||||
2. Receive: <SoundTouchSdkInfo .../>
|
||||
3. GET info → confirm margeAccountUUID is empty
|
||||
4. POST setup SETUP_START
|
||||
5. POST setup SETUP_IDENTIFY_DEVICE_ENTER (timeout=300000)
|
||||
(user physically presses button on speaker to confirm identity)
|
||||
6. POST language <sysLanguage>3</sysLanguage>
|
||||
7. POST setup SETUP_ENTER
|
||||
8. POST setup SETUP_IDENTIFY_DEVICE_LEAVE
|
||||
9. POST name <name>{desired name}</name>
|
||||
10. POST setMargeAccount <PairDeviceWithAccount>
|
||||
<accountId>{accountId}</accountId>
|
||||
<userAuthToken>Bearer {token}</userAuthToken>
|
||||
</PairDeviceWithAccount>
|
||||
→ device responds with info, margeAccountUUID is now set
|
||||
11. POST setup SETUP_LEAVE
|
||||
12. GET pushCustomerSupportInfoToMarge (telemetry, safe to skip)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Source References
|
||||
|
||||
- `bose-pairing-20260502-155542` — Session 1: initial pairing of SoundTouch 10 to account 9569497
|
||||
- `bose-pairing-20260502-165549` — Session 2: re-pairing and full operation (TuneIn, Spotify, presets)
|
||||
- Raw WebSocket files: `scripts/android/mitm/{session}/mirror/{n}-websocket/*.txt`
|
||||
- Companion HTTP upgrade files: `scripts/android/mitm/{session}/mirror/{n}-*.http`
|
||||
@@ -2,6 +2,42 @@
|
||||
|
||||
- https://www.radio-browser.info is a community driven radio station database.
|
||||
- It provides an API to access the data and allows users to submit new stations or update existing ones.
|
||||
- RadioBrowser provides a native SoundTouch-compatible API at `https://all.api.radio-browser.info/soundtouch`.
|
||||
|
||||
### Architecture
|
||||
|
||||
This service registers RadioBrowser in its BMX service registry (provider ID 39) pointing to RadioBrowser's SoundTouch API. The device discovers it from there and communicates directly with RadioBrowser for browsing and playback — the local service does not proxy streams.
|
||||
|
||||
The source is registered with type `RADIO_BROWSER` in the Marge sources list. The RadioBrowser provider ID (39) in the source entry identifies it as RadioBrowser within the BMX layer.
|
||||
|
||||
The device's own `Sources.xml` (`/mnt/nv/BoseApp-Persistence/1/Sources.xml`) must contain a `RADIO_BROWSER` entry for playback to work:
|
||||
|
||||
```xml
|
||||
<source secret="" secretType="">
|
||||
<sourceKey type="RADIO_BROWSER" account="" />
|
||||
</source>
|
||||
```
|
||||
|
||||
**A device reboot is required after adding this entry.** The firmware only registers `RADIO_BROWSER` as a selectable source type during the boot-time `Sources.xml` load. The Marge runtime sync stores the source in the registry but does not complete the activation — without a reboot, selecting a `RADIO_BROWSER` station results in `INVALID_SOURCE`.
|
||||
|
||||
A reboot achieves two things in sequence:
|
||||
|
||||
1. The speaker fetches all sources from the soundtouch-service via a `/full` request, which updates the device-local `Sources.xml`.
|
||||
2. The firmware initialises and registers the `RADIO_BROWSER` source type from that updated file.
|
||||
|
||||
The `INVALID_SOURCE_TYPE` message from the Bluetooth daemon visible in device logs (e.g. during `GET /serviceAvailability`) is informational noise and does not affect playback.
|
||||
|
||||
### Triggering a sources refresh without rebooting
|
||||
|
||||
Step 1 above (the `/full` fetch that updates `Sources.xml`) can be triggered independently by posting a `sourcesUpdated` notification directly to the speaker. This is useful for verifying that the soundtouch-service serves the correct sources list before committing to a full reboot:
|
||||
|
||||
```bash
|
||||
curl -v -X POST http://<speaker-ip>:8090/notification \
|
||||
-H "Content-Type: application/xml" \
|
||||
-d '<updates deviceID="<deviceID>"><sourcesUpdated/></updates>'
|
||||
```
|
||||
|
||||
Replace `<speaker-ip>` with your speaker's IP address and `<deviceID>` with its device ID (visible in `/info`). After this call the speaker re-fetches its sources from the service. Step 2 (source-type registration) still requires a reboot.
|
||||
|
||||
### Search for stations
|
||||
|
||||
@@ -9,10 +45,7 @@
|
||||
- Click on the station and copy the UUID from the URL.
|
||||
- e.g. `https://www.radio-browser.info/history/d28420a4-eccf-47a2-ace1-088c7e7cb7e0`
|
||||
|
||||
### RADIO_BROWSER
|
||||
|
||||
- This project supports source type RADIO_BROWSER to play radio stations.
|
||||
- Set the `location` attribute to `/stations/byuuid/{UUID}`.
|
||||
### Playing the station
|
||||
|
||||
```xml
|
||||
<ContentItem
|
||||
@@ -20,15 +53,34 @@
|
||||
type="stationurl"
|
||||
isPresetable="true"
|
||||
location="/stations/byuuid/9610c454-0601-11e8-ae97-52543be04c81">
|
||||
<itemName>RADIO_BROWSER</itemName>
|
||||
<itemName>Radio Station Name</itemName>
|
||||
<containerArt></containerArt>
|
||||
</ContentItem>
|
||||
```
|
||||
|
||||
### Playing the station
|
||||
|
||||
To start the radio stream replace `<uuid>` and `<soundtouch>` and run curl like this:
|
||||
|
||||
```bash
|
||||
curl -d '<ContentItem source="RADIO_BROWSER" type="stationurl" location="/stations/byuuid/<uuid>"/>' <soundtouch>:8090/select
|
||||
```
|
||||
|
||||
### BMX service registry entry
|
||||
|
||||
The entry in `pkg/service/handlers/static/bmx_services.json` that enables RadioBrowser:
|
||||
|
||||
```json
|
||||
{
|
||||
"baseUrl": "https://all.api.radio-browser.info/soundtouch",
|
||||
"id": {
|
||||
"name": "RADIO_BROWSER",
|
||||
"value": 39
|
||||
},
|
||||
"streamTypes": ["liveRadio", "onDemand"],
|
||||
"authenticationModel": {
|
||||
"anonymousAccount": {
|
||||
"autoCreate": true,
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
# Spotify Account Addition Technical Reference
|
||||
|
||||
This document details the exact network requests performed by the Bose SoundTouch "Stockholm" application and the SoundTouch speaker when adding a new Spotify account. This information is based on analysis of the Stockholm firmware version `27.0.13-4277-8963611`.
|
||||
|
||||
## Flow Overview
|
||||
|
||||
1. **User Authorization Initiation**: The app opens the system browser to Spotify's authorization page.
|
||||
2. **Redirect Handling**: After authorization, Spotify redirects back to the app via a custom URI scheme, delivering an authorization `code`.
|
||||
3. **OAuth Token Exchange**: The app sends this `code` to the background worker, which exchanges it for a Bose-mediated token.
|
||||
4. **Cloud Source Registration**: The app registers the Spotify account as a "source" in the user's Bose Cloud (Marge) profile.
|
||||
5. **Local Device Sync**: The app notifies the local SoundTouch speaker about the new source, which then updates its internal configuration.
|
||||
|
||||
---
|
||||
|
||||
## 0. User Authorization Initiation
|
||||
|
||||
The process begins in the Stockholm UI when the user selects Spotify to add a new account.
|
||||
|
||||
### Request Details (App to Browser)
|
||||
- **Action**: Open System Browser
|
||||
- **Base URL**: `[SPOTIFY_AUTH_URL]` (e.g., `https://accounts.spotify.com/authorize`)
|
||||
- **Query Parameters**:
|
||||
- `client_id`: Bose Spotify Client ID
|
||||
- `response_type`: `code`
|
||||
- `redirect_uri`: `http://localhost` (often used as a placeholder or specifically handled by the app's internal webview/proxy)
|
||||
- `scope`: `user-read-private user-read-email ...`
|
||||
- `state`: A base64-encoded JSON object containing metadata, e.g., `{"service": "SPOTIFY"}`.
|
||||
|
||||
### Redirect (Browser to App)
|
||||
Upon successful login and authorization, Spotify redirects the browser to a URL that the SoundTouch app intercepts.
|
||||
|
||||
- **URL Format**: `soundtouch://bose/musicservice/spotify/login?code=[AUTH_CODE]&state=[STATE]`
|
||||
- **App Action**: The `UIMain` component (in `ui_main.js`) handles this "deep link". It extracts the `code` from the query parameters and prepares to send it to the background worker.
|
||||
|
||||
---
|
||||
|
||||
## 1. OAuth Token Exchange (Bose Cloud)
|
||||
|
||||
After the UI intercepts the redirect and extracts the `code`, it sends a `createOAuthAccountRequest` to the background `SpotifyWorker`. The worker then performs the exchange for a Bose-mediated token.
|
||||
|
||||
### What is a "Bose-mediated token"?
|
||||
The "Bose-mediated token" is a token issued by the Bose OAuth proxy. When the app (or device) requests a token via `oauth.streaming.bose.com`, Bose's service performs the actual OAuth2 exchange with Spotify.
|
||||
|
||||
- **It is not directly a Spotify refresh token**: Instead, it is a Bose-issued token that *represents* the underlying Spotify session.
|
||||
- **Token Version 3**: Modern firmware uses `token_version_3`, which signifies that the device doesn't store the raw Spotify tokens but instead uses a Bose-specific "secret" that the Bose Cloud uses to fetch fresh Spotify access tokens on the device's behalf.
|
||||
- **Access vs Refresh**: The initial response from the `.../token/cs` endpoint typically contains an `access_token` (valid for ~1 hour) and a `token_type: "Bearer"`. The Bose cloud service manages the persistent refresh token internally.
|
||||
|
||||
### Internal Message (UI to Worker)
|
||||
- **Message Type**: `createOAuthAccountRequest`
|
||||
- **Payload**:
|
||||
```json
|
||||
{
|
||||
"source": "SPOTIFY",
|
||||
"code": "[AUTH_CODE_FROM_REDIRECT]",
|
||||
"credentialType": "token_version_3"
|
||||
}
|
||||
```
|
||||
|
||||
### Outgoing Request (Worker to Bose OAuth Proxy)
|
||||
- **Endpoint**: `https://oauth.streaming.bose.com/oauth/account/[ACCOUNT_ID]/music/musicprovider/15/token/cs`
|
||||
- **Method**: `POST`
|
||||
- **Headers**:
|
||||
- `Content-Type: application/json`
|
||||
- `Accept: application/json`
|
||||
- `Authorization: Bearer [SESSION_TOKEN]` (The user's Bose account session token)
|
||||
|
||||
### Payload (JSON)
|
||||
```json
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"code": "[AUTH_CODE_FROM_SPOTIFY]",
|
||||
"redirect_uri": "http://localhost"
|
||||
}
|
||||
```
|
||||
|
||||
### curl Example
|
||||
```bash
|
||||
curl -X POST "https://oauth.streaming.bose.com/oauth/account/[ACCOUNT_ID]/music/musicprovider/15/token/cs" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer [SESSION_TOKEN]" \
|
||||
-d '{
|
||||
"grant_type": "authorization_code",
|
||||
"code": "[AUTH_CODE_FROM_SPOTIFY]",
|
||||
"redirect_uri": "http://localhost"
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Cloud Source Registration (Marge)
|
||||
|
||||
The app now registers the Spotify account with the Bose "Marge" service. This makes the source available across all devices linked to the same Bose account.
|
||||
|
||||
### Request Details
|
||||
- **Endpoint**: `https://streaming.bose.com/streaming/account/[ACCOUNT_ID]/source`
|
||||
- **Method**: `POST`
|
||||
- **Headers**:
|
||||
- `Content-Type: application/vnd.bose.streaming-v1.1+xml`
|
||||
- `Authorization: [MARGE_TOKEN]`
|
||||
- `GUID: [DEVICE_GUID]`
|
||||
- `ClientType: Stockholm`
|
||||
|
||||
### Payload (XML)
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<source>
|
||||
<username>[SPOTIFY_USER_ID]</username>
|
||||
<sourceproviderid>15</sourceproviderid>
|
||||
<credential type="token_version_3">[SECRET_TOKEN_OBTAINED_IN_STEP_1]</credential>
|
||||
<sourcename>[DISPLAY_NAME_E_G_EMAIL]</sourcename>
|
||||
</source>
|
||||
```
|
||||
|
||||
### curl Example
|
||||
```bash
|
||||
curl -X POST "https://streaming.bose.com/streaming/account/[ACCOUNT_ID]/source" \
|
||||
-H "Content-Type: application/vnd.bose.streaming-v1.1+xml" \
|
||||
-H "Authorization: [MARGE_TOKEN]" \
|
||||
-d '<?xml version="1.0" encoding="UTF-8"?><source><username>[USER]</username><sourceproviderid>15</sourceproviderid><credential type="token_version_3">[TOKEN]</credential><sourcename>[NAME]</sourcename></source>'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Local Device Sync (LISA API)
|
||||
|
||||
The app notifies the physical SoundTouch speaker about the new source. This is usually done via the device's management API on port 8090.
|
||||
|
||||
#### Modern Flow (OAuth)
|
||||
- **Endpoint**: `http://[DEVICE_IP]:8090/setMusicServiceOAuthAccount`
|
||||
- **Method**: `POST`
|
||||
- **Payload**:
|
||||
```xml
|
||||
<OAuthCredentials source="SPOTIFY" displayName="[DISPLAY_NAME]">
|
||||
<user>[SPOTIFY_USER_ID]</user>
|
||||
<code>[AUTH_CODE_OR_TOKEN]</code>
|
||||
<version>token_version_3</version>
|
||||
</OAuthCredentials>
|
||||
```
|
||||
|
||||
#### Marge-Sync Notification (Fall-back)
|
||||
If the speaker returns `1029 UNKNOWN_ACTION_ERROR`, it signifies the LISA API version is too old for the OAuth flow. Stockholm-based firmware often expects the account to be registered in Marge first, followed by a notification to sync.
|
||||
- **Endpoint**: `http://[DEVICE_IP]:8090/notification`
|
||||
- **Method**: `POST`
|
||||
- **Payload**:
|
||||
```xml
|
||||
<updates deviceID="[DEVICE_UID]">
|
||||
<sourcesUpdated></sourcesUpdated>
|
||||
</updates>
|
||||
```
|
||||
|
||||
#### Legacy Flow (Fall-back)
|
||||
For older firmware that doesn't use Marge for Spotify:
|
||||
- **Endpoint**: `http://[DEVICE_IP]:8090/setMusicServiceAccount`
|
||||
- **Method**: `POST`
|
||||
- **Payload**:
|
||||
```xml
|
||||
<credentials source="SPOTIFY" displayName="Spotify Premium">
|
||||
<user>[USER]</user>
|
||||
<pass>[TOKEN]</pass>
|
||||
</credentials>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation in SoundTouch-Service
|
||||
|
||||
This project implements the "Bose-mediated token" flow as follows:
|
||||
|
||||
1. **Surrogate Secrets**: When a user links their Spotify account via `soundtouch-service`, the service generates a 32-character hex string (a "Bose Secret").
|
||||
2. **Marge & LISA registration**: This secret is sent to the speaker and stored in the emulated Marge cloud as the `credential`. The raw Spotify refresh token never leaves the server.
|
||||
3. **Token Refresh Proxy**: When the speaker needs a fresh Spotify `access_token`, it calls the `soundtouch-service` proxy (`/oauth/device/.../token/cs3`) providing this secret. The server maps the secret back to the actual Spotify account, performs the refresh with Spotify, and returns a fresh short-lived `access_token` to the speaker.
|
||||
|
||||
---
|
||||
|
||||
## Placeholders and Constants
|
||||
|
||||
| Placeholder | Description |
|
||||
|:------------------|:----------------------------------------------------|
|
||||
| `[ACCOUNT_ID]` | The internal Bose account ID (UUID). |
|
||||
| `[SESSION_TOKEN]` | Temporary token from Bose login. |
|
||||
| `[MARGE_TOKEN]` | Persistent authorization token for Marge services. |
|
||||
| `[DEVICE_GUID]` | Unique identifier for the controller app instance. |
|
||||
| `[DEVICE_IP]` | Local IP address of the SoundTouch speaker. |
|
||||
| `15` | Constant `sourceproviderid` for Spotify. |
|
||||
| `token_version_3` | Credential type for modern OAuth2 Spotify accounts. |
|
||||
|
||||
---
|
||||
|
||||
## Resulting Persistence
|
||||
|
||||
Once these requests succeed, the device updates its `/mnt/nv/BoseApp-Persistence/1/Sources.xml` file:
|
||||
|
||||
```xml
|
||||
<source displayName="user@example.com" secret="[SECRET_BLOB]" secretType="token_version_3">
|
||||
<sourceKey type="SPOTIFY" account="user" />
|
||||
</source>
|
||||
```
|
||||
@@ -0,0 +1,140 @@
|
||||
# SCMUDC Events Analysis
|
||||
|
||||
## Overview
|
||||
|
||||
SCMUDC (Sound Control Management Usage Data Collection) events are telemetry data sent from SoundTouch devices to `events.api.bosecm.com` via `/v1/scmudc/{deviceId}` endpoints. These events track user interactions and device behaviors for analytics and monitoring.
|
||||
|
||||
## Event Origins
|
||||
|
||||
Analysis of recorded interactions reveals three distinct origins for device events:
|
||||
|
||||
### 1. `"gabbo"` - SoundTouch App (Mobile/Desktop)
|
||||
- **Source**: Remote control via SoundTouch mobile/desktop applications
|
||||
- **Frequency**: Highest (primary control method)
|
||||
- **Event Types**: User-initiated actions through app interface
|
||||
- **Button Abstraction**: App UI elements (not physical buttons)
|
||||
|
||||
**Common Events**:
|
||||
- `power-pressed` → Power on/off via app
|
||||
- `play-pressed` → Play control
|
||||
- `pause-pressed` → Pause control
|
||||
- `skip-forward-pressed` → Next track
|
||||
- `stop-pressed` → Stop playback
|
||||
|
||||
### 2. `"console"` - Device Hardware Controls
|
||||
- **Source**: Physical buttons and controls on the speaker device
|
||||
- **Frequency**: Lower (secondary control method)
|
||||
- **Event Types**: Direct hardware interaction
|
||||
- **Physical Controls**: Actual buttons, knobs, or touch interfaces on device
|
||||
|
||||
**Common Events**:
|
||||
- `preset-pressed` → Physical preset buttons (PRESET_1, PRESET_5, etc.)
|
||||
- `power-pressed` → Hardware power button
|
||||
|
||||
### 3. `"device"` - Internal System Actions
|
||||
- **Source**: Device's internal software systems
|
||||
- **Frequency**: Automatic responses to user actions
|
||||
- **Event Types**: System-generated events, content playback
|
||||
- **Rich Content**: Base64-encoded XML with detailed metadata
|
||||
|
||||
**Common Events**:
|
||||
- `play-item` → Automatic content playback responses
|
||||
- `preset-assigned` → System preset assignments
|
||||
|
||||
## Event Data Structure
|
||||
|
||||
### Standard Button Events (gabbo/console)
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"buttonId": "POWER|PLAY|PAUSE|PRESET_5|etc",
|
||||
"origin": "gabbo|console"
|
||||
},
|
||||
"type": "power-pressed|play-pressed|pause-pressed|preset-pressed|etc"
|
||||
}
|
||||
```
|
||||
|
||||
### Device Content Events
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"contentItem": "PD94bWwgdmVyc2lvbj0...", // Base64-encoded XML
|
||||
"origin": "device",
|
||||
"preset": "none|P1|P5|etc"
|
||||
},
|
||||
"type": "play-item|preset-assigned"
|
||||
}
|
||||
```
|
||||
|
||||
## Content Item Structure
|
||||
|
||||
Device events include Base64-encoded XML with rich content metadata:
|
||||
|
||||
```xml
|
||||
<ContentItem source="SPOTIFY" type="tracklisturl"
|
||||
location="/playback/container/c3BvdGlmeTpwbGF5bGlzdDox..."
|
||||
sourceAccount="gesellix" isPresetable="true">
|
||||
<itemName>Billie Eilish - bad guy (instrumental version)</itemName>
|
||||
<containerArt>https://i.scdn.co/image/ab67616d0000b273...</containerArt>
|
||||
</ContentItem>
|
||||
```
|
||||
|
||||
**Key Fields**:
|
||||
- `source`: Music service (SPOTIFY, PANDORA, etc.)
|
||||
- `itemName`: Track/playlist/station name
|
||||
- `sourceAccount`: User account on the service
|
||||
- `location`: Service-specific content identifier
|
||||
- `containerArt`: Album/playlist artwork URL
|
||||
- `isPresetable`: Whether content can be saved as preset
|
||||
|
||||
## Usage Patterns
|
||||
|
||||
### Control Method Preferences
|
||||
1. **Primary**: SoundTouch App (`gabbo`) - Most frequent interactions
|
||||
2. **Secondary**: Device Hardware (`console`) - Occasional direct control
|
||||
3. **Automatic**: Internal System (`device`) - Background responses
|
||||
|
||||
### Event Flow
|
||||
1. User triggers action via app or hardware
|
||||
2. Device processes request and begins playback
|
||||
3. Device sends content event with full metadata
|
||||
4. System continues tracking playback state
|
||||
|
||||
## Telemetry Insights
|
||||
|
||||
### User Behavior Analytics
|
||||
- **Interface Preference**: App vs. hardware control usage ratios
|
||||
- **Feature Usage**: Most/least used controls and functions
|
||||
- **Content Patterns**: Music service preferences, playlist usage
|
||||
|
||||
### Device Health Monitoring
|
||||
- **Interaction Frequency**: Normal vs. abnormal usage patterns
|
||||
- **Error Detection**: Failed commands or unusual event sequences
|
||||
- **Performance**: Response times between user action and system response
|
||||
|
||||
### Service Integration Analysis
|
||||
- **Music Services**: Spotify dominance, other service usage
|
||||
- **Account Mapping**: User accounts across different services
|
||||
- **Content Types**: Music vs. radio vs. podcast preferences
|
||||
|
||||
## Data Quality Notes
|
||||
|
||||
- All events include comprehensive device information (deviceID, serialNumber, softwareVersion)
|
||||
- Timestamps include both UTC time and device monotonic time
|
||||
- Events are batched and sent with consistent protocol versioning
|
||||
- Content metadata is rich and includes artwork URLs for UI enhancement
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Events include user account information and listening habits
|
||||
- Device serial numbers and unique identifiers are transmitted
|
||||
- Content location data could reveal usage patterns
|
||||
- Data should be handled according to privacy regulations
|
||||
|
||||
## Technical Implementation Notes
|
||||
|
||||
- Endpoint: `POST /v1/scmudc/{deviceId}`
|
||||
- Protocol Version: 3.1 (current)
|
||||
- Encoding: JSON with Base64-encoded XML payloads
|
||||
- Authentication: Bearer token authorization
|
||||
- Content-Type: `text/json; charset=utf-8`
|
||||
@@ -1,8 +1,8 @@
|
||||
module navigation-station-demo
|
||||
|
||||
go 1.25.7
|
||||
go 1.26.3
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.0.0
|
||||
require github.com/gesellix/bose-soundtouch v0.71.2
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
module preset-management-example
|
||||
|
||||
go 1.25.7
|
||||
go 1.26.3
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.0.0
|
||||
require github.com/gesellix/bose-soundtouch v0.71.2
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// Package main demonstrates the new recording filename format that includes date information.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== Recording Filename Format Demo ===")
|
||||
fmt.Println()
|
||||
|
||||
// Create a temporary directory for the demo
|
||||
tmpDir, err := os.MkdirTemp("", "recording-filename-demo")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if removeErr := os.RemoveAll(tmpDir); removeErr != nil {
|
||||
log.Printf("Failed to remove temp directory: %v", removeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
fmt.Printf("Demo recordings will be saved to: %s\n\n", tmpDir)
|
||||
|
||||
// Create a recorder with async disabled for predictable demo output
|
||||
if envErr := os.Setenv("RECORDER_ASYNC", "false"); envErr != nil {
|
||||
log.Printf("Failed to set environment variable: %v", envErr)
|
||||
}
|
||||
|
||||
recorder := proxy.NewRecorder(tmpDir)
|
||||
defer recorder.Close()
|
||||
|
||||
fmt.Printf("Recorder session ID: %s\n", recorder.SessionID)
|
||||
fmt.Println()
|
||||
|
||||
// Create some sample HTTP requests to record
|
||||
requests := []struct {
|
||||
method string
|
||||
path string
|
||||
category string
|
||||
}{
|
||||
{"GET", "/info", "self"},
|
||||
{"POST", "/volume", "self"},
|
||||
{"GET", "/nowPlaying", "self"},
|
||||
{"PUT", "/preset_1", "self"},
|
||||
}
|
||||
|
||||
fmt.Println("Recording sample HTTP interactions...")
|
||||
fmt.Println()
|
||||
|
||||
for i, req := range requests {
|
||||
// Create a mock HTTP request
|
||||
httpReq, reqErr := http.NewRequest(req.method, "http://soundtouch.local:8090"+req.path, nil)
|
||||
if reqErr != nil {
|
||||
log.Printf("Failed to create request: %v", reqErr)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create a mock response
|
||||
httpRes := &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: make(http.Header),
|
||||
Request: httpReq,
|
||||
}
|
||||
httpRes.Header.Set("Content-Type", "application/xml")
|
||||
|
||||
// Record the interaction
|
||||
err = recorder.Record(req.category, httpReq, httpRes)
|
||||
if err != nil {
|
||||
log.Printf("Failed to record interaction: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("%d. Recorded: %s %s\n", i+1, req.method, req.path)
|
||||
|
||||
// Small delay to show different timestamps
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("=== Generated Filenames ===")
|
||||
fmt.Println()
|
||||
|
||||
// Walk through the recordings directory to show the generated filenames
|
||||
interactionsDir := filepath.Join(tmpDir, "interactions")
|
||||
|
||||
err = filepath.Walk(interactionsDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if strings.HasSuffix(info.Name(), ".http") {
|
||||
// Get relative path from interactions directory
|
||||
rel, _ := filepath.Rel(interactionsDir, path)
|
||||
fmt.Printf("📁 %s\n", rel)
|
||||
|
||||
// Parse and explain the filename format
|
||||
filename := info.Name()
|
||||
parts := strings.Split(strings.TrimSuffix(filename, ".http"), "-")
|
||||
|
||||
if len(parts) == 4 && len(parts[1]) == 8 {
|
||||
// New format: count-yyyyMMdd-HHMMSS.sss-method.http
|
||||
counter := parts[0]
|
||||
dateStr := parts[1]
|
||||
timeStr := parts[2]
|
||||
method := parts[3]
|
||||
|
||||
// Format for display
|
||||
date := dateStr[0:4] + "-" + dateStr[4:6] + "-" + dateStr[6:8]
|
||||
time := timeStr[0:2] + ":" + timeStr[2:4] + ":" + timeStr[4:]
|
||||
|
||||
fmt.Printf(" 📋 Format: count-yyyyMMdd-HHMMSS.sss-method.http\n")
|
||||
fmt.Printf(" 🔢 Counter: %s\n", counter)
|
||||
fmt.Printf(" 📅 Date: %s (from %s)\n", date, dateStr)
|
||||
fmt.Printf(" 🕒 Time: %s (from %s)\n", time, timeStr)
|
||||
fmt.Printf(" 🔧 Method: %s\n", method)
|
||||
fmt.Printf(" ✨ Full timestamp: %s %s\n", date, time)
|
||||
} else {
|
||||
fmt.Printf(" ⚠️ Legacy format or unexpected structure\n")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Error walking directory: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("=== Comparison with Old Format ===")
|
||||
fmt.Println()
|
||||
fmt.Println("🔴 OLD format (time only): 0047-21-53-06.128-GET.http")
|
||||
fmt.Println(" - No date information in filename")
|
||||
fmt.Println(" - Date extracted from session ID directory")
|
||||
fmt.Println(" - Confusing when recordings span midnight")
|
||||
fmt.Println()
|
||||
fmt.Println("🟢 NEW format (date + time): 0047-20260223-215306.128-GET.http")
|
||||
fmt.Println(" - Complete timestamp in filename")
|
||||
fmt.Println(" - Self-contained, no need to check directory")
|
||||
fmt.Println(" - Clear chronological ordering")
|
||||
fmt.Println()
|
||||
|
||||
fmt.Println("=== Benefits ===")
|
||||
fmt.Println("✅ No confusion when recordings cross midnight")
|
||||
fmt.Println("✅ Complete timestamp visible at a glance")
|
||||
fmt.Println("✅ Better sorting and organization")
|
||||
fmt.Println("✅ Backwards compatible with existing parsing logic")
|
||||
fmt.Println()
|
||||
|
||||
// Test the list interactions functionality
|
||||
fmt.Println("=== Using ListInteractions API ===")
|
||||
fmt.Println()
|
||||
|
||||
interactions, err := recorder.ListInteractions("", "", "")
|
||||
if err != nil {
|
||||
log.Printf("Failed to list interactions: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d recorded interactions:\n", len(interactions))
|
||||
|
||||
for i := range interactions {
|
||||
interaction := &interactions[i]
|
||||
fmt.Printf("%d. %s %s - %s (File: %s)\n",
|
||||
i+1, interaction.Method, interaction.Path,
|
||||
interaction.Timestamp, interaction.ID)
|
||||
}
|
||||
|
||||
fmt.Printf("\nDemo completed! Recordings saved in: %s\n", tmpDir)
|
||||
fmt.Println("You can explore the generated files to see the new format in action.")
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user