mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
747a9cec97 | ||
|
|
88c83b6131 | ||
|
|
5943abfddd | ||
|
|
56e82d5a01 | ||
|
|
56256de47b | ||
|
|
5b99d7f46b | ||
|
|
d0ce48ef03 | ||
|
|
9704e2d8ac | ||
|
|
aa5a25b382 | ||
|
|
f14cb45680 | ||
|
|
1fecb3948e | ||
|
|
0e2f05e6e5 | ||
|
|
ffe61dd7a6 | ||
|
|
76bb19ebcb | ||
|
|
13b8e7be82 | ||
|
|
57d020c407 | ||
|
|
4348d22c5c | ||
|
|
82fd77c8e2 | ||
|
|
0b59e66f70 | ||
|
|
3678719627 | ||
|
|
ccfd49778e | ||
|
|
bdc1f71ece | ||
|
|
68f8efce4e | ||
|
|
276d01fe42 | ||
|
|
4de7911817 | ||
|
|
5d933f7ebc | ||
|
|
153d387aaf | ||
|
|
fea6df32f3 | ||
|
|
3b1c639892 | ||
|
|
740cf54b9d | ||
|
|
e5b94158e6 | ||
|
|
c54ee79320 | ||
|
|
c4cf078d2a | ||
|
|
382567d67d | ||
|
|
de96b1f119 | ||
|
|
d22dc99c9e | ||
|
|
bd0e3d64a3 | ||
|
|
379ac758f6 | ||
|
|
6d0b5f2c78 | ||
|
|
f354c63bac | ||
|
|
50e45ab5f2 | ||
|
|
c1e7d513b4 | ||
|
|
cc92430e69 | ||
|
|
181cd550e3 | ||
|
|
7c92a785a4 | ||
|
|
b79a168084 | ||
|
|
21ce44fa2e | ||
|
|
65f1a2565c | ||
|
|
aa7b2c28ab | ||
|
|
8ef8d71121 | ||
|
|
c5c88f32c3 | ||
|
|
0b8f561077 | ||
|
|
71e3260823 | ||
|
|
bc1b70b8a5 | ||
|
|
a8140ad4fd | ||
|
|
766671f02b | ||
|
|
a06657f3f5 | ||
|
|
505a189ce5 | ||
|
|
509f613e34 | ||
|
|
1478d97886 | ||
|
|
a40fd8cdac | ||
|
|
e0a84d5904 | ||
|
|
5d080cf35f | ||
|
|
bcd383bdff | ||
|
|
7a09a2ddc0 | ||
|
|
b04b0bcc32 | ||
|
|
61b5c71097 | ||
|
|
9f7cb81b45 | ||
|
|
d5d6585517 | ||
|
|
50b694aa08 | ||
|
|
717693e01f | ||
|
|
a0833c113c | ||
|
|
e74d2e0fc3 | ||
|
|
5b642010d4 | ||
|
|
5078d933d5 | ||
|
|
6cf511e7e5 | ||
|
|
ba11394d0f | ||
|
|
37eb23fc36 | ||
|
|
4544486221 | ||
|
|
17bd3ea9ed | ||
|
|
ad5344b309 | ||
|
|
8d95e170f6 | ||
|
|
565ca33345 | ||
|
|
e2d52d9e3b | ||
|
|
f3b74998f1 | ||
|
|
ce15e706b8 | ||
|
|
bc61081acc | ||
|
|
16f7327b7a | ||
|
|
2e9f931797 | ||
|
|
41378f720b | ||
|
|
c87a28f3ba | ||
|
|
df18749220 | ||
|
|
b6702cd4b5 | ||
|
|
fc5de2bbc7 | ||
|
|
cf82feca06 | ||
|
|
b8bbc52803 | ||
|
|
a36c2e4629 | ||
|
|
d15cebdc95 | ||
|
|
d296b59a9e | ||
|
|
d2aaed0f9f | ||
|
|
eb50e9b6f6 | ||
|
|
1e24ca076a | ||
|
|
b19835427b | ||
|
|
6ee0fc8115 | ||
|
|
6211e34050 | ||
|
|
2132674768 | ||
|
|
b4c015ef75 | ||
|
|
5d22a53c8b | ||
|
|
f4268f3111 | ||
|
|
71fd9c1531 | ||
|
|
53184a6bca | ||
|
|
d97cd45b22 | ||
|
|
5edab77209 | ||
|
|
a1d0213f92 | ||
|
|
0b75a2f70d | ||
|
|
0090746b89 | ||
|
|
be762dbc22 | ||
|
|
403e2275dc | ||
|
|
9ee1c96477 | ||
|
|
b71a3830ec | ||
|
|
f50ee1131e | ||
|
|
6a65376784 | ||
|
|
44d04a2b41 | ||
|
|
0f802e65c6 | ||
|
|
01d702c745 | ||
|
|
7823b68bdd | ||
|
|
e1f3fc36c8 | ||
|
|
d68599896d | ||
|
|
d18b67d80f | ||
|
|
8642ecfc5c | ||
|
|
c37e94b5f8 | ||
|
|
4e33f6948f | ||
|
|
743ff5e061 |
@@ -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
|
||||
@@ -25,6 +25,18 @@
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"replacementPatterns": [
|
||||
|
||||
+52
-12
@@ -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:
|
||||
@@ -100,7 +115,7 @@ jobs:
|
||||
go build -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: |
|
||||
@@ -230,11 +269,11 @@ 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 }}
|
||||
@@ -242,7 +281,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
@@ -250,9 +289,10 @@ jobs:
|
||||
type=ref,event=pr
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
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 }}
|
||||
@@ -293,7 +333,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..."
|
||||
@@ -165,12 +168,16 @@ jobs:
|
||||
|
||||
# Build Service
|
||||
build_binary "soundtouch-service" "./cmd/soundtouch-service"
|
||||
|
||||
# Build Web
|
||||
build_binary "soundtouch-web" "./cmd/soundtouch-web"
|
||||
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 }}"
|
||||
|
||||
# Use atomic operations to avoid conflicts
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
@@ -186,18 +193,20 @@ jobs:
|
||||
|
||||
generate_checksums "$CLI_NAME"
|
||||
generate_checksums "$SVC_NAME"
|
||||
generate_checksums "$WEB_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*
|
||||
retention-days: 1
|
||||
|
||||
checksums:
|
||||
@@ -207,7 +216,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download binary artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: binaries-*
|
||||
path: ./binaries
|
||||
@@ -224,7 +233,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-*" \) -exec mv {} release-files/ \;
|
||||
|
||||
# Remove empty directories
|
||||
find . -type d -empty -delete
|
||||
@@ -246,7 +255,7 @@ jobs:
|
||||
cat checksums.sha256
|
||||
|
||||
# Verify all expected files are present (binaries only, not checksum files)
|
||||
EXPECTED_COUNT=14 # 7 platforms * 2 binaries
|
||||
EXPECTED_COUNT=21 # 7 platforms * 3 binaries
|
||||
ACTUAL_COUNT=$(ls soundtouch-* | grep -v '\.sha256$' | grep -v '\.sha512$' | wc -l)
|
||||
|
||||
if [[ $ACTUAL_COUNT -ne $EXPECTED_COUNT ]]; then
|
||||
@@ -264,7 +273,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload checksums
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: checksums
|
||||
path: |
|
||||
@@ -275,7 +284,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 +303,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 +386,12 @@ jobs:
|
||||
./soundtouch-service
|
||||
\`\`\`
|
||||
|
||||
### SoundTouch Web
|
||||
\`\`\`bash
|
||||
# Start the web app
|
||||
./soundtouch-web
|
||||
\`\`\`
|
||||
|
||||
## 🧪 Tested Hardware
|
||||
|
||||
- Bose SoundTouch 10
|
||||
@@ -395,7 +410,7 @@ jobs:
|
||||
- Windows (amd64)
|
||||
- FreeBSD (amd64)
|
||||
|
||||
Both `soundtouch-cli` and `soundtouch-service` are included.
|
||||
`soundtouch-cli`, `soundtouch-service`, and `soundtouch-web` are included.
|
||||
|
||||
## 🔐 Checksums
|
||||
|
||||
@@ -440,7 +455,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 +465,7 @@ jobs:
|
||||
files: |
|
||||
release-assets/soundtouch-cli-v*
|
||||
release-assets/soundtouch-service-v*
|
||||
release-assets/soundtouch-web-v*
|
||||
release-assets/checksums.sha256
|
||||
release-assets/checksums.sha512
|
||||
fail_on_unmatched_files: true
|
||||
@@ -464,18 +480,19 @@ 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/checksums.sha256
|
||||
release-assets/checksums.sha512
|
||||
fail_on_unmatched_files: true
|
||||
@@ -492,10 +509,10 @@ 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 }}
|
||||
@@ -503,7 +520,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
@@ -512,9 +529,10 @@ jobs:
|
||||
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -531,7 +549,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, and Web)"
|
||||
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
|
||||
|
||||
@@ -14,6 +14,7 @@ dist/
|
||||
# Root-level binary executables (exclude built binaries in root)
|
||||
/soundtouch-cli
|
||||
/soundtouch-service
|
||||
/soundtouch-web
|
||||
/example-mdns
|
||||
/example-upnp
|
||||
/example-unified
|
||||
|
||||
+16
-2
@@ -1,5 +1,12 @@
|
||||
# Build stage
|
||||
FROM golang:1.26.0-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.2-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,7 +18,11 @@ 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
|
||||
@@ -24,6 +35,9 @@ WORKDIR /app
|
||||
# Copy the binary from the builder stage
|
||||
COPY --from=builder /soundtouch-service /app/soundtouch-service
|
||||
|
||||
# Verify the binary works on the target platform
|
||||
RUN /app/soundtouch-service version || echo "Binary verification complete"
|
||||
|
||||
# Create data directory for persistence
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
|
||||
@@ -14,12 +14,16 @@ 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)
|
||||
BUILD_DIR=./build
|
||||
|
||||
# Version info
|
||||
@@ -27,7 +31,7 @@ BUILD_DIR=./build
|
||||
|
||||
all: check build
|
||||
|
||||
build: build-cli build-service build-examples
|
||||
build: build-cli build-service build-web build-examples build-favicon-gen
|
||||
|
||||
build-cli:
|
||||
@echo "Building $(BINARY_NAME)..."
|
||||
@@ -39,6 +43,11 @@ build-service:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME) $(SERVICE_PATH)
|
||||
|
||||
build-web:
|
||||
@echo "Building $(WEB_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(WEB_NAME) $(WEB_PATH)
|
||||
|
||||
build-examples:
|
||||
@echo "Building $(EXAMPLE_MDNS_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@@ -48,6 +57,11 @@ build-examples:
|
||||
@echo "Building $(SCANNER_NAME)..."
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME) $(SCANNER_PATH)
|
||||
|
||||
build-favicon-gen:
|
||||
@echo "Building $(FAVICON_GEN_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(FAVICON_GEN_NAME) $(FAVICON_GEN_PATH)
|
||||
|
||||
build-all: build-linux build-darwin build-windows build-examples-all
|
||||
|
||||
build-linux:
|
||||
@@ -96,7 +110,53 @@ 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/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/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 down; \
|
||||
exit $$EXIT_CODE
|
||||
|
||||
fmt:
|
||||
@echo "Formatting code..."
|
||||
@@ -187,10 +247,31 @@ 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-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
|
||||
@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/
|
||||
|
||||
clean:
|
||||
@echo "Cleaning..."
|
||||
@@ -226,6 +307,7 @@ 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-favicon-gen - Build the favicon generator"
|
||||
@echo " build-examples - Build only the example programs"
|
||||
@echo " build-all - Build for all platforms"
|
||||
@echo " test - Run tests"
|
||||
@@ -249,6 +331,9 @@ 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-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 +355,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"
|
||||
|
||||
@@ -17,6 +17,7 @@ A comprehensive solution for controlling and preserving Bose SoundTouch devices,
|
||||
- ⚡ **Real-time Events**: WebSocket connection for live device state monitoring
|
||||
- 🔍 **Device Discovery**: Automatic discovery via UPnP/SSDP and mDNS
|
||||
- 📻 **Content Navigation**: Browse and search TuneIn, Pandora, Spotify, local music
|
||||
- 📻 **Custom Radio**: Play any stream URL via [flexible proxying](docs/guides/CLI-REFERENCE.md#custom-radio-selection-via-soundtouch-service)
|
||||
- 📻 **RadioBrowser**: Access thousands of internet radio stations via [radio-browser.info](docs/reference/radio-browser.md)
|
||||
- 🎙️ **Station Management**: Add and play radio stations without presets
|
||||
- 🖥️ **CLI Tool**: Comprehensive command-line interface
|
||||
@@ -26,6 +27,8 @@ A comprehensive solution for controlling and preserving Bose SoundTouch devices,
|
||||
- 📊 **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
|
||||
@@ -77,6 +80,8 @@ The `soundtouch-service` is a local server that emulates Bose's cloud services.
|
||||
- **🔧 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
|
||||
|
||||
@@ -102,7 +107,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
)
|
||||
|
||||
@@ -112,20 +117,20 @@ func main() {
|
||||
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 {
|
||||
@@ -143,7 +148,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
)
|
||||
|
||||
@@ -154,9 +159,9 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
for _, device := range devices {
|
||||
fmt.Printf("Found: %s at %s:%d\n",
|
||||
fmt.Printf("Found: %s at %s:%d\n",
|
||||
device.Name, device.Host, device.Port)
|
||||
}
|
||||
}
|
||||
@@ -170,7 +175,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
@@ -180,13 +185,13 @@ func main() {
|
||||
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:
|
||||
@@ -207,7 +212,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
@@ -217,21 +222,21 @@ func main() {
|
||||
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",
|
||||
@@ -245,7 +250,7 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
// Store radio station as preset 3
|
||||
radioContent := &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
@@ -258,13 +263,13 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
// Select preset 1
|
||||
err = c.SelectPreset(1)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
fmt.Println("Preset management complete!")
|
||||
}
|
||||
```
|
||||
@@ -275,7 +280,7 @@ package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
@@ -285,7 +290,7 @@ func main() {
|
||||
Host: "192.168.1.100", // Master speaker
|
||||
Port: 8090,
|
||||
})
|
||||
|
||||
|
||||
// Create a multiroom zone
|
||||
zone := &models.Zone{
|
||||
Master: "192.168.1.100",
|
||||
@@ -294,12 +299,12 @@ func main() {
|
||||
{IPAddress: "192.168.1.102"}, // Kitchen
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
err := master.SetZone(zone)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
fmt.Println("Multiroom zone created!")
|
||||
}
|
||||
```
|
||||
@@ -310,7 +315,7 @@ package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
)
|
||||
|
||||
@@ -319,13 +324,13 @@ func main() {
|
||||
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",
|
||||
@@ -338,13 +343,13 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
// Play notification beep
|
||||
err = c.PlayNotificationBeep()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
fmt.Println("Notifications sent!")
|
||||
}
|
||||
```
|
||||
@@ -354,7 +359,7 @@ func main() {
|
||||
This library supports all Bose SoundTouch-compatible devices, including:
|
||||
|
||||
- SoundTouch 10, 20, 30 series
|
||||
- SoundTouch Portable
|
||||
- SoundTouch Portable
|
||||
- Wave SoundTouch music system
|
||||
- SoundTouch-enabled Bose speakers
|
||||
|
||||
@@ -475,7 +480,7 @@ SoundTouch is a trademark of Bose Corporation.
|
||||
|
||||
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.
|
||||
**Community Alternatives**: See the [Related Projects & Credits](#related-projects--credits) 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
|
||||
|
||||
@@ -515,7 +520,7 @@ This project builds upon the excellent work of several community projects:
|
||||
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
|
||||
- **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
|
||||
|
||||
@@ -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 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+499
-162
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -33,10 +34,15 @@ var (
|
||||
version = "dev"
|
||||
commit = "unknown"
|
||||
date = "unknown"
|
||||
repoURL = "https://github.com/gesellix/bose-soundtouch"
|
||||
)
|
||||
|
||||
func updateBuildInfo() {
|
||||
if info, ok := debug.ReadBuildInfo(); ok {
|
||||
if info.Main.Path != "" {
|
||||
repoURL = "https://" + info.Main.Path
|
||||
}
|
||||
|
||||
if info.Main.Version != "" && info.Main.Version != "(devel)" {
|
||||
version = info.Main.Version
|
||||
}
|
||||
@@ -47,7 +53,54 @@ func updateBuildInfo() {
|
||||
commit = setting.Value
|
||||
case "vcs.time":
|
||||
if t, err := time.Parse(time.RFC3339, setting.Value); err == nil {
|
||||
date = t.Format("2006-01-02_15:04:05")
|
||||
date = t.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func initializeDefaultSources(ds *datastore.DataStore) {
|
||||
// Ensure default sources exist for all known devices on startup
|
||||
allDevices, _ := ds.ListAllDevices()
|
||||
for i := range allDevices {
|
||||
dev := &allDevices[i]
|
||||
if sources, errGet := ds.GetConfiguredSources(dev.AccountID, dev.DeviceID); errGet == nil {
|
||||
log.Printf("Initializing default Sources.xml for existing device %s", dev.DeviceID)
|
||||
|
||||
// Find default sources and merge them if missing or outdated tokens
|
||||
defaults := ds.GetDefaultSources()
|
||||
modified := false
|
||||
|
||||
for i := range defaults {
|
||||
def := defaults[i]
|
||||
found := false
|
||||
|
||||
for j := range sources {
|
||||
if sources[j].SourceKeyType == def.SourceKeyType {
|
||||
found = true
|
||||
|
||||
if sources[j].Secret == "" && def.Secret != "" {
|
||||
log.Printf("Initializing missing token for source %s on device %s", def.SourceKeyType, dev.DeviceID)
|
||||
sources[j].Secret = def.Secret
|
||||
sources[j].SecretType = def.SecretType
|
||||
modified = true
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
log.Printf("Adding missing default source %s to device %s", def.SourceKeyType, dev.DeviceID)
|
||||
sources = append(sources, def)
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
|
||||
if modified {
|
||||
if errSave := ds.SaveConfiguredSources(dev.AccountID, dev.DeviceID, sources); errSave != nil {
|
||||
log.Printf("Failed to save updated sources for %s: %v", dev.DeviceID, errSave)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,17 +134,6 @@ func main() {
|
||||
Usage: "Network interface to bind to",
|
||||
EnvVars: []string{"BIND_ADDR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "soundcork-url",
|
||||
Usage: "URL for Soundcork-based service components (legacy)",
|
||||
Value: "http://localhost:8001",
|
||||
EnvVars: []string{"SOUNDCORK_BACKEND_URL", "TARGET_URL"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "enable-soundcork-proxy",
|
||||
Usage: "Enable proxying unknown requests to the Soundcork backend",
|
||||
EnvVars: []string{"ENABLE_SOUNDCORK_PROXY"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "data-dir",
|
||||
Usage: "Directory for persistent data",
|
||||
@@ -146,8 +188,8 @@ func main() {
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "dns-upstream",
|
||||
Usage: "Upstream DNS server for non-Bose queries",
|
||||
Value: "8.8.8.8",
|
||||
Usage: "Upstream DNS server(s) for non-Bose queries (comma-separated). If empty, /etc/resolv.conf is used.",
|
||||
Value: "",
|
||||
EnvVars: []string{"DNS_UPSTREAM"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
@@ -172,6 +214,16 @@ func main() {
|
||||
Value: "ueberboese-login://spotify",
|
||||
EnvVars: []string{"SPOTIFY_REDIRECT_URI"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-token-url",
|
||||
Usage: "Spotify OAuth token URL (for testing)",
|
||||
EnvVars: []string{"SPOTIFY_TOKEN_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-api-base",
|
||||
Usage: "Spotify API base URL (for testing)",
|
||||
EnvVars: []string{"SPOTIFY_API_BASE"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "mgmt-username",
|
||||
Usage: "Management API username for HTTP Basic Auth",
|
||||
@@ -189,6 +241,43 @@ func main() {
|
||||
Usage: "External base URL for OAuth callbacks behind reverse proxy",
|
||||
EnvVars: []string{"BASE_URL"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "mirror-enabled",
|
||||
Usage: "Enable background mirroring to Bose Cloud",
|
||||
EnvVars: []string{"MIRROR_ENABLED"},
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "mirror-endpoints",
|
||||
Usage: "Endpoints to mirror to Bose Cloud (comma-separated or multiple flags)",
|
||||
EnvVars: []string{"MIRROR_ENDPOINTS"},
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "skip-mirror-endpoints",
|
||||
Usage: "Endpoints to skip mirroring to Bose Cloud (comma-separated or multiple flags)",
|
||||
EnvVars: []string{"SKIP_MIRROR_ENDPOINTS"},
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "internal-paths",
|
||||
Usage: "Paths for internal requests (comma-separated or multiple flags)",
|
||||
EnvVars: []string{"INTERNAL_PATHS"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "migration-enabled",
|
||||
Usage: "Enable device directory migration from serial to MAC-based structure",
|
||||
Value: true,
|
||||
EnvVars: []string{"MIGRATION_ENABLED"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "migration-dry-run",
|
||||
Usage: "Log what would be migrated without actually doing it",
|
||||
EnvVars: []string{"MIGRATION_DRY_RUN"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "preferred-source",
|
||||
Usage: "Preferred source of truth (local or upstream)",
|
||||
Value: "local",
|
||||
EnvVars: []string{"PREFERRED_SOURCE"},
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
config := loadConfig(c)
|
||||
@@ -211,16 +300,18 @@ func main() {
|
||||
|
||||
cm := initCertificateManager(config.dataDir)
|
||||
sm := setup.NewManager(config.serverURL, ds, cm)
|
||||
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record, config.enableSoundcorkProxy)
|
||||
sm.MgmtUsername = config.mgmtUsername
|
||||
sm.MgmtPassword = config.mgmtPassword
|
||||
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record)
|
||||
sm.GetDNSRunning = server.GetDNSRunning
|
||||
server.SetSoundcorkURL(config.soundcorkURL)
|
||||
server.SetHTTPServerURL(config.httpsServerURL)
|
||||
server.SetVersionInfo(version, commit, date)
|
||||
server.SetVersionInfo(version, commit, date, repoURL)
|
||||
server.SetDiscoverySettings(config.discoveryInterval, persisted.DiscoveryEnabled)
|
||||
server.SetDNSSettings(persisted.DNSEnabled, persisted.DNSUpstream, persisted.DNSBindAddr)
|
||||
server.SetDNSSettings(persisted.DNSEnabled, strings.Join(persisted.DNSUpstream, ","), persisted.DNSBindAddr)
|
||||
server.SetMirrorSettings(persisted.MirrorEnabled, persisted.MirrorEndpoints, persisted.SkipMirrorEndpoints, persisted.PreferredSource)
|
||||
server.SetInternalPaths(persisted.InternalPaths)
|
||||
server.SetSpotifyConfig(config.spotifyClientID, config.spotifyClientSecret, config.spotifyRedirectURI)
|
||||
server.SetMgmtConfig(config.mgmtUsername, config.mgmtPassword)
|
||||
server.SetBaseURL(config.baseURL)
|
||||
|
||||
if config.spotifyClientID != "" {
|
||||
spotifyService := spotify.NewSpotifyService(
|
||||
@@ -229,6 +320,14 @@ func main() {
|
||||
config.spotifyRedirectURI,
|
||||
config.dataDir,
|
||||
)
|
||||
if config.spotifyTokenURL != "" || config.spotifyAPIBase != "" {
|
||||
spotifyService.SetEndpoints(config.spotifyTokenURL, config.spotifyAPIBase)
|
||||
}
|
||||
|
||||
if err := spotifyService.Load(); err != nil {
|
||||
log.Printf("[Spotify] Failed to load accounts: %v", err)
|
||||
}
|
||||
|
||||
server.SetSpotifyService(spotifyService)
|
||||
|
||||
clientIDPrefix := config.spotifyClientID
|
||||
@@ -292,6 +391,8 @@ func main() {
|
||||
|
||||
server.SetRecorder(recorder)
|
||||
|
||||
initializeDefaultSources(ds)
|
||||
|
||||
tlsConfig, err := cm.GetServerTLSConfig(config.domains)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to setup TLS: %v", err)
|
||||
@@ -301,7 +402,7 @@ func main() {
|
||||
|
||||
r := setupRouter(server)
|
||||
|
||||
log.Printf("Go service starting on %s, proxying to %s", config.serverURL, config.soundcorkURL)
|
||||
log.Printf("Go service starting on %s", config.serverURL)
|
||||
|
||||
if tlsConfig != nil {
|
||||
startHTTPSServer(config.httpsAddr, r, tlsConfig, config.httpsServerURL)
|
||||
@@ -335,29 +436,35 @@ func showVersionInfo(_ *cli.Context) error {
|
||||
}
|
||||
|
||||
type serviceConfig struct {
|
||||
port string
|
||||
bindAddr string
|
||||
addr string
|
||||
soundcorkURL string
|
||||
dataDir string
|
||||
serverURL string
|
||||
httpsServerURL string
|
||||
httpsAddr string
|
||||
redact bool
|
||||
logBody bool
|
||||
record bool
|
||||
enableSoundcorkProxy bool
|
||||
dnsEnabled bool
|
||||
dnsUpstream string
|
||||
dnsBind string
|
||||
discoveryInterval time.Duration
|
||||
domains []string
|
||||
spotifyClientID string
|
||||
spotifyClientSecret string
|
||||
spotifyRedirectURI string
|
||||
mgmtUsername string
|
||||
mgmtPassword string
|
||||
baseURL string
|
||||
port string
|
||||
bindAddr string
|
||||
addr string
|
||||
dataDir string
|
||||
serverURL string
|
||||
httpsServerURL string
|
||||
httpsAddr string
|
||||
redact bool
|
||||
logBody bool
|
||||
record bool
|
||||
dnsEnabled bool
|
||||
dnsUpstream string
|
||||
dnsBind string
|
||||
mirrorEnabled bool
|
||||
mirrorEndpoints []string
|
||||
skipMirrorEndpoints []string
|
||||
internalPaths []string
|
||||
discoveryInterval time.Duration
|
||||
domains []string
|
||||
spotifyClientID string
|
||||
spotifyClientSecret string
|
||||
spotifyRedirectURI string
|
||||
spotifyTokenURL string
|
||||
spotifyAPIBase string
|
||||
mgmtUsername string
|
||||
mgmtPassword string
|
||||
migrationEnabled bool
|
||||
migrationDryRun bool
|
||||
preferredSource string
|
||||
}
|
||||
|
||||
func loadConfig(c *cli.Context) serviceConfig {
|
||||
@@ -369,7 +476,6 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
addr = ":" + port
|
||||
}
|
||||
|
||||
soundcorkURL := c.String("soundcork-url")
|
||||
dataDir := c.String("data-dir")
|
||||
|
||||
hostname, _ := os.Hostname()
|
||||
@@ -401,7 +507,6 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
redact := c.Bool("redact-logs")
|
||||
logBody := c.Bool("log-bodies")
|
||||
record := c.Bool("record-interactions")
|
||||
enableSoundcorkProxy := c.Bool("enable-soundcork-proxy")
|
||||
|
||||
dnsEnabled := c.Bool("dns-discovery")
|
||||
dnsUpstream := c.String("dns-upstream")
|
||||
@@ -419,48 +524,73 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
spotifyClientID := c.String("spotify-client-id")
|
||||
spotifyClientSecret := c.String("spotify-client-secret")
|
||||
spotifyRedirectURI := c.String("spotify-redirect-uri")
|
||||
spotifyTokenURL := c.String("spotify-token-url")
|
||||
spotifyAPIBase := c.String("spotify-api-base")
|
||||
mgmtUsername := c.String("mgmt-username")
|
||||
mgmtPassword := c.String("mgmt-password")
|
||||
baseURL := c.String("base-url")
|
||||
mirrorEnabled := c.Bool("mirror-enabled")
|
||||
mirrorEndpoints := c.StringSlice("mirror-endpoints")
|
||||
skipMirrorEndpoints := c.StringSlice("skip-mirror-endpoints")
|
||||
internalPaths := c.StringSlice("internal-paths")
|
||||
migrationEnabled := c.Bool("migration-enabled")
|
||||
migrationDryRun := c.Bool("migration-dry-run")
|
||||
preferredSource := c.String("preferred-source")
|
||||
|
||||
return serviceConfig{
|
||||
port: port,
|
||||
bindAddr: bindAddr,
|
||||
addr: addr,
|
||||
soundcorkURL: soundcorkURL,
|
||||
dataDir: dataDir,
|
||||
serverURL: serverURL,
|
||||
httpsServerURL: httpsServerURL,
|
||||
httpsAddr: httpsAddr,
|
||||
redact: redact,
|
||||
logBody: logBody,
|
||||
record: record,
|
||||
enableSoundcorkProxy: enableSoundcorkProxy,
|
||||
dnsEnabled: dnsEnabled,
|
||||
dnsUpstream: dnsUpstream,
|
||||
dnsBind: dnsBind,
|
||||
discoveryInterval: discoveryInterval,
|
||||
domains: domains,
|
||||
spotifyClientID: spotifyClientID,
|
||||
spotifyClientSecret: spotifyClientSecret,
|
||||
spotifyRedirectURI: spotifyRedirectURI,
|
||||
mgmtUsername: mgmtUsername,
|
||||
mgmtPassword: mgmtPassword,
|
||||
baseURL: baseURL,
|
||||
port: port,
|
||||
bindAddr: bindAddr,
|
||||
addr: addr,
|
||||
dataDir: dataDir,
|
||||
serverURL: serverURL,
|
||||
httpsServerURL: httpsServerURL,
|
||||
httpsAddr: httpsAddr,
|
||||
redact: redact,
|
||||
logBody: logBody,
|
||||
record: record,
|
||||
dnsEnabled: dnsEnabled,
|
||||
dnsUpstream: dnsUpstream,
|
||||
dnsBind: dnsBind,
|
||||
mirrorEnabled: mirrorEnabled,
|
||||
mirrorEndpoints: mirrorEndpoints,
|
||||
skipMirrorEndpoints: skipMirrorEndpoints,
|
||||
internalPaths: internalPaths,
|
||||
discoveryInterval: discoveryInterval,
|
||||
domains: domains,
|
||||
spotifyClientID: spotifyClientID,
|
||||
spotifyClientSecret: spotifyClientSecret,
|
||||
spotifyRedirectURI: spotifyRedirectURI,
|
||||
spotifyTokenURL: spotifyTokenURL,
|
||||
spotifyAPIBase: spotifyAPIBase,
|
||||
mgmtUsername: mgmtUsername,
|
||||
mgmtPassword: mgmtPassword,
|
||||
migrationEnabled: migrationEnabled,
|
||||
migrationDryRun: migrationDryRun,
|
||||
preferredSource: preferredSource,
|
||||
}
|
||||
}
|
||||
|
||||
func getDomains(serverURL, httpsServerURL, hostname string) []string {
|
||||
domainsMap := map[string]bool{
|
||||
"streaming.bose.com": true,
|
||||
"updates.bose.com": true,
|
||||
"stats.bose.com": true,
|
||||
"bmx.bose.com": true,
|
||||
"content.api.bose.io": true,
|
||||
setup.TestDomain: true,
|
||||
hostname: true,
|
||||
"localhost": true,
|
||||
"127.0.0.1": true,
|
||||
// RFC-compliant wildcards for API patterns
|
||||
"*.api.bose.io": true,
|
||||
"*.api.bosecm.com": true,
|
||||
// Core Bose domains (keep specific ones for clarity)
|
||||
"streaming.bose.com": true,
|
||||
"updates.bose.com": true,
|
||||
"stats.bose.com": true,
|
||||
"bmx.bose.com": true,
|
||||
"worldwide.bose.com": true,
|
||||
"music.api.bose.com": true,
|
||||
"streamingoauth.bose.com": true,
|
||||
"bosecm.com": true,
|
||||
"bose.io": true,
|
||||
"bose-prod.apigee.net": true,
|
||||
"bose-test.apigee.net": true,
|
||||
// Local service domains
|
||||
setup.TestDomain: true,
|
||||
hostname: true,
|
||||
"localhost": true,
|
||||
"127.0.0.1": true,
|
||||
}
|
||||
|
||||
if u, err := url.Parse(serverURL); err == nil && u.Hostname() != "" {
|
||||
@@ -485,12 +615,15 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
|
||||
return datastore.Settings{}
|
||||
}
|
||||
|
||||
if persisted.ServerURL != "" {
|
||||
config.serverURL = persisted.ServerURL
|
||||
// Only override CLI values if settings file exists
|
||||
// If no settings file exists, GetSettings returns empty Settings{} and we should preserve CLI values
|
||||
settingsPath := filepath.Join(ds.DataDir, "settings.json")
|
||||
if _, err := os.Stat(settingsPath); os.IsNotExist(err) {
|
||||
return datastore.Settings{}
|
||||
}
|
||||
|
||||
if persisted.SoundcorkURL != "" {
|
||||
config.soundcorkURL = persisted.SoundcorkURL
|
||||
if persisted.ServerURL != "" {
|
||||
config.serverURL = persisted.ServerURL
|
||||
}
|
||||
|
||||
if persisted.HTTPServerURL != "" {
|
||||
@@ -506,39 +639,48 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
|
||||
config.redact = persisted.RedactLogs
|
||||
config.logBody = persisted.LogBodies
|
||||
config.record = persisted.RecordInteractions
|
||||
config.enableSoundcorkProxy = persisted.EnableSoundcorkProxy
|
||||
|
||||
config.dnsEnabled = persisted.DNSEnabled
|
||||
if persisted.DNSUpstream != "" {
|
||||
config.dnsUpstream = persisted.DNSUpstream
|
||||
if len(persisted.DNSUpstream) > 0 {
|
||||
config.dnsUpstream = strings.Join(persisted.DNSUpstream, ",")
|
||||
}
|
||||
|
||||
if persisted.DNSBindAddr != "" {
|
||||
config.dnsBind = persisted.DNSBindAddr
|
||||
}
|
||||
|
||||
config.mirrorEnabled = persisted.MirrorEnabled
|
||||
config.mirrorEndpoints = persisted.MirrorEndpoints
|
||||
config.skipMirrorEndpoints = persisted.SkipMirrorEndpoints
|
||||
config.preferredSource = persisted.PreferredSource
|
||||
config.internalPaths = persisted.InternalPaths
|
||||
|
||||
return persisted
|
||||
}
|
||||
|
||||
func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datastore.Settings {
|
||||
settings := datastore.Settings{
|
||||
ServerURL: config.serverURL,
|
||||
SoundcorkURL: config.soundcorkURL,
|
||||
HTTPServerURL: config.httpsServerURL,
|
||||
RedactLogs: config.redact,
|
||||
LogBodies: config.logBody,
|
||||
RecordInteractions: config.record,
|
||||
DiscoveryInterval: config.discoveryInterval.String(),
|
||||
DiscoveryEnabled: true,
|
||||
EnableSoundcorkProxy: config.enableSoundcorkProxy,
|
||||
DNSEnabled: config.dnsEnabled,
|
||||
DNSUpstream: config.dnsUpstream,
|
||||
DNSBindAddr: config.dnsBind,
|
||||
ServerURL: config.serverURL,
|
||||
HTTPServerURL: config.httpsServerURL,
|
||||
RedactLogs: config.redact,
|
||||
LogBodies: config.logBody,
|
||||
RecordInteractions: config.record,
|
||||
DiscoveryInterval: config.discoveryInterval.String(),
|
||||
DiscoveryEnabled: true,
|
||||
DNSEnabled: config.dnsEnabled,
|
||||
DNSUpstream: strings.Split(config.dnsUpstream, ","),
|
||||
DNSBindAddr: config.dnsBind,
|
||||
MirrorEnabled: config.mirrorEnabled,
|
||||
MirrorEndpoints: config.mirrorEndpoints,
|
||||
SkipMirrorEndpoints: config.skipMirrorEndpoints,
|
||||
PreferredSource: config.preferredSource,
|
||||
InternalPaths: config.internalPaths,
|
||||
Shortcuts: map[string]int{
|
||||
"/.well-known/appspecific/com.chrome.devtools.json": http.StatusNotFound,
|
||||
"/sw.js": http.StatusNotFound,
|
||||
},
|
||||
}
|
||||
|
||||
_ = ds.SaveSettings(settings)
|
||||
|
||||
return settings
|
||||
@@ -577,9 +719,11 @@ func startDeviceDiscovery(server *handlers.Server) {
|
||||
|
||||
func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
r.Use(server.SnapshotMiddleware)
|
||||
r.Use(server.OriginMiddleware)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(server.ShortcutMiddleware)
|
||||
r.Use(server.MirrorMiddleware)
|
||||
r.Use(server.RecordMiddleware)
|
||||
|
||||
r.Get("/", server.HandleRoot)
|
||||
@@ -595,53 +739,110 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
|
||||
r.Route("/bmx", func(r chi.Router) {
|
||||
r.Get("/registry/v1/services", server.HandleBMXRegistry)
|
||||
r.Get("/tunein/v1/playback/station/{stationID}", server.HandleTuneInPlayback)
|
||||
r.Get("/tunein/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo)
|
||||
r.Get("/tunein/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast)
|
||||
r.Get("/registry/v1/servicesAvailability", server.HandleBMXServicesAvailability)
|
||||
|
||||
r.Route("/tunein", func(r chi.Router) {
|
||||
r.Get("/v1/playback/station/{stationID}", server.HandleTuneInPlayback)
|
||||
r.Get("/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo)
|
||||
r.Get("/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast)
|
||||
r.Post("/v1/token", server.HandleTuneInToken)
|
||||
r.Post("/v1/report", server.HandleTuneInReport)
|
||||
r.Get("/v1/navigate", server.HandleTuneInNavigate)
|
||||
r.Get("/v1/navigate/*", server.HandleTuneInNavigate)
|
||||
r.Get("/v1/search", server.HandleTuneInSearch)
|
||||
})
|
||||
|
||||
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
|
||||
})
|
||||
|
||||
// Legacy or direct domain calls without /bmx prefix
|
||||
r.Get("/registry/v1/services", server.HandleBMXRegistry)
|
||||
r.Get("/tunein/v1/playback/station/{stationID}", server.HandleTuneInPlayback)
|
||||
r.Get("/tunein/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo)
|
||||
r.Get("/tunein/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast)
|
||||
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
|
||||
r.Get("/custom/v1/playback/{encodedURL}", server.HandleCustomPlayback)
|
||||
|
||||
r.Route("/marge", func(r chi.Router) {
|
||||
r.Get("/streaming/sourceproviders", server.HandleMargeSourceProviders)
|
||||
r.Get("/accounts/{account}/full", server.HandleMargeAccountFull)
|
||||
r.Post("/streaming/support/power_on", server.HandleMargePowerOn)
|
||||
r.Get("/updates/soundtouch", server.HandleMargeSoftwareUpdate)
|
||||
r.Get("/accounts/{account}/devices/{device}/presets", server.HandleMargePresets)
|
||||
r.Post("/accounts/{account}/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Post("/accounts/{account}/devices/{device}/recents", server.HandleMargeAddRecent)
|
||||
r.Post("/accounts/{account}/devices", server.HandleMargeAddDevice)
|
||||
r.Delete("/accounts/{account}/devices/{device}", server.HandleMargeRemoveDevice)
|
||||
r.Get("/streaming/account/{account}/provider_settings", server.HandleMargeProviderSettings)
|
||||
r.Get("/streaming/device/{device}/streaming_token", server.HandleMargeStreamingToken)
|
||||
r.Post("/streaming/support/customersupport", server.HandleMargeCustomerSupport)
|
||||
r.Get("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
|
||||
r.Post("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
|
||||
r.Get("/streaming/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
|
||||
r.Route("/streaming", func(r chi.Router) {
|
||||
r.Get("/sourceproviders", server.HandleMargeSourceProviders)
|
||||
r.Post("/account", server.HandleMargeCreateAccount)
|
||||
r.Post("/account/login", server.HandleMargeLogin)
|
||||
r.Post("/account/{account}/source", server.HandleMargeAddSource)
|
||||
|
||||
r.Route("/account/{account}", func(r chi.Router) {
|
||||
r.Get("/emailaddress", server.HandleMargeGetEmailAddress)
|
||||
r.Get("/full", server.HandleMargeAccountFull)
|
||||
r.Get("/sources", server.HandleMargeAccountSources)
|
||||
r.Get("/devices", server.HandleMargeAccountDevices)
|
||||
r.Get("/presets", server.HandleMargeAccountPresets)
|
||||
r.Get("/presets/all", server.HandleMargeAccountPresets)
|
||||
r.Get("/provider_settings", server.HandleMargeProviderSettings)
|
||||
|
||||
r.Route("/device", func(r chi.Router) {
|
||||
r.Post("/", server.HandleMargeAddDevice)
|
||||
r.Post("/{device}", server.HandleMargeAddDevice)
|
||||
})
|
||||
|
||||
r.Route("/device/{device}", func(r chi.Router) {
|
||||
r.Get("/presets", server.HandleMargePresets)
|
||||
r.Post("/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Put("/preset/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Delete("/preset/{presetNumber}", server.HandleMargeRemovePreset)
|
||||
r.Get("/recent", server.HandleMargeRecents)
|
||||
r.Get("/recents", server.HandleMargeRecents)
|
||||
r.Post("/recent", server.HandleMargeAddRecent)
|
||||
|
||||
r.Get("/group", server.HandleMargeDeviceGroup)
|
||||
r.Get("/group/", server.HandleMargeDeviceGroup)
|
||||
r.Get("/group/server", server.HandleMargeDeviceGroupServer)
|
||||
r.Get("/group/member", server.HandleMargeDeviceGroupMember)
|
||||
})
|
||||
|
||||
r.Delete("/device/{device}", server.HandleMargeRemoveDevice)
|
||||
})
|
||||
|
||||
r.Get("/device/{device}/streaming_token", server.HandleMargeStreamingToken)
|
||||
|
||||
r.Get("/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
|
||||
r.Post("/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
|
||||
|
||||
r.Get("/software/update/account/{account}", server.HandleMargeSoftwareUpdate)
|
||||
|
||||
r.Route("/support", func(r chi.Router) {
|
||||
r.Post("/power_on", server.HandleMargePowerOn)
|
||||
r.Post("/customersupport", server.HandleMargeCustomerSupport)
|
||||
})
|
||||
|
||||
r.Route("/stats", func(r chi.Router) {
|
||||
r.Post("/usage", server.HandleUsageStats)
|
||||
r.Post("/error", server.HandleErrorStats)
|
||||
})
|
||||
|
||||
r.Route("/music", func(r chi.Router) {
|
||||
r.Route("/musicprovider/{providerID}", func(r chi.Router) {
|
||||
r.Post("/is_eligible", server.HandleMusicProviderIsEligible)
|
||||
})
|
||||
})
|
||||
|
||||
r.Get("/resources/api_versions.xml", server.HandleMargeAPIVersions)
|
||||
})
|
||||
|
||||
r.Route("/accounts", func(r chi.Router) {
|
||||
r.Route("/{account}", func(r chi.Router) {
|
||||
r.Get("/full", server.HandleMargeAccountFull)
|
||||
r.Get("/sources", server.HandleMargeAccountSources)
|
||||
r.Get("/devices", server.HandleMargeAccountDevices)
|
||||
|
||||
r.Post("/devices", server.HandleMargeAddDevice)
|
||||
|
||||
r.Delete("/devices/{device}", server.HandleMargeRemoveDevice)
|
||||
r.Get("/devices/{device}/group", server.HandleMargeDeviceGroup)
|
||||
r.Get("/devices/{device}/group/", server.HandleMargeDeviceGroup)
|
||||
r.Get("/devices/{device}/group/server", server.HandleMargeDeviceGroupServer)
|
||||
r.Get("/devices/{device}/group/member", server.HandleMargeDeviceGroupMember)
|
||||
r.Get("/devices/{device}/presets", server.HandleMargePresets)
|
||||
r.Get("/devices/{device}/recents", server.HandleMargeRecents)
|
||||
|
||||
r.Post("/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Post("/devices/{device}/recents", server.HandleMargeAddRecent)
|
||||
})
|
||||
})
|
||||
|
||||
// Legacy or direct domain calls without /marge prefix
|
||||
r.Get("/streaming/sourceproviders", server.HandleMargeSourceProviders)
|
||||
r.Get("/accounts/{account}/full", server.HandleMargeAccountFull)
|
||||
r.Post("/streaming/support/power_on", server.HandleMargePowerOn)
|
||||
r.Get("/updates/soundtouch", server.HandleMargeSoftwareUpdate)
|
||||
r.Get("/accounts/{account}/devices/{device}/presets", server.HandleMargePresets)
|
||||
r.Post("/accounts/{account}/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Post("/accounts/{account}/devices/{device}/recents", server.HandleMargeAddRecent)
|
||||
r.Post("/accounts/{account}/devices", server.HandleMargeAddDevice)
|
||||
r.Delete("/accounts/{account}/devices/{device}", server.HandleMargeRemoveDevice)
|
||||
r.Get("/streaming/account/{account}/provider_settings", server.HandleMargeProviderSettings)
|
||||
r.Get("/streaming/device/{device}/streaming_token", server.HandleMargeStreamingToken)
|
||||
r.Post("/streaming/support/customersupport", server.HandleMargeCustomerSupport)
|
||||
r.Get("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
|
||||
r.Post("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
|
||||
r.Get("/streaming/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
|
||||
|
||||
r.Route("/customer", func(r chi.Router) {
|
||||
r.Get("/account/{account}", server.HandleMargeAccountProfile)
|
||||
@@ -649,14 +850,20 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Post("/account/{account}/password", server.HandleMargeChangePassword)
|
||||
})
|
||||
|
||||
r.Route("/oauth", func(r chi.Router) {
|
||||
r.Post("/device/{deviceID}/music/musicprovider/{sourceID}/token/cs3", server.HandleBoseToken)
|
||||
r.Post("/device/{deviceID}/music/musicprovider/{sourceID}/token", server.HandleBoseLegacyToken)
|
||||
r.Post("/account/{account}/music/musicprovider/{sourceID}/token/cs", server.HandleBoseAccountToken)
|
||||
r.HandleFunc("/*", server.HandleBoseProxy)
|
||||
})
|
||||
|
||||
r.Route("/v1", func(r chi.Router) {
|
||||
r.Post("/stapp/{deviceId}", server.HandleAppEvents)
|
||||
r.Post("/scmudc/{deviceId}", server.HandleAppEvents)
|
||||
})
|
||||
|
||||
r.Route("/streaming/stats", func(r chi.Router) {
|
||||
r.Post("/usage", server.HandleUsageStats)
|
||||
r.Post("/error", server.HandleErrorStats)
|
||||
// Return 405 Method Not Allowed as the upstream behavior also returns 405
|
||||
r.Get("/blacklist/{deviceId}", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
})
|
||||
})
|
||||
|
||||
r.Route("/mgmt", func(r chi.Router) {
|
||||
@@ -668,13 +875,25 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
// All other management endpoints require Basic Auth.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.BasicAuthMgmt())
|
||||
r.Get("/accounts/{accountId}/speakers", server.HandleMgmtListSpeakers)
|
||||
|
||||
r.Route("/accounts", func(r chi.Router) {
|
||||
r.Get("/", server.HandleMgmtListAccounts)
|
||||
r.Get("/{accountId}", server.HandleMgmtAccountDetails)
|
||||
r.Post("/{accountId}/language", server.HandleMgmtUpdateAccountLanguage)
|
||||
r.Post("/{accountId}/provider-settings", server.HandleMgmtUpdateAccountProviderSetting)
|
||||
r.Get("/{accountId}/speakers", server.HandleMgmtListSpeakers)
|
||||
})
|
||||
|
||||
r.Route("/spotify", func(r chi.Router) {
|
||||
r.Post("/init", server.HandleMgmtSpotifyInit)
|
||||
r.Post("/confirm", server.HandleMgmtSpotifyConfirm)
|
||||
r.Get("/accounts", server.HandleMgmtSpotifyAccounts)
|
||||
r.Get("/token", server.HandleMgmtSpotifyToken)
|
||||
r.Post("/entity", server.HandleMgmtSpotifyEntity)
|
||||
r.Post("/prime", server.HandleMgmtPrimeDevice)
|
||||
})
|
||||
|
||||
r.Get("/devices/{deviceId}/events", server.HandleMgmtDeviceEvents)
|
||||
r.Post("/spotify/init", server.HandleMgmtSpotifyInit)
|
||||
r.Post("/spotify/confirm", server.HandleMgmtSpotifyConfirm)
|
||||
r.Get("/spotify/accounts", server.HandleMgmtSpotifyAccounts)
|
||||
r.Get("/spotify/token", server.HandleMgmtSpotifyToken)
|
||||
r.Post("/spotify/entity", server.HandleMgmtSpotifyEntity)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -688,19 +907,19 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
|
||||
r.Get("/settings", server.HandleGetSettings)
|
||||
r.Post("/settings", server.HandleUpdateSettings)
|
||||
r.Get("/info/{deviceIP}", server.HandleGetDeviceInfo)
|
||||
r.Get("/summary/{deviceIP}", server.HandleGetMigrationSummary)
|
||||
r.Post("/migrate/{deviceIP}", server.HandleMigrateDevice)
|
||||
r.Post("/revert/{deviceIP}", server.HandleRevertMigration)
|
||||
r.Post("/reboot/{deviceIP}", server.HandleRebootDevice)
|
||||
r.Post("/trust-ca/{deviceIP}", server.HandleTrustCACert)
|
||||
r.Post("/ensure-remote-services/{deviceIP}", server.HandleEnsureRemoteServices)
|
||||
r.Post("/remove-remote-services/{deviceIP}", server.HandleRemoveRemoteServices)
|
||||
r.Post("/backup/{deviceIP}", server.HandleBackupConfig)
|
||||
r.Post("/sync/{deviceIP}", server.HandleInitialSync)
|
||||
r.Post("/test-connection/{deviceIP}", server.HandleTestConnection)
|
||||
r.Post("/test-hosts/{deviceIP}", server.HandleTestHostsRedirection)
|
||||
r.Post("/test-dns/{deviceIP}", server.HandleTestDNSRedirection)
|
||||
r.Get("/info/{deviceId}", server.HandleGetDeviceInfo)
|
||||
r.Get("/summary/{deviceId}", server.HandleGetMigrationSummary)
|
||||
r.Post("/migrate/{deviceId}", server.HandleMigrateDevice)
|
||||
r.Post("/revert/{deviceId}", server.HandleRevertMigration)
|
||||
r.Post("/reboot/{deviceId}", server.HandleRebootDevice)
|
||||
r.Post("/trust-ca/{deviceId}", server.HandleTrustCACert)
|
||||
r.Post("/ensure-remote-services/{deviceId}", server.HandleEnsureRemoteServices)
|
||||
r.Post("/remove-remote-services/{deviceId}", server.HandleRemoveRemoteServices)
|
||||
r.Post("/backup/{deviceId}", server.HandleBackupConfig)
|
||||
r.Post("/sync/{deviceId}", server.HandleInitialSync)
|
||||
r.Post("/test-connection/{deviceId}", server.HandleTestConnection)
|
||||
r.Post("/test-hosts/{deviceId}", server.HandleTestHostsRedirection)
|
||||
r.Post("/test-dns/{deviceId}", server.HandleTestDNSRedirection)
|
||||
r.Get("/ca.crt", server.HandleGetCACert)
|
||||
r.Get("/proxy-settings", server.HandleGetProxySettings)
|
||||
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
|
||||
@@ -708,11 +927,14 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Get("/interaction-stats", server.HandleGetInteractionStats)
|
||||
r.Get("/interactions", server.HandleListInteractions)
|
||||
r.Get("/interaction-content", server.HandleGetInteractionContent)
|
||||
r.Get("/parity-mismatches", server.HandleListParityMismatches)
|
||||
r.Delete("/parity-mismatches", server.HandleClearParityMismatches)
|
||||
r.Get("/interactions/sessions/{session}/download", server.HandleDownloadSession)
|
||||
r.Delete("/interactions/sessions/{session}", server.HandleDeleteSession)
|
||||
r.Delete("/interactions/sessions", server.HandleCleanupSessions)
|
||||
|
||||
r.Get("/dns-discoveries", server.HandleGetDNSDiscoveries)
|
||||
r.Get("/dns-discoveries/download", server.HandleDownloadDNSDiscoveries)
|
||||
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
|
||||
|
||||
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
|
||||
@@ -724,17 +946,132 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
}
|
||||
|
||||
func startHTTPSServer(httpsAddr string, r http.Handler, tlsConfig *tls.Config, httpsServerURL string) {
|
||||
// Add custom error logging and connection state tracking
|
||||
tlsConfig.GetCertificate = func(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
// log.Printf("[TLS] Certificate request for ServerName: %s", clientHello.ServerName)
|
||||
|
||||
// Use the default certificate selection logic
|
||||
for _, cert := range tlsConfig.Certificates {
|
||||
if cert.Leaf != nil {
|
||||
for _, name := range cert.Leaf.DNSNames {
|
||||
if matchesDomain(name, clientHello.ServerName) {
|
||||
// log.Printf("[TLS] ✅ Serving certificate for %s (matched %s)", clientHello.ServerName, name)
|
||||
return &cert, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no specific match, return the first certificate and log it
|
||||
if len(tlsConfig.Certificates) > 0 {
|
||||
// log.Printf("[TLS] ⚠️ No exact match for %s, using default certificate", clientHello.ServerName)
|
||||
return &tlsConfig.Certificates[0], nil
|
||||
}
|
||||
|
||||
log.Printf("[TLS] ❌ No certificate available for %s", clientHello.ServerName)
|
||||
|
||||
return nil, fmt.Errorf("no certificate available for %s", clientHello.ServerName)
|
||||
}
|
||||
|
||||
httpsServer := &http.Server{
|
||||
Addr: httpsAddr,
|
||||
Handler: r,
|
||||
TLSConfig: tlsConfig,
|
||||
ErrorLog: log.Default(), // Ensure error logging is enabled
|
||||
}
|
||||
|
||||
log.Printf("Go service starting HTTPS on %s", httpsServerURL)
|
||||
|
||||
go func() {
|
||||
if err := httpsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
|
||||
listener, err := net.Listen("tcp", httpsAddr)
|
||||
if err != nil {
|
||||
log.Printf("[TLS] Failed to create listener: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
tlsListener := tls.NewListener(listener, tlsConfig)
|
||||
|
||||
// Wrap listener to log connection attempts
|
||||
wrappedListener := &loggingTLSListener{
|
||||
Listener: tlsListener,
|
||||
}
|
||||
|
||||
if err := httpsServer.Serve(wrappedListener); err != nil && err != http.ErrServerClosed {
|
||||
log.Printf("HTTPS server error: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// matchesDomain checks if a certificate domain (which may be a wildcard) matches a server name
|
||||
func matchesDomain(certDomain, serverName string) bool {
|
||||
if certDomain == serverName {
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle wildcard certificates (only at the beginning of a label)
|
||||
if strings.HasPrefix(certDomain, "*.") {
|
||||
certBase := certDomain[2:] // Remove "*."
|
||||
|
||||
// For *.api.bose.io to match events.api.bose.io but not test.content.api.bose.io
|
||||
// We need to ensure only one label is replaced by the wildcard
|
||||
if strings.HasSuffix(serverName, "."+certBase) {
|
||||
// Count dots to ensure we're not matching too many levels
|
||||
serverPrefix := strings.TrimSuffix(serverName, "."+certBase)
|
||||
if !strings.Contains(serverPrefix, ".") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Also match the base domain (e.g., api.bose.io matches *.api.bose.io)
|
||||
if serverName == certBase {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// loggingTLSListener wraps a TLS listener to log connection attempts and handshake failures
|
||||
type loggingTLSListener struct {
|
||||
net.Listener
|
||||
}
|
||||
|
||||
func (l *loggingTLSListener) Accept() (net.Conn, error) {
|
||||
conn, err := l.Listener.Accept()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Wrap the connection to log TLS handshake results
|
||||
return &loggingTLSConn{
|
||||
Conn: conn,
|
||||
addr: conn.RemoteAddr(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// loggingTLSConn wraps a TLS connection to log handshake failures
|
||||
type loggingTLSConn struct {
|
||||
net.Conn
|
||||
addr net.Addr
|
||||
handshakeLogged bool
|
||||
}
|
||||
|
||||
func (c *loggingTLSConn) Read(b []byte) (n int, err error) {
|
||||
n, err = c.Conn.Read(b)
|
||||
|
||||
// Log TLS handshake failures on first read attempt
|
||||
if !c.handshakeLogged {
|
||||
c.handshakeLogged = true
|
||||
|
||||
if err != nil {
|
||||
// Check if this looks like a TLS handshake failure
|
||||
if strings.Contains(err.Error(), "tls:") ||
|
||||
strings.Contains(err.Error(), "handshake") ||
|
||||
strings.Contains(err.Error(), "certificate") {
|
||||
log.Printf("[TLS] ❌ Handshake failed from %s: %v", c.addr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
@@ -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
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
CONNECT /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
DELETE /accounts/{account}/devices/{device} handlers.(*Server).HandleMargeRemoveDevice-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
|
||||
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/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 /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/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 /bmx/orion/v1/playback/station/{data} handlers.(*Server).HandleOrionPlayback-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/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/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}/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/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,707 @@
|
||||
// Package handlers contains HTTP handlers for the SoundTouch web UI.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
bmxpkg "github.com/gesellix/bose-soundtouch/pkg/service/bmx"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// WebApp holds the application state and dependencies
|
||||
type WebApp struct {
|
||||
Devices map[string]*webtypes.DeviceConnection
|
||||
Upgrader websocket.Upgrader
|
||||
WSClients map[*websocket.Conn]bool
|
||||
WSMutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewWebApp creates a new WebApp instance for SPA mode
|
||||
func NewWebApp() *WebApp {
|
||||
return &WebApp{
|
||||
Devices: make(map[string]*webtypes.DeviceConnection),
|
||||
WSClients: make(map[*websocket.Conn]bool),
|
||||
Upgrader: websocket.Upgrader{
|
||||
CheckOrigin: func(_ *http.Request) bool { return true },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIDevices returns all devices as JSON
|
||||
func (app *WebApp) HandleAPIDevices(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Return all devices as JSON
|
||||
devices := make(map[string]interface{})
|
||||
for id, device := range app.Devices {
|
||||
devices[id] = map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
"lastSeen": device.LastSeen,
|
||||
}
|
||||
}
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: devices,
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIDevice returns a specific device as JSON
|
||||
func (app *WebApp) HandleAPIDevice(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := strings.TrimPrefix(r.URL.Path, "/api/device/")
|
||||
if deviceID == "" {
|
||||
app.sendError(w, "Device ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Update device status to get fresh power state
|
||||
app.UpdateDeviceStatus(deviceID, device)
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
},
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIControl handles device control commands
|
||||
func (app *WebApp) HandleAPIControl(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/control/")
|
||||
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 2 {
|
||||
app.sendError(w, "Invalid control path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceID := parts[0]
|
||||
action := parts[1]
|
||||
|
||||
// Check for empty device ID
|
||||
if deviceID == "" {
|
||||
app.sendError(w, "Device ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
app.handleControlAction(w, r, action, device)
|
||||
}
|
||||
|
||||
// handleControlAction processes different control actions
|
||||
func (app *WebApp) handleControlAction(w http.ResponseWriter, r *http.Request, action string, device *webtypes.DeviceConnection) {
|
||||
switch action {
|
||||
case "play":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.Play()
|
||||
app.sendControlResponse(w, err, "Started playback")
|
||||
case "pause":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.Pause()
|
||||
app.sendControlResponse(w, err, "Paused playback")
|
||||
case "stop":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.Stop()
|
||||
app.sendControlResponse(w, err, "Stopped playback")
|
||||
case "next":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.NextTrack()
|
||||
app.sendControlResponse(w, err, "Next track")
|
||||
case "previous":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.PrevTrack()
|
||||
app.sendControlResponse(w, err, "Previous track")
|
||||
case "volume":
|
||||
app.handleVolumeControl(w, r, device)
|
||||
case "mute":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.SendKey(models.KeyMute)
|
||||
app.sendControlResponse(w, err, "Toggled mute")
|
||||
case "preset":
|
||||
app.handlePresetControl(w, r, device)
|
||||
case "bass":
|
||||
app.handleBassControl(w, r, device)
|
||||
case "source":
|
||||
app.handleSourceControl(w, r, device)
|
||||
default:
|
||||
app.sendError(w, "Unknown action", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// handleVolumeControl processes volume control requests
|
||||
func (app *WebApp) handleVolumeControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
|
||||
if r.Method != http.MethodPost {
|
||||
app.sendError(w, "POST required for volume control", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var volumeReq webtypes.VolumeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&volumeReq); err != nil {
|
||||
app.sendError(w, "Invalid volume data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if volumeReq.Level < 0 || volumeReq.Level > 100 {
|
||||
app.sendError(w, "Volume must be between 0 and 100", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.SetVolume(volumeReq.Level)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Volume set to %d", volumeReq.Level))
|
||||
}
|
||||
|
||||
// handlePresetControl processes preset control requests
|
||||
func (app *WebApp) handlePresetControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
|
||||
presetParam := r.URL.Query().Get("id")
|
||||
if presetParam == "" {
|
||||
app.sendError(w, "Preset ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
presetID, err := strconv.Atoi(presetParam)
|
||||
if err != nil {
|
||||
app.sendError(w, "Invalid preset ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = device.Client.SelectPreset(presetID)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Selected preset %d", presetID))
|
||||
}
|
||||
|
||||
// handleBassControl processes bass control requests
|
||||
func (app *WebApp) handleBassControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
|
||||
if r.Method != http.MethodPost {
|
||||
app.sendError(w, "POST required for bass control", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var bassReq webtypes.BassRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&bassReq); err != nil {
|
||||
app.sendError(w, "Invalid bass data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if bassReq.Level < -9 || bassReq.Level > 9 {
|
||||
app.sendError(w, "Bass must be between -9 and 9", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.SetBass(bassReq.Level)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Bass set to %d", bassReq.Level))
|
||||
}
|
||||
|
||||
// handleSourceControl processes source control requests
|
||||
func (app *WebApp) handleSourceControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
|
||||
sourceParam := r.URL.Query().Get("name")
|
||||
if sourceParam == "" {
|
||||
app.sendError(w, "Source name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.SelectSource(sourceParam, "")
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Selected source %s", sourceParam))
|
||||
}
|
||||
|
||||
// sendControlResponse sends a control command response
|
||||
func (app *WebApp) sendControlResponse(w http.ResponseWriter, err error, successMessage string) {
|
||||
if err != nil {
|
||||
app.sendError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]string{"message": successMessage},
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// sendError sends an error response
|
||||
func (app *WebApp) sendError(w http.ResponseWriter, message string, statusCode int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: false,
|
||||
Error: message,
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode error response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDeviceKey handles sending key commands to devices
|
||||
func (app *WebApp) HandleDeviceKey(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
app.sendError(w, "POST required", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
pathParts := strings.Split(r.URL.Path, "/")
|
||||
if len(pathParts) < 5 || pathParts[1] != "api" || pathParts[2] != "device-key" {
|
||||
app.sendError(w, "Invalid path format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceID := pathParts[3]
|
||||
key := pathParts[4]
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
err := device.Client.SendKey(key)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Sent key command: %s", key))
|
||||
}
|
||||
|
||||
// HandleDirectVolumeControl handles direct volume setting via URL parameter
|
||||
func (app *WebApp) HandleDirectVolumeControl(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
app.sendError(w, "POST required", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
pathParts := strings.Split(r.URL.Path, "/")
|
||||
if len(pathParts) < 5 || pathParts[1] != "api" || pathParts[2] != "device-volume" {
|
||||
app.sendError(w, "Invalid path format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceID := pathParts[3]
|
||||
|
||||
volumeLevel, err := strconv.Atoi(pathParts[4])
|
||||
if err != nil || volumeLevel < 0 || volumeLevel > 100 {
|
||||
app.sendError(w, "Invalid volume level (0-100)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
err = device.Client.SetVolume(volumeLevel)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Volume set to %d", volumeLevel))
|
||||
}
|
||||
|
||||
// HandleDevicePower handles power toggle commands for devices
|
||||
func (app *WebApp) HandleDevicePower(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
app.sendError(w, "POST required", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
pathParts := strings.Split(r.URL.Path, "/")
|
||||
if len(pathParts) < 4 || pathParts[1] != "api" || pathParts[2] != "device-power" {
|
||||
app.sendError(w, "Invalid path format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceID := pathParts[3]
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Send POWER key command to toggle device power
|
||||
err := device.Client.SendKey("POWER")
|
||||
app.sendControlResponse(w, err, "Power toggle command sent")
|
||||
}
|
||||
|
||||
// HandleDevicePowerStatus handles lightweight power status check
|
||||
func (app *WebApp) HandleDevicePowerStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
app.sendError(w, "GET required", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
pathParts := strings.Split(r.URL.Path, "/")
|
||||
if len(pathParts) < 4 || pathParts[1] != "api" || pathParts[2] != "device-power-status" {
|
||||
app.sendError(w, "Invalid path format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceID := pathParts[3]
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Quick power status check by getting now playing
|
||||
nowPlaying, err := device.Client.GetNowPlaying()
|
||||
if err != nil {
|
||||
app.sendControlResponse(w, err, "Failed to get power status")
|
||||
return
|
||||
}
|
||||
|
||||
isPoweredOn := nowPlaying != nil && nowPlaying.Source != "STANDBY"
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]interface{}{
|
||||
"deviceId": deviceID,
|
||||
"isPoweredOn": isPoweredOn,
|
||||
"source": nowPlaying.Source,
|
||||
},
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastDeviceList sends updated device list to all connected WebSocket clients
|
||||
func (app *WebApp) BroadcastDeviceList() {
|
||||
app.WSMutex.RLock()
|
||||
defer app.WSMutex.RUnlock()
|
||||
|
||||
devices := make(map[string]interface{})
|
||||
for id, device := range app.Devices {
|
||||
devices[id] = map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
"lastSeen": device.LastSeen,
|
||||
}
|
||||
}
|
||||
|
||||
message := webtypes.WebSocketMessage{
|
||||
Type: "devices",
|
||||
Data: devices,
|
||||
}
|
||||
|
||||
// Send to all connected clients
|
||||
var failedClients []*websocket.Conn
|
||||
|
||||
for client := range app.WSClients {
|
||||
if err := client.WriteJSON(message); err != nil {
|
||||
log.Printf("Failed to send device update to WebSocket client: %v", err)
|
||||
// Mark for removal to avoid modifying map during iteration
|
||||
failedClients = append(failedClients, client)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove failed clients
|
||||
for _, client := range failedClients {
|
||||
delete(app.WSClients, client)
|
||||
client.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastDiscoveryStatus sends discovery progress updates to all connected WebSocket clients
|
||||
func (app *WebApp) BroadcastDiscoveryStatus(status string, deviceCount int) {
|
||||
app.WSMutex.RLock()
|
||||
defer app.WSMutex.RUnlock()
|
||||
|
||||
message := webtypes.WebSocketMessage{
|
||||
Type: "discovery_status",
|
||||
Data: map[string]interface{}{
|
||||
"status": status,
|
||||
"deviceCount": deviceCount,
|
||||
},
|
||||
}
|
||||
|
||||
// Send to all connected clients
|
||||
var failedClients []*websocket.Conn
|
||||
|
||||
for client := range app.WSClients {
|
||||
if err := client.WriteJSON(message); err != nil {
|
||||
log.Printf("Failed to send discovery status to WebSocket client: %v", err)
|
||||
// Mark for removal to avoid modifying map during iteration
|
||||
failedClients = append(failedClients, client)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove failed clients
|
||||
for _, client := range failedClients {
|
||||
delete(app.WSClients, client)
|
||||
client.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInSearch handles TuneIn search requests, proxying directly to the bmx package.
|
||||
func (app *WebApp) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("q")
|
||||
if query == "" {
|
||||
app.sendError(w, "query parameter 'q' is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := bmxpkg.TuneInSearch(query)
|
||||
if err != nil {
|
||||
app.sendError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: resp}); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInNavigate handles TuneIn browse/navigate requests, proxying directly to the bmx package.
|
||||
// Supported path suffixes (relative to /api/tunein/navigate):
|
||||
// - (empty) → top-level browse
|
||||
// - /{encodedURI} → browse the given TuneIn URI
|
||||
// - /sub/{n}/{encodedURI} → single subsection
|
||||
// - /profiles/{type}/{id}/{encodedURI} → artist/program profile
|
||||
func (app *WebApp) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request) {
|
||||
const navPrefix = "/api/tunein/navigate"
|
||||
|
||||
path := r.URL.Path
|
||||
wildcard := ""
|
||||
|
||||
if len(path) > len(navPrefix) {
|
||||
wildcard = strings.TrimPrefix(path[len(navPrefix):], "/")
|
||||
}
|
||||
|
||||
var (
|
||||
resp interface{}
|
||||
err error
|
||||
)
|
||||
|
||||
if wildcard == "" {
|
||||
resp, err = bmxpkg.TuneInNavigate("", nil)
|
||||
} else {
|
||||
firstSlash := strings.Index(wildcard, "/")
|
||||
if firstSlash == -1 {
|
||||
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
|
||||
} else {
|
||||
pfx := wildcard[:firstSlash]
|
||||
rest := wildcard[firstSlash+1:]
|
||||
|
||||
switch pfx {
|
||||
case "sub":
|
||||
secondSlash := strings.Index(rest, "/")
|
||||
if secondSlash == -1 {
|
||||
resp, err = bmxpkg.TuneInNavigate(rest, nil)
|
||||
} else {
|
||||
n, parseErr := strconv.Atoi(rest[:secondSlash])
|
||||
if parseErr != nil {
|
||||
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
|
||||
} else {
|
||||
resp, err = bmxpkg.TuneInNavigate(rest[secondSlash+1:], &n)
|
||||
}
|
||||
}
|
||||
case "profiles":
|
||||
parts := strings.SplitN(rest, "/", 3)
|
||||
if len(parts) < 3 {
|
||||
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
|
||||
} else {
|
||||
resp, err = bmxpkg.TuneInNavigateProfile(parts[2])
|
||||
}
|
||||
default:
|
||||
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
app.sendError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: resp}); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandlePlayTuneIn plays a TuneIn content item on a specific device via POST /select.
|
||||
func (app *WebApp) HandlePlayTuneIn(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := strings.TrimPrefix(r.URL.Path, "/api/tunein/play/")
|
||||
if deviceID == "" {
|
||||
app.sendError(w, "Device ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, fmt.Sprintf("Device '%s' not found", deviceID), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Location string `json:"location"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
ContainerArt string `json:"containerArt"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
app.sendError(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Location == "" {
|
||||
app.sendError(w, "location is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
itemType := req.Type
|
||||
if itemType == "" {
|
||||
itemType = "stationurl"
|
||||
}
|
||||
|
||||
contentItem := &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
Type: itemType,
|
||||
Location: req.Location,
|
||||
ItemName: req.Name,
|
||||
IsPresetable: true,
|
||||
ContainerArt: req.ContainerArt,
|
||||
}
|
||||
|
||||
if err := device.Client.SelectContentItem(contentItem); err != nil {
|
||||
app.sendError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: map[string]string{"message": "Playing " + req.Name}}); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
// Package handlers contains tests for HTTP handlers.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func createTestApp() *WebApp {
|
||||
app := NewWebApp()
|
||||
|
||||
// Add test device with minimal data
|
||||
deviceInfo := &models.DeviceInfo{
|
||||
Name: "Test Speaker",
|
||||
Type: "SoundTouch 30",
|
||||
NetworkInfo: []models.NetworkInfo{
|
||||
{MacAddress: "TEST123", IPAddress: "192.168.1.100"},
|
||||
},
|
||||
}
|
||||
|
||||
device := &webtypes.DeviceConnection{
|
||||
Client: nil, // No real client for unit tests
|
||||
DeviceInfo: deviceInfo,
|
||||
LastSeen: time.Now(),
|
||||
Status: webtypes.DeviceStatus{
|
||||
Volume: &models.Volume{ActualVolume: 50, MuteEnabled: false},
|
||||
Bass: &models.Bass{ActualBass: 0},
|
||||
IsConnected: true,
|
||||
LastActivity: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
app.Devices["test-device"] = device
|
||||
return app
|
||||
}
|
||||
|
||||
func TestNewWebApp(t *testing.T) {
|
||||
app := NewWebApp()
|
||||
|
||||
// Use require-style checks that satisfy static analyzer
|
||||
if app == nil {
|
||||
t.Fatal("NewWebApp returned nil")
|
||||
}
|
||||
if app.Devices == nil {
|
||||
t.Fatal("Devices map not initialized")
|
||||
}
|
||||
|
||||
// At this point we know app and app.Devices are not nil
|
||||
if len(app.Devices) != 0 {
|
||||
t.Errorf("Expected empty devices map, got %d devices", len(app.Devices))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAPIDevices(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/devices", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleAPIDevices(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
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.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if !response.Success {
|
||||
t.Errorf("Expected success=true, got false")
|
||||
}
|
||||
|
||||
// Check that devices data is present
|
||||
data, ok := response.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected data to be map[string]interface{}")
|
||||
}
|
||||
|
||||
if _, exists := data["test-device"]; !exists {
|
||||
t.Errorf("Expected 'test-device' in response data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAPIDevice(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
expectedStatus int
|
||||
expectSuccess bool
|
||||
}{
|
||||
{
|
||||
name: "valid device",
|
||||
path: "/api/device/test-device",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "missing device ID",
|
||||
path: "/api/device/",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "unknown device",
|
||||
path: "/api/device/unknown",
|
||||
expectedStatus: http.StatusNotFound,
|
||||
expectSuccess: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", tt.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleAPIDevice(w, req)
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
|
||||
}
|
||||
|
||||
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.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success != tt.expectSuccess {
|
||||
t.Errorf("Expected success=%v, got %v", tt.expectSuccess, response.Success)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAPIControl_InvalidDevice(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/control/unknown-device/play", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleAPIControl(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("Expected status 404, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success {
|
||||
t.Errorf("Expected success=false, got true")
|
||||
}
|
||||
|
||||
if response.Error != "Device not found" {
|
||||
t.Errorf("Expected 'Device not found' error, got '%s'", response.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAPIControl_InvalidPath(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{"missing action", "/api/control/test-device"},
|
||||
{"missing device and action", "/api/control/"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", tt.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleAPIControl(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success {
|
||||
t.Errorf("Expected success=false, got true")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAPIControl_VolumeValidation(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
body string
|
||||
expectedStatus int
|
||||
expectSuccess bool
|
||||
}{
|
||||
{
|
||||
name: "invalid method",
|
||||
method: "GET",
|
||||
body: "",
|
||||
expectedStatus: http.StatusMethodNotAllowed,
|
||||
expectSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "invalid JSON",
|
||||
method: "POST",
|
||||
body: `invalid json`,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "volume too low",
|
||||
method: "POST",
|
||||
body: `{"level": -1}`,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "volume too high",
|
||||
method: "POST",
|
||||
body: `{"level": 101}`,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectSuccess: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var req *http.Request
|
||||
if tt.body != "" {
|
||||
req = httptest.NewRequest(tt.method, "/api/control/test-device/volume", strings.NewReader(tt.body))
|
||||
} else {
|
||||
req = httptest.NewRequest(tt.method, "/api/control/test-device/volume", nil)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
app.HandleAPIControl(w, req)
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success != tt.expectSuccess {
|
||||
t.Errorf("Expected success=%v, got %v", tt.expectSuccess, response.Success)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAPIControl_BassValidation(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
body string
|
||||
expectedStatus int
|
||||
expectSuccess bool
|
||||
}{
|
||||
{
|
||||
name: "bass too low",
|
||||
method: "POST",
|
||||
body: `{"level": -10}`,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "bass too high",
|
||||
method: "POST",
|
||||
body: `{"level": 10}`,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectSuccess: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tt.method, "/api/control/test-device/bass", strings.NewReader(tt.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
app.HandleAPIControl(w, req)
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success != tt.expectSuccess {
|
||||
t.Errorf("Expected success=%v, got %v", tt.expectSuccess, response.Success)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAPIControl_PresetValidation(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
expectedStatus int
|
||||
expectSuccess bool
|
||||
}{
|
||||
{
|
||||
name: "missing preset ID",
|
||||
query: "",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "invalid preset ID",
|
||||
query: "?id=abc",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectSuccess: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/control/test-device/preset"+tt.query, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleAPIControl(w, req)
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success != tt.expectSuccess {
|
||||
t.Errorf("Expected success=%v, got %v", tt.expectSuccess, response.Success)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAPIControl_SourceValidation(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/control/test-device/source", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleAPIControl(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success {
|
||||
t.Errorf("Expected success=false, got true")
|
||||
}
|
||||
|
||||
if response.Error != "Source name required" {
|
||||
t.Errorf("Expected 'Source name required' error, got '%s'", response.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAPIDiscover(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
expectedStatus int
|
||||
expectSuccess bool
|
||||
}{
|
||||
{
|
||||
name: "valid POST request",
|
||||
method: "POST",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "invalid GET request",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusMethodNotAllowed,
|
||||
expectSuccess: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tt.method, "/api/discover", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleAPIDiscover(w, req)
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success != tt.expectSuccess {
|
||||
t.Errorf("Expected success=%v, got %v", tt.expectSuccess, response.Success)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendError(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
app.sendError(w, "Test error", http.StatusBadRequest)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success {
|
||||
t.Errorf("Expected success=false, got true")
|
||||
}
|
||||
|
||||
if response.Error != "Test error" {
|
||||
t.Errorf("Expected 'Test error', got '%s'", response.Error)
|
||||
}
|
||||
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if contentType != "application/json" {
|
||||
t.Errorf("Expected Content-Type 'application/json', got '%s'", contentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWebSocket_InvalidUpgrade(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
// Test without proper WebSocket headers (should fail gracefully)
|
||||
req := httptest.NewRequest("GET", "/ws", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// This will fail because it's not a real WebSocket upgrade, but should not panic
|
||||
app.HandleWebSocket(w, req)
|
||||
|
||||
// We're just checking that the handler doesn't panic
|
||||
// The actual upgrade will fail in test environment without proper headers
|
||||
}
|
||||
|
||||
func TestHandleAPIControl_UnsupportedAction(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/control/test-device/unsupported", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleAPIControl(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success {
|
||||
t.Errorf("Expected success=false, got true")
|
||||
}
|
||||
|
||||
if response.Error != "Unknown action" {
|
||||
t.Errorf("Expected 'Unknown action' error, got '%s'", response.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark tests
|
||||
func BenchmarkHandleAPIDevices(b *testing.B) {
|
||||
app := createTestApp()
|
||||
|
||||
// Add more devices for realistic benchmarking
|
||||
for i := 0; i < 10; i++ {
|
||||
deviceID := "device-" + string(rune('0'+i))
|
||||
app.Devices[deviceID] = &webtypes.DeviceConnection{
|
||||
Client: &client.Client{},
|
||||
DeviceInfo: &models.DeviceInfo{Name: "Test Device " + deviceID},
|
||||
Status: webtypes.DeviceStatus{IsConnected: true},
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/devices", nil)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
w := httptest.NewRecorder()
|
||||
app.HandleAPIDevices(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkHandleAPIDevice(b *testing.B) {
|
||||
app := createTestApp()
|
||||
req := httptest.NewRequest("GET", "/api/device/test-device", nil)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
w := httptest.NewRecorder()
|
||||
app.HandleAPIDevice(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkSendError(b *testing.B) {
|
||||
app := createTestApp()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
w := httptest.NewRecorder()
|
||||
app.sendError(w, "Test error", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
// Package handlers contains WebSocket handlers for real-time communication.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// HandleWebSocket handles WebSocket connections for real-time updates
|
||||
func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := app.Upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("WebSocket upgrade failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
defer func() {
|
||||
// Unregister client
|
||||
app.WSMutex.Lock()
|
||||
delete(app.WSClients, conn)
|
||||
app.WSMutex.Unlock()
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
// Register client
|
||||
app.WSMutex.Lock()
|
||||
app.WSClients[conn] = true
|
||||
app.WSMutex.Unlock()
|
||||
|
||||
// Send initial device list
|
||||
devices := make(map[string]interface{})
|
||||
for id, device := range app.Devices {
|
||||
devices[id] = map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
"lastSeen": device.LastSeen,
|
||||
}
|
||||
}
|
||||
|
||||
initialMessage := webtypes.WebSocketMessage{
|
||||
Type: "devices",
|
||||
Data: devices,
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(initialMessage); err != nil {
|
||||
log.Printf("Failed to send initial data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Keep connection alive and send updates
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Set up ping handler to detect client disconnects
|
||||
conn.SetPongHandler(func(string) error {
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
return nil
|
||||
})
|
||||
|
||||
// Set initial read deadline
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
|
||||
// Handle incoming messages in a separate goroutine
|
||||
go func() {
|
||||
defer conn.Close()
|
||||
|
||||
for {
|
||||
if _, _, err := conn.NextReader(); err != nil {
|
||||
log.Printf("WebSocket read error: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Main loop for sending periodic updates
|
||||
for range ticker.C {
|
||||
// Send ping to check if client is still connected
|
||||
if err := conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
|
||||
log.Printf("Failed to send ping: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Send periodic status updates
|
||||
for id, device := range app.Devices {
|
||||
if device.Status.IsConnected {
|
||||
statusMessage := webtypes.WebSocketMessage{
|
||||
Type: "status_update",
|
||||
DeviceID: id,
|
||||
Data: device.Status,
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(statusMessage); err != nil {
|
||||
log.Printf("Failed to send status update: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIDiscover triggers device discovery
|
||||
func (app *WebApp) HandleAPIDiscover(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
app.sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Discovery will be triggered by the main app
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]string{"message": "Discovery started"},
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// ConnectDeviceWebSocket establishes a WebSocket connection to a device
|
||||
func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.DeviceConnection) {
|
||||
// Skip WebSocket connection if client is not available (e.g., in tests)
|
||||
if conn.Client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
wsClient := conn.Client.NewWebSocketClient(nil)
|
||||
|
||||
// Setup event handlers
|
||||
wsClient.OnNowPlaying(func(event *models.NowPlayingUpdatedEvent) {
|
||||
conn.Status.NowPlaying = &event.NowPlaying
|
||||
conn.Status.LastActivity = time.Now()
|
||||
})
|
||||
|
||||
wsClient.OnVolumeUpdated(func(event *models.VolumeUpdatedEvent) {
|
||||
conn.Status.Volume = &event.Volume
|
||||
conn.Status.LastActivity = time.Now()
|
||||
})
|
||||
|
||||
wsClient.OnConnectionState(func(event *models.ConnectionStateUpdatedEvent) {
|
||||
conn.Status.IsConnected = event.ConnectionState.IsConnected()
|
||||
conn.Status.LastActivity = time.Now()
|
||||
})
|
||||
|
||||
wsClient.OnPresetUpdated(func(event *models.PresetUpdatedEvent) {
|
||||
conn.Status.Presets = &event.Presets
|
||||
conn.Status.LastActivity = time.Now()
|
||||
})
|
||||
|
||||
// Connect WebSocket
|
||||
if err := wsClient.Connect(); err != nil {
|
||||
log.Printf("Failed to connect WebSocket for device %s: %v", deviceID, err)
|
||||
return
|
||||
}
|
||||
|
||||
conn.WebSocket = wsClient
|
||||
conn.Status.IsConnected = true
|
||||
|
||||
log.Printf("WebSocket connected for device %s", deviceID)
|
||||
|
||||
// Wait for disconnection
|
||||
wsClient.Wait()
|
||||
|
||||
conn.Status.IsConnected = false
|
||||
|
||||
log.Printf("WebSocket disconnected for device %s", deviceID)
|
||||
}
|
||||
|
||||
// UpdateDeviceStatus fetches current status from device
|
||||
func (app *WebApp) UpdateDeviceStatus(_ string, conn *webtypes.DeviceConnection) {
|
||||
// Skip status update if client is not available (e.g., in tests)
|
||||
if conn.Client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
statusUpdated := false
|
||||
|
||||
// Get current now playing
|
||||
if nowPlaying, err := conn.Client.GetNowPlaying(); err == nil {
|
||||
conn.Status.NowPlaying = nowPlaying
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Get current volume
|
||||
if volume, err := conn.Client.GetVolume(); err == nil {
|
||||
conn.Status.Volume = volume
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Get presets
|
||||
if presets, err := conn.Client.GetPresets(); err == nil {
|
||||
conn.Status.Presets = presets
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Update last activity if any status was updated
|
||||
if statusUpdated {
|
||||
conn.Status.LastActivity = time.Now()
|
||||
}
|
||||
// Get sources
|
||||
if sources, err := conn.Client.GetSources(); err == nil {
|
||||
conn.Status.Sources = sources
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Get bass (if available)
|
||||
if bass, err := conn.Client.GetBass(); err == nil {
|
||||
conn.Status.Bass = bass
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Mark as connected if we successfully got at least one status
|
||||
conn.Status.IsConnected = statusUpdated
|
||||
conn.Status.LastActivity = time.Now()
|
||||
}
|
||||
|
||||
// HandleDeviceWebSocket handles individual device WebSocket connections for real-time device-specific updates
|
||||
func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
pathParts := strings.Split(r.URL.Path, "/")
|
||||
if len(pathParts) < 4 || pathParts[1] != "api" || pathParts[2] != "device-ws" {
|
||||
http.Error(w, "Invalid path format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceID := pathParts[3]
|
||||
if deviceID == "" {
|
||||
http.Error(w, "Device ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
http.Error(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := app.Upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("Device WebSocket upgrade failed for %s: %v", deviceID, err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
log.Printf("Device WebSocket connected for %s", deviceID)
|
||||
|
||||
// Send initial device status
|
||||
initialMessage := webtypes.WebSocketMessage{
|
||||
Type: "device_status",
|
||||
DeviceID: deviceID,
|
||||
Data: map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
},
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(initialMessage); err != nil {
|
||||
log.Printf("Failed to send initial device status: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Set up ping handler to detect client disconnects
|
||||
conn.SetPongHandler(func(string) error {
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
return nil
|
||||
})
|
||||
|
||||
// Set initial read deadline
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
|
||||
// Handle incoming messages in a separate goroutine
|
||||
go func() {
|
||||
defer conn.Close()
|
||||
|
||||
for {
|
||||
if _, _, err := conn.NextReader(); err != nil {
|
||||
log.Printf("Device WebSocket read error for %s: %v", deviceID, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Send periodic device status updates
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
// Send ping to check if client is still connected
|
||||
if err := conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
|
||||
log.Printf("Failed to send ping to device WebSocket %s: %v", deviceID, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Send device status update
|
||||
statusMessage := webtypes.WebSocketMessage{
|
||||
Type: "device_status",
|
||||
DeviceID: deviceID,
|
||||
Data: map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
},
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(statusMessage); err != nil {
|
||||
log.Printf("Failed to send device status update for %s: %v", deviceID, err)
|
||||
return
|
||||
}
|
||||
|
||||
// If device has active WebSocket connection to SoundTouch device,
|
||||
// also send any real-time updates from that connection
|
||||
if device.WebSocket != nil && device.Status.IsConnected {
|
||||
realtimeMessage := webtypes.WebSocketMessage{
|
||||
Type: "device_realtime",
|
||||
DeviceID: deviceID,
|
||||
Data: map[string]interface{}{
|
||||
"nowPlaying": device.Status.NowPlaying,
|
||||
"volume": device.Status.Volume,
|
||||
"timestamp": time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(realtimeMessage); err != nil {
|
||||
log.Printf("Failed to send realtime update for %s: %v", deviceID, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// Package main provides a web UI for controlling Bose SoundTouch devices.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/handlers"
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/config"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
)
|
||||
|
||||
var (
|
||||
port = flag.String("port", "8080", "Web server port")
|
||||
_ = flag.String("host", "", "Specific SoundTouch device host (optional)")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
// Create web app without templates (SPA mode)
|
||||
app := handlers.NewWebApp()
|
||||
|
||||
// Initialize discovery service
|
||||
cfg, err := config.LoadFromEnv()
|
||||
if err != nil {
|
||||
log.Printf("Failed to load config: %v, using defaults", err)
|
||||
|
||||
cfg = config.DefaultConfig()
|
||||
}
|
||||
|
||||
cfg.DiscoveryTimeout = 10 * time.Second
|
||||
cfg.CacheEnabled = true
|
||||
|
||||
discoveryService := discovery.NewUnifiedDiscoveryService(cfg)
|
||||
|
||||
// Discover devices on startup
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Broadcast discovery start
|
||||
app.BroadcastDiscoveryStatus("starting", len(app.Devices))
|
||||
|
||||
discoverDevices(ctx, app, discoveryService)
|
||||
|
||||
// Broadcast discovery completion and updated device list
|
||||
app.BroadcastDiscoveryStatus("completed", len(app.Devices))
|
||||
app.BroadcastDeviceList()
|
||||
}()
|
||||
|
||||
// Setup HTTP routes
|
||||
setupRoutes(app, discoveryService)
|
||||
|
||||
// Start web server
|
||||
log.Printf("SoundTouch Web UI starting on http://localhost:%s", *port)
|
||||
log.Fatal(http.ListenAndServe(":"+*port, nil))
|
||||
}
|
||||
|
||||
func setupRoutes(app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) {
|
||||
// Static files - try both relative paths
|
||||
staticDir := "cmd/soundtouch-web/static/"
|
||||
if _, err := os.Stat(staticDir); os.IsNotExist(err) {
|
||||
staticDir = "static/"
|
||||
}
|
||||
|
||||
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir))))
|
||||
|
||||
// WebSocket endpoint
|
||||
http.HandleFunc("/ws", app.HandleWebSocket)
|
||||
|
||||
// API endpoints
|
||||
http.HandleFunc("/api/devices", app.HandleAPIDevices)
|
||||
http.HandleFunc("/api/device/", app.HandleAPIDevice)
|
||||
http.HandleFunc("/api/discover", func(w http.ResponseWriter, r *http.Request) {
|
||||
app.HandleAPIDiscover(w, r)
|
||||
// Trigger discovery
|
||||
//nolint:contextcheck // Context is created within goroutine
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Broadcast discovery start
|
||||
app.BroadcastDiscoveryStatus("starting", len(app.Devices))
|
||||
|
||||
discoverDevices(ctx, app, discoveryService)
|
||||
|
||||
// Broadcast discovery completion and updated device list
|
||||
app.BroadcastDiscoveryStatus("completed", len(app.Devices))
|
||||
app.BroadcastDeviceList()
|
||||
}()
|
||||
})
|
||||
|
||||
// Device control endpoints
|
||||
http.HandleFunc("/api/control/", app.HandleAPIControl)
|
||||
|
||||
// TuneIn browse, search, and playback
|
||||
http.HandleFunc("/api/tunein/search", app.HandleTuneInSearch)
|
||||
http.HandleFunc("/api/tunein/navigate", app.HandleTuneInNavigate)
|
||||
http.HandleFunc("/api/tunein/navigate/", app.HandleTuneInNavigate)
|
||||
http.HandleFunc("/api/tunein/play/", app.HandlePlayTuneIn)
|
||||
|
||||
// Enhanced device control endpoints with specific patterns
|
||||
http.HandleFunc("/api/device-key/", app.HandleDeviceKey)
|
||||
http.HandleFunc("/api/device-volume/", app.HandleDirectVolumeControl)
|
||||
http.HandleFunc("/api/device-power/", app.HandleDevicePower)
|
||||
http.HandleFunc("/api/device-power-status/", app.HandleDevicePowerStatus)
|
||||
http.HandleFunc("/api/device-ws/", app.HandleDeviceWebSocket)
|
||||
|
||||
// SPA routes - serve index.html for specific routes only
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Serve the SPA index.html file for root path
|
||||
spaPath := staticDir + "index.html"
|
||||
http.ServeFile(w, r, spaPath)
|
||||
})
|
||||
|
||||
// Additional SPA routes for client-side routing
|
||||
http.HandleFunc("/devices", func(w http.ResponseWriter, r *http.Request) {
|
||||
spaPath := staticDir + "index.html"
|
||||
http.ServeFile(w, r, spaPath)
|
||||
})
|
||||
|
||||
http.HandleFunc("/device/", func(w http.ResponseWriter, r *http.Request) {
|
||||
spaPath := staticDir + "index.html"
|
||||
http.ServeFile(w, r, spaPath)
|
||||
})
|
||||
}
|
||||
|
||||
func discoverDevices(ctx context.Context, app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) {
|
||||
log.Println("Starting device discovery...")
|
||||
|
||||
devices, err := discoveryService.DiscoverDevices(ctx)
|
||||
if err != nil {
|
||||
log.Printf("Discovery failed: %v", err)
|
||||
app.BroadcastDiscoveryStatus("failed", len(app.Devices))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Found %d devices", len(devices))
|
||||
|
||||
for _, device := range devices {
|
||||
deviceID := device.Host // Use host as unique ID for now
|
||||
|
||||
// Skip if we already have this device
|
||||
if _, exists := app.Devices[deviceID]; exists {
|
||||
app.Devices[deviceID].LastSeen = time.Now()
|
||||
continue
|
||||
}
|
||||
|
||||
// Create new device connection
|
||||
clientConfig := &client.Config{
|
||||
Host: device.Host,
|
||||
Port: device.Port,
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
soundTouchClient := client.NewClient(clientConfig)
|
||||
|
||||
// Get device info
|
||||
deviceInfo, err := soundTouchClient.GetDeviceInfo()
|
||||
if err != nil {
|
||||
log.Printf("Failed to get device info for %s: %v", device.Host, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create device connection
|
||||
conn := &webtypes.DeviceConnection{
|
||||
Client: soundTouchClient,
|
||||
DeviceInfo: deviceInfo,
|
||||
LastSeen: time.Now(),
|
||||
Status: webtypes.DeviceStatus{
|
||||
IsConnected: false,
|
||||
LastActivity: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
// Initial status fetch asynchronously to avoid blocking discovery
|
||||
go app.UpdateDeviceStatus(deviceID, conn)
|
||||
|
||||
app.Devices[deviceID] = conn
|
||||
|
||||
log.Printf("Added device: %s (%s) at %s", deviceInfo.Name, deviceInfo.Type, device.Host)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/handlers"
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestSPARouting(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
expectedStatus int
|
||||
expectedHTML bool
|
||||
}{
|
||||
{
|
||||
name: "root path serves HTML",
|
||||
path: "/",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedHTML: true,
|
||||
},
|
||||
{
|
||||
name: "device path serves HTML",
|
||||
path: "/device/test-device",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedHTML: true,
|
||||
},
|
||||
{
|
||||
name: "arbitrary path serves HTML",
|
||||
path: "/some/random/path",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedHTML: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", tt.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Simulate SPA routing handler
|
||||
spaHandler := func(w http.ResponseWriter, r *http.Request) {
|
||||
// If it's an API route, let it pass through
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/static/") || strings.HasPrefix(r.URL.Path, "/ws") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Serve the SPA index.html content (simulated)
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>SoundTouch Control Center</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">SPA Content</div>
|
||||
</body>
|
||||
</html>`))
|
||||
}
|
||||
|
||||
spaHandler(w, req)
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
|
||||
}
|
||||
|
||||
if tt.expectedHTML {
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "text/html") {
|
||||
t.Errorf("Expected HTML content type, got %s", contentType)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "<!doctype html>") {
|
||||
t.Errorf("Expected HTML content, got: %s", body)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIEndpoints(t *testing.T) {
|
||||
app := handlers.NewWebApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
method string
|
||||
expectedStatus int
|
||||
expectedJSON bool
|
||||
}{
|
||||
{
|
||||
name: "devices API returns JSON",
|
||||
path: "/api/devices",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedJSON: true,
|
||||
},
|
||||
{
|
||||
name: "discover API accepts POST",
|
||||
path: "/api/discover",
|
||||
method: "POST",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedJSON: true,
|
||||
},
|
||||
{
|
||||
name: "device API with ID",
|
||||
path: "/api/device/test-device",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusNotFound, // Device won't exist in test
|
||||
expectedJSON: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tt.method, tt.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
switch tt.path {
|
||||
case "/api/devices":
|
||||
app.HandleAPIDevices(w, req)
|
||||
case "/api/discover":
|
||||
app.HandleAPIDiscover(w, req)
|
||||
default:
|
||||
if strings.HasPrefix(tt.path, "/api/device/") {
|
||||
app.HandleAPIDevice(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
|
||||
}
|
||||
|
||||
if tt.expectedJSON {
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "application/json") {
|
||||
t.Errorf("Expected JSON content type, got %s", contentType)
|
||||
}
|
||||
|
||||
// Validate JSON response structure
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Errorf("Invalid JSON response: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIResponseFormat(t *testing.T) {
|
||||
app := handlers.NewWebApp()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/devices", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleAPIDevices(w, req)
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode JSON response: %v", err)
|
||||
}
|
||||
|
||||
// Check API response structure
|
||||
if !response.Success {
|
||||
t.Errorf("Expected success=true, got success=%v", response.Success)
|
||||
}
|
||||
|
||||
if response.Data == nil {
|
||||
t.Errorf("Expected data field to be present")
|
||||
}
|
||||
|
||||
// Data should be an empty map for no devices
|
||||
dataMap, ok := response.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Errorf("Expected data to be a map, got %T", response.Data)
|
||||
}
|
||||
|
||||
if len(dataMap) != 0 {
|
||||
t.Errorf("Expected empty device map, got %d devices", len(dataMap))
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlAPIValidation(t *testing.T) {
|
||||
app := handlers.NewWebApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
method string
|
||||
body string
|
||||
expectedStatus int
|
||||
}{
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
name: "nonexistent device",
|
||||
path: "/api/control/nonexistent/play",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "unknown action with valid device",
|
||||
path: "/api/control/testdevice/unknownaction",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
|
||||
// Add a mock device for testing unknown action validation
|
||||
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)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
app.HandleAPIControl(w, req)
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("Test %s: Expected status %d, got %d. Response: %s", tt.name, tt.expectedStatus, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Validate error response format
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "application/json") {
|
||||
t.Errorf("Expected JSON content type, got %s", contentType)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Errorf("Invalid JSON response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success {
|
||||
t.Errorf("Expected success=false for error case, got success=true")
|
||||
}
|
||||
|
||||
if response.Error == "" {
|
||||
t.Errorf("Expected error message, got empty string")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSocketUpgrade(t *testing.T) {
|
||||
app := handlers.NewWebApp()
|
||||
|
||||
// Test WebSocket upgrade request
|
||||
req := httptest.NewRequest("GET", "/ws", nil)
|
||||
req.Header.Set("Connection", "upgrade")
|
||||
req.Header.Set("Upgrade", "websocket")
|
||||
req.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
|
||||
req.Header.Set("Sec-WebSocket-Version", "13")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// The actual WebSocket upgrade will fail in test environment,
|
||||
// but we can check that the handler exists and accepts the request
|
||||
app.HandleWebSocket(w, req)
|
||||
|
||||
// In a real test environment, this would fail with a websocket upgrade error
|
||||
// We're just checking the handler doesn't panic and processes the request
|
||||
}
|
||||
|
||||
func TestJSONAPIConsistency(t *testing.T) {
|
||||
app := handlers.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/") {
|
||||
app.HandleAPIDevice(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
// All API endpoints should return JSON
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "application/json") {
|
||||
t.Errorf("Endpoint %s should return JSON, got %s", endpoint, contentType)
|
||||
}
|
||||
|
||||
// All responses should follow APIResponse structure
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Errorf("Endpoint %s returned invalid JSON: %v", endpoint, err)
|
||||
}
|
||||
|
||||
// Response should have either data or error
|
||||
if response.Success && response.Data == nil {
|
||||
t.Errorf("Endpoint %s: success response should have data", endpoint)
|
||||
}
|
||||
if !response.Success && response.Error == "" {
|
||||
t.Errorf("Endpoint %s: error response should have error message", endpoint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,200 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SoundTouch Control Center</title>
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link href="/static/css/app.css" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="#" onclick="showPage('devices')">
|
||||
<i class="bi bi-speaker"></i>
|
||||
SoundTouch Control
|
||||
</a>
|
||||
<div class="navbar-nav ms-auto">
|
||||
<a
|
||||
class="nav-link"
|
||||
href="#"
|
||||
onclick="showPage('devices')"
|
||||
title="Home"
|
||||
>
|
||||
<i class="bi bi-house"></i>
|
||||
</a>
|
||||
<a
|
||||
class="nav-link tunein-nav-link"
|
||||
href="#"
|
||||
onclick="showPage('tunein')"
|
||||
title="TuneIn Browse"
|
||||
>
|
||||
<img
|
||||
src="/static/img/tunein-mono.svg"
|
||||
alt="TuneIn"
|
||||
class="tunein-nav-icon"
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
class="nav-link"
|
||||
href="#"
|
||||
onclick="discoverDevices()"
|
||||
title="Discover Devices"
|
||||
>
|
||||
<i class="bi bi-search"></i>
|
||||
</a>
|
||||
<button
|
||||
class="theme-toggle nav-link"
|
||||
onclick="toggleTheme()"
|
||||
title="Toggle Dark Mode"
|
||||
>
|
||||
<i id="theme-icon" class="bi bi-moon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-4">
|
||||
<!-- Device List Page -->
|
||||
<div id="devices-page" class="page active">
|
||||
<div
|
||||
class="d-flex justify-content-between align-items-center mb-4"
|
||||
>
|
||||
<h2>Your SoundTouch Devices</h2>
|
||||
<button class="btn btn-primary" onclick="discoverDevices()">
|
||||
<i class="bi bi-search"></i>
|
||||
Discover Devices
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="devices-loading" class="loading-spinner"></div>
|
||||
|
||||
<div id="devices-list" class="row">
|
||||
<!-- Device cards will be inserted here by JavaScript -->
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="no-devices"
|
||||
style="display: none"
|
||||
class="text-center py-5"
|
||||
>
|
||||
<i class="bi bi-speaker display-1 text-muted"></i>
|
||||
<h4 class="mt-3">No Devices Found</h4>
|
||||
<p class="text-muted">
|
||||
Click "Discover Devices" to search for SoundTouch
|
||||
speakers on your network.
|
||||
</p>
|
||||
<button class="btn btn-primary" onclick="discoverDevices()">
|
||||
<i class="bi bi-search"></i>
|
||||
Start Discovery
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TuneIn Browse Page -->
|
||||
<div id="tunein-page" class="page">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2><img src="/static/img/tunein-dark.svg" alt="TuneIn" class="tunein-heading-icon me-2" />TuneIn Browse</h2>
|
||||
</div>
|
||||
|
||||
<div class="tunein-search-bar mb-3">
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="text"
|
||||
id="tunein-search-input"
|
||||
class="form-control"
|
||||
placeholder="Search stations, podcasts..."
|
||||
/>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick="tuneInSearch(document.getElementById('tunein-search-input').value)"
|
||||
>
|
||||
<i class="bi bi-search"></i>
|
||||
Search
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
onclick="tuneInBrowse()"
|
||||
title="Browse top level"
|
||||
>
|
||||
<i class="bi bi-house"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav id="tunein-breadcrumb" class="mb-3" style="display: none">
|
||||
<!-- filled by JavaScript -->
|
||||
</nav>
|
||||
|
||||
<div id="tunein-results">
|
||||
<!-- filled by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Control Page -->
|
||||
<div id="device-page" class="page">
|
||||
<div class="back-button">
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
onclick="showPage('devices')"
|
||||
>
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
Back to Devices
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="device-content">
|
||||
<!-- Device control content will be inserted here by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container text-center">
|
||||
<small>
|
||||
SoundTouch Web Control Interface -
|
||||
<a
|
||||
href="https://github.com/gesellix/Bose-SoundTouch"
|
||||
target="_blank"
|
||||
class="text-decoration-none"
|
||||
>
|
||||
Open Source Project
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Toast container for notifications -->
|
||||
<div class="toast-container"></div>
|
||||
|
||||
<!-- Device picker for TuneIn playback -->
|
||||
<div class="modal fade" id="devicePickerModal" tabindex="-1" aria-labelledby="devicePickerLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header py-2">
|
||||
<h6 class="modal-title" id="devicePickerLabel">
|
||||
<i class="bi bi-speaker me-2"></i>Play on device
|
||||
</h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body p-2" id="devicePickerList">
|
||||
<!-- device buttons filled by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- Application JavaScript -->
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
// Package webtypes contains type definitions for the SoundTouch web UI.
|
||||
package webtypes
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
// SoundTouchClient defines the interface for SoundTouch client operations
|
||||
type SoundTouchClient interface {
|
||||
Play() error
|
||||
Pause() error
|
||||
Stop() error
|
||||
NextTrack() error
|
||||
PrevTrack() error
|
||||
SetVolume(level int) error
|
||||
SetBass(level int) error
|
||||
SelectPreset(id int) error
|
||||
SelectSource(source, account string) error
|
||||
SendKey(key string) error
|
||||
GetDeviceInfo() (*models.DeviceInfo, error)
|
||||
GetNowPlaying() (*models.NowPlaying, error)
|
||||
GetVolume() (*models.Volume, error)
|
||||
GetPresets() (*models.Presets, error)
|
||||
GetSources() (*models.Sources, error)
|
||||
GetBass() (*models.Bass, error)
|
||||
NewWebSocketClient(config interface{}) *client.WebSocketClient
|
||||
}
|
||||
|
||||
// DeviceConnection wraps a SoundTouch client with WebSocket connection
|
||||
type DeviceConnection struct {
|
||||
Client *client.Client
|
||||
WebSocket *client.WebSocketClient
|
||||
DeviceInfo *models.DeviceInfo
|
||||
LastSeen time.Time
|
||||
Status DeviceStatus
|
||||
}
|
||||
|
||||
// DeviceStatus represents the current device state
|
||||
type DeviceStatus struct {
|
||||
NowPlaying *models.NowPlaying `json:"nowPlaying,omitempty"`
|
||||
Volume *models.Volume `json:"volume,omitempty"`
|
||||
Presets *models.Presets `json:"presets,omitempty"`
|
||||
Sources *models.Sources `json:"sources,omitempty"`
|
||||
Bass *models.Bass `json:"bass,omitempty"`
|
||||
IsConnected bool `json:"isConnected"`
|
||||
LastActivity time.Time `json:"lastActivity"`
|
||||
}
|
||||
|
||||
// APIResponse is a standard JSON response wrapper
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// VolumeRequest represents a volume control request
|
||||
type VolumeRequest struct {
|
||||
Level int `json:"level"`
|
||||
}
|
||||
|
||||
// BassRequest represents a bass control request
|
||||
type BassRequest struct {
|
||||
Level int `json:"level"`
|
||||
}
|
||||
|
||||
// WebSocketMessage represents messages sent over WebSocket
|
||||
type WebSocketMessage struct {
|
||||
Type string `json:"type"`
|
||||
DeviceID string `json:"deviceId,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// Package types contains tests for type definitions.
|
||||
package webtypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestAPIResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response APIResponse
|
||||
wantJSON string
|
||||
}{
|
||||
{
|
||||
name: "success response",
|
||||
response: APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]string{"message": "OK"},
|
||||
},
|
||||
wantJSON: `{"success":true,"data":{"message":"OK"}}`,
|
||||
},
|
||||
{
|
||||
name: "error response",
|
||||
response: APIResponse{
|
||||
Success: false,
|
||||
Error: "Something went wrong",
|
||||
},
|
||||
wantJSON: `{"success":false,"error":"Something went wrong"}`,
|
||||
},
|
||||
{
|
||||
name: "success with nil data",
|
||||
response: APIResponse{
|
||||
Success: true,
|
||||
},
|
||||
wantJSON: `{"success":true}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Test that the struct fields are correctly set
|
||||
if tt.response.Success != (tt.name == "success response" || tt.name == "success with nil data") {
|
||||
t.Errorf("Expected success to match test case")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
req VolumeRequest
|
||||
level int
|
||||
}{
|
||||
{"zero volume", VolumeRequest{Level: 0}, 0},
|
||||
{"mid volume", VolumeRequest{Level: 50}, 50},
|
||||
{"max volume", VolumeRequest{Level: 100}, 100},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.req.Level != tt.level {
|
||||
t.Errorf("Expected level %d, got %d", tt.level, tt.req.Level)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBassRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
req BassRequest
|
||||
level int
|
||||
}{
|
||||
{"min bass", BassRequest{Level: -9}, -9},
|
||||
{"neutral bass", BassRequest{Level: 0}, 0},
|
||||
{"max bass", BassRequest{Level: 9}, 9},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.req.Level != tt.level {
|
||||
t.Errorf("Expected level %d, got %d", tt.level, tt.req.Level)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSocketMessage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
msg WebSocketMessage
|
||||
wantType string
|
||||
}{
|
||||
{
|
||||
name: "devices message",
|
||||
msg: WebSocketMessage{
|
||||
Type: "devices",
|
||||
Data: map[string]interface{}{"device1": "data"},
|
||||
},
|
||||
wantType: "devices",
|
||||
},
|
||||
{
|
||||
name: "status update message",
|
||||
msg: WebSocketMessage{
|
||||
Type: "status_update",
|
||||
DeviceID: "device1",
|
||||
Data: DeviceStatus{IsConnected: true},
|
||||
},
|
||||
wantType: "status_update",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.msg.Type != tt.wantType {
|
||||
t.Errorf("Expected type %s, got %s", tt.wantType, tt.msg.Type)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceConnection(t *testing.T) {
|
||||
deviceInfo := &models.DeviceInfo{
|
||||
Name: "Test Speaker",
|
||||
Type: "SoundTouch 30",
|
||||
NetworkInfo: []models.NetworkInfo{
|
||||
{MacAddress: "TEST123", IPAddress: "192.168.1.100"},
|
||||
},
|
||||
}
|
||||
|
||||
nowPlaying := &models.NowPlaying{
|
||||
Track: "Test Track",
|
||||
Artist: "Test Artist",
|
||||
Album: "Test Album",
|
||||
PlayStatus: models.PlayStatusPlaying,
|
||||
Source: "SPOTIFY",
|
||||
}
|
||||
|
||||
volume := &models.Volume{
|
||||
ActualVolume: 50,
|
||||
MuteEnabled: false,
|
||||
}
|
||||
|
||||
conn := &DeviceConnection{
|
||||
DeviceInfo: deviceInfo,
|
||||
LastSeen: time.Now(),
|
||||
Status: DeviceStatus{
|
||||
NowPlaying: nowPlaying,
|
||||
Volume: volume,
|
||||
IsConnected: true,
|
||||
LastActivity: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("device connection fields", func(t *testing.T) {
|
||||
if conn.DeviceInfo.Name != "Test Speaker" {
|
||||
t.Errorf("Expected device name 'Test Speaker', got '%s'", conn.DeviceInfo.Name)
|
||||
}
|
||||
|
||||
if conn.Status.NowPlaying.Track != "Test Track" {
|
||||
t.Errorf("Expected track 'Test Track', got '%s'", conn.Status.NowPlaying.Track)
|
||||
}
|
||||
|
||||
if conn.Status.Volume.ActualVolume != 50 {
|
||||
t.Errorf("Expected volume 50, got %d", conn.Status.Volume.ActualVolume)
|
||||
}
|
||||
|
||||
if !conn.Status.IsConnected {
|
||||
t.Error("Expected device to be connected")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeviceStatus(t *testing.T) {
|
||||
status := DeviceStatus{
|
||||
NowPlaying: &models.NowPlaying{
|
||||
Track: "Test Track",
|
||||
PlayStatus: models.PlayStatusPlaying,
|
||||
},
|
||||
Volume: &models.Volume{
|
||||
ActualVolume: 75,
|
||||
MuteEnabled: false,
|
||||
},
|
||||
Bass: &models.Bass{
|
||||
ActualBass: 3,
|
||||
},
|
||||
IsConnected: true,
|
||||
LastActivity: time.Now(),
|
||||
}
|
||||
|
||||
t.Run("device status fields", func(t *testing.T) {
|
||||
if status.NowPlaying == nil {
|
||||
t.Error("Expected now playing to be set")
|
||||
}
|
||||
|
||||
if status.Volume == nil {
|
||||
t.Error("Expected volume to be set")
|
||||
}
|
||||
|
||||
if status.Bass == nil {
|
||||
t.Error("Expected bass to be set")
|
||||
}
|
||||
|
||||
if !status.IsConnected {
|
||||
t.Error("Expected device to be connected")
|
||||
}
|
||||
|
||||
if status.LastActivity.IsZero() {
|
||||
t.Error("Expected last activity to be set")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil fields", func(t *testing.T) {
|
||||
emptyStatus := DeviceStatus{}
|
||||
|
||||
if emptyStatus.NowPlaying != nil {
|
||||
t.Error("Expected now playing to be nil")
|
||||
}
|
||||
|
||||
if emptyStatus.Volume != nil {
|
||||
t.Error("Expected volume to be nil")
|
||||
}
|
||||
|
||||
if emptyStatus.IsConnected {
|
||||
t.Error("Expected device to be disconnected by default")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Benchmark tests
|
||||
func BenchmarkAPIResponse(b *testing.B) {
|
||||
response := APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]string{"message": "OK"},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = response.Success
|
||||
_ = response.Data
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkDeviceStatus(b *testing.B) {
|
||||
status := DeviceStatus{
|
||||
NowPlaying: &models.NowPlaying{Track: "Test Track"},
|
||||
Volume: &models.Volume{ActualVolume: 50},
|
||||
IsConnected: true,
|
||||
LastActivity: time.Now(),
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = status.IsConnected
|
||||
_ = status.NowPlaying.Track
|
||||
_ = status.Volume.ActualVolume
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkWebSocketMessage(b *testing.B) {
|
||||
msg := WebSocketMessage{
|
||||
Type: "status_update",
|
||||
DeviceID: "device1",
|
||||
Data: DeviceStatus{
|
||||
IsConnected: true,
|
||||
LastActivity: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = msg.Type
|
||||
_ = msg.DeviceID
|
||||
_ = msg.Data
|
||||
}
|
||||
}
|
||||
@@ -3,5 +3,6 @@ certs/
|
||||
default/
|
||||
dns/
|
||||
interactions/
|
||||
parity_mismatches/
|
||||
patterns.json
|
||||
settings.json
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
services:
|
||||
soundtouch-service:
|
||||
build: .
|
||||
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
|
||||
@@ -8,6 +8,8 @@ services:
|
||||
ports:
|
||||
- "8000:8000"
|
||||
- "8443:8443"
|
||||
networks:
|
||||
- soundtouch-test-net
|
||||
environment:
|
||||
- PORT=8000
|
||||
- HTTPS_PORT=8443
|
||||
@@ -35,6 +37,22 @@ services:
|
||||
cpus: '0.25'
|
||||
memory: 128M
|
||||
|
||||
spotify-mock:
|
||||
image: golang:1.26.2-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
|
||||
|
||||
networks:
|
||||
soundtouch-test-net:
|
||||
name: soundtouch-test-net
|
||||
|
||||
volumes:
|
||||
soundtouch-data:
|
||||
# Named volumes are preferred in Swarm. For multi-node persistence,
|
||||
|
||||
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,41 @@
|
||||
# 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** | Placeholder handlers (return `<group/>` or 404). | Active group management (`groups.py`), supporting `/addGroup` and stereo pairing logic. |
|
||||
| **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`).
|
||||
|
||||
## 4. Suggested Implementation Steps for Bose-SoundTouch
|
||||
|
||||
### A. Implement Full Group Support (High Priority)
|
||||
- Add logic to `pkg/service/marge` to handle `/addGroup` and `/updateGroup`.
|
||||
- Persist group memberships in the datastore to allow speakers to function as stereo pairs or multi-room zones.
|
||||
|
||||
### B. 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.
|
||||
|
||||
### C. 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`.
|
||||
|
||||
### D. 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
|
||||
While our Go implementation is structurally more consistent with recent reference recordings (e.g., `buttonNumber`, detailed `components`), SoundCork provides better coverage of multi-device coordination (Groups) and service emulation (BMX) that we should adopt for a more complete offline experience.
|
||||
+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.
|
||||
|
||||
+78
-20
@@ -1,32 +1,90 @@
|
||||
# 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
|
||||
- **[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
|
||||
- [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.
|
||||
@@ -9,10 +9,15 @@
|
||||
* [Getting Started](guides/GETTING-STARTED.md)
|
||||
* [SoundTouch Service](guides/SOUNDTOUCH-SERVICE.md)
|
||||
* [Initial Device Setup](guides/DEVICE-INITIAL-SETUP.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,6 +29,7 @@
|
||||
## 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)
|
||||
@@ -38,6 +44,11 @@
|
||||
* [Key Controls](reference/KEY-CONTROLS.md)
|
||||
* [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)
|
||||
|
||||
## Analysis & Research
|
||||
* [API Coverage Analysis](analysis/API-COVERAGE.md)
|
||||
* [Supported URLs](analysis/SUPPORTED-URLS.md)
|
||||
@@ -45,8 +56,19 @@
|
||||
* [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)
|
||||
|
||||
## 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)
|
||||
@@ -64,3 +86,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,341 @@
|
||||
# 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.
|
||||
|
||||
## 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)
|
||||
|
||||
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)
|
||||
mitmweb --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
|
||||
/tmp/frida-venv/bin/frida \
|
||||
-U \
|
||||
-f com.bose.soundtouch \
|
||||
-l /tmp/config.js \
|
||||
-l /tmp/android-system-certificate-injection.js \
|
||||
-l /tmp/android-proxy-override.js \
|
||||
-l /tmp/android-certificate-unpinning.js \
|
||||
-l /tmp/android-certificate-unpinning-fallback.js
|
||||
```
|
||||
|
||||
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.*
|
||||
|
||||
+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,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.
|
||||
@@ -0,0 +1,137 @@
|
||||
# Spotify OAuth Integration
|
||||
|
||||
The SoundTouch service supports Spotify OAuth integration to broker access tokens for SoundTouch speakers. This is particularly useful for maintaining Spotify Connect functionality after the Bose cloud shutdown (scheduled for May 2026).
|
||||
|
||||
## OAuth Flows
|
||||
|
||||
The service supports two primary OAuth flows: a browser-based flow and a mobile app-based flow (specifically for the [ueberboese](https://github.com/julius-d/ueberboese-app) app).
|
||||
|
||||
### 1. Browser-based Flow
|
||||
|
||||
The user initiates the flow, completes authorization in their browser, and is redirected back to the service.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client as Client (curl/app)
|
||||
participant Service as Service
|
||||
participant Spotify as Spotify Auth Server
|
||||
participant Browser as User's Browser
|
||||
|
||||
Client->>Service: POST /mgmt/spotify/init [Basic Auth]
|
||||
Service-->>Client: {"redirectUrl": "https://accounts.spotify.com/authorize?..."}
|
||||
|
||||
Client->>Browser: User opens URL
|
||||
Browser->>Spotify: User logs in & grants access
|
||||
Spotify-->>Browser: Redirect to /mgmt/spotify/callback?code=abc
|
||||
|
||||
Browser->>Service: GET /mgmt/spotify/callback?code=abc
|
||||
Note over Service: No auth needed for callback
|
||||
|
||||
Service->>Spotify: POST /api/token (exchange code)
|
||||
Spotify-->>Service: {access_token, refresh_token}
|
||||
|
||||
Service->>Spotify: GET /v1/me (fetch profile)
|
||||
Spotify-->>Service: {id, display_name, email}
|
||||
|
||||
Note over Service: Store account to disk
|
||||
|
||||
Service-->>Browser: HTML: "Spotify Connected. You can close this window."
|
||||
```
|
||||
|
||||
### 2. Mobile App Flow (ueberboese)
|
||||
|
||||
The mobile app handles the redirect via a deep link and then confirms the authorization with the service.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App as ueberboese Flutter App
|
||||
participant Service as Service
|
||||
participant Spotify as Spotify Auth Server
|
||||
|
||||
App->>Service: POST /mgmt/spotify/init [Basic Auth]
|
||||
Service-->>App: {"redirectUrl": "https://..."}
|
||||
|
||||
App->>Spotify: Open in-app browser (User authorizes)
|
||||
Spotify-->>App: Deep link redirect: ueberboese-login://spotify?code=abc
|
||||
|
||||
App->>Service: POST /mgmt/spotify/confirm?code=abc [Basic Auth]
|
||||
|
||||
Service->>Spotify: POST /api/token (exchange code)
|
||||
Spotify-->>Service: {access_token, refresh_token}
|
||||
|
||||
Service->>Spotify: GET /v1/me (fetch profile)
|
||||
Spotify-->>Service: {profile}
|
||||
|
||||
Service-->>App: {"ok": true}
|
||||
```
|
||||
|
||||
### 3. Token Retrieval (Boot Primer / Speaker Setup)
|
||||
|
||||
Once an account is linked, access tokens can be retrieved for use with speakers (e.g., via the `addUser` ZeroConf command).
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Primer as Boot Primer Script
|
||||
participant Service as Service
|
||||
participant Spotify as Spotify Token API
|
||||
participant Speaker as Speaker (Bose ST 20)
|
||||
|
||||
Primer->>Service: GET /mgmt/spotify/token [Basic Auth]
|
||||
|
||||
alt Token expired
|
||||
Service->>Spotify: POST /api/token (refresh)
|
||||
Spotify-->>Service: new tokens
|
||||
end
|
||||
|
||||
Service-->>Primer: {"access_token": "...", "username": "..."}
|
||||
|
||||
Note over Primer: Spotify Connect ZeroConf
|
||||
Primer->>Speaker: POST /SpotifyConnect (addUser with token)
|
||||
Speaker-->>Primer: OK
|
||||
Note over Speaker: Speaker now has Spotify access
|
||||
```
|
||||
|
||||
## Boot Primer Script
|
||||
|
||||
A boot primer script that uses these endpoints to feed Spotify tokens to speakers via ZeroConf is available in the `scripts/spotify/` directory: [spotify-boot-primer.sh](../../scripts/spotify/spotify-boot-primer.sh).
|
||||
|
||||
This script can be installed on the speaker itself (which runs embedded Linux) to automatically prime Spotify Connect at boot time. See [README.md](../../scripts/spotify/README.md) and [INSTALL.md](../../scripts/spotify/INSTALL.md) for instructions.
|
||||
|
||||
### Automated Installation via Service
|
||||
|
||||
The SoundTouch service provides a dedicated management endpoint to automatically handle the installation of the Spotify boot primer on the speaker:
|
||||
`POST /mgmt/devices/{deviceId}/spotify/install-primer`
|
||||
|
||||
### Automated Installation Steps
|
||||
When you run the Spotify primer installation, the service performs the following:
|
||||
1. **Directories**: Creates `/mnt/nv/bin` and `/mnt/nv/BoseApp-Persistence/1` on the speaker.
|
||||
2. **Binary**: Uploads the `spotify-boot-primer` script to the speaker.
|
||||
3. **Configuration**: Automatically generates and uploads `spotify-primer.conf` containing the service's URL and management credentials.
|
||||
4. **Boot Hook**: Injects a call to the primer in the speaker's `/mnt/nv/rc.local` using idempotent markers.
|
||||
5. **Environment**: Updates `/mnt/nv/.profile` to include `/mnt/nv/bin` in the `PATH` for easier manual troubleshooting via SSH.
|
||||
|
||||
- **Idempotent Patching**: The service uses explicit markers to inject the hook, ensuring it doesn't corrupt existing content.
|
||||
- **Coexistence**: The service-injected hook is designed to coexist with a manually installed `rc.local` (e.g., from the community gist). It only adds a call to `/mnt/nv/bin/spotify-boot-primer` if it's not already managed by a service-controlled block.
|
||||
- **Markers**: Look for the following markers in your speaker's `/mnt/nv/rc.local`:
|
||||
- `# --- Aftertouch Spotify hook START ---`
|
||||
- `# --- Aftertouch Spotify hook END ---`
|
||||
- **Cleanup**: Reverting a migration via the service will cleanly remove these marker-delimited blocks.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|--------|---------------------------------------------------|-------|-----------------------------------------------------------------------|
|
||||
| POST | `/mgmt/devices/{deviceId}/spotify/install-primer` | Basic | Install Spotify boot primer on speaker (deviceId or IP) |
|
||||
| GET | `/mgmt/spotify/callback` | None | Browser OAuth callback (redirect from Spotify, returns HTML) |
|
||||
| POST | `/mgmt/spotify/init` | Basic | Start OAuth flow, returns authorization URL |
|
||||
| POST | `/mgmt/spotify/confirm` | Basic | Mobile app confirm (ueberboese deep link delivers code, returns JSON) |
|
||||
| GET | `/mgmt/spotify/accounts` | Basic | List linked Spotify accounts (tokens stripped) |
|
||||
| GET | `/mgmt/spotify/token` | Basic | Get fresh access token (auto-refreshes if expired) |
|
||||
| POST | `/mgmt/spotify/entity` | Basic | Resolve Spotify URI to name + image URL |
|
||||
|
||||
## Security
|
||||
|
||||
- `/mgmt/spotify/callback` is intentionally outside Basic Auth to allow direct redirects from Spotify's authorization server.
|
||||
- All other `/mgmt/*` endpoints require Basic Auth as configured by `--mgmt-username` and `--mgmt-password`.
|
||||
- Tokens are persisted to disk as JSON with restricted file permissions (`0600`).
|
||||
- The `GetAccounts` endpoint strips sensitive tokens from the response.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Spotify Priming Strategy
|
||||
|
||||
This document outlines the strategy for ensuring Bose SoundTouch devices are correctly "primed" for Spotify Connect integration within the AfterTouch ecosystem.
|
||||
|
||||
## 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.
|
||||
|
||||
AfterTouch adopts a **Server-Centric Hybrid Model** that prioritizes device cleanliness and user intent while providing automated self-healing.
|
||||
|
||||
## Core Principles
|
||||
|
||||
### 1. User Intent (Opt-in)
|
||||
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.
|
||||
- **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.
|
||||
|
||||
### 3. Triggers for Priming
|
||||
Priming is triggered when the speaker signals it is active and ready, specifically:
|
||||
|
||||
- **Power On:** When the speaker calls the `/marge/streaming/support/power_on` endpoint, AfterTouch ensures the device's ZeroConf state is correctly primed. This is the primary trigger.
|
||||
- **Manual Override:** Users can manually trigger a "Prime Spotify" from the device list in the UI if needed.
|
||||
|
||||
During any of these events, the server:
|
||||
1. Checks if a Spotify account is linked in AfterTouch.
|
||||
2. Checks the device's current priming status (via ZeroConf).
|
||||
3. If unprimed and an account is linked, it pushes the priming command.
|
||||
|
||||
### 4. Automated Recovery
|
||||
AfterTouch ensures that if a speaker loses its session (due to a crash or power loss), it is re-primed when it next powers on and reaches out to the service.
|
||||
|
||||
### 5. Decoupling
|
||||
The logic for account management and device interaction remains decoupled:
|
||||
- **Spotify Service:** Manages OAuth tokens and account state.
|
||||
- **Discovery Service:** Finds devices and tracks their network presence.
|
||||
- **Orchestrator:** Connects the two, deciding when to push tokens to discovered devices based on the current link status.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Initial Setup (The "Add Source" UX)
|
||||
1. User opens the AfterTouch Dashboard.
|
||||
2. User selects "Link Spotify Account."
|
||||
3. OAuth flow completes; AfterTouch stores the token.
|
||||
4. AfterTouch immediately triggers a discovery run to find and prime all compatible speakers.
|
||||
|
||||
### Maintenance (The "Watchdog" UX)
|
||||
1. A speaker reboots or loses its token.
|
||||
2. A discovery event occurs (periodic or triggered by UI).
|
||||
3. AfterTouch detects the "Empty" user state on the speaker.
|
||||
4. AfterTouch pushes a fresh token from the Spotify Service.
|
||||
5. UI reflects that the device is "Managed by AfterTouch" and healthy.
|
||||
|
||||
### 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.
|
||||
|
||||
## Network Topology & Deployment Scenarios
|
||||
|
||||
The strategy adapts based on where the AfterTouch server is deployed:
|
||||
|
||||
### Local Deployment (Home Server / Docker)
|
||||
- **Mechanism:** Both "Pull" (Marge) and "Push" (ZeroConf side-channel) are used.
|
||||
- **Advantage:** The server can proactively fix the speaker's state via port 8200 as soon as it sees a "Liveness Signal."
|
||||
|
||||
### External Deployment (Cloud VPS)
|
||||
- **Mechanism:** Primarily relies on "Pull" (Marge).
|
||||
- **Constraint:** The server cannot reach port 8200 on the speaker due to NAT/Firewall.
|
||||
- **Strategy:** In this scenario, AfterTouch acts as a passive token provider. The speaker must initiate the connection to our intercepted Bose endpoints to receive its Spotify configuration. If the speaker completely loses its user state and stops "pulling," a manual re-prime from a local machine or a temporary local discovery run might be required.
|
||||
|
||||
## Transition & Cleanup
|
||||
|
||||
As AfterTouch moves to the Server-Centric model, we will:
|
||||
1. **Revert On-Device Migration:** Update the Setup Manager to remove legacy `spotify-boot-primer` scripts and `rc.local` hooks from the speakers.
|
||||
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)
|
||||
|
||||
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.
|
||||
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
|
||||
@@ -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
|
||||
|
||||
@@ -33,16 +33,23 @@ The `soundtouch-service` now includes a built-in HTTPS listener. This simplifies
|
||||
|
||||
- **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`.
|
||||
- **Domain Coverage**: Automatically presents a certificate with comprehensive coverage using wildcard certificates (`*.api.bose.io`, `*.api.bosecm.com`) plus specific domains (`streaming.bose.com`, `updates.bose.com`, `stats.bose.com`, `bmx.bose.com`, `worldwide.bose.com`, `bose-prod.apigee.net`, etc.).
|
||||
- **Wildcard Support**: Uses RFC-compliant wildcard certificates for automatic coverage of all API subdomains, including event analytics endpoints like `events.api.bosecm.com`, `eventsdev.api.bosecm.com`, and future API services.
|
||||
- **TLS Error Logging**: Comprehensive logging of TLS handshake attempts, certificate matching, and connection failures for debugging DNS redirection issues.
|
||||
- **Automatic Setup**: On first start, it generates a server certificate signed by your AfterTouch local Root CA.
|
||||
|
||||
#### TLS Security
|
||||
#### TLS Security & Debugging
|
||||
|
||||
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`
|
||||
- **TLS Debugging**: Detailed logging of:
|
||||
- Certificate requests by domain (`[TLS] Certificate request for ServerName: events.api.bosecm.com`)
|
||||
- Wildcard certificate matching (`[TLS] ✅ Serving certificate for events.api.bosecm.com (matched *.api.bosecm.com)`)
|
||||
- Handshake failures (`[TLS] ❌ Handshake failed from 192.168.1.50: tls: certificate not found`)
|
||||
- Successful connections (`[TLS] ✅ Successful connection from 192.168.1.50`)
|
||||
- `ECDHE-RSA-AES256-GCM-SHA384`
|
||||
- `ECDHE-RSA-CHACHA20-POLY1305`
|
||||
- `RSA-AES128-GCM-SHA256` (Legacy support)
|
||||
|
||||
@@ -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,418 @@
|
||||
# THIS IS A PLANNED TO BE THE MIGRATION GUIDE
|
||||
|
||||
> This migration guide is not finalized, yet.
|
||||
> We're using it as an orientation for the required implementation.
|
||||
|
||||
---
|
||||
|
||||
# Complete Migration Guide - From Bose Cloud to Local SoundTouch Service
|
||||
|
||||
## Overview
|
||||
|
||||
This guide will walk you through migrating your Bose SoundTouch speakers from Bose's cloud services to AfterTouch, your own local SoundTouch service. By the end of this process, your speakers will be completely independent of Bose's servers while retaining all their functionality.
|
||||
|
||||
> **💡 Why Migrate?** Bose announced the shutdown of their SoundTouch cloud services in May 2026. This migration ensures your speakers continue working indefinitely with enhanced local control and monitoring.
|
||||
|
||||
## What You'll Need
|
||||
|
||||
### Hardware Requirements
|
||||
- **Raspberry Pi 4 or similar** (minimum: Raspberry Pi Zero 2W)
|
||||
- **MicroSD card** (16GB or larger)
|
||||
- **USB drive** (for device preparation)
|
||||
- **Network connection** for your Raspberry Pi
|
||||
|
||||
### Before You Start
|
||||
- **List all your SoundTouch devices** and their current locations
|
||||
- **Note your current presets and favorites** (they will be preserved)
|
||||
- **Ensure devices are on the same network** as your future SoundTouch service
|
||||
- **Basic computer skills** (following instructions, using a web browser)
|
||||
|
||||
### Time Estimate
|
||||
- **Setup**: 30-60 minutes for the service installation
|
||||
- **Per Device**: 10-15 minutes for each speaker migration
|
||||
- **Total**: 1-3 hours depending on number of devices
|
||||
|
||||
## Step 1: Install SoundTouch Service
|
||||
|
||||
### Option A: Raspberry Pi Installation (Recommended)
|
||||
|
||||
#### 1.1 Prepare Your Raspberry Pi
|
||||
|
||||
1. **Flash Raspberry Pi OS** to your SD card using Raspberry Pi Imager (see the raspberrypi.com documentation)
|
||||
2. **Enable SSH** during imaging or create an empty `ssh` file on the boot partition
|
||||
3. **Boot your Pi** and connect it to your network
|
||||
4. **Find your Pi's IP address** (check your router or use `ping raspberrypi.local`)
|
||||
|
||||
#### 1.2 Install SoundTouch Service
|
||||
|
||||
Connect to your Pi via SSH and run:
|
||||
|
||||
```bash
|
||||
# Download and install
|
||||
curl -sSL https://github.com/gesellix/Bose-SoundTouch/releases/latest/download/install.sh | bash
|
||||
|
||||
# Start the service
|
||||
sudo systemctl enable soundtouch-service
|
||||
sudo systemctl start soundtouch-service
|
||||
```
|
||||
|
||||
#### 1.3 Verify Installation
|
||||
|
||||
1. Open your web browser
|
||||
2. Go to `http://[PI_IP_ADDRESS]:8000` (replace with your Pi's IP)
|
||||
3. You should see the **SoundTouch Service Dashboard**
|
||||
|
||||

|
||||
*Example: SoundTouch Service main dashboard*
|
||||
|
||||
### Option B: Docker Installation
|
||||
|
||||
If you prefer Docker, run:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name soundtouch-service \
|
||||
--restart unless-stopped \
|
||||
-p 8000:8000 \
|
||||
-p 8443:8443 \
|
||||
-v soundtouch-data:/data \
|
||||
gesellix/soundtouch-service:latest
|
||||
```
|
||||
|
||||
## Step 2: Create Your Account
|
||||
|
||||
### 2.1 Initial Setup
|
||||
|
||||
1. **Open the dashboard** at `http://[SERVICE_IP]:8000`
|
||||
2. Click **"Create New Account"**
|
||||
3. **Fill in your details**:
|
||||
- Account Name: `My Home Audio`
|
||||
- Email: `your@email.com` (optional, for notifications)
|
||||
- Migration Strategy: `Gradual` (recommended)
|
||||
|
||||

|
||||
*Example: Account creation form*
|
||||
|
||||
### 2.2 Account Configuration
|
||||
|
||||
After creation, you'll see your **Account Dashboard**:
|
||||
- **Account ID**: Unique identifier (e.g., `acc_home_audio_001`)
|
||||
- **Status**: `Active - Ready for Migration`
|
||||
- **Device Count**: Initially 0
|
||||
- **Migration Status**: `Prepared`
|
||||
|
||||

|
||||
*Example: Fresh account dashboard ready for device migration*
|
||||
|
||||
### 2.3 Initial Settings
|
||||
|
||||
Once your account is created, configure the global settings:
|
||||
1. **Settings**:
|
||||
- Check **Target Domain**: Ensure it's reachable from the speaker (e.g., `soundtouch.fritz.box`).
|
||||
- **DNS Discovery**: Enable DNS discovery on port `:53`. This is crucial for the DNS hook migration method.
|
||||
2. **Devices**:
|
||||
- Go to the **"Device Discovery"** tab.
|
||||
- Click **"Scan Network"** or manually add a speaker via IP address.
|
||||
- Your devices should appear with **SSH Status**: `Enabled`.
|
||||
|
||||

|
||||
*Example: Discovered devices with remote access enabled*
|
||||
|
||||
## Step 3: Prepare Your Devices
|
||||
|
||||
> **⚠️ Important**: This step temporarily enables SSH access on your speakers. SSH will be automatically disabled after migration unless you choose to keep it enabled.
|
||||
|
||||
### 3.1 Enable Remote Services
|
||||
|
||||
For each SoundTouch device:
|
||||
|
||||
1. **Prepare a USB drive**:
|
||||
- Format as FAT32
|
||||
- Create an empty file named `remote_services` (no extension)
|
||||
- (Optional) Firmware update/reset, see [Bose SoundTouch USB Update](https://downloads.bose.com/ced/soundtouch/soundtouch_usb/index.html)
|
||||
|
||||
2. **Insert USB drive** into your SoundTouch speaker
|
||||
3. **Power cycle** the device (unplug for 10 seconds, then reconnect)
|
||||
|
||||

|
||||
*Example: USB drive setup for enabling remote services*
|
||||
|
||||
## Step 4: Discover and Register Devices
|
||||
|
||||
### 4.1 Automatic Discovery
|
||||
|
||||
The service automatically scans for SoundTouch devices every 5 minutes. To trigger immediate discovery:
|
||||
|
||||
1. **Dashboard** → **"Devices"** → **"Discover Devices"**
|
||||
2. **Wait 30-60 seconds** for scan completion
|
||||
3. **Review discovered devices** in the list
|
||||
|
||||
### 4.2 Register Devices to Your Account
|
||||
|
||||
For each discovered device:
|
||||
|
||||
1. **Click device name** in the discovery list
|
||||
2. **Verify device information**:
|
||||
- Name: `Living Room Speaker`
|
||||
- Model: `SoundTouch 30`
|
||||
- MAC Address: `A8:1B:6A:53:6A:98`
|
||||
- IP Address: `192.168.1.100`
|
||||
- Status: `Discovered - Ready for Registration`
|
||||
|
||||
3. **Click "Register to Account"**
|
||||
4. **Choose registration type**:
|
||||
- **Fresh Setup**: For new or factory-reset devices
|
||||
- **Migrate from Bose**: For devices with existing Bose account (recommended)
|
||||
|
||||

|
||||
*Example: Device registration dialog with migration options*
|
||||
|
||||
### 4.3 Device Registration Results
|
||||
|
||||
After registration, you'll see:
|
||||
- **Device Status**: `Registered - Active`
|
||||
- **Account Association**: Your account name
|
||||
- **Lifecycle State**: `Active`
|
||||
- **Data Sources**: `Mirror Primary` (initially uses Bose, falls back to local)
|
||||
|
||||
## Step 5: Migrate Individual Devices
|
||||
|
||||
### 5.1 Step 3: Data Sync
|
||||
|
||||
1. **Dashboard** → **"Devices"** → Select your device
|
||||
2. Click **"Data Sync"**
|
||||
3. This fetches configuration (presets, recents, sources) from the speaker to the SoundTouch service.
|
||||
|
||||
### 5.2 Step 4: Migration
|
||||
|
||||
Once data is synced, proceed to the migration tab for the device:
|
||||
|
||||
1. **Backup XML**: Create an off-device backup of the current configuration.
|
||||
2. **Enable Persistent Remote Service**: This ensures SSH remains available after reboots.
|
||||
- *Note*: If you see `'rw: command not found'`, you can safely ignore it.
|
||||
3. **CA Certificate Configuration**:
|
||||
- **Test with explicit CA**: Verify the speaker can communicate using the local CA.
|
||||
- **Trust CA now**: Inject the local Root CA into the speaker's trust store.
|
||||
- **Test with shared trust store**: Verify general HTTPS communication.
|
||||
4. **Migration Method**:
|
||||
- Select **"Redirect via DNS hook"**.
|
||||
- **Test DNS Redirection**: Ensure the speaker correctly resolves the service domain.
|
||||
5. **Confirm Migration**: Apply the final changes to the speaker.
|
||||
|
||||
#### Example Migration Output:
|
||||
```text
|
||||
Successfully created off-device backup of current configuration.
|
||||
Pre-flight: Write access verified.
|
||||
Resolved soundtouch.fritz.box to 192.168.1.100
|
||||
Uploaded /mnt/nv/soundtouch-service/aftertouch.resolv.conf
|
||||
/mnt/nv/rc.local already contains Aftertouch hook logic
|
||||
(rw || mount -o remount,rw /): sh: rw: command not found
|
||||
|
||||
cp /etc/udhcpc.d/50default /etc/udhcpc.d/50default.original:
|
||||
Applied patch to /etc/udhcpc.d/50default
|
||||
Verified patch on /etc/udhcpc.d/50default
|
||||
cp /opt/Bose/udhcpc.script /opt/Bose/udhcpc.script.original:
|
||||
Applied patch to /opt/Bose/udhcpc.script
|
||||
Verified patch on /opt/Bose/udhcpc.script
|
||||
CA certificate already trusted, skipping injection
|
||||
```
|
||||
|
||||
## Step 7: Complete Account Migration
|
||||
|
||||
### 7.1 Migrate All Devices
|
||||
|
||||
Repeat the migration process for each of your SoundTouch devices. You can migrate multiple devices simultaneously, but we recommend doing 1-2 at a time to monitor progress.
|
||||
|
||||
**Migration Dashboard** shows overall progress:
|
||||
- **Devices Migrated**: `2 of 4 completed`
|
||||
- **Currently Migrating**: `Living Room Speaker, Kitchen Speaker`
|
||||
- **Pending Migration**: `Bedroom Speaker, Office Speaker`
|
||||
- **Estimated Completion**: `3 days remaining`
|
||||
|
||||

|
||||
*Example: Account-wide migration progress*
|
||||
|
||||
### 7.2 Verify Complete Migration
|
||||
|
||||
When all devices are migrated:
|
||||
|
||||
1. **Account Status**: `Active - Fully Migrated`
|
||||
2. **Bose Dependency**: `None`
|
||||
3. **Local Control**: `100%`
|
||||
4. **Device Health**: All devices show `Healthy - Local Only`
|
||||
|
||||

|
||||
*Example: Completed migration dashboard*
|
||||
|
||||
## Step 8: Post-Migration Tasks
|
||||
|
||||
1. **Remove USB stick** from the speaker.
|
||||
2. **Reboot** the device to apply all changes.
|
||||
|
||||
### 8.1 Disable Remote Services (Optional)
|
||||
|
||||
For enhanced security, you can disable SSH on migrated devices. However, keeping it enabled allows for easier future maintenance or reverts.
|
||||
|
||||
### 8.2 Configure Backups
|
||||
|
||||
Set up automatic backups of your device configurations:
|
||||
|
||||
1. **Dashboard** → **"Settings"** → **"Backup"**
|
||||
2. **Enable Automatic Backups**: ✅
|
||||
3. **Backup Schedule**: `Daily at 2 AM`
|
||||
4. **Retention**: `Keep 30 days`
|
||||
5. **Export Location**: `/data/backups` or external storage
|
||||
|
||||

|
||||
*Example: Backup configuration settings*
|
||||
|
||||
### 8.3 Set Up Monitoring Alerts (Optional)
|
||||
|
||||
Configure notifications for important events:
|
||||
|
||||
1. **Dashboard** → **"Settings"** → **"Notifications"**
|
||||
2. **Email Notifications**: Enter your email
|
||||
3. **Alert Types**:
|
||||
- ✅ Device goes offline
|
||||
- ✅ Migration failures
|
||||
- ✅ Service errors
|
||||
- ✅ Daily health summary
|
||||
|
||||
## Troubleshooting Common Issues
|
||||
|
||||
### Device Not Discovered
|
||||
|
||||
**Problem**: Device doesn't appear in discovery scan
|
||||
|
||||
**Solutions**:
|
||||
1. **Check network**: Ensure device and service are on same network
|
||||
2. **Verify USB setup**: Confirm `remote_services` file was processed
|
||||
3. **Power cycle**: Unplug device for 30 seconds, reconnect
|
||||
4. **Manual add**: Dashboard → "Devices" → "Add Manually" with IP address
|
||||
|
||||
### Migration Stuck
|
||||
|
||||
**Problem**: Device stuck in "Migrating" status
|
||||
|
||||
**Solutions**:
|
||||
1. **Check device health**: Dashboard → Device → "Health Status"
|
||||
2. **Review logs**: Dashboard → Device → "View Logs"
|
||||
3. **Restart migration**: Device → "Migration" → "Restart Process"
|
||||
4. **Rollback**: Device → "Migration" → "Rollback to Bose"
|
||||
|
||||
### Presets Not Working
|
||||
|
||||
**Problem**: Saved presets don't work after migration
|
||||
|
||||
**Solutions**:
|
||||
1. **Verify sources**: Check configured sources are still available
|
||||
2. **Re-authenticate**: Re-login to music services (Spotify, etc.)
|
||||
3. **Rebuild presets**: Dashboard → Device → "Presets" → "Rebuild from Backup"
|
||||
|
||||
### Service Unreachable
|
||||
|
||||
**Problem**: Cannot access SoundTouch Service dashboard
|
||||
|
||||
**Solutions**:
|
||||
1. **Check service status**: `sudo systemctl status soundtouch-service`
|
||||
2. **Restart service**: `sudo systemctl restart soundtouch-service`
|
||||
3. **Check network**: Verify Pi is connected and accessible
|
||||
4. **Check ports**: Ensure ports 8000 and 8443 are not blocked
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Multi-Zone Management
|
||||
|
||||
After migration, your multi-zone setups work seamlessly:
|
||||
|
||||
1. **Dashboard** → **"Zones"**
|
||||
2. **Create Zone**: Select primary device and slaves
|
||||
3. **Zone Control**: Play, pause, volume control for entire zone
|
||||
4. **Individual Control**: Override individual speakers in zone
|
||||
|
||||
### Custom Sources
|
||||
|
||||
Add custom streaming sources:
|
||||
|
||||
1. **Dashboard** → **"Sources"** → **"Add Custom"**
|
||||
2. **Configure**:
|
||||
- Name: `Local Radio Station`
|
||||
- Stream URL: `http://stream.example.com:8000`
|
||||
- Image URL: `http://example.com/logo.png`
|
||||
3. **Assign to devices**: Select which devices can access this source
|
||||
|
||||
### API Access
|
||||
|
||||
For developers and advanced users:
|
||||
|
||||
- **REST API**: `http://[SERVICE_IP]:8000/api/v1/`
|
||||
- **Documentation**: `http://[SERVICE_IP]:8000/docs`
|
||||
- **WebSocket Events**: Real-time device status updates
|
||||
- **Export Data**: JSON/XML export of all device configurations
|
||||
|
||||
## Maintenance and Monitoring
|
||||
|
||||
### Daily Monitoring
|
||||
|
||||
Check your **Dashboard Summary**:
|
||||
- **All Devices Online**: ✅ Green indicators
|
||||
- **Response Times**: < 100ms average
|
||||
- **Error Rate**: < 1%
|
||||
- **Storage Usage**: Monitor disk space
|
||||
|
||||
### Weekly Tasks
|
||||
|
||||
1. **Review Health Reports**: Check weekly device health summaries
|
||||
2. **Update Service**: Check for SoundTouch service updates
|
||||
3. **Backup Verification**: Ensure backups are completing successfully
|
||||
4. **Log Review**: Check for any recurring issues or warnings
|
||||
|
||||
### Monthly Tasks
|
||||
|
||||
1. **Full System Backup**: Export complete account and device data
|
||||
2. **Performance Review**: Analyze response times and error patterns
|
||||
3. **Security Update**: Update Raspberry Pi OS and service
|
||||
4. **Capacity Planning**: Monitor storage and consider expansion
|
||||
|
||||
## Getting Help
|
||||
|
||||
### Documentation Resources
|
||||
|
||||
- **Technical Reference**: `/docs/reference/` - Detailed API and configuration docs
|
||||
- **Troubleshooting Guide**: `/docs/guides/TROUBLESHOOTING.md` - Common issues and solutions
|
||||
- **Community Forum**: GitHub Discussions for community support
|
||||
|
||||
### Diagnostic Information
|
||||
|
||||
When seeking help, provide:
|
||||
|
||||
1. **System Information**: Dashboard → "System" → "Download Diagnostic Report"
|
||||
2. **Device Logs**: Dashboard → Device → "Export Logs"
|
||||
3. **Migration History**: Dashboard → "Migration" → "Export Timeline"
|
||||
4. **Current Status**: Screenshot of main dashboard
|
||||
|
||||
### Support Channels
|
||||
|
||||
- **GitHub Issues**: Technical bugs and feature requests
|
||||
- **Community Discussions**: User questions and experiences
|
||||
- **Documentation Updates**: Corrections and improvements
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Congratulations! 🎉 You've successfully migrated your SoundTouch speakers to local control. Your devices are now:
|
||||
|
||||
- ✅ **Independent** of Bose cloud services
|
||||
- ✅ **Fully functional** with all original features preserved
|
||||
- ✅ **Enhanced** with better monitoring and control
|
||||
- ✅ **Future-proof** against service shutdowns
|
||||
|
||||
**What's Next?**
|
||||
|
||||
- **Enjoy your music** with enhanced local control
|
||||
- **Monitor your system** through the dashboard
|
||||
- **Share your experience** with the community
|
||||
- **Explore advanced features** as you become more comfortable
|
||||
|
||||
Your SoundTouch speakers will now continue working indefinitely, regardless of external service availability. Welcome to true audio independence! 🔊
|
||||
@@ -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.
|
||||
@@ -13,6 +13,8 @@ The service provides:
|
||||
- **🌐 Web Management UI**: Browser-based interface for device management
|
||||
- **💾 Persistent Data**: Store device configurations, presets, and usage statistics
|
||||
- **📝 HTTP Recording**: Persist all interactions as re-playable `.http` files
|
||||
- **🔄 Endpoint Mirroring**: Asynchronously mirror local requests to Bose cloud for parity testing
|
||||
- **⚖️ Parity Logging**: Detect and record discrepancies between local and official Bose responses
|
||||
- **📥 Session Archiving**: Download entire interaction sessions as `.tar.gz` for offline analysis
|
||||
- **🔍 Auto-Discovery**: Automatically detect and configure SoundTouch devices
|
||||
- **🔒 Offline Operation**: Continue using full device functionality without internet
|
||||
@@ -24,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
|
||||
@@ -55,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.
|
||||
|
||||
@@ -113,7 +116,7 @@ volumes:
|
||||
And run:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
@@ -167,6 +170,9 @@ The service supports multiple ways to configure its behavior. When multiple sour
|
||||
| `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` |
|
||||
| `MIRROR_ENABLED` | | Enable background mirroring of specific endpoints to Bose cloud | `false` |
|
||||
| `MIRROR_ENDPOINTS` | | Comma-separated list of path patterns to mirror (e.g., `/streaming/account/*/device/*/recent`) | `[]` |
|
||||
| `INTERNAL_PATHS` | `--internal-paths` | Paths for internal requests to exclude from recording (e.g., `/setup/*`, `/web/*`) | `[]` |
|
||||
| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` |
|
||||
|
||||
### Configuration Examples
|
||||
@@ -310,6 +316,34 @@ You can enable and configure the DNS server via the Web UI or environment variab
|
||||
#### Manual Discovery via DNS
|
||||
Even without migrating a device, you can use the DNS server to discover what a device is querying by manually setting your router's DNS or the device's DNS to point to the AfterTouch service.
|
||||
|
||||
## Endpoint Mirroring & Parity Logging
|
||||
|
||||
The SoundTouch service includes a powerful **Mirroring** feature that allows you to handle requests locally while simultaneously forwarding them to the official Bose cloud in the background. This is primarily used for maintaining long-term compatibility and verifying the accuracy of the local emulation.
|
||||
|
||||
### How Mirroring Works
|
||||
|
||||
When an endpoint is configured for mirroring:
|
||||
1. **GET Requests**: Handled locally first (Primary). The response is returned to the speaker immediately. In the background, the same request is sent to Bose.
|
||||
2. **POST/PUT/DELETE Requests**: Handled locally first. The service then synchronously (but without blocking the speaker's response) forwards the request to Bose to ensure the "official" account state stays in sync with your local changes (e.g., updating a preset).
|
||||
|
||||
### Parity Logging
|
||||
|
||||
The **Parity Logger** automatically compares the response from your local service with the one received from Bose. If it detects any discrepancies, it:
|
||||
1. Logs a warning to the console: `[PARITY] Mismatch detected for GET /...`
|
||||
2. Saves a detailed JSON report to `data/parity_mismatches/`.
|
||||
|
||||
Each report includes the full request, both response bodies, and a summary of what differed (status codes, content types, or missing/different XML tags).
|
||||
|
||||
### Configuration
|
||||
|
||||
Mirroring is configured via the **Settings** tab in the Web UI or through global settings:
|
||||
- **Mirror Enabled**: Master switch for the mirroring infrastructure.
|
||||
- **Mirror Endpoints**: A list of URL path patterns to mirror. You can use wildcards (`*`) to match variable parts like account or device IDs.
|
||||
- Example: `/streaming/account/*/device/*/recent`
|
||||
- Example: `/accounts/*/devices/*/presets/*`
|
||||
|
||||
Mirrored requests are also recorded in the **Interaction Log** under the category `upstream-mirror`, allowing you to see side-by-side exactly how our service's behavior compares to the official one.
|
||||
|
||||
## API Reference
|
||||
|
||||
### Discovery & Setup
|
||||
@@ -360,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)
|
||||
@@ -470,6 +504,17 @@ The web management interface provides a comprehensive dashboard for managing you
|
||||
|
||||
The service automatically records all HTTP interactions (both those handled locally and those proxied upstream) as `.http` files. These files are compatible with the [IntelliJ IDEA HTTP Client](https://www.jetbrains.com/help/idea/exploring-http-syntax.html).
|
||||
|
||||
### Internal Paths (Excluding Traffic)
|
||||
|
||||
To prevent internal management traffic (like the Web UI or setup API calls) from cluttering your interaction logs, you can configure **Internal Paths**. Requests matching these patterns will be processed normally but will **not** be recorded by the `RecordMiddleware`.
|
||||
|
||||
By default, we recommend adding:
|
||||
- `/setup/*`: Management API calls
|
||||
- `/web/*`: Static Web UI resources
|
||||
- `/media/*`: Icons and static media
|
||||
|
||||
You can configure these via the **Settings** tab in the Web UI or using the `--internal-paths` flag.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Session Grouping**: All interactions from a single server session are stored in a dedicated directory named `{timestamp}-{pid}`.
|
||||
@@ -720,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.
|
||||
|
||||
@@ -752,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)
|
||||
}
|
||||
```
|
||||
@@ -765,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
|
||||
|
||||
@@ -43,7 +43,7 @@ To migrate your speakers, the service needs SSH access. You can enable it by:
|
||||
3. Rebooting the speaker (unplug/replug).
|
||||
|
||||
**Verify SSH Access:**
|
||||
- Confirm the device responds to SSH without a password: `ssh -oHostKeyAlgorithms=+ssh-rsa root@<IP>`
|
||||
- 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).
|
||||
|
||||
|
||||
@@ -817,6 +817,42 @@ 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)
|
||||
|
||||
---
|
||||
|
||||
## 🛟 **Getting More Help**
|
||||
|
||||
### Information to Gather
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# Images for Migration Guide
|
||||
|
||||
This directory contains images, screenshots, and diagrams referenced in the migration guide and other documentation.
|
||||
|
||||
## Required Images for Migration Guide
|
||||
|
||||
The following images need to be created to complete the migration guide:
|
||||
|
||||
### Dashboard Screenshots
|
||||
- **dashboard-home.png** - Main SoundTouch Service dashboard homepage
|
||||
- **account-creation.png** - Account creation form with fields filled
|
||||
- **account-dashboard.png** - Fresh account dashboard showing ready state
|
||||
- **device-discovery.png** - Device discovery page showing found speakers
|
||||
- **device-registration.png** - Device registration dialog with options
|
||||
- **migration-setup.png** - Migration configuration dialog
|
||||
- **migration-progress.png** - Migration progress tracker showing phases
|
||||
- **migration-health.png** - Migration health monitoring dashboard
|
||||
- **account-migration.png** - Account-wide migration progress overview
|
||||
- **migration-complete.png** - Completed migration dashboard view
|
||||
- **backup-setup.png** - Backup configuration settings page
|
||||
|
||||
### Setup and Preparation
|
||||
- **usb-remote-services.png** - USB drive setup showing file structure
|
||||
- **raspberry-pi-setup.png** - Raspberry Pi with connected cables (optional)
|
||||
|
||||
### Process Diagrams
|
||||
- **migration-flow-diagram.png** - Flow chart showing migration phases
|
||||
- **network-topology.png** - Network diagram showing Pi, router, speakers
|
||||
- **data-flow-diagram.png** - How data flows between components
|
||||
|
||||
## Image Requirements
|
||||
|
||||
### Technical Specifications
|
||||
- **Format**: PNG preferred for screenshots, SVG for diagrams
|
||||
- **Resolution**: Minimum 1200px width for screenshots
|
||||
- **File Size**: Keep under 500KB when possible for fast loading
|
||||
- **Naming**: Use descriptive kebab-case names as shown above
|
||||
|
||||
### Content Guidelines
|
||||
- **Clean Interface**: Show realistic but clean interface states
|
||||
- **Consistent Styling**: Use consistent colors and styling across images
|
||||
- **Readable Text**: Ensure all text in screenshots is legible
|
||||
- **Example Data**: Use realistic example data (Living Room Speaker, etc.)
|
||||
- **Status Indicators**: Show clear success/error states with appropriate colors
|
||||
|
||||
### Placeholder Content
|
||||
Until real screenshots are available, consider:
|
||||
- **Mockups**: Create simple mockups showing the expected interface
|
||||
- **Wireframes**: Basic wireframes indicating layout and content
|
||||
- **Diagrams**: Technical diagrams can be created immediately
|
||||
- **Text Placeholders**: Use `[Image: Description]` in documentation
|
||||
|
||||
## Creating the Images
|
||||
|
||||
### For Dashboard Screenshots
|
||||
1. Set up the enhanced SoundTouch service
|
||||
2. Create sample account and register devices
|
||||
3. Take screenshots at key points in the migration process
|
||||
4. Edit for clarity (highlight important elements, add annotations)
|
||||
|
||||
### For Diagrams
|
||||
1. Use tools like Lucidchart, draw.io, or similar
|
||||
2. Follow consistent color scheme:
|
||||
- Blue: SoundTouch Service components
|
||||
- Green: Healthy/successful states
|
||||
- Orange: Warning/in-progress states
|
||||
- Red: Error/problematic states
|
||||
- Gray: External/third-party components
|
||||
|
||||
### For Physical Setup
|
||||
1. Take photos of actual hardware setup
|
||||
2. Show USB drive preparation process
|
||||
3. Demonstrate network connections if helpful
|
||||
|
||||
## Alternative Text Requirements
|
||||
|
||||
Each image should have appropriate alt text for accessibility:
|
||||
|
||||
```markdown
|
||||

|
||||
*Caption: Additional context or explanation*
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Consider adding:
|
||||
- **Video Walkthroughs**: Screen recordings of key processes
|
||||
- **Interactive Demos**: Web-based interactive guides
|
||||
- **Troubleshooting Screenshots**: Common error states and solutions
|
||||
- **Mobile Views**: How to access from mobile devices
|
||||
@@ -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,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.2
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.0.0
|
||||
require github.com/gesellix/bose-soundtouch v0.57.0
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
module preset-management-example
|
||||
|
||||
go 1.25.7
|
||||
go 1.26.2
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.0.0
|
||||
require github.com/gesellix/bose-soundtouch v0.57.0
|
||||
|
||||
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.")
|
||||
}
|
||||
@@ -1,23 +1,29 @@
|
||||
module github.com/gesellix/bose-soundtouch
|
||||
|
||||
go 1.25.7
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/google/gopacket v1.1.19
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/hashicorp/mdns v1.0.6
|
||||
github.com/miekg/dns v1.1.72
|
||||
github.com/russross/blackfriday/v2 v2.1.0
|
||||
github.com/sergi/go-diff v1.4.0
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
|
||||
github.com/urfave/cli/v2 v2.27.7
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
||||
golang.org/x/image v0.39.0 // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,39 +1,64 @@
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
|
||||
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/mdns v1.0.6 h1:SV8UcjnQ/+C7KeJ/QeVD/mdN2EmzYfcGfufcuzxfCLQ=
|
||||
github.com/hashicorp/mdns v1.0.6/go.mod h1:X4+yWh+upFECLOki1doUPaKpgNQII9gy4bUdCYKNhmM=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
|
||||
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
@@ -44,8 +69,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -53,9 +78,10 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -67,8 +93,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -79,8 +105,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
@@ -91,13 +117,22 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
|
||||
+115
-3
@@ -146,7 +146,9 @@ import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -192,12 +194,51 @@ func NewClient(config *Config) *Client {
|
||||
config.UserAgent = "Bose-SoundTouch-Go-Client/1.0"
|
||||
}
|
||||
|
||||
if config.Port == 0 {
|
||||
config.Port = 8090
|
||||
host := config.Host
|
||||
if !strings.Contains(host, "://") {
|
||||
host = "http://" + host
|
||||
}
|
||||
|
||||
u, err := url.Parse(host)
|
||||
if err != nil {
|
||||
// Fallback for invalid URLs
|
||||
port := config.Port
|
||||
if port == 0 {
|
||||
port = 8090
|
||||
}
|
||||
|
||||
return &Client{
|
||||
baseURL: fmt.Sprintf("http://%s:%d", config.Host, port),
|
||||
httpClient: &http.Client{
|
||||
Timeout: config.Timeout,
|
||||
},
|
||||
timeout: config.Timeout,
|
||||
userAgent: config.UserAgent,
|
||||
}
|
||||
}
|
||||
|
||||
// Use SplitHostPort to check for port in the host string
|
||||
_, p, splitErr := net.SplitHostPort(u.Host)
|
||||
if splitErr != nil {
|
||||
// No port in the host string, use the one from config or default
|
||||
port := config.Port
|
||||
if port == 0 {
|
||||
port = 8090
|
||||
}
|
||||
|
||||
u.Host = net.JoinHostPort(u.Host, fmt.Sprintf("%d", port))
|
||||
} else if p == "" {
|
||||
// Empty port, use config or default
|
||||
port := config.Port
|
||||
if port == 0 {
|
||||
port = 8090
|
||||
}
|
||||
|
||||
u.Host = net.JoinHostPort(u.Hostname(), fmt.Sprintf("%d", port))
|
||||
}
|
||||
|
||||
return &Client{
|
||||
baseURL: fmt.Sprintf("http://%s:%d", config.Host, config.Port),
|
||||
baseURL: u.String(),
|
||||
httpClient: &http.Client{
|
||||
Timeout: config.Timeout,
|
||||
},
|
||||
@@ -1116,6 +1157,19 @@ func (c *Client) post(endpoint string, payload interface{}) error {
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
responseBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
// Try to parse as ErrorsResponse (speaker error format)
|
||||
var errs models.ErrorsResponse
|
||||
if xmlErr := xml.Unmarshal(responseBody, &errs); xmlErr == nil && len(errs.Errors) > 0 {
|
||||
return &errs
|
||||
}
|
||||
|
||||
// Try to parse as APIError (standard format)
|
||||
var apiError models.APIError
|
||||
if xmlErr := xml.Unmarshal(responseBody, &apiError); xmlErr == nil && apiError.Message != "" {
|
||||
return &apiError
|
||||
}
|
||||
|
||||
return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(responseBody))
|
||||
}
|
||||
|
||||
@@ -1160,6 +1214,19 @@ func (c *Client) postWithResponse(endpoint string, payload, result interface{})
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
responseBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
// Try to parse as ErrorsResponse (speaker error format)
|
||||
var errs models.ErrorsResponse
|
||||
if xmlErr := xml.Unmarshal(responseBody, &errs); xmlErr == nil && len(errs.Errors) > 0 {
|
||||
return &errs
|
||||
}
|
||||
|
||||
// Try to parse as APIError (standard format)
|
||||
var apiError models.APIError
|
||||
if xmlErr := xml.Unmarshal(responseBody, &apiError); xmlErr == nil && apiError.Message != "" {
|
||||
return &apiError
|
||||
}
|
||||
|
||||
return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(responseBody))
|
||||
}
|
||||
|
||||
@@ -1172,6 +1239,11 @@ func (c *Client) postWithResponse(endpoint string, payload, result interface{})
|
||||
// Parse the actual response first
|
||||
if err := xml.Unmarshal(responseBody, result); err != nil {
|
||||
// Check if it might be an API error response instead
|
||||
var errs models.ErrorsResponse
|
||||
if xmlErr := xml.Unmarshal(responseBody, &errs); xmlErr == nil && len(errs.Errors) > 0 {
|
||||
return &errs
|
||||
}
|
||||
|
||||
var apiError models.APIError
|
||||
if xmlErr := xml.Unmarshal(responseBody, &apiError); xmlErr == nil && apiError.Message != "" {
|
||||
return &apiError
|
||||
@@ -1890,6 +1962,46 @@ func (c *Client) SetMusicServiceAccount(credentials *models.MusicServiceCredenti
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetMusicServiceOAuthAccount adds or updates a music service account using OAuth credentials
|
||||
func (c *Client) SetMusicServiceOAuthAccount(credentials *models.OAuthCredentials) error {
|
||||
if credentials == nil {
|
||||
return fmt.Errorf("credentials cannot be nil")
|
||||
}
|
||||
|
||||
var response models.MusicServiceAccountResponse
|
||||
|
||||
// Note: Modern firmware uses /setMusicServiceOAuthAccount, but we reuse the success logic
|
||||
err := c.postWithResponse("/setMusicServiceOAuthAccount", credentials, &response)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set music service OAuth account for %s: %w", credentials.Source, err)
|
||||
}
|
||||
|
||||
// The speaker returns /setMusicServiceOAuthAccount on success
|
||||
if response.Status != "/setMusicServiceOAuthAccount" {
|
||||
return fmt.Errorf("music service OAuth account operation failed: unexpected response %s", response.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NotifySourcesUpdated notifies the device that sources have been updated in Marge
|
||||
func (c *Client) NotifySourcesUpdated(deviceID string) error {
|
||||
notification := models.NewSourcesUpdatedNotification(deviceID)
|
||||
|
||||
var response models.MusicServiceAccountResponse
|
||||
|
||||
err := c.postWithResponse("/notification", notification, &response)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send sources updated notification: %w", err)
|
||||
}
|
||||
|
||||
if response.Status != "/notification" {
|
||||
return fmt.Errorf("sources updated notification failed: unexpected response %s", response.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveMusicServiceAccount removes an existing music service account
|
||||
func (c *Client) RemoveMusicServiceAccount(credentials *models.MusicServiceCredentials) error {
|
||||
if credentials == nil {
|
||||
|
||||
@@ -66,7 +66,7 @@ func TestNewClientFromHost(t *testing.T) {
|
||||
|
||||
func TestGetDeviceInfo_Success(t *testing.T) {
|
||||
// Load test data
|
||||
testData := loadTestData(t, "info_response.xml")
|
||||
testData := loadTestData(t, "info_response_st10.xml")
|
||||
|
||||
// Create mock server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -117,8 +117,8 @@ func TestGetDeviceInfo_Success(t *testing.T) {
|
||||
t.Errorf("Expected Name 'My SoundTouch Device', got '%s'", deviceInfo.Name)
|
||||
}
|
||||
|
||||
if deviceInfo.MargeAccountUUID != "3230304" {
|
||||
t.Errorf("Expected MargeAccountUUID '3230304', got '%s'", deviceInfo.MargeAccountUUID)
|
||||
if deviceInfo.MargeAccountUUID != "1234567" {
|
||||
t.Errorf("Expected MargeAccountUUID '1234567', got '%s'", deviceInfo.MargeAccountUUID)
|
||||
}
|
||||
|
||||
if deviceInfo.ModuleType != "sm2" {
|
||||
@@ -227,7 +227,7 @@ func TestGetDeviceInfo_APIError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPing_Success(t *testing.T) {
|
||||
testData := loadTestData(t, "info_response.xml")
|
||||
testData := loadTestData(t, "info_response_st10.xml")
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
@@ -1059,12 +1059,7 @@ func loadTestData(t *testing.T, filename string) string {
|
||||
}
|
||||
|
||||
func createTestClient(serverURL string) *Client {
|
||||
config := DefaultConfig()
|
||||
config.Host = "localhost" // Will be overridden by baseURL
|
||||
client := NewClient(config)
|
||||
client.baseURL = serverURL
|
||||
|
||||
return client
|
||||
return NewClientFromHost(serverURL)
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestClient_Post_ErrorsResponse(t *testing.T) {
|
||||
// Mock speaker error response
|
||||
errorXML := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<errors deviceID="08DF1F0BA325">
|
||||
<error value="1029" name="UNKNOWN_ACTION_ERROR" severity="Unknown">This version of SCM does not support spotify create account functionality.</error>
|
||||
</errors>`
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(errorXML))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := createTestClient(server.URL)
|
||||
|
||||
// Test post method
|
||||
err := c.post("/test", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
errs := &models.ErrorsResponse{}
|
||||
ok := errors.As(err, &errs)
|
||||
if !ok {
|
||||
t.Fatalf("expected models.ErrorsResponse, got %T: %v", err, err)
|
||||
}
|
||||
|
||||
if errs.DeviceID != "08DF1F0BA325" {
|
||||
t.Errorf("expected DeviceID 08DF1F0BA325, got %s", errs.DeviceID)
|
||||
}
|
||||
|
||||
if len(errs.Errors) != 1 {
|
||||
t.Fatalf("expected 1 error, got %d", len(errs.Errors))
|
||||
}
|
||||
|
||||
if errs.Errors[0].Value != 1029 {
|
||||
t.Errorf("expected error value 1029, got %d", errs.Errors[0].Value)
|
||||
}
|
||||
|
||||
if errs.Errors[0].Name != "UNKNOWN_ACTION_ERROR" {
|
||||
t.Errorf("expected error name UNKNOWN_ACTION_ERROR, got %s", errs.Errors[0].Name)
|
||||
}
|
||||
|
||||
expectedMsg := "This version of SCM does not support spotify create account functionality."
|
||||
if errs.Errors[0].Message != expectedMsg {
|
||||
t.Errorf("expected message '%s', got '%s'", expectedMsg, errs.Errors[0].Message)
|
||||
}
|
||||
|
||||
if err.Error() != expectedMsg {
|
||||
t.Errorf("expected Error() to return '%s', got '%s'", expectedMsg, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_PostWithResponse_ErrorsResponse(t *testing.T) {
|
||||
// Mock speaker error response
|
||||
errorXML := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<errors deviceID="08DF1F0BA325">
|
||||
<error value="1029" name="UNKNOWN_ACTION_ERROR" severity="Unknown">This version of SCM does not support spotify create account functionality.</error>
|
||||
</errors>`
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(errorXML))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := createTestClient(server.URL)
|
||||
|
||||
// Test postWithResponse method
|
||||
var result struct {
|
||||
XMLName xml.Name `xml:"status"`
|
||||
Data string `xml:",chardata"`
|
||||
}
|
||||
err := c.postWithResponse("/test", nil, &result)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
errs := &models.ErrorsResponse{}
|
||||
ok := errors.As(err, &errs)
|
||||
if !ok {
|
||||
t.Fatalf("expected models.ErrorsResponse, got %T: %v", err, err)
|
||||
}
|
||||
|
||||
if errs.Errors[0].Value != 1029 {
|
||||
t.Errorf("expected error value 1029, got %d", errs.Errors[0].Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Post_StandardAPIError(t *testing.T) {
|
||||
// Mock standard API error response
|
||||
errorXML := `<?xml version="1.0" encoding="UTF-8"?><error code="404">Not Found</error>`
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(errorXML))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := createTestClient(server.URL)
|
||||
|
||||
err := c.post("/test", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
apiErr := &models.APIError{}
|
||||
ok := errors.As(err, &apiErr)
|
||||
if !ok {
|
||||
t.Fatalf("expected models.APIError, got %T: %v", err, err)
|
||||
}
|
||||
|
||||
if apiErr.Code != 404 {
|
||||
t.Errorf("expected code 404, got %d", apiErr.Code)
|
||||
}
|
||||
|
||||
if apiErr.Message != "Not Found" {
|
||||
t.Errorf("expected message 'Not Found', got '%s'", apiErr.Message)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<info deviceID="ABCD1234EFGH">
|
||||
<name>My SoundTouch Device</name>
|
||||
<type>SoundTouch 10</type>
|
||||
<margeAccountUUID>3230304</margeAccountUUID>
|
||||
<margeAccountUUID>1234567</margeAccountUUID>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<info deviceID="ABCD1234EFGH">
|
||||
<name>My SoundTouch Device</name>
|
||||
<type>SoundTouch 20</type>
|
||||
<margeAccountUUID>3230304</margeAccountUUID>
|
||||
<margeAccountUUID>1234567</margeAccountUUID>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
|
||||
@@ -2,6 +2,7 @@ package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
@@ -519,6 +520,37 @@ func (ws *WebSocketClient) SendMessage(message []byte) error {
|
||||
return conn.WriteMessage(websocket.TextMessage, message)
|
||||
}
|
||||
|
||||
// PairWithAccount sends a request to pair the device with a specific account
|
||||
func (ws *WebSocketClient) PairWithAccount(accountID, userAuthToken string) error {
|
||||
request := models.PairDeviceWithAccount{
|
||||
AccountID: accountID,
|
||||
UserAuthToken: userAuthToken,
|
||||
}
|
||||
|
||||
data, err := xml.Marshal(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal pairing request: %w", err)
|
||||
}
|
||||
|
||||
ws.logger.Printf("Sending PairDeviceWithAccount for account %s", accountID)
|
||||
|
||||
return ws.SendMessage(data)
|
||||
}
|
||||
|
||||
// UnPairFromAccount sends a request to unpair the device from its account
|
||||
func (ws *WebSocketClient) UnPairFromAccount() error {
|
||||
request := models.UnPairDeviceWithAccount{}
|
||||
|
||||
data, err := xml.Marshal(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal unpairing request: %w", err)
|
||||
}
|
||||
|
||||
ws.logger.Printf("Sending UnPairDeviceWithAccount")
|
||||
|
||||
return ws.SendMessage(data)
|
||||
}
|
||||
|
||||
// Wait blocks until the WebSocket connection is closed or context is cancelled
|
||||
func (ws *WebSocketClient) Wait() {
|
||||
<-ws.ctx.Done()
|
||||
|
||||
@@ -30,6 +30,10 @@ type Config struct {
|
||||
// Cache settings
|
||||
CacheEnabled bool `env:"CACHE_ENABLED" default:"true"`
|
||||
CacheTTL time.Duration `env:"CACHE_TTL" default:"30s"`
|
||||
|
||||
// Migration settings (TODO: Remove after 3-4 releases when all devices are migrated)
|
||||
MigrationEnabled bool `env:"MIGRATION_ENABLED" default:"true"`
|
||||
MigrationDryRun bool `env:"MIGRATION_DRY_RUN" default:"false"`
|
||||
}
|
||||
|
||||
// DeviceConfig represents a configured SoundTouch device
|
||||
@@ -48,6 +52,8 @@ func DefaultConfig() *Config {
|
||||
PreferredDevices: []DeviceConfig{},
|
||||
HTTPTimeout: 10 * time.Second,
|
||||
UserAgent: "Bose-SoundTouch-Go-Client/1.0",
|
||||
MigrationEnabled: true, // TODO: Change to false after 3-4 releases
|
||||
MigrationDryRun: false,
|
||||
CacheEnabled: true,
|
||||
CacheTTL: 30 * time.Second,
|
||||
}
|
||||
|
||||
+108
-43
@@ -4,6 +4,7 @@ package discovery
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -14,7 +15,7 @@ import (
|
||||
// DNSDiscovery handles DNS queries and records discovered hosts.
|
||||
type DNSDiscovery struct {
|
||||
// Configuration
|
||||
upstreamDNS string
|
||||
upstreamDNS []string
|
||||
serviceIP string
|
||||
|
||||
// State
|
||||
@@ -31,6 +32,9 @@ type DNSDiscovery struct {
|
||||
// Address for loop prevention
|
||||
bindAddr string
|
||||
|
||||
// Forward timeout
|
||||
timeout time.Duration
|
||||
|
||||
// Log throttling
|
||||
lastLog map[string]time.Time
|
||||
lastLogMu sync.Mutex
|
||||
@@ -48,11 +52,12 @@ type DiscoveredHost struct {
|
||||
}
|
||||
|
||||
// NewDNSDiscovery creates a new DNSDiscovery instance.
|
||||
func NewDNSDiscovery(upstreamDNS, serviceIP string) *DNSDiscovery {
|
||||
func NewDNSDiscovery(upstreamDNS []string, serviceIP string) *DNSDiscovery {
|
||||
return &DNSDiscovery{
|
||||
upstreamDNS: upstreamDNS,
|
||||
serviceIP: serviceIP,
|
||||
discovered: make(map[string]*DiscoveredHost),
|
||||
timeout: 2 * time.Second,
|
||||
lastLog: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
@@ -83,7 +88,7 @@ func (d *DNSDiscovery) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
|
||||
d.throttledLog(fmt.Sprintf("[DNS] Intercepting %s (type %d) -> %s", hostname, q.Qtype, d.serviceIP))
|
||||
} else {
|
||||
// Forward to real DNS
|
||||
if d.upstreamDNS == "" {
|
||||
if len(d.upstreamDNS) == 0 {
|
||||
d.throttledLog("[DNS ERROR] No upstream DNS configured, cannot forward")
|
||||
|
||||
m := new(dns.Msg)
|
||||
@@ -94,7 +99,7 @@ func (d *DNSDiscovery) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
|
||||
return
|
||||
}
|
||||
|
||||
d.throttledLog(fmt.Sprintf("[DNS] Forwarding %s (type %d) to %s", hostname, q.Qtype, d.upstreamDNS))
|
||||
d.throttledLog(fmt.Sprintf("[DNS] Forwarding %s (type %d) to %v", hostname, q.Qtype, d.upstreamDNS))
|
||||
d.forward(w, r)
|
||||
}
|
||||
}
|
||||
@@ -155,6 +160,7 @@ func (d *DNSDiscovery) shouldIntercept(hostname string) bool {
|
||||
"marge.bose.com",
|
||||
"bmx.bose.com",
|
||||
"streaming.bose.com",
|
||||
"streamingoauth.bose.com",
|
||||
"updates.bose.com",
|
||||
"stats.bose.com",
|
||||
"content.api.bose.io",
|
||||
@@ -191,19 +197,78 @@ func (d *DNSDiscovery) respondWithIP(w dns.ResponseWriter, r *dns.Msg, ip string
|
||||
q := r.Question[0]
|
||||
log.Printf("[DNS] Intercepted query for %s (type %d) from %s", q.Name, q.Qtype, w.RemoteAddr())
|
||||
|
||||
resolvedIP := ip
|
||||
if net.ParseIP(ip) == nil {
|
||||
// Attempt resolution if it's not a numeric IP
|
||||
ips, err := net.LookupIP(ip)
|
||||
if err == nil && len(ips) > 0 {
|
||||
for _, rIP := range ips {
|
||||
if rIP.To4() != nil {
|
||||
resolvedIP = rIP.String()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if resolvedIP == ip && len(ips) > 0 {
|
||||
resolvedIP = ips[0].String()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch q.Qtype {
|
||||
case dns.TypeA, dns.TypeANY:
|
||||
rr, err := dns.NewRR(fmt.Sprintf("%s 60 IN A %s", q.Name, ip))
|
||||
if err == nil {
|
||||
m.Answer = append(m.Answer, rr)
|
||||
if net.ParseIP(resolvedIP) == nil || strings.Contains(resolvedIP, ":") {
|
||||
// If it's still not a valid IPv4 address, we can't create an A record.
|
||||
// Try CNAME as a fallback if it looks like a hostname.
|
||||
if !strings.Contains(resolvedIP, ":") {
|
||||
// Normalize hostname for CNAME
|
||||
target := resolvedIP
|
||||
if !strings.HasSuffix(target, ".") {
|
||||
target += "."
|
||||
}
|
||||
|
||||
log.Printf("[DNS] Returning A record %s -> %s", q.Name, ip)
|
||||
rr, err := dns.NewRR(fmt.Sprintf("%s 60 IN CNAME %s", q.Name, target))
|
||||
if err == nil {
|
||||
m.Answer = append(m.Answer, rr)
|
||||
|
||||
log.Printf("[DNS] Returning CNAME record %s -> %s", q.Name, target)
|
||||
} else {
|
||||
log.Printf("[DNS] Error creating CNAME fallback for %s: %v", target, err)
|
||||
|
||||
m.Rcode = dns.RcodeServerFailure
|
||||
}
|
||||
} else {
|
||||
m.Rcode = dns.RcodeServerFailure
|
||||
}
|
||||
} else {
|
||||
log.Printf("[DNS] Error creating A record: %v", err)
|
||||
rr, err := dns.NewRR(fmt.Sprintf("%s 60 IN A %s", q.Name, resolvedIP))
|
||||
if err == nil {
|
||||
m.Answer = append(m.Answer, rr)
|
||||
|
||||
log.Printf("[DNS] Returning A record %s -> %s", q.Name, resolvedIP)
|
||||
} else {
|
||||
log.Printf("[DNS] Error creating A record for %s: %v", resolvedIP, err)
|
||||
|
||||
m.Rcode = dns.RcodeServerFailure
|
||||
}
|
||||
}
|
||||
case dns.TypeAAAA:
|
||||
// Explicitly return SUCCESS with no data for AAAA to prevent fallback issues
|
||||
log.Printf("[DNS] Returning empty AAAA success (NODATA) for %s", q.Name)
|
||||
// Check if we have an IPv6 address
|
||||
if net.ParseIP(resolvedIP) != nil && strings.Contains(resolvedIP, ":") {
|
||||
rr, err := dns.NewRR(fmt.Sprintf("%s 60 IN AAAA %s", q.Name, resolvedIP))
|
||||
if err == nil {
|
||||
m.Answer = append(m.Answer, rr)
|
||||
|
||||
log.Printf("[DNS] Returning AAAA record %s -> %s", q.Name, resolvedIP)
|
||||
} else {
|
||||
log.Printf("[DNS] Error creating AAAA record for %s: %v", resolvedIP, err)
|
||||
|
||||
m.Rcode = dns.RcodeServerFailure
|
||||
}
|
||||
} else {
|
||||
// Explicitly return SUCCESS with no data for AAAA to prevent fallback issues if no IPv6
|
||||
log.Printf("[DNS] Returning empty AAAA success (NODATA) for %s", q.Name)
|
||||
}
|
||||
default:
|
||||
log.Printf("[DNS] Returning empty success for type %d", q.Qtype)
|
||||
}
|
||||
@@ -233,44 +298,44 @@ func (d *DNSDiscovery) forward(w dns.ResponseWriter, r *dns.Msg) {
|
||||
return
|
||||
}
|
||||
|
||||
// Add port 53 if not present
|
||||
upstream := d.upstreamDNS
|
||||
if !strings.Contains(upstream, ":") {
|
||||
upstream += ":53"
|
||||
}
|
||||
|
||||
// Loop prevention: don't forward to ourselves
|
||||
if upstream == d.bindAddr || (strings.HasPrefix(upstream, "127.0.0.1:") && strings.HasSuffix(d.bindAddr, upstream[9:])) {
|
||||
d.throttledLog(fmt.Sprintf("[DNS ERROR] Refusing to forward %s to ourselves (%s)", q.Name, upstream))
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
m.Rcode = dns.RcodeServerFailure
|
||||
_ = w.WriteMsg(m)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
c := new(dns.Client)
|
||||
c.Timeout = 2 * time.Second
|
||||
c.Timeout = d.timeout
|
||||
|
||||
in, _, err := c.Exchange(r, upstream)
|
||||
if err != nil {
|
||||
d.throttledLog(fmt.Sprintf("[DNS ERROR] Forward failed for %s (type %d): %v", q.Name, q.Qtype, err))
|
||||
// Return a failure response instead of just dropping
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
|
||||
m.Rcode = dns.RcodeServerFailure
|
||||
if err := w.WriteMsg(m); err != nil {
|
||||
log.Printf("[DNS ERROR] Failed to write failure response: %v", err)
|
||||
for _, upstream := range d.upstreamDNS {
|
||||
// Add port 53 if not present
|
||||
if !strings.Contains(upstream, ":") {
|
||||
upstream += ":53"
|
||||
}
|
||||
|
||||
return
|
||||
// Loop prevention: don't forward to ourselves
|
||||
if upstream == d.bindAddr || (strings.HasPrefix(upstream, "127.0.0.1:") && strings.HasSuffix(d.bindAddr, upstream[9:])) {
|
||||
d.throttledLog(fmt.Sprintf("[DNS ERROR] Refusing to forward %s to ourselves (%s)", q.Name, upstream))
|
||||
continue
|
||||
}
|
||||
|
||||
in, _, err := c.Exchange(r, upstream)
|
||||
if err == nil {
|
||||
if in.Rcode == dns.RcodeSuccess {
|
||||
if writeErr := w.WriteMsg(in); writeErr != nil {
|
||||
log.Printf("[DNS ERROR] Failed to write forwarded response from %s: %v", upstream, writeErr)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
d.throttledLog(fmt.Sprintf("[DNS] Upstream %s returned %s for %s, trying next", upstream, dns.RcodeToString[in.Rcode], q.Name))
|
||||
} else {
|
||||
d.throttledLog(fmt.Sprintf("[DNS ERROR] Forward failed for %s (type %d) via %s: %v", q.Name, q.Qtype, upstream, err))
|
||||
}
|
||||
}
|
||||
|
||||
if err := w.WriteMsg(in); err != nil {
|
||||
log.Printf("[DNS ERROR] Failed to write forwarded response: %v", err)
|
||||
// If we reach here, all upstreams failed
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
m.Rcode = dns.RcodeServerFailure
|
||||
|
||||
if err := w.WriteMsg(m); err != nil {
|
||||
log.Printf("[DNS ERROR] Failed to write failure response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+257
-12
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
func TestDNSDiscovery_Interception(t *testing.T) {
|
||||
serviceIP := "192.168.1.100"
|
||||
upstreamDNS := "8.8.8.8"
|
||||
upstreamDNS := []string{"8.8.8.8"}
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
|
||||
// Test intercepting Bose service
|
||||
@@ -38,6 +38,11 @@ func TestDNSDiscovery_Interception(t *testing.T) {
|
||||
t.Errorf("Expected A record, got %T", rw.msg.Answer[0])
|
||||
}
|
||||
|
||||
// Test intercepting streamingoauth.bose.com
|
||||
if !d.shouldIntercept("streamingoauth.bose.com") {
|
||||
t.Error("Expected streamingoauth.bose.com to be intercepted")
|
||||
}
|
||||
|
||||
// Test aftertouch.test
|
||||
m2 := new(dns.Msg)
|
||||
m2.SetQuestion("aftertouch.test.", dns.TypeA)
|
||||
@@ -61,7 +66,7 @@ func TestDNSDiscovery_Forwarding(t *testing.T) {
|
||||
// This test is harder because it needs a real upstream or a mock.
|
||||
// For now, let's just test that it calls forward and record.
|
||||
serviceIP := "192.168.1.100"
|
||||
upstreamDNS := "127.0.0.1:5353" // Use a port that is likely closed or we can mock
|
||||
upstreamDNS := []string{"127.0.0.1:5353"} // Use a port that is likely closed or we can mock
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
|
||||
m := new(dns.Msg)
|
||||
@@ -102,7 +107,7 @@ func TestDNSDiscovery_Forwarding(t *testing.T) {
|
||||
|
||||
func TestDNSDiscovery_StartTCP(t *testing.T) {
|
||||
serviceIP := "192.168.1.100"
|
||||
upstreamDNS := "8.8.8.8"
|
||||
upstreamDNS := []string{"8.8.8.8"}
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
|
||||
addr := "127.0.0.1:5354"
|
||||
@@ -149,9 +154,109 @@ func TestDNSDiscovery_StartTCP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSDiscovery_SelfForwarding(t *testing.T) {
|
||||
serviceIP := "soundtouch.local"
|
||||
upstreamDNS := []string{"127.0.0.1:5357"}
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
|
||||
// Mock upstream DNS server for soundtouch.local
|
||||
mux := dns.NewServeMux()
|
||||
mux.HandleFunc("soundtouch.local.", func(w dns.ResponseWriter, r *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
rr, _ := dns.NewRR("soundtouch.local. 60 IN A 192.168.178.10")
|
||||
m.Answer = append(m.Answer, rr)
|
||||
_ = w.WriteMsg(m)
|
||||
})
|
||||
ts := &dns.Server{Addr: "127.0.0.1:5357", Net: "udp", Handler: mux, ReadTimeout: 100 * time.Millisecond, WriteTimeout: 100 * time.Millisecond}
|
||||
go func() {
|
||||
_ = ts.ListenAndServe()
|
||||
}()
|
||||
defer func() { _ = ts.Shutdown() }()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("soundtouch.local.", dns.TypeA)
|
||||
rw := &mockResponseWriter{}
|
||||
d.ServeDNS(rw, m)
|
||||
|
||||
if rw.msg == nil {
|
||||
t.Fatal("Expected a response for soundtouch.local")
|
||||
}
|
||||
|
||||
if rw.msg.Rcode != dns.RcodeSuccess {
|
||||
t.Errorf("Expected Success (0) for soundtouch.local being forwarded, got %d", rw.msg.Rcode)
|
||||
}
|
||||
|
||||
if len(rw.msg.Answer) == 0 {
|
||||
t.Fatal("Expected an answer in the response")
|
||||
}
|
||||
|
||||
if a, ok := rw.msg.Answer[0].(*dns.A); ok {
|
||||
if a.A.String() != "192.168.178.10" {
|
||||
t.Errorf("Expected IP 192.168.178.10, got %s", a.A.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Check if d.recordQuery logged it correctly.
|
||||
d.mu.RLock()
|
||||
host, exists := d.discovered["soundtouch.local"]
|
||||
d.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
t.Error("Expected soundtouch.local to be recorded")
|
||||
}
|
||||
// It should NOT be intercepted anymore
|
||||
if host != nil && host.IsIntercepted {
|
||||
t.Error("Expected soundtouch.local NOT to be intercepted anymore, but forwarded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSDiscovery_ForwardLocal(t *testing.T) {
|
||||
serviceIP := "192.168.1.100"
|
||||
upstreamDNS := []string{"127.0.0.1:5356"}
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("someone-else.local.", dns.TypeA)
|
||||
rw := &mockResponseWriter{}
|
||||
|
||||
// Start a mock upstream DNS server that returns SUCCESS for .local
|
||||
mux := dns.NewServeMux()
|
||||
mux.HandleFunc("someone-else.local.", func(w dns.ResponseWriter, r *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
rr, _ := dns.NewRR("someone-else.local. 60 IN A 192.168.1.50")
|
||||
m.Answer = append(m.Answer, rr)
|
||||
_ = w.WriteMsg(m)
|
||||
})
|
||||
ts := &dns.Server{Addr: "127.0.0.1:5356", Net: "udp", Handler: mux, ReadTimeout: 100 * time.Millisecond, WriteTimeout: 100 * time.Millisecond}
|
||||
go func() {
|
||||
_ = ts.ListenAndServe()
|
||||
}()
|
||||
defer func() { _ = ts.Shutdown() }()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
d.ServeDNS(rw, m)
|
||||
|
||||
if rw.msg == nil {
|
||||
t.Fatal("Expected a response message")
|
||||
}
|
||||
|
||||
if rw.msg.Rcode != dns.RcodeSuccess {
|
||||
t.Errorf("Expected Success (0) for .local being forwarded, got %d", rw.msg.Rcode)
|
||||
}
|
||||
|
||||
if len(rw.msg.Answer) == 0 {
|
||||
t.Fatal("Expected an answer in the response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSDiscovery_IsRunning(t *testing.T) {
|
||||
serviceIP := "192.168.1.100"
|
||||
upstreamDNS := "8.8.8.8"
|
||||
upstreamDNS := []string{"8.8.8.8"}
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
|
||||
addr := "127.0.0.1:5355"
|
||||
@@ -196,7 +301,7 @@ func (m *mockResponseWriter) TsigTimersOnly(bool) {}
|
||||
func (m *mockResponseWriter) Hijack() {}
|
||||
|
||||
func TestDNSDiscovery_LogThrottling(t *testing.T) {
|
||||
d := NewDNSDiscovery("8.8.8.8", "192.168.1.100")
|
||||
d := NewDNSDiscovery([]string{"8.8.8.8"}, "192.168.1.100")
|
||||
|
||||
// Capture log output
|
||||
var logBuf strings.Builder
|
||||
@@ -229,7 +334,7 @@ func TestDNSDiscovery_LogThrottling(t *testing.T) {
|
||||
func TestDNSDiscovery_LoopPrevention(t *testing.T) {
|
||||
serviceIP := "192.168.1.100"
|
||||
bindAddr := "127.0.0.1:53"
|
||||
upstreamDNS := "127.0.0.1:53"
|
||||
upstreamDNS := []string{"127.0.0.1:53"}
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
d.bindAddr = bindAddr
|
||||
|
||||
@@ -256,7 +361,7 @@ func TestDNSDiscovery_LoopPrevention(t *testing.T) {
|
||||
|
||||
func TestDNSDiscovery_EmptyUpstream(t *testing.T) {
|
||||
serviceIP := "192.168.1.100"
|
||||
upstreamDNS := "" // Empty upstream
|
||||
var upstreamDNS []string // Empty upstream
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
d.bindAddr = ":53"
|
||||
|
||||
@@ -279,9 +384,26 @@ func TestDNSDiscovery_EmptyUpstream(t *testing.T) {
|
||||
|
||||
func TestDNSDiscovery_ForwardTimeout(t *testing.T) {
|
||||
serviceIP := "192.168.1.100"
|
||||
// Use an IP that is unroutable or doesn't exist on the network to ensure timeout
|
||||
upstreamDNS := "192.0.2.1:53" // TEST-NET-1, usually non-routable
|
||||
|
||||
// Mock server that deliberately delays its response
|
||||
mux := dns.NewServeMux()
|
||||
mux.HandleFunc("google.com.", func(w dns.ResponseWriter, r *dns.Msg) {
|
||||
time.Sleep(200 * time.Millisecond) // Longer than the timeout
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
_ = w.WriteMsg(m)
|
||||
})
|
||||
|
||||
ts := &dns.Server{Addr: "127.0.0.1:5358", Net: "udp", Handler: mux}
|
||||
go func() { _ = ts.ListenAndServe() }()
|
||||
defer func() { _ = ts.Shutdown() }()
|
||||
|
||||
// Give the server a moment to start
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
upstreamDNS := []string{"127.0.0.1:5358"}
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
d.timeout = 100 * time.Millisecond
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("google.com.", dns.TypeA)
|
||||
@@ -292,11 +414,134 @@ func TestDNSDiscovery_ForwardTimeout(t *testing.T) {
|
||||
d.forward(rw, m)
|
||||
duration := time.Since(start)
|
||||
|
||||
if duration < 2*time.Second {
|
||||
t.Errorf("Expected forward to take at least 2 seconds (timeout), but took %v", duration)
|
||||
// Since we're forwarding to a local server that sleeps for 200ms,
|
||||
// and our timeout is 100ms, it should take at least 100ms.
|
||||
if duration < 100*time.Millisecond {
|
||||
t.Errorf("Expected forward to take at least 100ms (timeout), but took %v", duration)
|
||||
}
|
||||
|
||||
if rw.msg == nil || rw.msg.Rcode != dns.RcodeServerFailure {
|
||||
t.Errorf("Expected RcodeServerFailure after timeout")
|
||||
t.Errorf("Expected RcodeServerFailure after timeout, got msg: %v", rw.msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSDiscovery_MultipleUpstreams(t *testing.T) {
|
||||
serviceIP := "192.168.1.100"
|
||||
|
||||
// Mock server 1: returns NXDOMAIN
|
||||
mux1 := dns.NewServeMux()
|
||||
mux1.HandleFunc("test.com.", func(w dns.ResponseWriter, r *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
m.Rcode = dns.RcodeNameError
|
||||
_ = w.WriteMsg(m)
|
||||
})
|
||||
ts1 := &dns.Server{Addr: "127.0.0.1:5356", Net: "udp", Handler: mux1}
|
||||
go func() { _ = ts1.ListenAndServe() }()
|
||||
defer func() { _ = ts1.Shutdown() }()
|
||||
|
||||
// Mock server 2: succeeds
|
||||
mux2 := dns.NewServeMux()
|
||||
mux2.HandleFunc("test.com.", func(w dns.ResponseWriter, r *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
m.Answer = append(m.Answer, &dns.A{
|
||||
Hdr: dns.RR_Header{Name: r.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300},
|
||||
A: net.ParseIP("1.2.3.4"),
|
||||
})
|
||||
_ = w.WriteMsg(m)
|
||||
})
|
||||
ts2 := &dns.Server{Addr: "127.0.0.1:5357", Net: "udp", Handler: mux2}
|
||||
go func() { _ = ts2.ListenAndServe() }()
|
||||
defer func() { _ = ts2.Shutdown() }()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
upstreamDNS := []string{"127.0.0.1:5356", "127.0.0.1:5357"}
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("test.com.", dns.TypeA)
|
||||
rw := &mockResponseWriter{}
|
||||
|
||||
d.forward(rw, m)
|
||||
|
||||
if rw.msg == nil {
|
||||
t.Fatal("Expected a response message")
|
||||
}
|
||||
|
||||
// It should succeed because it falls back to the second upstream
|
||||
if rw.msg.Rcode != dns.RcodeSuccess {
|
||||
t.Errorf("Expected RcodeSuccess (0), got %d. Fallback failed.", rw.msg.Rcode)
|
||||
}
|
||||
|
||||
if len(rw.msg.Answer) == 0 {
|
||||
t.Fatal("Expected an answer from the second upstream")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSDiscovery_HostnameServiceIP(t *testing.T) {
|
||||
// Use localhost which should resolve to 127.0.0.1
|
||||
serviceIP := "localhost"
|
||||
upstreamDNS := []string{"8.8.8.8"}
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("api.bose.com.", dns.TypeA)
|
||||
|
||||
rw := &mockResponseWriter{}
|
||||
d.ServeDNS(rw, m)
|
||||
|
||||
if rw.msg == nil {
|
||||
t.Fatal("Expected a response message, got nil")
|
||||
}
|
||||
|
||||
if len(rw.msg.Answer) == 0 {
|
||||
t.Fatal("Expected an answer in the response")
|
||||
}
|
||||
|
||||
if a, ok := rw.msg.Answer[0].(*dns.A); ok {
|
||||
// It should be resolved to 127.0.0.1 (or whatever localhost resolves to)
|
||||
if a.A.String() == "" {
|
||||
t.Error("Expected a non-empty IP address")
|
||||
}
|
||||
log.Printf("Resolved localhost to %s", a.A.String())
|
||||
} else if cname, ok := rw.msg.Answer[0].(*dns.CNAME); ok {
|
||||
// Fallback to CNAME is also acceptable if resolution failed but it shouldn't for localhost
|
||||
if cname.Target != "localhost." {
|
||||
t.Errorf("Expected CNAME to localhost., got %s", cname.Target)
|
||||
}
|
||||
} else {
|
||||
t.Errorf("Expected A or CNAME record, got %T", rw.msg.Answer[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSDiscovery_UnresolvableHostname(t *testing.T) {
|
||||
// Use a likely unresolvable hostname
|
||||
serviceIP := "this.hostname.does.not.exist.at.all.invalid"
|
||||
upstreamDNS := []string{"8.8.8.8"}
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("api.bose.com.", dns.TypeA)
|
||||
|
||||
rw := &mockResponseWriter{}
|
||||
d.ServeDNS(rw, m)
|
||||
|
||||
if rw.msg == nil {
|
||||
t.Fatal("Expected a response message, got nil")
|
||||
}
|
||||
|
||||
if len(rw.msg.Answer) == 0 {
|
||||
t.Fatal("Expected an answer in the response (CNAME fallback)")
|
||||
}
|
||||
|
||||
if cname, ok := rw.msg.Answer[0].(*dns.CNAME); ok {
|
||||
expected := serviceIP + "."
|
||||
if cname.Target != expected {
|
||||
t.Errorf("Expected CNAME to %s, got %s", expected, cname.Target)
|
||||
}
|
||||
} else {
|
||||
t.Errorf("Expected CNAME record for unresolvable hostname, got %T", rw.msg.Answer[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,9 @@ func TestNewMDNSDiscoveryService(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMDNSDiscoverDevices(t *testing.T) {
|
||||
service := NewMDNSDiscoveryService(2 * time.Second)
|
||||
service := NewMDNSDiscoveryService(100 * time.Millisecond)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
// Note: This test will attempt actual mDNS discovery
|
||||
|
||||
@@ -78,11 +78,11 @@ func TestUnifiedDiscoveryWithCustomConfig(t *testing.T) {
|
||||
|
||||
func TestUnifiedDiscoverDevices(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.DiscoveryTimeout = 2 * time.Second
|
||||
cfg.DiscoveryTimeout = 100 * time.Millisecond
|
||||
cfg.CacheEnabled = false // Disable cache for testing
|
||||
service := NewUnifiedDiscoveryService(cfg)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
devices, err := service.DiscoverDevices(ctx)
|
||||
@@ -119,13 +119,13 @@ func TestUnifiedDiscoverDevices(t *testing.T) {
|
||||
|
||||
func TestUnifiedDiscoveryOnlyMDNS(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.DiscoveryTimeout = 1 * time.Second
|
||||
cfg.DiscoveryTimeout = 100 * time.Millisecond
|
||||
cfg.UPnPEnabled = false // Disable UPnP
|
||||
cfg.MDNSEnabled = true // Enable only mDNS
|
||||
cfg.CacheEnabled = false
|
||||
service := NewUnifiedDiscoveryService(cfg)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
devices, err := service.DiscoverDevices(ctx)
|
||||
@@ -141,13 +141,13 @@ func TestUnifiedDiscoveryOnlyMDNS(t *testing.T) {
|
||||
|
||||
func TestUnifiedDiscoveryOnlySSDP(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.DiscoveryTimeout = 1 * time.Second
|
||||
cfg.DiscoveryTimeout = 100 * time.Millisecond
|
||||
cfg.UPnPEnabled = true // Enable only UPnP
|
||||
cfg.MDNSEnabled = false // Disable mDNS
|
||||
cfg.CacheEnabled = false
|
||||
service := NewUnifiedDiscoveryService(cfg)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
devices, err := service.DiscoverDevices(ctx)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user