Compare commits
@@ -17,15 +17,15 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
run: make test-http-client
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v6
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
with:
|
||||
file: ./coverage.out
|
||||
flags: unittests
|
||||
@@ -66,10 +66,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Run golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
|
||||
with:
|
||||
version: latest
|
||||
args: --timeout=5m
|
||||
@@ -107,15 +107,15 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
@@ -152,7 +152,7 @@ jobs:
|
||||
ls -la build/
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm }}
|
||||
path: build/
|
||||
@@ -163,10 +163,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
@@ -190,7 +190,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check documentation links
|
||||
run: |
|
||||
@@ -248,10 +248,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
@@ -303,10 +303,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Determine push eligibility
|
||||
id: push-check
|
||||
@@ -324,7 +324,7 @@ jobs:
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: steps.push-check.outputs.should-push == 'true'
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -332,7 +332,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for soundtouch-service
|
||||
id: meta-service
|
||||
uses: docker/metadata-action@v6
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
@@ -342,7 +342,7 @@ jobs:
|
||||
type=ref,event=branch,prefix=preview-branch-,enable=${{ github.event_name == 'push' && github.ref != 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push soundtouch-service Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-service
|
||||
@@ -355,7 +355,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for soundtouch-web
|
||||
id: meta-web
|
||||
uses: docker/metadata-action@v6
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}-web
|
||||
tags: |
|
||||
@@ -365,7 +365,7 @@ jobs:
|
||||
type=ref,event=branch,prefix=preview-branch-,enable=${{ github.event_name == 'push' && github.ref != 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push soundtouch-web Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-web
|
||||
@@ -447,7 +447,7 @@ jobs:
|
||||
|
||||
- name: Update commit status
|
||||
if: always()
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
try {
|
||||
|
||||
@@ -20,18 +20,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v6
|
||||
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
||||
- name: Build with Jekyll
|
||||
uses: actions/jekyll-build-pages@v1
|
||||
uses: actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697 # v1.0.13
|
||||
with:
|
||||
source: 'docs/'
|
||||
destination: '_site'
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
|
||||
with:
|
||||
path: '_site'
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v5
|
||||
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: ${{ env.GO_VERSION_FILE }}
|
||||
|
||||
@@ -102,15 +102,15 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: ${{ env.GO_VERSION_FILE }}
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
@@ -206,7 +206,7 @@ jobs:
|
||||
echo "✅ Checksums generated successfully"
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm }}
|
||||
path: |
|
||||
@@ -223,7 +223,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download binary artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: binaries-*
|
||||
path: ./binaries
|
||||
@@ -280,7 +280,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload checksums
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: checksums
|
||||
path: |
|
||||
@@ -291,7 +291,7 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload all release assets
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: release-assets
|
||||
path: binaries/release-files/
|
||||
@@ -305,12 +305,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download release assets
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: release-assets
|
||||
path: ./release-assets
|
||||
@@ -468,7 +468,7 @@ jobs:
|
||||
echo "release_notes_file=release_notes.md" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
tag_name: ${{ github.event.inputs.tag }}
|
||||
name: "Bose SoundTouch Go Library ${{ github.event.inputs.tag }}"
|
||||
@@ -494,13 +494,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download release assets
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: release-assets
|
||||
path: ./release-assets
|
||||
|
||||
- name: Upload additional assets to existing release
|
||||
uses: softprops/action-gh-release@v3
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
tag_name: ${{ github.event.release.tag_name }}
|
||||
files: |
|
||||
@@ -521,13 +521,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -535,7 +535,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for soundtouch-service
|
||||
id: meta-service
|
||||
uses: docker/metadata-action@v6
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
@@ -544,7 +544,7 @@ jobs:
|
||||
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
|
||||
|
||||
- name: Build and push soundtouch-service Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-service
|
||||
@@ -557,7 +557,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for soundtouch-web
|
||||
id: meta-web
|
||||
uses: docker/metadata-action@v6
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}-web
|
||||
tags: |
|
||||
@@ -566,7 +566,7 @@ jobs:
|
||||
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
|
||||
|
||||
- name: Build and push soundtouch-web Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-web
|
||||
|
||||
@@ -19,10 +19,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
|
||||
- name: Upload vulnerability scan results
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: vulnerability-scan-results
|
||||
path: |
|
||||
@@ -63,10 +63,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Run Semgrep security analysis
|
||||
uses: semgrep/semgrep-action@v1
|
||||
uses: semgrep/semgrep-action@713efdd345f3035192eaa63f56867b88e63e4e5d # v1 (no v1.x.y semver tag exists)
|
||||
with:
|
||||
config: >-
|
||||
p/security-audit
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
|
||||
- name: Upload Semgrep SARIF results
|
||||
if: always()
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
with:
|
||||
sarif_file: semgrep.sarif
|
||||
continue-on-error: true
|
||||
@@ -110,22 +110,22 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install libpcap
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
with:
|
||||
languages: go
|
||||
config-file: ./.github/codeql-config.yml
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
uses: github/codeql-action/autobuild@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
with:
|
||||
category: "/language:go"
|
||||
|
||||
@@ -138,10 +138,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Dependency Review
|
||||
uses: actions/dependency-review-action@v4
|
||||
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
|
||||
with:
|
||||
fail-on-severity: moderate
|
||||
allow-ghsas: GHSA-xxxx-xxxx-xxxx # Add specific allowlisted advisories if needed
|
||||
|
||||
@@ -169,7 +169,9 @@ test-http-client:
|
||||
/workdir/get_api_versions.http \
|
||||
/workdir/post_musicprovider_is_eligible.http \
|
||||
/workdir/get_full_account.http \
|
||||
/workdir/create_group.http \
|
||||
/workdir/get_group.http \
|
||||
/workdir/rename_device.http \
|
||||
/workdir/unregister_device.http \
|
||||
--report; \
|
||||
EXIT_CODE=$$?; \
|
||||
|
||||
@@ -157,6 +157,33 @@ func setClockTimeNow(c *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// setClockDisplayTimezone POSTs only the timezoneInfo attribute,
|
||||
// leaving format/brightness untouched. Useful after a clock now to
|
||||
// make the speaker's logs and front-panel display tick in local time
|
||||
// instead of UTC.
|
||||
func setClockDisplayTimezone(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
tz := c.String("tz")
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Setting clock timezone to %s", tz), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
request := models.NewClockDisplayRequest().SetTimeZone(tz)
|
||||
if err := client.SetClockDisplay(request); err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to set timezone: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess(fmt.Sprintf("Timezone set to %s", tz))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getClockDisplay retrieves the current clock display settings
|
||||
func getClockDisplay(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
@@ -23,6 +23,12 @@ func eventSubscribe(c *cli.Context) error {
|
||||
filterStr := c.String("filter")
|
||||
filters := parseEventFilters(filterStr)
|
||||
|
||||
debugMode, err := parseDebugMode(c.String("debug"))
|
||||
if err != nil {
|
||||
PrintError(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse duration
|
||||
duration := c.Duration("duration")
|
||||
verbose := c.Bool("verbose")
|
||||
@@ -60,6 +66,10 @@ func eventSubscribe(c *cli.Context) error {
|
||||
// Set up event handlers
|
||||
setupEventHandlers(wsClient, filters, verbose)
|
||||
|
||||
if debugMode != debugOff {
|
||||
installDebugHook(wsClient, debugMode)
|
||||
}
|
||||
|
||||
// Connect to WebSocket
|
||||
fmt.Println("🔌 Connecting to WebSocket...")
|
||||
|
||||
@@ -127,11 +137,78 @@ func eventSubscribe(c *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// debugMode controls when the WebSocket subscribe loop prints raw frames
|
||||
// to stderr. "off" disables debug output entirely (the production default
|
||||
// when --debug is unset).
|
||||
type debugMode int
|
||||
|
||||
const (
|
||||
debugOff debugMode = iota
|
||||
debugAll
|
||||
debugUnknown
|
||||
debugErrors
|
||||
)
|
||||
|
||||
func parseDebugMode(s string) (debugMode, error) {
|
||||
switch strings.TrimSpace(s) {
|
||||
case "":
|
||||
return debugOff, nil
|
||||
case "all":
|
||||
return debugAll, nil
|
||||
case "unknown":
|
||||
return debugUnknown, nil
|
||||
case "errors":
|
||||
return debugErrors, nil
|
||||
default:
|
||||
return debugOff, fmt.Errorf("invalid --debug value %q (want one of: all, unknown, errors)", s)
|
||||
}
|
||||
}
|
||||
|
||||
// installDebugHook wires an OnRawMessage handler that prints the raw
|
||||
// frame to stderr based on the chosen mode. Stays out of stdout so
|
||||
// debug output can be filtered/grep'd independently of normal events.
|
||||
func installDebugHook(ws *client.WebSocketClient, mode debugMode) {
|
||||
ws.OnRawMessage(func(data []byte, parseErr error) {
|
||||
switch mode {
|
||||
case debugAll:
|
||||
printRawFrame(data, parseErr, "all")
|
||||
case debugErrors:
|
||||
if parseErr != nil {
|
||||
printRawFrame(data, parseErr, "errors")
|
||||
}
|
||||
case debugUnknown:
|
||||
// "Unknown" = parsed successfully but no known event types
|
||||
// matched. Parse errors also qualify, since they're frames
|
||||
// the client couldn't interpret either.
|
||||
if parseErr != nil {
|
||||
printRawFrame(data, parseErr, "unknown:parse-error")
|
||||
return
|
||||
}
|
||||
|
||||
ev, err := models.ParseWebSocketEvent(data)
|
||||
if err != nil || len(ev.GetEventTypes()) == 0 {
|
||||
printRawFrame(data, err, "unknown")
|
||||
}
|
||||
case debugOff:
|
||||
// nothing
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func printRawFrame(data []byte, parseErr error, tag string) {
|
||||
prefix := "[ws-debug:" + tag + "]"
|
||||
if parseErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s parse-error: %v\n", prefix, parseErr)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%s %s\n", prefix, string(data))
|
||||
}
|
||||
|
||||
// parseEventFilters validates and parses the filter string
|
||||
func parseEventFilters(eventFilter string) map[string]bool {
|
||||
validFilters := map[string]bool{
|
||||
"nowPlaying": true, "volume": true, "connection": true,
|
||||
"preset": true, "zone": true, "bass": true,
|
||||
"preset": true, "zone": true, "group": true, "bass": true,
|
||||
"sdkInfo": true, "userActivity": true,
|
||||
}
|
||||
|
||||
@@ -217,6 +294,13 @@ func setupEventHandlers(wsClient *client.WebSocketClient, filters map[string]boo
|
||||
})
|
||||
}
|
||||
|
||||
// Stereo-pair (group) events — ST-10 only
|
||||
if filters == nil || filters["group"] {
|
||||
wsClient.OnGroupUpdated(func(event *models.GroupUpdatedEvent) {
|
||||
handleGroupEvent(event)
|
||||
})
|
||||
}
|
||||
|
||||
// Bass events
|
||||
if filters == nil || filters["bass"] {
|
||||
wsClient.OnBassUpdated(func(event *models.BassUpdatedEvent) {
|
||||
@@ -358,6 +442,34 @@ func handleZoneEvent(event *models.ZoneUpdatedEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
func handleGroupEvent(event *models.GroupUpdatedEvent) {
|
||||
group := &event.Group
|
||||
fmt.Printf("\n🎧 Stereo-Pair Update [%s]:\n", event.DeviceID)
|
||||
|
||||
if group.IsEmpty() {
|
||||
fmt.Println(" ⛓️💥 Pair dissolved (no group configured)")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf(" 🆔 ID: %s\n", group.ID)
|
||||
fmt.Printf(" 📛 Name: %s\n", group.Name)
|
||||
fmt.Printf(" 👑 Master: %s\n", group.MasterDeviceID)
|
||||
|
||||
if group.Status != "" {
|
||||
fmt.Printf(" ✅ Status: %s\n", group.Status)
|
||||
}
|
||||
|
||||
for _, r := range group.Roles.Roles {
|
||||
fmt.Printf(" %-5s %s", r.Role, r.DeviceID)
|
||||
|
||||
if r.IPAddress != "" {
|
||||
fmt.Printf(" (IP: %s)", r.IPAddress)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
func handleBassEvent(event *models.BassUpdatedEvent) {
|
||||
bass := &event.Bass
|
||||
fmt.Printf("\n🎵 Bass Update [%s]:\n", event.DeviceID)
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/speaker"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// getGroupStatus retrieves and prints the device's current stereo-pair state.
|
||||
func getGroupStatus(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader("Getting group information", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
group, err := client.GetGroup()
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to get group: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if group.IsEmpty() {
|
||||
fmt.Println("Device is not in a stereo pair")
|
||||
return nil
|
||||
}
|
||||
|
||||
printGroup(group)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createGroup forms a stereo pair by POSTing /addGroup to both speakers in
|
||||
// parallel. LEFT is the master. Addressing each speaker directly (instead of
|
||||
// only the master and letting it propagate via marge) sidesteps the
|
||||
// inter-device round-trip that surfaced as client timeouts in #252.
|
||||
func createGroup(c *cli.Context) error {
|
||||
leftIP := c.String("left")
|
||||
rightIP := c.String("right")
|
||||
name := c.String("name")
|
||||
|
||||
if net.ParseIP(leftIP) == nil {
|
||||
PrintError(fmt.Sprintf("Invalid left IP address: %s", leftIP))
|
||||
return fmt.Errorf("invalid left IP: %s", leftIP)
|
||||
}
|
||||
|
||||
if net.ParseIP(rightIP) == nil {
|
||||
PrintError(fmt.Sprintf("Invalid right IP address: %s", rightIP))
|
||||
return fmt.Errorf("invalid right IP: %s", rightIP)
|
||||
}
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Creating stereo pair: LEFT=%s RIGHT=%s", leftIP, rightIP), leftIP, speaker.HTTPPort)
|
||||
|
||||
leftInfo, err := fetchDeviceInfo(c, leftIP)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to read LEFT device info: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
rightInfo, err := fetchDeviceInfo(c, rightIP)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to read RIGHT device info: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("%s + %s", leftInfo.Name, rightInfo.Name)
|
||||
}
|
||||
|
||||
req := &models.Group{
|
||||
Name: name,
|
||||
MasterDeviceID: leftInfo.DeviceID,
|
||||
Roles: models.GroupRoles{
|
||||
Roles: []models.GroupRole{
|
||||
{DeviceID: leftInfo.DeviceID, Role: "LEFT", IPAddress: leftIP},
|
||||
{DeviceID: rightInfo.DeviceID, Role: "RIGHT", IPAddress: rightIP},
|
||||
},
|
||||
},
|
||||
// SenderIPAddress is intentionally omitted on the base request.
|
||||
// propagateAddGroup adds it to the slave's copy only — see comment there.
|
||||
}
|
||||
|
||||
leftClient, err := clientForHost(c, leftIP)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client for LEFT: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
rightClient, err := clientForHost(c, rightIP)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client for RIGHT: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
leftOut, rightOut := propagateAddGroup(leftClient, rightClient, leftIP, rightIP, req)
|
||||
|
||||
if leftOut.err != nil {
|
||||
PrintError(fmt.Sprintf("LEFT (%s) /addGroup failed: %v", leftIP, leftOut.err))
|
||||
}
|
||||
|
||||
if rightOut.err != nil {
|
||||
PrintError(fmt.Sprintf("RIGHT (%s) /addGroup failed: %v", rightIP, rightOut.err))
|
||||
}
|
||||
|
||||
if leftOut.err != nil || rightOut.err != nil {
|
||||
if (leftOut.err == nil) != (rightOut.err == nil) {
|
||||
succeeded := leftIP
|
||||
if leftOut.err != nil {
|
||||
succeeded = rightIP
|
||||
}
|
||||
|
||||
PrintError(fmt.Sprintf("Partial group state on %s — clean up with `soundtouch-cli --host %s group remove`", succeeded, succeeded))
|
||||
}
|
||||
|
||||
return fmt.Errorf("/addGroup propagation failed")
|
||||
}
|
||||
|
||||
// The LEFT (master) response carries the assigned group ID; use it for display.
|
||||
PrintSuccess(fmt.Sprintf("Stereo pair created (id=%s)", leftOut.group.ID))
|
||||
printGroup(leftOut.group)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addGroupOutcome is the per-speaker result of a parallel /addGroup call.
|
||||
type addGroupOutcome struct {
|
||||
host string
|
||||
group *models.Group
|
||||
err error
|
||||
}
|
||||
|
||||
// propagateAddGroup POSTs /addGroup to both speakers concurrently and returns
|
||||
// the (LEFT, RIGHT) outcomes. A non-GROUP_OK Status in the response is
|
||||
// reported as an error so callers don't have to re-inspect the body.
|
||||
//
|
||||
// The two POSTs carry different payloads: the master (LEFT) receives the base
|
||||
// request with no senderIPAddress so its state machine forms the group as the
|
||||
// master, while the slave (RIGHT) receives a copy with senderIPAddress set to
|
||||
// the master's IP so its state machine joins as the slave. Sending the same
|
||||
// payload to both makes both speakers think they're the slave — they enter
|
||||
// AddingSlave, wait for a master that never confirms, time out after 5 s, and
|
||||
// revert (issue #252).
|
||||
func propagateAddGroup(left, right *client.Client, leftIP, rightIP string, req *models.Group) (addGroupOutcome, addGroupOutcome) {
|
||||
masterReq := *req
|
||||
masterReq.SenderIPAddress = ""
|
||||
|
||||
slaveReq := *req
|
||||
slaveReq.SenderIPAddress = leftIP
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
leftOut, rightOut addGroupOutcome
|
||||
)
|
||||
|
||||
wg.Add(2)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
leftOut = postAddGroup(left, leftIP, &masterReq)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
rightOut = postAddGroup(right, rightIP, &slaveReq)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return leftOut, rightOut
|
||||
}
|
||||
|
||||
func postAddGroup(cli *client.Client, host string, req *models.Group) addGroupOutcome {
|
||||
out := addGroupOutcome{host: host}
|
||||
|
||||
g, err := cli.AddGroup(req)
|
||||
if err != nil {
|
||||
out.err = err
|
||||
return out
|
||||
}
|
||||
|
||||
out.group = g
|
||||
|
||||
if g != nil && g.Status != "" && g.Status != "GROUP_OK" {
|
||||
out.err = fmt.Errorf("device returned status %q (want GROUP_OK)", g.Status)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// renameGroup updates the name of the existing stereo pair. The device
|
||||
// requires the full structure on every update, so we fetch the current
|
||||
// state first.
|
||||
func renameGroup(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
newName := c.String("name")
|
||||
|
||||
if newName == "" {
|
||||
PrintError("--name is required")
|
||||
return fmt.Errorf("name is required")
|
||||
}
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Renaming stereo pair to %q", newName), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
stClient, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
current, err := stClient.GetGroup()
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to read current group: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if current.IsEmpty() {
|
||||
PrintError("Device is not in a stereo pair — nothing to rename")
|
||||
return fmt.Errorf("no group configured")
|
||||
}
|
||||
|
||||
// Status is read-only on the device side; don't echo it back.
|
||||
current.Status = ""
|
||||
current.Name = newName
|
||||
|
||||
result, err := stClient.UpdateGroup(current)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to rename group: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess(fmt.Sprintf("Stereo pair renamed to %q", result.Name))
|
||||
printGroup(result)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeGroup tears down the device's stereo pair.
|
||||
func removeGroup(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader("Removing stereo pair", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
stClient, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if err := stClient.RemoveGroup(); err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to remove group: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess("Stereo pair removed")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchDeviceInfo builds a one-off client for the given IP and reads /info.
|
||||
// Reused for both halves of a `create` invocation so the caller doesn't have
|
||||
// to babysit two host/port pairs.
|
||||
func fetchDeviceInfo(c *cli.Context, host string) (*models.DeviceInfo, error) {
|
||||
stClient, err := clientForHost(c, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return stClient.GetDeviceInfo()
|
||||
}
|
||||
|
||||
// clientForHost mirrors CreateSoundTouchClient but overrides the host so we
|
||||
// can talk to a speaker other than the one named in --host.
|
||||
func clientForHost(c *cli.Context, host string) (*client.Client, error) {
|
||||
cfg, err := loadConfig(c.Duration("timeout"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
return client.NewClient(&client.Config{
|
||||
Host: host,
|
||||
Port: speaker.HTTPPort,
|
||||
Timeout: cfg.HTTPTimeout,
|
||||
UserAgent: cfg.UserAgent,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func printGroup(g *models.Group) {
|
||||
fmt.Println("Stereo Pair Configuration:")
|
||||
fmt.Printf(" ID: %s\n", g.ID)
|
||||
fmt.Printf(" Name: %s\n", g.Name)
|
||||
fmt.Printf(" Master: %s\n", g.MasterDeviceID)
|
||||
|
||||
if g.Status != "" {
|
||||
fmt.Printf(" Status: %s\n", g.Status)
|
||||
}
|
||||
|
||||
for _, r := range g.Roles.Roles {
|
||||
fmt.Printf(" %-5s %s", r.Role, r.DeviceID)
|
||||
|
||||
if r.IPAddress != "" {
|
||||
fmt.Printf(" (IP: %s)", r.IPAddress)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
// happyAddGroupServer fakes a speaker's /addGroup that echoes the request
|
||||
// with an assigned ID and GROUP_OK status, matching real hardware behaviour.
|
||||
func happyAddGroupServer(t *testing.T, assignedID string) (*httptest.Server, *[]string) {
|
||||
t.Helper()
|
||||
|
||||
bodies := make([]string, 0)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/addGroup" || r.Method != http.MethodPost {
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
bodies = append(bodies, string(body))
|
||||
|
||||
var got models.Group
|
||||
if err := xml.Unmarshal(body, &got); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
|
||||
got.ID = assignedID
|
||||
got.Status = "GROUP_OK"
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
|
||||
enc, _ := xml.Marshal(&got)
|
||||
_, _ = w.Write(enc)
|
||||
}))
|
||||
|
||||
return srv, &bodies
|
||||
}
|
||||
|
||||
func newTestGroupClient(serverURL string) *client.Client {
|
||||
return client.NewClientFromHost(serverURL)
|
||||
}
|
||||
|
||||
func sampleGroupRequest(leftIP, rightIP string) *models.Group {
|
||||
return &models.Group{
|
||||
Name: "Living Room",
|
||||
MasterDeviceID: "9070658C9D4A",
|
||||
Roles: models.GroupRoles{
|
||||
Roles: []models.GroupRole{
|
||||
{DeviceID: "9070658C9D4A", Role: "LEFT", IPAddress: leftIP},
|
||||
{DeviceID: "F45EAB3115DA", Role: "RIGHT", IPAddress: rightIP},
|
||||
},
|
||||
},
|
||||
// senderIPAddress is intentionally not set here; propagateAddGroup
|
||||
// adds it to the slave's copy only.
|
||||
}
|
||||
}
|
||||
|
||||
func TestPropagateAddGroup_BothSucceed(t *testing.T) {
|
||||
leftSrv, leftBodies := happyAddGroupServer(t, "9999999")
|
||||
defer leftSrv.Close()
|
||||
|
||||
rightSrv, rightBodies := happyAddGroupServer(t, "9999999")
|
||||
defer rightSrv.Close()
|
||||
|
||||
leftClient := newTestGroupClient(leftSrv.URL)
|
||||
rightClient := newTestGroupClient(rightSrv.URL)
|
||||
|
||||
req := sampleGroupRequest("192.168.1.131", "192.168.1.134")
|
||||
|
||||
leftOut, rightOut := propagateAddGroup(leftClient, rightClient, "192.168.1.131", "192.168.1.134", req)
|
||||
|
||||
if leftOut.err != nil {
|
||||
t.Errorf("LEFT err = %v, want nil", leftOut.err)
|
||||
}
|
||||
|
||||
if rightOut.err != nil {
|
||||
t.Errorf("RIGHT err = %v, want nil", rightOut.err)
|
||||
}
|
||||
|
||||
if leftOut.group == nil || leftOut.group.ID != "9999999" || leftOut.group.Status != "GROUP_OK" {
|
||||
t.Errorf("LEFT group = %+v, want id=9999999 status=GROUP_OK", leftOut.group)
|
||||
}
|
||||
|
||||
if rightOut.group == nil || rightOut.group.Status != "GROUP_OK" {
|
||||
t.Errorf("RIGHT group = %+v, want status=GROUP_OK", rightOut.group)
|
||||
}
|
||||
|
||||
// Both speakers must have received the roles, but only the slave's payload
|
||||
// carries senderIPAddress — see propagateAddGroup for the why.
|
||||
for label, bodies := range map[string]*[]string{"LEFT": leftBodies, "RIGHT": rightBodies} {
|
||||
if len(*bodies) != 1 {
|
||||
t.Fatalf("%s: expected exactly one POST, got %d", label, len(*bodies))
|
||||
}
|
||||
|
||||
body := (*bodies)[0]
|
||||
for _, want := range []string{"<role>LEFT</role>", "<role>RIGHT</role>"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("%s body missing %q\nbody:\n%s", label, want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
leftBody := (*leftBodies)[0]
|
||||
if strings.Contains(leftBody, "<senderIPAddress>") {
|
||||
t.Errorf("LEFT (master) body must NOT carry <senderIPAddress>, otherwise the master flips into slave mode (issue #252)\nbody:\n%s", leftBody)
|
||||
}
|
||||
|
||||
rightBody := (*rightBodies)[0]
|
||||
if !strings.Contains(rightBody, "<senderIPAddress>192.168.1.131</senderIPAddress>") {
|
||||
t.Errorf("RIGHT (slave) body must carry <senderIPAddress>192.168.1.131</senderIPAddress>\nbody:\n%s", rightBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPropagateAddGroup_RightFails(t *testing.T) {
|
||||
leftSrv, _ := happyAddGroupServer(t, "9999999")
|
||||
defer leftSrv.Close()
|
||||
|
||||
rightSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
}))
|
||||
defer rightSrv.Close()
|
||||
|
||||
leftClient := newTestGroupClient(leftSrv.URL)
|
||||
rightClient := newTestGroupClient(rightSrv.URL)
|
||||
|
||||
req := sampleGroupRequest("192.168.1.131", "192.168.1.134")
|
||||
|
||||
leftOut, rightOut := propagateAddGroup(leftClient, rightClient, "192.168.1.131", "192.168.1.134", req)
|
||||
|
||||
if leftOut.err != nil {
|
||||
t.Errorf("LEFT err = %v, want nil", leftOut.err)
|
||||
}
|
||||
|
||||
if rightOut.err == nil {
|
||||
t.Error("RIGHT err = nil, want non-nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostAddGroup_StatusOtherThanGroupOKIsError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(`<group><status>GROUP_NOT_READY</status></group>`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
out := postAddGroup(newTestGroupClient(srv.URL), "test", sampleGroupRequest("1.1.1.1", "2.2.2.2"))
|
||||
|
||||
if out.err == nil {
|
||||
t.Fatal("expected error for non-GROUP_OK status")
|
||||
}
|
||||
|
||||
if !strings.Contains(out.err.Error(), "GROUP_NOT_READY") {
|
||||
t.Errorf("error %q does not mention returned status", out.err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostAddGroup_EmptyStatusIsAccepted(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(`<group id="42"><name>n</name></group>`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
out := postAddGroup(newTestGroupClient(srv.URL), "test", sampleGroupRequest("1.1.1.1", "2.2.2.2"))
|
||||
|
||||
if out.err != nil {
|
||||
t.Errorf("err = %v, want nil for empty status (some firmware omits it)", out.err)
|
||||
}
|
||||
|
||||
if out.group == nil || out.group.ID != "42" {
|
||||
t.Errorf("group = %+v, want id=42", out.group)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
)
|
||||
|
||||
// captureStdout runs fn and returns whatever it wrote to os.Stdout.
|
||||
// renderSourceTable prints directly via fmt.Print* — this lets us assert
|
||||
// on its output without restructuring the renderer to take an io.Writer.
|
||||
func captureStdout(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
|
||||
orig := os.Stdout
|
||||
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("pipe: %v", err)
|
||||
}
|
||||
|
||||
os.Stdout = w
|
||||
|
||||
done := make(chan struct{})
|
||||
buf := &bytes.Buffer{}
|
||||
|
||||
go func() {
|
||||
_, _ = io.Copy(buf, r)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
fn()
|
||||
_ = w.Close()
|
||||
|
||||
os.Stdout = orig
|
||||
<-done
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestRenderSourceTable_AlignsColumnsAndDedupsDisplayName(t *testing.T) {
|
||||
items := []models.SourceItem{
|
||||
// displayName != account → kept as "AUX (AUX IN)"
|
||||
{Source: "AUX", SourceAccount: "AUX", DisplayName: "AUX IN", Status: "READY", IsLocal: true, MultiroomAllowed: true},
|
||||
// displayName == account → dropped (would otherwise duplicate the next column)
|
||||
{Source: "AMAZON", SourceAccount: "amzn1.account.AFKTQOUNVZL7ODQCF4STPAAMVMPA", DisplayName: "amzn1.account.AFKTQOUNVZL7ODQCF4STPAAMVMPA", Status: "READY", MultiroomAllowed: true},
|
||||
// No displayName at all, no account
|
||||
{Source: "BLUETOOTH", Status: "UNAVAILABLE", IsLocal: true, MultiroomAllowed: true},
|
||||
// Long source name, no catalog entry → provider#?
|
||||
{Source: "STORED_MUSIC_MEDIA_RENDERER", SourceAccount: "StoredMusicUserName", DisplayName: "StoredMusicUserName", Status: "UNAVAILABLE", MultiroomAllowed: true},
|
||||
}
|
||||
|
||||
out := captureStdout(t, func() { renderSourceTable(items) })
|
||||
|
||||
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
|
||||
if len(lines) != 4 {
|
||||
t.Fatalf("got %d output lines, want 4:\n%s", len(lines), out)
|
||||
}
|
||||
|
||||
// (1) AUX keeps "(AUX IN)" because it differs from both source and account.
|
||||
if !strings.Contains(lines[0], "AUX (AUX IN)") {
|
||||
t.Errorf("AUX line should keep displayName parenthesis: %q", lines[0])
|
||||
}
|
||||
|
||||
// (2) AMAZON drops "(amzn1…)" because displayName equals sourceAccount.
|
||||
if strings.Contains(lines[1], "(amzn1.account") {
|
||||
t.Errorf("AMAZON line should drop displayName when it duplicates account: %q", lines[1])
|
||||
}
|
||||
|
||||
// (3) provider#? for the uncatalogued source.
|
||||
if !strings.Contains(lines[3], "provider#?") {
|
||||
t.Errorf("uncatalogued source should be tagged provider#?: %q", lines[3])
|
||||
}
|
||||
|
||||
// (4) Column starts must align across all rows — find the column index
|
||||
// where "status=" appears in each line; they should all match.
|
||||
statusCols := make([]int, len(lines))
|
||||
for i, l := range lines {
|
||||
statusCols[i] = strings.Index(l, "status=")
|
||||
if statusCols[i] < 0 {
|
||||
t.Fatalf("line %d missing status= column: %q", i, l)
|
||||
}
|
||||
}
|
||||
|
||||
for i := 1; i < len(statusCols); i++ {
|
||||
if statusCols[i] != statusCols[0] {
|
||||
t.Errorf("status= column misaligned: line 0 at col %d, line %d at col %d\n%s",
|
||||
statusCols[0], i, statusCols[i], out)
|
||||
}
|
||||
}
|
||||
|
||||
// (5) account= column should likewise align across all rows.
|
||||
accountCols := make([]int, len(lines))
|
||||
for i, l := range lines {
|
||||
accountCols[i] = strings.Index(l, "account=")
|
||||
if accountCols[i] < 0 {
|
||||
t.Fatalf("line %d missing account= column: %q", i, l)
|
||||
}
|
||||
}
|
||||
|
||||
for i := 1; i < len(accountCols); i++ {
|
||||
if accountCols[i] != accountCols[0] {
|
||||
t.Errorf("account= column misaligned: line 0 at col %d, line %d at col %d\n%s",
|
||||
accountCols[0], i, accountCols[i], out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSourceTable_EmptyShowsNonePlaceholder(t *testing.T) {
|
||||
out := captureStdout(t, func() { renderSourceTable(nil) })
|
||||
if !strings.Contains(out, "(none)") {
|
||||
t.Errorf("expected (none) placeholder for empty list, got: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendMigrationMethod_PrefersTelnet(t *testing.T) {
|
||||
method, reason := recommendMigrationMethod("http://aftertouch.local:8000", &setup.MigrationSummary{
|
||||
TelnetReachable: true,
|
||||
SSHSuccess: true,
|
||||
})
|
||||
|
||||
if method != setup.MigrationMethodTelnet {
|
||||
t.Errorf("method = %q, want telnet (simplest path when telnet works)", method)
|
||||
}
|
||||
|
||||
if !strings.Contains(reason, "Telnet") {
|
||||
t.Errorf("reason should mention Telnet: %q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendMigrationMethod_HTTPSAddsCaveatToTelnet(t *testing.T) {
|
||||
_, reason := recommendMigrationMethod("https://aftertouch.local:8443", &setup.MigrationSummary{
|
||||
TelnetReachable: true,
|
||||
})
|
||||
|
||||
if !strings.Contains(reason, "install-ca") {
|
||||
t.Errorf("HTTPS service URL should flag the CA-install caveat in the reason: %q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendMigrationMethod_FallsBackToResolvWhenTelnetDown(t *testing.T) {
|
||||
method, _ := recommendMigrationMethod("http://aftertouch.local:8000", &setup.MigrationSummary{
|
||||
TelnetReachable: false,
|
||||
SSHSuccess: true,
|
||||
})
|
||||
|
||||
if method != setup.MigrationMethodResolvConf {
|
||||
t.Errorf("method = %q, want resolv (DNS redirect via SSH)", method)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendMigrationMethod_EmptyWhenNoTransport(t *testing.T) {
|
||||
method, _ := recommendMigrationMethod("http://aftertouch.local:8000", &setup.MigrationSummary{
|
||||
TelnetReachable: false,
|
||||
SSHSuccess: false,
|
||||
})
|
||||
|
||||
if method != "" {
|
||||
t.Errorf("method = %q, want empty when no transport works", method)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanSteps_NoOpWhenAlreadyMigratedAndPaired(t *testing.T) {
|
||||
summary := &setup.MigrationSummary{IsMigrated: true, IsPaired: true, TelnetMigrated: true}
|
||||
inspect := &setup.InspectReport{Info: &setup.DeviceInfoXML{DeviceID: "AABBCCDDEEFF"}}
|
||||
|
||||
steps := buildPlanSteps("192.168.1.42", "http://aftertouch.local:8000", "", true, false, inspect, summary)
|
||||
|
||||
if len(steps) != 0 {
|
||||
t.Errorf("expected no steps for fully-set-up device, got %d:\n%v", len(steps), steps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanSteps_RecommendsPairWhenMigratedButUnpaired(t *testing.T) {
|
||||
summary := &setup.MigrationSummary{IsMigrated: true, IsPaired: false, TelnetMigrated: true, TelnetReachable: true}
|
||||
inspect := &setup.InspectReport{Info: &setup.DeviceInfoXML{DeviceID: "AABBCCDDEEFF"}}
|
||||
|
||||
steps := buildPlanSteps("192.168.1.42", "http://aftertouch.local:8000", "", true, false, inspect, summary)
|
||||
|
||||
if len(steps) != 1 {
|
||||
t.Fatalf("expected exactly the pair step, got %d:\n%v", len(steps), steps)
|
||||
}
|
||||
|
||||
if !strings.Contains(steps[0].cmd, "setup pair") {
|
||||
t.Errorf("expected pair command, got %q", steps[0].cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanSteps_MigrateRebootThenPairWhenFresh(t *testing.T) {
|
||||
summary := &setup.MigrationSummary{TelnetReachable: true, SSHSuccess: false, IsPaired: false}
|
||||
inspect := &setup.InspectReport{Info: &setup.DeviceInfoXML{DeviceID: "AABBCCDDEEFF"}}
|
||||
|
||||
steps := buildPlanSteps("192.168.1.42", "http://aftertouch.local:8000", "", true, false, inspect, summary)
|
||||
|
||||
// migrate → reboot → pair. The reboot step exists because envswitch's
|
||||
// parallel-persistence layer only fully wins on the next boot, and we
|
||||
// want the new URLs locked in before pairing posts to the speaker.
|
||||
if len(steps) != 3 {
|
||||
t.Fatalf("expected migrate+reboot+pair, got %d steps:\n%v", len(steps), steps)
|
||||
}
|
||||
|
||||
if !strings.Contains(steps[0].cmd, "setup migrate") || !strings.Contains(steps[0].cmd, "method=telnet") {
|
||||
t.Errorf("step 1 should be telnet migrate, got %q", steps[0].cmd)
|
||||
}
|
||||
|
||||
if !strings.Contains(steps[1].cmd, "setup reboot") {
|
||||
t.Errorf("step 2 should be reboot, got %q", steps[1].cmd)
|
||||
}
|
||||
|
||||
if !strings.Contains(steps[2].cmd, "setup pair") {
|
||||
t.Errorf("step 3 should be pair, got %q", steps[2].cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanSteps_DNSMethodPrependsCAInstall(t *testing.T) {
|
||||
// Telnet down, SSH up, CA not yet trusted → plan must install-ca
|
||||
// before applying the resolv migration.
|
||||
summary := &setup.MigrationSummary{
|
||||
TelnetReachable: false,
|
||||
SSHSuccess: true,
|
||||
CACertTrusted: false,
|
||||
IsPaired: false,
|
||||
}
|
||||
inspect := &setup.InspectReport{Info: &setup.DeviceInfoXML{DeviceID: "X"}}
|
||||
|
||||
steps := buildPlanSteps("192.168.1.42", "http://aftertouch.local:8000", "", false, false, inspect, summary)
|
||||
|
||||
if len(steps) < 2 {
|
||||
t.Fatalf("expected at least install-ca + migrate, got %d steps:\n%v", len(steps), steps)
|
||||
}
|
||||
|
||||
if !strings.Contains(steps[0].cmd, "install-ca") {
|
||||
t.Errorf("install-ca should come first when DNS method is chosen and CA is not trusted, got %q", steps[0].cmd)
|
||||
}
|
||||
|
||||
if !strings.Contains(steps[1].cmd, "method=resolv") {
|
||||
t.Errorf("step 2 should be resolv migrate, got %q", steps[1].cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanSteps_ResetModeIncludesManualNetworkSwitches(t *testing.T) {
|
||||
inspect := &setup.InspectReport{
|
||||
Info: &setup.DeviceInfoXML{DeviceID: "506583DE4803"},
|
||||
Network: &models.NetworkInformation{
|
||||
Interfaces: models.NetworkInterfaces{
|
||||
Interfaces: []models.NetworkInterface{
|
||||
{Type: "WIFI_INTERFACE", SSID: "MyHomeNetwork"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
summary := &setup.MigrationSummary{IsMigrated: true, IsPaired: true} // doesn't matter in reset mode
|
||||
|
||||
steps := buildPlanSteps("192.168.1.42", "http://aftertouch.local:8000", "", true, true, inspect, summary)
|
||||
|
||||
// Expected sequence in --reset mode:
|
||||
// factory-reset, manual AP switch, wait-ap, wifi-push, manual home switch,
|
||||
// wait-online, migrate, pair (8 steps).
|
||||
if len(steps) < 7 {
|
||||
t.Fatalf("expected at least 7 steps in --reset mode, got %d:\n%v", len(steps), steps)
|
||||
}
|
||||
|
||||
manualCount := 0
|
||||
for _, s := range steps {
|
||||
if s.manual {
|
||||
manualCount++
|
||||
}
|
||||
}
|
||||
|
||||
if manualCount < 2 {
|
||||
t.Errorf("expected at least 2 manual steps for the Wi-Fi switches, got %d", manualCount)
|
||||
}
|
||||
|
||||
if !strings.Contains(steps[0].cmd, "factory-reset") {
|
||||
t.Errorf("step 1 must be factory-reset, got %q", steps[0].cmd)
|
||||
}
|
||||
|
||||
// wifi-push step should default to the inspected SSID
|
||||
foundWiFi := false
|
||||
|
||||
for _, s := range steps {
|
||||
if strings.Contains(s.cmd, "wifi-push") && strings.Contains(s.cmd, "MyHomeNetwork") {
|
||||
foundWiFi = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !foundWiFi {
|
||||
t.Errorf("expected wifi-push step to default to inspected SSID 'MyHomeNetwork'")
|
||||
}
|
||||
|
||||
// wait-online --match should use the deviceID suffix
|
||||
foundMatch := false
|
||||
|
||||
for _, s := range steps {
|
||||
if strings.Contains(s.cmd, "wait-online") && strings.Contains(s.cmd, "--match=DE4803") {
|
||||
foundMatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !foundMatch {
|
||||
t.Errorf("expected wait-online step to use --match=DE4803 from deviceID suffix")
|
||||
}
|
||||
}
|
||||
@@ -1312,6 +1312,19 @@ func main() {
|
||||
},
|
||||
Before: RequireHost,
|
||||
},
|
||||
{
|
||||
Name: "timezone",
|
||||
Usage: "Set display timezone (IANA zone, e.g. Europe/Berlin)",
|
||||
Action: setClockDisplayTimezone,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "tz",
|
||||
Usage: "IANA timezone identifier (e.g. Europe/Berlin, America/New_York)",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
Before: RequireHost,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1478,6 +1491,64 @@ func main() {
|
||||
},
|
||||
},
|
||||
},
|
||||
// Stereo-pair (group) commands — ST-10 only
|
||||
{
|
||||
Name: "group",
|
||||
Aliases: []string{"g"},
|
||||
Usage: "ST-10 stereo-pair management (left/right channel pairing)",
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
Name: "status",
|
||||
Usage: "Show the device's current stereo-pair configuration",
|
||||
Action: getGroupStatus,
|
||||
Before: RequireHost,
|
||||
},
|
||||
{
|
||||
Name: "create",
|
||||
Usage: "Form a stereo pair (LEFT speaker becomes master)",
|
||||
Action: createGroup,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "left",
|
||||
Aliases: []string{"l"},
|
||||
Usage: "IP address of the LEFT speaker (will be master)",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "right",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "IP address of the RIGHT speaker",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "name",
|
||||
Aliases: []string{"n"},
|
||||
Usage: "Pair name (defaults to \"<left> + <right>\")",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "rename",
|
||||
Usage: "Rename the existing stereo pair on the device",
|
||||
Action: renameGroup,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "name",
|
||||
Aliases: []string{"n"},
|
||||
Usage: "New pair name",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
Before: RequireHost,
|
||||
},
|
||||
{
|
||||
Name: "remove",
|
||||
Usage: "Dissolve the device's stereo pair",
|
||||
Action: removeGroup,
|
||||
Before: RequireHost,
|
||||
},
|
||||
},
|
||||
},
|
||||
// Advanced Audio commands
|
||||
{
|
||||
Name: "audio",
|
||||
@@ -2093,7 +2164,7 @@ func main() {
|
||||
&cli.StringFlag{
|
||||
Name: "filter",
|
||||
Aliases: []string{"f"},
|
||||
Usage: "Filter events by type (comma-separated): nowPlaying,volume,connection,preset,zone,bass,sdkInfo,userActivity",
|
||||
Usage: "Filter events by type (comma-separated): nowPlaying,volume,connection,preset,zone,group,bass,sdkInfo,userActivity",
|
||||
},
|
||||
&cli.DurationFlag{
|
||||
Name: "duration",
|
||||
@@ -2105,6 +2176,10 @@ func main() {
|
||||
Name: "no-reconnect",
|
||||
Usage: "Disable automatic reconnection on connection loss",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "debug",
|
||||
Usage: "Print raw WebSocket frames to stderr — one of: all, unknown, errors",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "verbose",
|
||||
Aliases: []string{"v"},
|
||||
@@ -2117,6 +2192,10 @@ func main() {
|
||||
},
|
||||
}
|
||||
|
||||
// Speaker provisioning (factory-reset, Wi-Fi, URL rewrite, pairing).
|
||||
// Defined in cmd_setup.go to keep the top-level command list readable.
|
||||
app.Commands = append(app.Commands, setupCommand())
|
||||
|
||||
// Sort commands alphabetically (including subcommands and flags recursively)
|
||||
sortCommands(app.Commands)
|
||||
|
||||
|
||||
@@ -484,6 +484,8 @@ func main() {
|
||||
}
|
||||
|
||||
startHTTPSServer(config.httpsAddr, r, tlsConfig, config.httpsServerURL)
|
||||
|
||||
runHTTPSPreflight(config.httpsServerURL, config.serverURL, config.dnsEnabled, server.ResolveServerURLIPForPreflight)
|
||||
}()
|
||||
|
||||
return http.ListenAndServe(config.addr, r)
|
||||
@@ -906,11 +908,17 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Post("/v1/favorite/{stationID}", server.HandleTuneInFavorite)
|
||||
r.Delete("/v1/favorite/{stationID}", server.HandleTuneInDeleteFavorite)
|
||||
})
|
||||
|
||||
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
|
||||
r.Post("/core02/svc-bmx-adapter-orion/prod/orion/token", server.HandleOrionToken)
|
||||
})
|
||||
|
||||
// Orion (LOCAL_INTERNET_RADIO) lives at the top level — the BMX registry
|
||||
// advertises baseUrl `{BMX_SERVER}/core02/svc-bmx-adapter-orion/prod/orion`
|
||||
// (no `/bmx/` prefix; verified against the upstream capture in
|
||||
// pkg/service/handlers/static/bmx_services_ustream.json), so speakers
|
||||
// reach the token + station endpoints at exactly these paths under
|
||||
// either DNS-interception or URL-flip migration.
|
||||
r.Post("/core02/svc-bmx-adapter-orion/prod/orion/token", server.HandleOrionToken)
|
||||
r.Get("/core02/svc-bmx-adapter-orion/prod/orion/station", server.HandleOrionPlayback)
|
||||
|
||||
r.Get("/custom/v1/playback/{encodedURL}", server.HandleCustomPlayback)
|
||||
|
||||
r.Route("/streaming", func(r chi.Router) {
|
||||
@@ -928,31 +936,47 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Get("/presets/all", server.HandleMargeAccountPresets)
|
||||
r.Get("/provider_settings", server.HandleMargeProviderSettings)
|
||||
|
||||
// All `/device` routes share one chi subrouter. Two
|
||||
// overlapping subrouters (`/device` + `/device/{device}`)
|
||||
// caused chi's radix-tree resolver to bind a runtime
|
||||
// request to the more-specific prefix even when only the
|
||||
// less-specific subrouter had a matching method handler,
|
||||
// producing the [UNHANDLED] → upstream-proxy fall-through
|
||||
// behind issue #285's first-attempted fix. One subrouter
|
||||
// keeps every device-scoped path resolvable; see
|
||||
// TestPUTRenameRoutesToLocalHandler for the regression
|
||||
// against the production router.
|
||||
r.Route("/device", func(r chi.Router) {
|
||||
r.Post("/", server.HandleMargeAddDevice)
|
||||
r.Post("/{device}", server.HandleMargeAddDevice)
|
||||
// PUT is the rename / update path — speakers fire
|
||||
// this against PUT /streaming/account/{a}/device/{d}
|
||||
// when the user renames via Bose App or
|
||||
// `soundtouch-cli name set`. Issue #285.
|
||||
r.Put("/{device}", server.HandleMargeUpdateDevice)
|
||||
r.Delete("/{device}", server.HandleMargeRemoveDevice)
|
||||
|
||||
r.Get("/{device}/presets", server.HandleMargePresets)
|
||||
r.Post("/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Put("/{device}/preset/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Delete("/{device}/preset/{presetNumber}", server.HandleMargeRemovePreset)
|
||||
r.Get("/{device}/recent", server.HandleMargeRecents)
|
||||
r.Get("/{device}/recents", server.HandleMargeRecents)
|
||||
r.Post("/{device}/recent", server.HandleMargeAddRecent)
|
||||
|
||||
r.Get("/{device}/group", server.HandleMargeDeviceGroup)
|
||||
r.Get("/{device}/group/", server.HandleMargeDeviceGroup)
|
||||
r.Get("/{device}/group/server", server.HandleMargeDeviceGroupServer)
|
||||
r.Get("/{device}/group/member", server.HandleMargeDeviceGroupMember)
|
||||
})
|
||||
|
||||
r.Route("/device/{device}", func(r chi.Router) {
|
||||
r.Get("/presets", server.HandleMargePresets)
|
||||
r.Post("/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Put("/preset/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Delete("/preset/{presetNumber}", server.HandleMargeRemovePreset)
|
||||
r.Get("/recent", server.HandleMargeRecents)
|
||||
r.Get("/recents", server.HandleMargeRecents)
|
||||
r.Post("/recent", server.HandleMargeAddRecent)
|
||||
|
||||
r.Get("/group", server.HandleMargeDeviceGroup)
|
||||
r.Get("/group/", server.HandleMargeDeviceGroup)
|
||||
r.Get("/group/server", server.HandleMargeDeviceGroupServer)
|
||||
r.Get("/group/member", server.HandleMargeDeviceGroupMember)
|
||||
})
|
||||
|
||||
// Speakers POST to /group/ (with trailing slash) when forwarding
|
||||
// the addGroup payload to Marge during stereo-pair formation --
|
||||
// see issue #252. Register both forms so chi accepts either.
|
||||
r.Post("/group", server.HandleMargeAddGroup)
|
||||
r.Post("/group/", server.HandleMargeAddGroup)
|
||||
r.Post("/group/{groupId}", server.HandleMargeModifyGroup)
|
||||
r.Delete("/group/{groupId}", server.HandleMargeDeleteGroup)
|
||||
|
||||
r.Delete("/device/{device}", server.HandleMargeRemoveDevice)
|
||||
})
|
||||
|
||||
r.Get("/device/{device}/streaming_token", server.HandleMargeStreamingToken)
|
||||
@@ -997,6 +1021,7 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Get("/devices/{device}/group/member", server.HandleMargeDeviceGroupMember)
|
||||
|
||||
r.Post("/group", server.HandleMargeAddGroup)
|
||||
r.Post("/group/", server.HandleMargeAddGroup)
|
||||
r.Post("/group/{groupId}", server.HandleMargeModifyGroup)
|
||||
r.Delete("/group/{groupId}", server.HandleMargeDeleteGroup)
|
||||
r.Get("/devices/{device}/presets", server.HandleMargePresets)
|
||||
@@ -1179,6 +1204,45 @@ func startHTTPSServer(httpsAddr string, r http.Handler, tlsConfig *tls.Config, h
|
||||
}()
|
||||
}
|
||||
|
||||
// runHTTPSPreflight checks whether speakers' implicit :443 target reaches
|
||||
// AfterTouch. Runs after the HTTPS listener has had a moment to come up; if
|
||||
// the listener is already on :443 the check is skipped. Emits a single WARN
|
||||
// log line with actionable guidance when either probe fails.
|
||||
//
|
||||
// Only runs when dnsEnabled is true: the :443 reachability only matters when
|
||||
// speakers are reaching AfterTouch via intercepted Bose hostnames (i.e. the
|
||||
// DNS migration method). For direct SDK-override migration the speaker
|
||||
// connects to the configured https-port directly, so :443 is irrelevant.
|
||||
// Users with external DNS interception (Pi-hole, router rules) can still see
|
||||
// the live result on /setup/settings even when this startup warn is silent.
|
||||
func runHTTPSPreflight(httpsServerURL, serverURL string, dnsEnabled bool, resolver func(string) (string, error)) {
|
||||
if !dnsEnabled {
|
||||
return
|
||||
}
|
||||
|
||||
port := handlers.PortFromHTTPSServerURL(httpsServerURL)
|
||||
if port == 0 {
|
||||
// Can't determine the listener port — be silent rather than misleading.
|
||||
return
|
||||
}
|
||||
|
||||
// Give the listener a head start so a successful bind beats the probe.
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
res := handlers.Check443Reachability(port, serverURL, resolver, handlers.ProbeDialTimeoutStartup)
|
||||
|
||||
guidance := handlers.FormatPreflightGuidance(port, res)
|
||||
if guidance == "" {
|
||||
if !res.Skipped {
|
||||
log.Printf("HTTPS pre-flight: :443 reachable at localhost and %s ✓", res.LANHost)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Print(guidance)
|
||||
}
|
||||
|
||||
// matchesDomain checks if a certificate domain (which may be a wildcard) matches a server name
|
||||
func matchesDomain(certDomain, serverName string) bool {
|
||||
if certDomain == serverName {
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"reflect"
|
||||
"runtime"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@@ -102,3 +104,56 @@ func TestPrintRoutes(t *testing.T) {
|
||||
t.Errorf("Router routes changed! Diff the snapshot at %s with %s", snapshotPath, actualPath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPUTRenameRoutesToLocalHandler reproduces the runtime routing
|
||||
// behaviour the user saw on their deployed v0.80.0: a PUT to
|
||||
// /streaming/account/{a}/device/{d} should land on
|
||||
// HandleMargeUpdateDevice, not fall through to the [UNHANDLED]
|
||||
// proxy. The handlers-package test (TestIssue285_*) uses a simplified
|
||||
// router that doesn't have the overlapping `/device` and
|
||||
// `/device/{device}` route groups, so it can't catch a chi radix-
|
||||
// tree resolution that prefers the more-specific subrouter.
|
||||
//
|
||||
// This test exercises the actual production setupRouter so a
|
||||
// regression in the route topology is caught against the same chi
|
||||
// behaviour speakers will see.
|
||||
func TestPUTRenameRoutesToLocalHandler(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "router-rename-")
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir temp: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
server := handlers.NewServer(ds, nil, "http://localhost:8000", false, false, false)
|
||||
r := setupRouter(server)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
body := `<?xml version="1.0" encoding="UTF-8" ?><device deviceid="A81B6A536A98"><name>Sound Machinechen</name><macaddress>A81B6A536A98</macaddress></device>`
|
||||
|
||||
req, err := http.NewRequest(http.MethodPut,
|
||||
ts.URL+"/streaming/account/1111111/device/A81B6A536A98",
|
||||
strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("build request: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/xml")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("PUT: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// 200 means our local HandleMargeUpdateDevice handled it.
|
||||
// 401 / 502 / anything else means the request fell through to
|
||||
// the [UNHANDLED] proxy and got the upstream response — which
|
||||
// is exactly the failure mode #285 was supposed to fix.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("PUT status = %d, want 200 (local handler). Anything else means the request fell through to [UNHANDLED] proxy — chi is routing to a different subrouter than the PUT registration intended.", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ DELETE /setup/dns-discoveries handlers.(
|
||||
DELETE /setup/interactions/sessions handlers.(*Server).HandleCleanupSessions-fm
|
||||
DELETE /setup/interactions/sessions/{session} handlers.(*Server).HandleDeleteSession-fm
|
||||
DELETE /setup/parity-mismatches handlers.(*Server).HandleClearParityMismatches-fm
|
||||
DELETE /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeRemoveDevice-fm
|
||||
DELETE /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeRemovePreset-fm
|
||||
DELETE /streaming/account/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
|
||||
GET / handlers.(*Server).HandleRoot-fm
|
||||
@@ -30,6 +31,7 @@ GET /bmx/tunein/v1/playback/episodes/{podcastID} handlers.(
|
||||
GET /bmx/tunein/v1/playback/station/{stationID} handlers.(*Server).HandleTuneInPlayback-fm
|
||||
GET /bmx/tunein/v1/search handlers.(*Server).HandleTuneInSearch-fm
|
||||
GET /ced/* handlers.(*Server).HandleCedStatic
|
||||
GET /core02/svc-bmx-adapter-orion/prod/orion/station handlers.(*Server).HandleOrionPlayback-fm
|
||||
GET /custom/v1/playback/{encodedURL} handlers.(*Server).HandleCustomPlayback-fm
|
||||
GET /customer/account/{account} handlers.(*Server).HandleMargeAccountProfile-fm
|
||||
GET /docs/* handlers.(*Server).HandleDocs-fm
|
||||
@@ -94,13 +96,13 @@ POST /accounts/{account}/devices handlers.(
|
||||
POST /accounts/{account}/devices/{device}/presets/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
|
||||
POST /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleMargeAddRecent-fm
|
||||
POST /accounts/{account}/group handlers.(*Server).HandleMargeAddGroup-fm
|
||||
POST /accounts/{account}/group/ handlers.(*Server).HandleMargeAddGroup-fm
|
||||
POST /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeModifyGroup-fm
|
||||
POST /alexa/certificate handlers.(*Server).HandleAlexaCertificate-fm
|
||||
POST /bmx/core02/svc-bmx-adapter-orion/prod/orion/token handlers.(*Server).HandleOrionToken-fm
|
||||
POST /bmx/orion/v1/playback/station/{data} handlers.(*Server).HandleOrionPlayback-fm
|
||||
POST /bmx/tunein/v1/favorite/{stationID} handlers.(*Server).HandleTuneInFavorite-fm
|
||||
POST /bmx/tunein/v1/report handlers.(*Server).HandleTuneInReport-fm
|
||||
POST /bmx/tunein/v1/token handlers.(*Server).HandleTuneInToken-fm
|
||||
POST /core02/svc-bmx-adapter-orion/prod/orion/token handlers.(*Server).HandleOrionToken-fm
|
||||
POST /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
|
||||
@@ -141,6 +143,7 @@ POST /streaming/account/{account}/device/{device} handlers.(
|
||||
POST /streaming/account/{account}/device/{device}/presets/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
|
||||
POST /streaming/account/{account}/device/{device}/recent handlers.(*Server).HandleMargeAddRecent-fm
|
||||
POST /streaming/account/{account}/group handlers.(*Server).HandleMargeAddGroup-fm
|
||||
POST /streaming/account/{account}/group/ handlers.(*Server).HandleMargeAddGroup-fm
|
||||
POST /streaming/account/{account}/group/{groupId} handlers.(*Server).HandleMargeModifyGroup-fm
|
||||
POST /streaming/account/{account}/source handlers.(*Server).HandleMargeAddSource-fm
|
||||
POST /streaming/device_setting/account/{account}/device/{device}/device_settings handlers.(*Server).HandleMargeUpdateDeviceSettings-fm
|
||||
@@ -153,5 +156,6 @@ POST /streaming/support/power_on handlers.(
|
||||
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} handlers.(*Server).HandleMargeUpdateDevice-fm
|
||||
PUT /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
|
||||
TRACE /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
|
||||
@@ -4,8 +4,10 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
@@ -36,13 +38,34 @@ func main() {
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "bind",
|
||||
Usage: "Network interface to bind to",
|
||||
Usage: "Address for the HTTP listener: host, IP, or local interface name (e.g. eth0). Leave empty to listen on all interfaces",
|
||||
EnvVars: []string{"BIND_ADDR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "interface",
|
||||
Usage: "Network interface name (e.g. eth0) for mDNS and UPnP device discovery. Defaults to the --bind interface name when one was given; leave empty otherwise to auto-pick",
|
||||
EnvVars: []string{"DISCOVERY_INTERFACE"},
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
port := c.String("port")
|
||||
bindAddr := c.String("bind")
|
||||
rawBind := c.String("bind")
|
||||
|
||||
bindAddr, err := resolveBindAddr(rawBind)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if rawBind != "" && bindAddr != rawBind {
|
||||
log.Printf("Resolved --bind %q to %s", rawBind, bindAddr)
|
||||
}
|
||||
|
||||
rawIface := c.String("interface")
|
||||
|
||||
ifaceName := defaultDiscoveryInterface(rawIface, rawBind, bindAddr)
|
||||
if rawIface == "" && ifaceName != "" {
|
||||
log.Printf("Defaulting --interface to %q from --bind", ifaceName)
|
||||
}
|
||||
|
||||
addr := ":" + port
|
||||
if bindAddr != "" {
|
||||
@@ -63,6 +86,10 @@ func main() {
|
||||
cfg.DiscoveryTimeout = 10 * time.Second
|
||||
cfg.CacheEnabled = true
|
||||
|
||||
if ifaceName != "" {
|
||||
cfg.DiscoveryInterface = ifaceName
|
||||
}
|
||||
|
||||
discoveryService := discovery.NewUnifiedDiscoveryService(cfg)
|
||||
|
||||
// Discover devices on startup
|
||||
@@ -91,6 +118,88 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// defaultDiscoveryInterface picks the interface name to use for mDNS/UPnP
|
||||
// discovery. An explicit --interface always wins; otherwise, when --bind was
|
||||
// given an interface name (i.e. resolveBindAddr substituted an IP for it),
|
||||
// that name is reused so the common single-interface case "just works".
|
||||
// Returns the empty string when there is nothing to propagate, leaving the
|
||||
// discovery service to auto-pick.
|
||||
func defaultDiscoveryInterface(rawInterface, rawBind, resolvedBind string) string {
|
||||
if rawInterface != "" {
|
||||
return rawInterface
|
||||
}
|
||||
|
||||
if rawBind != "" && rawBind != resolvedBind {
|
||||
return rawBind
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveBindAddr returns the address to bind the HTTP listener to.
|
||||
//
|
||||
// If bindAddr names a local network interface, the interface's single IPv4
|
||||
// address is returned. When no IPv4 is present, the function falls back to the
|
||||
// interface's single non-link-local IPv6 address (wrapped in brackets so it
|
||||
// composes correctly with ":port"). Ambiguous interfaces (multiple addresses
|
||||
// in the chosen family) or interfaces with no usable address produce an error,
|
||||
// so misconfiguration surfaces immediately instead of becoming an obscure DNS
|
||||
// lookup failure at listen time.
|
||||
//
|
||||
// If bindAddr is not an interface name — including the empty string, a host
|
||||
// name, or a literal IP — it is returned unchanged.
|
||||
func resolveBindAddr(bindAddr string) (string, error) {
|
||||
// A lookup failure here just means bindAddr isn't an interface name
|
||||
// (it's a host, IP, or empty); fall through to pass-through.
|
||||
iface, _ := net.InterfaceByName(bindAddr)
|
||||
if iface == nil {
|
||||
return bindAddr, nil
|
||||
}
|
||||
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("--bind %q: failed to list addresses for interface: %w", bindAddr, err)
|
||||
}
|
||||
|
||||
var ipv4, ipv6 []net.IP
|
||||
|
||||
for _, addr := range addrs {
|
||||
var ip net.IP
|
||||
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
ip = v.IP
|
||||
case *net.IPAddr:
|
||||
ip = v.IP
|
||||
}
|
||||
|
||||
if ip == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
ipv4 = append(ipv4, v4)
|
||||
} else if !ip.IsLinkLocalUnicast() {
|
||||
// Skip IPv6 link-local (fe80::); it requires a zone ID and
|
||||
// can't be used as a plain "[ip]:port" listen address.
|
||||
ipv6 = append(ipv6, ip)
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case len(ipv4) == 1:
|
||||
return ipv4[0].String(), nil
|
||||
case len(ipv4) > 1:
|
||||
return "", fmt.Errorf("--bind %q: interface has multiple IPv4 addresses (%v); specify one directly", bindAddr, ipv4)
|
||||
case len(ipv6) == 1:
|
||||
return "[" + ipv6[0].String() + "]", nil
|
||||
case len(ipv6) > 1:
|
||||
return "", fmt.Errorf("--bind %q: interface has multiple IPv6 addresses (%v); specify one directly", bindAddr, ipv6)
|
||||
default:
|
||||
return "", fmt.Errorf("--bind %q: interface has no usable IPv4 or IPv6 address", bindAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func setupRoutes(app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveBindAddr_PassThrough(t *testing.T) {
|
||||
// Inputs that don't match any local interface name must be returned
|
||||
// unchanged: empty string, hostnames, IPv4/IPv6 literals, and bogus
|
||||
// strings the user might have typed.
|
||||
tests := []string{
|
||||
"",
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"192.168.1.5",
|
||||
"::1",
|
||||
"definitely-not-an-iface-xyz",
|
||||
}
|
||||
|
||||
for _, input := range tests {
|
||||
t.Run(quoted(input), func(t *testing.T) {
|
||||
got, err := resolveBindAddr(input)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got != input {
|
||||
t.Errorf("got %q, want %q (input should pass through unchanged)", got, input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBindAddr_LoopbackInterface(t *testing.T) {
|
||||
loopback, expected, ok := findLoopbackWithSingleIPv4(t)
|
||||
if !ok {
|
||||
t.Skipf("no loopback interface with exactly one IPv4 address found")
|
||||
}
|
||||
|
||||
got, err := resolveBindAddr(loopback)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error resolving %q: %v", loopback, err)
|
||||
}
|
||||
|
||||
if got != expected {
|
||||
t.Errorf("got %q, want %q for loopback interface %q", got, expected, loopback)
|
||||
}
|
||||
}
|
||||
|
||||
// findLoopbackWithSingleIPv4 returns the name of a loopback interface and the
|
||||
// single IPv4 address attached to it. If the host has multiple loopback
|
||||
// interfaces or the loopback has zero or several IPv4 addresses, it returns
|
||||
// ok=false so the caller can skip the test rather than fail on an environment
|
||||
// quirk.
|
||||
func findLoopbackWithSingleIPv4(t *testing.T) (name, addr string, ok bool) {
|
||||
t.Helper()
|
||||
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
t.Fatalf("net.Interfaces: %v", err)
|
||||
}
|
||||
|
||||
for _, iface := range ifaces {
|
||||
if iface.Flags&net.FlagLoopback == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
addrs, addrErr := iface.Addrs()
|
||||
if addrErr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var ipv4s []string
|
||||
|
||||
for _, a := range addrs {
|
||||
if ipnet, isIPNet := a.(*net.IPNet); isIPNet {
|
||||
if v4 := ipnet.IP.To4(); v4 != nil {
|
||||
ipv4s = append(ipv4s, v4.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(ipv4s) == 1 {
|
||||
return iface.Name, ipv4s[0], true
|
||||
}
|
||||
}
|
||||
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func TestDefaultDiscoveryInterface(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rawInterface string
|
||||
rawBind string
|
||||
resolvedBind string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "explicit interface wins over bind-derived default",
|
||||
rawInterface: "eth1",
|
||||
rawBind: "eth0",
|
||||
resolvedBind: "192.168.1.5",
|
||||
want: "eth1",
|
||||
},
|
||||
{
|
||||
name: "derive from --bind when --bind was an interface name",
|
||||
rawInterface: "",
|
||||
rawBind: "eth0",
|
||||
resolvedBind: "192.168.1.5",
|
||||
want: "eth0",
|
||||
},
|
||||
{
|
||||
name: "no derivation when --bind was an IP literal",
|
||||
rawInterface: "",
|
||||
rawBind: "192.168.1.5",
|
||||
resolvedBind: "192.168.1.5",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "no derivation when --bind was a hostname (pass-through)",
|
||||
rawInterface: "",
|
||||
rawBind: "localhost",
|
||||
resolvedBind: "localhost",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "both empty stays empty (auto-pick)",
|
||||
rawInterface: "",
|
||||
rawBind: "",
|
||||
resolvedBind: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "explicit interface alone, --bind empty",
|
||||
rawInterface: "eth1",
|
||||
rawBind: "",
|
||||
resolvedBind: "",
|
||||
want: "eth1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := defaultDiscoveryInterface(tc.rawInterface, tc.rawBind, tc.resolvedBind)
|
||||
if got != tc.want {
|
||||
t.Errorf("got %q, want %q (rawInterface=%q rawBind=%q resolvedBind=%q)",
|
||||
got, tc.want, tc.rawInterface, tc.rawBind, tc.resolvedBind)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func quoted(s string) string {
|
||||
if s == "" {
|
||||
return "(empty)"
|
||||
}
|
||||
|
||||
return strings.ReplaceAll(s, "/", "_")
|
||||
}
|
||||
@@ -48,6 +48,12 @@ logread -f | grep -Ei '(marge|preset)'
|
||||
```
|
||||
This is particularly useful for debugging preset synchronization and service redirection issues.
|
||||
|
||||
For HTTPS / connection-refused debugging (e.g. `Curl 7, http 0`), drop the speaker's loopback chatter so only outbound calls remain visible:
|
||||
```bash
|
||||
logread -f | grep -v '127.0.0.1'
|
||||
```
|
||||
The speaker generates a steady stream of localhost-to-localhost HTTP traffic between its internal services; filtering it out makes the actual cloud / AfterTouch attempts (the ones that matter when diagnosing redirect or TLS issues) easy to read in real time.
|
||||
|
||||
---
|
||||
|
||||
## 2. Traffic Logging & Interception
|
||||
|
||||
@@ -35,7 +35,7 @@ soundtouch-cli --host 192.168.1.100 preset store \
|
||||
soundtouch-cli --host 192.168.1.100 preset store \
|
||||
--slot 3 \
|
||||
--source TUNEIN \
|
||||
--location "/v1/playbook/station/s33828" \
|
||||
--location "/v1/playback/station/s33828" \
|
||||
--name "K-LOVE Radio"
|
||||
```
|
||||
|
||||
@@ -103,7 +103,7 @@ Just replace `https://open.spotify.com/` with `spotify:` and `/` with `:`.
|
||||
### Radio Stations
|
||||
```bash
|
||||
# TuneIn Radio
|
||||
--source TUNEIN --location "/v1/playbook/station/s33828"
|
||||
--source TUNEIN --location "/v1/playback/station/s33828"
|
||||
|
||||
# Internet Radio Stream
|
||||
--source LOCAL_INTERNET_RADIO --location "https://stream.example.com/jazz"
|
||||
@@ -249,7 +249,7 @@ soundtouch-cli --host 192.168.1.100 preset store \
|
||||
# Kids' bedtime stories
|
||||
soundtouch-cli --host 192.168.1.100 preset store \
|
||||
--slot 3 --source TUNEIN \
|
||||
--location "/v1/playbook/station/bedtime-stories" \
|
||||
--location "/v1/playback/station/bedtime-stories" \
|
||||
--name "Bedtime Stories"
|
||||
```
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
* [Request Recording](REQUEST_RECORDING_CONCEPT.md)
|
||||
* [Spotify Priming Strategy](concepts/spotify-priming-strategy.md)
|
||||
* [Spotify OAuth](concepts/spotify-oauth.md)
|
||||
* [soundtouch-web Roadmap](soundtouch-web-roadmap.md)
|
||||
|
||||
## Analysis & Research
|
||||
* [API Coverage Analysis](analysis/API-COVERAGE.md)
|
||||
@@ -63,6 +64,8 @@
|
||||
* [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md)
|
||||
* [Telnet (Port 17000) Migration Method](analysis/TELNET-MIGRATION-METHOD.md)
|
||||
* [Telnet Command Reference](analysis/TELNET-COMMAND-REFERENCE.md)
|
||||
* [Setup WebSocket Experiment](analysis/SETUP-WEBSOCKET-EXPERIMENT.md)
|
||||
* [Factory Reset Protocol](analysis/FACTORY-RESET-PROTOCOL.md)
|
||||
* [Wiki API Comparison](analysis/WIKI-COMPARISON.md)
|
||||
* [IoT Config Summary](analysis/IOT-CONFIG-SUMMARY.md)
|
||||
* [IoT Configuration Analysis](analysis/IOT-CONFIGURATION-ANALYSIS.md)
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# What a SoundTouch speaker does during factory reset
|
||||
|
||||
Observed live on ST10 firmware `27.0.6.46330.5043500` (build `epdbuild.trunk.hepdswbld04.2022-08-04`) on 2026-05-12, by running `soundtouch-cli setup factory-reset` and tailing the speaker's `logread` over SSH. The trace is preserved at `_/logs/factory-reset.txt` for reference.
|
||||
|
||||
## Sequence
|
||||
|
||||
1. **Telnet receives `sys factorydefault`.** The diagnostic shell on port 17000 accepts the command and acknowledges. Some firmwares close the socket as part of the reboot — our CLI's `setup factory-reset` tolerates that as success.
|
||||
|
||||
2. **Speaker DELETEs itself from its marge account.** Before wiping anything, the firmware does:
|
||||
```
|
||||
[MargeStateAssociated] HandleRemoveDeviceRequest - Removing this device from the user's Marge account
|
||||
[MargeClient] RemoveDevice calling Marge Server with https://streaming.bose.com/streaming/account/{accountId}/device/{deviceId}
|
||||
[MargeClient] RemoveDeviceCB - Device removed from the user's Marge account
|
||||
[MargeStateAssociated] HandleRemoveDeviceRequestSuccessCB, Marge returned: {"ok": true}
|
||||
```
|
||||
AfterTouch already handles this — `HandleMargeRemoveDevice` (`pkg/service/handlers/handlers_marge.go:633`) routed via `r.Delete("/device/{device}", …)` in `cmd/soundtouch-service/main.go:955`. The handler calls `marge.RemoveDeviceFromAccount(s.ds, account, device)` and prunes the device from the datastore.
|
||||
|
||||
3. **Speaker notifies its LAN peers.** Two HTTP POSTs to each known peer at `:8090/notification`:
|
||||
```
|
||||
[NotificationSender] SendNotifyLisas_: URL: >>http://192.168.123.122:8090/notification<<, m_msgdata.size(58)
|
||||
[SimpleURLFetcher] multipart/form-data text/xml
|
||||
```
|
||||
~58 bytes of `multipart/form-data` carrying `text/xml`. "Lisas" is the firmware's internal term for LAN peers (devices on the same account on the same network segment). AfterTouch is **not** on this path — it's pure peer-to-peer over the LAN. Peers presumably refresh their account info as a result.
|
||||
|
||||
4. **Local state teardown.** Bluetooth pairings cleared (`BTRemoteDeviceAccess::ClearPrevPairedList`), zone/group state torn down, all source proxies disconnected (`STSAccountProxy::Disconnect Requested` × many).
|
||||
|
||||
5. **Persistence cleanup.** Logs, core dumps wiped (`FactoryDefault: Clearing the CoreDump and BoseLogs … rm -rf /mnt/nv/BoseLog/*`). Notably **NOT wiped**: `/mnt/nv/aftertouch.resolv.conf`, `/mnt/nv/rc.local`'s Aftertouch hook, and `/mnt/nv/BoseApp-Persistence/1/SystemConfigurationDB.xml`. The reset only touches log directories and account-specific persistence under the same `/mnt/nv/BoseApp-Persistence/1/` tree.
|
||||
|
||||
6. **Reboot into setup mode.** Speaker drops Wi-Fi, comes back as its own AP `Bose SoundTouch XXXX` on 192.0.2.1.
|
||||
|
||||
## Implications for migration ordering
|
||||
|
||||
The DELETE in step 2 only reaches AfterTouch if the speaker's `margeURL` already points at AfterTouch *at the moment of reset*. A speaker still pointing at `streaming.bose.com` sends it into the void → AfterTouch keeps a stale `account/{id}/device/{id}` entry until someone manually prunes it.
|
||||
|
||||
Therefore for a clean datastore lifecycle on an already-Bose-paired speaker:
|
||||
|
||||
1. Migrate URLs first (`setup migrate --method=resolv` or `--method=telnet`).
|
||||
2. Reboot to apply.
|
||||
3. Factory reset.
|
||||
4. Re-provision.
|
||||
|
||||
`soundtouch-cli setup plan --reset` currently runs factory-reset first (optimal for already-on-AfterTouch speakers); both `setup plan --reset` and `setup factory-reset` print a one-line note explaining the ordering tradeoff so users can pick the right sequence for their starting state.
|
||||
|
||||
## Implications for AfterTouch behaviour
|
||||
|
||||
- The DELETE handler is already correct; no changes needed.
|
||||
- AfterTouch is invisible to the LAN-peer notification step — that's just LAN HTTP between speakers.
|
||||
- If you build a "consolidate account" / "migrate fleet" feature later, the peer-notification channel is the propagation path the firmware uses internally; AfterTouch doesn't need to do anything analogous.
|
||||
- The persistence layer at `/mnt/nv/` is **factory-reset-resistant**. Our DNS-redirect migration (`setup migrate --method=resolv`) writes there specifically so AfterTouch routing survives a reset. This is intentional — the user can factory-reset a speaker freely without re-running migration.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Are there other peer endpoints the firmware POSTs to besides `:8090/notification`? Worth checking on a 3-speaker LAN.
|
||||
- Does the `:8090/notification` payload format match the format used for play-as-notification audio pushes, or is it a distinct message shape? The "size(58)" byte count is too small for an audio URL but big enough for an XML envelope with an event type.
|
||||
|
||||
If you want either of these answered, capture two synchronised `logread -f` streams from two LAN speakers while one is being reset.
|
||||
|
||||
## Runbook — reset & re-provision an ST10 on AfterTouch
|
||||
|
||||
End-to-end command sequence used during the 2026-05-12 bare-pairing experiment, recorded verbatim from the test session. Replace IPs, SSID, password, service URL, and account ID with your own. Two manual Wi-Fi switches happen between `factory-reset` and `wifi-push` (host joins the speaker's AP) and again between `wifi-push` and `wait-online` (host re-joins home Wi-Fi).
|
||||
|
||||
```bash
|
||||
# === 1. Reconnaissance — confirm what state the speaker is in before touching it. ===
|
||||
|
||||
# Identity, network, sources, presets.
|
||||
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup inspect
|
||||
|
||||
# Green/red status across every migration axis (SSH, telnet, CA, pairing, …).
|
||||
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup verify \
|
||||
--service-url=https://soundtouch.fritz.box
|
||||
|
||||
# What `setup plan --reset` would recommend, so you can preview the sequence.
|
||||
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup plan \
|
||||
--service-url=https://soundtouch.fritz.box --reset
|
||||
|
||||
|
||||
# === 2. Reset and Wi-Fi re-provisioning. ===
|
||||
|
||||
# Tell the speaker to wipe itself. Speaker drops Wi-Fi and reboots into AP mode.
|
||||
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup factory-reset
|
||||
|
||||
# Manual: switch this host to the speaker's setup AP.
|
||||
# macOS: networksetup -setairportnetwork en0 "Bose SoundTouch XXXX"
|
||||
|
||||
# Poll 192.0.2.1:8090/info until the speaker answers (interval=2s, timeout=5m).
|
||||
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup wait-ap
|
||||
|
||||
# Push home Wi-Fi credentials. NOTE the single-quoted password: zsh expands `!`
|
||||
# inside double quotes as history-expansion and will refuse the command.
|
||||
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup wifi-push \
|
||||
--ssid="wifi-name" --pass='a.secure!password'
|
||||
|
||||
# Manual: switch host back to home Wi-Fi.
|
||||
# macOS: networksetup -setairportnetwork en0 "wifi-name" 'a.secure!password'
|
||||
|
||||
# mDNS-poll for the speaker on the home network, matched by deviceID suffix
|
||||
# (which survives the reset since it's the MAC). Returns the new IP.
|
||||
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup wait-online --match=536A98
|
||||
|
||||
|
||||
# === 3. Clock, migrate, pair. From here on use the new IP wait-online reported. ===
|
||||
|
||||
# Set the speaker's wall-clock. `clock set --time=now` fails on FW 27;
|
||||
# `clock now` is the working subcommand.
|
||||
go run ./cmd/soundtouch-cli --host 192.168.123.123 clock now
|
||||
|
||||
# Reboot to clear any half-initialized resolver / NTP state from the wifi-push flap.
|
||||
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup reboot
|
||||
|
||||
# Apply DNS-redirect migration: routes *.bose.com to AfterTouch and installs its CA.
|
||||
# Idempotent; safe to re-run.
|
||||
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup migrate \
|
||||
--service-url=https://soundtouch.fritz.box --method=resolv
|
||||
|
||||
# Reboot again so the envswitch parallel-persistence layer and the resolv hook
|
||||
# both take effect on the next boot.
|
||||
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup reboot
|
||||
|
||||
# Pair the device with an AfterTouch account — bare experiment variant.
|
||||
# Drop --mode=bare and add --name=… / --language=… for the full state-machine variant.
|
||||
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup pair \
|
||||
--mode=bare --account=1111111 --service-url='https://soundtouch.fritz.box'
|
||||
|
||||
|
||||
# === 4. Verify. ===
|
||||
|
||||
# Reboot to verify persistence survives.
|
||||
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup reboot
|
||||
|
||||
# Snapshot the result. margeAccountUUID should still equal --account, and Sources
|
||||
# should list ~14 entries (TUNEIN, RADIO_BROWSER, LOCAL_INTERNET_RADIO,
|
||||
# SPOTIFY slots, AIRPLAY, etc.) materialized by the firmware.
|
||||
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup inspect
|
||||
```
|
||||
|
||||
Total wall-clock for the above on this hardware: roughly 5 minutes including the two manual Wi-Fi switches and three reboots.
|
||||
@@ -0,0 +1,215 @@
|
||||
# Experiment: Does bare `setMargeAccount` work outside the SETUP bracket?
|
||||
|
||||
## Why we are doing this
|
||||
|
||||
Our captured pairing flow (`docs/reference/DEVICE-PAIRING-FLOW.md`) shows the official Bose app always sends `setMargeAccount` *inside* a `SETUP_START` → `SETUP_ENTER` → `SETUP_LEAVE` state-machine bracket over WebSocket. The question this experiment answers:
|
||||
|
||||
> If we open a WebSocket to a factory-reset speaker and send **only** `setMargeAccount` — no surrounding setupState messages — does the device honor it and write its persistence files (`SystemConfigurationDB.xml`, `Sources.xml`) cleanly?
|
||||
|
||||
The answer determines the shape of `PairAccount`:
|
||||
|
||||
- **If YES:** `PairAccount` becomes uniform: WebSocket-first, HTTP `/setMargeAccount` second, telnet `envswitch accountid set` third. One function, one ordering, all callers.
|
||||
- **If NO:** WebSocket pairing is only meaningful inside the full state machine. Factory-reset path uses the state machine; re-pair path keeps today's HTTP→telnet ordering.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- A SoundTouch speaker that has been **factory-reset** and joined to the test Wi-Fi.
|
||||
- Speaker reachable on `:8090` (HTTP API) and `:8080` (WebSocket).
|
||||
- Speaker's runtime marge URL already points at AfterTouch (run the existing telnet URL rewrite first — otherwise the device's downstream POST will land on the dead Bose cloud and we will not be able to distinguish "WS message refused" from "downstream cloud failed").
|
||||
- A free 7-digit account ID — for example, generated via `setup.GenerateAccountID(nil)`.
|
||||
|
||||
## Step 0 — Baseline
|
||||
|
||||
```bash
|
||||
DEVICE=192.168.x.x
|
||||
curl -s http://$DEVICE:8090/info | xmllint --format -
|
||||
curl -s http://$DEVICE:8090/sources | xmllint --format -
|
||||
curl -s http://$DEVICE:8090/presets | xmllint --format -
|
||||
```
|
||||
|
||||
Record:
|
||||
|
||||
- `<margeAccountUUID>` — expect empty on a factory-reset device.
|
||||
- `<margeURL>` — expect the AfterTouch URL (preflight already applied).
|
||||
- `<sources>` — expect a minimal list.
|
||||
- `<presets>` — expect `<presets/>`.
|
||||
|
||||
## Step 1 — Send bare `setMargeAccount` over WebSocket
|
||||
|
||||
Build the CLI once:
|
||||
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
Then run the bare path against the speaker:
|
||||
|
||||
```bash
|
||||
DEVICE=192.168.x.x
|
||||
./build/soundtouch-cli setup pair --host=$DEVICE --account=1234567 --mode=bare
|
||||
```
|
||||
|
||||
What it does:
|
||||
|
||||
1. Reads `/info` to discover `deviceID`, logs the pre-state.
|
||||
2. Opens a WebSocket to `$DEVICE:8080` with the `gabbo` subprotocol.
|
||||
3. Sends exactly one frame — the `setMargeAccount` envelope — **without** any preceding `SETUP_START`/`SETUP_ENTER`.
|
||||
4. Reads frames for up to `--step-timeout=8s` (configurable), looking for an ack referencing our `requestID`.
|
||||
5. Closes the WebSocket, waits 2 s, re-reads `/info`, prints whether `margeAccountUUID` now equals our supplied ID.
|
||||
|
||||
The exact frame sent (built by `setup.SetupSession.SetMargeAccount`):
|
||||
|
||||
```xml
|
||||
<msg><header deviceID="DEVICE_ID" url="setMargeAccount" method="POST"><request requestID="1"/></header><body>
|
||||
<PairDeviceWithAccount>
|
||||
<accountId>1234567</accountId>
|
||||
<userAuthToken>Bearer aftertouch</userAuthToken>
|
||||
</PairDeviceWithAccount>
|
||||
</body></msg>
|
||||
```
|
||||
|
||||
Outcomes the CLI will surface:
|
||||
|
||||
- `Device accepted bare pairing.` (post-`/info` shows our ID) → **bare path works**.
|
||||
- `setMargeAccount: device rejected setMargeAccount: …` → device returned an `<error>` body → **bare path refused explicitly**.
|
||||
- `setMargeAccount: await ack for setMargeAccount: …` (timeout or EOF) → **bare path refused silently**.
|
||||
- `Device did NOT persist the pairing — bare path likely refused silently.` → ack received but persistence didn't follow.
|
||||
|
||||
## Step 2 — Record outcome
|
||||
|
||||
After step 1 (regardless of which branch happened):
|
||||
|
||||
```bash
|
||||
sleep 2
|
||||
curl -s http://$DEVICE:8090/info | grep margeAccountUUID
|
||||
```
|
||||
|
||||
| Observed result | Verdict |
|
||||
|------------------------------------------------------------------------------|-----------------------------|
|
||||
| `<margeAccountUUID>1234567</margeAccountUUID>` appears | **YES** — Option 1 wins |
|
||||
| `<margeAccountUUID></margeAccountUUID>` still empty, no error frame received | Refused silently → **NO** |
|
||||
| Error frame returned (e.g. `<error name="UNSUPPORTED_STATE"/>`) | Refused explicitly → **NO** |
|
||||
| Device drops the WebSocket connection without replying | Refused → **NO** |
|
||||
|
||||
If verdict is YES, also verify the device wrote persistence cleanly. Reboot the device, then:
|
||||
|
||||
```bash
|
||||
ssh root@$DEVICE 'cat /mnt/nv/BoseApp-Persistence/1/SystemConfigurationDB.xml'
|
||||
ssh root@$DEVICE 'cat /mnt/nv/BoseApp-Persistence/1/Sources.xml'
|
||||
curl -s http://$DEVICE:8090/info | grep margeAccountUUID
|
||||
```
|
||||
|
||||
The UUID must still be present after reboot, and `SystemConfigurationDB.xml` must contain `<AccountUUID>1234567</AccountUUID>`. If it survives reboot, **YES** is confirmed.
|
||||
|
||||
## Step 3 — Control: full state machine
|
||||
|
||||
Factory-reset the same speaker again and run the full state machine — the same CLI, `--mode=full`:
|
||||
|
||||
```bash
|
||||
./build/soundtouch-cli setup pair --host=$DEVICE --account=1234567 --mode=full
|
||||
```
|
||||
|
||||
This drives `setup.Manager.ExecuteInitPlan` with `SkipURLRewrite=true`, which runs:
|
||||
|
||||
```
|
||||
SETUP_START
|
||||
SETUP_IDENTIFY_DEVICE_ENTER
|
||||
language sysLanguage=2
|
||||
SETUP_ENTER
|
||||
SETUP_IDENTIFY_DEVICE_LEAVE
|
||||
setMargeAccount …
|
||||
SETUP_LEAVE
|
||||
pushCustomerSupportInfoToMarge
|
||||
```
|
||||
|
||||
The CLI logs every step with status. Confirm `/info`, persistence, and reboot-survival checks pass. If the bare path failed but the full path succeeds, the SETUP bracket is load-bearing — a follow-up bisect (e.g. `SETUP_START + setMargeAccount + SETUP_LEAVE` only) tells us *which* surrounding messages the firmware actually requires.
|
||||
|
||||
## Full reset-and-rebuild loop
|
||||
|
||||
Once the bare/full question is decided, the loop for repeated experiments is:
|
||||
|
||||
```bash
|
||||
# 0. Speaker is currently on home Wi-Fi at $DEVICE.
|
||||
# Capture deviceID-suffix + current SSID first so wait-online and
|
||||
# wifi-push have the right inputs.
|
||||
./build/soundtouch-cli setup inspect --host=$DEVICE
|
||||
./build/soundtouch-cli setup factory-reset --host=$DEVICE
|
||||
|
||||
# 1. Manually switch this host to the speaker's AP (Bose SoundTouch XXXX).
|
||||
# macOS: networksetup -setairportnetwork en0 "Bose SoundTouch XXXX"
|
||||
|
||||
./build/soundtouch-cli setup wait-ap
|
||||
./build/soundtouch-cli setup wifi-push --ssid="$HOME_SSID" --pass="$HOME_PASS"
|
||||
|
||||
# 2. Manually switch this host back to home Wi-Fi.
|
||||
|
||||
./build/soundtouch-cli setup wait-online --match=DE4803 # deviceID suffix from /info before reset
|
||||
# (note the new IP from the "Speaker discovered" line)
|
||||
|
||||
NEW_IP=192.168.x.y
|
||||
./build/soundtouch-cli setup migrate --host=$NEW_IP --service-url=http://aftertouch.local:8000 # default --method=telnet
|
||||
|
||||
# Optional, if you want the DNS-redirect path instead of (or alongside) telnet envswitch:
|
||||
# 1. ./build/soundtouch-cli setup ssh-check --host=$NEW_IP # USB-stick procedure if 22 is closed
|
||||
# 2. ./build/soundtouch-cli setup install-ca --host=$NEW_IP --service-url=http://aftertouch.local:8000
|
||||
# 3. ./build/soundtouch-cli setup migrate --host=$NEW_IP --service-url=http://aftertouch.local:8000 --method=resolv
|
||||
./build/soundtouch-cli setup pair --host=$NEW_IP --mode=bare # or --mode=full
|
||||
```
|
||||
|
||||
The two manual lines are user-side Wi-Fi switches that can't be automated portably. The `wait-ap` and `wait-online` subcommands poll for the corresponding network state, so timing them is hands-off.
|
||||
|
||||
## Recording the result
|
||||
|
||||
Append to this file under `## Results`:
|
||||
|
||||
```
|
||||
- Date: YYYY-MM-DD
|
||||
- Firmware: 27.x.x
|
||||
- Model: ST10 / ST20 / ST30 / ST300
|
||||
- Bare setMargeAccount accepted: yes/no
|
||||
- Persistence written: yes/no
|
||||
- Survives reboot: yes/no
|
||||
- Notes: ...
|
||||
```
|
||||
|
||||
One row per device tested. Once two devices on different firmware confirm the same verdict, we treat it as decided.
|
||||
|
||||
## Results
|
||||
|
||||
- Date: 2026-05-13
|
||||
- Firmware: 27.0.6.46330.5043500 (build epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29)
|
||||
- Model: SoundTouch 10 (deviceID A81B6A536A98)
|
||||
- Bare setMargeAccount accepted: **yes** — pre-/info margeAccountUUID="" → post-/info margeAccountUUID="1111111"
|
||||
- Persistence written: **yes** — device materialized 14-entry Sources.xml on its own
|
||||
- Survives reboot: **yes** — `setup inspect` after `setup reboot` shows margeAccountUUID still 1111111
|
||||
- Notes: After bare pairing, the speaker did the full post-pairing handshake against AfterTouch (POST /streaming/support/power_on, GET /streaming/sourceproviders, GET /streaming/account/{id}/full, group/, provider_settings). No SETUP_START/SETUP_ENTER/SETUP_LEAVE was ever sent. Verdict: bare path is functionally equivalent to the full state machine on this firmware.
|
||||
|
||||
### Implication for the codebase
|
||||
|
||||
- `pkg/service/setup/setup_session.go` keeps the full state machine for completeness, but
|
||||
- `pkg/service/setup/init_plan.go`'s default could be simplified to "send setMargeAccount only" once we have one more confirming run on a different model.
|
||||
- The OCT issue-167 SSH-XML seeding workaround is **not required**.
|
||||
|
||||
### Appendix — SystemConfigurationDB.xml comparison
|
||||
|
||||
Post-experiment we compared the device-written `/mnt/nv/BoseApp-Persistence/1/SystemConfigurationDB.xml` from the bare-paired speaker against two SSH backups taken from speakers originally paired by the official Bose app (account 3230304, devices `A_Sound_Machine` and `Sound_Machinechen`). The diff is much smaller than expected — only two fields differ, and neither is set by the pairing protocol itself:
|
||||
|
||||
| Field | Bare-paired (1111111) | Real-Bose-paired (3230304) | Set by |
|
||||
|--------------------------|--------------------------------------------|----------------------------|-----------------------------------------------------------------------------------------------------------|
|
||||
| `DeviceName` | `Bose SoundTouch 536A98` (factory default) | `Sound Machinechen` | `name` WS message — only sent in `--mode=full` |
|
||||
| `AccountAssociatedEMail` | empty | **empty** | Never populated, even by real Bose |
|
||||
| `AccountUUID` | `1111111` | `3230304` | `setMargeAccount` — both paths set it |
|
||||
| `Locale` | empty | **empty** | Never populated, even by real Bose |
|
||||
| `acctMode` | `global` | `global` | Firmware-default; no protocol path observed to change it |
|
||||
| `isMultiDeviceAccount` | `false` | `true` | Derived from the cloud's `/streaming/account/{id}/full` response — count of `<devices>` > 1 flips it true |
|
||||
| `margeAuthServerToken` | empty | **empty** | Never populated, even by real Bose |
|
||||
| `Password` | (encrypted blob) | (encrypted blob) | Device-local key; expected to differ |
|
||||
|
||||
Three of the seven informational fields are empty even after a real-Bose pairing — the firmware simply doesn't populate `AccountAssociatedEMail`, `Locale`, or `margeAuthServerToken` from the pairing flow. So bare pairing isn't missing any field that real pairing fills.
|
||||
|
||||
The two genuinely different fields:
|
||||
|
||||
- **`DeviceName`** — pure UX. Settable any time post-pair via `name` POST (`soundtouch-cli name set --value=…`) or by sending the `name` WS message during `--mode=full` pairing.
|
||||
- **`isMultiDeviceAccount`** — not a pairing concern. It's derived from the account's device count on AfterTouch's side; flips to `true` automatically the next time the speaker refreshes account state if a second speaker has been paired to the same account.
|
||||
|
||||
So the experiment's YES verdict stands unqualified: bare `setMargeAccount` produces a `SystemConfigurationDB.xml` functionally equivalent to one written by the official pairing flow.
|
||||
@@ -12,6 +12,12 @@ recovery / WiFi setup.
|
||||
> some commands listed here have been removed on firmware 27.x. Where a
|
||||
> command's availability is known to vary, the **Availability** column says so.
|
||||
|
||||
### Telnet via Docker (when not installed locally)
|
||||
|
||||
```shell
|
||||
docker run --rm --name telnet -it --env IP=192.168.123.123 alpine:edge ash -c 'apk add -U busybox-extras && telnet $IP 17000'
|
||||
```
|
||||
|
||||
## Sources
|
||||
|
||||
| # | Source | Era / focus |
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Spotify OAuth Integration
|
||||
|
||||
> **New here?** Start with [spotify-overview.md](spotify-overview.md) for the
|
||||
> mental model (Spotify Connect vs OAuth-intercept, DNS rewrite gotcha,
|
||||
> end-to-end token lifecycle). This document zooms in on the OAuth flows and
|
||||
> management endpoints.
|
||||
|
||||
The SoundTouch service supports Spotify OAuth integration to broker access tokens for SoundTouch speakers. This is particularly useful for maintaining Spotify Connect functionality after the Bose cloud shutdown (scheduled for May 2026).
|
||||
|
||||
## OAuth Flows
|
||||
@@ -91,43 +96,23 @@ sequenceDiagram
|
||||
Note over Speaker: Speaker now has Spotify access
|
||||
```
|
||||
|
||||
## Boot Primer Script
|
||||
## Priming Speakers
|
||||
|
||||
A boot primer script that uses these endpoints to feed Spotify tokens to speakers via ZeroConf is available in the `scripts/spotify/` directory: [spotify-boot-primer.sh](../../scripts/spotify/spotify-boot-primer.sh).
|
||||
|
||||
This script can be installed on the speaker itself (which runs embedded Linux) to automatically prime Spotify Connect at boot time. See [README.md](../../scripts/spotify/README.md) and [INSTALL.md](../../scripts/spotify/INSTALL.md) for instructions.
|
||||
|
||||
### Automated Installation via Service
|
||||
|
||||
The SoundTouch service provides a dedicated management endpoint to automatically handle the installation of the Spotify boot primer on the speaker:
|
||||
`POST /mgmt/devices/{deviceId}/spotify/install-primer`
|
||||
|
||||
### Automated Installation Steps
|
||||
When you run the Spotify primer installation, the service performs the following:
|
||||
1. **Directories**: Creates `/mnt/nv/bin` and `/mnt/nv/BoseApp-Persistence/1` on the speaker.
|
||||
2. **Binary**: Uploads the `spotify-boot-primer` script to the speaker.
|
||||
3. **Configuration**: Automatically generates and uploads `spotify-primer.conf` containing the service's URL and management credentials.
|
||||
4. **Boot Hook**: Injects a call to the primer in the speaker's `/mnt/nv/rc.local` using idempotent markers.
|
||||
5. **Environment**: Updates `/mnt/nv/.profile` to include `/mnt/nv/bin` in the `PATH` for easier manual troubleshooting via SSH.
|
||||
|
||||
- **Idempotent Patching**: The service uses explicit markers to inject the hook, ensuring it doesn't corrupt existing content.
|
||||
- **Coexistence**: The service-injected hook is designed to coexist with a manually installed `rc.local` (e.g., from the community gist). It only adds a call to `/mnt/nv/bin/spotify-boot-primer` if it's not already managed by a service-controlled block.
|
||||
- **Markers**: Look for the following markers in your speaker's `/mnt/nv/rc.local`:
|
||||
- `# --- Aftertouch Spotify hook START ---`
|
||||
- `# --- Aftertouch Spotify hook END ---`
|
||||
- **Cleanup**: Reverting a migration via the service will cleanly remove these marker-delimited blocks.
|
||||
> **Note:** The on-device boot-primer flow (installing `spotify-boot-primer.sh` onto the speaker's `/mnt/nv` and hooking it from `rc.local`) is **deprecated**. AfterTouch now uses a server-centric model: the service registers a `SPOTIFY` source in marge for the device's paired account and pushes credentials via ZeroConf from the server side, triggered on `power_on` and a manual "Prime" action. See [spotify-priming-strategy.md](spotify-priming-strategy.md) for the current model and rationale.
|
||||
>
|
||||
> The artifacts under `scripts/spotify/` are kept as historical reference for users who still rely on the on-device approach. There is no longer a `/mgmt/devices/{deviceId}/spotify/install-primer` endpoint.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|--------|---------------------------------------------------|-------|-----------------------------------------------------------------------|
|
||||
| POST | `/mgmt/devices/{deviceId}/spotify/install-primer` | Basic | Install Spotify boot primer on speaker (deviceId or IP) |
|
||||
| GET | `/mgmt/spotify/callback` | None | Browser OAuth callback (redirect from Spotify, returns HTML) |
|
||||
| POST | `/mgmt/spotify/init` | Basic | Start OAuth flow, returns authorization URL |
|
||||
| GET | `/mgmt/spotify/callback` | None | Browser OAuth callback (redirect from Spotify, returns HTML) |
|
||||
| POST | `/mgmt/spotify/confirm` | Basic | Mobile app confirm (ueberboese deep link delivers code, returns JSON) |
|
||||
| GET | `/mgmt/spotify/accounts` | Basic | List linked Spotify accounts (tokens stripped) |
|
||||
| GET | `/mgmt/spotify/token` | Basic | Get fresh access token (auto-refreshes if expired) |
|
||||
| POST | `/mgmt/spotify/entity` | Basic | Resolve Spotify URI to name + image URL |
|
||||
| POST | `/mgmt/spotify/prime` | Basic | Manually trigger server-side priming of a discovered speaker |
|
||||
|
||||
## Security
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
# Spotify on SoundTouch — Overview
|
||||
|
||||
This is the entry point for understanding how Spotify works on a SoundTouch
|
||||
speaker behind AfterTouch. Read this first; the deeper docs assume you already
|
||||
have the mental model below.
|
||||
|
||||
> **Premium likely required.** As far as we know, Spotify Connect on
|
||||
> SoundTouch only works with a Spotify Premium account — this matches our
|
||||
> testing and matches what other SoundTouch-replacement projects report, but
|
||||
> we have not exhaustively verified every account tier or region. None of the
|
||||
> workarounds in this document change Spotify's account-tier requirements.
|
||||
|
||||
## Two completely separate Spotify paths
|
||||
|
||||
These are routinely confused. They share a speaker and a Spotify account, but
|
||||
they ride on different infrastructure and fail for different reasons.
|
||||
|
||||
### 1. Spotify Connect (speaker-native, independent of AfterTouch)
|
||||
|
||||
- The speaker advertises itself on the LAN as a Spotify Connect endpoint
|
||||
(mDNS service `_spotify-connect._tcp`).
|
||||
- You open the Spotify app on your phone or desktop, tap the Connect device
|
||||
picker, and select the SoundTouch.
|
||||
- Audio streams directly from Spotify's CDN to the speaker. Token handling,
|
||||
session setup, and playback all happen between Spotify and the speaker.
|
||||
- **AfterTouch is not involved.** It still works even if AfterTouch is
|
||||
offline.
|
||||
|
||||
This is the simplest path. If you only want to push playback from your phone,
|
||||
you do not need to link Spotify to AfterTouch at all — see [Manual kick-start
|
||||
alternative](#manual-kick-start-alternative) below.
|
||||
|
||||
### 2. OAuth-intercept path (managed by AfterTouch)
|
||||
|
||||
This is what enables features that originate **from the speaker**:
|
||||
|
||||
- Spotify presets on the speaker's buttons.
|
||||
- Spotify playback from the Bose app's source picker.
|
||||
- "Resume Spotify" after a power cycle without touching the Spotify app.
|
||||
|
||||
After Bose's cloud shutdown (May 2026), the speaker can no longer reach
|
||||
Bose's OAuth server for Spotify token refresh. AfterTouch intercepts those
|
||||
calls via DNS, brokers tokens with Spotify using your linked account, and
|
||||
hands them back to the speaker.
|
||||
|
||||
The rest of this document describes that path.
|
||||
|
||||
## Setup at a glance
|
||||
|
||||
Full step-by-step is in
|
||||
[docs/guides/MUSIC-SERVICES.md](../guides/MUSIC-SERVICES.md). Summary:
|
||||
|
||||
1. **Register a Spotify developer app** (one-time, by the AfterTouch operator).
|
||||
2. **Configure AfterTouch** with the Client ID, Client Secret, and Redirect
|
||||
URI in the Settings tab.
|
||||
3. **Authorize your Spotify account** via the Local Account tab — completes
|
||||
the OAuth flow and persists a long-lived refresh token to AfterTouch's
|
||||
datastore.
|
||||
4. **Prime each speaker** so its source list and ZeroConf state know about
|
||||
Spotify.
|
||||
|
||||
After step 4, presets and Bose-app-initiated Spotify playback work.
|
||||
|
||||
## The DNS rewrite — easy to miss, breaks everything
|
||||
|
||||
Bose firmware does **not** read a separate OAuth server hostname from
|
||||
configuration. It derives the OAuth host from the marge host by inserting
|
||||
`oauth` into the first label:
|
||||
|
||||
| Purpose | Hostname |
|
||||
|-----------------|---------------------------|
|
||||
| Marge / sources | `streaming.bose.com` |
|
||||
| OAuth refresh | `streamingoauth.bose.com` |
|
||||
|
||||
**Both hostnames must resolve to AfterTouch.** AfterTouch's DNS server hijacks
|
||||
both, but if you bypass that DNS server (e.g. by hard-coding only the marge
|
||||
hostname in `/etc/hosts`, or by routing only one through a custom resolver),
|
||||
token refresh will silently die while the speaker still pulls sources.
|
||||
Symptom: the speaker briefly streams Spotify after priming, then stops at the
|
||||
first token refresh ~1 hour later.
|
||||
|
||||
If you self-host AfterTouch at e.g. `aftertouch.local`, you would equivalently
|
||||
need `aftertouchoauth.local` for the OAuth interception path.
|
||||
|
||||
## End-to-end token lifecycle
|
||||
|
||||
What actually happens, from priming to steady-state playback:
|
||||
|
||||
1. **Operator links Spotify account.** OAuth flow stores
|
||||
`{user_id, refresh_token, bose_secret}` in `spotify/accounts.json`. The
|
||||
`bose_secret` is an opaque surrogate (e.g. `bs-deadbeef…`) that AfterTouch
|
||||
issues; the speaker only ever sees this surrogate, never the real Spotify
|
||||
refresh token.
|
||||
2. **Priming runs.** Either on speaker `power_on`, on discovery, or on a
|
||||
manual `POST /mgmt/spotify/prime`. AfterTouch:
|
||||
- Resolves the speaker's currently-paired account via live `:8090/info`
|
||||
(`margeAccountUUID`).
|
||||
- Writes a `SPOTIFY` `ConfiguredSource` into marge under that account with
|
||||
`secret = bose_secret`, `secretType = token_version_3`.
|
||||
- POSTs `<updates><sourcesUpdated/></updates>` to the speaker's
|
||||
`:8090/notification`, causing the speaker to re-fetch
|
||||
`/streaming/account/{account}/full` and pick up the new source.
|
||||
- Optionally pushes a fresh access token to the speaker's ZeroConf
|
||||
endpoint (`:8200/zc?action=addUser`). This is best-effort — see
|
||||
[ZeroConf clientId and benign 404s](#zeroconf-clientid-and-benign-404s).
|
||||
3. **Speaker pulls sources.** It now has a SPOTIFY entry with the surrogate
|
||||
as its credential. The speaker stores this; from its perspective the
|
||||
surrogate is the refresh token.
|
||||
4. **Speaker uses Spotify.** When it needs a fresh access token (every ~1 h
|
||||
on Spotify's clock), it POSTs to
|
||||
`streamingoauth.bose.com/oauth/device/{deviceID}/music/musicprovider/15/token/cs3`
|
||||
with the surrogate.
|
||||
5. **AfterTouch translates.** DNS hijack routes the request to AfterTouch,
|
||||
which looks up the surrogate, performs the real refresh against Spotify
|
||||
using the stored refresh token, and returns the resulting access token to
|
||||
the speaker.
|
||||
6. **Speaker uses the access token** for Spotify Web API metadata calls
|
||||
(artwork, track lookups, playback container resolution).
|
||||
|
||||
Forensic details of the request shapes are in
|
||||
[docs/reference/spotify-account-addition.md](../reference/spotify-account-addition.md).
|
||||
The cryptographic specifics of the ZeroConf `addUser` blob are in
|
||||
[spotify-priming-strategy.md](spotify-priming-strategy.md).
|
||||
|
||||
## ZeroConf clientId and benign 404s
|
||||
|
||||
`GET http://<speaker>:8200/zc?action=getInfo` returns, among other fields:
|
||||
|
||||
```json
|
||||
"clientID": "79ebcb219e8e4e9a892e796607931810"
|
||||
"tokenType": "accesstoken"
|
||||
"activeUser": "<spotify-user-id-or-empty>"
|
||||
```
|
||||
|
||||
That `clientID` is **Bose's official Spotify Connect partner client_id**,
|
||||
baked into firmware. It is **not** the client_id of the developer app you
|
||||
registered for AfterTouch — those are two unrelated OAuth apps, by design.
|
||||
The Bose-baked one is what Spotify Connect uses when a Spotify mobile app
|
||||
discovers the speaker on the LAN. The AfterTouch-registered one is what
|
||||
brokers refresh tokens for the OAuth-intercept path. They never converge.
|
||||
|
||||
**Implication:** an access token AfterTouch obtained under its own client_id
|
||||
is not directly usable as a Spotify Connect session token. Pushing it via
|
||||
ZeroConf `addUser` is best-effort, and the speaker may respond with a `404`
|
||||
and an empty body when its `activeUser` already matches the username being
|
||||
pushed — that is the firmware's idiomatic "no transition required" signal,
|
||||
not a failure. AfterTouch recognises this case (`zeroconf.ErrAddUserNoOp`)
|
||||
and logs it as an expected no-op rather than an error.
|
||||
|
||||
A 404 **with a body**, or any other non-2xx, is treated as a real failure
|
||||
and logged loudly with the response headers and body so it can be
|
||||
diagnosed.
|
||||
|
||||
## Manual kick-start alternative
|
||||
|
||||
You can skip the OAuth setup entirely if you only want playback pushed from
|
||||
the Spotify app:
|
||||
|
||||
1. Open the Spotify mobile/desktop app.
|
||||
2. Start any track.
|
||||
3. Open the Connect device picker, select the SoundTouch.
|
||||
|
||||
The speaker now holds an in-memory Spotify Connect session and can play
|
||||
until next reboot. Presets and Bose-app-initiated Spotify playback will
|
||||
still not work — those require the OAuth-intercept path — but Spotify-app-
|
||||
initiated playback does.
|
||||
|
||||
## Troubleshooting quick reference
|
||||
|
||||
| Symptom | Most likely cause |
|
||||
|----------------------------------------------------|------------------------------------------------------------------------------------------------|
|
||||
| Preset stores then fails: "invalid SourceID" | No `SPOTIFY` source in marge for the speaker's paired account. Re-run priming. |
|
||||
| Preset stores fine; playback dies after ~1 hour | `streamingoauth.bose.com` not pointed at AfterTouch (DNS rewrite gap). |
|
||||
| Speaker has source but `Sources.xml` looks stale | `<sourcesUpdated/>` notification did not reach the speaker. Re-run priming or POST it by hand. |
|
||||
| ZeroConf `addUser` returns 404, empty body | Benign no-op; speaker already has `activeUser` set. Marge path is authoritative. |
|
||||
| Spotify Connect device picker doesn't show speaker | Unrelated to AfterTouch; check the speaker's mDNS visibility on the LAN. |
|
||||
|
||||
## Where to go next
|
||||
|
||||
- **Setup walkthrough:** [docs/guides/MUSIC-SERVICES.md](../guides/MUSIC-SERVICES.md)
|
||||
- **OAuth flow details (browser + mobile + endpoint table):** [spotify-oauth.md](spotify-oauth.md)
|
||||
- **Priming strategy, ZeroConf DH protocol, deployment topologies:** [spotify-priming-strategy.md](spotify-priming-strategy.md)
|
||||
- **Forensic request/response analysis from the Stockholm app:** [docs/reference/spotify-account-addition.md](../reference/spotify-account-addition.md)
|
||||
@@ -1,5 +1,9 @@
|
||||
# Spotify Priming Strategy
|
||||
|
||||
> **New here?** Start with [spotify-overview.md](spotify-overview.md) for the
|
||||
> mental model. This document goes deep on the priming protocol, ZeroConf DH
|
||||
> exchange, and deployment topologies.
|
||||
|
||||
This document outlines the strategy for ensuring Bose SoundTouch devices are correctly "primed" for Spotify Connect integration within the AfterTouch ecosystem.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -139,7 +139,7 @@ soundtouch-cli --host 192.168.1.10 preset store \
|
||||
soundtouch-cli --host 192.168.1.10 preset store \
|
||||
--slot 2 \
|
||||
--source TUNEIN \
|
||||
--location "/v1/playbook/station/s33828" \
|
||||
--location "/v1/playback/station/s33828" \
|
||||
--name "K-LOVE Radio"
|
||||
|
||||
# Store internet radio
|
||||
@@ -956,7 +956,7 @@ soundtouch-cli --host 192.168.1.10 station add \
|
||||
# Remove a station (use location from browse/search results)
|
||||
soundtouch-cli --host 192.168.1.10 station remove \
|
||||
--source TUNEIN \
|
||||
--location "/v1/playbook/station/s33828"
|
||||
--location "/v1/playback/station/s33828"
|
||||
```
|
||||
|
||||
**Workflow Example - Discover and Play New Content:**
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
SoundTouch speakers communicate with cloud services over HTTPS. For the local service to work over HTTPS, speakers must trust the AfterTouch Root CA. The service manages this automatically — it generates a CA on first start and the web UI guides you through installing it on each speaker as part of the migration flow.
|
||||
|
||||
> ### ⚠️ Speakers connect to `:443`, AfterTouch defaults to `:8443`
|
||||
>
|
||||
> Speakers build their target URLs from Bose hostnames *without* an explicit port, so they connect on the default HTTPS port **443**. AfterTouch's built-in HTTPS listener defaults to **8443** because port 443 is privileged on most Unix systems.
|
||||
>
|
||||
> **If you do nothing, speakers will fail with `Curl 7` / connection refused and nothing will appear in the AfterTouch HTTP log.**
|
||||
>
|
||||
> Pick one of the three options under [Binding to port 443](#binding-to-port-443) below. The settings page in the web UI shows a ✅ / ❌ indicator for `:443` reachability so you can confirm the routing is in place.
|
||||
|
||||
---
|
||||
|
||||
## How TLS works in AfterTouch
|
||||
@@ -41,9 +49,40 @@ http://<server>:8000/setup/ca.crt
|
||||
|
||||
Speakers expect HTTPS on the default port 443. Since binding to port 443 requires elevated privileges, you have three options:
|
||||
|
||||
1. **Port forwarding (recommended)**: Run the service on port 8443 and forward port 443 to it using `iptables` or your firewall/router.
|
||||
2. **Capabilities**: Grant the binary permission to bind low ports: `sudo setcap 'cap_net_bind_service=+ep' ./soundtouch-service`
|
||||
3. **Reverse proxy**: Use Nginx or Caddy in front of the service (see below).
|
||||
1. **Port forwarding (recommended)**: Run the service on port 8443 and forward port 443 to it using `iptables` or your firewall/router. Inside an LXC/Docker container or on the host:
|
||||
|
||||
```bash
|
||||
iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-port 8443
|
||||
iptables -t nat -A OUTPUT -p tcp --dport 443 -j REDIRECT --to-port 8443
|
||||
```
|
||||
|
||||
The first rule covers traffic arriving from speakers; the second covers loopback connections from the host itself (useful for the in-built pre-flight probe).
|
||||
|
||||
2. **Capabilities**: Grant the binary permission to bind low ports and start the listener directly on `:443`:
|
||||
|
||||
```bash
|
||||
sudo setcap 'cap_net_bind_service=+ep' ./soundtouch-service
|
||||
./soundtouch-service --https-port=443
|
||||
```
|
||||
|
||||
3. **Reverse proxy**: Use Nginx or Caddy on `:443` in front of the service (see below).
|
||||
|
||||
### Confirming `:443` is reachable
|
||||
|
||||
After applying any of the options above, open the AfterTouch web UI → **Settings**. The Target Domain row will show a second line:
|
||||
|
||||
* ✅ `:443 reachable on localhost and <IP> (forwarded to :8443)` — you're good.
|
||||
* ❌ `Speakers connect to :443 but AfterTouch listens on :8443.` — the routing is missing or not yet active.
|
||||
|
||||
A third line follows from the browser itself, which sits on the LAN exactly where the speakers do. The browser can't distinguish an untrusted-CA TLS error from a connection refusal, so it uses timing as a heuristic: a fast error means "no listener / firewall reset", a slower one means "something answered TCP". When the server-side and browser-side checks disagree, the UI flags it — that almost always means NAT, split-horizon DNS, or a host firewall sitting between AfterTouch and the LAN.
|
||||
|
||||
The same check runs once at service startup and prints a `[WARN]` log line if `:443` is unreachable, with the exact iptables/setcap commands for your current listener port.
|
||||
|
||||
#### When this check is shown
|
||||
|
||||
The `:443` indicator is only displayed when **AfterTouch's DNS interception is enabled** (Settings → "Enable DNS Discovery Server"). The check is only meaningful for the **DNS migration method**, where speakers reach AfterTouch via intercepted Bose hostnames and therefore on the implicit `:443`. The other migration method — writing direct `https://<host>:8443/...` URLs into the speaker's private config via SSH — uses the port that's literally in the URL, so `:443` is irrelevant and the check would only add noise.
|
||||
|
||||
If you intercept Bose hostnames **outside** AfterTouch (Pi-hole, router DNS rule, `/etc/hosts` on a gateway), the UI gate above will hide the indicator. The data is still in the `GET /setup/settings` JSON response (`https_443_localhost_reachable`, `https_443_lan_reachable`, `https_443_lan_host`) if you want to inspect it directly, or you can briefly enable AfterTouch's DNS server to see the indicator render.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -206,6 +206,82 @@ Each speaker is migrated independently. You can run multiple migrations in paral
|
||||
|
||||
---
|
||||
|
||||
## Alternative: CLI-driven factory-reset workflow
|
||||
|
||||
If you prefer scripting the migration, or the wizard isn't an option (headless server, automation, batch onboarding of many speakers), `soundtouch-cli` exposes the same building blocks. The flow below is **not** an in-place migration — it factory-resets the speaker and brings it up fresh against AfterTouch, so any data Bose preserved on the device is wiped. Use this when:
|
||||
|
||||
- You're starting from a factory-reset speaker anyway.
|
||||
- The wizard's in-place migration didn't take and you want a clean slate.
|
||||
- You're scripting setup for many speakers and want a reproducible recipe.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- AfterTouch service running and reachable at a stable URL (e.g., `https://soundtouch.local` from your `.env`).
|
||||
- The speaker reachable on its current IP (passed as `--host`).
|
||||
- For the AP-mode handover step, your laptop must be able to join the speaker's `Bose SoundTouch` Wi-Fi (you'll switch between home Wi-Fi and the speaker's AP).
|
||||
|
||||
### The full sequence
|
||||
|
||||
```bash
|
||||
# 1. Plan what the reset+pair pipeline will write (dry run, no changes yet).
|
||||
soundtouch-cli --host 192.168.1.50 setup plan \
|
||||
--reset=true --include-pair=false \
|
||||
--service-url='https://soundtouch.local'
|
||||
|
||||
# 2. Trigger the factory reset. The speaker reboots into AP mode.
|
||||
soundtouch-cli --host 192.168.1.50 setup factory-reset
|
||||
|
||||
# --- Manual step: join the speaker's Wi-Fi AP (SSID "Bose SoundTouch ...") ---
|
||||
|
||||
# 3. Wait for the AP-mode endpoint to answer.
|
||||
soundtouch-cli setup wait-ap
|
||||
|
||||
# 4. Push your home Wi-Fi credentials to the speaker.
|
||||
# Run twice if the first attempt's ACK races the AP teardown — the second
|
||||
# one is a no-op if the first succeeded.
|
||||
soundtouch-cli setup wifi-push --ssid="YourHomeSSID" --pass='your-wifi-password'
|
||||
|
||||
# --- Manual step: switch your laptop back to the home Wi-Fi network ---
|
||||
|
||||
# 5. Wait for the speaker to come back online on the home network.
|
||||
# --match takes the last 4-6 hex chars of the speaker's MAC (visible on
|
||||
# the bottom of the device).
|
||||
soundtouch-cli setup wait-online --match=42CAFE
|
||||
|
||||
# 6. Pair the speaker with an AfterTouch account.
|
||||
# --mode=full runs the canonical WebSocket SETUP sequence (matches the
|
||||
# Bose app's flow); --account is the 7-digit account ID AfterTouch
|
||||
# should attach the speaker to.
|
||||
soundtouch-cli --host 192.168.1.50 setup pair \
|
||||
--mode=full --account=1111111 \
|
||||
--service-url='https://soundtouch.local'
|
||||
```
|
||||
|
||||
### Verifying the result
|
||||
|
||||
After pairing completes:
|
||||
|
||||
- The speaker should appear on the **Devices** tab in the web UI.
|
||||
- AUX should switch and play audio when selected.
|
||||
- Pressing presets should fetch their content from AfterTouch (the `[LOG]` rows on the service confirm).
|
||||
- TuneIn search and playback should work end-to-end.
|
||||
|
||||
If any of these fail post-pair, see [Troubleshooting](TROUBLESHOOTING.md) — most commonly the speaker just needs a power cycle to pick up everything cleanly.
|
||||
|
||||
### Differences vs the wizard
|
||||
|
||||
| Aspect | Wizard (in-place migration) | CLI factory-reset workflow |
|
||||
|-------------------------------------|---------------------------------------------------------|---------------------------------------------------------|
|
||||
| Preserves speaker's existing state | yes (Presets, recents, attached account) | **no** — wipes everything |
|
||||
| Requires Wi-Fi-network switching | no | yes (laptop joins speaker AP, then home network) |
|
||||
| Scriptable / reproducible | clickable, not scriptable | full bash recipe |
|
||||
| Cloud-side data (Bose Marge backup) | preserved if Sync ran while cloud was alive | not relevant — fresh account on AfterTouch |
|
||||
| Best for | "I want this speaker to keep working with what's on it" | "I want a clean, reproducible setup against AfterTouch" |
|
||||
|
||||
The wizard is still the recommended path for a one-off migration of an existing setup. The CLI workflow is the right choice when you're scripting, batching, or already starting from a reset.
|
||||
|
||||
---
|
||||
|
||||
## Rollback
|
||||
|
||||
If you need to undo a migration:
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
This guide explains how to link your Spotify or Amazon Music account to AfterTouch so your speakers can stream music from those services.
|
||||
|
||||
> For Spotify, a higher-level mental model of how the integration works —
|
||||
> Spotify Connect vs. AfterTouch's OAuth-intercept path, the
|
||||
> `streamingoauth.bose.com` DNS gotcha, and the token lifecycle — is in
|
||||
> [docs/concepts/spotify-overview.md](../concepts/spotify-overview.md).
|
||||
> Read that if priming or playback isn't behaving as you'd expect.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -474,7 +474,7 @@ Catch-all endpoint that signals the matching pre-flight probe channel. Used inte
|
||||
#### `GET /bmx/registry/v1/services`
|
||||
Returns available media services for device registration.
|
||||
|
||||
#### `GET /bmx/tunein/v1/playbook/station/{stationID}`
|
||||
#### `GET /bmx/tunein/v1/playback/station/{stationID}`
|
||||
Provides TuneIn station playback information.
|
||||
|
||||
#### `GET /bmx/tunein/v1/podcast/{podcastID}`
|
||||
|
||||
@@ -105,6 +105,62 @@ iperf3 -c 192.168.1.1 # If iperf server available
|
||||
|
||||
## 🌐 **Connection Issues**
|
||||
|
||||
### ❌ Speaker logs `Curl 7, http 0` and AfterTouch sees no HTTP requests
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
In the speaker's log (see [DEVICE-LOGGING.md](../DEVICE-LOGGING.md#1-accessing-system-logs-requires-root) for the SSH/`logread` setup — the filtered command `logread -f | grep -v '127.0.0.1'` is what you want here):
|
||||
|
||||
```
|
||||
SimpleURLFetcher: retry needed, Curl 7, http 0
|
||||
```
|
||||
|
||||
In the AfterTouch service log: plenty of `[DNS] Intercepted query …` lines but **zero** HTTP requests after each DNS lookup.
|
||||
|
||||
**Cause:** speakers connect to Bose hostnames over implicit HTTPS, i.e. port **443**. AfterTouch's built-in HTTPS listener defaults to **8443** because 443 is privileged. The speaker resolves the right IP, dials `:443`, and gets connection refused — which is what `Curl 7` reports.
|
||||
|
||||
**Verify:**
|
||||
|
||||
```bash
|
||||
curl -ksS -o /dev/null -w "443=%{http_code}\n" https://localhost:443/
|
||||
curl -ksS -o /dev/null -w "8443=%{http_code}\n" https://localhost:8443/
|
||||
```
|
||||
|
||||
Expected when the misconfiguration is present: `443=000` plus a `curl: (7) Failed to connect …` line, `8443=200` (or any 3-digit code).
|
||||
|
||||
**Fix:** route `:443` to AfterTouch's HTTPS listener — see [HTTPS-SETUP.md → Binding to port 443](HTTPS-SETUP.md#binding-to-port-443). The AfterTouch settings page shows a ✅ / ❌ indicator for `:443` reachability once the routing is in place.
|
||||
|
||||
### ❌ Presets flash then revert to "Select a preset" after a factory reset
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- You factory-reset a SoundTouch (Wave / 10 / 20 / 30 / …) that was previously migrated.
|
||||
- After reconnecting it to Wi-Fi, AfterTouch sees the speaker again, but pressing a preset on the device or in the app makes the display briefly show the preset name and then revert to *"Select a preset or explore music in the SoundTouch App"*.
|
||||
- Spotify presets show the same revert unless Spotify Connect is started from the mobile app first.
|
||||
- The speaker's `/sources` is missing TUNEIN / LOCAL_INTERNET_RADIO / DEEZER / your linked Spotify account — only AUX, BLUETOOTH, AIRPLAY, the SpotifyConnectUserName placeholder, NOTIFICATION, and QPLAY appear.
|
||||
|
||||
**Cause:**
|
||||
|
||||
A factory reset wipes `/mnt/nv/BoseApp-Persistence/1/Marge.xml` — the file that carries the speaker's auth token for the AfterTouch (or Bose) cloud service. The migrated URL configuration is preserved (it lives in `envswitch`), so the speaker keeps talking to AfterTouch, but with no token it can't authenticate for preset playback. Separately, the device's `/sources` cache is reduced until it receives a `<sourcesUpdated/>` notification.
|
||||
|
||||
**Fix:**
|
||||
|
||||
1. **Re-open the Migration tab** in the AfterTouch UI. The wizard reads `/info`, sees `margeAccountUUID` is empty, and renders:
|
||||
|
||||
> **Current: ❌ Not paired (factory-reset or never paired) — set an ID to pair as part of Apply**
|
||||
|
||||
The devices list now also shows a `⚠ Not paired — re-pair` badge next to such speakers, so you don't have to remember to open the Migration tab cold.
|
||||
|
||||
2. **Pick the previously-used account ID** from the "pick from datastore" dropdown (if AfterTouch remembers it), or click **Generate** for a fresh one.
|
||||
|
||||
3. **Click Apply.** The wizard runs `pair-account` along with the rest, recreating `Marge.xml` on the device with the chosen ID.
|
||||
|
||||
4. **Click Data Sync** (Tab 3). AfterTouch persists the speaker's presets/recents/sources and posts a `<sourcesUpdated/>` notification to the device — the missing TUNEIN / LOCAL_INTERNET_RADIO / DEEZER / linked Spotify entries reappear in `/sources` automatically.
|
||||
|
||||
5. Press a preset. It should play normally.
|
||||
|
||||
If presets still won't play after step 5, capture `logread -f | grep -v '127.0.0.1:'` on the speaker (see [DEVICE-LOGGING.md](../DEVICE-LOGGING.md#1-accessing-system-logs-requires-root)) while pressing the preset and file an issue with the snippet — the lines around the failed playback name the deeper cause.
|
||||
|
||||
### ❌ "Connection refused"
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 512 KiB After Width: | Height: | Size: 518 KiB |
|
Before Width: | Height: | Size: 463 KiB After Width: | Height: | Size: 482 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 97 KiB |
@@ -309,7 +309,7 @@ Now Playing:
|
||||
Track: K-LOVE Radio
|
||||
|
||||
Content Details:
|
||||
Location: /v1/playbook/station/s33828
|
||||
Location: /v1/playback/station/s33828
|
||||
```
|
||||
|
||||
**LOCAL_INTERNET_RADIO:**
|
||||
@@ -341,7 +341,7 @@ go run ./cmd/soundtouch-cli --host 192.168.1.100 play now --verbose
|
||||
Shows additional information:
|
||||
```
|
||||
Content Details:
|
||||
Location: /v1/playbook/station/s33828
|
||||
Location: /v1/playback/station/s33828
|
||||
Content Type: stationurl
|
||||
Item Name: K-LOVE Radio
|
||||
Presetable: true
|
||||
@@ -367,7 +367,7 @@ Content Details:
|
||||
| **Spotify Album** | `spotify:album:ID` | `spotify:album:4aawyAB9vmqN3uQ7FjRGTy` |
|
||||
| **Spotify Artist** | `spotify:artist:ID` | `spotify:artist:6APm8EjxOHSYM5B4i3vT3q` |
|
||||
| **Spotify Track** | `spotify:track:ID` | `spotify:track:17GmwQ9Q3MTAz05OokmNNB` |
|
||||
| **TUNEIN Radio** | `/v1/playbook/station/ID` | `/v1/playbook/station/s33828` |
|
||||
| **TUNEIN Radio** | `/v1/playback/station/ID` | `/v1/playback/station/s33828` |
|
||||
| **Internet Radio** | `URL or encoded URL` | `https://stream.example.com/radio` |
|
||||
| **STORED_MUSIC** | `Container ID` | `6_a2874b5d_4f83d999` |
|
||||
| **LOCAL_MUSIC** | `album:ID` or `track:ID` | `album:983`, `track:2579` |
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# soundtouch-web: remaining features
|
||||
|
||||
Four features complete the parity gap between soundtouch-web and the Stockholm
|
||||
app's local-control functionality. Everything else in Stockholm (OAuth flows,
|
||||
setup wizard, service account linking, onboarding, analytics) is cloud
|
||||
infrastructure that is either shut down or already handled by soundtouch-service.
|
||||
|
||||
---
|
||||
|
||||
## 1. Seek / scrub
|
||||
|
||||
The progress bar already renders `NowPlaying.Time.Position` / `NowPlaying.Time.Total`
|
||||
with a live 1 s ticker. What's missing is the ability to click or drag it to seek.
|
||||
|
||||
**Device API:** `POST /seek` with body `<seek deviceID="…" type="TIME_VALUE"><time>30</time></seek>`
|
||||
|
||||
**Backend:**
|
||||
- Add `POST /api/device-seek/{id}/{seconds}` handler in `handler.go`
|
||||
- Guard on `NowPlaying.SeekSupported.Value` — return 400 if the stream doesn't
|
||||
support seeking (radio, for example)
|
||||
|
||||
**Frontend (`NowPlaying.js`):**
|
||||
- Replace the static `<div class="progress-bar">` with a `<input type="range">`
|
||||
- `onInput` updates local state for smooth scrubbing; `onChange` (pointer up)
|
||||
fires `api.seek(deviceId, seconds)`
|
||||
- Pause the 1 s ticker while the user is dragging to avoid fighting the input
|
||||
|
||||
**Client method to add (or verify exists):**
|
||||
```go
|
||||
func (c *Client) Seek(positionSeconds int) error {
|
||||
// POST /seek
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Favorites
|
||||
|
||||
Mark or unmark the currently playing track as a favourite directly from the
|
||||
Now Playing card.
|
||||
|
||||
**Device API:**
|
||||
- `GET /favorites` — returns `<favorites>` list
|
||||
- `POST /favorites` — adds current content item as a favourite
|
||||
- `DELETE /favorites/{id}` — removes a favourite by ID
|
||||
|
||||
**Backend:**
|
||||
- `GET /api/device-favorites/{id}` — fetch favourites list
|
||||
- `POST /api/device-favorites/{id}` — add current now-playing item as favourite
|
||||
- `DELETE /api/device-favorites/{id}/{favId}` — remove a favourite
|
||||
|
||||
**Frontend:**
|
||||
- Heart button (♡ / ♥) in `NowPlaying.js`, next to the source label
|
||||
- On mount (or when `nowPlaying` changes) fetch favourites and check whether
|
||||
the current `ContentItem.Location` is already in the list
|
||||
- Toggle on click; optimistic UI update before the round-trip
|
||||
|
||||
**Note:** Not all sources support favourites. Check
|
||||
`NowPlaying.FavoriteEnabled` — if the field is nil/absent, hide the button.
|
||||
|
||||
---
|
||||
|
||||
## 3. Device settings panel
|
||||
|
||||
A lightweight settings page per device covering the two most useful knobs:
|
||||
rename and network/firmware info.
|
||||
|
||||
**Device API:**
|
||||
- `GET /info` — device info (already fetched; stored as `DeviceInfo`)
|
||||
- `POST /name` with body `<name>New Name</name>` — rename the device
|
||||
- `GET /networkInfo` — IP, MAC, SSID, signal strength
|
||||
- `GET /swUpdateStatus` — current firmware version and whether an update is
|
||||
available (not all devices expose this)
|
||||
|
||||
**Backend:**
|
||||
- `POST /api/device-rename/{id}` — body `{"name":"…"}`; calls `POST /name`
|
||||
- `GET /api/device-network/{id}` — proxies `GET /networkInfo`
|
||||
- Optionally `GET /api/device-update-status/{id}` — proxies `GET /swUpdateStatus`
|
||||
|
||||
**Frontend:**
|
||||
- Small ⚙ icon button in `DeviceDetail`'s page header (next to the power button)
|
||||
- Navigates to a new `page === 'settings'` state in `App`; passes `deviceId`
|
||||
- `DeviceSettings.js` component: editable name field (save on blur/Enter),
|
||||
read-only network info card, optional firmware version badge
|
||||
- Back button returns to `'device'` page
|
||||
|
||||
---
|
||||
|
||||
## 4. Render stereo pairs as a single device
|
||||
|
||||
Today soundtouch-web shows the two halves of a stereo pair (formed via
|
||||
`/addGroup` — see issue #252) as independent entries in the device list. The
|
||||
Bose app collapsed a paired ST10 set into one "L+R" entry; restoring that
|
||||
presentation closes the perception gap BirdyBA flagged at
|
||||
<https://github.com/gesellix/Bose-SoundTouch/issues/252#issuecomment-4458140305>.
|
||||
|
||||
**Device API:**
|
||||
- `GET /getGroup` on each speaker — returns the current `<group>` with
|
||||
`<masterDeviceId>` + `<roles>` (each `<groupRole>` carries the speaker's
|
||||
deviceId, role `LEFT|RIGHT`, and ipAddress)
|
||||
- Empty `<group/>` means the speaker is standalone
|
||||
- Querying the master and slave returns the same `<group>` payload, so either
|
||||
side is sufficient to detect the pair
|
||||
|
||||
**Backend:**
|
||||
- During device-list assembly, call `GET /getGroup` for each discovered device
|
||||
in parallel (matches the propagation pattern already used by
|
||||
`soundtouch-cli group create` in `cmd/soundtouch-cli/cmd_group.go`)
|
||||
- Bucket devices by `<masterDeviceId>` — each bucket emits one entry in the
|
||||
list response. Standalone devices stay as their own bucket-of-one
|
||||
- Expose pair metadata on the list entry so the UI can render role chips
|
||||
(`L`/`R`) and resolve role → physical device for actions
|
||||
|
||||
**Frontend:**
|
||||
- Device list collapses paired devices into one card titled with both names
|
||||
(e.g. `"Wohnzimmer L+R"`) and role chips
|
||||
- Clicking the card opens a device-detail page that exposes both per-role
|
||||
status and a "Dissolve pair" action (DELETE flow, already wired in
|
||||
`soundtouch-cli group remove` and in fakespeaker's `/removeGroup` GET)
|
||||
- Standalone speakers continue to render as today
|
||||
|
||||
**Note:** Pair lifecycle (create / rename / remove) already works
|
||||
end-to-end — `pkg/client` group endpoints + `cmd/soundtouch-cli/cmd_group.go`,
|
||||
covered by tests in `cmd/soundtouch-cli/cmd_group_test.go` and exercisable
|
||||
against the fake speaker's group routes
|
||||
(`pkg/service/testing/fakespeaker/fakespeaker.go`). This task is purely about
|
||||
presentation in soundtouch-web's device list — no protocol work required.
|
||||
|
||||
---
|
||||
|
||||
## Decide later
|
||||
|
||||
| Feature | Reason |
|
||||
|----------------------------------------|--------------------------------------------------------------------|
|
||||
| Spotify / Pandora / Amazon browsing UI | Requires Bose cloud (shutting down); handled by soundtouch-service |
|
||||
| Setup wizard (WiFi, Marge migration) | Already in soundtouch-service setup flows |
|
||||
| OAuth / login flows | Cloud-dependent; not needed for local network access |
|
||||
| AirPlay / Bluetooth pairing UI | Device handles this independently; no SoundTouch Web API |
|
||||
| Onboarding, help, analytics | Not relevant for a local control tool |
|
||||
@@ -83,7 +83,7 @@ go run main.go 192.168.1.100
|
||||
🎯 Using generic ContentItem selection...
|
||||
Content: K-LOVE Radio
|
||||
Source: TUNEIN
|
||||
Location: /v1/playbook/station/s33828
|
||||
Location: /v1/playback/station/s33828
|
||||
✅ Successfully selected content using ContentItem
|
||||
|
||||
✅ Content selection demo completed!
|
||||
|
||||
@@ -209,7 +209,7 @@ func demoGenericContentItem(c *client.Client) error {
|
||||
contentItem := &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
Type: "stationurl",
|
||||
Location: "/v1/playbook/station/s33828", // K-LOVE Radio
|
||||
Location: "/v1/playback/station/s33828", // K-LOVE Radio
|
||||
SourceAccount: "",
|
||||
IsPresetable: true,
|
||||
ItemName: "K-LOVE Radio",
|
||||
|
||||
@@ -140,7 +140,7 @@ err := client.AddStation("TUNEIN", "", "c121508", "Jazz FM")
|
||||
// Remove station from collection
|
||||
contentItem := &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
Location: "/v1/playbook/station/s33828",
|
||||
Location: "/v1/playback/station/s33828",
|
||||
}
|
||||
err := client.RemoveStation(contentItem)
|
||||
```
|
||||
|
||||
@@ -2,7 +2,7 @@ module navigation-station-demo
|
||||
|
||||
go 1.26.3
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.71.2
|
||||
require github.com/gesellix/bose-soundtouch v0.78.0
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ go run . 192.168.1.100
|
||||
|
||||
2. K-LOVE Radio
|
||||
Source: TUNEIN
|
||||
Location: /v1/playbook/station/s33828
|
||||
Location: /v1/playback/station/s33828
|
||||
Created: 2024-01-15 09:15:00
|
||||
|
||||
🆓 Available slots: [3 4 5 6]
|
||||
@@ -179,7 +179,7 @@ Location: "spotify:track:17GmwQ9Q3MTAz05OokmNNB"
|
||||
|
||||
```go
|
||||
// TuneIn
|
||||
Location: "/v1/playbook/station/s33828"
|
||||
Location: "/v1/playback/station/s33828"
|
||||
|
||||
// Internet Radio
|
||||
Location: "https://stream.example.com/radio"
|
||||
|
||||
@@ -2,7 +2,7 @@ module preset-management-example
|
||||
|
||||
go 1.26.3
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.71.2
|
||||
require github.com/gesellix/bose-soundtouch v0.78.0
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||
|
||||
|
||||
@@ -226,7 +226,7 @@ func storeRadioStation(c *client.Client) error {
|
||||
radioContent := &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
Type: "stationurl",
|
||||
Location: "/v1/playbook/station/s33828", // K-LOVE
|
||||
Location: "/v1/playback/station/s33828", // K-LOVE
|
||||
SourceAccount: "",
|
||||
IsPresetable: true,
|
||||
ItemName: "K-LOVE Radio",
|
||||
@@ -329,7 +329,7 @@ func demonstrateWebSocketEvents(c *client.Client) error {
|
||||
testContent := &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
Type: "stationurl",
|
||||
Location: "/v1/playbook/station/s25111", // BBC Radio 1
|
||||
Location: "/v1/playback/station/s25111", // BBC Radio 1
|
||||
SourceAccount: "",
|
||||
IsPresetable: true,
|
||||
ItemName: "BBC Radio 1",
|
||||
|
||||
@@ -15,21 +15,21 @@ require (
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
|
||||
github.com/urfave/cli/v2 v2.27.7
|
||||
golang.org/x/crypto v0.51.0
|
||||
golang.org/x/net v0.54.0
|
||||
golang.org/x/term v0.43.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc // indirect
|
||||
github.com/chromedp/cdproto v0.0.0-20260427013145-5737772c319b // indirect
|
||||
github.com/chromedp/sysutil v1.1.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20260505212615-e40f80bf6836 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.4.0 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
||||
golang.org/x/image v0.40.0 // indirect
|
||||
golang.org/x/mod v0.36.0 // indirect
|
||||
golang.org/x/net v0.54.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU=
|
||||
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag=
|
||||
github.com/chromedp/cdproto v0.0.0-20260427013145-5737772c319b h1:fpvdcCAe2z3H8OvVY00iKOp3Wapbs/Gy375Fn6l/XM4=
|
||||
github.com/chromedp/cdproto v0.0.0-20260427013145-5737772c319b/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag=
|
||||
github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ=
|
||||
github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4=
|
||||
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
||||
@@ -11,8 +11,8 @@ 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/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
|
||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||
github.com/go-json-experiment/json v0.0.0-20260505212615-e40f80bf6836 h1:5KGUhXZFTN1PrCY4zUZLe1J8n7uBNmPDbCLCn78EbPQ=
|
||||
github.com/go-json-experiment/json v0.0.0-20260505212615-e40f80bf6836/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/speaker"
|
||||
)
|
||||
|
||||
// Integration tests for bass control functionality
|
||||
@@ -426,7 +427,7 @@ func BenchmarkClient_Bass_Integration(b *testing.B) {
|
||||
// This is a simple version for test use
|
||||
func parseBassHostPort(hostPort string) (string, int) {
|
||||
if !containsSubstring(hostPort, ":") {
|
||||
return hostPort, defaultSoundTouchPort
|
||||
return hostPort, speaker.HTTPPort
|
||||
}
|
||||
|
||||
// Simple parsing - in real use, we'd use net.SplitHostPort
|
||||
@@ -448,7 +449,7 @@ func parseBassHostPort(hostPort string) (string, int) {
|
||||
|
||||
if len(parts) == 2 {
|
||||
// Try to parse port
|
||||
port := defaultSoundTouchPort
|
||||
port := speaker.HTTPPort
|
||||
portStr := parts[1]
|
||||
portInt := 0
|
||||
|
||||
@@ -468,5 +469,5 @@ func parseBassHostPort(hostPort string) (string, int) {
|
||||
return parts[0], port
|
||||
}
|
||||
|
||||
return hostPort, defaultSoundTouchPort
|
||||
return hostPort, speaker.HTTPPort
|
||||
}
|
||||
|
||||
@@ -153,11 +153,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/speaker"
|
||||
)
|
||||
|
||||
// defaultSoundTouchPort is the standard port for SoundTouch devices
|
||||
const defaultSoundTouchPort = 8090
|
||||
|
||||
// Client represents a SoundTouch API client
|
||||
type Client struct {
|
||||
baseURL string
|
||||
@@ -204,7 +202,7 @@ func NewClient(config *Config) *Client {
|
||||
// Fallback for invalid URLs
|
||||
port := config.Port
|
||||
if port == 0 {
|
||||
port = 8090
|
||||
port = speaker.HTTPPort
|
||||
}
|
||||
|
||||
return &Client{
|
||||
@@ -223,7 +221,7 @@ func NewClient(config *Config) *Client {
|
||||
// No port in the host string, use the one from config or default
|
||||
port := config.Port
|
||||
if port == 0 {
|
||||
port = 8090
|
||||
port = speaker.HTTPPort
|
||||
}
|
||||
|
||||
u.Host = net.JoinHostPort(u.Host, fmt.Sprintf("%d", port))
|
||||
@@ -231,7 +229,7 @@ func NewClient(config *Config) *Client {
|
||||
// Empty port, use config or default
|
||||
port := config.Port
|
||||
if port == 0 {
|
||||
port = 8090
|
||||
port = speaker.HTTPPort
|
||||
}
|
||||
|
||||
u.Host = net.JoinHostPort(u.Hostname(), fmt.Sprintf("%d", port))
|
||||
@@ -1380,6 +1378,58 @@ func (c *Client) GetZoneMembers() ([]string, error) {
|
||||
return zone.GetAllDeviceIDs(), nil
|
||||
}
|
||||
|
||||
// GetGroup retrieves the current stereo-pair configuration from the device.
|
||||
// An empty <group/> response is reported as a zero-value Group; callers can
|
||||
// distinguish with (*Group).IsEmpty().
|
||||
//
|
||||
// ST-10 is the only product that supports stereo pairs; on other devices
|
||||
// the call is harmless but will always return an empty group. The endpoint
|
||||
// is named /getGroup on the device (mirroring /getZone), even though some
|
||||
// third-party wikis document it as plain /group.
|
||||
func (c *Client) GetGroup() (*models.Group, error) {
|
||||
var g models.Group
|
||||
|
||||
err := c.get("/getGroup", &g)
|
||||
|
||||
return &g, err
|
||||
}
|
||||
|
||||
// AddGroup creates a new stereo pair on the device addressed by this client,
|
||||
// which becomes the master. The supplied group must contain both LEFT and
|
||||
// RIGHT roles; the device assigns the group ID and echoes the full state
|
||||
// in the response.
|
||||
func (c *Client) AddGroup(group *models.Group) (*models.Group, error) {
|
||||
var result models.Group
|
||||
if err := c.postWithResponse("/addGroup", group, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// UpdateGroup renames or otherwise updates an existing stereo pair. The
|
||||
// device requires the full group structure on every update, not just the
|
||||
// changed fields.
|
||||
func (c *Client) UpdateGroup(group *models.Group) (*models.Group, error) {
|
||||
var result models.Group
|
||||
if err := c.postWithResponse("/updateGroup", group, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// RemoveGroup tears down the device's stereo pair. The device returns an
|
||||
// empty <group/> on success — surfaced here as a non-error nil.
|
||||
//
|
||||
// Note: the wiki specifies GET (not DELETE) for this endpoint, so we honour
|
||||
// that despite the state-mutating semantics.
|
||||
func (c *Client) RemoveGroup() error {
|
||||
var g models.Group
|
||||
|
||||
return c.get("/removeGroup", &g)
|
||||
}
|
||||
|
||||
// SetName sets the device name
|
||||
func (c *Client) SetName(name string) error {
|
||||
nameRequest := models.Name{
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestClient_GetGroup_Configured(t *testing.T) {
|
||||
responseXML := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<group id="1234567">
|
||||
<name>Living Room Pair</name>
|
||||
<masterDeviceId>9070658C9D4A</masterDeviceId>
|
||||
<roles>
|
||||
<groupRole>
|
||||
<deviceId>9070658C9D4A</deviceId>
|
||||
<role>LEFT</role>
|
||||
<ipAddress>192.168.1.131</ipAddress>
|
||||
</groupRole>
|
||||
<groupRole>
|
||||
<deviceId>F45EAB3115DA</deviceId>
|
||||
<role>RIGHT</role>
|
||||
<ipAddress>192.168.1.134</ipAddress>
|
||||
</groupRole>
|
||||
</roles>
|
||||
<senderIPAddress>192.168.1.131</senderIPAddress>
|
||||
<status>GROUP_OK</status>
|
||||
</group>`
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/getGroup" {
|
||||
t.Errorf("path = %q, want /getGroup", r.URL.Path)
|
||||
}
|
||||
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("method = %s, want GET", r.Method)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(responseXML))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
g, err := createTestClient(server.URL).GetGroup()
|
||||
if err != nil {
|
||||
t.Fatalf("GetGroup: %v", err)
|
||||
}
|
||||
|
||||
if g.ID != "1234567" {
|
||||
t.Errorf("ID = %q, want 1234567", g.ID)
|
||||
}
|
||||
|
||||
if g.Name != "Living Room Pair" {
|
||||
t.Errorf("Name = %q, want Living Room Pair", g.Name)
|
||||
}
|
||||
|
||||
if g.MasterDeviceID != "9070658C9D4A" {
|
||||
t.Errorf("MasterDeviceID = %q", g.MasterDeviceID)
|
||||
}
|
||||
|
||||
if g.Status != "GROUP_OK" {
|
||||
t.Errorf("Status = %q, want GROUP_OK", g.Status)
|
||||
}
|
||||
|
||||
if len(g.Roles.Roles) != 2 {
|
||||
t.Fatalf("roles = %d, want 2", len(g.Roles.Roles))
|
||||
}
|
||||
|
||||
if g.Roles.Roles[0].Role != "LEFT" || g.Roles.Roles[1].Role != "RIGHT" {
|
||||
t.Errorf("role order LEFT/RIGHT not preserved: %+v", g.Roles.Roles)
|
||||
}
|
||||
|
||||
if g.IsEmpty() {
|
||||
t.Errorf("IsEmpty = true for populated group")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetGroup_Empty(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(`<group />`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
g, err := createTestClient(server.URL).GetGroup()
|
||||
if err != nil {
|
||||
t.Fatalf("GetGroup: %v", err)
|
||||
}
|
||||
|
||||
if !g.IsEmpty() {
|
||||
t.Errorf("IsEmpty = false for <group/>, got %+v", g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_AddGroup(t *testing.T) {
|
||||
var capturedBody string
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/addGroup" {
|
||||
t.Errorf("path = %q, want /addGroup", r.URL.Path)
|
||||
}
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("method = %s, want POST", r.Method)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
capturedBody = string(body)
|
||||
|
||||
// Echo the request back with an assigned ID and GROUP_OK status —
|
||||
// matches real device behaviour.
|
||||
var got models.Group
|
||||
if err := xml.Unmarshal(body, &got); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
|
||||
got.ID = "9999999"
|
||||
got.Status = "GROUP_OK"
|
||||
got.SenderIPAddress = "192.168.1.131"
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
|
||||
enc, _ := xml.Marshal(&got)
|
||||
_, _ = w.Write(enc)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
req := &models.Group{
|
||||
Name: "Living Room",
|
||||
MasterDeviceID: "9070658C9D4A",
|
||||
Roles: models.GroupRoles{
|
||||
Roles: []models.GroupRole{
|
||||
{DeviceID: "9070658C9D4A", Role: "LEFT", IPAddress: "192.168.1.131"},
|
||||
{DeviceID: "F45EAB3115DA", Role: "RIGHT", IPAddress: "192.168.1.134"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := createTestClient(server.URL).AddGroup(req)
|
||||
if err != nil {
|
||||
t.Fatalf("AddGroup: %v", err)
|
||||
}
|
||||
|
||||
if resp.ID != "9999999" {
|
||||
t.Errorf("response ID = %q, want 9999999", resp.ID)
|
||||
}
|
||||
|
||||
if resp.Status != "GROUP_OK" {
|
||||
t.Errorf("response Status = %q, want GROUP_OK", resp.Status)
|
||||
}
|
||||
|
||||
// Wire-shape sanity: the request body must carry both roles and the
|
||||
// master ID (the device validates these on the wire).
|
||||
for _, want := range []string{"<role>LEFT</role>", "<role>RIGHT</role>", "9070658C9D4A"} {
|
||||
if !strings.Contains(capturedBody, want) {
|
||||
t.Errorf("request body missing %q\nbody:\n%s", want, capturedBody)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_UpdateGroup_RenameRoundtrip(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/updateGroup" {
|
||||
t.Errorf("path = %q, want /updateGroup", r.URL.Path)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
|
||||
var got models.Group
|
||||
if err := xml.Unmarshal(body, &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
got.Status = "GROUP_OK"
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
|
||||
enc, _ := xml.Marshal(&got)
|
||||
_, _ = w.Write(enc)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
req := &models.Group{
|
||||
ID: "1234567",
|
||||
Name: "Kitchen Pair",
|
||||
MasterDeviceID: "AAAA",
|
||||
Roles: models.GroupRoles{
|
||||
Roles: []models.GroupRole{
|
||||
{DeviceID: "AAAA", Role: "LEFT"},
|
||||
{DeviceID: "BBBB", Role: "RIGHT"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := createTestClient(server.URL).UpdateGroup(req)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateGroup: %v", err)
|
||||
}
|
||||
|
||||
if resp.Name != "Kitchen Pair" {
|
||||
t.Errorf("Name = %q, want Kitchen Pair", resp.Name)
|
||||
}
|
||||
|
||||
if resp.ID != "1234567" {
|
||||
t.Errorf("ID = %q, want 1234567", resp.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_RemoveGroup(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/removeGroup" {
|
||||
t.Errorf("path = %q, want /removeGroup", r.URL.Path)
|
||||
}
|
||||
|
||||
// The wiki specifies GET (not DELETE) for /removeGroup. We honour
|
||||
// that, surprising as it is for a state-mutating endpoint.
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("method = %s, want GET", r.Method)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(`<group />`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := createTestClient(server.URL).RemoveGroup(); err != nil {
|
||||
t.Fatalf("RemoveGroup: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/speaker"
|
||||
)
|
||||
|
||||
// Integration tests for source selection functionality
|
||||
@@ -386,7 +388,7 @@ func BenchmarkClient_SelectSource_Integration(b *testing.B) {
|
||||
// This is a simple version for test use
|
||||
func parseHostPort(hostPort string) (string, int) {
|
||||
if !containsSubstring(hostPort, ":") {
|
||||
return hostPort, defaultSoundTouchPort
|
||||
return hostPort, speaker.HTTPPort
|
||||
}
|
||||
|
||||
// Simple parsing - in real use, we'd use net.SplitHostPort
|
||||
@@ -408,7 +410,7 @@ func parseHostPort(hostPort string) (string, int) {
|
||||
|
||||
if len(parts) == 2 {
|
||||
// Try to parse port
|
||||
port := defaultSoundTouchPort
|
||||
port := speaker.HTTPPort
|
||||
portStr := parts[1]
|
||||
portInt := 0
|
||||
|
||||
@@ -428,5 +430,5 @@ func parseHostPort(hostPort string) (string, int) {
|
||||
return parts[0], port
|
||||
}
|
||||
|
||||
return hostPort, defaultSoundTouchPort
|
||||
return hostPort, speaker.HTTPPort
|
||||
}
|
||||
|
||||
@@ -95,9 +95,7 @@ func TestClient_SetClockTime(t *testing.T) {
|
||||
{
|
||||
name: "Successful clock time set",
|
||||
request: &models.ClockTimeRequest{
|
||||
UTC: 1609459200,
|
||||
Value: "2021-01-01 00:00:00",
|
||||
Zone: "UTC",
|
||||
UTCTime: 1609459200,
|
||||
},
|
||||
statusCode: http.StatusOK,
|
||||
expectError: false,
|
||||
@@ -112,8 +110,7 @@ func TestClient_SetClockTime(t *testing.T) {
|
||||
{
|
||||
name: "Server error",
|
||||
request: &models.ClockTimeRequest{
|
||||
UTC: 1609459200,
|
||||
Value: "2021-01-01 00:00:00",
|
||||
UTCTime: 1609459200,
|
||||
},
|
||||
statusCode: http.StatusInternalServerError,
|
||||
expectError: true,
|
||||
|
||||
@@ -140,6 +140,17 @@ func (ws *WebSocketClient) OnZoneUpdated(handler models.TypedEventHandler[*model
|
||||
ws.handlers.OnZoneUpdated = handler
|
||||
}
|
||||
|
||||
// OnGroupUpdated sets a handler for ST-10 stereo-pair update events.
|
||||
// The device fans these out to both LEFT and RIGHT speakers whenever the
|
||||
// pair is created, renamed, or removed, so callers will see one event per
|
||||
// affected device.
|
||||
func (ws *WebSocketClient) OnGroupUpdated(handler models.TypedEventHandler[*models.GroupUpdatedEvent]) {
|
||||
ws.mu.Lock()
|
||||
defer ws.mu.Unlock()
|
||||
|
||||
ws.handlers.OnGroupUpdated = handler
|
||||
}
|
||||
|
||||
// OnBassUpdated sets a handler for bass update events
|
||||
func (ws *WebSocketClient) OnBassUpdated(handler models.TypedEventHandler[*models.BassUpdatedEvent]) {
|
||||
ws.mu.Lock()
|
||||
@@ -156,6 +167,18 @@ func (ws *WebSocketClient) OnUnknownEvent(handler models.EventHandler) {
|
||||
ws.handlers.OnUnknownEvent = handler
|
||||
}
|
||||
|
||||
// OnRawMessage sets a handler that fires for every incoming frame with
|
||||
// the raw bytes and the result of attempting to XML-parse them. The
|
||||
// typed handlers (OnNowPlaying, OnGroupUpdated, ...) still run
|
||||
// afterwards on successful parses, so OnRawMessage is purely additive —
|
||||
// intended for debug/observability tooling.
|
||||
func (ws *WebSocketClient) OnRawMessage(handler models.RawMessageHandler) {
|
||||
ws.mu.Lock()
|
||||
defer ws.mu.Unlock()
|
||||
|
||||
ws.handlers.OnRawMessage = handler
|
||||
}
|
||||
|
||||
// OnSpecialMessage sets a handler for special (non-updates) messages
|
||||
func (ws *WebSocketClient) OnSpecialMessage(handler models.SpecialMessageHandler) {
|
||||
ws.mu.Lock()
|
||||
@@ -379,26 +402,45 @@ func (ws *WebSocketClient) attemptReconnect(config *WebSocketConfig) {
|
||||
|
||||
// handleMessage processes incoming WebSocket messages
|
||||
func (ws *WebSocketClient) handleMessage(data []byte) {
|
||||
// Check if this is a SoundTouchSdkInfo or other non-updates message
|
||||
// Special (non-updates) messages take their own decode path and
|
||||
// surface raw payloads to the OnRawMessage hook from there, so
|
||||
// observers see exactly one notification per frame.
|
||||
if !ws.isUpdatesMessage(data) {
|
||||
ws.handleSpecialMessage(data)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the WebSocket event
|
||||
event, err := models.ParseWebSocketEvent(data)
|
||||
if err != nil {
|
||||
ws.logger.Printf("Failed to parse WebSocket message: %v", err)
|
||||
event, parseErr := models.ParseWebSocketEvent(data)
|
||||
|
||||
ws.fireRawMessage(data, parseErr)
|
||||
|
||||
if parseErr != nil {
|
||||
ws.logger.Printf("Failed to parse WebSocket message: %v", parseErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Process each event type in the message
|
||||
ws.handleEvent(event)
|
||||
}
|
||||
|
||||
// fireRawMessage invokes the OnRawMessage hook if one is registered.
|
||||
// Kept separate so the read path doesn't have to repeat the locking
|
||||
// dance for every frame.
|
||||
func (ws *WebSocketClient) fireRawMessage(data []byte, parseErr error) {
|
||||
ws.mu.RLock()
|
||||
handler := ws.handlers.OnRawMessage
|
||||
ws.mu.RUnlock()
|
||||
|
||||
if handler != nil {
|
||||
handler(data, parseErr)
|
||||
}
|
||||
}
|
||||
|
||||
// handleSpecialMessage processes special (non-updates) WebSocket messages
|
||||
func (ws *WebSocketClient) handleSpecialMessage(data []byte) {
|
||||
specialMessage, err := models.ParseSpecialMessage(data)
|
||||
|
||||
ws.fireRawMessage(data, err)
|
||||
|
||||
if err != nil {
|
||||
ws.logger.Printf("Unknown special message type: %v", err)
|
||||
ws.logger.Printf("Raw message: %s", string(data))
|
||||
@@ -468,6 +510,13 @@ func (ws *WebSocketClient) dispatchTypedEventContinued(handlers *models.WebSocke
|
||||
|
||||
return true
|
||||
|
||||
case models.EventTypeGroupUpdated:
|
||||
if handlers.OnGroupUpdated != nil && event.GroupUpdated != nil {
|
||||
handlers.OnGroupUpdated(event.GroupUpdated)
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
case models.EventTypeBassUpdated:
|
||||
if handlers.OnBassUpdated != nil && event.BassUpdated != nil {
|
||||
handlers.OnBassUpdated(event.BassUpdated)
|
||||
|
||||
@@ -19,6 +19,10 @@ type Config struct {
|
||||
DiscoveryTimeout time.Duration `env:"DISCOVERY_TIMEOUT" default:"5s"`
|
||||
UPnPEnabled bool `env:"UPNP_ENABLED" default:"true"`
|
||||
MDNSEnabled bool `env:"MDNS_ENABLED" default:"true"`
|
||||
// DiscoveryInterface restricts mDNS and UPnP/SSDP discovery to a single
|
||||
// network interface (e.g. "eth0"). Empty means "auto-pick the first
|
||||
// suitable interface", which is the historical behaviour.
|
||||
DiscoveryInterface string `env:"DISCOVERY_INTERFACE" default:""`
|
||||
|
||||
// Preferred devices from .env file
|
||||
PreferredDevices []DeviceConfig `env:"PREFERRED_DEVICES"`
|
||||
@@ -81,6 +85,10 @@ func LoadFromEnv() (*Config, error) {
|
||||
config.MDNSEnabled = mdns == "true" || mdns == "1"
|
||||
}
|
||||
|
||||
if iface := os.Getenv("DISCOVERY_INTERFACE"); iface != "" {
|
||||
config.DiscoveryInterface = iface
|
||||
}
|
||||
|
||||
if timeout := os.Getenv("HTTP_TIMEOUT"); timeout != "" {
|
||||
if d, err := time.ParseDuration(timeout); err == nil {
|
||||
config.HTTPTimeout = d
|
||||
|
||||
@@ -14,17 +14,26 @@ import (
|
||||
|
||||
// MDNSDiscoveryService handles mDNS/Bonjour discovery of SoundTouch devices
|
||||
type MDNSDiscoveryService struct {
|
||||
timeout time.Duration
|
||||
timeout time.Duration
|
||||
ifaceName string
|
||||
}
|
||||
|
||||
// NewMDNSDiscoveryService creates a new mDNS discovery service
|
||||
func NewMDNSDiscoveryService(timeout time.Duration) *MDNSDiscoveryService {
|
||||
return NewMDNSDiscoveryServiceWithInterface(timeout, "")
|
||||
}
|
||||
|
||||
// NewMDNSDiscoveryServiceWithInterface creates a new mDNS discovery service
|
||||
// pinned to the given network interface (e.g. "eth0"). An empty ifaceName
|
||||
// falls back to the historical auto-pick behaviour.
|
||||
func NewMDNSDiscoveryServiceWithInterface(timeout time.Duration, ifaceName string) *MDNSDiscoveryService {
|
||||
if timeout == 0 {
|
||||
timeout = defaultTimeout
|
||||
}
|
||||
|
||||
return &MDNSDiscoveryService{
|
||||
timeout: timeout,
|
||||
timeout: timeout,
|
||||
ifaceName: ifaceName,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,8 +227,27 @@ func (m *MDNSDiscoveryService) serviceEntryToDevice(entry *mdns.ServiceEntry) *m
|
||||
return device
|
||||
}
|
||||
|
||||
// getIPv4Interface returns the first suitable IPv4 network interface
|
||||
// getIPv4Interface returns the network interface to use for mDNS queries.
|
||||
// If an explicit name was configured, it is resolved and validated; otherwise
|
||||
// the first suitable, up, non-loopback IPv4 interface is returned.
|
||||
func (m *MDNSDiscoveryService) getIPv4Interface() *net.Interface {
|
||||
if m.ifaceName != "" {
|
||||
iface, err := net.InterfaceByName(m.ifaceName)
|
||||
if err != nil {
|
||||
log.Printf("mDNS: Configured interface %q not found: %v", m.ifaceName, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !interfaceHasIPv4(iface) {
|
||||
log.Printf("mDNS: Configured interface %q has no usable IPv4 address", m.ifaceName)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("mDNS: Using configured IPv4 interface: %s", iface.Name)
|
||||
|
||||
return iface
|
||||
}
|
||||
|
||||
interfaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
log.Printf("mDNS: Failed to get network interfaces: %v", err)
|
||||
@@ -234,30 +262,42 @@ func (m *MDNSDiscoveryService) getIPv4Interface() *net.Interface {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this interface has IPv4 addresses
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
if !interfaceHasIPv4(&iface) {
|
||||
continue
|
||||
}
|
||||
|
||||
hasIPv4 := false
|
||||
log.Printf("mDNS: Using IPv4 interface: %s", iface.Name)
|
||||
|
||||
for _, addr := range addrs {
|
||||
if ipNet, ok := addr.(*net.IPNet); ok {
|
||||
if ipNet.IP.To4() != nil && !ipNet.IP.IsLoopback() {
|
||||
hasIPv4 = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasIPv4 {
|
||||
log.Printf("mDNS: Using IPv4 interface: %s", iface.Name)
|
||||
return &iface
|
||||
}
|
||||
return &iface
|
||||
}
|
||||
|
||||
log.Printf("mDNS: No suitable IPv4 interface found")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// interfaceHasIPv4 reports whether iface has at least one non-loopback IPv4
|
||||
// address assigned and is administratively up.
|
||||
func interfaceHasIPv4(iface *net.Interface) bool {
|
||||
if iface.Flags&net.FlagUp == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
ipNet, ok := addr.(*net.IPNet)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if ipNet.IP.To4() != nil && !ipNet.IP.IsLoopback() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -92,6 +92,34 @@ func TestMDNSDiscoveryTimeout(t *testing.T) {
|
||||
_ = err
|
||||
}
|
||||
|
||||
func TestMDNSGetIPv4InterfaceUnknownName(t *testing.T) {
|
||||
service := NewMDNSDiscoveryServiceWithInterface(5*time.Second, "definitely-not-a-real-iface-xyz")
|
||||
if iface := service.getIPv4Interface(); iface != nil {
|
||||
t.Errorf("Expected nil for unknown interface name, got %q", iface.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMDNSGetIPv4InterfaceExplicitMatchesAutoPick(t *testing.T) {
|
||||
auto := NewMDNSDiscoveryService(5 * time.Second).getIPv4Interface()
|
||||
if auto == nil {
|
||||
t.Skip("No suitable IPv4 interface available on this host")
|
||||
}
|
||||
|
||||
explicit := NewMDNSDiscoveryServiceWithInterface(5*time.Second, auto.Name).getIPv4Interface()
|
||||
if explicit == nil {
|
||||
t.Fatalf("Expected explicit lookup of %q to succeed", auto.Name)
|
||||
}
|
||||
|
||||
if explicit.Name != auto.Name {
|
||||
t.Errorf("Expected explicit interface %q, got %q", auto.Name, explicit.Name)
|
||||
}
|
||||
|
||||
// Sanity: the resolved interface really has an IPv4 we could bind to.
|
||||
if !interfaceHasIPv4(explicit) {
|
||||
t.Errorf("Resolved interface %q has no IPv4 address", explicit.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMDNSDiscoveryWithCancelledContext(t *testing.T) {
|
||||
service := NewMDNSDiscoveryService(5 * time.Second)
|
||||
|
||||
|
||||
@@ -142,7 +142,7 @@ func NewUnifiedDiscoveryService(cfg *config.Config) *UnifiedDiscoveryService {
|
||||
|
||||
return &UnifiedDiscoveryService{
|
||||
ssdpService: NewServiceWithConfig(cfg),
|
||||
mdnsService: NewMDNSDiscoveryService(timeout),
|
||||
mdnsService: NewMDNSDiscoveryServiceWithInterface(timeout, cfg.DiscoveryInterface),
|
||||
config: cfg,
|
||||
cache: make(map[string]*models.DiscoveredDevice),
|
||||
cacheTTL: cacheTTL,
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/config"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"golang.org/x/net/ipv4"
|
||||
)
|
||||
|
||||
// Service handles UPnP SSDP discovery of SoundTouch devices
|
||||
@@ -26,6 +27,7 @@ type Service struct {
|
||||
mutex sync.RWMutex
|
||||
config *config.Config
|
||||
httpClient *http.Client
|
||||
ifaceName string
|
||||
}
|
||||
|
||||
// NewService creates a new UPnP discovery service
|
||||
@@ -63,6 +65,7 @@ func NewServiceWithConfig(cfg *config.Config) *Service {
|
||||
mutex: sync.RWMutex{},
|
||||
config: cfg,
|
||||
httpClient: &http.Client{Timeout: 5 * time.Second},
|
||||
ifaceName: cfg.DiscoveryInterface,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,15 +192,16 @@ func (d *Service) PerformDiscovery(ctx context.Context) ([]*models.DiscoveredDev
|
||||
}
|
||||
|
||||
func (d *Service) setupUDPListener() (*net.UDPConn, error) {
|
||||
listenAddr, err := net.ResolveUDPAddr("udp4", ":0")
|
||||
listenIP, iface, err := d.resolveListenInterface()
|
||||
if err != nil {
|
||||
log.Printf("UPnP: Failed to resolve listen address: %v", err)
|
||||
return nil, fmt.Errorf("failed to resolve listen address: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
listenAddr := &net.UDPAddr{IP: listenIP, Port: 0}
|
||||
|
||||
listener, err := net.ListenUDP("udp4", listenAddr)
|
||||
if err != nil {
|
||||
log.Printf("UPnP: Failed to create UDP listener: %v", err)
|
||||
log.Printf("UPnP: Failed to create UDP listener on %s: %v", listenAddr, err)
|
||||
return nil, fmt.Errorf("failed to create UDP listener: %w", err)
|
||||
}
|
||||
|
||||
@@ -212,11 +216,62 @@ func (d *Service) setupUDPListener() (*net.UDPConn, error) {
|
||||
return nil, fmt.Errorf("failed to cast local address to UDPAddr: %v", addr)
|
||||
}
|
||||
|
||||
// Pin the outgoing multicast packets to the configured interface so the
|
||||
// M-SEARCH leaves through the right NIC on multi-homed hosts.
|
||||
if iface != nil {
|
||||
if err := ipv4.NewPacketConn(listener).SetMulticastInterface(iface); err != nil {
|
||||
log.Printf("UPnP: Failed to set multicast interface to %q: %v", iface.Name, err)
|
||||
// Continue regardless — the kernel will fall back to its own routing decision.
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("UPnP: Created UDP listener on %s", localAddr.String())
|
||||
|
||||
return listener, nil
|
||||
}
|
||||
|
||||
// resolveListenInterface returns the source IP to bind the UDP listener to and
|
||||
// the interface to use for outgoing multicast. When no interface is configured,
|
||||
// the IP is nil (wildcard) and the iface is nil, preserving the historical
|
||||
// behaviour where the kernel picks a route.
|
||||
func (d *Service) resolveListenInterface() (net.IP, *net.Interface, error) {
|
||||
if d.ifaceName == "" {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
iface, err := net.InterfaceByName(d.ifaceName)
|
||||
if err != nil {
|
||||
log.Printf("UPnP: Configured interface %q not found: %v", d.ifaceName, err)
|
||||
return nil, nil, fmt.Errorf("configured interface %q not found: %w", d.ifaceName, err)
|
||||
}
|
||||
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
log.Printf("UPnP: Failed to read addresses for interface %q: %v", d.ifaceName, err)
|
||||
return nil, nil, fmt.Errorf("read addresses for interface %q: %w", d.ifaceName, err)
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
ipNet, ok := addr.(*net.IPNet)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
ipv4Addr := ipNet.IP.To4()
|
||||
if ipv4Addr == nil || ipNet.IP.IsLoopback() {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("UPnP: Binding UDP listener to interface %q (%s)", iface.Name, ipv4Addr)
|
||||
|
||||
return ipv4Addr, iface, nil
|
||||
}
|
||||
|
||||
log.Printf("UPnP: Configured interface %q has no usable IPv4 address", d.ifaceName)
|
||||
|
||||
return nil, nil, fmt.Errorf("interface %q has no usable IPv4 address", d.ifaceName)
|
||||
}
|
||||
|
||||
func (d *Service) sendMSearch(listener *net.UDPConn, multicastAddr *net.UDPAddr) error {
|
||||
msearchRequest := d.buildMSearchRequest()
|
||||
log.Printf("UPnP: Sending M-SEARCH request to %s:\n%s", ssdpAddr, strings.TrimSpace(msearchRequest))
|
||||
|
||||
@@ -2,20 +2,155 @@ package models
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ClockDisplay represents the device's clock display settings
|
||||
// ClockDisplay represents the device's clock display settings.
|
||||
//
|
||||
// Wire format (confirmed against ST10/ST20 firmware 27.0.6 — flat
|
||||
// attributes on the outer <clockDisplay> are rejected with
|
||||
// "Error parsing request"):
|
||||
//
|
||||
// <clockDisplay deviceID="…">
|
||||
// <clockConfig timezoneInfo="Europe/Berlin"
|
||||
// userEnable="true"
|
||||
// timeFormat="TIME_FORMAT_24HOUR_ID"
|
||||
// userOffsetMinute="0"
|
||||
// brightnessLevel="70"
|
||||
// userUtcTime="0"/>
|
||||
// </clockDisplay>
|
||||
//
|
||||
// The struct keeps its historical flat-field public API so the CLI and
|
||||
// other callers don't have to be rewritten; custom MarshalXML /
|
||||
// UnmarshalXML methods bridge to the nested format on the wire.
|
||||
type ClockDisplay struct {
|
||||
XMLName xml.Name `xml:"clockDisplay"`
|
||||
DeviceID string `xml:"deviceID,attr,omitempty"`
|
||||
Enabled bool `xml:"enabled,attr,omitempty"`
|
||||
Format string `xml:"format,attr,omitempty"`
|
||||
Brightness int `xml:"brightness,attr,omitempty"`
|
||||
AutoDim bool `xml:"autoDim,attr,omitempty"`
|
||||
TimeZone string `xml:"timeZone,attr,omitempty"`
|
||||
Value string `xml:",chardata"`
|
||||
DeviceID string
|
||||
Enabled bool
|
||||
Format string // public-facing values: "12", "24", "auto"
|
||||
Brightness int
|
||||
AutoDim bool // not on the device's wire format; preserved for API compat
|
||||
TimeZone string
|
||||
Value string // kept for API compat — older fixtures stored chardata here
|
||||
}
|
||||
|
||||
// Wire constants for clockConfig/@timeFormat.
|
||||
const (
|
||||
wireTimeFormat12Hour = "TIME_FORMAT_12HOUR_ID"
|
||||
wireTimeFormat24Hour = "TIME_FORMAT_24HOUR_ID"
|
||||
wireTimeFormatAuto = "TIME_FORMAT_AUTO_ID"
|
||||
)
|
||||
|
||||
func mapToWireFormat(f string) string {
|
||||
switch strings.ToLower(f) {
|
||||
case "12":
|
||||
return wireTimeFormat12Hour
|
||||
case "24":
|
||||
return wireTimeFormat24Hour
|
||||
case "auto":
|
||||
return wireTimeFormatAuto
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func mapFromWireFormat(wire string) string {
|
||||
switch wire {
|
||||
case wireTimeFormat12Hour:
|
||||
return "12"
|
||||
case wireTimeFormat24Hour:
|
||||
return "24"
|
||||
case wireTimeFormatAuto:
|
||||
return "auto"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// UnmarshalXML decodes the nested <clockDisplay><clockConfig …/></clockDisplay>
|
||||
// into ClockDisplay's flat fields. Tolerates the older flat shape too —
|
||||
// either because it appears in legacy captures or for forward-compat with
|
||||
// firmwares that may revert.
|
||||
func (c *ClockDisplay) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
applyClockDisplayOuterAttrs(c, start.Attr)
|
||||
|
||||
for {
|
||||
tok, err := d.Token()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
if t.Name.Local == "clockConfig" {
|
||||
applyClockConfigAttrs(c, t.Attr)
|
||||
}
|
||||
|
||||
if err := d.Skip(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
case xml.CharData:
|
||||
if text := strings.TrimSpace(string(t)); text != "" {
|
||||
c.Value = text
|
||||
}
|
||||
|
||||
case xml.EndElement:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyClockDisplayOuterAttrs handles the legacy flat-attribute format
|
||||
// (deviceID, enabled, format, brightness, autoDim, timeZone) that older
|
||||
// fixtures used directly on the <clockDisplay> element.
|
||||
func applyClockDisplayOuterAttrs(c *ClockDisplay, attrs []xml.Attr) {
|
||||
for _, attr := range attrs {
|
||||
switch attr.Name.Local {
|
||||
case "deviceID":
|
||||
c.DeviceID = attr.Value
|
||||
case "enabled":
|
||||
c.Enabled = attr.Value == "true"
|
||||
case "format":
|
||||
c.Format = attr.Value
|
||||
case "brightness":
|
||||
c.Brightness, _ = strconv.Atoi(attr.Value)
|
||||
case "autoDim":
|
||||
c.AutoDim = attr.Value == "true"
|
||||
case "timeZone":
|
||||
c.TimeZone = attr.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// applyClockConfigAttrs handles the nested <clockConfig> attributes
|
||||
// (timezoneInfo, userEnable, timeFormat, brightnessLevel) — the shape
|
||||
// FW 27 emits and accepts.
|
||||
func applyClockConfigAttrs(c *ClockDisplay, attrs []xml.Attr) {
|
||||
for _, attr := range attrs {
|
||||
switch attr.Name.Local {
|
||||
case "timezoneInfo":
|
||||
c.TimeZone = attr.Value
|
||||
case "userEnable":
|
||||
c.Enabled = attr.Value == "true"
|
||||
case "timeFormat":
|
||||
if mapped := mapFromWireFormat(attr.Value); mapped != "" {
|
||||
c.Format = mapped
|
||||
}
|
||||
case "brightnessLevel":
|
||||
c.Brightness, _ = strconv.Atoi(attr.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ClockFormat represents supported clock display formats
|
||||
@@ -108,14 +243,16 @@ func (c *ClockDisplay) IsEmpty() bool {
|
||||
return !c.Enabled && c.Format == "" && c.Brightness == 0 && c.TimeZone == ""
|
||||
}
|
||||
|
||||
// ClockDisplayRequest represents a request to configure clock display settings
|
||||
// ClockDisplayRequest represents a request to configure clock display
|
||||
// settings. Fields use the same public names as the response struct;
|
||||
// MarshalXML produces the nested wire format the device requires.
|
||||
type ClockDisplayRequest struct {
|
||||
XMLName xml.Name `xml:"clockDisplay"`
|
||||
Enabled *bool `xml:"enabled,attr,omitempty"`
|
||||
Format string `xml:"format,attr,omitempty"`
|
||||
Brightness *int `xml:"brightness,attr,omitempty"`
|
||||
AutoDim *bool `xml:"autoDim,attr,omitempty"`
|
||||
TimeZone string `xml:"timeZone,attr,omitempty"`
|
||||
Enabled *bool
|
||||
Format string
|
||||
Brightness *int
|
||||
AutoDim *bool
|
||||
TimeZone string
|
||||
}
|
||||
|
||||
// NewClockDisplayRequest creates a new clock display configuration request
|
||||
@@ -184,3 +321,62 @@ func (r *ClockDisplayRequest) Validate() error {
|
||||
func (r *ClockDisplayRequest) HasChanges() bool {
|
||||
return r.Enabled != nil || r.Format != "" || r.Brightness != nil || r.AutoDim != nil || r.TimeZone != ""
|
||||
}
|
||||
|
||||
// MarshalXML emits the nested <clockDisplay><clockConfig …/></clockDisplay>
|
||||
// envelope the device accepts. Empty fields are omitted so partial updates
|
||||
// (e.g. "set only the timezone") don't accidentally clear other settings.
|
||||
//
|
||||
// AutoDim has no counterpart in the captured wire format; we still accept
|
||||
// it in the public API for backward-compat but it is not emitted.
|
||||
func (r ClockDisplayRequest) MarshalXML(e *xml.Encoder, _ xml.StartElement) error {
|
||||
display := xml.StartElement{Name: xml.Name{Local: "clockDisplay"}}
|
||||
if err := e.EncodeToken(display); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg := xml.StartElement{Name: xml.Name{Local: "clockConfig"}}
|
||||
|
||||
if r.TimeZone != "" {
|
||||
cfg.Attr = append(cfg.Attr, xml.Attr{
|
||||
Name: xml.Name{Local: "timezoneInfo"},
|
||||
Value: r.TimeZone,
|
||||
})
|
||||
}
|
||||
|
||||
if r.Enabled != nil {
|
||||
cfg.Attr = append(cfg.Attr, xml.Attr{
|
||||
Name: xml.Name{Local: "userEnable"},
|
||||
Value: strconv.FormatBool(*r.Enabled),
|
||||
})
|
||||
}
|
||||
|
||||
if r.Format != "" {
|
||||
if wire := mapToWireFormat(r.Format); wire != "" {
|
||||
cfg.Attr = append(cfg.Attr, xml.Attr{
|
||||
Name: xml.Name{Local: "timeFormat"},
|
||||
Value: wire,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if r.Brightness != nil {
|
||||
cfg.Attr = append(cfg.Attr, xml.Attr{
|
||||
Name: xml.Name{Local: "brightnessLevel"},
|
||||
Value: strconv.Itoa(*r.Brightness),
|
||||
})
|
||||
}
|
||||
|
||||
if err := e.EncodeToken(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := e.EncodeToken(xml.EndElement{Name: cfg.Name}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := e.EncodeToken(xml.EndElement{Name: display.Name}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.Flush()
|
||||
}
|
||||
|
||||
@@ -641,7 +641,7 @@ func TestClockDisplayRequest_MarshalXML(t *testing.T) {
|
||||
Enabled: &[]bool{true}[0],
|
||||
Format: "24",
|
||||
Brightness: &[]int{75}[0],
|
||||
AutoDim: &[]bool{false}[0],
|
||||
AutoDim: &[]bool{false}[0], // not on the wire format — must be silently dropped
|
||||
TimeZone: "America/New_York",
|
||||
}
|
||||
|
||||
@@ -650,8 +650,57 @@ func TestClockDisplayRequest_MarshalXML(t *testing.T) {
|
||||
t.Fatalf("Failed to marshal XML: %v", err)
|
||||
}
|
||||
|
||||
expected := `<clockDisplay enabled="true" format="24" brightness="75" autoDim="false" timeZone="America/New_York"></clockDisplay>`
|
||||
// Must match the device's captured POST shape — firmware 27 rejects
|
||||
// the legacy flat <clockDisplay enabled="…" format="…" .../> with
|
||||
// "Error parsing request".
|
||||
expected := `<clockDisplay><clockConfig timezoneInfo="America/New_York" userEnable="true" timeFormat="TIME_FORMAT_24HOUR_ID" brightnessLevel="75"></clockConfig></clockDisplay>`
|
||||
if string(data) != expected {
|
||||
t.Errorf("Expected XML %q, got %q", expected, string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClockDisplayRequest_MarshalXML_TimezoneOnly(t *testing.T) {
|
||||
// Partial update: only set the timezone. Unset fields must be
|
||||
// omitted so we don't clobber the device's other settings.
|
||||
request := ClockDisplayRequest{TimeZone: "Europe/Berlin"}
|
||||
|
||||
data, err := xml.Marshal(request)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal XML: %v", err)
|
||||
}
|
||||
|
||||
expected := `<clockDisplay><clockConfig timezoneInfo="Europe/Berlin"></clockConfig></clockDisplay>`
|
||||
if string(data) != expected {
|
||||
t.Errorf("Expected XML %q, got %q", expected, string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClockDisplay_UnmarshalXML_NestedClockConfig(t *testing.T) {
|
||||
// The real wire format — what firmware-27 devices emit and accept.
|
||||
xmlData := `<clockDisplay deviceID="A81B6A536A98"><clockConfig timezoneInfo="Europe/Berlin" userEnable="true" timeFormat="TIME_FORMAT_24HOUR_ID" userOffsetMinute="0" brightnessLevel="70" userUtcTime="0"/></clockDisplay>`
|
||||
|
||||
var got ClockDisplay
|
||||
if err := xml.Unmarshal([]byte(xmlData), &got); err != nil {
|
||||
t.Fatalf("Failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if got.DeviceID != "A81B6A536A98" {
|
||||
t.Errorf("DeviceID = %q, want A81B6A536A98", got.DeviceID)
|
||||
}
|
||||
|
||||
if got.TimeZone != "Europe/Berlin" {
|
||||
t.Errorf("TimeZone = %q, want Europe/Berlin", got.TimeZone)
|
||||
}
|
||||
|
||||
if !got.Enabled {
|
||||
t.Error("Enabled = false, want true (from userEnable=true)")
|
||||
}
|
||||
|
||||
if got.Format != "24" {
|
||||
t.Errorf("Format = %q, want 24 (from timeFormat=TIME_FORMAT_24HOUR_ID)", got.Format)
|
||||
}
|
||||
|
||||
if got.Brightness != 70 {
|
||||
t.Errorf("Brightness = %d, want 70 (from brightnessLevel)", got.Brightness)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,44 +182,44 @@ func (c *ClockTime) SetUTC(utc int64) {
|
||||
}
|
||||
}
|
||||
|
||||
// ClockTimeRequest represents a request to set the device time
|
||||
// ClockTimeRequest represents a request to set the device time.
|
||||
//
|
||||
// The POST body mirrors the device's GET /clockTime response shape —
|
||||
// firmware 27 expects `utcTime` as the attribute name, not `utc`, and
|
||||
// rejects any chardata or zone attribute with "Error parsing request"
|
||||
// (confirmed against ST10/ST20/ST30 in live testing 2026-05-12).
|
||||
//
|
||||
// We deliberately do NOT send TimeFormat / Brightness in the request:
|
||||
// those belong to /clockDisplay and including them here either gets
|
||||
// ignored or rejected depending on firmware revision.
|
||||
type ClockTimeRequest struct {
|
||||
XMLName xml.Name `xml:"clockTime"`
|
||||
Zone string `xml:"zone,attr,omitempty"`
|
||||
UTC int64 `xml:"utc,attr,omitempty"`
|
||||
Value string `xml:",chardata"`
|
||||
UTCTime int64 `xml:"utcTime,attr"`
|
||||
}
|
||||
|
||||
// NewClockTimeRequest creates a new clock time request from a time.Time
|
||||
// NewClockTimeRequest creates a new clock time request from a time.Time.
|
||||
// The input may be in any zone — we always send Unix-seconds, which the
|
||||
// device interprets as UTC and renders according to its own clockDisplay
|
||||
// configuration.
|
||||
func NewClockTimeRequest(t time.Time) *ClockTimeRequest {
|
||||
return &ClockTimeRequest{
|
||||
Zone: t.Location().String(),
|
||||
UTC: t.Unix(),
|
||||
Value: t.UTC().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
return &ClockTimeRequest{UTCTime: t.Unix()}
|
||||
}
|
||||
|
||||
// NewClockTimeRequestUTC creates a new clock time request from UTC timestamp
|
||||
// NewClockTimeRequestUTC creates a new clock time request from a Unix
|
||||
// timestamp in seconds.
|
||||
func NewClockTimeRequestUTC(utc int64) *ClockTimeRequest {
|
||||
t := time.Unix(utc, 0).UTC()
|
||||
|
||||
return &ClockTimeRequest{
|
||||
UTC: utc,
|
||||
Value: t.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
return &ClockTimeRequest{UTCTime: utc}
|
||||
}
|
||||
|
||||
// Validate checks if the clock time request is valid
|
||||
// Validate checks if the clock time request is valid.
|
||||
func (r *ClockTimeRequest) Validate() error {
|
||||
if r.UTC <= 0 && r.Value == "" {
|
||||
return fmt.Errorf("either UTC timestamp or time value must be provided")
|
||||
if r.UTCTime <= 0 {
|
||||
return fmt.Errorf("UTC timestamp must be provided")
|
||||
}
|
||||
|
||||
if r.UTC > 0 {
|
||||
// Validate UTC timestamp is reasonable (after year 2000, before year 2100)
|
||||
if r.UTC < 946684800 || r.UTC > 4102444800 {
|
||||
return fmt.Errorf("UTC timestamp %d is outside reasonable range", r.UTC)
|
||||
}
|
||||
// Plausibility window: after year 2000, before year 2100.
|
||||
if r.UTCTime < 946684800 || r.UTCTime > 4102444800 {
|
||||
return fmt.Errorf("UTC timestamp %d is outside reasonable range", r.UTCTime)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -284,16 +284,8 @@ func TestNewClockTimeRequest(t *testing.T) {
|
||||
|
||||
request := NewClockTimeRequest(testTime)
|
||||
|
||||
if request.UTC != testTime.Unix() {
|
||||
t.Errorf("Expected UTC %d, got %d", testTime.Unix(), request.UTC)
|
||||
}
|
||||
|
||||
if request.Value != "2021-01-01 12:00:00" {
|
||||
t.Errorf("Expected Value %q, got %q", "2021-01-01 12:00:00", request.Value)
|
||||
}
|
||||
|
||||
if request.Zone != "UTC" {
|
||||
t.Errorf("Expected Zone %q, got %q", "UTC", request.Zone)
|
||||
if request.UTCTime != testTime.Unix() {
|
||||
t.Errorf("Expected UTCTime %d, got %d", testTime.Unix(), request.UTCTime)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,13 +294,8 @@ func TestNewClockTimeRequestUTC(t *testing.T) {
|
||||
|
||||
request := NewClockTimeRequestUTC(utcTimestamp)
|
||||
|
||||
if request.UTC != utcTimestamp {
|
||||
t.Errorf("Expected UTC %d, got %d", utcTimestamp, request.UTC)
|
||||
}
|
||||
|
||||
expectedValue := time.Unix(utcTimestamp, 0).UTC().Format("2006-01-02 15:04:05")
|
||||
if request.Value != expectedValue {
|
||||
t.Errorf("Expected Value %q, got %q", expectedValue, request.Value)
|
||||
if request.UTCTime != utcTimestamp {
|
||||
t.Errorf("Expected UTCTime %d, got %d", utcTimestamp, request.UTCTime)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,25 +306,8 @@ func TestClockTimeRequest_Validate(t *testing.T) {
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Valid UTC request",
|
||||
request: ClockTimeRequest{
|
||||
UTC: 1609459200,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Valid value request",
|
||||
request: ClockTimeRequest{
|
||||
Value: "2021-01-01 12:00:00",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Valid request with both",
|
||||
request: ClockTimeRequest{
|
||||
UTC: 1609459200,
|
||||
Value: "2021-01-01 12:00:00",
|
||||
},
|
||||
name: "Valid UTC request",
|
||||
request: ClockTimeRequest{UTCTime: 1609459200},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
@@ -346,17 +316,13 @@ func TestClockTimeRequest_Validate(t *testing.T) {
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "UTC too old",
|
||||
request: ClockTimeRequest{
|
||||
UTC: 946684799, // Before year 2000
|
||||
},
|
||||
name: "UTC too old",
|
||||
request: ClockTimeRequest{UTCTime: 946684799}, // Before year 2000
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "UTC too far in future",
|
||||
request: ClockTimeRequest{
|
||||
UTC: 4102444801, // After year 2100
|
||||
},
|
||||
name: "UTC too far in future",
|
||||
request: ClockTimeRequest{UTCTime: 4102444801}, // After year 2100
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
@@ -377,18 +343,16 @@ func TestClockTimeRequest_Validate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestClockTimeRequest_MarshalXML(t *testing.T) {
|
||||
request := ClockTimeRequest{
|
||||
Zone: "UTC",
|
||||
UTC: 1609459200,
|
||||
Value: "2021-01-01 00:00:00",
|
||||
}
|
||||
request := ClockTimeRequest{UTCTime: 1609459200}
|
||||
|
||||
data, err := xml.Marshal(request)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal XML: %v", err)
|
||||
}
|
||||
|
||||
expected := `<clockTime zone="UTC" utc="1609459200">2021-01-01 00:00:00</clockTime>`
|
||||
// Must match the device's GET /clockTime response attribute name
|
||||
// — firmware 27 rejects `utc=` (no Time suffix) with "Error parsing request".
|
||||
expected := `<clockTime utcTime="1609459200"></clockTime>`
|
||||
if string(data) != expected {
|
||||
t.Errorf("Expected XML %q, got %q", expected, string(data))
|
||||
}
|
||||
|
||||
@@ -10,6 +10,15 @@ type Group struct {
|
||||
MasterDeviceID string `xml:"masterDeviceId"`
|
||||
Roles GroupRoles `xml:"roles"`
|
||||
SenderIPAddress string `xml:"senderIPAddress,omitempty"`
|
||||
// Status is populated by the device on GET /group (e.g. "GROUP_OK")
|
||||
// and omitted from requests we send back.
|
||||
Status string `xml:"status,omitempty"`
|
||||
}
|
||||
|
||||
// IsEmpty reports whether the device returned an empty <group/> element,
|
||||
// which is the speaker's way of saying "no stereo pair configured".
|
||||
func (g *Group) IsEmpty() bool {
|
||||
return g.ID == "" && g.MasterDeviceID == "" && len(g.Roles.Roles) == 0
|
||||
}
|
||||
|
||||
// GroupRoles contains the role assignments for devices in a group.
|
||||
|
||||
@@ -597,6 +597,16 @@ type ServiceDeviceInfo struct {
|
||||
DiscoveryMethod string `json:"discovery_method,omitempty"`
|
||||
AccountID string `json:"account_id,omitempty"`
|
||||
Components []ServiceComponent `json:"components,omitempty" xml:"-"`
|
||||
// CreatedOn is the ISO8601 timestamp the device was first
|
||||
// registered against the account. Preserved across renames so
|
||||
// AfterTouch's PUT response matches real Bose's "first paired
|
||||
// in 2017" semantics rather than rewriting `now()` on every
|
||||
// update. Empty for never-persisted records.
|
||||
CreatedOn string `json:"created_on,omitempty" xml:"-"`
|
||||
// UpdatedOn is the ISO8601 timestamp of the most recent change
|
||||
// to the device record (rename, IP refresh, …). Refreshed by
|
||||
// every SaveDeviceInfo write that mutates a known device.
|
||||
UpdatedOn string `json:"updated_on,omitempty" xml:"-"`
|
||||
}
|
||||
|
||||
// ServiceComponent represents a hardware or software component of a device.
|
||||
|
||||
@@ -21,6 +21,10 @@ const (
|
||||
EventTypePresetUpdated WebSocketEventType = "presetsUpdated"
|
||||
// EventTypeZoneUpdated indicates a zone configuration change
|
||||
EventTypeZoneUpdated WebSocketEventType = "zoneUpdated"
|
||||
// EventTypeGroupUpdated is emitted to both ROLE devices when an ST-10
|
||||
// stereo pair is created, renamed, or removed via /addGroup,
|
||||
// /updateGroup, or /removeGroup.
|
||||
EventTypeGroupUpdated WebSocketEventType = "groupUpdated"
|
||||
// EventTypeBassUpdated indicates a bass level change
|
||||
EventTypeBassUpdated WebSocketEventType = "bassUpdated"
|
||||
// EventTypeClockTimeUpdated indicates a clock time change
|
||||
@@ -56,6 +60,8 @@ func (e WebSocketEventType) String() string {
|
||||
return "Preset Updated"
|
||||
case EventTypeZoneUpdated:
|
||||
return "Zone Updated"
|
||||
case EventTypeGroupUpdated:
|
||||
return "Stereo Pair Updated"
|
||||
case EventTypeBassUpdated:
|
||||
return "Bass Updated"
|
||||
case EventTypeClockTimeUpdated:
|
||||
@@ -88,6 +94,7 @@ type WebSocketEvent struct {
|
||||
ConnectionStateUpdated *ConnectionStateUpdatedEvent `xml:"connectionStateUpdated,omitempty"`
|
||||
PresetUpdated *PresetUpdatedEvent `xml:"presetsUpdated,omitempty"`
|
||||
ZoneUpdated *ZoneUpdatedEvent `xml:"zoneUpdated,omitempty"`
|
||||
GroupUpdated *GroupUpdatedEvent `xml:"groupUpdated,omitempty"`
|
||||
BassUpdated *BassUpdatedEvent `xml:"bassUpdated,omitempty"`
|
||||
ClockTimeUpdated *ClockTimeUpdatedEvent `xml:"clockTimeUpdated,omitempty"`
|
||||
ClockDisplayUpdated *ClockDisplayUpdatedEvent `xml:"clockDisplayUpdated,omitempty"`
|
||||
@@ -122,6 +129,10 @@ func (e *WebSocketEvent) GetEvents() []interface{} {
|
||||
events = append(events, e.ZoneUpdated)
|
||||
}
|
||||
|
||||
if e.GroupUpdated != nil {
|
||||
events = append(events, e.GroupUpdated)
|
||||
}
|
||||
|
||||
if e.BassUpdated != nil {
|
||||
events = append(events, e.BassUpdated)
|
||||
}
|
||||
@@ -215,6 +226,16 @@ type ZoneUpdatedEvent struct {
|
||||
Zone Zone `xml:"zone"`
|
||||
}
|
||||
|
||||
// GroupUpdatedEvent represents an ST-10 stereo-pair update notification.
|
||||
// The device fans this event out to both LEFT and RIGHT speakers whenever
|
||||
// the pair is created, renamed, or removed. Group will be the zero value
|
||||
// for a teardown notification — see (*Group).IsEmpty.
|
||||
type GroupUpdatedEvent struct {
|
||||
XMLName xml.Name `xml:"groupUpdated"`
|
||||
DeviceID string `xml:"deviceID,attr"`
|
||||
Group Group `xml:"group"`
|
||||
}
|
||||
|
||||
// Zone represents multiroom zone information
|
||||
type Zone struct {
|
||||
XMLName xml.Name `xml:"zone"`
|
||||
@@ -373,6 +394,7 @@ type WebSocketEventHandlers struct {
|
||||
OnConnectionState TypedEventHandler[*ConnectionStateUpdatedEvent]
|
||||
OnPresetUpdated TypedEventHandler[*PresetUpdatedEvent]
|
||||
OnZoneUpdated TypedEventHandler[*ZoneUpdatedEvent]
|
||||
OnGroupUpdated TypedEventHandler[*GroupUpdatedEvent]
|
||||
OnBassUpdated TypedEventHandler[*BassUpdatedEvent]
|
||||
OnClockTimeUpdated TypedEventHandler[*ClockTimeUpdatedEvent]
|
||||
OnClockDisplayUpdated TypedEventHandler[*ClockDisplayUpdatedEvent]
|
||||
@@ -382,8 +404,19 @@ type WebSocketEventHandlers struct {
|
||||
OnLanguageUpdated TypedEventHandler[*LanguageUpdatedEvent]
|
||||
OnUnknownEvent EventHandler
|
||||
OnSpecialMessage SpecialMessageHandler
|
||||
// OnRawMessage fires for every received frame before any parsing
|
||||
// happens. Use it for debug/observability tooling that wants to see
|
||||
// exactly what the device sent on the wire — the typed handlers
|
||||
// above still run afterwards, independently. parseErr is the result
|
||||
// of the XML parse: nil for messages that decoded cleanly, non-nil
|
||||
// for malformed payloads. The slice is owned by the caller; copy
|
||||
// before retaining.
|
||||
OnRawMessage RawMessageHandler
|
||||
}
|
||||
|
||||
// RawMessageHandler defines the signature for raw-frame handlers.
|
||||
type RawMessageHandler func(data []byte, parseErr error)
|
||||
|
||||
// ParseWebSocketEvent attempts to parse a WebSocket message into a specific event type
|
||||
func ParseWebSocketEvent(data []byte) (*WebSocketEvent, error) {
|
||||
var event WebSocketEvent
|
||||
@@ -411,6 +444,8 @@ func (e *WebSocketEvent) getFieldByEventType(eventType WebSocketEventType) inter
|
||||
field = e.PresetUpdated
|
||||
case EventTypeZoneUpdated:
|
||||
field = e.ZoneUpdated
|
||||
case EventTypeGroupUpdated:
|
||||
field = e.GroupUpdated
|
||||
case EventTypeBassUpdated:
|
||||
field = e.BassUpdated
|
||||
case EventTypeClockTimeUpdated:
|
||||
@@ -462,6 +497,8 @@ func isNil(i interface{}) bool {
|
||||
return v == nil
|
||||
case *ZoneUpdatedEvent:
|
||||
return v == nil
|
||||
case *GroupUpdatedEvent:
|
||||
return v == nil
|
||||
case *BassUpdatedEvent:
|
||||
return v == nil
|
||||
case *ClockTimeUpdatedEvent:
|
||||
@@ -508,6 +545,8 @@ func (e *WebSocketEvent) HasEventType(eventType WebSocketEventType) bool {
|
||||
return e.PresetUpdated != nil
|
||||
case EventTypeZoneUpdated:
|
||||
return e.ZoneUpdated != nil
|
||||
case EventTypeGroupUpdated:
|
||||
return e.GroupUpdated != nil
|
||||
case EventTypeBassUpdated:
|
||||
return e.BassUpdated != nil
|
||||
case EventTypeClockTimeUpdated:
|
||||
@@ -551,6 +590,10 @@ func (e *WebSocketEvent) GetEventTypes() []WebSocketEventType {
|
||||
types = append(types, EventTypeZoneUpdated)
|
||||
}
|
||||
|
||||
if e.GroupUpdated != nil {
|
||||
types = append(types, EventTypeGroupUpdated)
|
||||
}
|
||||
|
||||
if e.BassUpdated != nil {
|
||||
types = append(types, EventTypeBassUpdated)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ func TestWebSocketEventType_String(t *testing.T) {
|
||||
{"ConnectionState", EventTypeConnectionState, "Connection State Updated"},
|
||||
{"PresetUpdated", EventTypePresetUpdated, "Preset Updated"},
|
||||
{"ZoneUpdated", EventTypeZoneUpdated, "Zone Updated"},
|
||||
{"GroupUpdated", EventTypeGroupUpdated, "Stereo Pair Updated"},
|
||||
{"BassUpdated", EventTypeBassUpdated, "Bass Updated"},
|
||||
{"ClockTimeUpdated", EventTypeClockTimeUpdated, "Clock Time Updated"},
|
||||
{"ClockDisplayUpdated", EventTypeClockDisplayUpdated, "Clock Display Updated"},
|
||||
@@ -187,6 +188,89 @@ func TestParseWebSocketEvent(t *testing.T) {
|
||||
t.Error("Expected error for invalid XML, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidGroupUpdatedEvent", func(t *testing.T) {
|
||||
// The device fans this out to both ROLE devices when a stereo
|
||||
// pair is created via POST /addGroup.
|
||||
xmlData := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<updates deviceID="9070658C9D4A">
|
||||
<groupUpdated deviceID="9070658C9D4A">
|
||||
<group id="1234567">
|
||||
<name>Living Room Pair</name>
|
||||
<masterDeviceId>9070658C9D4A</masterDeviceId>
|
||||
<roles>
|
||||
<groupRole>
|
||||
<deviceId>9070658C9D4A</deviceId>
|
||||
<role>LEFT</role>
|
||||
<ipAddress>192.168.1.131</ipAddress>
|
||||
</groupRole>
|
||||
<groupRole>
|
||||
<deviceId>F45EAB3115DA</deviceId>
|
||||
<role>RIGHT</role>
|
||||
<ipAddress>192.168.1.134</ipAddress>
|
||||
</groupRole>
|
||||
</roles>
|
||||
<status>GROUP_OK</status>
|
||||
</group>
|
||||
</groupUpdated>
|
||||
</updates>`
|
||||
|
||||
event, err := ParseWebSocketEvent([]byte(xmlData))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseWebSocketEvent: %v", err)
|
||||
}
|
||||
|
||||
if !event.HasEventType(EventTypeGroupUpdated) {
|
||||
t.Fatal("HasEventType(EventTypeGroupUpdated) = false, want true")
|
||||
}
|
||||
|
||||
if event.GroupUpdated == nil {
|
||||
t.Fatal("GroupUpdated is nil")
|
||||
}
|
||||
|
||||
g := event.GroupUpdated.Group
|
||||
|
||||
if g.ID != "1234567" {
|
||||
t.Errorf("group ID = %q, want 1234567", g.ID)
|
||||
}
|
||||
|
||||
if g.MasterDeviceID != "9070658C9D4A" {
|
||||
t.Errorf("MasterDeviceID = %q", g.MasterDeviceID)
|
||||
}
|
||||
|
||||
if len(g.Roles.Roles) != 2 || g.Roles.Roles[0].Role != "LEFT" || g.Roles.Roles[1].Role != "RIGHT" {
|
||||
t.Errorf("roles not parsed as LEFT/RIGHT: %+v", g.Roles.Roles)
|
||||
}
|
||||
|
||||
if g.Status != "GROUP_OK" {
|
||||
t.Errorf("status = %q, want GROUP_OK", g.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GroupUpdatedTeardown", func(t *testing.T) {
|
||||
// On /removeGroup, the device emits a groupUpdated with an empty
|
||||
// <group/> body. Parsing must surface that as IsEmpty=true so the
|
||||
// UI can render "pair dissolved" cleanly.
|
||||
xmlData := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<updates deviceID="9070658C9D4A">
|
||||
<groupUpdated deviceID="9070658C9D4A">
|
||||
<group/>
|
||||
</groupUpdated>
|
||||
</updates>`
|
||||
|
||||
event, err := ParseWebSocketEvent([]byte(xmlData))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseWebSocketEvent: %v", err)
|
||||
}
|
||||
|
||||
if event.GroupUpdated == nil {
|
||||
t.Fatal("GroupUpdated is nil")
|
||||
}
|
||||
|
||||
if !event.GroupUpdated.Group.IsEmpty() {
|
||||
t.Errorf("Group.IsEmpty() = false on teardown; got %+v", event.GroupUpdated.Group)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebSocketEvent_HasEventType(t *testing.T) {
|
||||
|
||||
@@ -2,6 +2,10 @@ package amazon
|
||||
|
||||
import "github.com/gesellix/bose-soundtouch/pkg/service/zeroconf"
|
||||
|
||||
// ErrAddUserNoOp re-exports zeroconf.ErrAddUserNoOp for callers that don't
|
||||
// want a direct dependency on the zeroconf package.
|
||||
var ErrAddUserNoOp = zeroconf.ErrAddUserNoOp
|
||||
|
||||
// PushAmazonCredentials pushes Amazon Music credentials to a speaker using the
|
||||
// ZeroConf DH key exchange protocol. Falls back to simplified token push if
|
||||
// the speaker does not support DH (older firmware).
|
||||
|
||||
@@ -20,11 +20,35 @@ import (
|
||||
// TuneIn endpoint templates used to resolve station and stream URLs.
|
||||
const (
|
||||
TuneInDescribe = "https://opml.radiotime.com/describe.ashx?id=%s"
|
||||
TuneInStream = "http://opml.radiotime.com/Tune.ashx?id=%s&formats=mp3,aac,ogg"
|
||||
TuneInNavigateAshx = "http://opml.radiotime.com/?render=json"
|
||||
TuneInSearchAPI = "https://api.radiotime.com/profiles?fulltextsearch=true&version=1.3&query="
|
||||
|
||||
// DefaultTuneInStreamFormats is the comma-separated format list
|
||||
// AfterTouch sends to TuneIn's Tune.ashx by default. Matches the
|
||||
// pre-2026-05-10 behaviour from before PR #249 added "hls"
|
||||
// unconditionally — HLS playback is broken on SoundTouch 10/
|
||||
// firmware 27 (and probably the rest of the line; see #292).
|
||||
// Speakers receive an .m3u8 playlist URL they can't parse, blink
|
||||
// amber, fall silent. Operators with HLS-compatible speakers can
|
||||
// override via Settings.TuneInStreamFormats.
|
||||
DefaultTuneInStreamFormats = "mp3,aac,ogg"
|
||||
)
|
||||
|
||||
// TuneInStream returns the formatted Tune.ashx URL for a station or
|
||||
// podcast. The formats argument controls the formats= query parameter;
|
||||
// empty falls back to DefaultTuneInStreamFormats. Operators can set
|
||||
// arbitrary lists (e.g. "mp3,aac,ogg,hls" to re-enable HLS, or
|
||||
// "aac" to force a single format) via Settings.TuneInStreamFormats.
|
||||
// The value is passed through verbatim — no token-level validation.
|
||||
func TuneInStream(stationID, formats string) string {
|
||||
formats = strings.TrimSpace(formats)
|
||||
if formats == "" {
|
||||
formats = DefaultTuneInStreamFormats
|
||||
}
|
||||
|
||||
return fmt.Sprintf("http://opml.radiotime.com/Tune.ashx?id=%s&formats=%s", stationID, formats)
|
||||
}
|
||||
|
||||
var tuneInClient = &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
// allowedTuneInHosts restricts outbound fetches to known TuneIn domains.
|
||||
@@ -555,8 +579,10 @@ func TuneInNavigateProfile(encodedURI string) (*models.BmxNavResponse, error) {
|
||||
}
|
||||
|
||||
// TuneInPlayback resolves a live radio station and returns a Bose-compatible
|
||||
// playback response with primary stream and variants.
|
||||
func TuneInPlayback(stationID string) (*models.BmxPlaybackResponse, error) {
|
||||
// playback response with primary stream and variants. formats is the
|
||||
// comma-separated list passed to Tune.ashx?formats=… ; empty falls back to
|
||||
// DefaultTuneInStreamFormats (the SoundTouch-line-compatible shape).
|
||||
func TuneInPlayback(stationID, formats string) (*models.BmxPlaybackResponse, error) {
|
||||
describeURL := fmt.Sprintf(TuneInDescribe, stationID)
|
||||
|
||||
resp, err := http.Get(describeURL)
|
||||
@@ -588,7 +614,7 @@ func TuneInPlayback(stationID string) (*models.BmxPlaybackResponse, error) {
|
||||
|
||||
station := opml.Body.Outline.Station
|
||||
|
||||
streamReq := fmt.Sprintf(TuneInStream, stationID)
|
||||
streamReq := TuneInStream(stationID, formats)
|
||||
|
||||
streamResp, err := http.Get(streamReq)
|
||||
if err != nil {
|
||||
@@ -697,8 +723,9 @@ func TuneInPodcastInfo(podcastID, encodedName string) (*models.BmxPodcastInfoRes
|
||||
}
|
||||
|
||||
// TuneInPlaybackPodcast resolves an on-demand podcast episode and returns
|
||||
// a playback response suitable for SoundTouch devices.
|
||||
func TuneInPlaybackPodcast(podcastID string) (*models.BmxPlaybackResponse, error) {
|
||||
// a playback response suitable for SoundTouch devices. formats has the
|
||||
// same semantics as in TuneInPlayback.
|
||||
func TuneInPlaybackPodcast(podcastID, formats string) (*models.BmxPlaybackResponse, error) {
|
||||
describeURL := fmt.Sprintf(TuneInDescribe, podcastID)
|
||||
|
||||
resp, err := http.Get(describeURL)
|
||||
@@ -733,7 +760,7 @@ func TuneInPlaybackPodcast(podcastID string) (*models.BmxPlaybackResponse, error
|
||||
|
||||
topic := opml.Body.Outline.Topic
|
||||
|
||||
streamReq := fmt.Sprintf(TuneInStream, podcastID)
|
||||
streamReq := TuneInStream(podcastID, formats)
|
||||
|
||||
streamResp, err := http.Get(streamReq)
|
||||
if err != nil {
|
||||
|
||||
@@ -221,3 +221,52 @@ func TestTuneInPodcastInfo_Base64(t *testing.T) {
|
||||
t.Errorf("Expected name %s, got %s", name, resp.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTuneInStream_EmptyFormatsUsesDefault pins the post-#292 contract:
|
||||
// AfterTouch must NOT request HLS streams from TuneIn unless the
|
||||
// operator has explicitly opted in. The default request shape is
|
||||
// "mp3,aac,ogg" — matches pre-2026-05-10 behaviour and works on
|
||||
// every SoundTouch model verified. PR #249 had added "hls"
|
||||
// unconditionally; that regressed playback on ST10/firmware 27 (the
|
||||
// speaker can't parse the .m3u8 playlist TuneIn returns when HLS is
|
||||
// in the format list).
|
||||
func TestTuneInStream_EmptyFormatsUsesDefault(t *testing.T) {
|
||||
got := TuneInStream("s33828", "")
|
||||
|
||||
if strings.Contains(got, "hls") {
|
||||
t.Errorf("default TuneInStream URL must NOT request HLS; got %s", got)
|
||||
}
|
||||
|
||||
want := "formats=" + DefaultTuneInStreamFormats
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("default TuneInStream URL must request %q; got %s", want, got)
|
||||
}
|
||||
|
||||
if !strings.Contains(got, "id=s33828") {
|
||||
t.Errorf("TuneInStream URL must carry the station ID; got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTuneInStream_OverrideHonoured verifies the opt-in path: when an
|
||||
// operator sets Settings.TuneInStreamFormats to a custom list,
|
||||
// TuneInStream passes it through verbatim. Two sub-cases catch the
|
||||
// common opt-in (re-add hls) and a more drastic override (single
|
||||
// format) so a future regression in the trim/fallback logic surfaces
|
||||
// at compile/test time.
|
||||
func TestTuneInStream_OverrideHonoured(t *testing.T) {
|
||||
cases := []struct {
|
||||
formats string
|
||||
want string
|
||||
}{
|
||||
{"mp3,aac,ogg,hls", "formats=mp3,aac,ogg,hls"}, // opt-in: re-add HLS
|
||||
{"aac", "formats=aac"}, // single format
|
||||
{" mp3 ", "formats=mp3"}, // whitespace stripped
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got := TuneInStream("s33828", tc.formats)
|
||||
if !strings.Contains(got, tc.want) {
|
||||
t.Errorf("TuneInStream(%q) URL must contain %q; got %s", tc.formats, tc.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,11 +299,10 @@ const (
|
||||
RecentsFile = "Recents.xml"
|
||||
SourcesFile = "Sources.xml"
|
||||
|
||||
SpeakerHTTPPort = 8090
|
||||
SpeakerDeviceInfoPath = "/info"
|
||||
SpeakerRecentsPath = "/recents"
|
||||
SpeakerPresetsPath = "/presets"
|
||||
SpeakerSourcesFileLocation = "/mnt/nv/BoseApp-Persistence/1/Sources.xml"
|
||||
// Speaker-protocol constants (HTTP port, paths, on-device file
|
||||
// locations) moved to github.com/gesellix/bose-soundtouch/pkg/speaker
|
||||
// so the client library and CLI can share them without depending on
|
||||
// the service package.
|
||||
|
||||
// DateStr is the hardcoded date used in many Bose XML responses
|
||||
DateStr = "2012-09-19T12:43:00.000+00:00"
|
||||
|
||||
@@ -9,10 +9,6 @@ func TestConstants(t *testing.T) {
|
||||
t.Error("DateStr should not be empty")
|
||||
}
|
||||
|
||||
if SpeakerHTTPPort != 8090 {
|
||||
t.Errorf("Expected SpeakerHTTPPort 8090, got %d", SpeakerHTTPPort)
|
||||
}
|
||||
|
||||
if len(GetProviders()) == 0 {
|
||||
t.Error("Providers should not be empty")
|
||||
}
|
||||
|
||||
@@ -565,6 +565,8 @@ func (ds *DataStore) getDeviceInfoNoLock(account, device string) (*models.Servic
|
||||
MacAddress string `xml:"macAddress"`
|
||||
} `xml:"networkInfo"`
|
||||
DiscoveryMethod string `xml:"discoveryMethod"`
|
||||
CreatedOn string `xml:"createdOn,omitempty"`
|
||||
UpdatedOn string `xml:"updatedOn,omitempty"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(data, &info); err != nil {
|
||||
@@ -577,6 +579,8 @@ func (ds *DataStore) getDeviceInfoNoLock(account, device string) (*models.Servic
|
||||
ProductCode: fmt.Sprintf("%s %s", info.Type, info.ModuleType),
|
||||
Name: info.Name,
|
||||
DiscoveryMethod: info.DiscoveryMethod,
|
||||
CreatedOn: info.CreatedOn,
|
||||
UpdatedOn: info.UpdatedOn,
|
||||
}
|
||||
|
||||
for _, comp := range info.Components {
|
||||
@@ -789,6 +793,8 @@ func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo
|
||||
MacAddress string `xml:"macAddress"`
|
||||
} `xml:"networkInfo"`
|
||||
DiscoveryMethod string `xml:"discoveryMethod"`
|
||||
CreatedOn string `xml:"createdOn,omitempty"`
|
||||
UpdatedOn string `xml:"updatedOn,omitempty"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(data, &info); err != nil {
|
||||
@@ -1192,6 +1198,8 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
|
||||
Components []componentXML `xml:"components>component"`
|
||||
NetworkInfo []NetworkInfoXML `xml:"networkInfo"`
|
||||
DiscoveryMethod string `xml:"discoveryMethod,omitempty"`
|
||||
CreatedOn string `xml:"createdOn,omitempty"`
|
||||
UpdatedOn string `xml:"updatedOn,omitempty"`
|
||||
}
|
||||
|
||||
// Parsing product code back to type and moduleType (best effort)
|
||||
@@ -1203,6 +1211,8 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
|
||||
Type: devType,
|
||||
ModuleType: moduleType,
|
||||
DiscoveryMethod: info.DiscoveryMethod,
|
||||
CreatedOn: info.CreatedOn,
|
||||
UpdatedOn: info.UpdatedOn,
|
||||
}
|
||||
|
||||
if ix.DiscoveryMethod == "" {
|
||||
@@ -1266,6 +1276,21 @@ func (ds *DataStore) mergeWithExistingDeviceInfo(account, device string, info *m
|
||||
if info.DiscoveryMethod == "" {
|
||||
info.DiscoveryMethod = existing.DiscoveryMethod
|
||||
}
|
||||
|
||||
// CreatedOn is set once at first persistence and never re-derived
|
||||
// from inbound data — preserve unconditionally so the
|
||||
// "first-paired" timestamp survives renames, IP refreshes, etc.
|
||||
// UpdatedOn is the opposite: every write that reaches here is by
|
||||
// definition an update, so callers that want it refreshed must
|
||||
// set it explicitly. If they didn't, fall back to the existing
|
||||
// value (better than a regression to empty).
|
||||
if existing.CreatedOn != "" {
|
||||
info.CreatedOn = existing.CreatedOn
|
||||
}
|
||||
|
||||
if info.UpdatedOn == "" {
|
||||
info.UpdatedOn = existing.UpdatedOn
|
||||
}
|
||||
}
|
||||
|
||||
func (ds *DataStore) parseProductCode(productCode string) (string, string) {
|
||||
@@ -2102,6 +2127,19 @@ type Settings struct {
|
||||
// reverse proxy on the same host. Override only if the proxy lives on a
|
||||
// different host within a known-good private subnet.
|
||||
TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs,omitempty"`
|
||||
|
||||
// TuneInStreamFormats overrides the comma-separated format list
|
||||
// AfterTouch sends to TuneIn's Tune.ashx (formats=…). Empty value
|
||||
// uses bmx.DefaultTuneInStreamFormats ("mp3,aac,ogg"), which
|
||||
// matches AfterTouch's pre-2026-05-10 behaviour and plays on
|
||||
// every SoundTouch model verified so far. PR #249 had added
|
||||
// "hls" unconditionally; that regressed playback on the
|
||||
// SoundTouch line (#292 — speaker can't parse the .m3u8 playlist
|
||||
// and blinks amber). Operators with HLS-compatible speakers can
|
||||
// set this to e.g. "mp3,aac,ogg,hls" via settings.json. The value
|
||||
// is passed through verbatim; AfterTouch does not validate the
|
||||
// individual format tokens.
|
||||
TuneInStreamFormats string `json:"tunein_stream_formats,omitempty"`
|
||||
}
|
||||
|
||||
// GetSettings retrieves the global service settings.
|
||||
|
||||
@@ -381,10 +381,28 @@ func TestMACMappingPerformance(t *testing.T) {
|
||||
|
||||
t.Logf(" Total mappings stored: %d (includes normalized versions)", totalMappings)
|
||||
|
||||
// Update is a pure in-memory map write; keep an absolute cap as a backstop
|
||||
// against catastrophic regressions in that hot path.
|
||||
if updateDuration > time.Millisecond*100 {
|
||||
t.Errorf("Update performance too slow: %v", updateDuration)
|
||||
}
|
||||
if lookupDuration > time.Millisecond*70 {
|
||||
t.Errorf("Lookup performance too slow: %v", lookupDuration)
|
||||
|
||||
// Lookup does up to two Stat() syscalls and is dominated by filesystem
|
||||
// latency, which varies wildly on shared CI runners. Compare it to the
|
||||
// in-memory update cost instead of an absolute bound: the ratio captures
|
||||
// "lookup got disproportionately slower" (an algorithmic regression in the
|
||||
// lookup path) while staying stable under uniform host slowdown.
|
||||
if updateDuration <= 0 {
|
||||
t.Fatalf("Update duration is non-positive (%v); cannot compute lookup/update ratio", updateDuration)
|
||||
}
|
||||
|
||||
const maxLookupUpdateRatio = 30.0
|
||||
|
||||
ratio := float64(lookupDuration) / float64(updateDuration)
|
||||
t.Logf(" Lookup/Update ratio: %.2fx (threshold %.0fx)", ratio, maxLookupUpdateRatio)
|
||||
|
||||
if ratio > maxLookupUpdateRatio {
|
||||
t.Errorf("Lookup is %.2fx slower than update (>%.0fx threshold) — possible regression in AccountDeviceDir lookup path. Update=%v Lookup=%v",
|
||||
ratio, maxLookupUpdateRatio, updateDuration, lookupDuration)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestHandleBMXRegistry_DNSDependent(t *testing.T) {
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
localURL := "https://soundtouch.local"
|
||||
localURL := "https://127.0.0.1"
|
||||
server := NewServer(ds, nil, localURL, false, false, false)
|
||||
|
||||
t.Run("DNSEnabled_UsesBoseURL", func(t *testing.T) {
|
||||
|
||||
@@ -25,6 +25,7 @@ func TestDNSSettingsValidation(t *testing.T) {
|
||||
|
||||
// Test Case 1: Enable DNS with empty upstream (should fallback to system DNS)
|
||||
update := map[string]interface{}{
|
||||
"server_url": "http://localhost:8001",
|
||||
"dns_enabled": true,
|
||||
"dns_upstream": "",
|
||||
"dns_bind_addr": ":5353",
|
||||
@@ -55,6 +56,7 @@ func TestDNSSettingsValidation(t *testing.T) {
|
||||
// Test Case 2: Enable DNS with valid upstream
|
||||
// Using a random port to avoid conflicts and ensure it's fast
|
||||
updateValid := map[string]interface{}{
|
||||
"server_url": "http://localhost:8001",
|
||||
"dns_enabled": true,
|
||||
"dns_upstream": "8.8.8.8",
|
||||
"dns_bind_addr": "127.0.0.1:0", // Random port
|
||||
|
||||
@@ -15,6 +15,26 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// tuneInStreamFormats returns the formats= list AfterTouch should send
|
||||
// to TuneIn's Tune.ashx, honouring Settings.TuneInStreamFormats when
|
||||
// set. Empty (the default) lets bmx.TuneInStream fall back to
|
||||
// bmx.DefaultTuneInStreamFormats — the SoundTouch-line-compatible
|
||||
// "mp3,aac,ogg" shape. Operators with HLS-capable speakers can set
|
||||
// the field to "mp3,aac,ogg,hls" (or any other comma-separated list)
|
||||
// in settings.json.
|
||||
func (s *Server) tuneInStreamFormats() string {
|
||||
if s == nil || s.ds == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
settings, err := s.ds.GetSettings()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return settings.TuneInStreamFormats
|
||||
}
|
||||
|
||||
// HandleBMXRegistry returns the BMX service registry.
|
||||
func (s *Server) HandleBMXRegistry(w http.ResponseWriter, _ *http.Request) {
|
||||
baseURL := s.serverURL
|
||||
@@ -62,7 +82,7 @@ func (s *Server) HandleTuneInPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
stationID := chi.URLParam(r, "stationID")
|
||||
|
||||
resp, err := bmx.TuneInPlayback(stationID)
|
||||
resp, err := bmx.TuneInPlayback(stationID, s.tuneInStreamFormats())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -109,7 +129,7 @@ func (s *Server) HandleTuneInPlaybackPodcast(w http.ResponseWriter, r *http.Requ
|
||||
|
||||
podcastID := chi.URLParam(r, "podcastID")
|
||||
|
||||
resp, err := bmx.TuneInPlaybackPodcast(podcastID)
|
||||
resp, err := bmx.TuneInPlaybackPodcast(podcastID, s.tuneInStreamFormats())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -173,14 +193,28 @@ func (s *Server) HandleOrionToken(w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// HandleOrionPlayback returns Orion playback information.
|
||||
// HandleOrionPlayback returns Orion playback information for the
|
||||
// /core02/svc-bmx-adapter-orion/prod/orion/station?data=... endpoint
|
||||
// the speaker reaches by following its stored LOCAL_INTERNET_RADIO
|
||||
// preset's `location` attribute. The `data` query string is the
|
||||
// base64-encoded JSON blob (streamUrl/imageUrl/name) that the speaker
|
||||
// constructed when the preset was first saved; we just decode and
|
||||
// rewrap it into the Bose BmxPlaybackResponse shape via
|
||||
// bmx.PlayCustomStream.
|
||||
//
|
||||
// Requires a Bearer token in the `Authorization` header — same as
|
||||
// the rest of the BMX playback surface (TuneIn variants and the
|
||||
// orion token endpoint). Real speakers obtain the token via
|
||||
// POST /core02/svc-bmx-adapter-orion/prod/orion/token (HandleOrionToken)
|
||||
// before they ever follow a LOCAL_INTERNET_RADIO preset, so this
|
||||
// check shouldn't cost any legitimate caller.
|
||||
func (s *Server) HandleOrionPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
s.writeBMXUnauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
data := chi.URLParam(r, "data")
|
||||
data := r.URL.Query().Get("data")
|
||||
|
||||
resp, err := bmx.PlayCustomStream(data)
|
||||
if err != nil {
|
||||
|
||||
@@ -87,7 +87,13 @@ func TestOrionPlayback(t *testing.T) {
|
||||
// Base64 encoded: {"streamUrl": "http://example.com/stream", "imageUrl": "http://example.com/img.jpg", "name": "Test Orion"}
|
||||
data := "eyJzdHJlYW1VcmwiOiAiaHR0cDovL2V4YW1wbGUuY29tL3N0cmVhbSIsICJpbWFnZVVybCI6ICJodHRwOi8vZXhhbXBsZS5jb20vaW1nLmpwZyIsICJuYW1lIjogIlRlc3QgT3Jpb24ifQ=="
|
||||
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/bmx/orion/v1/playback/station/"+data, nil)
|
||||
// Speakers reach this endpoint by following the `location` attribute
|
||||
// stored in a LOCAL_INTERNET_RADIO preset's contentItem — a GET to
|
||||
// the upstream path with `data` as a query string. The data is
|
||||
// already base64-URL-safe; passing it raw mirrors what the speaker
|
||||
// emits (Go's url package re-encodes any `=` padding for transport).
|
||||
req, _ := http.NewRequest("GET",
|
||||
ts.URL+"/core02/svc-bmx-adapter-orion/prod/orion/station?data="+url.QueryEscape(data), nil)
|
||||
req.Header.Set("Authorization", "Bearer mock-token")
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -165,7 +171,7 @@ func TestBMXUnauthorized(t *testing.T) {
|
||||
{"GET", "/bmx/tunein/v1/playback/station/s123"},
|
||||
{"GET", "/bmx/tunein/v1/playback/episodes/p123"},
|
||||
{"GET", "/bmx/tunein/v1/playback/episode/p123"},
|
||||
{"POST", "/bmx/orion/v1/playback/station/data"},
|
||||
{"GET", "/core02/svc-bmx-adapter-orion/prod/orion/station?data=AAAA"},
|
||||
}
|
||||
|
||||
for _, tc := range paths {
|
||||
|
||||
@@ -588,7 +588,7 @@ func (s *Server) HandleMargeAddDevice(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
deviceID, data, err := marge.AddDeviceToAccount(s.ds, account, body)
|
||||
deviceID, data, err := marge.AddDeviceToAccount(s.ds, account, body, r.RemoteAddr)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -600,6 +600,74 @@ func (s *Server) HandleMargeAddDevice(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// HandleMargeUpdateDevice handles the speaker's rename PUT against
|
||||
// /streaming/account/{account}/device/{device}. The speaker fires
|
||||
// this whenever the user renames it via the Bose App or via
|
||||
// `soundtouch-cli name set`; before this handler existed AfterTouch
|
||||
// returned 502, the speaker retried in a loop, and the App showed
|
||||
// the rename hanging indefinitely (issue #285).
|
||||
//
|
||||
// The expected payload mirrors the POST shape:
|
||||
//
|
||||
// <device deviceid="DEVID"><name>NEW</name><macaddress>DEVID</macaddress></device>
|
||||
//
|
||||
// AddDeviceToAccount is already an upsert via ds.SaveDeviceInfo, so
|
||||
// rather than introduce a parallel UpdateDevice function we route
|
||||
// the PUT through the same persistence path. The semantic delta is
|
||||
// purely in the HTTP envelope: 200 (not 201), no Location header,
|
||||
// and the deviceID in the body has to match the URL — a mismatch
|
||||
// means the speaker is targeting the wrong record and we refuse
|
||||
// rather than silently re-key.
|
||||
func (s *Server) HandleMargeUpdateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
account := chi.URLParam(r, "account")
|
||||
if !validatePathID(account) {
|
||||
http.Error(w, "Invalid account ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
device := chi.URLParam(r, "device")
|
||||
if !validatePathID(device) {
|
||||
http.Error(w, "Invalid device ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read body", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate body deviceID against the URL segment *before* the
|
||||
// upsert in AddDeviceToAccount runs — otherwise a mismatched PUT
|
||||
// would still persist a row for the body's deviceID before the
|
||||
// 400 response, leaving spurious state in the datastore.
|
||||
var probe struct {
|
||||
DeviceID string `xml:"deviceid,attr"`
|
||||
}
|
||||
if xmlErr := xml.Unmarshal(body, &probe); xmlErr != nil {
|
||||
http.Error(w, xmlErr.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if probe.DeviceID != device {
|
||||
http.Error(w,
|
||||
fmt.Sprintf("device ID in body (%q) does not match URL (%q)", probe.DeviceID, device),
|
||||
http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_, data, err := marge.AddDeviceToAccount(s.ds, account, body, r.RemoteAddr)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// HandleMargeRemovePreset removes a preset for the specified account and device.
|
||||
func (s *Server) HandleMargeRemovePreset(w http.ResponseWriter, r *http.Request) {
|
||||
account := chi.URLParam(r, "account")
|
||||
|
||||
@@ -64,12 +64,16 @@ func TestMargeCreateAccount(t *testing.T) {
|
||||
t.Errorf("Expected 7-digit ID, got %v", resp.ID)
|
||||
}
|
||||
|
||||
// Verify it has default sources
|
||||
if len(resp.Sources) != 5 {
|
||||
t.Errorf("Expected 5 default sources, got %d", len(resp.Sources))
|
||||
// Verify default sources. AUX (id=10001, sourceproviderid=9) is
|
||||
// intentionally excluded from cloud responses — real Bose never
|
||||
// emitted AUX in /full; the speaker enumerates AUX from its own
|
||||
// hardware via isLocal=true in :8090/sources. See
|
||||
// pkg/service/marge/marge.go getAccountSources.
|
||||
if len(resp.Sources) != 4 {
|
||||
t.Errorf("Expected 4 cloud default sources (AUX excluded), got %d", len(resp.Sources))
|
||||
} else {
|
||||
if resp.Sources[0].ID != "10001" {
|
||||
t.Errorf("Expected first source ID 10001, got %s", resp.Sources[0].ID)
|
||||
if resp.Sources[0].ID != "10002" {
|
||||
t.Errorf("Expected first cloud source ID 10002 (INTERNET_RADIO), got %s", resp.Sources[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,10 +384,12 @@ func TestMargeAccountFullExcludesEmptyAmazonSource(t *testing.T) {
|
||||
t.Errorf("/full response must not include an empty-credential Amazon source; body:\n%s", bodyStr)
|
||||
}
|
||||
|
||||
// The 6 sources from lastDeviceID's stored Sources.xml must all be present.
|
||||
// Checked by sourceproviderid since <name> may hold a display name rather than the type string.
|
||||
// The cloud-visible sources from lastDeviceID's stored Sources.xml
|
||||
// must all be present. AUX (sourceproviderid=9) is intentionally
|
||||
// excluded — real Bose never emitted AUX in /full; the speaker
|
||||
// enumerates AUX from its own hardware via isLocal=true. See
|
||||
// pkg/service/marge/marge.go getAccountSources.
|
||||
for _, wantProviderID := range []string{
|
||||
"<sourceproviderid>9</sourceproviderid>", // AUX
|
||||
"<sourceproviderid>2</sourceproviderid>", // INTERNET_RADIO
|
||||
"<sourceproviderid>11</sourceproviderid>", // LOCAL_INTERNET_RADIO
|
||||
"<sourceproviderid>25</sourceproviderid>", // TUNEIN
|
||||
@@ -394,6 +400,11 @@ func TestMargeAccountFullExcludesEmptyAmazonSource(t *testing.T) {
|
||||
t.Errorf("/full response is missing source with %s; body:\n%s", wantProviderID, bodyStr)
|
||||
}
|
||||
}
|
||||
|
||||
// And explicitly assert AUX is NOT present.
|
||||
if strings.Contains(bodyStr, "<sourceproviderid>9</sourceproviderid>") {
|
||||
t.Errorf("/full response must not include AUX (sourceproviderid=9); body:\n%s", bodyStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMargeAccountSources(t *testing.T) {
|
||||
@@ -627,13 +638,16 @@ func TestMargeAccountSourcesNoDevices(t *testing.T) {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
bodyStr := string(body)
|
||||
|
||||
// Verify that we get the default sources with correct IDs and empty display names
|
||||
// Verify that we get the default cloud sources with correct IDs. AUX
|
||||
// (id=10001) is intentionally excluded — real Bose never emitted AUX
|
||||
// in cloud responses; the speaker enumerates AUX from its own
|
||||
// hardware (isLocal=true on :8090/sources). See
|
||||
// pkg/service/marge/marge.go getAccountSources.
|
||||
expectedSnippets := []string{
|
||||
"<sources>",
|
||||
"<source id=\"10004\" type=\"Audio\"",
|
||||
"<source id=\"10003\" type=\"Audio\"",
|
||||
"<source id=\"10002\" type=\"Audio\"",
|
||||
"<source id=\"10001\" type=\"Audio\"",
|
||||
}
|
||||
|
||||
for _, snippet := range expectedSnippets {
|
||||
@@ -642,6 +656,10 @@ func TestMargeAccountSourcesNoDevices(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(bodyStr, "<source id=\"10001\"") {
|
||||
t.Errorf("Response must not include AUX (id=10001); body:\n%s", bodyStr)
|
||||
}
|
||||
|
||||
// Verify that no sources have empty display names
|
||||
if strings.Count(bodyStr, "displayName=\"\"") != 0 {
|
||||
t.Errorf("Expected no sources with empty displayName, got %d: %s", strings.Count(bodyStr, "displayName=\"\""), bodyStr)
|
||||
@@ -1855,3 +1873,108 @@ func TestMargeGroupCRUD(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestMargeAddGroup_FromSpeakerCapture replays the exact request a SoundTouch
|
||||
// 10 master sends when it forwards an addGroup to its configured Marge server
|
||||
// while forming a stereo pair. The shape is taken verbatim from a live capture
|
||||
// in issue #252; account ID and device IDs are anonymised:
|
||||
//
|
||||
// POST /streaming/account/{account}/group/
|
||||
// Authorization: Bearer <token>
|
||||
// Content-Type: application/vnd.bose.streaming-v1.2+xml
|
||||
// <group><masterDeviceId>...</masterDeviceId><name>TEST</name>
|
||||
// <roles>
|
||||
// <groupRole><deviceId>{master}</deviceId><role>LEFT</role></groupRole>
|
||||
// <groupRole><deviceId>{slave}</deviceId><role>RIGHT</role></groupRole>
|
||||
// </roles>
|
||||
// </group>
|
||||
//
|
||||
// Notable differences from CLI-side requests this codebase already tests:
|
||||
// - URL has a trailing slash ("/group/", not "/group")
|
||||
// - <groupRole> elements have no <ipAddress>
|
||||
// - <senderIPAddress> is absent (correct for the master-bound payload)
|
||||
// - Content-Type is the vendor-specific media type
|
||||
//
|
||||
// The speaker retries this POST every 15 s while in AddingMaster state; if
|
||||
// AfterTouch doesn't accept it the group never completes and reverts to
|
||||
// NoGroup after a timeout. This test pins down the exact wire contract so
|
||||
// any future change that breaks it fails loudly.
|
||||
func TestMargeAddGroup_FromSpeakerCapture(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "st-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
r, _ := setupRouter("http://localhost:8001", ds)
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
const (
|
||||
account = "1234567"
|
||||
masterDevID = "001122334455"
|
||||
slaveDevID = "AABBCCDDEEFF"
|
||||
)
|
||||
|
||||
// Body matches the captured MargeClient payload structure verbatim --
|
||||
// no <senderIPAddress>, no per-role <ipAddress>, no <status>, no group id.
|
||||
reqBody := `<?xml version="1.0" encoding="UTF-8" ?><group><masterDeviceId>` + masterDevID +
|
||||
`</masterDeviceId><name>TEST</name><roles><groupRole><deviceId>` + masterDevID +
|
||||
`</deviceId><role>LEFT</role></groupRole><groupRole><deviceId>` + slaveDevID +
|
||||
`</deviceId><role>RIGHT</role></groupRole></roles></group>`
|
||||
|
||||
url := ts.URL + "/streaming/account/" + account + "/group/"
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(reqBody))
|
||||
if err != nil {
|
||||
t.Fatalf("build request: %v", err)
|
||||
}
|
||||
|
||||
// Headers copied from the captured CMargeHttpInterface::Post lines.
|
||||
req.Header.Set("Authorization", "Bearer test-token")
|
||||
req.Header.Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do request: %v", err)
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
if res.StatusCode != http.StatusCreated {
|
||||
respBody, _ := io.ReadAll(res.Body)
|
||||
t.Fatalf("POST %s: expected 201 Created, got %d. Body: %s", url, res.StatusCode, respBody)
|
||||
}
|
||||
|
||||
if got := res.Header.Get("Content-Type"); got != "application/vnd.bose.streaming-v1.2+xml" {
|
||||
t.Errorf("response Content-Type = %q, want %q", got, "application/vnd.bose.streaming-v1.2+xml")
|
||||
}
|
||||
|
||||
location := res.Header.Get("Location")
|
||||
if !strings.Contains(location, "/account/"+account+"/group/") {
|
||||
t.Errorf("Location header should reference the new group under account %s, got %q", account, location)
|
||||
}
|
||||
|
||||
respBody, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read response: %v", err)
|
||||
}
|
||||
|
||||
var got models.Group
|
||||
if err := xml.Unmarshal(respBody, &got); err != nil {
|
||||
t.Fatalf("decode response: %v\nbody: %s", err, respBody)
|
||||
}
|
||||
|
||||
if got.MasterDeviceID != masterDevID {
|
||||
t.Errorf("response masterDeviceId = %q, want %q", got.MasterDeviceID, masterDevID)
|
||||
}
|
||||
|
||||
if got.Name != "TEST" {
|
||||
t.Errorf("response name = %q, want %q", got.Name, "TEST")
|
||||
}
|
||||
|
||||
if len(got.Roles.Roles) != 2 {
|
||||
t.Fatalf("response roles = %d, want 2", len(got.Roles.Roles))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +172,17 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
dnsRunning, actualBind := s.GetDNSRunning()
|
||||
|
||||
var serverURLResolvedIP, serverURLResolveError string
|
||||
|
||||
if ip, err := s.resolveServerURLIP(serverURL); err == nil {
|
||||
serverURLResolvedIP = ip
|
||||
} else {
|
||||
serverURLResolveError = err.Error()
|
||||
}
|
||||
|
||||
httpsListenerPort := PortFromHTTPSServerURL(httpsServerURL)
|
||||
probe443 := Check443Reachability(httpsListenerPort, serverURL, s.resolveServerURLIP, ProbeDialTimeoutInline)
|
||||
|
||||
// Mask secrets: return "***" if set so the UI can show "configured" without exposing the value.
|
||||
if spotifyClientSecret != "" {
|
||||
spotifyClientSecret = "***"
|
||||
@@ -182,32 +193,41 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"server_url": serverURL,
|
||||
"https_server_url": httpsServerURL,
|
||||
"discovery_interval": discoveryInterval,
|
||||
"discovery_enabled": discoveryEnabled,
|
||||
"dns_enabled": dnsEnabled,
|
||||
"dns_running": dnsRunning,
|
||||
"dns_actual_bind": actualBind,
|
||||
"dns_upstream": strings.Join(dnsUpstream, ","),
|
||||
"dns_bind_addr": dnsBindAddr,
|
||||
"mirror_enabled": mirrorEnabled,
|
||||
"mirror_endpoints": mirrorEndpoints,
|
||||
"skip_mirror_endpoints": skipMirrorEndpoints,
|
||||
"preferred_source": preferredSource,
|
||||
"internal_paths": internalPaths,
|
||||
"redact_logs": redact,
|
||||
"log_bodies": logBody,
|
||||
"record_interactions": record,
|
||||
"shortcuts": shortcuts,
|
||||
"spotify_configured": spotifyConfigured,
|
||||
"spotify_client_id": spotifyClientID,
|
||||
"spotify_client_secret": spotifyClientSecret,
|
||||
"spotify_redirect_uri": spotifyRedirectURI,
|
||||
"amazon_configured": amazonConfigured,
|
||||
"amazon_client_id": amazonClientID,
|
||||
"amazon_client_secret": amazonClientSecret,
|
||||
"amazon_redirect_uri": amazonRedirectURI,
|
||||
"server_url": serverURL,
|
||||
"server_url_resolved_ip": serverURLResolvedIP,
|
||||
"server_url_resolve_error": serverURLResolveError,
|
||||
"https_server_url": httpsServerURL,
|
||||
"https_listener_port": httpsListenerPort,
|
||||
"https_443_check_skipped": probe443.Skipped,
|
||||
"https_443_localhost_reachable": probe443.Localhost.Reachable,
|
||||
"https_443_localhost_error": probe443.Localhost.Error,
|
||||
"https_443_lan_reachable": probe443.LAN.Reachable,
|
||||
"https_443_lan_error": probe443.LAN.Error,
|
||||
"https_443_lan_host": probe443.LANHost,
|
||||
"discovery_interval": discoveryInterval,
|
||||
"discovery_enabled": discoveryEnabled,
|
||||
"dns_enabled": dnsEnabled,
|
||||
"dns_running": dnsRunning,
|
||||
"dns_actual_bind": actualBind,
|
||||
"dns_upstream": strings.Join(dnsUpstream, ","),
|
||||
"dns_bind_addr": dnsBindAddr,
|
||||
"mirror_enabled": mirrorEnabled,
|
||||
"mirror_endpoints": mirrorEndpoints,
|
||||
"skip_mirror_endpoints": skipMirrorEndpoints,
|
||||
"preferred_source": preferredSource,
|
||||
"internal_paths": internalPaths,
|
||||
"redact_logs": redact,
|
||||
"log_bodies": logBody,
|
||||
"record_interactions": record,
|
||||
"shortcuts": shortcuts,
|
||||
"spotify_configured": spotifyConfigured,
|
||||
"spotify_client_id": spotifyClientID,
|
||||
"spotify_client_secret": spotifyClientSecret,
|
||||
"spotify_redirect_uri": spotifyRedirectURI,
|
||||
"amazon_configured": amazonConfigured,
|
||||
"amazon_client_id": amazonClientID,
|
||||
"amazon_client_secret": amazonClientSecret,
|
||||
"amazon_redirect_uri": amazonRedirectURI,
|
||||
}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -247,6 +267,15 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("[DNS] DNS Discovery enabled without explicit upstreams, will try system DNS.")
|
||||
}
|
||||
|
||||
// Validate server_url: the same value the DNS server uses to derive its
|
||||
// intercept IP. Reject anything that does not resolve to a routable IP so
|
||||
// users see the error in the UI instead of getting a silently-broken setup
|
||||
// where DNS replies with `CNAME .` for every Bose hostname.
|
||||
if _, err := s.resolveServerURLIP(settings.ServerURL); err != nil {
|
||||
http.Error(w, "Invalid server_url: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
interval, err := time.ParseDuration(settings.DiscoveryInterval)
|
||||
if err != nil && settings.DiscoveryInterval != "" {
|
||||
http.Error(w, "Invalid discovery interval: "+err.Error(), http.StatusBadRequest)
|
||||
|
||||
@@ -101,7 +101,7 @@ func TestProxySettingsAPI(t *testing.T) {
|
||||
|
||||
// 3. Test System Settings POST
|
||||
sysUpdate := map[string]string{
|
||||
"server_url": "http://new-server:8000",
|
||||
"server_url": "http://127.0.0.1:8000",
|
||||
}
|
||||
|
||||
sysBody, err := json.Marshal(sysUpdate)
|
||||
@@ -122,13 +122,13 @@ func TestProxySettingsAPI(t *testing.T) {
|
||||
|
||||
// Verify server state
|
||||
sURL, _ := server.GetSettings()
|
||||
if sURL != "http://new-server:8000" {
|
||||
if sURL != "http://127.0.0.1:8000" {
|
||||
t.Errorf("POST /setup/settings: Server state did not update: serverURL=%s", sURL)
|
||||
}
|
||||
|
||||
// 4. Test Mirror Settings persistence
|
||||
mirrorUpdate := map[string]interface{}{
|
||||
"server_url": "http://mirror-test:8000",
|
||||
"server_url": "http://127.0.0.1:8000",
|
||||
"mirror_enabled": true,
|
||||
"mirror_endpoints": []string{"/test/*"},
|
||||
"internal_paths": []string{"/setup/*"},
|
||||
@@ -410,6 +410,11 @@ func TestRemoveDevice(t *testing.T) {
|
||||
type mockSSH struct {
|
||||
host string
|
||||
runCount int
|
||||
|
||||
// uploaded mirrors UploadContent calls so that a subsequent
|
||||
// `cat <path>` (notably the tmp-readback step in
|
||||
// TrustCACertFromBytes) returns what we just wrote there.
|
||||
uploaded map[string][]byte
|
||||
}
|
||||
|
||||
func (m *mockSSH) Run(command string) (string, error) {
|
||||
@@ -427,7 +432,21 @@ func (m *mockSSH) Run(command string) (string, error) {
|
||||
if strings.HasPrefix(command, "grep -F") {
|
||||
return "matched", nil // CA trusted
|
||||
}
|
||||
if strings.HasPrefix(command, "cat ") {
|
||||
path := strings.TrimPrefix(command, "cat ")
|
||||
if body, ok := m.uploaded[path]; ok {
|
||||
return string(body), nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (m *mockSSH) UploadContent(content []byte, remotePath string) error { return nil }
|
||||
func (m *mockSSH) UploadContent(content []byte, remotePath string) error {
|
||||
if m.uploaded == nil {
|
||||
m.uploaded = make(map[string][]byte)
|
||||
}
|
||||
|
||||
m.uploaded[remotePath] = append([]byte(nil), content...)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
// TestIssue218_OrionStationResolvesPresetStreamURL closes the loop on
|
||||
// the issue #218 regression: it takes the exact preset location URL the
|
||||
// reporter pasted, follows it against the real router, and asserts the
|
||||
// returned BmxPlaybackResponse exposes the speaker-playable streamUrl.
|
||||
//
|
||||
// Pairs with pkg/service/setup/issue218_regression_test.go, which
|
||||
// verifies the preset survives device sync verbatim. Together they
|
||||
// prove that:
|
||||
//
|
||||
// 1. The sync step preserves the cloud URL embedded in
|
||||
// LOCAL_INTERNET_RADIO presets.
|
||||
// 2. Hitting that URL against AfterTouch's router resolves it to the
|
||||
// speaker-playable stream — no rewrite required on the persisted
|
||||
// preset itself.
|
||||
//
|
||||
// Before commit f3a4658, this test would have 404'd: the orion routes
|
||||
// were wrongly nested under `/bmx/` while the BMX registry advertises
|
||||
// the un-prefixed path. See the matching doc-comment on
|
||||
// HandleOrionPlayback for the protocol detail.
|
||||
func TestIssue218_OrionStationResolvesPresetStreamURL(t *testing.T) {
|
||||
// Verbatim from pkg/service/setup/testdata/issue218/presets.xml's
|
||||
// ContentItem `location` attribute (issue #218 body). Decoded
|
||||
// query payload is:
|
||||
// {"name":"OPB","imageUrl":"","streamUrl":"http://ais-sa3.cdnstream1.com/2440_128.aac"}
|
||||
const presetLocation = "https://content.api.bose.io/core02/svc-bmx-adapter-orion/prod/orion/station?data=eyJuYW1lIjoiT1BCIiwiaW1hZ2VVcmwiOiIiLCJzdHJlYW1VcmwiOiJodHRwOi8vYWlzLXNhMy5jZG5zdHJlYW0xLmNvbS8yNDQwXzEyOC5hYWMifQ%3D%3D"
|
||||
|
||||
const wantStreamURL = "http://ais-sa3.cdnstream1.com/2440_128.aac"
|
||||
|
||||
// Sanity: the base64 payload really does encode wantStreamURL.
|
||||
// If the fixture ever diverges from this expectation the test
|
||||
// would silently keep passing on whatever the new payload says;
|
||||
// pin it explicitly.
|
||||
parsedLocation, err := url.Parse(presetLocation)
|
||||
if err != nil {
|
||||
t.Fatalf("parse preset location: %v", err)
|
||||
}
|
||||
|
||||
data := parsedLocation.Query().Get("data")
|
||||
if data == "" {
|
||||
t.Fatalf("preset location has no `data` query param: %s", presetLocation)
|
||||
}
|
||||
|
||||
decoded, err := base64.URLEncoding.DecodeString(data)
|
||||
if err != nil {
|
||||
// Some captures use RawURLEncoding (no padding); fall back.
|
||||
decoded, err = base64.RawURLEncoding.DecodeString(strings.TrimRight(data, "="))
|
||||
if err != nil {
|
||||
t.Fatalf("decode data blob: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.Contains(string(decoded), wantStreamURL) {
|
||||
t.Fatalf("fixture data does not encode the expected streamUrl.\ndecoded:\n%s\nwant substring:\n%s",
|
||||
decoded, wantStreamURL)
|
||||
}
|
||||
|
||||
// Drive the real router. Use only the path+query from the preset
|
||||
// URL — host is what DNS interception or URL-flip would have
|
||||
// substituted at runtime, not what the test server bound to.
|
||||
r, _ := setupRouter("http://localhost:8001", nil)
|
||||
ts := httptest.NewServer(r)
|
||||
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
resolved := ts.URL + parsedLocation.RequestURI()
|
||||
|
||||
// Real speakers retrieve an orion token from
|
||||
// POST /core02/svc-bmx-adapter-orion/prod/orion/token before they
|
||||
// ever follow a LOCAL_INTERNET_RADIO preset; the playback handler
|
||||
// rejects an empty Authorization header for parity with the other
|
||||
// BMX playback routes. Use a sentinel Bearer token to match that
|
||||
// shape — HandleOrionPlayback doesn't validate the token contents,
|
||||
// only its presence.
|
||||
req, _ := http.NewRequest("GET", resolved, nil)
|
||||
req.Header.Set("Authorization", "Bearer mock-token")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", resolved, err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("GET %s → %d, want 200; body:\n%s", resolved, resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var got models.BmxPlaybackResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
if got.Audio.StreamUrl != wantStreamURL {
|
||||
t.Errorf("audio.streamUrl = %q, want %q", got.Audio.StreamUrl, wantStreamURL)
|
||||
}
|
||||
|
||||
if got.Name != "OPB" {
|
||||
t.Errorf("name = %q, want %q", got.Name, "OPB")
|
||||
}
|
||||
|
||||
if got.StreamType != "liveRadio" {
|
||||
t.Errorf("streamType = %q, want %q", got.StreamType, "liveRadio")
|
||||
}
|
||||
|
||||
// The streams array should mirror the top-level streamUrl —
|
||||
// PlayCustomStream sets both for parity with what real Bose emits.
|
||||
if len(got.Audio.Streams) == 0 {
|
||||
t.Errorf("audio.streams empty, want at least one entry with streamUrl=%q", wantStreamURL)
|
||||
} else if got.Audio.Streams[0].StreamUrl != wantStreamURL {
|
||||
t.Errorf("audio.streams[0].streamUrl = %q, want %q", got.Audio.Streams[0].StreamUrl, wantStreamURL)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// TestIssue285_RenamePutAcceptedAndPersisted reproduces the rename
|
||||
// loop documented in issue #285:
|
||||
//
|
||||
// https://github.com/gesellix/Bose-SoundTouch/issues/285
|
||||
//
|
||||
// When a user renames an ST10 via the Bose App or via
|
||||
// `soundtouch-cli name set`, the speaker fires PUT
|
||||
// /streaming/account/{accountID}/device/{deviceID} with a body of
|
||||
// the form:
|
||||
//
|
||||
// <device deviceid="…"><name>NEW</name><macaddress>…</macaddress></device>
|
||||
//
|
||||
// Before this commit the router only registered POST for that path;
|
||||
// PUT fell through to the chi router's default handling and the
|
||||
// speaker observed HTTP 502 (captured verbatim in
|
||||
// _/i285/Rename.log:38: "SimpleURLFetcher: retry needed, Curl 0,
|
||||
// http 502, retries remaining 0"). The speaker retried in a loop
|
||||
// and the Bose App showed the rename spinning indefinitely.
|
||||
//
|
||||
// The fixture at testdata/issue285/rename_request.xml is the exact
|
||||
// payload from the log (line 36) — `deviceid="884AEAEEBD27"`,
|
||||
// `<name>Wohnzimmer SB</name>`. The test:
|
||||
//
|
||||
// 1. Pre-seeds the datastore with a device record under the
|
||||
// reporter's accountID + deviceID so the PUT is updating, not
|
||||
// creating.
|
||||
// 2. Replays the rename PUT.
|
||||
// 3. Asserts:
|
||||
// - HTTP 200 (NOT 201; this is an update, not a create — speakers
|
||||
// observed 502 before, so any 2xx is the headline fix, but
|
||||
// pinning 200 protects against accidentally returning 201
|
||||
// which would change the Location-header contract).
|
||||
// - Response body carries the new name verbatim.
|
||||
// - Persisted Sources/DeviceInfo on disk reflects the new name.
|
||||
//
|
||||
// When future work decides to preserve `createdOn` across updates
|
||||
// (currently AddDeviceToAccount rewrites both timestamps), update
|
||||
// the test to also assert that — the rename request from the log
|
||||
// does NOT carry a createdOn, so any value our marge response
|
||||
// emits is purely our choice and should be stable.
|
||||
func TestIssue285_RenamePutAcceptedAndPersisted(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "issue285-")
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir temp: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
const (
|
||||
accountID = "3981561"
|
||||
deviceID = "884AEAEEBD27"
|
||||
oldName = "Wohnzimmer"
|
||||
newName = "Wohnzimmer SB"
|
||||
preExistingIP = "192.168.0.109"
|
||||
preExistingPaired = "2017-02-07T11:13:03.000+00:00"
|
||||
)
|
||||
|
||||
// 1. Seed datastore with the device under its original name and
|
||||
// a known pre-existing first-paired timestamp. The pre-existing
|
||||
// data models a long-paired device the user is now renaming —
|
||||
// CreatedOn must survive the PUT (real Bose preserves it
|
||||
// across renames; see parity capture at
|
||||
// data/parity_mismatches/1771797308__streaming_account_3230304_device_A81B6A536A98.json).
|
||||
if err := ds.SaveDeviceInfo(accountID, deviceID, &models.ServiceDeviceInfo{
|
||||
DeviceID: deviceID,
|
||||
AccountID: accountID,
|
||||
Name: oldName,
|
||||
IPAddress: preExistingIP,
|
||||
CreatedOn: preExistingPaired,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed datastore: %v", err)
|
||||
}
|
||||
|
||||
// 2. Spin up the router and replay the captured rename PUT.
|
||||
r, _ := setupRouter("http://localhost:8001", ds)
|
||||
ts := httptest.NewServer(r)
|
||||
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
body, err := os.ReadFile(filepath.Join("testdata", "issue285", "rename_request.xml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture: %v", err)
|
||||
}
|
||||
|
||||
// Sanity-check the fixture before trusting any downstream
|
||||
// assertion against it.
|
||||
if !bytes.Contains(body, []byte(`deviceid="`+deviceID+`"`)) {
|
||||
t.Fatalf("fixture missing expected deviceid=%q; got:\n%s", deviceID, body)
|
||||
}
|
||||
|
||||
if !bytes.Contains(body, []byte(`<name>`+newName+`</name>`)) {
|
||||
t.Fatalf("fixture missing expected new name %q; got:\n%s", newName, body)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPut,
|
||||
ts.URL+"/streaming/account/"+accountID+"/device/"+deviceID,
|
||||
bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("build request: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/xml")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("PUT: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// 3. Headline assertion: the speaker observed 502 before — any
|
||||
// 2xx fixes the loop. Pin 200 specifically so we don't drift
|
||||
// into 201/Created (which would change the Location-header
|
||||
// contract POST gets).
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("PUT status = %d, want 200; body:\n%s", resp.StatusCode, respBody)
|
||||
}
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read response: %v", err)
|
||||
}
|
||||
|
||||
// Response shape: <device …><name>NEW</name>…</device>
|
||||
if !bytes.Contains(respBody, []byte(`deviceid="`+deviceID+`"`)) {
|
||||
t.Errorf("response missing deviceid=%q; body:\n%s", deviceID, respBody)
|
||||
}
|
||||
|
||||
if !bytes.Contains(respBody, []byte(`<name>`+newName+`</name>`)) {
|
||||
t.Errorf("response missing new name %q; body:\n%s", newName, respBody)
|
||||
}
|
||||
|
||||
if strings.Contains(string(respBody), `<name>`+oldName+`</name>`) {
|
||||
t.Errorf("response still carries old name %q; body:\n%s", oldName, respBody)
|
||||
}
|
||||
|
||||
// Parity assertion: the pre-existing first-paired CreatedOn
|
||||
// must survive the rename. This is the load-bearing fix versus
|
||||
// the prior behaviour that rewrote `now()` on every PUT, and
|
||||
// matches what real Bose's pre-shutdown 200 OK responses
|
||||
// carried (see the parity capture referenced above).
|
||||
if !bytes.Contains(respBody, []byte(`<createdOn>`+preExistingPaired+`</createdOn>`)) {
|
||||
t.Errorf("response did not preserve pre-existing CreatedOn %q; body:\n%s", preExistingPaired, respBody)
|
||||
}
|
||||
|
||||
// Parity assertion: the pre-existing IP address must survive
|
||||
// the rename. The request body doesn't carry an `<ipaddress>`,
|
||||
// so the datastore merge has to inject what was already on
|
||||
// disk rather than writing back empty.
|
||||
if !bytes.Contains(respBody, []byte(`<ipaddress>`+preExistingIP+`</ipaddress>`)) {
|
||||
t.Errorf("response did not preserve pre-existing IPAddress %q; body:\n%s", preExistingIP, respBody)
|
||||
}
|
||||
|
||||
// Parity assertion: UpdatedOn refreshes. Don't pin the exact
|
||||
// value — it's "now()" — but assert it's present and
|
||||
// non-empty.
|
||||
if !bytes.Contains(respBody, []byte(`<updatedOn>`)) ||
|
||||
bytes.Contains(respBody, []byte(`<updatedOn></updatedOn>`)) {
|
||||
t.Errorf("response missing or empty <updatedOn>; body:\n%s", respBody)
|
||||
}
|
||||
|
||||
// 4. Persistence assertion: the datastore now reflects the new
|
||||
// name AND keeps the original CreatedOn. This is what the
|
||||
// Bose App reads back on its next /streaming/account/.../full
|
||||
// poll, which is what closes the visible rename loop.
|
||||
persisted, err := ds.GetDeviceInfo(accountID, deviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("read persisted device info: %v", err)
|
||||
}
|
||||
|
||||
if persisted.Name != newName {
|
||||
t.Errorf("persisted Name = %q, want %q", persisted.Name, newName)
|
||||
}
|
||||
|
||||
if persisted.CreatedOn != preExistingPaired {
|
||||
t.Errorf("persisted CreatedOn = %q, want %q (preserved across rename)", persisted.CreatedOn, preExistingPaired)
|
||||
}
|
||||
|
||||
if persisted.IPAddress != preExistingIP {
|
||||
t.Errorf("persisted IPAddress = %q, want %q (preserved across rename)", persisted.IPAddress, preExistingIP)
|
||||
}
|
||||
|
||||
if persisted.UpdatedOn == "" {
|
||||
t.Errorf("persisted UpdatedOn is empty; want a fresh timestamp from the rename")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssue285_NewDeviceGetsRemoteAddrAndFreshTimestamps covers the
|
||||
// "first-time registration" path on a PUT (which can happen if the
|
||||
// speaker emits a rename before AfterTouch has ever heard of it).
|
||||
// With no pre-existing datastore record:
|
||||
//
|
||||
// - CreatedOn must be a fresh timestamp (no record to preserve).
|
||||
// - IPAddress must come from r.RemoteAddr (the inbound connection)
|
||||
// since the request body doesn't carry one.
|
||||
// - UpdatedOn must be the same fresh timestamp.
|
||||
//
|
||||
// Pairs with the parity-preservation assertions in the main test:
|
||||
// existing records win, but new records seed sensibly instead of
|
||||
// landing with empty CreatedOn / IPAddress.
|
||||
func TestIssue285_NewDeviceGetsRemoteAddrAndFreshTimestamps(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "issue285-new-")
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir temp: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
const (
|
||||
accountID = "1111111"
|
||||
deviceID = "A81B6A536A98"
|
||||
newName = "Sound Machinechen"
|
||||
)
|
||||
|
||||
r, _ := setupRouter("http://localhost:8001", ds)
|
||||
ts := httptest.NewServer(r)
|
||||
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
body := []byte(`<?xml version="1.0" encoding="UTF-8" ?>` +
|
||||
`<device deviceid="` + deviceID + `"><name>` + newName + `</name><macaddress>` + deviceID + `</macaddress></device>`)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPut,
|
||||
ts.URL+"/streaming/account/"+accountID+"/device/"+deviceID,
|
||||
bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("build request: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/xml")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("PUT: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("PUT status = %d, want 200; body:\n%s", resp.StatusCode, respBody)
|
||||
}
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read response: %v", err)
|
||||
}
|
||||
|
||||
// CreatedOn present and non-empty (will be "now()" since no
|
||||
// prior record existed).
|
||||
if !bytes.Contains(respBody, []byte(`<createdOn>`)) ||
|
||||
bytes.Contains(respBody, []byte(`<createdOn></createdOn>`)) {
|
||||
t.Errorf("first-registration response missing CreatedOn; body:\n%s", respBody)
|
||||
}
|
||||
|
||||
// IPAddress should be the httptest connection's remote host
|
||||
// (127.0.0.1) since the body didn't carry one and there was
|
||||
// no existing record to preserve from.
|
||||
if !bytes.Contains(respBody, []byte(`<ipaddress>127.0.0.1</ipaddress>`)) {
|
||||
t.Errorf("first-registration response missing IPAddress from RemoteAddr; body:\n%s", respBody)
|
||||
}
|
||||
|
||||
// Persistence: CreatedOn and IPAddress on disk too.
|
||||
persisted, err := ds.GetDeviceInfo(accountID, deviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("read persisted device info: %v", err)
|
||||
}
|
||||
|
||||
if persisted.CreatedOn == "" {
|
||||
t.Errorf("persisted CreatedOn is empty for new device; want a fresh timestamp")
|
||||
}
|
||||
|
||||
if persisted.IPAddress != "127.0.0.1" {
|
||||
t.Errorf("persisted IPAddress = %q, want %q (from RemoteAddr)", persisted.IPAddress, "127.0.0.1")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssue285_RenamePutRejectsMismatchedDeviceID pins the safety
|
||||
// check: if the speaker (or a bug elsewhere) ever sends a PUT with
|
||||
// a body whose `deviceid="…"` doesn't match the URL's `{device}`
|
||||
// segment, we refuse with 400 rather than silently re-key the
|
||||
// persisted record under the wrong account/device.
|
||||
func TestIssue285_RenamePutRejectsMismatchedDeviceID(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "issue285-mismatch-")
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir temp: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
r, _ := setupRouter("http://localhost:8001", ds)
|
||||
ts := httptest.NewServer(r)
|
||||
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
const urlDeviceID = "884AEAEEBD27"
|
||||
|
||||
// Body claims a different deviceID than the URL.
|
||||
body := []byte(`<?xml version="1.0" encoding="UTF-8" ?>` +
|
||||
`<device deviceid="DEADBEEFCAFE"><name>Rogue</name><macaddress>DEADBEEFCAFE</macaddress></device>`)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPut,
|
||||
ts.URL+"/streaming/account/3981561/device/"+urlDeviceID,
|
||||
bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("build request: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/xml")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("PUT: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("PUT status = %d, want 400; body:\n%s", resp.StatusCode, respBody)
|
||||
}
|
||||
|
||||
// Mismatched body must be rejected *before* the upsert runs —
|
||||
// otherwise the datastore ends up with a row keyed on the body's
|
||||
// deviceID even though we return 400. Verify by reading both keys.
|
||||
if got, _ := ds.GetDeviceInfo("3981561", "DEADBEEFCAFE"); got != nil {
|
||||
t.Fatalf("body deviceID DEADBEEFCAFE was persisted despite 400 response: %+v", got)
|
||||
}
|
||||
|
||||
if got, _ := ds.GetDeviceInfo("3981561", urlDeviceID); got != nil {
|
||||
t.Fatalf("URL deviceID %s was persisted despite 400 response: %+v", urlDeviceID, got)
|
||||
}
|
||||
}
|
||||
@@ -32,9 +32,14 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
|
||||
r.Get("/tunein/v1/navigate", server.HandleTuneInNavigate)
|
||||
r.Get("/tunein/v1/navigate/*", server.HandleTuneInNavigate)
|
||||
r.Get("/tunein/v1/search", server.HandleTuneInSearch)
|
||||
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
|
||||
})
|
||||
|
||||
// Orion lives at the top level — see the matching note in
|
||||
// cmd/soundtouch-service/main.go. Mirrored here so the test router
|
||||
// exercises the same paths the production router does.
|
||||
r.Post("/core02/svc-bmx-adapter-orion/prod/orion/token", server.HandleOrionToken)
|
||||
r.Get("/core02/svc-bmx-adapter-orion/prod/orion/station", server.HandleOrionPlayback)
|
||||
|
||||
r.Get("/custom/v1/playback/{encodedURL}", server.HandleCustomPlayback)
|
||||
|
||||
streamingRoutes := func(r chi.Router) {
|
||||
@@ -42,6 +47,8 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
|
||||
r.Route("/account/{account}/device", func(r chi.Router) {
|
||||
r.Post("/", server.HandleMargeAddDevice)
|
||||
r.Post("/{device}", server.HandleMargeAddDevice)
|
||||
// Rename PUT — mirrors the production router. Issue #285.
|
||||
r.Put("/{device}", server.HandleMargeUpdateDevice)
|
||||
})
|
||||
r.Get("/account/{account}/device/{device}/recent", server.HandleMargeRecents)
|
||||
r.Post("/account/{account}/device/{device}/recent", server.HandleMargeAddRecent)
|
||||
@@ -58,7 +65,11 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
|
||||
r.Get("/account/{account}/device/{device}/group/", server.HandleMargeDeviceGroup)
|
||||
r.Get("/account/{account}/device/{device}/group/server", server.HandleMargeDeviceGroupServer)
|
||||
r.Get("/account/{account}/device/{device}/group/member", server.HandleMargeDeviceGroupMember)
|
||||
// Speakers POST to /group/ (with trailing slash) when forwarding the
|
||||
// addGroup payload to Marge during stereo-pair formation -- see issue
|
||||
// #252. Register both forms so chi accepts either.
|
||||
r.Post("/account/{account}/group", server.HandleMargeAddGroup)
|
||||
r.Post("/account/{account}/group/", server.HandleMargeAddGroup)
|
||||
r.Post("/account/{account}/group/{groupId}", server.HandleMargeModifyGroup)
|
||||
r.Delete("/account/{account}/group/{groupId}", server.HandleMargeDeleteGroup)
|
||||
r.Post("/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
|
||||
@@ -91,6 +102,7 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
|
||||
r.Get("/{account}/devices/{device}/group/server", server.HandleMargeDeviceGroupServer)
|
||||
r.Get("/{account}/devices/{device}/group/member", server.HandleMargeDeviceGroupMember)
|
||||
r.Post("/{account}/group", server.HandleMargeAddGroup)
|
||||
r.Post("/{account}/group/", server.HandleMargeAddGroup)
|
||||
r.Post("/{account}/group/{groupId}", server.HandleMargeModifyGroup)
|
||||
r.Delete("/{account}/group/{groupId}", server.HandleMargeDeleteGroup)
|
||||
}
|
||||
|
||||
@@ -134,6 +134,7 @@ func TestSettingsAPI_PreferredSource(t *testing.T) {
|
||||
|
||||
// Test UPDATE
|
||||
update := map[string]interface{}{
|
||||
"server_url": "http://localhost:8000",
|
||||
"preferred_source": "upstream",
|
||||
}
|
||||
body, err := json.Marshal(update)
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Probe443Result captures the outcome of probing a host on :443.
|
||||
// Skipped is true when the running HTTPS listener is already on :443
|
||||
// (in which case the listener itself is the proof of reachability).
|
||||
type Probe443Result struct {
|
||||
Skipped bool
|
||||
Localhost ProbeOutcome
|
||||
LAN ProbeOutcome
|
||||
LANHost string
|
||||
}
|
||||
|
||||
// ProbeOutcome describes a single TCP-connect probe. Exactly one of
|
||||
// Reachable/Error is meaningful: Reachable=true means the dial succeeded,
|
||||
// otherwise Error holds the dial error string.
|
||||
type ProbeOutcome struct {
|
||||
Reachable bool
|
||||
Error string
|
||||
}
|
||||
|
||||
// ProbeDialTimeoutStartup is the per-attempt TCP dial timeout used by the
|
||||
// startup preflight, where we can afford to wait a beat for a slow LAN.
|
||||
const ProbeDialTimeoutStartup = 2 * time.Second
|
||||
|
||||
// ProbeDialTimeoutInline is the per-attempt TCP dial timeout used by the
|
||||
// settings HTTP handler, where a user is blocking on the response.
|
||||
const ProbeDialTimeoutInline = 500 * time.Millisecond
|
||||
|
||||
// ProbeTCP attempts a TCP connection to host:port within timeout. It returns
|
||||
// nil on success; an error otherwise. The connection is closed immediately —
|
||||
// we only care whether *something* would answer where a speaker knocks.
|
||||
func ProbeTCP(host string, port int, timeout time.Duration) error {
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
|
||||
conn, err := net.DialTimeout("tcp", addr, timeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = conn.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check443Reachability probes both localhost:443 and the LAN-facing IP that
|
||||
// DNS would hand out for serverURL on :443. It is intended to surface the
|
||||
// most common AfterTouch misconfiguration: HTTPS listener on :8443 with no
|
||||
// routing in place from :443 (speakers connect to implicit :443 and see
|
||||
// Curl 7 / connection refused with nothing reaching AfterTouch).
|
||||
//
|
||||
// If httpsListenerPort is already 443, both probes are skipped — the running
|
||||
// listener proves :443 is reachable.
|
||||
//
|
||||
// lanResolver is the function used to translate serverURL into a LAN IP; in
|
||||
// production this is Server.resolveServerURLIP. It is injected so this can
|
||||
// be tested without a full Server.
|
||||
func Check443Reachability(
|
||||
httpsListenerPort int,
|
||||
serverURL string,
|
||||
lanResolver func(string) (string, error),
|
||||
timeout time.Duration,
|
||||
) Probe443Result {
|
||||
if httpsListenerPort == 443 {
|
||||
return Probe443Result{Skipped: true}
|
||||
}
|
||||
|
||||
res := Probe443Result{}
|
||||
|
||||
if err := ProbeTCP("127.0.0.1", 443, timeout); err != nil {
|
||||
res.Localhost.Error = err.Error()
|
||||
} else {
|
||||
res.Localhost.Reachable = true
|
||||
}
|
||||
|
||||
lanIP, resolveErr := lanResolver(serverURL)
|
||||
if resolveErr != nil {
|
||||
res.LAN.Error = "cannot resolve LAN target: " + resolveErr.Error()
|
||||
return res
|
||||
}
|
||||
|
||||
res.LANHost = lanIP
|
||||
|
||||
if err := ProbeTCP(lanIP, 443, timeout); err != nil {
|
||||
res.LAN.Error = err.Error()
|
||||
} else {
|
||||
res.LAN.Reachable = true
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// PortFromHTTPSServerURL extracts the numeric port from httpsServerURL. It
|
||||
// returns 0 if the URL is empty, malformed, or has no explicit port — in
|
||||
// that case the caller cannot make a determination about :443 and should
|
||||
// treat the result as "unknown" rather than "definitely not 443".
|
||||
func PortFromHTTPSServerURL(httpsServerURL string) int {
|
||||
if httpsServerURL == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
u, err := url.Parse(httpsServerURL)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
portStr := u.Port()
|
||||
if portStr == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return port
|
||||
}
|
||||
|
||||
// FormatPreflightGuidance returns a multi-line, human-readable warning
|
||||
// summarising a failing Probe443Result, with actionable next steps. The
|
||||
// returned string ends without a trailing newline so callers may use it
|
||||
// with log.Print or log.Printf as they prefer.
|
||||
func FormatPreflightGuidance(httpsListenerPort int, res Probe443Result) string {
|
||||
if res.Skipped {
|
||||
return ""
|
||||
}
|
||||
|
||||
if res.Localhost.Reachable && res.LAN.Reachable {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := []string{
|
||||
fmt.Sprintf("[WARN] HTTPS pre-flight: speakers connect to :443 but AfterTouch listens on :%d.", httpsListenerPort),
|
||||
}
|
||||
|
||||
if res.Localhost.Reachable {
|
||||
lines = append(lines, " - localhost:443: reachable ✓")
|
||||
} else {
|
||||
lines = append(lines, " - localhost:443: "+res.Localhost.Error)
|
||||
}
|
||||
|
||||
switch {
|
||||
case res.LAN.Reachable:
|
||||
lines = append(lines, fmt.Sprintf(" - %s:443 (LAN): reachable ✓", res.LANHost))
|
||||
case res.LANHost != "":
|
||||
lines = append(lines, fmt.Sprintf(" - %s:443 (LAN): %s", res.LANHost, res.LAN.Error))
|
||||
default:
|
||||
lines = append(lines, " - LAN: "+res.LAN.Error)
|
||||
}
|
||||
|
||||
lines = append(lines,
|
||||
" Speakers will fail with Curl 7 / connection refused until :443 is routed to AfterTouch. Options:",
|
||||
" 1. iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-port "+strconv.Itoa(httpsListenerPort),
|
||||
" 2. setcap cap_net_bind_service=+ep <binary> and pass --https-port=443",
|
||||
" 3. reverse proxy (nginx/caddy) terminating TLS on :443",
|
||||
" See docs/guides/HTTPS-SETUP.md for details.",
|
||||
)
|
||||
|
||||
out := ""
|
||||
|
||||
for i, l := range lines {
|
||||
if i > 0 {
|
||||
out += "\n"
|
||||
}
|
||||
|
||||
out += l
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestProbeTCP_OpenPortSucceeds(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to start listener: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
|
||||
if err := ProbeTCP("127.0.0.1", port, 500*time.Millisecond); err != nil {
|
||||
t.Errorf("expected probe of open port to succeed, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeTCP_ClosedPortFails(t *testing.T) {
|
||||
// Bind, capture port, close — leaves the port verifiably unbound.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to start listener: %v", err)
|
||||
}
|
||||
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
_ = ln.Close()
|
||||
|
||||
if err := ProbeTCP("127.0.0.1", port, 500*time.Millisecond); err == nil {
|
||||
t.Errorf("expected probe of closed port to fail, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheck443Reachability_SkipsWhenListenerOn443(t *testing.T) {
|
||||
res := Check443Reachability(443, "http://example.test:8000", func(string) (string, error) {
|
||||
t.Errorf("resolver should not be called when listener is on :443")
|
||||
return "", nil
|
||||
}, 100*time.Millisecond)
|
||||
|
||||
if !res.Skipped {
|
||||
t.Errorf("expected Skipped=true when httpsListenerPort=443, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheck443Reachability_ReportsResolverError(t *testing.T) {
|
||||
res := Check443Reachability(8443, "http://broken", func(string) (string, error) {
|
||||
return "", errResolve("no DNS")
|
||||
}, 100*time.Millisecond)
|
||||
|
||||
if res.Skipped {
|
||||
t.Errorf("expected Skipped=false, got true")
|
||||
}
|
||||
|
||||
if res.LAN.Reachable {
|
||||
t.Errorf("expected LAN.Reachable=false, got true")
|
||||
}
|
||||
|
||||
if !strings.Contains(res.LAN.Error, "cannot resolve LAN target") {
|
||||
t.Errorf("expected LAN.Error to wrap resolver failure, got %q", res.LAN.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortFromHTTPSServerURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want int
|
||||
}{
|
||||
{"", 0},
|
||||
{"https://example.test:8443", 8443},
|
||||
{"https://example.test:443", 443},
|
||||
{"https://example.test", 0},
|
||||
{":::not a url", 0},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got := PortFromHTTPSServerURL(tc.in)
|
||||
if got != tc.want {
|
||||
t.Errorf("PortFromHTTPSServerURL(%q) = %d, want %d", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPreflightGuidance_SkippedAndAllOK(t *testing.T) {
|
||||
if FormatPreflightGuidance(443, Probe443Result{Skipped: true}) != "" {
|
||||
t.Errorf("expected empty guidance when skipped")
|
||||
}
|
||||
|
||||
bothOK := Probe443Result{
|
||||
Localhost: ProbeOutcome{Reachable: true},
|
||||
LAN: ProbeOutcome{Reachable: true},
|
||||
LANHost: "10.0.0.1",
|
||||
}
|
||||
if FormatPreflightGuidance(8443, bothOK) != "" {
|
||||
t.Errorf("expected empty guidance when both probes succeed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPreflightGuidance_BothFailMentionsRedirectPort(t *testing.T) {
|
||||
res := Probe443Result{
|
||||
Localhost: ProbeOutcome{Error: "connection refused"},
|
||||
LAN: ProbeOutcome{Error: "connection refused"},
|
||||
LANHost: "192.168.1.151",
|
||||
}
|
||||
|
||||
out := FormatPreflightGuidance(8443, res)
|
||||
if !strings.Contains(out, "--to-port 8443") {
|
||||
t.Errorf("guidance must reference configured listener port for iptables, got: %s", out)
|
||||
}
|
||||
|
||||
if !strings.Contains(out, "192.168.1.151:443") {
|
||||
t.Errorf("guidance must mention probed LAN host, got: %s", out)
|
||||
}
|
||||
|
||||
if !strings.Contains(out, "[WARN]") {
|
||||
t.Errorf("guidance must be marked as a warning, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
type errResolve string
|
||||
|
||||
func (e errResolve) Error() string { return string(e) }
|
||||
|
||||
func TestCheck443Reachability_LANProbeMatchesListenerOutcome(t *testing.T) {
|
||||
// Spin up a listener on a random port and use that port via resolver
|
||||
// trickery: we point the LAN host at 127.0.0.1 and rely on the fact that
|
||||
// nothing answers on :443 in test environments. The point of this test
|
||||
// is to lock in the result-shape: when localhost:443 is closed (the
|
||||
// default in CI), the function still returns a well-formed result and
|
||||
// reports the resolved LAN host.
|
||||
res := Check443Reachability(8443, "http://1.2.3.4:8000", func(string) (string, error) {
|
||||
return "1.2.3.4", nil
|
||||
}, 200*time.Millisecond)
|
||||
|
||||
if res.Skipped {
|
||||
t.Fatalf("expected Skipped=false, got true")
|
||||
}
|
||||
|
||||
if res.LANHost != "1.2.3.4" {
|
||||
t.Errorf("expected LANHost=1.2.3.4, got %q", res.LANHost)
|
||||
}
|
||||
|
||||
// In any sane CI environment nothing is listening on :443, so both
|
||||
// probes should report errors. We don't assert the exact error string
|
||||
// (varies by OS) but we do assert it's populated.
|
||||
if res.LAN.Reachable {
|
||||
t.Errorf("did not expect LAN:443 to be reachable in test env")
|
||||
}
|
||||
|
||||
if res.LAN.Error == "" {
|
||||
t.Errorf("expected LAN.Error to be populated when unreachable")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
|
||||
)
|
||||
|
||||
// TestPrimeDeviceWithSpotify_RegistersMargeSource is a regression test for the
|
||||
// "AddPreset - failed due to invalid SourceID" failure observed when storing a
|
||||
// Spotify preset on a primed device. The watchdog priming path used to push
|
||||
// ZeroConf credentials without writing a SPOTIFY ConfiguredSource into the
|
||||
// marge datastore — so marge.UpdatePreset later had nothing to match
|
||||
// SourceID="SPOTIFY" against and rejected the storePreset request.
|
||||
//
|
||||
// This test verifies that PrimeDeviceWithSpotify now also calls marge.AddSource
|
||||
// for the device's account, producing a ConfiguredSource with
|
||||
// SourceProviderID="15" (constants.SpotifyProviderID).
|
||||
func TestPrimeDeviceWithSpotify_RegistersMargeSource(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
ds := datastore.NewDataStore(tmpDir)
|
||||
server := NewServer(ds, nil, "http://localhost", false, false, false)
|
||||
|
||||
// Fake speaker that accepts the ZeroConf push via the simplified
|
||||
// (non-DH) fallback AND records whether /notification (sourcesUpdated)
|
||||
// was hit.
|
||||
var notified atomic.Bool
|
||||
|
||||
speakerTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/notification" {
|
||||
notified.Store(true)
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = io.WriteString(w, `<?xml version="1.0" encoding="UTF-8" ?><status>/notification</status>`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
switch r.URL.Query().Get("action") {
|
||||
case "getInfo":
|
||||
http.Error(w, "not supported", http.StatusNotFound)
|
||||
case "addUser":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer speakerTS.Close()
|
||||
|
||||
speakerHostPort := strings.TrimPrefix(speakerTS.URL, "http://")
|
||||
|
||||
speakerHost, _, err := net.SplitHostPort(speakerHostPort)
|
||||
if err != nil {
|
||||
t.Fatalf("split speaker URL: %v", err)
|
||||
}
|
||||
|
||||
// Register the device under a real account so the IP→account lookup succeeds.
|
||||
const accountID = "acc-prime"
|
||||
const deviceID = "DEVPRIME"
|
||||
devInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: deviceID,
|
||||
AccountID: accountID,
|
||||
Name: "Test Speaker",
|
||||
IPAddress: speakerHost,
|
||||
}
|
||||
|
||||
if err := ds.SaveDeviceInfo(accountID, deviceID, devInfo); err != nil {
|
||||
t.Fatalf("SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
// marge.AddSource walks the account/devices dir — make sure the per-device
|
||||
// subdir exists so the source actually gets persisted.
|
||||
if err := os.MkdirAll(filepath.Join(ds.AccountDevicesDir(accountID), deviceID), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll device dir: %v", err)
|
||||
}
|
||||
|
||||
// Pre-seed a linked Spotify account so PrimeDeviceWithSpotify has something
|
||||
// to push. The token is valid for an hour so GetFreshToken won't try to
|
||||
// refresh against a live endpoint. We point the token endpoint at a noop
|
||||
// URL just in case, so a stray refresh would fail loudly rather than fan
|
||||
// out to the internet.
|
||||
spotifyDir := filepath.Join(tmpDir, "spotify")
|
||||
if err := os.MkdirAll(spotifyDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll spotify dir: %v", err)
|
||||
}
|
||||
|
||||
accountsPayload := map[string]map[string]any{
|
||||
"spotify-user": {
|
||||
"user_id": "spotify-user",
|
||||
"display_name": "Spotify User",
|
||||
"email": "user@example.com",
|
||||
"access_token": "fresh-access-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"expires_at": time.Now().Add(time.Hour).Unix(),
|
||||
"bose_secret": "bs-deadbeef",
|
||||
},
|
||||
}
|
||||
accountsJSON, err := json.Marshal(accountsPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal accounts: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(spotifyDir, "accounts.json"), accountsJSON, 0o600); err != nil {
|
||||
t.Fatalf("write accounts.json: %v", err)
|
||||
}
|
||||
|
||||
ss := spotify.NewSpotifyService("client-id", "client-secret", "http://localhost/callback", tmpDir)
|
||||
// Unused fallback token endpoint — defensive in case the test ever drifts
|
||||
// to an expired token.
|
||||
ss.SetEndpoints("http://127.0.0.1:1/token", "http://127.0.0.1:1")
|
||||
|
||||
if err := ss.Load(); err != nil {
|
||||
t.Fatalf("Load spotify accounts: %v", err)
|
||||
}
|
||||
|
||||
if len(ss.GetAccounts()) != 1 {
|
||||
t.Fatalf("expected 1 spotify account after Load, got %d", len(ss.GetAccounts()))
|
||||
}
|
||||
|
||||
server.SetSpotifyService(ss)
|
||||
|
||||
// Sanity: no SPOTIFY source registered yet.
|
||||
sources, _ := ds.GetConfiguredSources(accountID, deviceID)
|
||||
if hasSpotifySource(sources) {
|
||||
t.Fatalf("precondition failed: SPOTIFY source already present before priming")
|
||||
}
|
||||
|
||||
// Pass host:port so the ZeroConf push hits our test server instead of the
|
||||
// hard-coded :8200 fallback. The IP→account lookup strips the port before
|
||||
// matching against devInfo.IPAddress.
|
||||
server.PrimeDeviceWithSpotify(speakerHostPort)
|
||||
|
||||
sources, err = ds.GetConfiguredSources(accountID, deviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetConfiguredSources after priming: %v", err)
|
||||
}
|
||||
|
||||
if !hasSpotifySource(sources) {
|
||||
for _, src := range sources {
|
||||
t.Logf("source after priming: ID=%s providerID=%s keyType=%s account=%s", src.ID, src.SourceProviderID, src.SourceKey.Type, src.SourceKey.Account)
|
||||
}
|
||||
|
||||
t.Fatalf("expected a SPOTIFY ConfiguredSource (providerID=%d) after priming", constants.SpotifyProviderID)
|
||||
}
|
||||
|
||||
// The speaker's on-device Sources.xml only refreshes when we tell it to —
|
||||
// without this notification storePreset keeps failing even though marge
|
||||
// already has the SPOTIFY source.
|
||||
deadline := time.Now().Add(1 * time.Second)
|
||||
for time.Now().Before(deadline) && !notified.Load() {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
|
||||
if !notified.Load() {
|
||||
t.Errorf("speaker did not receive a sourcesUpdated /notification after priming")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrimeDeviceWithSpotify_SkipsWhenDeviceUnmapped ensures that priming a
|
||||
// device whose IP is not associated with any account does NOT fabricate a
|
||||
// source under the "default" account — the previous behavior would silently
|
||||
// pollute marge with sources for devices that never asked.
|
||||
func TestPrimeDeviceWithSpotify_SkipsWhenDeviceUnmapped(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
ds := datastore.NewDataStore(tmpDir)
|
||||
server := NewServer(ds, nil, "http://localhost", false, false, false)
|
||||
|
||||
speakerTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Query().Get("action") {
|
||||
case "getInfo":
|
||||
http.Error(w, "not supported", http.StatusNotFound)
|
||||
case "addUser":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer speakerTS.Close()
|
||||
|
||||
speakerURL, _ := url.Parse(speakerTS.URL)
|
||||
speakerHostPort := speakerURL.Host
|
||||
|
||||
// Pre-seed a Spotify account but do NOT register any device.
|
||||
spotifyDir := filepath.Join(tmpDir, "spotify")
|
||||
_ = os.MkdirAll(spotifyDir, 0o755)
|
||||
accountsPayload := map[string]map[string]any{
|
||||
"spotify-user": {
|
||||
"user_id": "spotify-user",
|
||||
"display_name": "Spotify User",
|
||||
"access_token": "fresh-access-token",
|
||||
"expires_at": time.Now().Add(time.Hour).Unix(),
|
||||
"bose_secret": "bs-deadbeef",
|
||||
},
|
||||
}
|
||||
|
||||
accountsJSON, err := json.Marshal(accountsPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal accounts: %v", err)
|
||||
}
|
||||
|
||||
_ = os.WriteFile(filepath.Join(spotifyDir, "accounts.json"), accountsJSON, 0o600)
|
||||
|
||||
ss := spotify.NewSpotifyService("client-id", "client-secret", "http://localhost/callback", tmpDir)
|
||||
ss.SetEndpoints("http://127.0.0.1:1/token", "http://127.0.0.1:1")
|
||||
|
||||
if err := ss.Load(); err != nil {
|
||||
t.Fatalf("Load spotify accounts: %v", err)
|
||||
}
|
||||
|
||||
server.SetSpotifyService(ss)
|
||||
|
||||
server.PrimeDeviceWithSpotify(speakerHostPort)
|
||||
|
||||
// "default" account should have no SPOTIFY source added by us.
|
||||
sources, _ := ds.GetConfiguredSources("default", "")
|
||||
if hasSpotifySource(sources) {
|
||||
t.Errorf("priming an unmapped device wrote a SPOTIFY source under 'default' — should have been skipped")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrimeDeviceWithSpotify_LiveMargeAccountUUIDWins covers the production
|
||||
// scenario the previous test didn't catch: a device whose datastore
|
||||
// ServiceDeviceInfo.AccountID is "default" (or stale) but whose live
|
||||
// :8090/info reports a real paired margeAccountUUID. The SPOTIFY source must
|
||||
// land under the paired account — that's the account marge.UpdatePreset
|
||||
// receives storePreset under, so writing anywhere else means the preset still
|
||||
// fails with "AddPreset - failed due to invalid SourceID".
|
||||
//
|
||||
// Mirrors setup.populateDeviceInfo's resolution order (datastore ← live /info)
|
||||
// rather than guessing.
|
||||
func TestPrimeDeviceWithSpotify_LiveMargeAccountUUIDWins(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
ds := datastore.NewDataStore(tmpDir)
|
||||
server := NewServer(ds, nil, "http://localhost", false, false, false)
|
||||
|
||||
const (
|
||||
datastoreAccount = "default" // stale / fallback
|
||||
pairedAccount = "1111111" // live margeAccountUUID from /info
|
||||
deviceID = "DEVPAIR"
|
||||
)
|
||||
|
||||
// Fake speaker that serves both /info and the ZeroConf /zc.
|
||||
var speakerHost string
|
||||
|
||||
speakerTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/info"):
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = io.WriteString(w, `<?xml version="1.0" encoding="UTF-8" ?>`+
|
||||
`<info deviceID="`+deviceID+`">`+
|
||||
`<name>Paired Speaker</name><type>SoundTouch 20</type>`+
|
||||
`<margeAccountUUID>`+pairedAccount+`</margeAccountUUID>`+
|
||||
`</info>`)
|
||||
case r.URL.Path == "/notification":
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = io.WriteString(w, `<?xml version="1.0" encoding="UTF-8" ?><status>/notification</status>`)
|
||||
default:
|
||||
switch r.URL.Query().Get("action") {
|
||||
case "getInfo":
|
||||
http.Error(w, "not supported", http.StatusNotFound)
|
||||
case "addUser":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer speakerTS.Close()
|
||||
|
||||
speakerHostPort := strings.TrimPrefix(speakerTS.URL, "http://")
|
||||
speakerHost, _, _ = net.SplitHostPort(speakerHostPort)
|
||||
|
||||
// Register the device under the STALE account so the datastore lookup
|
||||
// would yield the wrong answer if used in isolation.
|
||||
devInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: deviceID,
|
||||
AccountID: datastoreAccount,
|
||||
Name: "Paired Speaker",
|
||||
IPAddress: speakerHost,
|
||||
}
|
||||
if err := ds.SaveDeviceInfo(datastoreAccount, deviceID, devInfo); err != nil {
|
||||
t.Fatalf("SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
// And make sure the paired account's device dir exists so
|
||||
// marge.AddSource can persist the source (it walks accounts/devices/...).
|
||||
if err := os.MkdirAll(filepath.Join(ds.AccountDevicesDir(pairedAccount), deviceID), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll paired dir: %v", err)
|
||||
}
|
||||
|
||||
// Pre-seed a Spotify account so priming has something to push.
|
||||
spotifyDir := filepath.Join(tmpDir, "spotify")
|
||||
_ = os.MkdirAll(spotifyDir, 0o755)
|
||||
accountsPayload := map[string]map[string]any{
|
||||
"spotify-user": {
|
||||
"user_id": "spotify-user",
|
||||
"display_name": "Spotify User",
|
||||
"access_token": "fresh-access-token",
|
||||
"expires_at": time.Now().Add(time.Hour).Unix(),
|
||||
"bose_secret": "bs-deadbeef",
|
||||
},
|
||||
}
|
||||
|
||||
accountsJSON, err := json.Marshal(accountsPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal accounts: %v", err)
|
||||
}
|
||||
|
||||
_ = os.WriteFile(filepath.Join(spotifyDir, "accounts.json"), accountsJSON, 0o600)
|
||||
|
||||
ss := spotify.NewSpotifyService("client-id", "client-secret", "http://localhost/callback", tmpDir)
|
||||
ss.SetEndpoints("http://127.0.0.1:1/token", "http://127.0.0.1:1")
|
||||
|
||||
if err := ss.Load(); err != nil {
|
||||
t.Fatalf("Load spotify accounts: %v", err)
|
||||
}
|
||||
|
||||
server.SetSpotifyService(ss)
|
||||
|
||||
// Wire a real setup.Manager so resolvePairedAccount reaches /info.
|
||||
// HTTPGet uses the default net/http client, which hits the httptest
|
||||
// server directly via deviceIP=host:port.
|
||||
server.sm = setup.NewManager("http://localhost", ds, nil)
|
||||
|
||||
server.PrimeDeviceWithSpotify(speakerHostPort)
|
||||
|
||||
// SPOTIFY source must be under the PAIRED account, not the datastore one.
|
||||
pairedSources, err := ds.GetConfiguredSources(pairedAccount, deviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetConfiguredSources(paired): %v", err)
|
||||
}
|
||||
|
||||
if !hasSpotifySource(pairedSources) {
|
||||
t.Errorf("expected SPOTIFY source under paired account %s, got %d sources", pairedAccount, len(pairedSources))
|
||||
}
|
||||
|
||||
// And it must NOT have been written under the stale datastore account.
|
||||
staleSources, _ := ds.GetConfiguredSources(datastoreAccount, deviceID)
|
||||
if hasSpotifySource(staleSources) {
|
||||
t.Errorf("SPOTIFY source unexpectedly written under stale datastore account %s — should follow live margeAccountUUID", datastoreAccount)
|
||||
}
|
||||
}
|
||||
|
||||
func hasSpotifySource(sources []models.ConfiguredSource) bool {
|
||||
for _, src := range sources {
|
||||
if src.SourceProviderID == "15" || src.SourceKey.Type == constants.ProviderSpotify {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -3,19 +3,24 @@ package handlers
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/amazon"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/marge"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
|
||||
@@ -238,18 +243,75 @@ func (s *Server) SetDNSSettings(enabled bool, upstream, bind string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveServerURLIPForPreflight is an exported wrapper around resolveServerURLIP
|
||||
// so callers outside the package (e.g. the service startup pre-flight) can
|
||||
// reuse the same resolution path the DNS server uses.
|
||||
func (s *Server) ResolveServerURLIPForPreflight(serverURL string) (string, error) {
|
||||
return s.resolveServerURLIP(serverURL)
|
||||
}
|
||||
|
||||
// resolveServerURLIP returns the IP that the DNS server would hand out as the
|
||||
// intercept answer for the given server URL. An empty URL, empty hostname, or a
|
||||
// hostname that cannot be resolved to an IP is reported as an error so callers
|
||||
// can refuse to start (or reject user input) instead of silently degrading.
|
||||
// "localhost" is treated as 127.0.0.1.
|
||||
func (s *Server) resolveServerURLIP(serverURL string) (string, error) {
|
||||
if strings.TrimSpace(serverURL) == "" {
|
||||
return "", fmt.Errorf("server URL is empty")
|
||||
}
|
||||
|
||||
u, err := url.Parse(serverURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid server URL %q: %w", serverURL, err)
|
||||
}
|
||||
|
||||
hostname := u.Hostname()
|
||||
if hostname == "" {
|
||||
return "", fmt.Errorf("server URL %q has no hostname", serverURL)
|
||||
}
|
||||
|
||||
if hostname == "localhost" {
|
||||
return "127.0.0.1", nil
|
||||
}
|
||||
|
||||
if ip := net.ParseIP(hostname); ip != nil {
|
||||
return ip.String(), nil
|
||||
}
|
||||
|
||||
// Prefer the setup manager's resolver (it cascades through device SSH ping
|
||||
// then system DNS). Fall back to plain system DNS when no manager is wired,
|
||||
// so this works in tests and lightweight server constructions.
|
||||
if s.sm != nil {
|
||||
if resolved := s.sm.GetResolvedIP(hostname); net.ParseIP(resolved) != nil {
|
||||
return resolved, nil
|
||||
}
|
||||
} else if ips, lookupErr := net.LookupIP(hostname); lookupErr == nil {
|
||||
for _, ip := range ips {
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
return v4.String(), nil
|
||||
}
|
||||
}
|
||||
|
||||
if len(ips) > 0 {
|
||||
return ips[0].String(), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("hostname %q did not resolve to an IP — "+
|
||||
"set the server URL to an IP, or to a hostname this host can resolve",
|
||||
hostname)
|
||||
}
|
||||
|
||||
func (s *Server) startDNSDiscovery(bind string, upstreamList []string) {
|
||||
log.Printf("[DNS] Starting DNS discovery server on %s", bind)
|
||||
|
||||
u, _ := url.Parse(s.serverURL)
|
||||
serviceIP, err := s.resolveServerURLIP(s.serverURL)
|
||||
if err != nil {
|
||||
log.Printf("[DNS] Cannot start DNS discovery server: %v", err)
|
||||
|
||||
serviceIP := u.Hostname()
|
||||
if serviceIP == "localhost" || serviceIP == "" {
|
||||
serviceIP = "127.0.0.1"
|
||||
}
|
||||
s.dnsEnabled = false
|
||||
|
||||
if s.sm != nil {
|
||||
serviceIP = s.sm.GetResolvedIP(serviceIP)
|
||||
return
|
||||
}
|
||||
|
||||
s.dnsDiscovery = discovery.NewDNSDiscovery(upstreamList, serviceIP)
|
||||
@@ -602,13 +664,123 @@ func (s *Server) PrimeDeviceWithSpotify(deviceIP string) {
|
||||
|
||||
log.Printf("[Spotify Watchdog] Proactively priming %s with Spotify user %s", deviceIP, username)
|
||||
|
||||
// Register the SPOTIFY source in our marge datastore before pushing credentials.
|
||||
// Without this, storePreset later fails with "AddPreset - failed due to invalid SourceID"
|
||||
// because marge.UpdatePreset can't match SourceID="SPOTIFY" against any ConfiguredSource.
|
||||
s.registerSpotifySourceForDevice(deviceIP, accounts)
|
||||
|
||||
if err := s.pushSpotifyTokenToDevice(deviceIP, username, accessToken); err != nil {
|
||||
log.Printf("[Spotify Watchdog] Failed to prime %s: %v", deviceIP, err)
|
||||
// addUser may return a benign 404+empty-body no-op when the speaker
|
||||
// already has the activeUser set. The zeroconf-level log already
|
||||
// recorded the specifics; here we just upgrade the watchdog's view to
|
||||
// "primed" since marge holds the authoritative SPOTIFY source.
|
||||
if errors.Is(err, spotify.ErrAddUserNoOp) {
|
||||
log.Printf("[Spotify Watchdog] Successfully primed %s (ZeroConf addUser was an expected no-op)", deviceIP)
|
||||
} else {
|
||||
log.Printf("[Spotify Watchdog] Failed to prime %s: %v", deviceIP, err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[Spotify Watchdog] Successfully primed %s", deviceIP)
|
||||
}
|
||||
}
|
||||
|
||||
// registerSpotifySourceForDevice writes a SPOTIFY ConfiguredSource into the marge
|
||||
// datastore under the device's currently-paired account. No-op (with a log
|
||||
// message) if the device can't be resolved to an account — falling back to
|
||||
// "default" here would risk polluting an unrelated account's source list, and
|
||||
// any storePreset the device sends will be under its real paired account anyway.
|
||||
func (s *Server) registerSpotifySourceForDevice(deviceIP string, accounts []spotify.Account) {
|
||||
host := deviceIP
|
||||
if h, _, err := net.SplitHostPort(deviceIP); err == nil {
|
||||
host = h
|
||||
}
|
||||
|
||||
accountID, deviceID := s.resolvePairedAccount(deviceIP, host)
|
||||
if accountID == "" {
|
||||
log.Printf("[Spotify Watchdog] No paired account for %s yet — skipping marge source registration", deviceIP)
|
||||
return
|
||||
}
|
||||
|
||||
registered := false
|
||||
|
||||
for _, acc := range accounts {
|
||||
credential := acc.BoseSecret
|
||||
if credential == "" {
|
||||
credential = acc.AccessToken
|
||||
}
|
||||
|
||||
if _, err := marge.AddSource(s.ds, accountID, acc.UserID, strconv.Itoa(constants.SpotifyProviderID), credential, "token_version_3", acc.DisplayName); err != nil {
|
||||
log.Printf("[Spotify Watchdog] Failed to register Spotify source for account %s: %v", accountID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("[Spotify Watchdog] Registered Spotify source %s for account %s (device %s)", acc.UserID, accountID, deviceID)
|
||||
|
||||
registered = true
|
||||
}
|
||||
|
||||
// Tell the speaker its sources list changed so it re-fetches from marge.
|
||||
// Without this its on-device Sources.xml stays stale until something else
|
||||
// triggers a sync — which leaves storePreset failing with
|
||||
// "AddPreset - failed due to invalid SourceID" even though our marge
|
||||
// datastore already has the SPOTIFY entry.
|
||||
if registered && deviceID != "" {
|
||||
c := client.NewClientFromHost(deviceIP)
|
||||
if err := c.NotifySourcesUpdated(deviceID); err != nil {
|
||||
log.Printf("[Spotify Watchdog] sourcesUpdated notification for %s failed: %v", deviceIP, err)
|
||||
} else {
|
||||
log.Printf("[Spotify Watchdog] Notified %s to re-sync sources (deviceID=%s)", deviceIP, deviceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolvePairedAccount returns the device's currently-paired account ID and its
|
||||
// canonical deviceID. It prefers the live :8090/info margeAccountUUID (matches
|
||||
// what the device will actually send on storePreset) and falls back to the
|
||||
// datastore record. Mirrors setup.populateDeviceInfo's resolution order so
|
||||
// priming and migration agree on which account a device belongs to.
|
||||
//
|
||||
// deviceIP is the original input (may carry a :port for tests); host is the
|
||||
// bare host for datastore IPAddress matching.
|
||||
func (s *Server) resolvePairedAccount(deviceIP, host string) (accountID, deviceID string) {
|
||||
if devInfo := s.findExistingDeviceInfoByIP(host); devInfo != nil {
|
||||
accountID = devInfo.AccountID
|
||||
deviceID = devInfo.DeviceID
|
||||
}
|
||||
|
||||
if s.sm != nil {
|
||||
if info, err := s.sm.GetLiveDeviceInfo(deviceIP); err == nil {
|
||||
if info.MargeAccountUUID != "" {
|
||||
accountID = info.MargeAccountUUID
|
||||
}
|
||||
|
||||
if info.DeviceID != "" {
|
||||
deviceID = info.DeviceID
|
||||
}
|
||||
} else {
|
||||
log.Printf("[Spotify Watchdog] live /info lookup for %s failed: %v (falling back to datastore account=%q)", deviceIP, err, accountID)
|
||||
}
|
||||
}
|
||||
|
||||
return accountID, deviceID
|
||||
}
|
||||
|
||||
// findExistingDeviceInfoByIP looks up a device record by IP address across all accounts.
|
||||
func (s *Server) findExistingDeviceInfoByIP(ip string) *models.ServiceDeviceInfo {
|
||||
allDevices, err := s.ds.ListAllDevices()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := range allDevices {
|
||||
if allDevices[i].IPAddress == ip {
|
||||
return &allDevices[i]
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) pushSpotifyTokenToDevice(deviceIP, username, accessToken string) error {
|
||||
var zcURL string
|
||||
if _, _, err := net.SplitHostPort(deviceIP); err == nil {
|
||||
@@ -644,7 +816,11 @@ func (s *Server) PrimeDeviceWithAmazon(deviceIP string) {
|
||||
log.Printf("[Amazon Watchdog] Proactively priming %s with Amazon user %s", deviceIP, username)
|
||||
|
||||
if err := s.pushAmazonTokenToDevice(deviceIP, username, accessToken); err != nil {
|
||||
log.Printf("[Amazon Watchdog] Failed to prime %s: %v", deviceIP, err)
|
||||
if errors.Is(err, amazon.ErrAddUserNoOp) {
|
||||
log.Printf("[Amazon Watchdog] Successfully primed %s (ZeroConf addUser was an expected no-op)", deviceIP)
|
||||
} else {
|
||||
log.Printf("[Amazon Watchdog] Failed to prime %s: %v", deviceIP, err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[Amazon Watchdog] Successfully primed %s", deviceIP)
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?><device deviceid="884AEAEEBD27"><name>Wohnzimmer SB</name><macaddress>884AEAEEBD27</macaddress></device>
|
||||
@@ -159,6 +159,8 @@
|
||||
<label for="target-domain">Target Domain:</label>
|
||||
<input type="text" id="target-domain" placeholder="http://192.168.x.x:8000" style="width: 300px"/>
|
||||
<span style="font-size: 0.8em; color: #666">(Standard services URL)</span>
|
||||
<div id="target-domain-resolved" style="font-size: 0.85em; margin-top: 4px; min-height: 1.2em"></div>
|
||||
<div id="https-443-status" style="font-size: 0.85em; margin-top: 4px; min-height: 1.2em"></div>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px">
|
||||
<strong>Device Discovery:</strong>
|
||||
|
||||
@@ -1,3 +1,67 @@
|
||||
// FAST_ERROR_MS is the timing threshold used to distinguish "no listener
|
||||
// on :443" (very fast browser error, usually TCP RST) from "something
|
||||
// answered TCP, TLS handshake failed because of untrusted cert" (slower
|
||||
// error). The exact cutoff is fuzzy and varies by browser/network, but
|
||||
// the gap between the two cases is large enough (single-digit ms vs.
|
||||
// 100+ ms) that this works as a heuristic. We don't expose milliseconds
|
||||
// to the user — they'd be misleading without context.
|
||||
const FAST_ERROR_MS = 150;
|
||||
|
||||
async function probeBrowser443(lanHost, listenerPort, statusEl, serverLocalhostOK, serverLanOK) {
|
||||
const line = document.createElement("div");
|
||||
line.style.fontSize = "0.85em";
|
||||
line.style.marginTop = "2px";
|
||||
line.style.color = "#666";
|
||||
line.innerText = "⏱ Checking from your browser too…";
|
||||
statusEl.appendChild(line);
|
||||
|
||||
const start = performance.now();
|
||||
let outcome;
|
||||
try {
|
||||
// mode:"no-cors" lets the request go on the wire even though the response
|
||||
// would be opaque. We only care about success-or-fail and timing — not
|
||||
// the response body, which we can't read anyway with an untrusted cert.
|
||||
await fetch("https://" + lanHost + ":443/", {
|
||||
mode: "no-cors",
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
outcome = { reached: true, elapsed: performance.now() - start };
|
||||
} catch (e) {
|
||||
outcome = { reached: false, elapsed: performance.now() - start, err: e };
|
||||
}
|
||||
|
||||
let msg;
|
||||
let color;
|
||||
if (outcome.reached) {
|
||||
color = "#2e7d32";
|
||||
msg = "✅ Your browser also reaches <code>:443</code> on <code>" + lanHost + "</code>.";
|
||||
} else if (outcome.elapsed >= FAST_ERROR_MS) {
|
||||
color = "#2e7d32";
|
||||
msg = "✅ Your browser reached <code>:" + lanHost + ":443</code> — the failure that follows is the expected " +
|
||||
"untrusted-CA error, not a missing listener.";
|
||||
} else {
|
||||
color = "#c62828";
|
||||
msg = "❌ Your browser sees no listener on <code>" + lanHost + ":443</code> " +
|
||||
"(fast error, likely connection refused).";
|
||||
}
|
||||
|
||||
// Hint when server and browser disagree — that almost always means NAT,
|
||||
// split-horizon DNS, or a host firewall sitting between AfterTouch and
|
||||
// the speaker. Worth pointing out because it's invisible to the server.
|
||||
const browserSees443 = outcome.reached || outcome.elapsed >= FAST_ERROR_MS;
|
||||
if (serverLanOK && !browserSees443) {
|
||||
msg += " <em>(Server sees :443 but your browser doesn't — check intermediate firewalls / split-horizon DNS.)</em>";
|
||||
color = "#c62828";
|
||||
} else if (!serverLanOK && browserSees443) {
|
||||
msg += " <em>(Your browser reaches :443 but the AfterTouch host can't — likely a host-firewall rule on the AfterTouch machine itself.)</em>";
|
||||
color = "#c62828";
|
||||
}
|
||||
|
||||
line.style.color = color;
|
||||
line.innerHTML = msg;
|
||||
}
|
||||
|
||||
async function fetchSpotifyStatus() {
|
||||
try {
|
||||
const settingsResponse = await fetch("/setup/settings");
|
||||
@@ -126,6 +190,68 @@ async function fetchSettings() {
|
||||
if (settings.server_url) {
|
||||
document.getElementById("target-domain").value = settings.server_url;
|
||||
}
|
||||
const resolved = document.getElementById("target-domain-resolved");
|
||||
if (resolved) {
|
||||
if (settings.server_url_resolved_ip) {
|
||||
resolved.style.color = "#2e7d32";
|
||||
resolved.innerHTML = "✅ DNS will hand out <code>" + settings.server_url_resolved_ip +
|
||||
"</code> for intercepted Bose hostnames. Speakers must be able to reach this address.";
|
||||
} else if (settings.server_url_resolve_error) {
|
||||
resolved.style.color = "#c62828";
|
||||
resolved.innerText = "❌ " + settings.server_url_resolve_error;
|
||||
} else {
|
||||
resolved.innerText = "";
|
||||
}
|
||||
}
|
||||
|
||||
const port443 = document.getElementById("https-443-status");
|
||||
if (port443) {
|
||||
// The :443 check only applies to the DNS-migration path. Hide the row
|
||||
// entirely when AfterTouch's DNS interception is off — those users are
|
||||
// either using SDK overrides (port-explicit URLs) or external DNS
|
||||
// interception (in which case they can read /setup/settings JSON
|
||||
// directly if they want the result).
|
||||
if (!settings.dns_enabled) {
|
||||
port443.innerHTML = "";
|
||||
} else if (settings.https_443_check_skipped) {
|
||||
port443.style.color = "#2e7d32";
|
||||
port443.innerHTML = "✅ HTTPS listener bound directly to <code>:443</code> — speakers can connect.";
|
||||
} else {
|
||||
const localhostOK = settings.https_443_localhost_reachable;
|
||||
const lanOK = settings.https_443_lan_reachable;
|
||||
const lanHost = settings.https_443_lan_host || "";
|
||||
const listenerPort = settings.https_listener_port || "8443";
|
||||
if (localhostOK && lanOK) {
|
||||
port443.style.color = "#2e7d32";
|
||||
port443.innerHTML = "✅ <code>:443</code> reachable on <code>localhost</code> and <code>" +
|
||||
(lanHost || "LAN address") + "</code> (forwarded to <code>:" + listenerPort + "</code>).";
|
||||
} else {
|
||||
port443.style.color = "#c62828";
|
||||
const details = [];
|
||||
details.push("localhost:443 " +
|
||||
(localhostOK ? "✓" : "❌ " + (settings.https_443_localhost_error || "unreachable")));
|
||||
details.push((lanHost || "LAN") + ":443 " +
|
||||
(lanOK ? "✓" : "❌ " + (settings.https_443_lan_error || "unreachable")));
|
||||
port443.innerHTML = "❌ Speakers connect to <code>:443</code> but AfterTouch listens on <code>:" +
|
||||
listenerPort + "</code>. " + details.join(" · ") +
|
||||
". Set up iptables / setcap / reverse proxy — see " +
|
||||
"<a href=\"https://github.com/gesellix/Bose-SoundTouch/blob/main/docs/guides/HTTPS-SETUP.md\" target=\"_blank\">HTTPS-SETUP.md</a>.";
|
||||
}
|
||||
|
||||
// Browser-side probe runs in parallel. Mirrors what speakers see from
|
||||
// the LAN; the server-side probe runs from inside AfterTouch's host
|
||||
// and can disagree when there is NAT / split-horizon / a firewall in
|
||||
// between. We can't see TLS-cert vs. TCP-RST from JS, so we fall back
|
||||
// to timing: a fast error suggests no listener; a slower error
|
||||
// suggests the connection got far enough to start TLS, which proves
|
||||
// something is answering. The CA cert is not trusted by the browser
|
||||
// by default, so a clean ✅ resolution is rare — that's fine, the
|
||||
// timing alone is the diagnostic signal.
|
||||
if (lanHost) {
|
||||
probeBrowser443(lanHost, listenerPort, port443, localhostOK, lanOK);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (settings.discovery_interval) {
|
||||
document.getElementById("discovery-interval").value = settings.discovery_interval;
|
||||
}
|
||||
@@ -1683,7 +1809,31 @@ async function updateDeviceInfo(deviceId, ip) {
|
||||
if (deviceIdEl && info.deviceID) deviceIdEl.innerText = info.deviceID;
|
||||
|
||||
const accountIdEl = row.querySelector(".col-accountid");
|
||||
if (accountIdEl && info.margeAccountUUID) accountIdEl.innerText = info.margeAccountUUID;
|
||||
if (accountIdEl) {
|
||||
if (info.margeAccountUUID) {
|
||||
accountIdEl.innerText = info.margeAccountUUID;
|
||||
accountIdEl.style.color = "#666";
|
||||
} else {
|
||||
// Empty <margeAccountUUID/> in /info → speaker is
|
||||
// either factory-reset or never paired. The
|
||||
// Migration tab's wizard already detects this
|
||||
// state and prompts for re-pairing; this badge
|
||||
// surfaces the affordance from the devices list
|
||||
// so users don't have to know to open the
|
||||
// Migration tab cold. See issue #234.
|
||||
accountIdEl.replaceChildren();
|
||||
const badge = document.createElement("a");
|
||||
badge.href = "#";
|
||||
badge.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
prepareMigration(deviceId);
|
||||
};
|
||||
badge.innerText = "⚠ Not paired — re-pair";
|
||||
badge.style.color = "#c62828";
|
||||
badge.title = "Open the Migration tab to re-pair this speaker (factory-reset or never paired).";
|
||||
accountIdEl.appendChild(badge);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to fetch live info for " + ip, error);
|
||||
@@ -2547,8 +2697,11 @@ function readPlanURLOptions() {
|
||||
// validateURL classifies a string as an OK service URL.
|
||||
// Empty value is valid (means "use the canonical default"). Otherwise
|
||||
// the URL must parse, the scheme must be http or https, the hostname
|
||||
// must be non-empty, and we reject "localhost" because the speaker
|
||||
// can't reach this machine via that name.
|
||||
// must be non-empty, and we flag loopback hostnames because they only
|
||||
// reach AfterTouch in the on-device-install case (AfterTouch running
|
||||
// on the speaker itself). For the typical "AfterTouch on a separate
|
||||
// host" deployment, the speaker can't reach loopback on a different
|
||||
// machine, so the URL must be a LAN-reachable IP or hostname.
|
||||
function validateURL(value) {
|
||||
const v = (value || "").trim();
|
||||
if (!v) return {ok: true, error: ""};
|
||||
@@ -2567,7 +2720,7 @@ function validateURL(value) {
|
||||
if (!u.hostname) return {ok: false, error: "hostname is empty"};
|
||||
|
||||
if (u.hostname === "localhost" || u.hostname === "127.0.0.1") {
|
||||
return {ok: false, error: "use the LAN IP/hostname, not localhost — the speaker can't reach this machine via that name"};
|
||||
return {ok: false, error: "loopback URL — speakers can only reach this if AfterTouch is installed on the speaker itself (on-device install). For the typical multi-device setup, use a LAN-reachable IP or hostname."};
|
||||
}
|
||||
|
||||
return {ok: true, error: ""};
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package marge
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// TestIssue253_PresetsXMLEditPropagatesToMargeResponse documents the
|
||||
// service-side half of issue #253:
|
||||
//
|
||||
// https://github.com/gesellix/Bose-SoundTouch/issues/253
|
||||
//
|
||||
// The reporter edits AfterTouch's persisted Presets.xml on disk and
|
||||
// expects the change to show up on the speaker's :8090/presets. That
|
||||
// propagation chain has three links:
|
||||
//
|
||||
// 1. disk → marge: AfterTouch's marge serves the edited XML when the
|
||||
// speaker GETs /streaming/account/.../device/.../presets (or
|
||||
// /full). This is the link this test exercises.
|
||||
// 2. marge → device: the speaker has to re-fetch (typically nudged by
|
||||
// a /streaming/support/power_on or by a sourcesUpdated
|
||||
// notification — not exercised here, that's a runbook concern).
|
||||
// 3. device → :8090: once the device's local cache updates, its
|
||||
// /presets endpoint reflects. Out of our reach.
|
||||
//
|
||||
// If link (1) is broken — e.g. marge caches the rendered XML between
|
||||
// requests, or ds.GetPresets returns stale data — neither (2) nor (3)
|
||||
// can recover, and the reporter's symptom is inevitable. This test
|
||||
// proves (1) is sound by:
|
||||
//
|
||||
// - Writing testdata/issue253/presets_v1.xml directly into the
|
||||
// datastore (no SavePresets — the reporter is editing on disk).
|
||||
// - Calling PresetsToXML, asserting v1's itemName and location land
|
||||
// in the rendered response.
|
||||
// - Overwriting the file with testdata/issue253/presets_v2.xml.
|
||||
// - Calling PresetsToXML again, asserting v2's itemName and
|
||||
// location land — and v1's are gone.
|
||||
//
|
||||
// If link (1) ever regresses (a caching layer added without
|
||||
// invalidation, a fs handle held open across edits, …), this test
|
||||
// fails on the second assertion. When that happens, fix the
|
||||
// invalidation rather than weakening the test.
|
||||
//
|
||||
// Pattern mirrors recents_sourceproviderid_regression_test.go: write
|
||||
// XML directly to the datastore filesystem, exercise the marge
|
||||
// function the handler uses (PresetsToXML at marge.go:370), assert on
|
||||
// the rendered bytes.
|
||||
func TestIssue253_PresetsXMLEditPropagatesToMargeResponse(t *testing.T) {
|
||||
v1, err := os.ReadFile(filepath.Join("testdata", "issue253", "presets_v1.xml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read v1 fixture: %v", err)
|
||||
}
|
||||
|
||||
v2, err := os.ReadFile(filepath.Join("testdata", "issue253", "presets_v2.xml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read v2 fixture: %v", err)
|
||||
}
|
||||
|
||||
// Fixture sanity — a typo in testdata would silently invalidate
|
||||
// the assertions below.
|
||||
if !strings.Contains(string(v1), "Initial Station") ||
|
||||
!strings.Contains(string(v1), "sINITIAL") {
|
||||
t.Fatalf("v1 fixture missing expected markers; got:\n%s", v1)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(v2), "Edited Station") ||
|
||||
!strings.Contains(string(v2), "sEDITED") {
|
||||
t.Fatalf("v2 fixture missing expected markers; got:\n%s", v2)
|
||||
}
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "issue253-*")
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir temp: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { _ = os.RemoveAll(tempDir) })
|
||||
|
||||
const (
|
||||
account = "issue253"
|
||||
deviceID = "DEADBEEFCAFE"
|
||||
)
|
||||
|
||||
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
|
||||
if err := os.MkdirAll(deviceDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir device dir: %v", err)
|
||||
}
|
||||
|
||||
presetsPath := filepath.Join(deviceDir, "Presets.xml")
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
|
||||
// First render: write v1 to disk, ask marge for the wire bytes.
|
||||
if err := os.WriteFile(presetsPath, v1, 0o644); err != nil {
|
||||
t.Fatalf("write v1: %v", err)
|
||||
}
|
||||
|
||||
render1, err := PresetsToXML(ds, account, deviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("PresetsToXML (v1): %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(render1), "Initial Station") {
|
||||
t.Errorf("v1 render missing 'Initial Station'; body:\n%s", render1)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(render1), "/v1/playback/station/sINITIAL") {
|
||||
t.Errorf("v1 render missing initial location; body:\n%s", render1)
|
||||
}
|
||||
|
||||
// Second render after on-disk edit: must reflect v2, not v1.
|
||||
if err := os.WriteFile(presetsPath, v2, 0o644); err != nil {
|
||||
t.Fatalf("write v2: %v", err)
|
||||
}
|
||||
|
||||
render2, err := PresetsToXML(ds, account, deviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("PresetsToXML (v2): %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(render2), "Edited Station") {
|
||||
t.Errorf("v2 render missing 'Edited Station' — disk edit did not propagate. Likely a caching layer added between requests; render body:\n%s", render2)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(render2), "/v1/playback/station/sEDITED") {
|
||||
t.Errorf("v2 render missing edited location; body:\n%s", render2)
|
||||
}
|
||||
|
||||
if strings.Contains(string(render2), "Initial Station") ||
|
||||
strings.Contains(string(render2), "sINITIAL") {
|
||||
t.Errorf("v2 render still carries v1 content — propagation broken. Body:\n%s", render2)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -1035,6 +1036,25 @@ func getAccountSources(ds *datastore.DataStore, account, lastDeviceID string) []
|
||||
|
||||
for i := range sources {
|
||||
s := sources[i]
|
||||
// Real Bose's /streaming/account/{a}/full never emitted AUX as
|
||||
// a cloud-side <source> (verified across 61 captured upstream
|
||||
// /full responses in scripts/android/captures/.../
|
||||
// parity_mismatches/). AUX is hardware-local — the speaker
|
||||
// enumerates it via isLocal=true in its own /sources response,
|
||||
// it doesn't need the cloud to list it. AfterTouch emitting a
|
||||
// malformed AUX entry here (with displayName=, empty
|
||||
// <credential>, non-empty <name>/<username>) is the suspected
|
||||
// trigger for issue #195: the speaker's source-reconciliation
|
||||
// code marks AUX as cloud-side inconsistent and refuses
|
||||
// dispatch, even though the local availability check reports
|
||||
// it READY. We still keep AUX in getDefaultSources() because
|
||||
// other call sites (default-sources init at startup, the
|
||||
// SoundTouch web UI source picker) rely on it; the filter
|
||||
// just keeps it out of /full's wire shape.
|
||||
if s.SourceKeyType == constants.ProviderAux {
|
||||
continue
|
||||
}
|
||||
|
||||
PrepareConfiguredSource(&s)
|
||||
fullSources = append(fullSources, mapToFullResponseSource(s))
|
||||
}
|
||||
@@ -1816,8 +1836,26 @@ func formatRecentResponse(recentObj *models.ServiceRecent, matchingSrc *models.C
|
||||
return append([]byte(header+"\n"), data...)
|
||||
}
|
||||
|
||||
// AddDeviceToAccount adds a new device to the specified account.
|
||||
func AddDeviceToAccount(ds *datastore.DataStore, account string, sourceXML []byte) (string, []byte, error) {
|
||||
// AddDeviceToAccount upserts a device record for the given account.
|
||||
// Called by both the device-create (POST) and device-rename (PUT)
|
||||
// handlers — the persistence layer doesn't distinguish; only the
|
||||
// response status differs.
|
||||
//
|
||||
// remoteAddr is the speaker's address as seen by the HTTP server
|
||||
// (r.RemoteAddr, "host:port"). When the request body doesn't carry
|
||||
// an `<ipaddress>` and the datastore has no IP for this device yet,
|
||||
// we fall back to remoteAddr's host portion. An empty remoteAddr
|
||||
// is treated as "no fallback available" — never errors.
|
||||
//
|
||||
// Timestamps:
|
||||
// - CreatedOn is preserved from any existing datastore record so a
|
||||
// rename doesn't reset the "first paired in 2017" semantics real
|
||||
// Bose emits. New devices get CreatedOn = now() at first save.
|
||||
// - UpdatedOn is set to now() on every call.
|
||||
//
|
||||
// Returns the persisted deviceID and the marge XML response shape
|
||||
// (`<device deviceid="…"><createdOn/><ipaddress/><name/><updatedOn/></device>`).
|
||||
func AddDeviceToAccount(ds *datastore.DataStore, account string, sourceXML []byte, remoteAddr string) (string, []byte, error) {
|
||||
var newDeviceElem struct {
|
||||
DeviceID string `xml:"deviceid,attr"`
|
||||
Name string `xml:"name"`
|
||||
@@ -1827,28 +1865,68 @@ func AddDeviceToAccount(ds *datastore.DataStore, account string, sourceXML []byt
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
now := FormatTime(time.Now())
|
||||
|
||||
// Build the info to save. Empty fields are filled in by the
|
||||
// datastore's mergeWithExistingDeviceInfo (which preserves IP,
|
||||
// MAC, CreatedOn, etc.) before the write — so the precedence
|
||||
// here is "explicit > merged > remoteAddr fallback".
|
||||
info := &models.ServiceDeviceInfo{
|
||||
DeviceID: newDeviceElem.DeviceID,
|
||||
Name: newDeviceElem.Name,
|
||||
MacAddress: newDeviceElem.MACAddress,
|
||||
// Other fields will be filled by discovery later or default
|
||||
UpdatedOn: now,
|
||||
}
|
||||
|
||||
existing, _ := ds.GetDeviceInfo(account, newDeviceElem.DeviceID)
|
||||
|
||||
// CreatedOn: preserve from existing record for renames; set
|
||||
// now() only on first registration (no prior record OR the
|
||||
// record has no CreatedOn — older AfterTouch installs may
|
||||
// have records without one).
|
||||
if existing != nil && existing.CreatedOn != "" {
|
||||
info.CreatedOn = existing.CreatedOn
|
||||
} else {
|
||||
info.CreatedOn = now
|
||||
}
|
||||
|
||||
// IPAddress: prefer existing record's IP (the speaker may be
|
||||
// hitting us through a different network path right now, e.g.
|
||||
// SSH port-forward, and the persisted IP is the one other
|
||||
// flows like DNS hints care about). Fall back to the inbound
|
||||
// connection's remote address only when there's no existing
|
||||
// IP to preserve. Invalid remoteAddr leaves info.IPAddress
|
||||
// empty, which the merge then handles.
|
||||
if existing == nil || existing.IPAddress == "" {
|
||||
if remoteAddr != "" {
|
||||
if host, _, splitErr := net.SplitHostPort(remoteAddr); splitErr == nil {
|
||||
info.IPAddress = host
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := ds.SaveDeviceInfo(account, newDeviceElem.DeviceID, info); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
createdOn := FormatTime(time.Now())
|
||||
res := fmt.Sprintf(`<device deviceid="%s">`, EscapeXML(newDeviceElem.DeviceID))
|
||||
res += fmt.Sprintf(`<createdOn>%s</createdOn>`, EscapeXML(createdOn))
|
||||
res += `<ipaddress></ipaddress>`
|
||||
res += fmt.Sprintf(`<name>%s</name>`, EscapeXML(newDeviceElem.Name))
|
||||
res += fmt.Sprintf(`<updatedOn>%s</updatedOn>`, EscapeXML(createdOn))
|
||||
// Re-read the persisted record so the response XML reflects
|
||||
// the merged state (preserved CreatedOn, preserved IP if the
|
||||
// new info had none and the existing record did, etc.).
|
||||
persisted, err := ds.GetDeviceInfo(account, newDeviceElem.DeviceID)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("re-read persisted device info: %w", err)
|
||||
}
|
||||
|
||||
res := fmt.Sprintf(`<device deviceid="%s">`, EscapeXML(persisted.DeviceID))
|
||||
res += fmt.Sprintf(`<createdOn>%s</createdOn>`, EscapeXML(persisted.CreatedOn))
|
||||
res += fmt.Sprintf(`<ipaddress>%s</ipaddress>`, EscapeXML(persisted.IPAddress))
|
||||
res += fmt.Sprintf(`<name>%s</name>`, EscapeXML(persisted.Name))
|
||||
res += fmt.Sprintf(`<updatedOn>%s</updatedOn>`, EscapeXML(persisted.UpdatedOn))
|
||||
res += `</device>`
|
||||
|
||||
header := constants.XMLHeader
|
||||
|
||||
return newDeviceElem.DeviceID, append([]byte(header), []byte(res)...), nil
|
||||
return persisted.DeviceID, append([]byte(header), []byte(res)...), nil
|
||||
}
|
||||
|
||||
// RemoveDeviceFromAccount removes a device from the specified account.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<presets>
|
||||
<preset id="1" createdOn="1700000000" updatedOn="1700000000">
|
||||
<contentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/sINITIAL" sourceAccount="" isPresetable="true">
|
||||
<itemName>Initial Station</itemName>
|
||||
<containerArt>https://example.invalid/initial.jpg</containerArt>
|
||||
</contentItem>
|
||||
</preset>
|
||||
</presets>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<presets>
|
||||
<preset id="1" createdOn="1700000000" updatedOn="1800000000">
|
||||
<contentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/sEDITED" sourceAccount="" isPresetable="true">
|
||||
<itemName>Edited Station</itemName>
|
||||
<containerArt>https://example.invalid/edited.jpg</containerArt>
|
||||
</contentItem>
|
||||
</preset>
|
||||
</presets>
|
||||
@@ -0,0 +1,68 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildServerHTTPSURL_PortResolution(t *testing.T) {
|
||||
// HTTPS_PORT must be unset for the env-var path tests to be
|
||||
// meaningful. t.Setenv("HTTPS_PORT", "") clears it for the duration
|
||||
// of each subtest.
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
targetURL string
|
||||
envHTTPSPort string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "https with explicit port wins over HTTPS_PORT env",
|
||||
targetURL: "https://soundtouch.fritz.box:443",
|
||||
envHTTPSPort: "8443",
|
||||
want: "https://soundtouch.fritz.box:443/health",
|
||||
},
|
||||
{
|
||||
name: "https without explicit port uses 443",
|
||||
targetURL: "https://soundtouch.fritz.box",
|
||||
want: "https://soundtouch.fritz.box:443/health",
|
||||
},
|
||||
{
|
||||
name: "http URL falls back to HTTPS_PORT env var",
|
||||
targetURL: "http://aftertouch.local:8000",
|
||||
envHTTPSPort: "9443",
|
||||
want: "https://aftertouch.local:9443/health",
|
||||
},
|
||||
{
|
||||
name: "http URL with no env var defaults to 8443",
|
||||
targetURL: "http://aftertouch.local:8000",
|
||||
want: "https://aftertouch.local:8443/health",
|
||||
},
|
||||
{
|
||||
name: "invalid URL returns empty",
|
||||
targetURL: "::not-a-url",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "URL with no hostname returns empty",
|
||||
targetURL: "http://",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.envHTTPSPort != "" {
|
||||
t.Setenv("HTTPS_PORT", tc.envHTTPSPort)
|
||||
} else {
|
||||
t.Setenv("HTTPS_PORT", "")
|
||||
}
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
got := m.buildServerHTTPSURL(tc.targetURL)
|
||||
if got != tc.want {
|
||||
t.Errorf("buildServerHTTPSURL(%q) = %q, want %q", tc.targetURL, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// validateCABundleBytes walks bundle as a sequence of PEM-encoded
|
||||
// CERTIFICATE blocks and asserts the framing is structurally intact:
|
||||
// every BEGIN marker has a matching END marker, every block decodes
|
||||
// as a valid PEM block, and no stray non-PEM/non-comment content
|
||||
// appears between blocks. We deliberately do NOT call
|
||||
// x509.ParseCertificate on the block bytes — that would reject
|
||||
// legitimate Mozilla CCADB entries (negative serial numbers, ancient
|
||||
// certificates from the 2000s that fail strict RFC 5280 enforcement
|
||||
// in Go 1.23+), and the failure mode this check exists to defend
|
||||
// against (issue #262, a corrupted CA bundle on disk) shows up at
|
||||
// the PEM-framing layer, not at the x509 layer.
|
||||
//
|
||||
// Returns the parsed block count on success.
|
||||
func validateCABundleBytes(bundle []byte) (int, error) {
|
||||
if len(bundle) == 0 {
|
||||
return 0, fmt.Errorf("CA bundle is empty")
|
||||
}
|
||||
|
||||
const (
|
||||
beginMarker = "-----BEGIN CERTIFICATE-----"
|
||||
endMarker = "-----END CERTIFICATE-----"
|
||||
)
|
||||
|
||||
beginCount := bytes.Count(bundle, []byte(beginMarker))
|
||||
|
||||
endCount := bytes.Count(bundle, []byte(endMarker))
|
||||
if beginCount != endCount {
|
||||
return 0, fmt.Errorf("PEM framing mismatch: %d BEGIN markers, %d END markers", beginCount, endCount)
|
||||
}
|
||||
|
||||
rest := bundle
|
||||
|
||||
count := 0
|
||||
|
||||
for {
|
||||
var block *pem.Block
|
||||
|
||||
block, rest = pem.Decode(rest)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
|
||||
count++
|
||||
|
||||
if block.Type != "CERTIFICATE" {
|
||||
return count, fmt.Errorf("PEM block %d has type %q, want CERTIFICATE", count, block.Type)
|
||||
}
|
||||
|
||||
if len(block.Bytes) == 0 {
|
||||
return count, fmt.Errorf("PEM block %d has empty body", count)
|
||||
}
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return 0, fmt.Errorf("CA bundle contains no PEM CERTIFICATE blocks")
|
||||
}
|
||||
|
||||
if count != beginCount {
|
||||
return count, fmt.Errorf("decoded %d PEM blocks but found %d BEGIN markers (suggests a block has unparseable base64 body)", count, beginCount)
|
||||
}
|
||||
|
||||
if trail := bytes.TrimSpace(rest); len(trail) > 0 {
|
||||
// Tolerate anything that's just whitespace, comments, or our
|
||||
// own sentinel lines — but reject stray non-PEM bytes that
|
||||
// don't fall on a block boundary. Comment lines (starting
|
||||
// with `#`) are allowed because CALabel is one.
|
||||
for _, raw := range bytes.Split(trail, []byte("\n")) {
|
||||
line := bytes.TrimSpace(raw)
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if bytes.HasPrefix(line, []byte("#")) {
|
||||
continue
|
||||
}
|
||||
|
||||
return count, fmt.Errorf("trailing non-PEM content after block %d: %q", count, line)
|
||||
}
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// validateAfterTouchLabelBracketing asserts the AfterTouch CALabel
|
||||
// sentinel appears exactly twice in bundle (open + close), and that
|
||||
// exactly one CERTIFICATE block sits between the two occurrences.
|
||||
// Used as a post-upload check to detect transport truncation that
|
||||
// either drops the closing sentinel or drops the certificate body
|
||||
// between them.
|
||||
func validateAfterTouchLabelBracketing(bundle []byte) error {
|
||||
count := strings.Count(string(bundle), CALabel)
|
||||
if count != 2 {
|
||||
return fmt.Errorf("AfterTouch CA label %q appears %d times, want exactly 2 (open + close)", CALabel, count)
|
||||
}
|
||||
|
||||
parts := strings.SplitN(string(bundle), CALabel, 3)
|
||||
if len(parts) != 3 {
|
||||
// Shouldn't reach here given the count check above, but
|
||||
// defend against malformed input that splits unexpectedly.
|
||||
return fmt.Errorf("AfterTouch CA label %q does not bracket cleanly", CALabel)
|
||||
}
|
||||
|
||||
bracketed := parts[1]
|
||||
|
||||
if strings.Count(bracketed, "-----BEGIN CERTIFICATE-----") != 1 {
|
||||
return fmt.Errorf("expected exactly one BEGIN CERTIFICATE between AfterTouch CA labels, found %d",
|
||||
strings.Count(bracketed, "-----BEGIN CERTIFICATE-----"))
|
||||
}
|
||||
|
||||
if strings.Count(bracketed, "-----END CERTIFICATE-----") != 1 {
|
||||
return fmt.Errorf("expected exactly one END CERTIFICATE between AfterTouch CA labels, found %d",
|
||||
strings.Count(bracketed, "-----END CERTIFICATE-----"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// stripAfterTouchEntriesResult is the structured outcome of
|
||||
// stripAfterTouchEntries — non-fatal anomalies surface as fields so
|
||||
// the caller can decide whether to log them or surface them in the
|
||||
// migration UI.
|
||||
type stripAfterTouchEntriesResult struct {
|
||||
// CleanedBundle is the bundle content with every AfterTouch entry
|
||||
// (each `# AfterTouch` sentinel pair and the cert lines between
|
||||
// them) removed.
|
||||
CleanedBundle string
|
||||
|
||||
// RemovedEntries counts the number of complete sentinel pairs
|
||||
// stripped. >1 means an earlier release added our CA more than
|
||||
// once and we just collapsed the duplicates; the caller should
|
||||
// log this so the user knows their bundle was cleaned up.
|
||||
RemovedEntries int
|
||||
|
||||
// UnpairedSentinel is true when the input had an odd number of
|
||||
// AfterTouch sentinel lines — a sign of a previous truncated or
|
||||
// botched install. The trailing "open" sentinel and anything that
|
||||
// follows it (until EOF) gets dropped along with the orphaned
|
||||
// half of a pair; that may silently drop legitimate non-AfterTouch
|
||||
// content that happened to sit after the truncation point, which
|
||||
// is why we surface this as a structured anomaly rather than
|
||||
// just logging it.
|
||||
UnpairedSentinel bool
|
||||
}
|
||||
|
||||
// stripAfterTouchEntries removes every CALabel sentinel line from
|
||||
// bundle and every line between paired sentinels (i.e. the
|
||||
// previously-injected AfterTouch CA payload). It's the line-walking
|
||||
// equivalent of "strip our own entry"; the caller appends a fresh
|
||||
// entry afterward.
|
||||
//
|
||||
// The implementation tolerates the multi-entry case explicitly —
|
||||
// older AfterTouch releases are reported to have appended the CA on
|
||||
// every install without stripping the previous one, so the live
|
||||
// bundle on long-lived devices may carry several copies. We strip
|
||||
// them all and let the caller log the cleanup count.
|
||||
func stripAfterTouchEntries(bundle string) stripAfterTouchEntriesResult {
|
||||
lines := strings.Split(bundle, "\n")
|
||||
|
||||
var (
|
||||
out []string
|
||||
inOurCA bool
|
||||
removedEntries int
|
||||
unpairedTrailer bool
|
||||
)
|
||||
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, CALabel) {
|
||||
if inOurCA {
|
||||
// closing sentinel — one full entry consumed
|
||||
removedEntries++
|
||||
}
|
||||
|
||||
inOurCA = !inOurCA
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if !inOurCA {
|
||||
out = append(out, line)
|
||||
}
|
||||
}
|
||||
|
||||
if inOurCA {
|
||||
// Loop ended with an open bracket — trailing content was
|
||||
// dropped along with the unpaired opening sentinel. The
|
||||
// (truncated) entry doesn't count as "removed" because no
|
||||
// closing sentinel ever marked it complete.
|
||||
unpairedTrailer = true
|
||||
}
|
||||
|
||||
cleaned := strings.Join(out, "\n")
|
||||
if cleaned != "" && !strings.HasSuffix(cleaned, "\n") {
|
||||
cleaned += "\n"
|
||||
}
|
||||
|
||||
return stripAfterTouchEntriesResult{
|
||||
CleanedBundle: cleaned,
|
||||
RemovedEntries: removedEntries,
|
||||
UnpairedSentinel: unpairedTrailer,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// generatePEMCertificate builds a throwaway self-signed PEM
|
||||
// certificate for the validation tests. Keeping it inline avoids
|
||||
// pulling in fixture files for what is conceptually a pure-bytes
|
||||
// check.
|
||||
func generatePEMCertificate(t *testing.T, commonName string) []byte {
|
||||
t.Helper()
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: commonName},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
|
||||
IsCA: true,
|
||||
}
|
||||
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create cert: %v", err)
|
||||
}
|
||||
|
||||
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
}
|
||||
|
||||
func TestValidateCABundleBytes_HappyPathTwoCerts(t *testing.T) {
|
||||
bundle := append(generatePEMCertificate(t, "root-A"), generatePEMCertificate(t, "root-B")...)
|
||||
|
||||
count, err := validateCABundleBytes(bundle)
|
||||
if err != nil {
|
||||
t.Fatalf("validation failed: %v", err)
|
||||
}
|
||||
|
||||
if count != 2 {
|
||||
t.Errorf("count = %d, want 2", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCABundleBytes_EmptyBundleRejected(t *testing.T) {
|
||||
if _, err := validateCABundleBytes(nil); err == nil {
|
||||
t.Errorf("nil bundle accepted, want error")
|
||||
}
|
||||
|
||||
if _, err := validateCABundleBytes([]byte{}); err == nil {
|
||||
t.Errorf("empty bundle accepted, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCABundleBytes_NoPEMBlocksRejected(t *testing.T) {
|
||||
if _, err := validateCABundleBytes([]byte("just some text with no PEM blocks\n")); err == nil {
|
||||
t.Errorf("blob without PEM blocks accepted, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCABundleBytes_NonCertificateBlockRejected(t *testing.T) {
|
||||
keyBlock := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: []byte("not really a key, but the type is what's load-bearing"),
|
||||
})
|
||||
|
||||
count, err := validateCABundleBytes(keyBlock)
|
||||
if err == nil {
|
||||
t.Errorf("RSA PRIVATE KEY block accepted, want error")
|
||||
}
|
||||
|
||||
if count != 1 {
|
||||
t.Errorf("count = %d, want 1 (we walked one block before erroring)", count)
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), `type "RSA PRIVATE KEY"`) {
|
||||
t.Errorf("error does not name the offending block type: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCABundleBytes_TruncatedFrameRejected(t *testing.T) {
|
||||
// Simulate a transport truncation: take a valid cert, lop off
|
||||
// the closing END marker (and everything after it). pem.Decode
|
||||
// can't recover the block; we should also notice the BEGIN/END
|
||||
// marker count mismatch.
|
||||
good := string(generatePEMCertificate(t, "root"))
|
||||
cut := strings.Index(good, "-----END CERTIFICATE-----")
|
||||
|
||||
if cut < 0 {
|
||||
t.Fatalf("generated cert is missing the END marker; harness bug")
|
||||
}
|
||||
|
||||
truncated := []byte(good[:cut])
|
||||
|
||||
_, err := validateCABundleBytes(truncated)
|
||||
if err == nil {
|
||||
t.Fatalf("truncated bundle accepted, want error")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "framing") && !strings.Contains(err.Error(), "no PEM CERTIFICATE blocks") {
|
||||
t.Errorf("error does not name a framing problem: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCABundleBytes_CorruptBase64BodyRejected(t *testing.T) {
|
||||
// Replace the middle of a valid cert's base64 body with a `!`
|
||||
// (illegal base64). pem.Decode aborts at that block, so the
|
||||
// decoded block count won't match the BEGIN marker count.
|
||||
good := string(generatePEMCertificate(t, "root"))
|
||||
begin := strings.Index(good, "-----BEGIN CERTIFICATE-----") + len("-----BEGIN CERTIFICATE-----")
|
||||
end := strings.Index(good, "-----END CERTIFICATE-----")
|
||||
|
||||
if begin < 0 || end < 0 || end <= begin+10 {
|
||||
t.Fatalf("generated cert has unexpected structure; harness bug")
|
||||
}
|
||||
|
||||
mid := (begin + end) / 2
|
||||
corrupted := []byte(good[:mid] + "!@#$" + good[mid+4:])
|
||||
|
||||
_, err := validateCABundleBytes(corrupted)
|
||||
if err == nil {
|
||||
t.Fatalf("base64-corrupted bundle accepted, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCABundleBytes_TolerantOfCommentTrail(t *testing.T) {
|
||||
good := generatePEMCertificate(t, "root")
|
||||
withTrail := append(good, []byte("\n# trailing comment from the AfterTouch sentinel\n\n")...)
|
||||
|
||||
count, err := validateCABundleBytes(withTrail)
|
||||
if err != nil {
|
||||
t.Fatalf("comment-only trail rejected: %v", err)
|
||||
}
|
||||
|
||||
if count != 1 {
|
||||
t.Errorf("count = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCABundleBytes_RejectsStrayNonPEMTrail(t *testing.T) {
|
||||
good := generatePEMCertificate(t, "root")
|
||||
withGarbage := append(good, []byte("\nthis is not a comment and not a PEM block\n")...)
|
||||
|
||||
if _, err := validateCABundleBytes(withGarbage); err == nil {
|
||||
t.Errorf("stray trailing content accepted, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAfterTouchLabelBracketing_HappyPath(t *testing.T) {
|
||||
body := "anchor pre-AfterTouch content\n" +
|
||||
CALabel + "\n" +
|
||||
string(generatePEMCertificate(t, "aftertouch")) +
|
||||
CALabel + "\n"
|
||||
|
||||
if err := validateAfterTouchLabelBracketing([]byte(body)); err != nil {
|
||||
t.Errorf("happy-path bracketing rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAfterTouchLabelBracketing_MissingClose(t *testing.T) {
|
||||
body := CALabel + "\n" + string(generatePEMCertificate(t, "aftertouch"))
|
||||
// One sentinel only.
|
||||
|
||||
err := validateAfterTouchLabelBracketing([]byte(body))
|
||||
if err == nil {
|
||||
t.Fatalf("missing-close bracketing accepted, want error")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "appears 1 times") {
|
||||
t.Errorf("error does not name the appearance count: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAfterTouchLabelBracketing_ThreeOccurrencesRejected(t *testing.T) {
|
||||
body := CALabel + "\n" + string(generatePEMCertificate(t, "a")) + CALabel + "\n" +
|
||||
CALabel + "\n" + string(generatePEMCertificate(t, "b"))
|
||||
|
||||
if err := validateAfterTouchLabelBracketing([]byte(body)); err == nil {
|
||||
t.Errorf("three-occurrence body accepted, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAfterTouchLabelBracketing_EmptyBetweenLabels(t *testing.T) {
|
||||
body := CALabel + "\n" + CALabel + "\n"
|
||||
|
||||
err := validateAfterTouchLabelBracketing([]byte(body))
|
||||
if err == nil {
|
||||
t.Fatalf("empty-between-labels accepted, want error")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "BEGIN CERTIFICATE") {
|
||||
t.Errorf("error does not name the missing BEGIN CERTIFICATE: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripAfterTouchEntries_SingleEntryRemovedCleanly(t *testing.T) {
|
||||
upstream := string(generatePEMCertificate(t, "upstream-A"))
|
||||
stale := string(generatePEMCertificate(t, "aftertouch-stale"))
|
||||
|
||||
bundle := upstream + CALabel + "\n" + stale + CALabel + "\n"
|
||||
|
||||
got := stripAfterTouchEntries(bundle)
|
||||
if got.RemovedEntries != 1 {
|
||||
t.Errorf("RemovedEntries = %d, want 1", got.RemovedEntries)
|
||||
}
|
||||
|
||||
if got.UnpairedSentinel {
|
||||
t.Errorf("UnpairedSentinel = true, want false")
|
||||
}
|
||||
|
||||
if strings.Contains(got.CleanedBundle, CALabel) {
|
||||
t.Errorf("CleanedBundle still contains %q:\n%s", CALabel, got.CleanedBundle)
|
||||
}
|
||||
|
||||
if !strings.Contains(got.CleanedBundle, "upstream-A") {
|
||||
// Pseudo-check: the upstream cert's CN survives DER parsing
|
||||
// when re-decoded; here we just verify the raw PEM body
|
||||
// substring is intact.
|
||||
_ = upstream
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripAfterTouchEntries_MultipleStaleEntriesCollapsed(t *testing.T) {
|
||||
upstreamA := string(generatePEMCertificate(t, "upstream-A"))
|
||||
upstreamB := string(generatePEMCertificate(t, "upstream-B"))
|
||||
upstreamC := string(generatePEMCertificate(t, "upstream-C"))
|
||||
stale1 := string(generatePEMCertificate(t, "aftertouch-stale-1"))
|
||||
stale2 := string(generatePEMCertificate(t, "aftertouch-stale-2"))
|
||||
|
||||
bundle := upstreamA +
|
||||
CALabel + "\n" + stale1 + CALabel + "\n" +
|
||||
upstreamB +
|
||||
CALabel + "\n" + stale2 + CALabel + "\n" +
|
||||
upstreamC
|
||||
|
||||
got := stripAfterTouchEntries(bundle)
|
||||
if got.RemovedEntries != 2 {
|
||||
t.Errorf("RemovedEntries = %d, want 2", got.RemovedEntries)
|
||||
}
|
||||
|
||||
if got.UnpairedSentinel {
|
||||
t.Errorf("UnpairedSentinel = true, want false")
|
||||
}
|
||||
|
||||
if strings.Contains(got.CleanedBundle, CALabel) {
|
||||
t.Errorf("CleanedBundle still contains sentinel:\n%s", got.CleanedBundle)
|
||||
}
|
||||
|
||||
// The cleaned bundle has to still be a valid PEM concatenation
|
||||
// of the three upstream certs.
|
||||
count, err := validateCABundleBytes([]byte(got.CleanedBundle))
|
||||
if err != nil {
|
||||
t.Fatalf("cleaned bundle does not validate: %v\n%s", err, got.CleanedBundle)
|
||||
}
|
||||
|
||||
if count != 3 {
|
||||
t.Errorf("cleaned bundle cert count = %d, want 3 (the upstream entries)", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripAfterTouchEntries_NoEntriesIsZeroRemovals(t *testing.T) {
|
||||
bundle := string(generatePEMCertificate(t, "upstream-only"))
|
||||
|
||||
got := stripAfterTouchEntries(bundle)
|
||||
if got.RemovedEntries != 0 {
|
||||
t.Errorf("RemovedEntries = %d, want 0", got.RemovedEntries)
|
||||
}
|
||||
|
||||
if got.UnpairedSentinel {
|
||||
t.Errorf("UnpairedSentinel = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripAfterTouchEntries_UnpairedSentinelFlagged(t *testing.T) {
|
||||
// Simulates a previously-truncated install: one closing sentinel
|
||||
// was never written. Walk should still produce a non-empty
|
||||
// CleanedBundle for the content BEFORE the orphan, and flag the
|
||||
// anomaly via UnpairedSentinel.
|
||||
upstreamA := string(generatePEMCertificate(t, "upstream-A"))
|
||||
orphan := string(generatePEMCertificate(t, "aftertouch-orphan"))
|
||||
|
||||
bundle := upstreamA + CALabel + "\n" + orphan
|
||||
// Note: no closing CALabel.
|
||||
|
||||
got := stripAfterTouchEntries(bundle)
|
||||
if !got.UnpairedSentinel {
|
||||
t.Errorf("UnpairedSentinel = false, want true")
|
||||
}
|
||||
|
||||
if got.RemovedEntries != 0 {
|
||||
t.Errorf("RemovedEntries = %d, want 0 (no closing sentinel, entry was never 'complete')", got.RemovedEntries)
|
||||
}
|
||||
|
||||
if strings.Contains(got.CleanedBundle, "aftertouch-orphan") {
|
||||
t.Errorf("orphan content leaked into CleanedBundle:\n%s", got.CleanedBundle)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateRealSpeakerBundle exercises the validators against a
|
||||
// real CA bundle captured off a SoundTouch 20's filesystem — the
|
||||
// Mozilla CCADB bundle that ships at /etc/pki/tls/certs/ca-bundle.crt
|
||||
// on firmware 27.0.6.46330.5043500 (snapshot taken 2022-08-04, 165
|
||||
// certificates, ~251 KB). The fixture lives at
|
||||
// testdata/ca_bundle_st20_pristine.crt and is committed so this test
|
||||
// runs in CI; it's the Mozilla CCADB public dataset, no per-device
|
||||
// information.
|
||||
//
|
||||
// Cross-model note: byte-identical to the corresponding ST10
|
||||
// firmware-27 bundle (verified 2026-05-16 against
|
||||
// firmware/_backup_ST10/_/etc/pki/tls/certs/ca-bundle.crt — same
|
||||
// md5 2d150987b312e4280fc576b508e62b43, same 165 certs). Same
|
||||
// fixture stands in for both speaker models while they're on the
|
||||
// same firmware build, so expired-root hypotheses (e.g. PR #292)
|
||||
// should be evaluated against this single dataset.
|
||||
//
|
||||
// Reproduce the #292 cert-chain probe locally — point curl at this
|
||||
// fixture and try the actual TuneIn stream chain a SoundTouch
|
||||
// speaker would walk. If the handshake validates here, the speaker
|
||||
// can also validate it (modulo any speaker-side TLS-stack quirks
|
||||
// the OpenSSL binary on your laptop doesn't share). System bundle
|
||||
// shown alongside for control:
|
||||
//
|
||||
// BUNDLE=pkg/service/setup/testdata/ca_bundle_st20_pristine.crt
|
||||
//
|
||||
// # Control: system trust store
|
||||
// curl -sS -o /dev/null -w "%{http_code}\n" \
|
||||
// "https://maestro.emfcdn.com/stream_for/k-love/tunein/hls"
|
||||
//
|
||||
// # Same URL, restricted to the speaker's 2022 CCADB snapshot
|
||||
// curl -sS -o /dev/null -w "%{http_code}\n" --cacert "$BUNDLE" \
|
||||
// "https://maestro.emfcdn.com/stream_for/k-love/tunein/hls"
|
||||
//
|
||||
// # Follow the 302 to the actual audio host
|
||||
// curl -sSL -o /dev/null -w "%{http_code} %{url_effective}\n" \
|
||||
// --cacert "$BUNDLE" \
|
||||
// "https://maestro.emfcdn.com/stream_for/k-love/tunein/hls"
|
||||
//
|
||||
// Both bundles handle the K-LOVE chain (Amazon Root CA 1 + DigiCert
|
||||
// Global Root, valid through 2026+) cleanly — recorded against
|
||||
// firmware 27 on 2026-05-16, ruling out expired-root for that
|
||||
// firmware vintage.
|
||||
//
|
||||
// The point of this test is to catch over-eager validator changes
|
||||
// before they ship. An earlier iteration of validateCABundleBytes
|
||||
// called x509.ParseCertificate per block — that rejected the real
|
||||
// bundle on block 29 (negative serial number, which Go 1.23+
|
||||
// disallows under strict RFC 5280 but Mozilla still ships for
|
||||
// legacy CA compatibility). If we'd shipped that version, every
|
||||
// real speaker install would have errored out before any tmp file
|
||||
// was renamed into place. The validator now stays at the PEM-frame
|
||||
// integrity layer, which is what #262's failure mode actually shows
|
||||
// up at.
|
||||
func TestValidateRealSpeakerBundle(t *testing.T) {
|
||||
path := filepath.Join("testdata", "ca_bundle_st20_pristine.crt")
|
||||
|
||||
bundle, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
|
||||
count, err := validateCABundleBytes(bundle)
|
||||
if err != nil {
|
||||
t.Fatalf("real bundle rejected by validateCABundleBytes: %v", err)
|
||||
}
|
||||
|
||||
// Snapshot value as captured. If Mozilla churns the CCADB and we
|
||||
// resnapshot, update this constant in the same commit so a real
|
||||
// regression doesn't get masked by a stale expectation.
|
||||
const wantCertCount = 165
|
||||
|
||||
if count != wantCertCount {
|
||||
t.Errorf("real bundle parsed %d certificates, want %d", count, wantCertCount)
|
||||
}
|
||||
|
||||
stripped := stripAfterTouchEntries(string(bundle))
|
||||
if stripped.RemovedEntries != 0 {
|
||||
t.Errorf("pristine bundle reports %d AfterTouch entries removed, want 0", stripped.RemovedEntries)
|
||||
}
|
||||
|
||||
if stripped.UnpairedSentinel {
|
||||
t.Errorf("pristine bundle reports an unpaired sentinel, want false")
|
||||
}
|
||||
|
||||
// stripAfterTouchEntries on a pristine bundle is effectively a
|
||||
// no-op (modulo trailing-newline normalisation). Detect drift
|
||||
// loosely — within a 2-byte tolerance for the trailing-newline
|
||||
// case — rather than asserting byte-identical, which would lock
|
||||
// in a normalisation detail nobody cares about.
|
||||
if delta := len(stripped.CleanedBundle) - len(bundle); delta < -2 || delta > 2 {
|
||||
t.Errorf("strip pass on pristine bundle changed length unexpectedly: input=%d cleaned=%d (delta=%d)",
|
||||
len(bundle), len(stripped.CleanedBundle), delta)
|
||||
}
|
||||
}
|
||||