mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-09-07 15:07:17 +00:00
Compare commits
161
Commits
@@ -0,0 +1,18 @@
|
||||
# CodeQL configuration
|
||||
# https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning
|
||||
|
||||
name: "JavaScript/TypeScript Security Analysis"
|
||||
|
||||
disable-default-queries: false
|
||||
|
||||
queries:
|
||||
- uses: security-extended
|
||||
- uses: security-and-quality
|
||||
|
||||
# Paths to exclude from analysis
|
||||
paths-ignore:
|
||||
- "**/node_modules/**"
|
||||
# The minified es-module-shims distribution currently triggers findings in
|
||||
# third-party code. Keep this exception file-specific so Preact, HTM, and
|
||||
# future files under static/lib remain covered.
|
||||
- "pkg/service/soundtouchweb/static/lib/es-module-shims.js"
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Browser Compatibility Tests
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test-browser:
|
||||
name: Player browser tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.mod') }}-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Verify a Chrome/Chromium binary is available
|
||||
# chromedp (used by the browsertest-tagged tests) discovers Chrome on
|
||||
# PATH or in a standard install location; GitHub's ubuntu-latest
|
||||
# runner image ships Google Chrome preinstalled. Fail fast here with a
|
||||
# clear message instead of a cryptic chromedp allocator error if that
|
||||
# image ever stops including it.
|
||||
run: google-chrome --version
|
||||
|
||||
- name: Run browser-level player compatibility tests
|
||||
run: make test-browser
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Run frontend unit tests
|
||||
run: make test-frontend
|
||||
@@ -308,7 +308,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Set build date
|
||||
id: build_date
|
||||
|
||||
@@ -40,17 +40,17 @@ jobs:
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
config-file: ${{ matrix.language == 'go' && './.github/codeql-config.yml' || '' }}
|
||||
config-file: ${{ matrix.language == 'go' && './.github/codeql-config.yml' || matrix.language == 'javascript-typescript' && './.github/codeql-config-js.yml' || '' }}
|
||||
|
||||
- name: Build Go (required for manual build-mode)
|
||||
if: matrix.language == 'go'
|
||||
run: go build ./...
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
@@ -346,6 +346,14 @@ jobs:
|
||||
TAG_NAME="${{ needs.validate.outputs.tag }}"
|
||||
VERSION="${TAG_NAME#v}"
|
||||
|
||||
# Real per-platform links for the two most-used tools, generated
|
||||
# from the deterministic `<binary>-<tag>-<os>-<arch>[.exe]` asset
|
||||
# naming convention (see scripts/release/quick-downloads.sh),
|
||||
# instead of requiring a scroll through the flat, alphabetical
|
||||
# Assets list. Inline checksum link per row (à la Helm's release
|
||||
# notes) instead of sending people to the combined checksums file.
|
||||
QUICK_DOWNLOADS="$(scripts/release/quick-downloads.sh "$TAG_NAME" "${{ github.repository }}")"
|
||||
|
||||
# Short, accurate header. GitHub's auto-generated "What's Changed"
|
||||
# + "Full Changelog" are appended after this (generate_release_notes).
|
||||
cat > release_notes.md << EOF
|
||||
@@ -353,13 +361,15 @@ jobs:
|
||||
|
||||
**Bose SoundTouch Toolkit.** Keep your Bose SoundTouch speakers alive after the Bose cloud shutdown. No Bose infrastructure required.
|
||||
|
||||
$QUICK_DOWNLOADS
|
||||
|
||||
## What's included
|
||||
|
||||
Pre-built binaries for Linux (amd64, arm64, armv7), macOS (Intel & Apple Silicon), Windows (amd64), and FreeBSD (amd64):
|
||||
|
||||
- **soundtouch-service**: local server that replaces the Bose cloud. Point your speaker at it and you keep full control; the built-in web UI on port 8000 handles setup.
|
||||
- **soundtouch-service** (see above)
|
||||
- **soundtouch-cli** (see above)
|
||||
- **soundtouch-player**: standalone LAN web UI for device control: play/pause, volume, presets, live status. (Formerly \`soundtouch-web\`.)
|
||||
- **soundtouch-cli**: command-line control of any device: playback, presets, sources, multiroom zones, discovery, and migration. Good for scripting and home automation.
|
||||
- **soundtouch-backup**: back up your Bose cloud account and each speaker's local state. \`soundtouch-backup all\` captures everything in one step.
|
||||
|
||||
Not sure which file to grab? The [Downloads page](https://gesellix.github.io/Bose-SoundTouch/docs/downloads/) explains which tool you need and which \`<os>-<arch>\` build matches your computer.
|
||||
@@ -388,7 +398,7 @@ jobs:
|
||||
echo "release_notes_file=release_notes.md" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
|
||||
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
|
||||
with:
|
||||
tag_name: ${{ needs.validate.outputs.tag }}
|
||||
name: ${{ needs.validate.outputs.tag }}
|
||||
@@ -414,14 +424,64 @@ jobs:
|
||||
if: github.event_name == 'release' && github.event.action == 'published'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.tag }}
|
||||
|
||||
- name: Download release assets
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: release-assets
|
||||
path: ./release-assets
|
||||
|
||||
- name: Upgrade the Downloads footer with direct per-platform links
|
||||
# This is the path real releases take: a maintainer hand-writes
|
||||
# "Noteworthy" notes and publishes via the GitHub web UI, which
|
||||
# fires this job, not create_release (workflow_dispatch only).
|
||||
# _/releases/_TEMPLATE.md's convention is a trailing footer line:
|
||||
# ---
|
||||
# 📦 **Downloads / installation:** <downloads page URL>
|
||||
# Drop that line (if present) and append the quick-downloads
|
||||
# block in its place. Always goes through the same append path
|
||||
# (strip block + strip footer + append), whether or not a
|
||||
# footer line is still there, so re-runs stay byte-for-byte
|
||||
# idempotent instead of drifting on the 2nd run.
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG_NAME="${{ needs.validate.outputs.tag }}"
|
||||
|
||||
scripts/release/quick-downloads.sh "$TAG_NAME" "${{ github.repository }}" > quick_downloads.md
|
||||
gh release view "$TAG_NAME" --json body -q .body > existing_body.md
|
||||
|
||||
python3 - << 'PYEOF'
|
||||
import re
|
||||
|
||||
with open("existing_body.md") as f:
|
||||
body = f.read()
|
||||
with open("quick_downloads.md") as f:
|
||||
block = f.read().rstrip("\n")
|
||||
|
||||
# Drop a block this automation inserted on a previous run.
|
||||
body = re.sub(r"\n*<!-- quick-downloads:start -->.*?<!-- quick-downloads:end -->\n*", "\n", body, flags=re.DOTALL)
|
||||
|
||||
# Drop the hand-authored footer line (first run only) so both
|
||||
# cases converge on the same append below and re-runs stay
|
||||
# byte-for-byte idempotent.
|
||||
footer = re.compile(r"^📦 \*\*Downloads / installation:\*\*.*\n?", re.MULTILINE)
|
||||
body = footer.sub("", body, count=1)
|
||||
|
||||
body = body.rstrip("\n") + "\n\n" + block + "\n"
|
||||
|
||||
with open("combined_notes.md", "w") as f:
|
||||
f.write(body)
|
||||
PYEOF
|
||||
|
||||
gh release edit "$TAG_NAME" --notes-file combined_notes.md
|
||||
|
||||
- name: Upload additional assets to existing release
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
|
||||
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
|
||||
with:
|
||||
tag_name: ${{ needs.validate.outputs.tag }}
|
||||
files: |
|
||||
@@ -454,7 +514,7 @@ jobs:
|
||||
echo "commit=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
|
||||
- name: Upload Semgrep SARIF results
|
||||
if: always()
|
||||
uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
sarif_file: semgrep.sarif
|
||||
continue-on-error: true
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# golangci-lint configuration for Bose SoundTouch Go Library
|
||||
# Compatible with golangci-lint v2.8.0
|
||||
# Compatible with golangci-lint v2.13.1
|
||||
# See: https://golangci-lint.run/usage/configuration/
|
||||
|
||||
version: "2"
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.6-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.27.1-alpine AS builder
|
||||
|
||||
# Declare automatic platform ARGs to make them available in build stage
|
||||
# See https://docs.docker.com/reference/dockerfile#automatic-platform-args-in-the-global-scope
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: all build build-cli test test-coverage test-http-client test-http-client-rotate check fmt vet lint clean dev help screenshots build-stockholm-image prepare-stockholm update-static-deps dev-docs dev-docs-tidy hugo
|
||||
.PHONY: all build build-cli test test-coverage test-browser test-frontend test-http-client test-http-client-rotate check fmt vet lint clean dev help screenshots build-stockholm-image prepare-stockholm update-static-deps dev-docs dev-docs-tidy hugo
|
||||
|
||||
# Load .env if present (simple KEY=VALUE format, no shell quoting)
|
||||
-include .env
|
||||
@@ -147,6 +147,23 @@ test-coverage:
|
||||
$(GOCMD) tool cover -html=coverage.out -o coverage.html
|
||||
@echo "Coverage report generated: coverage.html"
|
||||
|
||||
# Browser-level regression tests for the embedded player's static assets
|
||||
# (see pkg/service/soundtouchweb/browser_compatibility_test.go). Opt-in via
|
||||
# the "browsertest" build tag, not part of `test`/`check`, since they need a
|
||||
# Chrome/Chromium binary that chromedp can find on PATH or in a standard
|
||||
# install location.
|
||||
test-browser:
|
||||
@echo "Running browser-level compatibility tests..."
|
||||
$(GOTEST) -tags browsertest -v ./pkg/service/soundtouchweb/...
|
||||
|
||||
# Unit tests for the embedded player's static JS modules (see
|
||||
# pkg/service/soundtouchweb/frontend_test/), run via Node's built-in test
|
||||
# runner. Not part of `test`/`check`, since they need a Node binary matching
|
||||
# package.json's engines field, same reasoning as test-browser needing Chrome.
|
||||
test-frontend:
|
||||
@echo "Running frontend unit tests..."
|
||||
node --test pkg/service/soundtouchweb/frontend_test/*.test.mjs
|
||||
|
||||
check: fmt vet test test-http-client
|
||||
|
||||
# Archive any existing tests/integration/testdata/ to a timestamped sibling
|
||||
@@ -165,9 +182,16 @@ test-http-client-rotate:
|
||||
|
||||
test-http-client:
|
||||
@echo "Starting services with docker compose (waiting for healthchecks)..."
|
||||
@docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d --build --wait
|
||||
@echo "Running .http tests..."
|
||||
@docker run --rm --network soundtouch-test-net \
|
||||
@docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d --build --wait; \
|
||||
UP_EXIT_CODE=$$?; \
|
||||
if [ $$UP_EXIT_CODE -ne 0 ]; then \
|
||||
echo "docker compose up failed (exit $$UP_EXIT_CODE); dumping container logs:"; \
|
||||
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs; \
|
||||
docker compose -f docker-compose.yml -f docker-compose.ci.yml down; \
|
||||
exit $$UP_EXIT_CODE; \
|
||||
fi; \
|
||||
echo "Running .http tests..."; \
|
||||
docker run --rm --network soundtouch-test-net \
|
||||
-v "$(PWD)/tests/integration/http-client:/workdir" \
|
||||
jetbrains/intellij-http-client:2026.1 \
|
||||
--env-file /workdir/http-client.env.json \
|
||||
@@ -227,9 +251,7 @@ test-http-client:
|
||||
/workdir/unregister_device.http \
|
||||
--report; \
|
||||
EXIT_CODE=$$?; \
|
||||
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs soundtouch-service; \
|
||||
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs spotify-mock; \
|
||||
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs amazon-mock; \
|
||||
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs; \
|
||||
docker compose -f docker-compose.yml -f docker-compose.ci.yml down; \
|
||||
exit $$EXIT_CODE
|
||||
|
||||
@@ -243,7 +265,7 @@ vet:
|
||||
|
||||
lint:
|
||||
@echo "Running golangci-lint..."
|
||||
@which golangci-lint > /dev/null || (echo "golangci-lint not found. Install with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest" && exit 1)
|
||||
@which golangci-lint > /dev/null || (echo "golangci-lint not found. Install with: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest" && exit 1)
|
||||
golangci-lint run
|
||||
|
||||
tidy:
|
||||
@@ -501,6 +523,8 @@ help:
|
||||
@echo " build-linux-armv7 - Build for Linux ARMv7 (kernel 3.14+ compatible, CGO_ENABLED=0)"
|
||||
@echo " test - Run tests"
|
||||
@echo " test-coverage - Run tests with coverage report"
|
||||
@echo " test-browser - Run browser-level (chromedp) player compatibility tests"
|
||||
@echo " test-frontend - Run player static JS unit tests (Node's test runner)"
|
||||
@echo " test-http-client - Run .http integration tests via Docker Compose"
|
||||
@echo " test-http-client-rotate - Archive tests/integration/testdata/ before a fresh run (non-destructive)"
|
||||
@echo " check - Run fmt, vet, and tests"
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<p style="margin-top: -10px; font-style: italic; color: #666;">Bose SoundTouch Toolkit</p>
|
||||
|
||||
[](https://pkg.go.dev/github.com/gesellix/bose-soundtouch)
|
||||
[](https://goreportcard.com/report/github.com/gesellix/bose-soundtouch)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
> Independent project. **Not affiliated with, endorsed by, sponsored
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
// sanitizeLog strips newline characters from s to prevent log-injection
|
||||
// (CodeQL go/log-injection). Values from HTTP requests may contain
|
||||
// attacker-controlled newlines.
|
||||
func sanitizeLog(s string) string {
|
||||
s = strings.ReplaceAll(s, "\n", `\n`)
|
||||
s = strings.ReplaceAll(s, "\r", `\r`)
|
||||
|
||||
return s
|
||||
}
|
||||
@@ -654,8 +654,8 @@ func withAccessLog(logger *slog.Logger, next http.Handler) http.Handler {
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
|
||||
browseAttrs = []any{
|
||||
"objectID", between(string(body), "<ObjectID>", "</ObjectID>"),
|
||||
"browseFlag", between(string(body), "<BrowseFlag>", "</BrowseFlag>"),
|
||||
"objectID", sanitizeLog(between(string(body), "<ObjectID>", "</ObjectID>")),
|
||||
"browseFlag", sanitizeLog(between(string(body), "<BrowseFlag>", "</BrowseFlag>")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -664,7 +664,7 @@ func withAccessLog(logger *slog.Logger, next http.Handler) http.Handler {
|
||||
|
||||
attrs := []any{
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"path", sanitizeLog(r.URL.Path),
|
||||
"status", rec.status,
|
||||
"bytes", rec.bytes,
|
||||
"from", r.RemoteAddr,
|
||||
|
||||
+192
-256
@@ -3,11 +3,10 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
"net/http"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/speaker"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/stereopair"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
@@ -16,32 +15,26 @@ 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()
|
||||
result, err := newGroupCoordinator(clientConfig).Inspect(clientConfig.Host)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to get group: %v", err))
|
||||
printGroupResultDetails(result)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
if group.IsEmpty() {
|
||||
if result.Group == nil || result.Group.IsEmpty() {
|
||||
fmt.Println("Device is not in a stereo pair")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
printGroup(group)
|
||||
printGroup(result.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.
|
||||
// createGroup forms and verifies a stereo pair. LEFT is always the master.
|
||||
func createGroup(c *cli.Context) error {
|
||||
leftIP := c.String("left")
|
||||
rightIP := c.String("right")
|
||||
@@ -49,283 +42,117 @@ func createGroup(c *cli.Context) error {
|
||||
|
||||
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)
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader(fmt.Sprintf("Creating stereo pair: LEFT=%s RIGHT=%s", leftIP, rightIP), leftIP, clientConfig.Port)
|
||||
|
||||
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{
|
||||
result, err := newGroupCoordinator(clientConfig).Create(stereopair.CreateRequest{
|
||||
LeftIPAddress: leftIP,
|
||||
RightIPAddress: rightIP,
|
||||
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))
|
||||
PrintError(fmt.Sprintf("Failed to create stereo pair: %v", err))
|
||||
printGroupResultDetails(result)
|
||||
|
||||
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)
|
||||
PrintSuccess(fmt.Sprintf("Stereo pair created (id=%s)", result.Group.ID))
|
||||
printGroup(result.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.
|
||||
// renameGroup updates and verifies the name on both stereo-pair members.
|
||||
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)
|
||||
coordinator := newGroupCoordinator(clientConfig)
|
||||
|
||||
current, err := coordinator.Inspect(clientConfig.Host)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
PrintError(fmt.Sprintf("Failed to inspect stereo pair before rename: %v", err))
|
||||
printGroupResultDetails(current)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
current, err := stClient.GetGroup()
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to read current group: %v", err))
|
||||
return err
|
||||
if current.Group == nil || current.Group.IsEmpty() {
|
||||
return fmt.Errorf("device is not in a stereo pair")
|
||||
}
|
||||
|
||||
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)
|
||||
result, err := coordinator.Rename(stereopair.RenameRequest{
|
||||
MemberIPAddress: clientConfig.Host,
|
||||
ExpectedGroupID: current.Group.ID,
|
||||
Name: newName,
|
||||
})
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to rename group: %v", err))
|
||||
printGroupResultDetails(result)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess(fmt.Sprintf("Stereo pair renamed to %q", result.Name))
|
||||
printGroup(result)
|
||||
PrintSuccess(fmt.Sprintf("Stereo pair renamed to %q", result.Group.Name))
|
||||
printGroup(result.Group)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeGroup tears down the device's stereo pair by sending /removeGroup to
|
||||
// every member in parallel. Sending it only to the master (as the old code
|
||||
// did) leaves the slave stuck in GroupSlave state indefinitely — mirrors the
|
||||
// same symmetry as createGroup (see issue #252 comment there).
|
||||
// removeGroup dissolves and verifies the stereo pair on every member.
|
||||
func removeGroup(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader("Removing stereo pair", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
stClient, err := CreateSoundTouchClient(clientConfig)
|
||||
coordinator := newGroupCoordinator(clientConfig)
|
||||
|
||||
current, err := coordinator.Inspect(clientConfig.Host)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
PrintWarning(fmt.Sprintf("Stereo pair is degraded before removal: %v", err))
|
||||
printGroupResultDetails(current)
|
||||
|
||||
if current.Group == nil || current.Group.IsEmpty() {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch current group to learn every member's IP before tearing down.
|
||||
group, err := stClient.GetGroup()
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to read current group: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if group.IsEmpty() {
|
||||
if current.Group == nil || current.Group.IsEmpty() {
|
||||
fmt.Println("Device is not in a stereo pair — nothing to remove")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Collect the unique set of member IPs. The master is always reachable
|
||||
// via clientConfig.Host; the roles carry all members including slaves.
|
||||
type memberResult struct {
|
||||
ip string
|
||||
err error
|
||||
}
|
||||
dissolveHost := dissolveRecoveryHost(current, clientConfig.Host)
|
||||
|
||||
members := make([]string, 0, len(group.Roles.Roles))
|
||||
seen := map[string]bool{}
|
||||
result, err := coordinator.Dissolve(stereopair.DissolveRequest{
|
||||
MemberIPAddress: dissolveHost,
|
||||
ExpectedGroupID: current.Group.ID,
|
||||
ExpectedGroup: current.Group,
|
||||
})
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to remove stereo pair: %v", err))
|
||||
printGroupResultDetails(result)
|
||||
|
||||
for _, role := range group.Roles.Roles {
|
||||
if role.IPAddress != "" && !seen[role.IPAddress] {
|
||||
seen[role.IPAddress] = true
|
||||
members = append(members, role.IPAddress)
|
||||
}
|
||||
}
|
||||
|
||||
// Always include the addressed host even if the group response omitted IPs.
|
||||
if !seen[clientConfig.Host] {
|
||||
members = append(members, clientConfig.Host)
|
||||
}
|
||||
|
||||
results := make([]memberResult, len(members))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, ip := range members {
|
||||
wg.Add(1)
|
||||
|
||||
go func(idx int, host string) {
|
||||
defer wg.Done()
|
||||
|
||||
mc, mcErr := clientForHost(c, host)
|
||||
if mcErr != nil {
|
||||
results[idx] = memberResult{ip: host, err: mcErr}
|
||||
return
|
||||
}
|
||||
|
||||
results[idx] = memberResult{ip: host, err: mc.RemoveGroup()}
|
||||
}(i, ip)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
anyErr := false
|
||||
|
||||
for _, r := range results {
|
||||
if r.err != nil {
|
||||
PrintError(fmt.Sprintf("%s /removeGroup failed: %v", r.ip, r.err))
|
||||
|
||||
anyErr = true
|
||||
}
|
||||
}
|
||||
|
||||
if anyErr {
|
||||
return fmt.Errorf("/removeGroup propagation failed")
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess("Stereo pair removed")
|
||||
@@ -333,32 +160,141 @@ func removeGroup(c *cli.Context) error {
|
||||
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
|
||||
func dissolveRecoveryHost(result stereopair.Result, fallback string) string {
|
||||
if result.Group == nil || result.Group.ID == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return stClient.GetDeviceInfo()
|
||||
for i := range result.Members {
|
||||
member := &result.Members[i]
|
||||
if member.Group != nil && member.Group.ID == result.Group.ID && net.ParseIP(member.IPAddress) != nil {
|
||||
return member.IPAddress
|
||||
}
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
// 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)
|
||||
func newGroupCoordinator(config *ClientConfig) *stereopair.Coordinator {
|
||||
lifecycleConfig := *config
|
||||
if lifecycleConfig.Timeout < stereopair.RequestTimeout {
|
||||
lifecycleConfig.Timeout = stereopair.RequestTimeout
|
||||
}
|
||||
|
||||
return client.NewClient(&client.Config{
|
||||
Host: host,
|
||||
Port: speaker.HTTPPort,
|
||||
Timeout: cfg.HTTPTimeout,
|
||||
UserAgent: cfg.UserAgent,
|
||||
}), nil
|
||||
cleanupClient := &http.Client{Timeout: lifecycleConfig.Timeout}
|
||||
cleanup, preflight, rename := cliStereoPairGenerationPersistence(cleanupClient)
|
||||
|
||||
return stereopair.NewWithGenerationLifecyclePersistence(
|
||||
groupClientFactory(&lifecycleConfig),
|
||||
cleanup, preflight, rename,
|
||||
)
|
||||
}
|
||||
|
||||
// cliStereoPairGenerationPersistence wires generation-lifecycle hooks for
|
||||
// the CLI: cleanup and rename are no-ops, and preflight's read-only
|
||||
// dangling-generation check is advisory (mirrors -service's and -player's
|
||||
// own equivalents).
|
||||
//
|
||||
// A speaker self-reports its own group create/rename/teardown to whatever
|
||||
// Marge backend it's configured with -- that's the entire reason
|
||||
// HandleMargeAddGroup/HandleMargeModifyGroup/HandleMargeDeleteGroup exist,
|
||||
// they're only ever called by speakers, never by us. Proactively pushing the
|
||||
// same update ourselves would duplicate that against a backend we generally
|
||||
// can't authenticate to anyway (real Bose cloud, another AfterTouch/SoundCork
|
||||
// instance, ...). The one part with a distinct purpose -- checking for a
|
||||
// dangling stale generation before a new Create -- is still attempted, but
|
||||
// its failure must not block Create: it's a best-effort safety net on top of
|
||||
// the coordinator's own physical preflight, not the primary guard.
|
||||
func cliStereoPairGenerationPersistence(
|
||||
cleanupClient *http.Client,
|
||||
) (stereopair.GenerationCleanup, stereopair.GenerationPreflight, stereopair.GenerationRename) {
|
||||
cleanup := func(stereopair.GenerationRef) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
preflight := func(refs []stereopair.GenerationRef) error {
|
||||
if err := stereopair.EnsureMargeNoGroupGenerations(cleanupClient, refs); err != nil {
|
||||
PrintWarning(fmt.Sprintf("stereo-pair external generation preflight inconclusive, proceeding: %v", err))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
rename := func(stereopair.GenerationRef, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
return cleanup, preflight, rename
|
||||
}
|
||||
|
||||
// groupClientFactory addresses every member directly while retaining the
|
||||
// effective CLI port and timeout.
|
||||
func groupClientFactory(config *ClientConfig) stereopair.ClientFactory {
|
||||
baseConfig := *config
|
||||
|
||||
return func(ipAddress string) (stereopair.Client, error) {
|
||||
memberConfig := baseConfig
|
||||
memberConfig.Host = ipAddress
|
||||
|
||||
return CreateSoundTouchClient(&memberConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func printGroupResultDetails(result stereopair.Result) {
|
||||
if result.Status == stereopair.StatusDegraded {
|
||||
PrintWarning(fmt.Sprintf("Stereo-pair %s result is degraded", result.Operation))
|
||||
}
|
||||
|
||||
for i := range result.Members {
|
||||
member := &result.Members[i]
|
||||
label := groupMemberLabel(i, member)
|
||||
|
||||
if member.PreflightError != nil {
|
||||
PrintError(fmt.Sprintf("%s preflight failed: %v", label, member.PreflightError))
|
||||
}
|
||||
|
||||
if member.MutationError != nil {
|
||||
PrintError(fmt.Sprintf("%s mutation failed: %v", label, member.MutationError))
|
||||
}
|
||||
|
||||
if member.VerificationError != nil {
|
||||
PrintError(fmt.Sprintf("%s verification failed: %v", label, member.VerificationError))
|
||||
}
|
||||
|
||||
if member.CompensationError != nil {
|
||||
PrintError(fmt.Sprintf("%s cleanup failed: %v", label, member.CompensationError))
|
||||
} else if member.CompensationAttempted && !member.CompensationVerified {
|
||||
PrintWarning(fmt.Sprintf("%s cleanup could not be verified", label))
|
||||
}
|
||||
}
|
||||
|
||||
if result.CompensationAttempted {
|
||||
if result.CompensationComplete {
|
||||
PrintWarning("Partial stereo-pair state was cleaned up and verified")
|
||||
} else {
|
||||
PrintError("Partial stereo-pair state cleanup is incomplete")
|
||||
}
|
||||
}
|
||||
|
||||
if result.PersistenceError != nil {
|
||||
PrintError(fmt.Sprintf("Persistent group generation update failed: %v", result.PersistenceError))
|
||||
}
|
||||
}
|
||||
|
||||
func groupMemberLabel(index int, member *stereopair.MemberResult) string {
|
||||
if member.IPAddress != "" && member.DeviceID != "" {
|
||||
return fmt.Sprintf("%s (%s)", member.IPAddress, member.DeviceID)
|
||||
}
|
||||
|
||||
if member.IPAddress != "" {
|
||||
return member.IPAddress
|
||||
}
|
||||
|
||||
if member.DeviceID != "" {
|
||||
return member.DeviceID
|
||||
}
|
||||
|
||||
return fmt.Sprintf("member %d", index+1)
|
||||
}
|
||||
|
||||
func printGroup(g *models.Group) {
|
||||
|
||||
@@ -1,184 +1,255 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/stereopair"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
func TestGroupClientFactoryUsesMemberHostAndConfiguredPort(t *testing.T) {
|
||||
srv := 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)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(`<group id="pair-id"><name>Pair</name></group>`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
host, port := testServerHostPort(t, srv.URL)
|
||||
factory := groupClientFactory(&ClientConfig{
|
||||
Host: "192.0.2.200",
|
||||
Port: port,
|
||||
Timeout: time.Second,
|
||||
})
|
||||
|
||||
memberClient, err := factory(host)
|
||||
if err != nil {
|
||||
t.Fatalf("factory: %v", err)
|
||||
}
|
||||
|
||||
group, err := memberClient.GetGroup()
|
||||
if err != nil {
|
||||
t.Fatalf("GetGroup: %v", err)
|
||||
}
|
||||
|
||||
if group.ID != "pair-id" || group.Name != "Pair" {
|
||||
t.Fatalf("group = %+v, want test server response", group)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupClientFactoryUsesConfiguredTimeout(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
_, _ = w.Write([]byte(`<group/>`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
host, port := testServerHostPort(t, srv.URL)
|
||||
factory := groupClientFactory(&ClientConfig{Port: port, Timeout: 5 * time.Millisecond})
|
||||
memberClient, err := factory(host)
|
||||
if err != nil {
|
||||
t.Fatalf("factory: %v", err)
|
||||
}
|
||||
|
||||
if _, err := memberClient.GetGroup(); err == nil {
|
||||
t.Fatal("GetGroup succeeded, want configured timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMargeGroupGenerationURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
base string
|
||||
want string
|
||||
}{
|
||||
{base: "http://aftertouch.example:8000", want: "http://aftertouch.example:8000/streaming/account/ACCOUNT1/group/PAIR1"},
|
||||
{base: "http://unifi:8001/marge", want: "http://unifi:8001/marge/streaming/account/ACCOUNT1/group/PAIR1"},
|
||||
{base: "https://proxy.example/prefix/streaming/", want: "https://proxy.example/prefix/streaming/account/ACCOUNT1/group/PAIR1"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
got, err := stereopair.MargeGroupGenerationURL(stereopair.GenerationRef{
|
||||
MargeURL: test.base, AccountID: "ACCOUNT1", GroupID: "PAIR1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("margeGroupGenerationURL(%q): %v", test.base, err)
|
||||
}
|
||||
if got != test.want {
|
||||
t.Errorf("margeGroupGenerationURL(%q) = %q, want %q", test.base, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMargeGroupGenerationUsesExactEndpoint(t *testing.T) {
|
||||
deleteSeen := false
|
||||
getSeen := false
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodDelete:
|
||||
deleteSeen = true
|
||||
if r.URL.Path != "/streaming/account/ACCOUNT1/group/PAIR1" {
|
||||
t.Errorf("DELETE path = %s", r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case http.MethodGet:
|
||||
getSeen = true
|
||||
if r.URL.Path != "/streaming/account/ACCOUNT1/device/LEFT-ID/group" {
|
||||
t.Errorf("GET path = %s", r.URL.Path)
|
||||
}
|
||||
if deleteSeen {
|
||||
_, _ = w.Write([]byte(`<group/>`))
|
||||
} else {
|
||||
_, _ = w.Write([]byte(`<group id="PAIR1"><masterDeviceId>LEFT-ID</masterDeviceId><roles><groupRole><deviceId>LEFT-ID</deviceId><role>LEFT</role><ipAddress>192.0.2.10</ipAddress></groupRole><groupRole><deviceId>RIGHT-ID</deviceId><role>RIGHT</role><ipAddress>192.0.2.11</ipAddress></groupRole></roles></group>`))
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := stereopair.DeleteMargeGroupGeneration(server.Client(), stereopair.GenerationRef{
|
||||
MargeURL: server.URL, AccountID: "ACCOUNT1", GroupID: "PAIR1", DeviceID: "LEFT-ID",
|
||||
ExpectedGroup: &models.Group{
|
||||
ID: "PAIR1",
|
||||
MasterDeviceID: "LEFT-ID",
|
||||
Roles: models.GroupRoles{Roles: []models.GroupRole{
|
||||
{DeviceID: "LEFT-ID", Role: "LEFT", IPAddress: "192.0.2.10"},
|
||||
{DeviceID: "RIGHT-ID", Role: "RIGHT", IPAddress: "192.0.2.11"},
|
||||
}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("deleteMargeGroupGeneration: %v", err)
|
||||
}
|
||||
if !deleteSeen || !getSeen {
|
||||
t.Fatalf("Marge cleanup requests DELETE=%t GET=%t, want both", deleteSeen, getSeen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintGroupResultDetailsReportsMemberFailuresAndCleanup(t *testing.T) {
|
||||
result := stereopair.Result{
|
||||
Operation: stereopair.OperationCreate,
|
||||
Status: stereopair.StatusDegraded,
|
||||
CompensationAttempted: true,
|
||||
PersistenceError: errors.New("datastore unavailable"),
|
||||
Members: []stereopair.MemberResult{
|
||||
{
|
||||
IPAddress: "192.0.2.10",
|
||||
DeviceID: "LEFT-ID",
|
||||
PreflightError: errors.New("offline"),
|
||||
},
|
||||
{
|
||||
IPAddress: "192.0.2.11",
|
||||
MutationError: errors.New("add failed"),
|
||||
VerificationError: errors.New("unexpected group"),
|
||||
CompensationAttempted: true,
|
||||
CompensationError: errors.New("remove failed"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
output := captureStdout(t, func() {
|
||||
printGroupResultDetails(result)
|
||||
})
|
||||
|
||||
for _, expected := range []string{
|
||||
"Stereo-pair create result is degraded",
|
||||
"192.0.2.10 (LEFT-ID) preflight failed: offline",
|
||||
"192.0.2.11 mutation failed: add failed",
|
||||
"192.0.2.11 verification failed: unexpected group",
|
||||
"192.0.2.11 cleanup failed: remove failed",
|
||||
"Partial stereo-pair state cleanup is incomplete",
|
||||
"Persistent group generation update failed: datastore unavailable",
|
||||
} {
|
||||
if !strings.Contains(output, expected) {
|
||||
t.Errorf("output missing %q:\n%s", expected, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDissolveRecoveryHostSelectsStillGroupedMember(t *testing.T) {
|
||||
result := stereopair.Result{
|
||||
Group: &models.Group{ID: "PAIR-ID"},
|
||||
Members: []stereopair.MemberResult{
|
||||
{IPAddress: "192.0.2.10", Group: &models.Group{}},
|
||||
{IPAddress: "192.0.2.11", Group: &models.Group{ID: "PAIR-ID"}},
|
||||
},
|
||||
}
|
||||
|
||||
if got := dissolveRecoveryHost(result, "192.0.2.10"); got != "192.0.2.11" {
|
||||
t.Fatalf("recovery host = %q, want surviving member", got)
|
||||
}
|
||||
}
|
||||
|
||||
func testServerHostPort(t *testing.T, serverURL string) (string, int) {
|
||||
t.Helper()
|
||||
|
||||
bodies := make([]string, 0)
|
||||
parsed, err := url.Parse(serverURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse server URL: %v", err)
|
||||
}
|
||||
|
||||
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)
|
||||
host, portText, err := net.SplitHostPort(parsed.Host)
|
||||
if err != nil {
|
||||
t.Fatalf("split server host: %v", err)
|
||||
}
|
||||
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil {
|
||||
t.Fatalf("parse server port: %v", err)
|
||||
}
|
||||
|
||||
return host, port
|
||||
}
|
||||
|
||||
// TestCLIStereoPairGenerationPersistenceSkipsWritesButAttemptsRead covers
|
||||
// the CLI's generation-lifecycle wiring: cleanup and rename must never push
|
||||
// to a Marge backend -- the speaker itself self-reports its own group
|
||||
// teardown/rename to whatever backend it's configured with (see
|
||||
// HandleMargeDeleteGroup/HandleMargeModifyGroup, only ever called by
|
||||
// speakers). Preflight still attempts its read-only dangling-generation
|
||||
// check, but a failure there (network error, wrong credentials, real Bose
|
||||
// cloud rejecting us, ...) must not block Create.
|
||||
func TestCLIStereoPairGenerationPersistenceSkipsWritesButAttemptsRead(t *testing.T) {
|
||||
getCalls := 0
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/device/LEFT-ID/group") {
|
||||
getCalls++
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
|
||||
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)
|
||||
t.Fatalf("unexpected external %s %s: cleanup/rename must not write to a backend the CLI doesn't own", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
return srv, &bodies
|
||||
}
|
||||
cleanup, preflight, rename := cliStereoPairGenerationPersistence(server.Client())
|
||||
|
||||
func newTestGroupClient(serverURL string) *client.Client {
|
||||
return client.NewClientFromHost(serverURL)
|
||||
}
|
||||
ref := stereopair.GenerationRef{
|
||||
DeviceID: "LEFT-ID", AccountID: "ACCOUNT1", MargeURL: server.URL + "/marge",
|
||||
GroupID: "7654321",
|
||||
}
|
||||
|
||||
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.
|
||||
if err := rename(ref, "Renamed living room"); err != nil {
|
||||
t.Fatalf("rename = %v, want nil (speaker self-reports its own rename)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPropagateAddGroup_BothSucceed(t *testing.T) {
|
||||
leftSrv, leftBodies := happyAddGroupServer(t, "9999999")
|
||||
defer leftSrv.Close()
|
||||
|
||||
rightSrv, rightBodies := happyAddGroupServer(t, "9999999")
|
||||
defer rightSrv.Close()
|
||||
|
||||
leftClient := newTestGroupClient(leftSrv.URL)
|
||||
rightClient := newTestGroupClient(rightSrv.URL)
|
||||
|
||||
req := sampleGroupRequest("192.0.2.131", "192.0.2.134")
|
||||
|
||||
leftOut, rightOut := propagateAddGroup(leftClient, rightClient, "192.0.2.131", "192.0.2.134", req)
|
||||
|
||||
if leftOut.err != nil {
|
||||
t.Errorf("LEFT err = %v, want nil", leftOut.err)
|
||||
if err := cleanup(ref); err != nil {
|
||||
t.Fatalf("cleanup = %v, want nil (speaker self-reports its own teardown)", err)
|
||||
}
|
||||
|
||||
if rightOut.err != nil {
|
||||
t.Errorf("RIGHT err = %v, want nil", rightOut.err)
|
||||
if err := preflight([]stereopair.GenerationRef{ref}); err != nil {
|
||||
t.Fatalf("preflight = %v, want nil: an unauthenticated external check must not block Create", err)
|
||||
}
|
||||
|
||||
if leftOut.group == nil || leftOut.group.ID != "9999999" || leftOut.group.Status != "GROUP_OK" {
|
||||
t.Errorf("LEFT group = %+v, want id=9999999 status=GROUP_OK", leftOut.group)
|
||||
}
|
||||
|
||||
if rightOut.group == nil || rightOut.group.Status != "GROUP_OK" {
|
||||
t.Errorf("RIGHT group = %+v, want status=GROUP_OK", rightOut.group)
|
||||
}
|
||||
|
||||
// Both speakers must have received the roles, but only the slave's payload
|
||||
// carries senderIPAddress — see propagateAddGroup for the why.
|
||||
for label, bodies := range map[string]*[]string{"LEFT": leftBodies, "RIGHT": rightBodies} {
|
||||
if len(*bodies) != 1 {
|
||||
t.Fatalf("%s: expected exactly one POST, got %d", label, len(*bodies))
|
||||
}
|
||||
|
||||
body := (*bodies)[0]
|
||||
for _, want := range []string{"<role>LEFT</role>", "<role>RIGHT</role>"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("%s body missing %q\nbody:\n%s", label, want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
leftBody := (*leftBodies)[0]
|
||||
if strings.Contains(leftBody, "<senderIPAddress>") {
|
||||
t.Errorf("LEFT (master) body must NOT carry <senderIPAddress>, otherwise the master flips into slave mode (issue #252)\nbody:\n%s", leftBody)
|
||||
}
|
||||
|
||||
rightBody := (*rightBodies)[0]
|
||||
if !strings.Contains(rightBody, "<senderIPAddress>192.0.2.131</senderIPAddress>") {
|
||||
t.Errorf("RIGHT (slave) body must carry <senderIPAddress>192.0.2.131</senderIPAddress>\nbody:\n%s", rightBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPropagateAddGroup_RightFails(t *testing.T) {
|
||||
leftSrv, _ := happyAddGroupServer(t, "9999999")
|
||||
defer leftSrv.Close()
|
||||
|
||||
rightSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
}))
|
||||
defer rightSrv.Close()
|
||||
|
||||
leftClient := newTestGroupClient(leftSrv.URL)
|
||||
rightClient := newTestGroupClient(rightSrv.URL)
|
||||
|
||||
req := sampleGroupRequest("192.0.2.131", "192.0.2.134")
|
||||
|
||||
leftOut, rightOut := propagateAddGroup(leftClient, rightClient, "192.0.2.131", "192.0.2.134", req)
|
||||
|
||||
if leftOut.err != nil {
|
||||
t.Errorf("LEFT err = %v, want nil", leftOut.err)
|
||||
}
|
||||
|
||||
if rightOut.err == nil {
|
||||
t.Error("RIGHT err = nil, want non-nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostAddGroup_StatusOtherThanGroupOKIsError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(`<group><status>GROUP_NOT_READY</status></group>`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
out := postAddGroup(newTestGroupClient(srv.URL), "test", sampleGroupRequest("1.1.1.1", "2.2.2.2"))
|
||||
|
||||
if out.err == nil {
|
||||
t.Fatal("expected error for non-GROUP_OK status")
|
||||
}
|
||||
|
||||
if !strings.Contains(out.err.Error(), "GROUP_NOT_READY") {
|
||||
t.Errorf("error %q does not mention returned status", out.err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostAddGroup_EmptyStatusIsAccepted(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(`<group id="42"><name>n</name></group>`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
out := postAddGroup(newTestGroupClient(srv.URL), "test", sampleGroupRequest("1.1.1.1", "2.2.2.2"))
|
||||
|
||||
if out.err != nil {
|
||||
t.Errorf("err = %v, want nil for empty status (some firmware omits it)", out.err)
|
||||
}
|
||||
|
||||
if out.group == nil || out.group.ID != "42" {
|
||||
t.Errorf("group = %+v, want id=42", out.group)
|
||||
if getCalls != 1 {
|
||||
t.Fatalf("external GET calls = %d, want exactly 1 (preflight must still attempt the read)", getCalls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"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/urfave/cli/v2"
|
||||
"golang.org/x/term"
|
||||
@@ -674,9 +675,9 @@ func setupEnableSSHCmd() *cli.Command {
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "account",
|
||||
Usage: "Only used when the device is unpaired and --no-auto-pair is not set: 7-digit account ID to pair " +
|
||||
"with (empty = generate one). Use this if you already know which account this device should end up " +
|
||||
"on (e.g. to match one already in the datastore) rather than getting a random one now",
|
||||
Usage: "Only used when the device is unpaired and --no-auto-pair is not set: account ID to pair with " +
|
||||
"(empty = generate a fresh 7-digit one). Use this if you already know which account this device " +
|
||||
"should end up on (e.g. to match one already in the datastore) rather than getting a random one now",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "no-reset-urls",
|
||||
@@ -1509,12 +1510,23 @@ func renderMigrationSummary(deviceIP, serviceURL string, s *setup.MigrationSumma
|
||||
}
|
||||
}
|
||||
|
||||
// setupRevertCmd wraps setup.Manager.RevertMigration — the same operation
|
||||
// as the web UI's "Revert to Defaults" button (Migrate tab). Restores
|
||||
// SoundTouchSdkPrivateCfg.xml, /etc/hosts, and /etc/resolv.conf from their
|
||||
// .original backups, removes the AfterTouch DNS-hook artifacts, and strips
|
||||
// just the AfterTouch-labeled cert out of the trust bundle. No --service-url
|
||||
// needed: everything it touches already lives on the speaker.
|
||||
var telnetRevertOverrideFlags = []string{"marge-url", "stats-url", "sw-update-url", "bmx-url"}
|
||||
|
||||
func validateRevertMethodOptions(method string, overrideFlags []string) error {
|
||||
if method != "ssh" && method != string(setup.MigrationMethodTelnet) {
|
||||
return fmt.Errorf("unsupported revert method %q; expected ssh or telnet", method)
|
||||
}
|
||||
|
||||
if method != string(setup.MigrationMethodTelnet) && len(overrideFlags) > 0 {
|
||||
return fmt.Errorf("--%s requires --method telnet", strings.Join(overrideFlags, ", --"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setupRevertCmd restores either the SSH/filesystem migration state or only
|
||||
// the four URL fields written by a telnet migration. The default remains the
|
||||
// existing SSH path for backwards compatibility.
|
||||
//
|
||||
// Deliberately out of scope (matches the web UI button): SSH/remote_services
|
||||
// persistence (use `setup remote-services --remove`) and account pairing
|
||||
@@ -1522,15 +1534,53 @@ func renderMigrationSummary(deviceIP, serviceURL string, s *setup.MigrationSumma
|
||||
func setupRevertCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "revert",
|
||||
Usage: "Undo a migration: restore SoundTouchSdkPrivateCfg.xml/hosts/resolv.conf from backups and remove the AfterTouch CA cert",
|
||||
Usage: "Undo a migration via SSH backups or restore canonical Bose service URLs over telnet",
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "method", Value: "ssh", Usage: "ssh | telnet"},
|
||||
&cli.StringFlag{Name: "marge-url", Usage: "Override the canonical Bose margeServerUrl (telnet only)"},
|
||||
&cli.StringFlag{Name: "stats-url", Usage: "Override the canonical Bose statsServerUrl (telnet only)"},
|
||||
&cli.StringFlag{Name: "sw-update-url", Usage: "Override the canonical Bose swUpdateUrl (telnet only)"},
|
||||
&cli.StringFlag{Name: "bmx-url", Usage: "Override the canonical Bose bmxRegistryUrl (telnet only)"},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
cfg := GetClientConfig(c)
|
||||
method := c.String("method")
|
||||
|
||||
var overrideFlags []string
|
||||
|
||||
for _, flag := range telnetRevertOverrideFlags {
|
||||
if c.IsSet(flag) {
|
||||
overrideFlags = append(overrideFlags, flag)
|
||||
}
|
||||
}
|
||||
|
||||
if err := validateRevertMethodOptions(method, overrideFlags); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m := setup.NewManager("", nil, nil)
|
||||
|
||||
fmt.Printf("Reverting migration on %s...\n", cfg.Host)
|
||||
fmt.Printf("Reverting migration on %s using method=%s...\n", cfg.Host, method)
|
||||
|
||||
var (
|
||||
logs string
|
||||
err error
|
||||
)
|
||||
|
||||
switch method {
|
||||
case "ssh":
|
||||
logs, err = m.RevertMigration(cfg.Host)
|
||||
case string(setup.MigrationMethodTelnet):
|
||||
options := map[string]string{
|
||||
"marge_url": c.String("marge-url"),
|
||||
"stats_url": c.String("stats-url"),
|
||||
"sw_update_url": c.String("sw-update-url"),
|
||||
"bmx_url": c.String("bmx-url"),
|
||||
}
|
||||
logs, err = m.RevertTelnetURLs(cfg.Host, options)
|
||||
}
|
||||
|
||||
logs, err := m.RevertMigration(cfg.Host)
|
||||
if logs != "" {
|
||||
fmt.Print(logs)
|
||||
}
|
||||
@@ -1540,8 +1590,12 @@ func setupRevertCmd() *cli.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess("Migration reverted. SSH access and account pairing are untouched by this — " +
|
||||
"see `setup remote-services --remove` and `account unpair` if you want those cleared too.")
|
||||
if method == string(setup.MigrationMethodTelnet) {
|
||||
PrintSuccess("Canonical Bose URL configuration restored. Reboot the speaker to verify the persisted layer; filesystem, DNS, CA, SSH, and account state were not changed.")
|
||||
} else {
|
||||
PrintSuccess("Migration reverted. SSH access and account pairing are untouched by this — " +
|
||||
"see `setup remote-services --remove` and `account unpair` if you want those cleared too.")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
@@ -1974,11 +2028,11 @@ func setupPairCmd() *cli.Command {
|
||||
Usage: "Pair the speaker with an account via WebSocket SETUP state machine",
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "account", Usage: "7-digit account ID (empty = generate)"},
|
||||
&cli.StringFlag{Name: "account", Usage: "Account ID to pair with (empty = generate a fresh 7-digit one)"},
|
||||
&cli.StringFlag{Name: "mode", Value: "full", Usage: "full (state machine) or bare (setMargeAccount only — experimental)"},
|
||||
&cli.StringFlag{Name: "service-url", Value: "http://aftertouch.local:8000", Usage: "AfterTouch base URL (also populates <boseServer>/<updateServer> in setMargeAccount)"},
|
||||
&cli.StringFlag{Name: "name", Usage: "Speaker name to set during pairing (empty = keep current)"},
|
||||
&cli.IntFlag{Name: "language", Value: setup.LanguageEnglish, Usage: "sysLanguage code (2 = English)"},
|
||||
&cli.IntFlag{Name: "language", Value: setup.LanguageEnglish, Usage: "sysLanguage code (3 = English)"},
|
||||
&cli.DurationFlag{Name: "step-timeout", Value: 8 * time.Second},
|
||||
&cli.StringFlag{Name: "token", Usage: "userAuthToken value (empty = use built-in placeholder matching the Bose app token shape)"},
|
||||
},
|
||||
@@ -1998,8 +2052,8 @@ func setupPairCmd() *cli.Command {
|
||||
fmt.Printf("Generated account id: %s\n", accountID)
|
||||
}
|
||||
|
||||
if !setup.IsValidAccountID(accountID) {
|
||||
return fmt.Errorf("invalid account id %q: must be 7 digits", accountID)
|
||||
if !datastore.IsSafeIdentifier(accountID) {
|
||||
return fmt.Errorf("invalid account id %q: must be a non-empty, path-safe identifier", accountID)
|
||||
}
|
||||
|
||||
switch mode {
|
||||
@@ -2081,6 +2135,17 @@ func runPairBare(c *cli.Context, deviceIP, accountID string) error {
|
||||
func runPairFull(c *cli.Context, deviceIP, accountID string) error {
|
||||
m := setup.NewManager(c.String("service-url"), nil, nil)
|
||||
|
||||
needed, status, err := m.PreflightInitPlan(deviceIP)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("preflight: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if !needed {
|
||||
PrintSuccess(fmt.Sprintf("Device already configured (status=%s) — nothing to do.", status))
|
||||
return nil
|
||||
}
|
||||
|
||||
plan := setup.InitPlan{
|
||||
DeviceIP: deviceIP,
|
||||
ServiceURL: c.String("service-url"),
|
||||
@@ -2094,7 +2159,7 @@ func runPairFull(c *cli.Context, deviceIP, accountID string) error {
|
||||
ctx, cancel := context.WithTimeout(c.Context, 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := m.ExecuteInitPlan(ctx, plan, func(e setup.StepEvent) {
|
||||
_, err = m.ExecuteInitPlan(ctx, plan, func(e setup.StepEvent) {
|
||||
switch e.Status {
|
||||
case setup.StatusOK:
|
||||
fmt.Printf("[%d] %s — ok\n", e.Kind, e.Name)
|
||||
|
||||
@@ -207,6 +207,38 @@ func TestRecommendMigrationMethod_EmptyWhenNoTransport(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRevertMethodOptions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
overrides []string
|
||||
wantError string
|
||||
}{
|
||||
{name: "ssh defaults", method: "ssh"},
|
||||
{name: "telnet defaults", method: "telnet"},
|
||||
{name: "telnet overrides", method: "telnet", overrides: []string{"marge-url"}},
|
||||
{name: "ssh rejects overrides", method: "ssh", overrides: []string{"marge-url"}, wantError: "requires --method telnet"},
|
||||
{name: "unknown method", method: "serial", wantError: "unsupported revert method"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateRevertMethodOptions(tt.method, tt.overrides)
|
||||
if tt.wantError == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("validateRevertMethodOptions: %v", err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantError) {
|
||||
t.Fatalf("error = %v, want text %q", err, tt.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanSteps_NoOpWhenAlreadyMigratedAndPaired(t *testing.T) {
|
||||
summary := &setup.MigrationSummary{IsMigrated: true, IsPaired: true, TelnetMigrated: true}
|
||||
inspect := &setup.InspectReport{Info: &setup.DeviceInfoXML{DeviceID: "AABBCCDDEEFF"}}
|
||||
|
||||
@@ -25,6 +25,9 @@ Based on captured WebSocket interactions and device API capabilities, this web U
|
||||
- **Real-time status monitoring** via WebSocket connections
|
||||
- **Multi-device support** with centralized control
|
||||
- **Connection status** indicators and health monitoring
|
||||
- **SoundTouch 10 stereo pairs** shown as one target, with verified create,
|
||||
rename, and dissolve operations across both physical speakers and their
|
||||
exact persisted group generation
|
||||
|
||||
### Playback Control
|
||||
- **Play/Pause/Stop/Next/Previous** controls
|
||||
@@ -88,7 +91,7 @@ go build -o soundtouch-player
|
||||
./soundtouch-player -port 8888
|
||||
|
||||
# Connect to specific device
|
||||
./soundtouch-player -host 192.0.2.100
|
||||
./soundtouch-player --devices 192.0.2.100
|
||||
```
|
||||
|
||||
### Command Line Options
|
||||
@@ -137,7 +140,7 @@ device datastore).
|
||||
The application automatically discovers SoundTouch devices using:
|
||||
- **mDNS discovery** for local network devices
|
||||
- **UPnP/SSDP discovery** as fallback
|
||||
- **Manual device addition** via IP address
|
||||
- **Configured devices** via `--devices`, retried whenever discovery runs
|
||||
|
||||
### Real-time Updates
|
||||
The interface maintains WebSocket connections to each device for instant updates of:
|
||||
@@ -345,6 +348,14 @@ Based on WebSocket interaction analysis, potential future features:
|
||||
- Advanced preset programming
|
||||
- Progressive Web App (PWA) features
|
||||
|
||||
## Behaviour reference
|
||||
|
||||
[Player: Sources and Selection State](../../docs/content/docs/reference/PLAYER-SOURCE-BEHAVIOUR.md)
|
||||
covers the parts that are not obvious from the code: which advertised sources
|
||||
can actually be selected and which need a station ContentItem, how a selection
|
||||
is confirmed against a speaker that answers 200 either way, and the revision
|
||||
and epoch fields the browser uses to order status updates.
|
||||
|
||||
## License
|
||||
|
||||
Same as the parent project - see main repository LICENSE file.
|
||||
|
||||
@@ -161,7 +161,7 @@ func main() {
|
||||
log.Printf("Trusting AfterTouch service CA from %s", sanitizeLog(caPath))
|
||||
}
|
||||
|
||||
discoveryService := soundtouchweb.NewDiscoveryService(ifaceName)
|
||||
discoveryService := soundtouchweb.NewDiscoveryService(ifaceName, manualHosts...)
|
||||
|
||||
// Discover devices on startup
|
||||
go func() {
|
||||
@@ -170,6 +170,11 @@ func main() {
|
||||
|
||||
webApp.BroadcastDiscoveryStatus("starting", webApp.DeviceCount())
|
||||
|
||||
// Register configured devices immediately rather than waiting for
|
||||
// the full mDNS/UPnP sweep below (bounded by cfg.DiscoveryTimeout,
|
||||
// currently 10s) to complete. manualHosts are also folded into
|
||||
// discoveryService's PreferredDevices so a host that's offline
|
||||
// right now still gets retried on every subsequent discovery pass.
|
||||
for _, host := range manualHosts {
|
||||
webApp.AddDeviceByHost(host, 8090, "manual")
|
||||
}
|
||||
|
||||
+277
-107
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -34,6 +35,7 @@ import (
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/stockholm"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/stereopair"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/urfave/cli/v2"
|
||||
@@ -300,6 +302,18 @@ var serviceFlags = []cli.Flag{
|
||||
Value: "5m",
|
||||
EnvVars: []string{"DISCOVERY_INTERVAL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "device-seed-retry-interval",
|
||||
Usage: "Interval between embedded-player startup retries for unreachable persisted devices",
|
||||
Value: "30s",
|
||||
EnvVars: []string{"DEVICE_SEED_RETRY_INTERVAL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "device-seed-retry-window",
|
||||
Usage: "Bounded window during which the embedded player retries unreachable persisted devices at startup",
|
||||
Value: "10m",
|
||||
EnvVars: []string{"DEVICE_SEED_RETRY_WINDOW"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "update-check-enabled",
|
||||
Usage: "Periodically check GitHub for a newer release (opt-in; the only network call this makes beyond speaker/provider traffic)",
|
||||
@@ -656,7 +670,7 @@ func main() {
|
||||
}
|
||||
|
||||
internalURL := "http://" + net.JoinHostPort(loopbackHost, config.port)
|
||||
webApp := newEmbeddedWebApp(server, config.serverURL, internalURL, ds)
|
||||
webApp := newEmbeddedWebApp(server, config.serverURL, internalURL, ds, config.deviceSeedRetryInterval, config.deviceSeedRetryWindow)
|
||||
|
||||
r := setupRouter(server, stockholmHandler, webApp)
|
||||
|
||||
@@ -715,55 +729,57 @@ func showVersionInfo(_ *cli.Context) error {
|
||||
}
|
||||
|
||||
type serviceConfig struct {
|
||||
port string
|
||||
bindAddr string
|
||||
addr string
|
||||
dataDir string
|
||||
hostname string
|
||||
serverURL string
|
||||
httpsServerURL string // effective (derived or overridden)
|
||||
httpsOverride string // explicit override; "" = derive from serverURL
|
||||
httpsPort string
|
||||
httpsDefaultURL string // hostname-based fallback
|
||||
httpsAddr string
|
||||
redact bool
|
||||
logBody bool
|
||||
record bool
|
||||
dnsEnabled bool
|
||||
dnsUpstream string
|
||||
dnsBind string
|
||||
internalPaths []string
|
||||
tlsExtraHosts []string
|
||||
discoveryEnabled bool
|
||||
discoveryInterval time.Duration
|
||||
updateCheckEnabled bool
|
||||
updateCheckInterval time.Duration
|
||||
domains []string
|
||||
spotifyClientID string
|
||||
spotifyClientSecret string
|
||||
spotifyRedirectURI string
|
||||
spotifyTokenURL string
|
||||
spotifyAPIBase string
|
||||
amazonClientID string
|
||||
amazonClientSecret string
|
||||
amazonRedirectURI string
|
||||
amazonTokenURL string
|
||||
amazonProfileURL string
|
||||
tuneInOpmlURL string
|
||||
tuneInAPIURL string
|
||||
mgmtUsername string
|
||||
mgmtPassword string
|
||||
ttsProvider string
|
||||
ttsGoogleAPIKey string
|
||||
ttsGoogleEndpoint string
|
||||
ttsLanguage string
|
||||
ttsVoice string
|
||||
ttsAppKey string
|
||||
ttsVolume int
|
||||
migrationEnabled bool
|
||||
migrationDryRun bool
|
||||
stockholmDir string
|
||||
stockholmBasePath string
|
||||
port string
|
||||
bindAddr string
|
||||
addr string
|
||||
dataDir string
|
||||
hostname string
|
||||
serverURL string
|
||||
httpsServerURL string // effective (derived or overridden)
|
||||
httpsOverride string // explicit override; "" = derive from serverURL
|
||||
httpsPort string
|
||||
httpsDefaultURL string // hostname-based fallback
|
||||
httpsAddr string
|
||||
redact bool
|
||||
logBody bool
|
||||
record bool
|
||||
dnsEnabled bool
|
||||
dnsUpstream string
|
||||
dnsBind string
|
||||
internalPaths []string
|
||||
tlsExtraHosts []string
|
||||
discoveryEnabled bool
|
||||
discoveryInterval time.Duration
|
||||
deviceSeedRetryInterval time.Duration
|
||||
deviceSeedRetryWindow time.Duration
|
||||
updateCheckEnabled bool
|
||||
updateCheckInterval time.Duration
|
||||
domains []string
|
||||
spotifyClientID string
|
||||
spotifyClientSecret string
|
||||
spotifyRedirectURI string
|
||||
spotifyTokenURL string
|
||||
spotifyAPIBase string
|
||||
amazonClientID string
|
||||
amazonClientSecret string
|
||||
amazonRedirectURI string
|
||||
amazonTokenURL string
|
||||
amazonProfileURL string
|
||||
tuneInOpmlURL string
|
||||
tuneInAPIURL string
|
||||
mgmtUsername string
|
||||
mgmtPassword string
|
||||
ttsProvider string
|
||||
ttsGoogleAPIKey string
|
||||
ttsGoogleEndpoint string
|
||||
ttsLanguage string
|
||||
ttsVoice string
|
||||
ttsAppKey string
|
||||
ttsVolume int
|
||||
migrationEnabled bool
|
||||
migrationDryRun bool
|
||||
stockholmDir string
|
||||
stockholmBasePath string
|
||||
}
|
||||
|
||||
// resolveFallbackHost picks the host used to guess a server URL when
|
||||
@@ -860,6 +876,24 @@ func loadConfig(c *cli.Context) (serviceConfig, error) {
|
||||
discoveryInterval = 5 * time.Minute
|
||||
}
|
||||
|
||||
deviceSeedRetryIntervalStr := c.String("device-seed-retry-interval")
|
||||
|
||||
deviceSeedRetryInterval, err := time.ParseDuration(deviceSeedRetryIntervalStr)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to parse device seed retry interval %s, using default 30s: %v", sanitizeLog(deviceSeedRetryIntervalStr), err)
|
||||
|
||||
deviceSeedRetryInterval = 30 * time.Second
|
||||
}
|
||||
|
||||
deviceSeedRetryWindowStr := c.String("device-seed-retry-window")
|
||||
|
||||
deviceSeedRetryWindow, err := time.ParseDuration(deviceSeedRetryWindowStr)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to parse device seed retry window %s, using default 10m: %v", sanitizeLog(deviceSeedRetryWindowStr), err)
|
||||
|
||||
deviceSeedRetryWindow = 10 * time.Minute
|
||||
}
|
||||
|
||||
updateCheckEnabled := c.Bool("update-check-enabled")
|
||||
updateCheckIntervalStr := c.String("update-check-interval")
|
||||
|
||||
@@ -898,55 +932,57 @@ func loadConfig(c *cli.Context) (serviceConfig, error) {
|
||||
stockholmBasePath := c.String("stockholm-base-path")
|
||||
|
||||
return serviceConfig{
|
||||
port: port,
|
||||
bindAddr: bindAddr,
|
||||
addr: addr,
|
||||
dataDir: dataDir,
|
||||
hostname: fallbackHost,
|
||||
serverURL: serverURL,
|
||||
httpsServerURL: httpsServerURL,
|
||||
httpsOverride: httpsOverride,
|
||||
httpsPort: httpsPort,
|
||||
httpsDefaultURL: httpsDefaultURL,
|
||||
httpsAddr: httpsAddr,
|
||||
redact: redact,
|
||||
logBody: logBody,
|
||||
record: record,
|
||||
dnsEnabled: dnsEnabled,
|
||||
dnsUpstream: dnsUpstream,
|
||||
dnsBind: dnsBind,
|
||||
internalPaths: internalPaths,
|
||||
tlsExtraHosts: tlsExtraHosts,
|
||||
discoveryEnabled: discoveryEnabled,
|
||||
discoveryInterval: discoveryInterval,
|
||||
updateCheckEnabled: updateCheckEnabled,
|
||||
updateCheckInterval: updateCheckInterval,
|
||||
domains: domains,
|
||||
spotifyClientID: spotifyClientID,
|
||||
spotifyClientSecret: spotifyClientSecret,
|
||||
spotifyRedirectURI: spotifyRedirectURI,
|
||||
spotifyTokenURL: spotifyTokenURL,
|
||||
spotifyAPIBase: spotifyAPIBase,
|
||||
amazonClientID: amazonClientID,
|
||||
amazonClientSecret: amazonClientSecret,
|
||||
amazonRedirectURI: amazonRedirectURI,
|
||||
amazonTokenURL: amazonTokenURL,
|
||||
amazonProfileURL: amazonProfileURL,
|
||||
tuneInOpmlURL: tuneInOpmlURL,
|
||||
tuneInAPIURL: tuneInAPIURL,
|
||||
mgmtUsername: mgmtUsername,
|
||||
mgmtPassword: mgmtPassword,
|
||||
ttsProvider: ttsProvider,
|
||||
ttsGoogleAPIKey: ttsGoogleAPIKey,
|
||||
ttsGoogleEndpoint: ttsGoogleEndpoint,
|
||||
ttsLanguage: ttsLanguage,
|
||||
ttsVoice: ttsVoice,
|
||||
ttsAppKey: ttsAppKey,
|
||||
ttsVolume: ttsVolume,
|
||||
migrationEnabled: migrationEnabled,
|
||||
migrationDryRun: migrationDryRun,
|
||||
stockholmDir: stockholmDir,
|
||||
stockholmBasePath: stockholmBasePath,
|
||||
port: port,
|
||||
bindAddr: bindAddr,
|
||||
addr: addr,
|
||||
dataDir: dataDir,
|
||||
hostname: fallbackHost,
|
||||
serverURL: serverURL,
|
||||
httpsServerURL: httpsServerURL,
|
||||
httpsOverride: httpsOverride,
|
||||
httpsPort: httpsPort,
|
||||
httpsDefaultURL: httpsDefaultURL,
|
||||
httpsAddr: httpsAddr,
|
||||
redact: redact,
|
||||
logBody: logBody,
|
||||
record: record,
|
||||
dnsEnabled: dnsEnabled,
|
||||
dnsUpstream: dnsUpstream,
|
||||
dnsBind: dnsBind,
|
||||
internalPaths: internalPaths,
|
||||
tlsExtraHosts: tlsExtraHosts,
|
||||
discoveryEnabled: discoveryEnabled,
|
||||
discoveryInterval: discoveryInterval,
|
||||
deviceSeedRetryInterval: deviceSeedRetryInterval,
|
||||
deviceSeedRetryWindow: deviceSeedRetryWindow,
|
||||
updateCheckEnabled: updateCheckEnabled,
|
||||
updateCheckInterval: updateCheckInterval,
|
||||
domains: domains,
|
||||
spotifyClientID: spotifyClientID,
|
||||
spotifyClientSecret: spotifyClientSecret,
|
||||
spotifyRedirectURI: spotifyRedirectURI,
|
||||
spotifyTokenURL: spotifyTokenURL,
|
||||
spotifyAPIBase: spotifyAPIBase,
|
||||
amazonClientID: amazonClientID,
|
||||
amazonClientSecret: amazonClientSecret,
|
||||
amazonRedirectURI: amazonRedirectURI,
|
||||
amazonTokenURL: amazonTokenURL,
|
||||
amazonProfileURL: amazonProfileURL,
|
||||
tuneInOpmlURL: tuneInOpmlURL,
|
||||
tuneInAPIURL: tuneInAPIURL,
|
||||
mgmtUsername: mgmtUsername,
|
||||
mgmtPassword: mgmtPassword,
|
||||
ttsProvider: ttsProvider,
|
||||
ttsGoogleAPIKey: ttsGoogleAPIKey,
|
||||
ttsGoogleEndpoint: ttsGoogleEndpoint,
|
||||
ttsLanguage: ttsLanguage,
|
||||
ttsVoice: ttsVoice,
|
||||
ttsAppKey: ttsAppKey,
|
||||
ttsVolume: ttsVolume,
|
||||
migrationEnabled: migrationEnabled,
|
||||
migrationDryRun: migrationDryRun,
|
||||
stockholmDir: stockholmDir,
|
||||
stockholmBasePath: stockholmBasePath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1427,7 +1463,7 @@ func runUpdateCheckTick(checker *updatecheck.Checker, lastLoggedVersion string)
|
||||
// TriggerDiscovery runs the service sweep on a UI-initiated "discover", and the
|
||||
// devices-changed hook re-syncs the UI registry whenever the service's
|
||||
// discovery or a manual add changes the set.
|
||||
func newEmbeddedWebApp(server *handlers.Server, serverURL, internalURL string, ds *datastore.DataStore) *soundtouchweb.WebApp {
|
||||
func newEmbeddedWebApp(server *handlers.Server, serverURL, internalURL string, ds *datastore.DataStore, deviceSeedRetryInterval, deviceSeedRetryWindow time.Duration) *soundtouchweb.WebApp {
|
||||
webApp := soundtouchweb.NewWebApp()
|
||||
webApp.Version = version
|
||||
webApp.Commit = commit
|
||||
@@ -1444,11 +1480,10 @@ func newEmbeddedWebApp(server *handlers.Server, serverURL, internalURL string, d
|
||||
// stream URLs the speaker fetches and the UI displays it.
|
||||
webApp.InternalServiceURL = internalURL
|
||||
|
||||
webApp.ExtraDeviceHosts = func() []string {
|
||||
webApp.ExtraDeviceHosts = func() ([]string, error) {
|
||||
devices, listErr := ds.ListAllDevices()
|
||||
if listErr != nil {
|
||||
log.Printf("web UI: failed to list devices from datastore: %v", listErr)
|
||||
return nil
|
||||
return nil, fmt.Errorf("web UI: failed to list devices from datastore: %w", listErr)
|
||||
}
|
||||
|
||||
hosts := make([]string, 0, len(devices))
|
||||
@@ -1458,7 +1493,7 @@ func newEmbeddedWebApp(server *handlers.Server, serverURL, internalURL string, d
|
||||
}
|
||||
}
|
||||
|
||||
return hosts
|
||||
return hosts, nil
|
||||
}
|
||||
|
||||
// UI "discover" runs the service's sweep, not a second mDNS stack.
|
||||
@@ -1471,6 +1506,38 @@ func newEmbeddedWebApp(server *handlers.Server, serverURL, internalURL string, d
|
||||
return err
|
||||
}
|
||||
|
||||
// Preserve the datastore's atomic, all-account guarantees for speakers that
|
||||
// point at this service, while following fresh /info to an external Marge
|
||||
// backend for speakers still managed by SoundCork or another service.
|
||||
cleanup, preflight, rename := embeddedStereoPairGenerationPersistence(
|
||||
ds,
|
||||
func() []string {
|
||||
localServerURL, localHTTPSServerURL := server.GetSettings()
|
||||
return []string{localServerURL, localHTTPSServerURL}
|
||||
},
|
||||
func(margeURL string) bool {
|
||||
if !server.DNSHijackEnabled() {
|
||||
return false
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(strings.TrimSpace(margeURL))
|
||||
if err != nil || parsed.Hostname() == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
host := parsed.Hostname()
|
||||
for _, hijacked := range discovery.InterceptedBoseHosts {
|
||||
if strings.Contains(host, hijacked) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
&http.Client{Timeout: stereopair.RequestTimeout},
|
||||
)
|
||||
webApp.SetStereoPairGenerationPersistence(cleanup, preflight, rename)
|
||||
|
||||
// Keep the UI registry live as the service discovers or devices are added.
|
||||
server.SetDevicesChangedHook(func() {
|
||||
webApp.SeedExtraDevices()
|
||||
@@ -1478,15 +1545,118 @@ func newEmbeddedWebApp(server *handlers.Server, serverURL, internalURL string, d
|
||||
})
|
||||
|
||||
go func() {
|
||||
// Project the current device set into the UI; the devices-changed hook
|
||||
// and the service's periodic discovery keep it current from here on.
|
||||
webApp.SeedExtraDevices()
|
||||
// Project the current device set into the UI. During gateway boot the
|
||||
// service can start before persisted speaker addresses are routable, so
|
||||
// retry only those known addresses for a bounded startup window. The
|
||||
// devices-changed hook and explicit discovery keep it current afterwards.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), deviceSeedRetryWindow)
|
||||
defer cancel()
|
||||
|
||||
webApp.SeedExtraDevicesUntilReady(ctx, deviceSeedRetryInterval)
|
||||
|
||||
// Unconditional: a WebSocket client connected during a window where
|
||||
// every attempt inserted or removed nothing (e.g. no persisted devices
|
||||
// at all, or every persisted host stayed unreachable for the whole
|
||||
// window) must still see the current, converged device list once this
|
||||
// goroutine's work is done.
|
||||
webApp.BroadcastDeviceList()
|
||||
}()
|
||||
|
||||
return webApp
|
||||
}
|
||||
|
||||
func embeddedStereoPairGenerationPersistence(
|
||||
ds *datastore.DataStore,
|
||||
localMargeURLs func() []string,
|
||||
dnsHijackedMargeHost func(margeURL string) bool,
|
||||
httpClient *http.Client,
|
||||
) (stereopair.GenerationCleanup, stereopair.GenerationPreflight, stereopair.GenerationRename) {
|
||||
isLocal := func(margeURL string, localURLs []string) bool {
|
||||
for _, localURL := range localURLs {
|
||||
if stereopair.SameMargeBackend(margeURL, localURL) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// DNS-level migration never changes a speaker's own reported
|
||||
// MargeURL -- only how that Bose hostname resolves on the network --
|
||||
// so a speaker reporting e.g. https://streaming.bose.com can still be
|
||||
// pointed at this very service. Treating it as "external" instead
|
||||
// sends the generation-conflict check out over the real internet,
|
||||
// where Bose's still-live Apigee gateway rejects it (HTTP 401),
|
||||
// hard-blocking Create for a normal DNS-migrated setup.
|
||||
return dnsHijackedMargeHost(margeURL)
|
||||
}
|
||||
|
||||
cleanup := func(ref stereopair.GenerationRef) error {
|
||||
if isLocal(ref.MargeURL, localMargeURLs()) {
|
||||
err := ds.DeleteGroupGenerationForDevice(ref.DeviceID, ref.GroupID, ref.ExpectedGroup)
|
||||
if errors.Is(err, datastore.ErrGroupDeleteAmbiguous) {
|
||||
return fmt.Errorf("%w: %w", stereopair.ErrConflict, err)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// The speaker itself self-reports its own group teardown to
|
||||
// whatever Marge backend it's configured with -- that's the entire
|
||||
// reason HandleMargeDeleteGroup/HandleMargeDeleteAccountGroups
|
||||
// exist, they're only ever called by speakers, never by us.
|
||||
// Nothing for us to push to a backend we don't own.
|
||||
return nil
|
||||
}
|
||||
|
||||
preflight := func(refs []stereopair.GenerationRef) error {
|
||||
localURLs := localMargeURLs()
|
||||
localDeviceIDs := make([]string, 0, len(refs))
|
||||
externalRefs := make([]stereopair.GenerationRef, 0, len(refs))
|
||||
|
||||
for i := range refs {
|
||||
if isLocal(refs[i].MargeURL, localURLs) {
|
||||
localDeviceIDs = append(localDeviceIDs, refs[i].DeviceID)
|
||||
} else {
|
||||
externalRefs = append(externalRefs, refs[i])
|
||||
}
|
||||
}
|
||||
|
||||
if len(localDeviceIDs) > 0 {
|
||||
if err := ds.EnsureNoGroupsForDevices(localDeviceIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(externalRefs) > 0 {
|
||||
// Best-effort dangling-generation check against a backend we
|
||||
// don't own (real Bose cloud, another AfterTouch/SoundCork
|
||||
// instance, ...): attempt it, but never let it being
|
||||
// unreachable or unauthenticated block a Create the
|
||||
// coordinator's own physical preflight already verified safe.
|
||||
if err := stereopair.EnsureMargeNoGroupGenerations(httpClient, externalRefs); err != nil {
|
||||
log.Printf("Stereo-pair external generation preflight inconclusive, proceeding: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
rename := func(ref stereopair.GenerationRef, name string) error {
|
||||
if isLocal(ref.MargeURL, localMargeURLs()) {
|
||||
_, err := ds.RenameGroupGenerationForDevice(ref.DeviceID, ref.GroupID, ref.ExpectedGroup, name)
|
||||
if errors.Is(err, datastore.ErrGroupNotFound) || errors.Is(err, datastore.ErrGroupDeleteAmbiguous) {
|
||||
return fmt.Errorf("%w: %w", stereopair.ErrConflict, err)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// See cleanup's comment above: the speaker self-reports its own
|
||||
// rename to whatever Marge backend it's configured with.
|
||||
return nil
|
||||
}
|
||||
|
||||
return cleanup, preflight, rename
|
||||
}
|
||||
|
||||
func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler, webApp *soundtouchweb.WebApp) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/urfave/cli/v2"
|
||||
@@ -69,6 +70,57 @@ func TestResolveFallbackHost(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_DeviceSeedRetryTuning(t *testing.T) {
|
||||
t.Run("defaults", func(t *testing.T) {
|
||||
config, err := loadConfig(newTestServiceContext(t))
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if config.deviceSeedRetryInterval != 30*time.Second {
|
||||
t.Errorf("deviceSeedRetryInterval = %s, want 30s", config.deviceSeedRetryInterval)
|
||||
}
|
||||
|
||||
if config.deviceSeedRetryWindow != 10*time.Minute {
|
||||
t.Errorf("deviceSeedRetryWindow = %s, want 10m", config.deviceSeedRetryWindow)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("flags override the defaults", func(t *testing.T) {
|
||||
config, err := loadConfig(newTestServiceContext(t,
|
||||
"--device-seed-retry-interval=5s",
|
||||
"--device-seed-retry-window=1m"))
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if config.deviceSeedRetryInterval != 5*time.Second {
|
||||
t.Errorf("deviceSeedRetryInterval = %s, want 5s", config.deviceSeedRetryInterval)
|
||||
}
|
||||
|
||||
if config.deviceSeedRetryWindow != time.Minute {
|
||||
t.Errorf("deviceSeedRetryWindow = %s, want 1m", config.deviceSeedRetryWindow)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unparseable values fall back to the defaults", func(t *testing.T) {
|
||||
config, err := loadConfig(newTestServiceContext(t,
|
||||
"--device-seed-retry-interval=not-a-duration",
|
||||
"--device-seed-retry-window=also-not-a-duration"))
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if config.deviceSeedRetryInterval != 30*time.Second {
|
||||
t.Errorf("deviceSeedRetryInterval = %s, want fallback 30s", config.deviceSeedRetryInterval)
|
||||
}
|
||||
|
||||
if config.deviceSeedRetryWindow != 10*time.Minute {
|
||||
t.Errorf("deviceSeedRetryWindow = %s, want fallback 10m", config.deviceSeedRetryWindow)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadConfig_DeploymentMode(t *testing.T) {
|
||||
t.Run("on-device with no --server-url defaults to localhost", func(t *testing.T) {
|
||||
config, err := loadConfig(newTestServiceContext(t, "--deployment-mode=on-device", "--port=8000"))
|
||||
|
||||
@@ -39,11 +39,8 @@ func TestPrintRoutes(t *testing.T) {
|
||||
// Now we might have "soundtouch-service.setupRouter.func1"
|
||||
// or "command-line-arguments.setupRouter.func1"
|
||||
// or "main.setupRouter.func1"
|
||||
// Let's remove the first part if it's a known varying package name
|
||||
if idx := strings.Index(handlerName, "setupRouter"); idx != -1 {
|
||||
handlerName = handlerName[idx:]
|
||||
}
|
||||
// In case it's not setupRouter but still has a package prefix
|
||||
// Remove the leading package/binary-name segment(s), whatever form
|
||||
// they take.
|
||||
for {
|
||||
dotIdx := strings.Index(handlerName, ".")
|
||||
if dotIdx == -1 {
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/stereopair"
|
||||
)
|
||||
|
||||
// neverDNSHijacked is the default DNS-hijack predicate for tests that don't
|
||||
// exercise the DNS-migrated-speaker path (see
|
||||
// TestEmbeddedStereoPairPersistenceTreatsDNSHijackedBoseHostAsLocal).
|
||||
func neverDNSHijacked(string) bool { return false }
|
||||
|
||||
// rejectingRoundTripper errors on every request and counts how many it saw.
|
||||
// A preflight failure is now logged and swallowed rather than propagated
|
||||
// (see TestEmbeddedStereoPairPersistenceSkipsExternalWritesButAttemptsRead),
|
||||
// so tests that need to prove an external dispatch actually happened check
|
||||
// calls rather than the returned error.
|
||||
type rejectingRoundTripper struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (r *rejectingRoundTripper) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
r.calls++
|
||||
|
||||
return nil, errors.New("unexpected HTTP persistence request")
|
||||
}
|
||||
|
||||
func persistenceTestGroup(id string) *models.Group {
|
||||
return &models.Group{
|
||||
ID: id,
|
||||
Name: "Living room",
|
||||
MasterDeviceID: "LEFT-ID",
|
||||
Roles: models.GroupRoles{Roles: []models.GroupRole{
|
||||
{DeviceID: "LEFT-ID", Role: "LEFT", IPAddress: "192.0.2.10"},
|
||||
{DeviceID: "RIGHT-ID", Role: "RIGHT", IPAddress: "192.0.2.11"},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedStereoPairPersistenceUsesLocalDatastoreAcrossAccounts(t *testing.T) {
|
||||
ds := datastore.NewDataStore(t.TempDir())
|
||||
group := persistenceTestGroup("")
|
||||
groupID, err := ds.AddGroup("OLD-ACCOUNT", group)
|
||||
if err != nil {
|
||||
t.Fatalf("AddGroup: %v", err)
|
||||
}
|
||||
|
||||
localURL := "https://aftertouch.invalid:18443"
|
||||
cleanup, preflight, rename := embeddedStereoPairGenerationPersistence(
|
||||
ds,
|
||||
func() []string { return []string{localURL} },
|
||||
neverDNSHijacked,
|
||||
&http.Client{Transport: &rejectingRoundTripper{}},
|
||||
)
|
||||
|
||||
err = preflight([]stereopair.GenerationRef{{
|
||||
DeviceID: "LEFT-ID", AccountID: "NEW-ACCOUNT", MargeURL: localURL,
|
||||
}})
|
||||
if err == nil || !strings.Contains(err.Error(), groupID) {
|
||||
t.Fatalf("preflight error = %v, want cross-account generation %s", err, groupID)
|
||||
}
|
||||
if err := rename(stereopair.GenerationRef{
|
||||
DeviceID: "LEFT-ID", AccountID: "NEW-ACCOUNT", MargeURL: localURL,
|
||||
GroupID: groupID, ExpectedGroup: group,
|
||||
}, "Renamed living room"); err != nil {
|
||||
t.Fatalf("rename: %v", err)
|
||||
}
|
||||
group.Name = "Renamed living room"
|
||||
|
||||
if err := cleanup(stereopair.GenerationRef{
|
||||
DeviceID: "LEFT-ID", AccountID: "NEW-ACCOUNT", MargeURL: localURL,
|
||||
GroupID: groupID, ExpectedGroup: group,
|
||||
}); err != nil {
|
||||
t.Fatalf("cleanup: %v", err)
|
||||
}
|
||||
|
||||
if err := preflight([]stereopair.GenerationRef{{
|
||||
DeviceID: "LEFT-ID", AccountID: "NEW-ACCOUNT", MargeURL: localURL,
|
||||
}}); err != nil {
|
||||
t.Fatalf("preflight after exact cleanup: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedStereoPairCleanupMapsAmbiguousGenerationToConflict(t *testing.T) {
|
||||
ds := datastore.NewDataStore(t.TempDir())
|
||||
group := persistenceTestGroup("")
|
||||
groupID, err := ds.AddGroup("ACCOUNT1", group)
|
||||
if err != nil {
|
||||
t.Fatalf("AddGroup: %v", err)
|
||||
}
|
||||
localURL := "https://aftertouch.invalid:18443"
|
||||
cleanup, _, _ := embeddedStereoPairGenerationPersistence(
|
||||
ds,
|
||||
func() []string { return []string{localURL} },
|
||||
neverDNSHijacked,
|
||||
&http.Client{Transport: &rejectingRoundTripper{}},
|
||||
)
|
||||
wrongTopology := persistenceTestGroup(groupID)
|
||||
wrongTopology.Roles.Roles[1].DeviceID = "SUBSTITUTE-RIGHT-ID"
|
||||
|
||||
err = cleanup(stereopair.GenerationRef{
|
||||
DeviceID: "LEFT-ID", AccountID: "ACCOUNT1", MargeURL: localURL,
|
||||
GroupID: groupID, ExpectedGroup: wrongTopology,
|
||||
})
|
||||
if !errors.Is(err, stereopair.ErrConflict) || errors.Is(err, stereopair.ErrUnavailable) {
|
||||
t.Fatalf("cleanup error = %v, want ErrConflict only", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmbeddedStereoPairPersistenceSkipsExternalWritesButAttemptsRead covers
|
||||
// an external (non-local) MargeURL: cleanup and rename must never push to a
|
||||
// backend we don't own -- the speaker itself self-reports its own group
|
||||
// teardown/rename to whatever Marge backend it's configured with, which is
|
||||
// the entire reason HandleMargeDeleteGroup/HandleMargeModifyGroup exist
|
||||
// (they're only ever called by speakers). Preflight still attempts its
|
||||
// read-only dangling-generation check, but a failure there (network error,
|
||||
// wrong credentials, real Bose cloud rejecting us, ...) must not block
|
||||
// Create, since it's a best-effort check on top of the coordinator's own
|
||||
// physical preflight, not the primary guard.
|
||||
func TestEmbeddedStereoPairPersistenceSkipsExternalWritesButAttemptsRead(t *testing.T) {
|
||||
getCalls := 0
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/device/LEFT-ID/group") {
|
||||
getCalls++
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
t.Fatalf("unexpected external %s %s: cleanup/rename must not write to a backend we don't own", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cleanup, preflight, rename := embeddedStereoPairGenerationPersistence(
|
||||
datastore.NewDataStore(t.TempDir()),
|
||||
func() []string { return []string{"http://aftertouch.invalid:18000"} },
|
||||
neverDNSHijacked,
|
||||
server.Client(),
|
||||
)
|
||||
|
||||
ref := stereopair.GenerationRef{
|
||||
DeviceID: "LEFT-ID", AccountID: "ACCOUNT1", MargeURL: server.URL + "/marge",
|
||||
GroupID: "7654321", ExpectedGroup: persistenceTestGroup("7654321"),
|
||||
}
|
||||
|
||||
if err := rename(ref, "Renamed living room"); err != nil {
|
||||
t.Fatalf("rename = %v, want nil (speaker self-reports its own rename)", err)
|
||||
}
|
||||
if err := cleanup(ref); err != nil {
|
||||
t.Fatalf("cleanup = %v, want nil (speaker self-reports its own teardown)", err)
|
||||
}
|
||||
if err := preflight([]stereopair.GenerationRef{ref}); err != nil {
|
||||
t.Fatalf("preflight = %v, want nil: an unauthenticated external check must not block Create", err)
|
||||
}
|
||||
if getCalls != 1 {
|
||||
t.Fatalf("external GET calls = %d, want exactly 1 (preflight must still attempt the read)", getCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedStereoPairPersistenceReadsOneCurrentURLSnapshot(t *testing.T) {
|
||||
ds := datastore.NewDataStore(t.TempDir())
|
||||
group := persistenceTestGroup("")
|
||||
groupID, err := ds.AddGroup("OLD-ACCOUNT", group)
|
||||
if err != nil {
|
||||
t.Fatalf("AddGroup: %v", err)
|
||||
}
|
||||
|
||||
currentURL := "http://old.invalid:18000"
|
||||
providerCalls := 0
|
||||
transport := &rejectingRoundTripper{}
|
||||
_, preflight, _ := embeddedStereoPairGenerationPersistence(
|
||||
ds,
|
||||
func() []string {
|
||||
providerCalls++
|
||||
return []string{currentURL}
|
||||
},
|
||||
neverDNSHijacked,
|
||||
&http.Client{Transport: transport},
|
||||
)
|
||||
|
||||
currentURL = "http://new.invalid:18000"
|
||||
err = preflight([]stereopair.GenerationRef{
|
||||
{DeviceID: "LEFT-ID", AccountID: "NEW-ACCOUNT", MargeURL: currentURL},
|
||||
{DeviceID: "RIGHT-ID", AccountID: "NEW-ACCOUNT", MargeURL: currentURL},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), groupID) {
|
||||
t.Fatalf("preflight error = %v, want current local generation %s", err, groupID)
|
||||
}
|
||||
if providerCalls != 1 {
|
||||
t.Fatalf("URL provider calls = %d, want one coherent snapshot", providerCalls)
|
||||
}
|
||||
|
||||
// The old URL no longer matches localMargeURLs()'s current snapshot, so
|
||||
// this ref is external. Preflight still attempts the read (proven by the
|
||||
// transport call count) but no longer propagates its failure -- an
|
||||
// external check failing must not block Create.
|
||||
err = preflight([]stereopair.GenerationRef{{
|
||||
DeviceID: "LEFT-ID", AccountID: "OLD-ACCOUNT", MargeURL: "http://old.invalid:18000",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("old URL preflight error = %v, want nil (external check failure must not block)", err)
|
||||
}
|
||||
if transport.calls != 1 {
|
||||
t.Fatalf("external HTTP dispatch calls = %d, want exactly 1", transport.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmbeddedStereoPairPersistenceTreatsDNSHijackedBoseHostAsLocal covers a
|
||||
// speaker migrated at the DNS level: its own reported MargeURL is still the
|
||||
// literal Bose cloud hostname (DNS migration never changes it), but this
|
||||
// service's DNS hijack redirects that hostname to itself on the network.
|
||||
// Routing it through the external HTTP path instead would reach the real,
|
||||
// still-live Bose cloud and 401 there, hard-blocking Create for a normal
|
||||
// DNS-migrated setup. Uses rejectingRoundTripper to prove no HTTP call is
|
||||
// attempted at all.
|
||||
func TestEmbeddedStereoPairPersistenceTreatsDNSHijackedBoseHostAsLocal(t *testing.T) {
|
||||
ds := datastore.NewDataStore(t.TempDir())
|
||||
group := persistenceTestGroup("")
|
||||
groupID, err := ds.AddGroup("OLD-ACCOUNT", group)
|
||||
if err != nil {
|
||||
t.Fatalf("AddGroup: %v", err)
|
||||
}
|
||||
|
||||
_, preflight, _ := embeddedStereoPairGenerationPersistence(
|
||||
ds,
|
||||
func() []string { return []string{"https://aftertouch.invalid:18443"} },
|
||||
func(margeURL string) bool { return strings.Contains(margeURL, "streaming.bose.com") },
|
||||
&http.Client{Transport: &rejectingRoundTripper{}},
|
||||
)
|
||||
|
||||
err = preflight([]stereopair.GenerationRef{{
|
||||
DeviceID: "LEFT-ID", AccountID: "NEW-ACCOUNT", MargeURL: "https://streaming.bose.com",
|
||||
}})
|
||||
if err == nil || !strings.Contains(err.Error(), groupID) {
|
||||
t.Fatalf("preflight error = %v, want local generation %s found via datastore, not an external HTTP call", err, groupID)
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -6,6 +6,7 @@ DELETE /accounts/{account}/group/ handlers.(
|
||||
DELETE /accounts/{account}/group/{groupId} handlers.(*Server).HandleUnsupported-fm
|
||||
DELETE /api/control/devices/{id}/ soundtouchweb.(*WebApp).HandleDeleteDevice-fm
|
||||
DELETE /api/control/devices/{id}/library/servers/{account} soundtouchweb.(*WebApp).HandleRemoveLibraryServer-fm
|
||||
DELETE /api/control/devices/{id}/stereo-pair/ soundtouchweb.(*WebApp).HandleDissolveStereoPair-fm
|
||||
DELETE /api/setup/devices/{deviceId} handlers.(*Server).HandleRemoveDevice-fm
|
||||
DELETE /api/setup/dns-discoveries handlers.(*Server).HandleClearDNSDiscoveries-fm
|
||||
DELETE /api/setup/interactions/sessions handlers.(*Server).HandleCleanupSessions-fm
|
||||
@@ -42,10 +43,13 @@ GET /api/control/devices/{id}/ soundtouch
|
||||
GET /api/control/devices/{id}/action/{action} soundtouchweb.(*WebApp).HandleAPIControl-fm
|
||||
GET /api/control/devices/{id}/library/browse soundtouchweb.(*WebApp).HandleLibraryBrowse-fm
|
||||
GET /api/control/devices/{id}/library/servers soundtouchweb.(*WebApp).HandleDeviceLibraryServers-fm
|
||||
GET /api/control/devices/{id}/now-playing soundtouchweb.(*WebApp).HandleDeviceNowPlaying-fm
|
||||
GET /api/control/devices/{id}/power-status soundtouchweb.(*WebApp).HandleDevicePowerStatus-fm
|
||||
GET /api/control/devices/{id}/recents soundtouchweb.(*WebApp).HandleDeviceRecents-fm
|
||||
GET /api/control/devices/{id}/stereo-pair/ soundtouchweb.(*WebApp).HandleGetStereoPair-fm
|
||||
GET /api/control/devices/{id}/ws soundtouchweb.(*WebApp).HandleDeviceWebSocket-fm
|
||||
GET /api/control/devices/{id}/zone/ soundtouchweb.(*WebApp).HandleGetZone-fm
|
||||
GET /api/control/devices/{id}/zone/candidates soundtouchweb.(*WebApp).HandleGetZoneCandidates-fm
|
||||
GET /api/control/providers/library/servers soundtouchweb.(*WebApp).HandleDiscoverLibraryServers-fm
|
||||
GET /api/control/providers/radiobrowser/search soundtouchweb.(*WebApp).HandleRadioBrowserSearch-fm
|
||||
GET /api/control/providers/tunein/navigate soundtouchweb.(*WebApp).HandleTuneInNavigate-fm
|
||||
@@ -169,11 +173,12 @@ GET /streaming/sourceproviders handlers.(
|
||||
GET /updates/soundtouch handlers.(*Server).HandleMargeSoftwareUpdate-fm
|
||||
GET /v1/auth handlers.(*Server).HandleSpeakerAuth-fm
|
||||
GET /v1/blacklist/{deviceId} setupRouter
|
||||
GET /web/* setupRouter.(*Server).HandleWeb
|
||||
GET /web/* handlers.(*Server).HandleWeb
|
||||
HEAD /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
HEAD /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
OPTIONS /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
OPTIONS /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
PATCH /api/control/devices/{id}/stereo-pair/ soundtouchweb.(*WebApp).HandleRenameStereoPair-fm
|
||||
PATCH /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
PATCH /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
POST /accounts/{account}/devices handlers.(*Server).HandleUnsupported-fm
|
||||
@@ -194,6 +199,7 @@ POST /api/control/devices/{id}/providers/radiobrowser/play soundtouch
|
||||
POST /api/control/devices/{id}/providers/tts/play soundtouchweb.(*WebApp).HandleAPISpeakText-fm
|
||||
POST /api/control/devices/{id}/providers/tunein/play soundtouchweb.(*WebApp).HandlePlayTuneIn-fm
|
||||
POST /api/control/devices/{id}/providers/url/play soundtouchweb.(*WebApp).HandlePlayURL-fm
|
||||
POST /api/control/devices/{id}/stereo-pair/ soundtouchweb.(*WebApp).HandleCreateStereoPair-fm
|
||||
POST /api/control/devices/{id}/volume/{volume} soundtouchweb.(*WebApp).HandleDirectVolumeControl-fm
|
||||
POST /api/control/devices/{id}/zone/add/{slaveId} soundtouchweb.(*WebApp).HandleZoneAdd-fm
|
||||
POST /api/control/devices/{id}/zone/dissolve soundtouchweb.(*WebApp).HandleZoneDissolve-fm
|
||||
|
||||
@@ -35,7 +35,7 @@ services:
|
||||
start_period: 3s
|
||||
|
||||
spotify-mock:
|
||||
image: golang:1.26.6-alpine
|
||||
image: golang:1.27.1-alpine
|
||||
container_name: spotify-mock
|
||||
working_dir: /app
|
||||
volumes:
|
||||
@@ -53,7 +53,7 @@ services:
|
||||
start_period: 3s
|
||||
|
||||
amazon-mock:
|
||||
image: golang:1.26.6-alpine
|
||||
image: golang:1.27.1-alpine
|
||||
container_name: amazon-mock
|
||||
working_dir: /app
|
||||
volumes:
|
||||
@@ -71,7 +71,7 @@ services:
|
||||
start_period: 3s
|
||||
|
||||
tunein-mock:
|
||||
image: golang:1.26.6-alpine
|
||||
image: golang:1.27.1-alpine
|
||||
container_name: tunein-mock
|
||||
working_dir: /app
|
||||
volumes:
|
||||
|
||||
@@ -112,6 +112,18 @@ Factory-reset the same speaker again and run the full state machine — the same
|
||||
|
||||
This drives `setup.Manager.ExecuteInitPlan` with `SkipURLRewrite=true`, which runs:
|
||||
|
||||
> **Update (#615):** `--mode=full` now preflights via `Manager.PreflightInitPlan`
|
||||
> before opening the WebSocket — it checks `/supportedURLs` for
|
||||
> `/setMargeAccount` and requires `/soundTouchConfigurationStatus` to read
|
||||
> `SOUNDTOUCH_NOT_CONFIGURED`, and no-ops on an already-configured device.
|
||||
> A freshly factory-reset speaker (as in this experiment) reports
|
||||
> `SOUNDTOUCH_NOT_CONFIGURED`, so the preflight passes through unchanged;
|
||||
> see `docs/content/docs/reference/DEVICE-PAIRING-FLOW.md`.
|
||||
|
||||
The historical capture below sent language code `2`. Stockholm's language
|
||||
table identifies that code as German; current English-language setup uses code
|
||||
`3`. The original wire value is retained here as experiment evidence.
|
||||
|
||||
```
|
||||
SETUP_START
|
||||
SETUP_IDENTIFY_DEVICE_ENTER
|
||||
|
||||
@@ -450,11 +450,14 @@ that `setup.PairAccount` already implements.
|
||||
| ST Portable / FW 27.0.6 | jmosen | #236 | After migration: `POST /marge/streaming/support/power_on` → 502; `<margeAccountUUID/>` empty. Time-bounded HTTP path fails; envswitch fallback succeeds. |
|
||||
| BST20 Portable (factory reset) | ubittner | scheilch/opencloudtouch#167 | `<margeAccountUUID/>` empty; `/setMargeAccount` not in `/supportedURLs`. HTTP path skipped entirely; only the telnet fallback works. |
|
||||
|
||||
### 8.3 Likely to fail (but the failure is clean)
|
||||
### 8.3 Likely to reject the telnet sequence
|
||||
|
||||
Our preflight + abort-on-first-rejection design (`TestMigrateViaTelnet_CommandNotFoundAborts`)
|
||||
means none of these scenarios leave a device half-configured. The user is
|
||||
told what failed and pointed to the XML or DNS method.
|
||||
stops at the first rejected command and reports whether earlier runtime writes
|
||||
or the persistence command may already have applied. A rejection of command #1
|
||||
leaves the URL state untouched; after any later failure, read back all four URL
|
||||
fields before retrying or rebooting. The user is also pointed to the XML or DNS
|
||||
method.
|
||||
|
||||
| Device | Source | Likely cause |
|
||||
|--------------------------------------------|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
@@ -482,8 +485,10 @@ negative claim: the author writes "I've made some educated guesses and come
|
||||
up with the following valid commands" and never says they tested
|
||||
`envswitch`. We do not down-weight `envswitch` availability on the strength
|
||||
of S5 alone — but if a real-device run ever shows `envswitch` rejected on
|
||||
an ST 10, our preflight catches it, the migration aborts on the first
|
||||
non-OK response, and the user gets a clear error rather than partial state.
|
||||
an ST 10, the migration aborts on the first unconfirmed response and reports
|
||||
whether runtime writes were confirmed or persistence is uncertain. Because the
|
||||
commands are sequential, the user must read back all four fields before retrying
|
||||
or rebooting.
|
||||
|
||||
### 8.6 Failure-mode matrix
|
||||
|
||||
@@ -492,10 +497,12 @@ What `migrateViaTelnet` does in each failure mode (verified by
|
||||
|
||||
| Failure | Outcome | Test |
|
||||
|------------------------------------------------|---------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------|
|
||||
| Port 17000 closed / TCP unreachable | `Dial` errors before any command is sent; UI shows the error; nothing persisted | `TestMigrateViaTelnet_DialFailureReturnsError` |
|
||||
| `sys configuration` rejected (cmd #1) | Sequence aborts; verification not sent; rest of commands not attempted | `TestMigrateViaTelnet_CommandNotFoundAborts` (envswitch variant — generalises) |
|
||||
| `envswitch boseurls set` rejected | Sequence aborts; runtime-only `sys configuration` state reverts on reboot — no permanent damage | `TestMigrateViaTelnet_CommandNotFoundAborts` |
|
||||
| Verification mismatch (URLs not echoed back) | Loud "verification failed" error; live state may persist until reboot but UI never claims success | `TestMigrateViaTelnet_VerifyMismatchFails` |
|
||||
| Port 17000 closed / TCP unreachable | `Dial` errors before any command is sent; UI shows the error; nothing persisted | `TestMigrateViaTelnet_DialFailureReturnsError` |
|
||||
| `sys configuration` rejected | Sequence aborts; earlier or attempted runtime writes may have applied; read back all four fields | `TestMigrateViaTelnet_GenericRuntimeRejectionReportsPartialState` |
|
||||
| `envswitch boseurls set` rejected | Sequence aborts after four confirmed runtime writes; persistence outcome is uncertain; inspect before rebooting | `TestMigrateViaTelnet_EnvswitchRejectionReportsUncertainPersistence` |
|
||||
| Verification mismatch (URLs not echoed back) | Loud error after accepted `envswitch`; runtime differs and persistence may already have changed; UI never claims success | `TestMigrateViaTelnet_VerifyMismatchFails` |
|
||||
| Invalid or command-unsafe URL input | Rejected before a telnet client is created or any device connection is attempted | `TestMigrateViaTelnet_RejectsUnsafeURLsBeforeCreatingClient` |
|
||||
| Concurrent URL mutations for one speaker | Process-local per-speaker lock keeps command sequences contiguous; different processes remain out of scope | `TestTelnetURLMutationsSameSpeakerAreSerialized` |
|
||||
| `/setMargeAccount` 502 / hang | 5s connect + 12s total budget enforced; falls through to telnet `envswitch accountid set` | `TestPairAccount_FallsBackWhenHTTPReturnsServerError` |
|
||||
| `/setMargeAccount` missing in `/supportedURLs` | HTTP path skipped; goes straight to telnet `envswitch accountid set` | `TestPairAccount_FallsBackWhenSetMargeAccountMissing` |
|
||||
| Both pairing paths unavailable | Structured error: "use the official Bose app before EOS, or open SSH and use the XML method" | `TestPairAccount_NoTelnetAndHTTPMissingReturnsClearError`, `TestPairAccount_TelnetCommandNotFoundReportsBothPaths` |
|
||||
@@ -504,7 +511,7 @@ What `migrateViaTelnet` does in each failure mode (verified by
|
||||
|
||||
- **Green light** — ST 10, ST 20, ST 300, Wave III, Wave IV on FW 27.0.6 (multi-reporter agreement).
|
||||
- **Yellow** — ST Portable and BST20 Portable: migration works, pairing needs our fallback (already implemented).
|
||||
- **Red, but fails cleanly** — SA-5 on FW 9.x, possibly newer ST Portable builds.
|
||||
- **Red, aborts with explicit state diagnostics** — SA-5 on FW 9.x, possibly newer ST Portable builds.
|
||||
- **Unverified but expected to work** — ST 30, ST 520, Wave Music System I/II.
|
||||
|
||||
The most useful next verification step is touching a real ST 30 and ST 520
|
||||
|
||||
@@ -566,7 +566,7 @@ func (m *MockClient) GetNowPlaying() (*models.NowPlaying, error) {
|
||||
|
||||
```dockerfile
|
||||
# test/docker/Dockerfile
|
||||
FROM golang:1.25-alpine
|
||||
FROM golang:1.27.0-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
@@ -101,13 +101,13 @@ rename and network/firmware info.
|
||||
|
||||
---
|
||||
|
||||
## 4. Render stereo pairs as a single device
|
||||
## 4. Stereo-pair presentation and lifecycle (shipped)
|
||||
|
||||
Today soundtouch-player 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>.
|
||||
soundtouch-player projects a valid two-speaker stereo pair (formed via
|
||||
`/addGroup` - see [issue #252](https://github.com/gesellix/Bose-SoundTouch/issues/252))
|
||||
as one logical control target. This restores the single-entry presentation
|
||||
expected by users while preserving both physical speakers in the service
|
||||
registry.
|
||||
|
||||
**Device API:**
|
||||
- `GET /getGroup` on each speaker — returns the current `<group>` with
|
||||
@@ -118,28 +118,77 @@ presentation closes the perception gap BirdyBA flagged at
|
||||
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
|
||||
- Poll `GET /getGroup` together with the other device status and consume
|
||||
`groupUpdated` events. A generation check prevents an older poll from
|
||||
overwriting a newer event.
|
||||
- Collapse only an exact two-member `LEFT`/`RIGHT` group whose registered
|
||||
members agree on the group claim. Malformed, conflicting, or ambiguous data
|
||||
fails open and leaves the physical entries visible.
|
||||
- Use the master speaker's existing registry key for the logical target, so
|
||||
controls continue to route through the master without changing the raw
|
||||
physical-device registry.
|
||||
- Use the same projection for the REST device list and the global player
|
||||
WebSocket snapshot.
|
||||
|
||||
**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
|
||||
- Render one card using the shared member name or the group's name.
|
||||
- Show pair availability as `Stereo pair n/2` and mark the card degraded when
|
||||
a member is unavailable or the group reports a non-OK state.
|
||||
- Hide the single-device remove action on a projected pair. Standalone
|
||||
speakers continue to render as before.
|
||||
- For standalone stereo-capable SoundTouch 10 speakers, offer pair creation
|
||||
with an explicit LEFT/master and RIGHT member.
|
||||
- For an existing pair, offer rename and a separately confirmed dissolve
|
||||
action. A dissolve changes speaker group state; it does not delete either
|
||||
physical speaker from the player registry.
|
||||
|
||||
**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-player's device list — no protocol work required.
|
||||
**Lifecycle safety:**
|
||||
- A shared coordinator backs both the CLI and player. It freshly checks both
|
||||
speakers, their L/R capability, current group, and temporary-zone state
|
||||
before a mutation. Pair creation also requires one shared Marge account and
|
||||
backend.
|
||||
- After both create candidates are freshly verified as physically standalone,
|
||||
a fail-closed, read-only persistence barrier checks for a stored group before
|
||||
either speaker is mutated. The embedded service searches every account by
|
||||
device ID; standalone player and CLI query the speakers' current Marge
|
||||
backend. Creation stops and reports the exact stale generation when any
|
||||
record remains; pre-create checks never delete it.
|
||||
- Create sends the asymmetric master/slave payloads required by the speaker
|
||||
state machines, then freshly verifies that both members agree. A partial
|
||||
create is compensated only where the exact group generation returned by
|
||||
that speaker can be proven.
|
||||
- Rename and dissolve update both physical speakers and report a degraded
|
||||
result, including per-member detail, instead of claiming success after a
|
||||
partial transition.
|
||||
- Rename and dissolve carry the group ID displayed to the user and reject a
|
||||
stale request if either speaker now belongs to another generation.
|
||||
- A degraded dissolve retains the last exact L/R topology for a bounded retry.
|
||||
The retry freshly verifies both physical identities and states, and stored
|
||||
persistence must match that full topology before it can be retired.
|
||||
- Legacy Marge teardown callbacks without a group ID are acknowledged without
|
||||
deleting persistent state. After a verified physical dissolve, the embedded
|
||||
player retires the exact generation directly in its datastore; standalone
|
||||
player and CLI use the generation-aware endpoint derived from fresh speaker
|
||||
info. Physical verification and exact persistence cleanup share one
|
||||
coordinator lock, and a cleanup failure is returned as degraded.
|
||||
- Retired group IDs leave their small XML snapshot in the datastore and
|
||||
active/retired IDs are reserved across all accounts, so an account move or
|
||||
stale request cannot match a later physical generation.
|
||||
- Pair mutations are rejected while either member belongs to a temporary
|
||||
multi-room zone. The zone must be dissolved first.
|
||||
|
||||
!!! warning "Run lifecycle operations site-locally"
|
||||
Marge hostnames such as `unifi` are resolved from the caller's site, not
|
||||
from the speaker's site. Create, rename, and dissolve a pair through the
|
||||
Player/service deployment co-located with both speakers and their Marge
|
||||
backend; a cross-site registry entry is not a backend-routing mechanism.
|
||||
|
||||
!!! warning "Datastore downgrade boundary"
|
||||
Once this lifecycle has written a `Group_<id>.retired` snapshot, do not run an
|
||||
older service binary against the same datastore. Older allocators do not
|
||||
reserve these generation IDs globally and can reuse one. Restore both the
|
||||
binary and its pre-lifecycle datastore snapshot for a rollback, or upgrade
|
||||
forward.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -17,12 +17,17 @@ then **which build** matches your computer.
|
||||
AfterTouch is a small set of separate programs. Most people run one or
|
||||
two of them.
|
||||
|
||||
| Tool | What it does | You want this if… |
|
||||
|----------------------|-----------------------------------------------------------------------------------------------|----------------------------------------------------------|
|
||||
| `soundtouch-service` | The local cloud replacement ("AfterTouch"). Runs always-on and takes over from the Bose cloud. | You are migrating speakers off the Bose cloud. |
|
||||
| `soundtouch-player` | A browser control panel (radio browsing, device control). | You want a web UI to browse radio and control speakers. |
|
||||
| `soundtouch-cli` | Command-line control and setup (status, play, presets, groups, **migration**, …). | You want to script things, or run a migration by hand. |
|
||||
| `soundtouch-backup` | Backs up your Bose cloud account and each speaker's local state. | You are preparing before a shutdown / factory reset. |
|
||||
| Tool | What it does | You want this if… |
|
||||
|----------------------|------------------------------------------------------------------------------------------------|---------------------------------------------------------|
|
||||
| `soundtouch-service` | The local cloud replacement ("AfterTouch"). Runs always-on and takes over from the Bose cloud. | You are migrating speakers off the Bose cloud. |
|
||||
| `soundtouch-cli` | Command-line control and setup (status, play, presets, groups, **migration**, …). | You want to script things, or run a migration by hand. |
|
||||
| `soundtouch-player` | A browser control panel (radio browsing, device control). | You want a web UI to browse radio and control speakers. |
|
||||
| `soundtouch-backup` | Backs up your Bose cloud account and each speaker's local state. | You are preparing before a shutdown / factory reset. |
|
||||
|
||||
Most people only need **`soundtouch-service`** and **`soundtouch-cli`** — the
|
||||
release notes on each [GitHub release](https://github.com/gesellix/Bose-SoundTouch/releases/latest)
|
||||
link those two directly, one row per platform, so you don't have to hunt
|
||||
through the flat Assets list below.
|
||||
|
||||
> Running a migration from the command line (for example the telnet
|
||||
> re-migration in the
|
||||
|
||||
@@ -855,6 +855,48 @@ soundtouch-cli --host 192.0.2.10 zone remove --member 192.0.2.12
|
||||
soundtouch-cli --host 192.0.2.10 zone dissolve
|
||||
```
|
||||
|
||||
### Stereo Pair Management
|
||||
|
||||
Create and manage a persistent LEFT/RIGHT pair of two SoundTouch 10 speakers.
|
||||
This is distinct from a temporary multi-room zone. Both speakers must be
|
||||
online, stereo-capable, standalone, and outside any zone before a lifecycle
|
||||
operation. Pair creation also requires both speakers to use the same Marge
|
||||
backend, though they need not share a Marge account. Run lifecycle commands
|
||||
from the site containing both speakers; site-relative Marge names such as
|
||||
`unifi` do not identify a remote site when resolved by the CLI host.
|
||||
|
||||
```bash
|
||||
# Inspect a standalone speaker or either member of a pair
|
||||
soundtouch-cli --host 192.0.2.10 group status
|
||||
|
||||
# Create a pair; the LEFT speaker becomes the master
|
||||
soundtouch-cli group create \
|
||||
--left 192.0.2.10 \
|
||||
--right 192.0.2.11 \
|
||||
--name "Living Room"
|
||||
|
||||
# Rename through either member
|
||||
soundtouch-cli --host 192.0.2.10 group rename --name "Living Room Pair"
|
||||
|
||||
# Dissolve the pair without removing either speaker from AfterTouch
|
||||
soundtouch-cli --host 192.0.2.10 group remove
|
||||
```
|
||||
|
||||
Create, rename, and remove verify fresh state on both speakers. Rename and
|
||||
remove first inspect the current group and carry its ID as a generation guard;
|
||||
if the pair changes before mutation, the operation fails without touching the
|
||||
newer pair. A partial transition is reported as degraded with per-speaker
|
||||
details rather than as a successful operation. A remove attempt carries the
|
||||
last exact L/R topology, freshly verifies both speakers, and retires
|
||||
persistence only if the stored generation still matches it. Before create,
|
||||
the CLI verifies
|
||||
both speakers as standalone, queries their current Marge backend for stale
|
||||
group records, and refuses to mutate either speaker while any record remains.
|
||||
After verified physical cleanup, the CLI removes the exact group ID through the
|
||||
Marge URL and account freshly read from the speaker. A backend cleanup failure
|
||||
is therefore visible as a degraded result instead of leaving an apparently
|
||||
successful stale generation.
|
||||
|
||||
### Browse and Navigation
|
||||
|
||||
Browse and navigate content sources on your device.
|
||||
@@ -1357,17 +1399,37 @@ soundtouch-cli --host <device> setup migrate --method telnet \
|
||||
|
||||
#### `setup revert`
|
||||
|
||||
Undoes a migration — the CLI equivalent of the web UI's "Revert to
|
||||
Defaults" button. Restores `SoundTouchSdkPrivateCfg.xml`, `/etc/hosts`, and
|
||||
`/etc/resolv.conf` from their `.original` backups, removes the AfterTouch
|
||||
DNS-hook artifacts, and strips just the AfterTouch-labeled certificate out
|
||||
of the trust bundle. No `--service-url` needed — everything it touches
|
||||
already lives on the speaker.
|
||||
Undoes a migration. The default `--method ssh` is the CLI equivalent of the
|
||||
web UI's **Revert to Defaults** button: it restores
|
||||
`SoundTouchSdkPrivateCfg.xml`, `/etc/hosts`, and `/etc/resolv.conf` from their
|
||||
`.original` backups, removes the AfterTouch DNS-hook artifacts, and strips
|
||||
just the AfterTouch-labeled certificate out of the trust bundle.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup revert
|
||||
```
|
||||
|
||||
For a telnet-only migration, `--method telnet` restores the four canonical
|
||||
Bose service URLs without requiring SSH or an XML backup:
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup revert --method telnet
|
||||
```
|
||||
|
||||
This only changes `margeServerUrl`, `statsServerUrl`, `swUpdateUrl`, and
|
||||
`bmxRegistryUrl`. It does not restore filesystem, DNS, CA, SSH, or account
|
||||
state. Reboot the speaker afterwards and verify all four persisted values.
|
||||
The `--marge-url`, `--stats-url`, `--sw-update-url`, and `--bmx-url` flags can
|
||||
override the canonical defaults for firmware- or region-specific values.
|
||||
These flags require `--method telnet`; using them with the default SSH method
|
||||
is an error. Each value must be an absolute HTTP or HTTPS service URL without
|
||||
userinfo, query parameters, fragments, whitespace, control characters, or
|
||||
shell metacharacters.
|
||||
Telnet writes are sequential rather than transactional. If the command reports
|
||||
an error, read back and reconcile all four fields before retrying or rebooting;
|
||||
the error distinguishes a partial runtime update from an uncertain persistence
|
||||
outcome after `envswitch`.
|
||||
|
||||
**Out of scope for this command** (matches the web UI button): SSH /
|
||||
`remote_services` persistence (use `setup remote-services --remove`) and
|
||||
account pairing (use `account unpair`) are untouched — revert them
|
||||
@@ -1423,10 +1485,19 @@ soundtouch-cli --host <device> setup pair --mode=bare --account=1111111 --servic
|
||||
```
|
||||
|
||||
`--account` empty generates a fresh 7-digit ID. `--name` sets the speaker
|
||||
name during pairing (empty keeps current). `--language` defaults to `2`
|
||||
name during pairing (empty keeps current). `--language` defaults to `3`
|
||||
(English). `--token` defaults to a built-in placeholder matching the Bose
|
||||
app's token shape.
|
||||
|
||||
`--mode=full` first reads `/supportedURLs` and `/soundTouchConfigurationStatus`
|
||||
and only runs the state machine when the device reports
|
||||
`SOUNDTOUCH_NOT_CONFIGURED` (see [#615](https://github.com/gesellix/Bose-SoundTouch/issues/615):
|
||||
a speaker can be reachable, named, and already account-paired yet still
|
||||
report `SOUNDTOUCH_NOT_CONFIGURED`, leaving the "install the Bose app"
|
||||
prompt on screen — only a full pass through the state machine clears it).
|
||||
An already-configured device is a no-op; an unsupported route or an
|
||||
unrecognised status value fails the command instead of guessing.
|
||||
|
||||
#### `setup sync`
|
||||
|
||||
Pulls presets, recents, and sources from the speaker into AfterTouch's
|
||||
|
||||
@@ -115,25 +115,25 @@ type ProductionSoundTouchService struct {
|
||||
type Config struct {
|
||||
// Server settings
|
||||
ListenAddr string `env:"LISTEN_ADDR" default:":8080"`
|
||||
|
||||
|
||||
// SoundTouch settings
|
||||
DeviceHosts []string `env:"DEVICE_HOSTS" separator:","`
|
||||
DiscoveryTimeout time.Duration `env:"DISCOVERY_TIMEOUT" default:"30s"`
|
||||
RequestTimeout time.Duration `env:"REQUEST_TIMEOUT" default:"15s"`
|
||||
MaxRetries int `env:"MAX_RETRIES" default:"3"`
|
||||
|
||||
|
||||
// Connection pool
|
||||
MaxConnections int `env:"MAX_CONNECTIONS" default:"10"`
|
||||
IdleTimeout time.Duration `env:"IDLE_TIMEOUT" default:"5m"`
|
||||
|
||||
|
||||
// Monitoring
|
||||
MetricsEnabled bool `env:"METRICS_ENABLED" default:"true"`
|
||||
HealthCheckInterval time.Duration `env:"HEALTH_CHECK_INTERVAL" default:"30s"`
|
||||
|
||||
|
||||
// Logging
|
||||
LogLevel string `env:"LOG_LEVEL" default:"info"`
|
||||
LogFormat string `env:"LOG_FORMAT" default:"json"`
|
||||
|
||||
|
||||
// Security
|
||||
EnableTLS bool `env:"ENABLE_TLS" default:"false"`
|
||||
TLSCertFile string `env:"TLS_CERT_FILE"`
|
||||
@@ -145,7 +145,7 @@ func LoadConfig() (*Config, error) {
|
||||
if err := env.Parse(cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config: %w", err)
|
||||
}
|
||||
|
||||
|
||||
return cfg, cfg.Validate()
|
||||
}
|
||||
|
||||
@@ -153,15 +153,15 @@ func (c *Config) Validate() error {
|
||||
if len(c.DeviceHosts) == 0 {
|
||||
return fmt.Errorf("at least one device host must be specified")
|
||||
}
|
||||
|
||||
|
||||
if c.RequestTimeout < time.Second {
|
||||
return fmt.Errorf("request timeout must be at least 1 second")
|
||||
}
|
||||
|
||||
|
||||
if c.EnableTLS && (c.TLSCertFile == "" || c.TLSKeyFile == "") {
|
||||
return fmt.Errorf("TLS cert and key files required when TLS is enabled")
|
||||
}
|
||||
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
@@ -191,7 +191,7 @@ pool:
|
||||
monitoring:
|
||||
metrics_enabled: true
|
||||
health_check_interval: "30s"
|
||||
|
||||
|
||||
logging:
|
||||
level: "info"
|
||||
format: "json"
|
||||
@@ -203,12 +203,12 @@ func LoadConfigFromFile(path string) (*Config, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
return &cfg, cfg.Validate()
|
||||
}
|
||||
```
|
||||
@@ -224,14 +224,14 @@ func LoadConfigFromFile(path string) (*Config, error) {
|
||||
type SecureNetworkConfig struct {
|
||||
// Allowed source IP ranges
|
||||
AllowedCIDRs []string
|
||||
|
||||
|
||||
// Rate limiting
|
||||
RateLimit int
|
||||
RateLimitWindow time.Duration
|
||||
|
||||
|
||||
// TLS configuration
|
||||
TLSConfig *tls.Config
|
||||
|
||||
|
||||
// Timeouts for security
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
@@ -240,7 +240,7 @@ type SecureNetworkConfig struct {
|
||||
|
||||
func NewSecureServer(config SecureNetworkConfig) *http.Server {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
|
||||
// Add middleware
|
||||
handler := applyMiddleware(mux,
|
||||
corsMiddleware(),
|
||||
@@ -249,7 +249,7 @@ func NewSecureServer(config SecureNetworkConfig) *http.Server {
|
||||
loggingMiddleware(),
|
||||
metricsMiddleware(),
|
||||
)
|
||||
|
||||
|
||||
return &http.Server{
|
||||
Handler: handler,
|
||||
TLSConfig: config.TLSConfig,
|
||||
@@ -275,12 +275,12 @@ func (r *DeviceControlRequest) Validate() error {
|
||||
if err := validate.Struct(r); err != nil {
|
||||
return fmt.Errorf("validation failed: %w", err)
|
||||
}
|
||||
|
||||
|
||||
// Additional business logic validation
|
||||
if r.Action == "volume" && r.Volume == nil {
|
||||
return fmt.Errorf("volume value required for volume action")
|
||||
}
|
||||
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
@@ -302,12 +302,12 @@ func loadSecretsFromK8s() (*SecretsConfig, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
tlsKey, err := os.ReadFile("/etc/secrets/tls.key")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
return &SecretsConfig{
|
||||
TLSCert: string(tlsCert),
|
||||
TLSKey: string(tlsKey),
|
||||
@@ -335,21 +335,21 @@ type Logger struct {
|
||||
|
||||
func NewLogger(level, format, component string) (*Logger, error) {
|
||||
logger := logrus.New()
|
||||
|
||||
|
||||
// Set level
|
||||
logLevel, err := logrus.ParseLevel(level)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger.SetLevel(logLevel)
|
||||
|
||||
|
||||
// Set format
|
||||
if format == "json" {
|
||||
logger.SetFormatter(&logrus.JSONFormatter{
|
||||
TimestampFormat: time.RFC3339,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
return &Logger{
|
||||
Logger: logger,
|
||||
component: component,
|
||||
@@ -376,15 +376,15 @@ type Metrics struct {
|
||||
RequestsTotal prometheus.CounterVec
|
||||
RequestDuration prometheus.HistogramVec
|
||||
RequestsInFlight prometheus.GaugeVec
|
||||
|
||||
|
||||
// Device metrics
|
||||
DevicesConnected prometheus.Gauge
|
||||
DeviceHealth prometheus.GaugeVec
|
||||
WebSocketConnections prometheus.Gauge
|
||||
|
||||
|
||||
// Error metrics
|
||||
ErrorsTotal prometheus.CounterVec
|
||||
|
||||
|
||||
// Business metrics
|
||||
VolumeChanges prometheus.CounterVec
|
||||
SourceChanges prometheus.CounterVec
|
||||
@@ -400,7 +400,7 @@ func NewMetrics() *Metrics {
|
||||
},
|
||||
[]string{"method", "endpoint", "status"},
|
||||
),
|
||||
|
||||
|
||||
RequestDuration: *prometheus.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "soundtouch_request_duration_seconds",
|
||||
@@ -409,14 +409,14 @@ func NewMetrics() *Metrics {
|
||||
},
|
||||
[]string{"method", "endpoint"},
|
||||
),
|
||||
|
||||
|
||||
DevicesConnected: prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "soundtouch_devices_connected",
|
||||
Help: "Number of connected devices",
|
||||
},
|
||||
),
|
||||
|
||||
|
||||
DeviceHealth: *prometheus.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "soundtouch_device_health",
|
||||
@@ -425,7 +425,7 @@ func NewMetrics() *Metrics {
|
||||
[]string{"device_id", "device_name"},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
// Register metrics
|
||||
prometheus.MustRegister(
|
||||
m.RequestsTotal,
|
||||
@@ -433,7 +433,7 @@ func NewMetrics() *Metrics {
|
||||
m.DevicesConnected,
|
||||
m.DeviceHealth,
|
||||
)
|
||||
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -457,7 +457,7 @@ type HealthChecker struct {
|
||||
func (hc *HealthChecker) Start(ctx context.Context) {
|
||||
ticker := time.NewTicker(hc.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -470,7 +470,7 @@ func (hc *HealthChecker) Start(ctx context.Context) {
|
||||
|
||||
func (hc *HealthChecker) checkAllDevices() {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
|
||||
for deviceID, device := range hc.manager.devices {
|
||||
wg.Add(1)
|
||||
go func(id string, dev *DeviceInfo) {
|
||||
@@ -478,18 +478,18 @@ func (hc *HealthChecker) checkAllDevices() {
|
||||
hc.checkDevice(id, dev)
|
||||
}(deviceID, device)
|
||||
}
|
||||
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (hc *HealthChecker) checkDevice(deviceID string, device *DeviceInfo) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), hc.timeout)
|
||||
defer cancel()
|
||||
|
||||
|
||||
start := time.Now()
|
||||
err := device.Client.Ping()
|
||||
duration := time.Since(start)
|
||||
|
||||
|
||||
if err != nil {
|
||||
device.Status = DeviceStatusUnhealthy
|
||||
hc.metrics.DeviceHealth.WithLabelValues(deviceID, device.Name).Set(0)
|
||||
@@ -507,14 +507,14 @@ func (hc *HealthChecker) HealthHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
healthy := 0
|
||||
total := 0
|
||||
|
||||
|
||||
for _, device := range hc.manager.devices {
|
||||
total++
|
||||
if device.Status == DeviceStatusHealthy {
|
||||
healthy++
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
status := map[string]interface{}{
|
||||
"status": "ok",
|
||||
"devices": map[string]interface{}{
|
||||
@@ -524,14 +524,14 @@ func (hc *HealthChecker) HealthHandler() http.HandlerFunc {
|
||||
},
|
||||
"timestamp": time.Now().UTC(),
|
||||
}
|
||||
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
|
||||
if healthy < total {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
status["status"] = "degraded"
|
||||
}
|
||||
|
||||
|
||||
json.NewEncoder(w).Encode(status)
|
||||
}
|
||||
}
|
||||
@@ -560,16 +560,16 @@ func NewConnectionPool(maxIdle, maxActive int, idleTimeout time.Duration) *Conne
|
||||
maxActive: maxActive,
|
||||
idleTimeout: idleTimeout,
|
||||
}
|
||||
|
||||
|
||||
// Start cleanup goroutine
|
||||
go cp.cleanup()
|
||||
|
||||
|
||||
return cp
|
||||
}
|
||||
|
||||
func (cp *ConnectionPool) Get(host string, port int) (*client.Client, error) {
|
||||
key := fmt.Sprintf("%s:%d", host, port)
|
||||
|
||||
|
||||
// Check if connection exists and is valid
|
||||
if val, ok := cp.clients.Load(key); ok {
|
||||
conn := val.(*pooledConnection)
|
||||
@@ -580,35 +580,35 @@ func (cp *ConnectionPool) Get(host string, port int) (*client.Client, error) {
|
||||
// Connection expired, remove it
|
||||
cp.clients.Delete(key)
|
||||
}
|
||||
|
||||
|
||||
// Check active connection limit
|
||||
if atomic.LoadInt64(&cp.activeCount) >= int64(cp.maxActive) {
|
||||
return nil, fmt.Errorf("connection pool exhausted")
|
||||
}
|
||||
|
||||
|
||||
// Create new connection
|
||||
config := client.ClientConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
|
||||
|
||||
newClient := client.NewClient(config)
|
||||
|
||||
|
||||
// Test connection
|
||||
if err := newClient.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to %s:%d: %w", host, port, err)
|
||||
}
|
||||
|
||||
|
||||
conn := &pooledConnection{
|
||||
client: newClient,
|
||||
lastUsed: time.Now(),
|
||||
created: time.Now(),
|
||||
}
|
||||
|
||||
|
||||
cp.clients.Store(key, conn)
|
||||
atomic.AddInt64(&cp.activeCount, 1)
|
||||
|
||||
|
||||
return newClient, nil
|
||||
}
|
||||
|
||||
@@ -621,7 +621,7 @@ type pooledConnection struct {
|
||||
func (cp *ConnectionPool) cleanup() {
|
||||
ticker := time.NewTicker(cp.idleTimeout / 2)
|
||||
defer ticker.Stop()
|
||||
|
||||
|
||||
for range ticker.C {
|
||||
now := time.Now()
|
||||
cp.clients.Range(func(key, val interface{}) bool {
|
||||
@@ -649,10 +649,10 @@ func NewCacheManager() *CacheManager {
|
||||
return &CacheManager{
|
||||
// Device info rarely changes, cache for 1 hour
|
||||
deviceInfoCache: cache.New(1*time.Hour, 2*time.Hour),
|
||||
|
||||
|
||||
// Capabilities never change, cache for 24 hours
|
||||
capabilitiesCache: cache.New(24*time.Hour, 48*time.Hour),
|
||||
|
||||
|
||||
// Volume changes frequently, cache for 5 seconds
|
||||
volumeCache: cache.New(5*time.Second, 10*time.Second),
|
||||
}
|
||||
@@ -662,12 +662,12 @@ func (cm *CacheManager) GetDeviceInfo(deviceID string, fetcher func() (*models.D
|
||||
if cached, found := cm.deviceInfoCache.Get(deviceID); found {
|
||||
return cached.(*models.DeviceInfo), nil
|
||||
}
|
||||
|
||||
|
||||
info, err := fetcher()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
cm.deviceInfoCache.Set(deviceID, info, cache.DefaultExpiration)
|
||||
return info, nil
|
||||
}
|
||||
@@ -702,7 +702,7 @@ func NewResilientSoundTouchService(client *client.Client) *ResilientSoundTouchSe
|
||||
log.Printf("Circuit breaker '%s' changed from '%s' to '%s'", name, from, to)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
return &ResilientSoundTouchService{
|
||||
client: client,
|
||||
cb: gobreaker.NewCircuitBreaker(settings),
|
||||
@@ -713,12 +713,12 @@ func (r *ResilientSoundTouchService) SetVolume(deviceID string, volume int) erro
|
||||
result, err := r.cb.Execute(func() (interface{}, error) {
|
||||
return nil, r.client.SetVolume(volume)
|
||||
})
|
||||
|
||||
|
||||
if err != nil {
|
||||
r.metrics.ErrorsTotal.WithLabelValues("circuit_breaker", "volume").Inc()
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
return result.(error)
|
||||
}
|
||||
```
|
||||
@@ -730,16 +730,16 @@ func (app *Application) Run(ctx context.Context) error {
|
||||
// Setup signal handling
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
|
||||
// Start services
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
|
||||
|
||||
// HTTP server
|
||||
server := &http.Server{
|
||||
Addr: app.config.ListenAddr,
|
||||
Handler: app.handler,
|
||||
}
|
||||
|
||||
|
||||
g.Go(func() error {
|
||||
app.logger.Info("Starting HTTP server", "addr", app.config.ListenAddr)
|
||||
if err := server.ListenAndServe(); err != http.ErrServerClosed {
|
||||
@@ -747,38 +747,38 @@ func (app *Application) Run(ctx context.Context) error {
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
|
||||
// Health checker
|
||||
g.Go(func() error {
|
||||
return app.healthChecker.Start(ctx)
|
||||
})
|
||||
|
||||
|
||||
// WebSocket manager
|
||||
g.Go(func() error {
|
||||
return app.wsManager.Start(ctx)
|
||||
})
|
||||
|
||||
|
||||
// Wait for shutdown signal
|
||||
go func() {
|
||||
<-sigChan
|
||||
app.logger.Info("Shutdown signal received")
|
||||
|
||||
|
||||
// Graceful shutdown with timeout
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
|
||||
// Shutdown HTTP server
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
app.logger.Error("HTTP server shutdown error", "error", err)
|
||||
}
|
||||
|
||||
|
||||
// Close WebSocket connections
|
||||
app.wsManager.Shutdown(shutdownCtx)
|
||||
|
||||
|
||||
// Close connection pool
|
||||
app.connectionPool.Close()
|
||||
}()
|
||||
|
||||
|
||||
return g.Wait()
|
||||
}
|
||||
```
|
||||
@@ -791,7 +791,7 @@ func (app *Application) Run(ctx context.Context) error {
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile
|
||||
FROM golang:1.25-alpine AS builder
|
||||
FROM golang:1.27.0-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
@@ -830,7 +830,7 @@ services:
|
||||
networks:
|
||||
- soundtouch-net
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus:latest
|
||||
ports:
|
||||
@@ -839,7 +839,7 @@ services:
|
||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
networks:
|
||||
- soundtouch-net
|
||||
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
ports:
|
||||
@@ -1011,7 +1011,7 @@ groups:
|
||||
annotations:
|
||||
summary: "SoundTouch device {{ $labels.device_name }} is unhealthy"
|
||||
description: "Device {{ $labels.device_id }} has been unhealthy for more than 2 minutes"
|
||||
|
||||
|
||||
- alert: HighErrorRate
|
||||
expr: rate(soundtouch_errors_total[5m]) > 0.1
|
||||
for: 5m
|
||||
@@ -1020,7 +1020,7 @@ groups:
|
||||
annotations:
|
||||
summary: "High error rate detected"
|
||||
description: "Error rate is {{ $value }} errors/second over the last 5 minutes"
|
||||
|
||||
|
||||
- alert: ServiceDown
|
||||
expr: up{job="soundtouch"} == 0
|
||||
for: 1m
|
||||
@@ -1040,33 +1040,33 @@ func (m *Manager) BackupConfigurations() error {
|
||||
Timestamp: time.Now(),
|
||||
Devices: make(map[string]DeviceConfig),
|
||||
}
|
||||
|
||||
|
||||
for deviceID, device := range m.devices {
|
||||
config := DeviceConfig{}
|
||||
|
||||
|
||||
// Backup presets
|
||||
if presets, err := device.Client.GetPresets(); err == nil {
|
||||
config.Presets = presets
|
||||
}
|
||||
|
||||
|
||||
// Backup settings
|
||||
if volume, err := device.Client.GetVolume(); err == nil {
|
||||
config.Volume = volume.TargetVolume
|
||||
}
|
||||
|
||||
|
||||
if bass, err := device.Client.GetBass(); err == nil {
|
||||
config.Bass = bass.TargetBass
|
||||
}
|
||||
|
||||
|
||||
backup.Devices[deviceID] = config
|
||||
}
|
||||
|
||||
|
||||
// Save to file
|
||||
data, err := json.MarshalIndent(backup, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
filename := fmt.Sprintf("backup_%s.json", time.Now().Format("2006-01-02_15-04-05"))
|
||||
return os.WriteFile(filepath.Join(m.config.BackupDir, filename), data, 0644)
|
||||
}
|
||||
@@ -1083,7 +1083,7 @@ func init() {
|
||||
runtime.GOMAXPROCS(int(limit))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Set GC target percentage
|
||||
if os.Getenv("GOGC") == "" {
|
||||
debug.SetGCPerc
|
||||
|
||||
@@ -174,6 +174,20 @@ Once the speaker appears, click **Sync Data**. This connects to the speaker and
|
||||
|
||||
Sync pulls the speaker's local state into AfterTouch's datastore, creating an off-device backup of its configuration. If you ran this before May 6, 2026, your account data from Bose's servers was also captured at that time.
|
||||
|
||||
Migration is refused until the service has a valid snapshot for that exact
|
||||
account and device and verifies that its rendered account data preserves every
|
||||
live preset slot. If the migration page asks for Data Sync, sync the device and
|
||||
retry instead of bypassing the check.
|
||||
|
||||
If the account already contains other devices, migration proceeds but the log
|
||||
says so. Some speaker firmware has been reported to wipe its presets after a
|
||||
reboot-triggered resync of a shared account even when `/full` contains the
|
||||
correct data (see issue #614, where the root cause is still open). One account
|
||||
holding every speaker in the household is the normal arrangement, so this is a
|
||||
warning rather than a refusal; if you do hit the preset wipe, moving that
|
||||
speaker to a dedicated account and running Data Sync for it is the known
|
||||
workaround.
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Migrate
|
||||
@@ -331,7 +345,7 @@ The wizard is still the recommended path for a one-off migration of an existing
|
||||
If you need to undo a migration:
|
||||
|
||||
- **From the web UI**: Use the **Revert to Defaults** action on the device — this restores the `.original` backup files created on the speaker during the XML migration.
|
||||
- **Telnet-only migrations**: the wizard writes both the runtime configuration layer (`sys configuration …`) and the persistent layer (`envswitch boseurls set …`) so the migration survives reboot. If you want to revert quickly, the cleanest path is to re-run the wizard with the original Bose URLs in the URL editor.
|
||||
- **Telnet-only migrations**: the wizard writes both the runtime configuration layer (`sys configuration …`) and the persistent layer (`envswitch boseurls set …`) so the migration survives reboot. Use **Restore Bose URLs via Telnet** in the web UI or run `soundtouch-cli --host <device> setup revert --method telnet`. This restores only the four canonical Bose URL fields; use the CLI URL override flags if your original firmware- or region-specific values differ. The web action is offered whenever the live telnet configuration contains a non-canonical URL, including a URL for an older AfterTouch backend.
|
||||
- **Via SSH**: The original XML config is backed up on the speaker with a `.original` suffix. Restore it manually if the UI is unreachable.
|
||||
- **Factory reset**: As a last resort, perform a factory reset (see [Device Initial Setup](DEVICE-INITIAL-SETUP.md) for button sequences). This wipes all configuration and returns the speaker to out-of-box state.
|
||||
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
---
|
||||
title: "Migration & Safety Guide"
|
||||
---
|
||||
Starting a migration on real hardware requires a "Safety First" approach. This guide outlines the safety features implemented in the `soundtouch-service` and provides a checklist for a successful migration.
|
||||
Starting a migration on real hardware requires a "Safety First" approach. This guide outlines the safety features implemented in the `soundtouch-service` and provides a checklist for a successful migration. The available safeguards depend on the migration method: SSH-backed methods can preserve files, while telnet-only URL migration is sequential and creates no filesystem backup.
|
||||
|
||||
#### 🛠 Technical Safety Enhancements
|
||||
|
||||
The following features are built into the `soundtouch-service` to ensure stability and easy rollbacks:
|
||||
|
||||
1. **Off-Device Backups**: Before any migration starts, the service automatically fetches the original `SoundTouchSdkPrivateCfg.xml` and `/etc/hosts` from your speaker and saves them locally in your `data/default/devices/<SERIAL>/` directory. This ensures you have a recovery path even if the speaker's filesystem becomes inaccessible.
|
||||
2. **Pre-flight Write Verification**: The migration process includes a mandatory check for SSH write access (`rw`) before attempting any modifications. This prevents "half-baked" migrations where a script might fail halfway through due to a read-only filesystem.
|
||||
1. **Off-Device Backups**: Before an SSH-backed migration starts, the service fetches the original `SoundTouchSdkPrivateCfg.xml` and `/etc/hosts` from your speaker and saves them locally in your `data/default/devices/<SERIAL>/` directory. This ensures you have a recovery path even if the speaker's filesystem becomes inaccessible. Telnet-only migration does not create these files.
|
||||
2. **Pre-flight Write Verification**: SSH-backed migration checks for write access (`rw`) before modifying files. Telnet migration instead checks each command response and reads back all four runtime URL fields; its writes remain sequential rather than atomic.
|
||||
3. **Automatic Safety on Sync**: Running a "Sync" in the Web UI or CLI automatically triggers an off-device backup, making it the perfect first step for any new device discovery.
|
||||
|
||||
#### 📋 Professional Migration Checklist
|
||||
|
||||
Before you proceed with the actual migration, follow these steps:
|
||||
|
||||
1. **Enable SSH Access (Prerequisite)**: This toolkit requires SSH access to your speakers, which is not enabled by default.
|
||||
1. **Enable SSH Access (SSH-backed methods only)**: SSH is not enabled by default. Skip this step for a telnet-only URL migration.
|
||||
- Create a file named `remote_services` on a FAT-formatted USB drive. The drive may need its bootable flag set — see [SoundCork issue #172](https://github.com/deborahgu/soundcork/issues/172) for details.
|
||||
- Insert the USB stick into the SoundTouch speaker's **SERVICE** port.
|
||||
- Reboot the speaker (unplug and replug).
|
||||
@@ -25,14 +25,15 @@ Before you proceed with the actual migration, follow these steps:
|
||||
3. **Initial Discovery & Sync**:
|
||||
- Run `soundtouch-cli discover devices` to ensure connectivity.
|
||||
- Use the Web UI or CLI to "Sync" the device. This will automatically backup your presets and system configuration files to your local server.
|
||||
4. **Validate SSH Access**: Confirm the device responds to SSH without a password.
|
||||
4. **Validate SSH Access (SSH-backed methods only)**: Confirm the device responds to SSH without a password.
|
||||
- In the Web UI **Migration** tab, select your speaker and verify that the "SSH Connection" status shows ✅ Success.
|
||||
- This toolkit automatically handles the necessary SSH parameters (ciphers and key exchanges) required by older Bose firmware.
|
||||
5. **Migration Methods**:
|
||||
- **XML redirect (default)**: Uploads a config file to the speaker via the Web API. Less invasive — only changes the application-level service URLs. Best for testing or single-device migration.
|
||||
- **Telnet URL redirect**: Writes the four service URLs through the port-17000 diagnostic shell without SSH. The commands are sequential, so a failed run can leave partial runtime state and must be inspected before retry or reboot.
|
||||
- **DNS/DHCP redirect**: Configures the speaker to use a custom DNS server that resolves Bose hostnames to the local service. Best for all-device coverage; requires the AfterTouch DNS server running on port 53. The service includes a pre-flight check before applying this method.
|
||||
|
||||
The web UI walks you through both methods. Both require the CA certificate to be trusted on the speaker for HTTPS to work — the web UI handles this as part of the migration flow.
|
||||
The web UI walks you through the available methods. When the target uses HTTPS, its CA certificate must be trusted on the speaker; the web UI handles this as part of the migration flow.
|
||||
6. **Monitor Logs**: Run the `soundtouch-service` with `DEBUG` or `INFO` logging to see the step-by-step progress of the migration.
|
||||
|
||||
#### 🔄 Rollback Strategy
|
||||
@@ -40,6 +41,8 @@ Before you proceed with the actual migration, follow these steps:
|
||||
If something goes wrong or you want to return to the original Bose cloud services:
|
||||
|
||||
* **Standard Revert**: Use the "Revert Migration" button in the Web UI or the corresponding CLI command. This restores the `.original` files created on the device.
|
||||
* **Telnet URL Restore**: A telnet-only migration creates no filesystem backup. Use **Restore Bose URLs via Telnet** or `setup revert --method telnet` to restore the four canonical Bose URL fields, then reboot and verify them. This does not restore DNS, CA, SSH, account, or filesystem state; pass explicit URL overrides when the device's original values differ from the canonical defaults. If any command fails, read back and reconcile all four fields before rebooting because earlier runtime writes or the `envswitch` persistence commit may already have taken effect.
|
||||
* **Concurrent Telnet Operations**: The service keeps URL-changing telnet sequences and telnet reboot operations contiguous per speaker. This process-local serialization prevents two HTTP requests from interleaving commands, but it cannot coordinate a separate CLI process or another service instance and does not make the device's multi-command update transactional.
|
||||
* **Emergency Recovery**: If the device is unreachable via the UI but SSH still works, you can manually restore the files from your local `data/` directory using `scp` or the backups created on-device (`.original`).
|
||||
* **Factory Reset**: As a last resort, Bose SoundTouch devices can be factory reset (usually by holding '1' and 'Volume Down' while plugging in). This will wipe all settings and return the device to the stock firmware configuration (the firmware itself remains at the current version, but configurations are reset).
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ rm -f /mnt/nv/aftertouch/soundtouch-cli
|
||||
df -h /mnt/nv # confirm space recovered
|
||||
```
|
||||
|
||||
> **From v0.89.0 onwards the installer prunes stale artefacts automatically**
|
||||
> **From v0.93.0 onwards the installer prunes stale artefacts automatically**
|
||||
> during every upgrade — manual cleanup should no longer be necessary on
|
||||
> fresh installs.
|
||||
|
||||
@@ -404,6 +404,13 @@ curl -sSLo install.sh https://raw.githubusercontent.com/gesellix/Bose-SoundTouch
|
||||
sh install.sh --version 0.123.0
|
||||
```
|
||||
|
||||
The script's own final output already confirms the new version came up and
|
||||
is answering on `:8000`. If you separately check the version yourself
|
||||
(`wget -qO- http://localhost:8000/health`, or the Admin UI), **reboot the
|
||||
speaker first**: an Admin UI tab left open from before the update, or a
|
||||
browser cache of the previous page load, can otherwise still show the old
|
||||
version even though the new binary is already running.
|
||||
|
||||
**Rollback:** the installer keeps a `.backup` file alongside the binary:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -156,33 +156,35 @@ The service supports multiple ways to configure its behavior. When multiple sour
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Variable | Flag | Description | Default |
|
||||
|------------------------------------|----------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------|
|
||||
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
|
||||
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
|
||||
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
|
||||
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
|
||||
| `DEPLOYMENT_MODE` | `--deployment-mode` | Where this service runs: `on-device`, `private-network`, or `public-network`. Only changes behavior when `SERVER_URL` is *not* set: `on-device` defaults to `http://localhost:<port>` instead of guessing a hostname (the speaker's own Linux hostname is never resolvable — see issue #546); `public-network` refuses to start rather than guess a publicly reachable address; unset/`private-network` keeps the previous hostname-guessing behavior, now with a startup warning. The on-device install script sets this automatically. | unset (legacy hostname guess, with warning) |
|
||||
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
|
||||
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL. An override: when empty it is derived from `SERVER_URL` (same host, `https`, on `HTTPS_PORT`), and can also be viewed/overridden in Settings. | derived from `SERVER_URL` |
|
||||
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
|
||||
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
|
||||
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
|
||||
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
|
||||
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
|
||||
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
|
||||
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
|
||||
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for DNS/DHCP migration) | `:53` |
|
||||
| `INTERNAL_PATHS` | `--internal-paths` | Paths for internal requests to exclude from recording (e.g., `/setup/*`, `/web/*`) | `[]` |
|
||||
| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` |
|
||||
| `UPDATE_CHECK_ENABLED` | `--update-check-enabled` | Periodically check GitHub Releases for a newer version and show a dismissible notice in the admin UI and Player when one is found. **Opt-in**: this is the only network call AfterTouch makes beyond speaker/provider traffic when enabled, so it defaults off. One unauthenticated `GET` per interval to `api.github.com`, nothing else leaves the box. Also available as an "Update Check" toggle on the admin Settings page, which applies without a restart; the env var/flag is the seed value for a fresh install with no `settings.json` yet. | `false` |
|
||||
| `UPDATE_CHECK_INTERVAL` | `--update-check-interval` | Update check interval. Also editable on the admin Settings page (applies without a restart). | `24h` |
|
||||
| `MGMT_USERNAME` | `--mgmt-username` | Username for HTTP Basic Auth on the Management API (`/api/mgmt/*`, `/mgmt/*`) — Spotify/Amazon account linking, Local Accounts | `admin` |
|
||||
| `MGMT_PASSWORD` | `--mgmt-password` | Password for the same Management API Basic Auth. **Change this if AfterTouch is reachable beyond a trusted LAN** — the default is published in this doc. | `change_me!` |
|
||||
| `STOCKHOLM_DIR` | `--stockholm-dir` | Path to extracted Stockholm frontend directory — enables the Stockholm UI when set | *(disabled)* |
|
||||
| `MARGE_URL` | | Streaming/marge base URL used when rewriting `stockholm/json/config.json`. Defaults to `SERVER_URL`. Set to `SERVER_URL/marge` only when using a soundcork backend. | *(same as `SERVER_URL`)* |
|
||||
| `MARGE_AUTH_TOKEN` | | Pre-seeds the Stockholm `margeAuthToken` state (skips the login step for the first session) | *(empty)* |
|
||||
| `MARGE_ACCOUNT_ID` | | Pre-seeds the Stockholm `margeAccountID` state (used to filter device-discovery results by account) | *(empty)* |
|
||||
| Variable | Flag | Description | Default |
|
||||
|------------------------------------|--------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------|
|
||||
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
|
||||
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
|
||||
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
|
||||
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
|
||||
| `DEPLOYMENT_MODE` | `--deployment-mode` | Where this service runs: `on-device`, `private-network`, or `public-network`. Only changes behavior when `SERVER_URL` is *not* set: `on-device` defaults to `http://localhost:<port>` instead of guessing a hostname (the speaker's own Linux hostname is never resolvable — see issue #546); `public-network` refuses to start rather than guess a publicly reachable address; unset/`private-network` keeps the previous hostname-guessing behavior, now with a startup warning. The on-device install script sets this automatically. | unset (legacy hostname guess, with warning) |
|
||||
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
|
||||
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL. An override: when empty it is derived from `SERVER_URL` (same host, `https`, on `HTTPS_PORT`), and can also be viewed/overridden in Settings. | derived from `SERVER_URL` |
|
||||
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
|
||||
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
|
||||
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
|
||||
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
|
||||
| `DISCOVERY_ENABLED` | `--discovery-enabled` | Enable periodic device discovery | `true` |
|
||||
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
|
||||
| `DEVICE_SEED_RETRY_INTERVAL` | `--device-seed-retry-interval` | Interval between embedded-player startup retries for persisted devices that failed their first probe (e.g. LAN not yet routable on a cold boot) | `30s` |
|
||||
| `DEVICE_SEED_RETRY_WINDOW` | `--device-seed-retry-window` | Bounded window during which the embedded player retries those unreachable persisted devices at startup | `10m` |
|
||||
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
|
||||
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
|
||||
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for DNS/DHCP migration) | `:53` |
|
||||
| `INTERNAL_PATHS` | `--internal-paths` | Paths for internal requests to exclude from recording (e.g., `/setup/*`, `/web/*`) | `[]` |
|
||||
| `UPDATE_CHECK_ENABLED` | `--update-check-enabled` | Periodically check GitHub Releases for a newer version and show a dismissible notice in the admin UI and Player when one is found. **Opt-in**: this is the only network call AfterTouch makes beyond speaker/provider traffic when enabled, so it defaults off. One unauthenticated `GET` per interval to `api.github.com`, nothing else leaves the box. Also available as an "Update Check" toggle on the admin Settings page, which applies without a restart; the env var/flag is the seed value for a fresh install with no `settings.json` yet. | `false` |
|
||||
| `UPDATE_CHECK_INTERVAL` | `--update-check-interval` | Update check interval. Also editable on the admin Settings page (applies without a restart). | `24h` |
|
||||
| `MGMT_USERNAME` | `--mgmt-username` | Username for HTTP Basic Auth on the Management API (`/api/mgmt/*`, `/mgmt/*`) — Spotify/Amazon account linking, Local Accounts | `admin` |
|
||||
| `MGMT_PASSWORD` | `--mgmt-password` | Password for the same Management API Basic Auth. **Change this if AfterTouch is reachable beyond a trusted LAN** — the default is published in this doc. | `change_me!` |
|
||||
| `STOCKHOLM_DIR` | `--stockholm-dir` | Path to extracted Stockholm frontend directory — enables the Stockholm UI when set | *(disabled)* |
|
||||
| `MARGE_URL` | | Streaming/marge base URL used when rewriting `stockholm/json/config.json`. Defaults to `SERVER_URL`. Set to `SERVER_URL/marge` only when using a soundcork backend. | *(same as `SERVER_URL`)* |
|
||||
| `MARGE_AUTH_TOKEN` | | Pre-seeds the Stockholm `margeAuthToken` state (skips the login step for the first session) | *(empty)* |
|
||||
| `MARGE_ACCOUNT_ID` | | Pre-seeds the Stockholm `margeAccountID` state (used to filter device-discovery results by account) | *(empty)* |
|
||||
|
||||
### Configuration Examples
|
||||
|
||||
@@ -475,6 +477,24 @@ curl -X POST "http://localhost:8000/setup/migrate/192.0.2.100?method=telnet&targ
|
||||
curl -X POST "http://localhost:8000/setup/migrate/192.0.2.100?method=resolv&target_url=https://my-server.com:8443"
|
||||
```
|
||||
|
||||
#### `POST /setup/revert/{deviceID}`
|
||||
Reverts either an SSH-backed migration or the URL fields written by a telnet migration.
|
||||
|
||||
- No `method` query parameter, or `method=ssh`, preserves the existing behavior:
|
||||
restore the on-speaker `.original` files and related SSH-managed state.
|
||||
- `method=telnet` restores the four canonical Bose service URLs without SSH.
|
||||
The optional `marge_url`, `stats_url`, `sw_update_url`, and `bmx_url` query
|
||||
parameters override individual canonical values.
|
||||
- Supplying those URL parameters with the default SSH method returns `400`
|
||||
instead of silently ignoring them. Invalid or command-unsafe telnet URLs also
|
||||
return `400` before a speaker connection is attempted.
|
||||
|
||||
The telnet path changes URL configuration only and does not reboot the speaker.
|
||||
Its commands are sequential, so an error can mean partial runtime state or an
|
||||
uncertain persistence outcome. Read back and reconcile all four fields before
|
||||
retrying or rebooting. A successful response confirms the runtime readback;
|
||||
verify persistence after the subsequent reboot.
|
||||
|
||||
#### `POST /setup/telnet-probe/{deviceIP}`
|
||||
SSH-less reachability check. Temporarily flips the speaker's `swUpdateUrl` via the port-17000 diagnostic shell, triggers `:8090/swUpdateCheck` on the device, and observes whether the resulting outbound lands on this service's `/probe/{token}` handler within 6 s. Always attempts to restore the original `swUpdateUrl` even on failure.
|
||||
|
||||
|
||||
@@ -594,6 +594,28 @@ Once the source plays once, it gets persisted to `/mnt/nv/BoseApp-Persistence/1/
|
||||
|
||||
If `soundtouch-cli source content --source TUNEIN ...` returns `1005` on a reset device that has never had TuneIn, the speaker is refusing because the source isn't registered yet — chicken-and-egg. The SoundTouch app is then the only practical path to register it; we can't write `Sources.xml` directly over telnet on most models.
|
||||
|
||||
### ❌ Presets get wiped after a reboot, on a speaker sharing its Marge account with other devices {#preset-wipe-shared-account}
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Presets are programmed and confirmed correct (e.g. via the Admin UI or `soundtouch-cli`), but after a plain reboot of the speaker, its own preset list comes back empty (`<presets />`) — even though the service's own `Presets.xml` for that device is untouched and still shows the correct presets.
|
||||
- The affected speaker is one of several devices under the **same** Marge account — for example a separate on-device AfterTouch instance per speaker, or several physical speakers migrated to one shared account.
|
||||
- Clicking **Sync** in the Admin UI can also lose presets, but since v0.129.0 that path shows a confirmation warning before it overwrites anything destructively — that's a different, already-fixed issue (a stale-snapshot overwrite guard), not the reboot behavior described here.
|
||||
|
||||
**Cause:**
|
||||
|
||||
Not fully root-caused — this is firmware-internal. A byte-exact capture of the speaker's own `/full` request confirmed AfterTouch serves the correct preset data at the exact moment of the reboot-triggered resync; the wipe happens *after* that, entirely inside the speaker's own firmware callback chain, with no further network exchange to intercept from the service side. The trigger correlates with the **number of devices** listed under the account, not the account ID itself: removing the other devices from the account fixed it for one reporter, while changing only the account ID (with the other devices still present) did not. This isn't a universal shared-account problem either — a setup using a distinct account ID per speaker, with discovery left enabled, has not reproduced it — so treat this as an observed correlation, not a proven mechanism. See [issue #614](https://github.com/gesellix/Bose-SoundTouch/issues/614) for the full debugging history.
|
||||
|
||||
**Workaround (confirmed working, root cause still open):**
|
||||
|
||||
1. Admin UI → **Settings** → disable **"Enable Periodic Discovery"** first. Order matters — leaving it on lets a background sweep re-add a device you just removed, mid-cleanup.
|
||||
2. Admin UI → **Devices** tab → click **✕** to remove every other device from the account, leaving only the speaker you're troubleshooting.
|
||||
3. Reboot the speaker and confirm the presets survive.
|
||||
|
||||
This is fully reversible: re-enabling discovery brings the other devices back as harmless entries, and it doesn't touch their own presets/recents.
|
||||
|
||||
If you'd rather not change device-list membership, the Health tab's **"Restore presets to speaker"** QuickFix pushes the service's stored presets back onto the speaker without a reboot — a workaround for the symptom rather than the trigger, but useful if you hit this again before removing devices.
|
||||
|
||||
### ❌ Changing Target Domain in Settings doesn't change what a speaker actually uses {#settings-vs-migrate}
|
||||
|
||||
**Symptoms:**
|
||||
@@ -653,7 +675,8 @@ Notes:
|
||||
|
||||
If the telnet method isn't available for your model, factory reset the speaker, then re-migrate it:
|
||||
|
||||
1. Factory reset (on most models: hold `1` + `−` for ~10 seconds).
|
||||
1. Factory reset (on most models: hold `1` + `−` for ~10 seconds — confirmed
|
||||
identical on the SoundTouch 30 Series III, not just the original ST30).
|
||||
2. Reconnect the speaker to your network.
|
||||
3. Re-migrate it in AfterTouch.
|
||||
|
||||
|
||||
@@ -100,6 +100,20 @@ All subsequent messages (except `selectLastWiFiSource`, see below) use this enve
|
||||
|
||||
## Phase 2 — Pairing a New Speaker
|
||||
|
||||
> **Preflight (AfterTouch's `setup pair --mode=full`).** Before opening the
|
||||
> WebSocket, AfterTouch reads `GET /supportedURLs` (must list
|
||||
> `/setMargeAccount`) and `GET /soundTouchConfigurationStatus`, and only
|
||||
> runs the state machine below when the status is exactly
|
||||
> `SOUNDTOUCH_NOT_CONFIGURED`. This matters because a speaker can be
|
||||
> reachable, named, and already have a `margeAccountUUID` set, yet still
|
||||
> report `SOUNDTOUCH_NOT_CONFIGURED` — the firmware keeps prompting to
|
||||
> install the Bose app until a full acknowledged pass through this state
|
||||
> machine runs, not just `setMargeAccount` on its own. Already-configured
|
||||
> devices are a no-op; an unsupported route or an unrecognised status value
|
||||
> aborts without writing anything. See
|
||||
> [#615](https://github.com/gesellix/Bose-SoundTouch/issues/615) and
|
||||
> `Manager.PreflightInitPlan` (`pkg/service/setup/marge_pairing.go`).
|
||||
|
||||
### 2.1 Setup State Machine
|
||||
|
||||
The pairing flow uses a setup state machine on the device. States must be sent in order.
|
||||
@@ -122,7 +136,7 @@ The pairing flow uses a setup state machine on the device. States must be sent i
|
||||
</soundTouchConfigurationUpdated>
|
||||
</updates>
|
||||
|
||||
<!-- 3. Set language (3 = German; adjust as needed) -->
|
||||
<!-- 3. Set language (3 = English; adjust as needed) -->
|
||||
<msg><header deviceID="{device_id}" url="language" method="POST">
|
||||
<request requestID="23"></request>
|
||||
</header><body><sysLanguage>3</sysLanguage></body></msg>
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
---
|
||||
title: "Player: Sources and Selection State"
|
||||
---
|
||||
How the embedded web player (`soundtouch-player`, and the same UI served by
|
||||
`soundtouch-service`) decides what a source button does, and how it decides
|
||||
whether a selection worked.
|
||||
|
||||
Both questions have non-obvious answers, learned from real hardware. This page
|
||||
records what the speaker actually does, so the behaviour is not re-derived or
|
||||
accidentally undone.
|
||||
|
||||
## Not every advertised source can be selected
|
||||
|
||||
A speaker's `/sources` lists what it knows about, each with a `status`. The
|
||||
player renders every `status="READY"` entry as a button. That set is not
|
||||
uniform: some entries are **inputs**, some are **providers**.
|
||||
|
||||
An **input** can be selected on its own. `AUX`, `BLUETOOTH`, a `SPOTIFY`
|
||||
entry with a real `sourceAccount`: `POST /select` with just the source and
|
||||
account is meaningful, and the speaker resumes that input.
|
||||
|
||||
A **provider** cannot. `RADIO_BROWSER`, `TUNEIN` and `LOCAL_INTERNET_RADIO`
|
||||
need a station **ContentItem carrying a `Location`** (see
|
||||
`stations.ResolveContentItem`, which sets `type="stationurl"`). There is
|
||||
nothing for the speaker to resume from the source name alone.
|
||||
|
||||
All three are confirmed on hardware: `RADIO_BROWSER` and
|
||||
`LOCAL_INTERNET_RADIO` by the stub described below, `TUNEIN` by its resume
|
||||
path playing the station as intended.
|
||||
|
||||
`STORED_MUSIC` is a third case: one entry per media server, its
|
||||
`sourceAccount` being a server UDN. Selecting it identifies no track or
|
||||
container.
|
||||
|
||||
## What a bare select does to a provider
|
||||
|
||||
The speaker does not refuse. It answers `200`, and parks on a stub
|
||||
now-playing, while whatever was playing before **carries on**:
|
||||
|
||||
```xml
|
||||
<nowPlaying deviceID="..." source="RADIO_BROWSER" sourceAccount="">
|
||||
<ContentItem source="RADIO_BROWSER" type="" location="" isPresetable="false">
|
||||
<itemName>RADIO_BROWSER</itemName>
|
||||
</ContentItem>
|
||||
</nowPlaying>
|
||||
```
|
||||
|
||||
Four things identify the stub: no `playStatus`, empty `type`, empty
|
||||
`location`, and an `itemName` that just echoes the source name.
|
||||
|
||||
The speaker then reports that stub indefinitely. Observed on hardware: the
|
||||
player showed RadioBrowser while Spotify was audible, and a naive readback
|
||||
"confirmed" the selection because the reported source did match the one
|
||||
requested. `LOCAL_INTERNET_RADIO` produces a byte-identical stub.
|
||||
|
||||
This is speaker behaviour, not something the player or the service can fix
|
||||
after the fact. The only remedy is not to issue such a select.
|
||||
|
||||
## What the player does instead
|
||||
|
||||
| Source | Click behaviour | Resumes from Recents |
|
||||
|------------------------|-----------------------------------------------|----------------------|
|
||||
| `RADIO_BROWSER` | resume newest station, else open RadioBrowser | yes |
|
||||
| `TUNEIN` | resume newest station, else open TuneIn | yes |
|
||||
| `LOCAL_INTERNET_RADIO` | open Play URL | no |
|
||||
| `STORED_MUSIC` | open Library | no |
|
||||
| anything else | `POST /select` as before | n/a |
|
||||
|
||||
Resuming replays the newest Recents entry for that source, using that entry's
|
||||
own ContentItem: the real item the speaker was given, `Location` included.
|
||||
|
||||
**`LOCAL_INTERNET_RADIO` deliberately does not resume.** AfterTouch plays its
|
||||
own one-shot audio through that source: TTS and the notification ding both go
|
||||
out over `/custom/v1/playback/`. Its Recents therefore mix notifications with
|
||||
stations, and on a test speaker the *only* entry was "AfterTouch ding", so
|
||||
resuming played the ding. The announcement path is distinguishable from Play
|
||||
URL's `bmx.BuildOrionLocation`, but it also carries CLI URL playback, and any
|
||||
future audio-injecting feature would have to remember to stay clear of it.
|
||||
Opening Play URL does not depend on classifying what is in Recents.
|
||||
|
||||
`ALEXA` is advertised `READY` too and is deliberately left alone: it cannot be
|
||||
tested on the hardware available, and guessing at its behaviour risks breaking
|
||||
a source that works today. The backstop below covers it instead.
|
||||
|
||||
### The backstop
|
||||
|
||||
The table above only covers sources known to need it, and a source list is
|
||||
whatever the speaker chooses to advertise. So the readback additionally
|
||||
refuses to *confirm* the stub itself, wherever it comes from: a now-playing
|
||||
naming the requested source but with no `Location`, no `PlayStatus`, and an
|
||||
`ItemName` equal to the source is reported as a failure.
|
||||
|
||||
All three conditions are required together. A physical input reports no
|
||||
location and no item name of its own yet is genuinely playing, so any single
|
||||
condition alone would reject real selections.
|
||||
|
||||
## How a selection is confirmed
|
||||
|
||||
`POST /select` returning `200` proves nothing: the speaker can reject a source
|
||||
seconds later, surfacing as a transition to an error source
|
||||
(`INVALID_SOURCE`, `*_ERROR`). So the player posts once, then watches.
|
||||
|
||||
- The **event stream** is the primary watcher. A `nowPlayingUpdated` event
|
||||
reports a late rejection as it happens, and the player turns it into a
|
||||
failure.
|
||||
- **Bounded readbacks** at 2s, 5s and 10s are the fallback for a speaker whose
|
||||
events are not arriving. They stop as soon as a confirmation arrives *and*
|
||||
the readback reports a live event stream, so a confirmed selection normally
|
||||
costs one request rather than three. That signal is `webSocketConnected`,
|
||||
which reports the service's own socket to the speaker; it is opened lazily
|
||||
on first fetch or control of a device, so the very first click after
|
||||
loading one can still take all three.
|
||||
- Readbacks use `GET /devices/{id}/now-playing`, which refreshes only
|
||||
`/now_playing`. The full device fetch runs a complete status poll: six
|
||||
sequential speaker calls plus `/getGroup` on a stereo-capable model, to
|
||||
answer one question, against a device that may be slow precisely because
|
||||
something is wrong.
|
||||
|
||||
Outcomes are `pending`, `provisional-confirmed`, `final-confirmed`,
|
||||
`unverified` and `failed`, shown in a live region under the source list. A
|
||||
confirmation from a push event is never retracted by a later failed readback.
|
||||
|
||||
### Definitive versus uncertain failures
|
||||
|
||||
A rejected write is not always proof the command never landed:
|
||||
|
||||
- **4xx** is produced before the service contacts the speaker (unknown device,
|
||||
unparseable body, empty source). The command provably never went out, so the
|
||||
failure is reported immediately.
|
||||
- **5xx and transport errors** are ambiguous. `handleSourceControl` reports a
|
||||
failed `Client.SelectSource` through `sendControlResponse`, which maps any
|
||||
speaker-call error to 500, and a request that timed out *after* the speaker
|
||||
already switched looks identical to one it never received. The readbacks
|
||||
keep running and the reason is carried into whatever outcome they reach.
|
||||
|
||||
## Ordering: revisions and epochs
|
||||
|
||||
Status reaches the browser three ways — a full `devices` snapshot, a
|
||||
`status_update` delta, and REST refreshes — with no inherent ordering. Two
|
||||
fields fix that:
|
||||
|
||||
- `revision` advances on every projection, so a frame no newer than what the
|
||||
browser holds is dropped.
|
||||
- `nowPlayingRevision` is the now-playing field's own generation. `revision`
|
||||
alone cannot answer "did now-playing actually change?", because any other
|
||||
field's merge advances it; a selection waiting for confirmation needs
|
||||
exactly that distinction.
|
||||
|
||||
Revisions are per-connection and restart at 0, so they are only comparable
|
||||
within one **`epoch`**, which identifies the connection that produced the
|
||||
status. Without it, a device backed by a fresh connection would publish
|
||||
revisions the browser rejects forever, freezing that device's display until
|
||||
reload. Epochs are seeded from the wall clock and forced strictly increasing,
|
||||
so they keep rising across a service restart, and are in milliseconds because
|
||||
the browser compares them as JSON numbers.
|
||||
|
||||
## Source inventory staleness
|
||||
|
||||
`sourcesStale` marks an inventory the speaker has stopped confirming; the
|
||||
player keeps showing it but disables the buttons.
|
||||
|
||||
It is set after **two consecutive** failed `/sources` reads, not one. A single
|
||||
dropped read is not evidence the list is wrong, and marking it stale
|
||||
immediately disabled every source button on a transient hiccup. This mirrors
|
||||
`offlineFailureThreshold`, which debounces connectivity the same way. A
|
||||
successful read clears the marker and resets the count.
|
||||
|
||||
Successful reads remain ordered by generation, so an older one cannot
|
||||
overwrite a newer one. Failures are not ordered: a failure carries no
|
||||
inventory, so spending the generation on it would let a failed read discard a
|
||||
concurrent successful one.
|
||||
|
||||
## Related
|
||||
|
||||
- [Source Selection Guide](SOURCE-SELECTION.md) — the `/select` endpoint and
|
||||
the client library
|
||||
- [WebSocket Events](WEBSOCKET-EVENTS.md) — the event stream the confirmation
|
||||
relies on
|
||||
- [Radio Browser](radio-browser.md) — the RadioBrowser provider
|
||||
@@ -29,17 +29,34 @@ The Bose SoundTouch Go client provides comprehensive source selection functional
|
||||
|
||||
**Response**: HTTP 200 OK (no body) on success
|
||||
|
||||
**Supported Sources:**
|
||||
- `SPOTIFY` - Spotify streaming service
|
||||
**Sources selectable this way:**
|
||||
- `SPOTIFY` - Spotify streaming service (with a real `sourceAccount`)
|
||||
- `BLUETOOTH` - Bluetooth audio input
|
||||
- `AUX` - Auxiliary input (3.5mm jack)
|
||||
- `TUNEIN` - TuneIn internet radio
|
||||
- `PANDORA` - Pandora streaming service
|
||||
- `AMAZON` - Amazon Music
|
||||
- `IHEARTRADIO` - iHeartRadio streaming
|
||||
- `STORED_MUSIC` - Local/network stored music
|
||||
- `AIRPLAY` - Apple AirPlay (device dependent)
|
||||
- `PANDORA`, `AMAZON`, `IHEARTRADIO` - streaming services (with an account)
|
||||
|
||||
### Sources that need a ContentItem instead
|
||||
|
||||
A bare `/select` carrying only a source and account is **not** enough for
|
||||
every source a speaker advertises. These need a full ContentItem with a
|
||||
`Location`, built by `stations.ResolveContentItem` with `type="stationurl"`:
|
||||
|
||||
- `TUNEIN` - TuneIn internet radio
|
||||
- `RADIO_BROWSER` - [RadioBrowser](radio-browser.md) internet radio directory
|
||||
- `LOCAL_INTERNET_RADIO` - stream URLs, including Play URL and TTS output
|
||||
- `STORED_MUSIC` - one entry per media server; selecting it names no track
|
||||
|
||||
Given a bare select for one of these the speaker does **not** report an
|
||||
error. It answers `200`, parks on a stub now-playing with no `playStatus`,
|
||||
empty `type` and `location`, and an `itemName` echoing the source name, and
|
||||
carries on playing whatever it was playing. It then reports that stub
|
||||
indefinitely, so callers that check `/now_playing` see the source they asked
|
||||
for while the audio is something else.
|
||||
|
||||
Use `SelectContentItem` with a real `Location` for these, and see
|
||||
[Player: Sources and Selection State](PLAYER-SOURCE-BEHAVIOUR.md) for the
|
||||
stub's full signature and how the web player avoids it.
|
||||
|
||||
## Client Library Usage
|
||||
|
||||
@@ -158,20 +175,20 @@ soundtouch-cli -host 192.0.2.100 -aux
|
||||
|
||||
### CLI Flags
|
||||
|
||||
| Flag | Description | Example |
|
||||
|------|-------------|---------|
|
||||
| `-select-source <source>` | Select audio source | `-select-source SPOTIFY` |
|
||||
| `-source-account <account>` | Account for streaming services | `-source-account "user123"` |
|
||||
| `-spotify` | Select Spotify source | `-spotify -source-account "user"` |
|
||||
| `-bluetooth` | Select Bluetooth source | `-bluetooth` |
|
||||
| `-aux` | Select AUX input | `-aux` |
|
||||
| Flag | Description | Example |
|
||||
|-----------------------------|--------------------------------|-----------------------------------|
|
||||
| `-select-source <source>` | Select audio source | `-select-source SPOTIFY` |
|
||||
| `-source-account <account>` | Account for streaming services | `-source-account "user123"` |
|
||||
| `-spotify` | Select Spotify source | `-spotify -source-account "user"` |
|
||||
| `-bluetooth` | Select Bluetooth source | `-bluetooth` |
|
||||
| `-aux` | Select AUX input | `-aux` |
|
||||
|
||||
## Source Account Information
|
||||
|
||||
### When Source Accounts are Required
|
||||
|
||||
- **Spotify**: Required for multi-account setups
|
||||
- **Pandora**: Required for account-based access
|
||||
- **Pandora**: Required for account-based access
|
||||
- **TuneIn**: Optional, may improve personalization
|
||||
- **Amazon Music**: Required for account access
|
||||
- **Bluetooth/AUX**: Not required (leave empty)
|
||||
@@ -259,11 +276,11 @@ func selectWithConfig(client *client.Client, config SourceConfig) error {
|
||||
|
||||
### Common Error Codes
|
||||
|
||||
| Code | Name | Description | Solution |
|
||||
|------|------|-------------|----------|
|
||||
| 1005 | UNKNOWN_SOURCE_ERROR | Invalid or unavailable source | Check available sources first |
|
||||
| 1006 | SOURCE_UNAVAILABLE | Source temporarily unavailable | Try again later |
|
||||
| 1007 | ACCOUNT_ERROR | Invalid account for source | Check account name format |
|
||||
| Code | Name | Description | Solution |
|
||||
|------|----------------------|--------------------------------|-------------------------------|
|
||||
| 1005 | UNKNOWN_SOURCE_ERROR | Invalid or unavailable source | Check available sources first |
|
||||
| 1006 | SOURCE_UNAVAILABLE | Source temporarily unavailable | Try again later |
|
||||
| 1007 | ACCOUNT_ERROR | Invalid account for source | Check account name format |
|
||||
|
||||
### Troubleshooting Tips
|
||||
|
||||
@@ -347,13 +364,16 @@ The implementation follows the official SoundTouch API:
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Player: Sources and Selection State](PLAYER-SOURCE-BEHAVIOUR.md) - which
|
||||
sources accept a bare select, and how a selection is confirmed
|
||||
|
||||
- **[API Endpoints Overview](API-ENDPOINTS.md)** - Complete API reference
|
||||
- **[Sources](https://github.com/gesellix/Bose-SoundTouch/blob/main/pkg/models/sources.go)** - Source model implementation
|
||||
- **[Sources](https://github.com/gesellix/Bose-SoundTouch/blob/main/pkg/models/sources.go)** - Source model implementation
|
||||
- **[Now Playing](https://github.com/gesellix/Bose-SoundTouch/blob/main/pkg/models/nowplaying.go)** - ContentItem model
|
||||
- **[Client Usage Examples](https://github.com/gesellix/Bose-SoundTouch/blob/main/cmd/soundtouch-cli/main.go)** - CLI implementation reference
|
||||
|
||||
---
|
||||
|
||||
**Implementation Date**: 2026-01-09
|
||||
**Status**: ✅ Complete and tested
|
||||
**Implementation Date**: 2026-01-09
|
||||
**Status**: ✅ Complete and tested
|
||||
**Real Device Validation**: SoundTouch 10, SoundTouch 20
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
module navigation-station-demo
|
||||
|
||||
go 1.26.6
|
||||
go 1.27.1
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.123.0
|
||||
require github.com/gesellix/bose-soundtouch v0.128.0
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
module preset-management-example
|
||||
|
||||
go 1.26.6
|
||||
go 1.27.1
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.123.0
|
||||
require github.com/gesellix/bose-soundtouch v0.128.0
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||
|
||||
|
||||
@@ -1,40 +1,38 @@
|
||||
module github.com/gesellix/bose-soundtouch
|
||||
|
||||
go 1.26.6
|
||||
go 1.27.1
|
||||
|
||||
require (
|
||||
filippo.io/age v1.3.1
|
||||
filippo.io/age v1.3.2
|
||||
github.com/chromedp/chromedp v0.16.0
|
||||
github.com/go-chi/chi/v5 v5.3.1
|
||||
github.com/go-chi/chi/v5 v5.3.2
|
||||
github.com/google/gopacket v1.1.19
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/hashicorp/mdns v1.0.7
|
||||
github.com/miekg/dns v1.1.72
|
||||
github.com/miekg/dns v1.1.73
|
||||
github.com/russross/blackfriday/v2 v2.1.0
|
||||
github.com/sergi/go-diff v1.4.0
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
|
||||
github.com/urfave/cli/v2 v2.27.7
|
||||
golang.org/x/crypto v0.55.0
|
||||
golang.org/x/mod v0.39.0
|
||||
golang.org/x/net v0.57.0
|
||||
golang.org/x/crypto v0.56.0
|
||||
golang.org/x/mod v0.40.0
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/term v0.45.0
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
filippo.io/hpke v0.4.0 // indirect
|
||||
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f // indirect
|
||||
github.com/chromedp/cdproto v0.0.0-20260804232424-e85f50dbfd32 // 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-20260623181947-01eb4420fa68 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 // 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.45.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
golang.org/x/tools v0.48.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M=
|
||||
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
|
||||
filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0=
|
||||
filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4=
|
||||
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d h1:Blprhc2SbChNZtWcU+BLTM4YdoqYAS9V7cJgOwJKyAs=
|
||||
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
|
||||
filippo.io/age v1.3.2 h1:r6RSZLFSMm6rzKepZ7ZAYkKCu14f3/Me8c7uKYh7C8c=
|
||||
filippo.io/age v1.3.2/go.mod h1:TH/Yr2sSRhCKbaH4XPxpUV0Us8Gv6txYUpiZQWz8Evk=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
|
||||
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
|
||||
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f h1:0Z1zcSLEmnj2c2CmJYBqewtS6pxhB39bNWUSEUAWjgk=
|
||||
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f/go.mod h1:RwFsSODCtFExll+GhHM6R92SARHR3Z3oipaxLHj46C0=
|
||||
github.com/chromedp/cdproto v0.0.0-20260804232424-e85f50dbfd32 h1:6JI+JS7Zef+bMzZQ+OgzTHf79v3GqdvP6rD0FaP9CMk=
|
||||
github.com/chromedp/cdproto v0.0.0-20260804232424-e85f50dbfd32/go.mod h1:RwFsSODCtFExll+GhHM6R92SARHR3Z3oipaxLHj46C0=
|
||||
github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk=
|
||||
github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U=
|
||||
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
||||
@@ -17,18 +17,16 @@ github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg=
|
||||
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
|
||||
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 h1:UADEEmDKgfXbtnGJZ97beY5XLo9ZechG1nlU4KnRrkE=
|
||||
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3/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=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
|
||||
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
@@ -40,8 +38,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
|
||||
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -63,18 +61,18 @@ github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAz
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
|
||||
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
|
||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74=
|
||||
golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY=
|
||||
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
@@ -89,8 +87,6 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
Generated
+7
@@ -7,6 +7,7 @@
|
||||
"name": "@gesellix/bose-soundtouch",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-module-shims": "2.8.4",
|
||||
"htm": "3.1.1",
|
||||
"preact": "10.29.8"
|
||||
},
|
||||
@@ -14,6 +15,12 @@
|
||||
"node": ">=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-shims": {
|
||||
"version": "2.8.4",
|
||||
"resolved": "https://registry.npmjs.org/es-module-shims/-/es-module-shims-2.8.4.tgz",
|
||||
"integrity": "sha512-ea5srn5L89PWVad6Qle6r2kg+HvvLiL/GHqgNx06eFrkERHkrTFWaqo1w8Sd+XI3Xr0iAG5x3ZQ7mQ/hnQzNpA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/htm": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/htm/-/htm-3.1.1.tgz",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"es-module-shims": "2.8.4",
|
||||
"htm": "3.1.1",
|
||||
"preact": "10.29.8"
|
||||
}
|
||||
|
||||
+334
-12
@@ -144,6 +144,7 @@ package client
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
@@ -169,6 +170,11 @@ type Client struct {
|
||||
avTransportURLOverride string
|
||||
}
|
||||
|
||||
// ErrMutationOutcomeUnknown reports that a state-changing GET may have reached
|
||||
// the speaker, but its authoritative response could not be verified. Callers
|
||||
// must read device state back rather than retrying the mutation blindly.
|
||||
var ErrMutationOutcomeUnknown = errors.New("state-changing GET outcome is unknown")
|
||||
|
||||
// Config holds configuration for the SoundTouch client
|
||||
type Config struct {
|
||||
Host string
|
||||
@@ -790,7 +796,14 @@ func (c *Client) DecreaseBalance(amount int) (*models.Balance, error) {
|
||||
return c.GetBalance()
|
||||
}
|
||||
|
||||
// SelectSource selects an audio source using the /select endpoint
|
||||
// SelectSource selects an audio source using the /select endpoint.
|
||||
//
|
||||
// Only sources that are inputs in their own right can be selected this way:
|
||||
// AUX, BLUETOOTH, or a streaming service with a real sourceAccount. Provider
|
||||
// sources (TUNEIN, RADIO_BROWSER, LOCAL_INTERNET_RADIO) and STORED_MUSIC need
|
||||
// SelectContentItem with a Location instead; a bare select leaves such a
|
||||
// speaker reporting a source it is not playing. See
|
||||
// docs/content/docs/reference/PLAYER-SOURCE-BEHAVIOUR.md.
|
||||
func (c *Client) SelectSource(source, sourceAccount string) error {
|
||||
// Validate source parameter
|
||||
if source == "" {
|
||||
@@ -855,7 +868,15 @@ func (c *Client) SelectAux() error {
|
||||
return c.SelectSource("AUX", "")
|
||||
}
|
||||
|
||||
// SelectTuneIn is a convenience method to select TuneIn source
|
||||
// SelectTuneIn is a convenience method to select TuneIn source.
|
||||
//
|
||||
// This sends a bare select, with no station. A speaker does not report an
|
||||
// error for that: it answers 200, parks on a stub now-playing (no playStatus,
|
||||
// empty type and location, itemName echoing the source name) and carries on
|
||||
// playing whatever it was playing, then reports that stub indefinitely. To
|
||||
// actually play something use SelectContentItem with a ContentItem carrying a
|
||||
// Location, as stations.ResolveContentItem builds. See
|
||||
// docs/content/docs/reference/PLAYER-SOURCE-BEHAVIOUR.md.
|
||||
func (c *Client) SelectTuneIn(sourceAccount string) error {
|
||||
return c.SelectSource("TUNEIN", sourceAccount)
|
||||
}
|
||||
@@ -1021,6 +1042,152 @@ func (c *Client) SetClockTimeNow() error {
|
||||
return c.SetClockTime(request)
|
||||
}
|
||||
|
||||
// GetSystemTimeout retrieves the power-saving setting from /systemtimeout.
|
||||
func (c *Client) GetSystemTimeout() (*models.SystemTimeout, error) {
|
||||
var setting models.SystemTimeout
|
||||
if err := c.get("/systemtimeout", &setting); err != nil {
|
||||
return nil, fmt.Errorf("failed to get system timeout: %w", err)
|
||||
}
|
||||
|
||||
return &setting, nil
|
||||
}
|
||||
|
||||
// SetSystemTimeout updates /systemtimeout. HTTP success only confirms that the
|
||||
// request was accepted; callers must read the setting back before reporting it.
|
||||
func (c *Client) SetSystemTimeout(setting *models.SystemTimeout) error {
|
||||
if err := setting.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid system timeout request: %w", err)
|
||||
}
|
||||
|
||||
if err := c.post("/systemtimeout", setting); err != nil {
|
||||
return fmt.Errorf("failed to set system timeout: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRebroadcastLatencyMode retrieves /rebroadcastlatencymode.
|
||||
func (c *Client) GetRebroadcastLatencyMode() (*models.RebroadcastLatencyMode, error) {
|
||||
var setting models.RebroadcastLatencyMode
|
||||
if err := c.get("/rebroadcastlatencymode", &setting); err != nil {
|
||||
return nil, fmt.Errorf("failed to get rebroadcast latency mode: %w", err)
|
||||
}
|
||||
|
||||
return &setting, nil
|
||||
}
|
||||
|
||||
// SetRebroadcastLatencyMode updates /rebroadcastlatencymode. HTTP success only
|
||||
// confirms request acceptance; callers must read the setting back.
|
||||
func (c *Client) SetRebroadcastLatencyMode(mode models.RebroadcastLatencyModeValue) error {
|
||||
request := &models.RebroadcastLatencyModeRequest{Mode: mode}
|
||||
if err := request.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid rebroadcast latency mode request: %w", err)
|
||||
}
|
||||
|
||||
if err := c.post("/rebroadcastlatencymode", request); err != nil {
|
||||
return fmt.Errorf("failed to set rebroadcast latency mode: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLanguage retrieves the current integer system language from /language.
|
||||
// Unknown codes are returned unchanged for compatibility with newer firmware.
|
||||
func (c *Client) GetLanguage() (*models.SystemLanguage, error) {
|
||||
var language models.SystemLanguage
|
||||
if err := c.get("/language", &language); err != nil {
|
||||
return nil, fmt.Errorf("failed to get system language: %w", err)
|
||||
}
|
||||
|
||||
return &language, nil
|
||||
}
|
||||
|
||||
// SetLanguage updates /language. HTTP success only confirms request acceptance;
|
||||
// callers must read the language back before reporting the change.
|
||||
func (c *Client) SetLanguage(code models.LanguageCode) error {
|
||||
request := &models.SystemLanguage{Code: code}
|
||||
if err := request.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid system language request: %w", err)
|
||||
}
|
||||
|
||||
if err := c.post("/language", request); err != nil {
|
||||
return fmt.Errorf("failed to set system language: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetBluetoothInfo retrieves the speaker adapter information from /bluetoothInfo.
|
||||
func (c *Client) GetBluetoothInfo() (*models.BluetoothInfo, error) {
|
||||
var info models.BluetoothInfo
|
||||
if err := c.get("/bluetoothInfo", &info); err != nil {
|
||||
return nil, fmt.Errorf("failed to get Bluetooth info: %w", err)
|
||||
}
|
||||
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// RenameSource updates the source display name through /nameSource. HTTP
|
||||
// success only confirms request acceptance; callers must read sources back.
|
||||
func (c *Client) RenameSource(source, sourceAccount, itemName string) error {
|
||||
request := &models.SourceRenameRequest{
|
||||
Source: source,
|
||||
SourceAccount: sourceAccount,
|
||||
ItemName: itemName,
|
||||
}
|
||||
if err := request.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid source rename request: %w", err)
|
||||
}
|
||||
|
||||
if err := c.post("/nameSource", request); err != nil {
|
||||
return fmt.Errorf("failed to rename source: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnterPairingMode requests the firmware's legacy general pairing mode through
|
||||
// its state-changing GET endpoint.
|
||||
func (c *Client) EnterPairingMode() error {
|
||||
if err := c.mutatingGetConfirmStatus("/enterPairingMode"); err != nil {
|
||||
return fmt.Errorf("failed to enter pairing mode: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnterBluetoothPairing requests Bluetooth discoverable mode through the
|
||||
// Bluetooth-specific state-changing GET endpoint. Callers must verify
|
||||
// discoverability through a subsequent now-playing read.
|
||||
func (c *Client) EnterBluetoothPairing() error {
|
||||
if err := c.mutatingGetConfirmStatus("/enterBluetoothPairing"); err != nil {
|
||||
return fmt.Errorf("failed to enter Bluetooth pairing mode: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearPairedList requests the firmware's legacy general paired-list clearing
|
||||
// through its state-changing GET endpoint.
|
||||
func (c *Client) ClearPairedList() error {
|
||||
if err := c.mutatingGetConfirmStatus("/clearPairedList"); err != nil {
|
||||
return fmt.Errorf("failed to clear paired list: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearBluetoothPaired requests removal of Bluetooth pairings through the
|
||||
// Bluetooth-specific state-changing GET endpoint. The firmware exposes no
|
||||
// paired-list readback, so HTTP success alone does not verify physical state.
|
||||
func (c *Client) ClearBluetoothPaired() error {
|
||||
if err := c.mutatingGetConfirmStatus("/clearBluetoothPaired"); err != nil {
|
||||
return fmt.Errorf("failed to clear Bluetooth paired devices: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetClockDisplay retrieves clock display settings from the /clockDisplay endpoint
|
||||
func (c *Client) GetClockDisplay() (*models.ClockDisplay, error) {
|
||||
var clockDisplay models.ClockDisplay
|
||||
@@ -1105,6 +1272,10 @@ func (c *Client) Host() string {
|
||||
|
||||
// get performs a GET request and unmarshals the XML response
|
||||
func (c *Client) get(endpoint string, result interface{}) error {
|
||||
return c.getWithHTTPClient(c.httpClient, endpoint, result)
|
||||
}
|
||||
|
||||
func (c *Client) getWithHTTPClient(httpClient *http.Client, endpoint string, result interface{}) error {
|
||||
url := c.baseURL + endpoint
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
@@ -1115,7 +1286,7 @@ func (c *Client) get(endpoint string, result interface{}) error {
|
||||
req.Header.Set("User-Agent", c.userAgent)
|
||||
req.Header.Set("Accept", "application/xml")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute request: %w", err)
|
||||
}
|
||||
@@ -1151,6 +1322,148 @@ func (c *Client) get(endpoint string, result interface{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// newOneShotHTTPClient clones the client's transport with keep-alives
|
||||
// disabled, so a firmware-required state-changing GET runs exactly once at
|
||||
// the HTTP transport layer instead of risking an automatic replay by
|
||||
// net/http after an ambiguous failure on a reused connection. The caller
|
||||
// owns the returned transport's lifetime and must close its idle
|
||||
// connections once done (defer oneShotTransport.CloseIdleConnections()).
|
||||
func (c *Client) newOneShotHTTPClient() (client *http.Client, oneShotTransport *http.Transport, err error) {
|
||||
baseTransport := c.httpClient.Transport
|
||||
if baseTransport == nil {
|
||||
baseTransport = http.DefaultTransport
|
||||
}
|
||||
|
||||
transport, ok := baseTransport.(*http.Transport)
|
||||
if !ok {
|
||||
return nil, nil, errors.New("state-changing GET requires a cloneable HTTP transport")
|
||||
}
|
||||
|
||||
oneShotTransport = transport.Clone()
|
||||
oneShotTransport.DisableKeepAlives = true
|
||||
|
||||
return &http.Client{
|
||||
Transport: oneShotTransport,
|
||||
Timeout: c.httpClient.Timeout,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}, oneShotTransport, nil
|
||||
}
|
||||
|
||||
// mutatingGet performs a firmware-required state-changing GET exactly once at
|
||||
// the HTTP transport layer, unmarshaling the response into result. A fresh
|
||||
// connection prevents net/http from automatically replaying the request
|
||||
// after an ambiguous failure on a reused connection.
|
||||
func (c *Client) mutatingGet(endpoint string, result interface{}) error {
|
||||
oneShotClient, oneShotTransport, err := c.newOneShotHTTPClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer oneShotTransport.CloseIdleConnections()
|
||||
|
||||
return c.getWithHTTPClient(oneShotClient, endpoint, result)
|
||||
}
|
||||
|
||||
// mutatingGetConfirmStatus performs the same one-shot, firmware-required
|
||||
// state-changing GET as mutatingGet, for endpoints that return no meaningful
|
||||
// body to unmarshal -- confirmation instead comes from the device echoing
|
||||
// back <status>{endpoint}</status>.
|
||||
func (c *Client) mutatingGetConfirmStatus(endpoint string) error {
|
||||
oneShotClient, oneShotTransport, err := c.newOneShotHTTPClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer oneShotTransport.CloseIdleConnections()
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, c.baseURL+endpoint, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", c.userAgent)
|
||||
req.Header.Set("Accept", "application/xml")
|
||||
|
||||
resp, err := oneShotClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: failed to execute request: %w", ErrMutationOutcomeUnknown, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: failed to read response: %w", ErrMutationOutcomeUnknown, err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if apiErr := mutationAPIError(body); apiErr != nil {
|
||||
return apiErr
|
||||
}
|
||||
|
||||
return fmt.Errorf(
|
||||
"%w: API request failed with status %d: %s",
|
||||
ErrMutationOutcomeUnknown,
|
||||
resp.StatusCode,
|
||||
string(body),
|
||||
)
|
||||
}
|
||||
|
||||
if apiErr := mutationAPIError(body); apiErr != nil {
|
||||
return apiErr
|
||||
}
|
||||
|
||||
return validateMutationStatus(body, endpoint)
|
||||
}
|
||||
|
||||
func mutationAPIError(body []byte) error {
|
||||
switch mutationResponseRoot(body) {
|
||||
case "errors":
|
||||
var errs models.ErrorsResponse
|
||||
if err := xml.Unmarshal(body, &errs); err == nil && len(errs.Errors) > 0 {
|
||||
return &errs
|
||||
}
|
||||
case "error":
|
||||
var apiError models.APIError
|
||||
if err := xml.Unmarshal(body, &apiError); err == nil && apiError.Message != "" {
|
||||
return &apiError
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func mutationResponseRoot(body []byte) string {
|
||||
var root struct {
|
||||
XMLName xml.Name
|
||||
}
|
||||
if xml.Unmarshal(body, &root) != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return root.XMLName.Local
|
||||
}
|
||||
|
||||
func validateMutationStatus(body []byte, endpoint string) error {
|
||||
var status struct {
|
||||
XMLName xml.Name `xml:"status"`
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
if err := xml.Unmarshal(body, &status); err != nil {
|
||||
return fmt.Errorf("%w: malformed XML response: %w", ErrMutationOutcomeUnknown, err)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(status.Value) != endpoint {
|
||||
return fmt.Errorf(
|
||||
"%w: expected <status>%s</status>, got %s",
|
||||
ErrMutationOutcomeUnknown,
|
||||
endpoint,
|
||||
strings.TrimSpace(string(body)),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// post performs a POST request with XML body
|
||||
func (c *Client) post(endpoint string, payload interface{}) error {
|
||||
url := c.baseURL + endpoint
|
||||
@@ -1428,10 +1741,18 @@ func (c *Client) GetZoneMembers() ([]string, error) {
|
||||
// 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.
|
||||
// ST-10 is the only product that supports stereo pairs. Verified against
|
||||
// real hardware: a SoundTouch 20 does not reply to /getGroup promptly. The
|
||||
// device's own firmware ("AllegroWebserver") eventually answers with a
|
||||
// plain-text "AllegroWebserver timeout: /getGroup" error body after an
|
||||
// internal delay exceeding several seconds, but well within the client's
|
||||
// own timeout (30s by default, see DefaultConfig) the request just looks
|
||||
// like it never replied at all. Callers on a poll cycle must gate this call
|
||||
// behind a stereo-pair-capable model check (see stereoPairCapable in
|
||||
// pkg/service/soundtouchweb) instead of relying on a fast, harmless
|
||||
// response on unsupported models. 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
|
||||
|
||||
@@ -1440,10 +1761,11 @@ func (c *Client) GetGroup() (*models.Group, error) {
|
||||
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.
|
||||
// AddGroup applies one side of stereo-pair creation to the addressed device.
|
||||
// The supplied group must contain both LEFT and RIGHT roles. A master-bound
|
||||
// request omits SenderIPAddress; a slave-bound request sets it to the master's
|
||||
// IP address. Firmware may acknowledge the request without returning the
|
||||
// assigned group ID, so callers must verify the resulting state with GetGroup.
|
||||
func (c *Client) AddGroup(group *models.Group) (*models.Group, error) {
|
||||
var result models.Group
|
||||
if err := c.postWithResponse("/addGroup", group, &result); err != nil {
|
||||
@@ -1473,7 +1795,7 @@ func (c *Client) UpdateGroup(group *models.Group) (*models.Group, error) {
|
||||
func (c *Client) RemoveGroup() error {
|
||||
var g models.Group
|
||||
|
||||
return c.get("/removeGroup", &g)
|
||||
return c.mutatingGet("/removeGroup", &g)
|
||||
}
|
||||
|
||||
// SetName sets the device name
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
@@ -232,3 +233,74 @@ func TestClient_RemoveGroup(t *testing.T) {
|
||||
t.Fatalf("RemoveGroup: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRemoveGroupDoesNotReplayDroppedResponse(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/getGroup" {
|
||||
_, _ = w.Write([]byte(`<group />`))
|
||||
return
|
||||
}
|
||||
|
||||
if r.URL.Path != "/removeGroup" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
calls.Add(1)
|
||||
connection, _, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
t.Errorf("hijack response: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
_ = connection.Close()
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := createTestClient(server.URL)
|
||||
if _, err := client.GetGroup(); err != nil {
|
||||
t.Fatalf("prime ordinary client connection: %v", err)
|
||||
}
|
||||
|
||||
err := client.RemoveGroup()
|
||||
if err == nil {
|
||||
t.Fatal("RemoveGroup succeeded after the response was dropped")
|
||||
}
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Fatalf("/removeGroup requests = %d, want exactly 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRemoveGroupDoesNotFollowRedirect(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
var redirectedCalls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
|
||||
switch r.URL.Path {
|
||||
case "/removeGroup":
|
||||
http.Redirect(w, r, "/redirected", http.StatusTemporaryRedirect)
|
||||
case "/redirected":
|
||||
redirectedCalls.Add(1)
|
||||
_, _ = w.Write([]byte(`<group />`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := createTestClient(server.URL).RemoveGroup()
|
||||
if err == nil {
|
||||
t.Fatal("RemoveGroup followed a redirect")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "status 307") {
|
||||
t.Fatalf("RemoveGroup error = %q, want redirect status", err)
|
||||
}
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Fatalf("HTTP requests = %d, want exactly 1", got)
|
||||
}
|
||||
if got := redirectedCalls.Load(); got != 0 {
|
||||
t.Fatalf("redirect target requests = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestClientSystemSettingsGETs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
response string
|
||||
call func(*Client) error
|
||||
}{
|
||||
{
|
||||
name: "system timeout",
|
||||
path: "/systemtimeout",
|
||||
response: `<systemtimeout><powersaving_enabled>true</powersaving_enabled></systemtimeout>`,
|
||||
call: func(client *Client) error {
|
||||
setting, err := client.GetSystemTimeout()
|
||||
if err == nil && !setting.PowerSavingEnabled {
|
||||
t.Error("PowerSavingEnabled = false, want true")
|
||||
}
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rebroadcast latency",
|
||||
path: "/rebroadcastlatencymode",
|
||||
response: `<rebroadcastlatencymode mode="SYNC_TO_ZONE" controllable="true"/>`,
|
||||
call: func(client *Client) error {
|
||||
setting, err := client.GetRebroadcastLatencyMode()
|
||||
if err == nil && (setting.Mode != models.RebroadcastLatencySyncToZone || !setting.Controllable) {
|
||||
t.Errorf("setting = %#v", setting)
|
||||
}
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "known language",
|
||||
path: "/language",
|
||||
response: `<sysLanguage>15</sysLanguage>`,
|
||||
call: func(client *Client) error {
|
||||
language, err := client.GetLanguage()
|
||||
if err == nil && language.Code != models.LanguageCzech {
|
||||
t.Errorf("Code = %d, want %d", language.Code, models.LanguageCzech)
|
||||
}
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unknown language remains readable",
|
||||
path: "/language",
|
||||
response: `<sysLanguage>99</sysLanguage>`,
|
||||
call: func(client *Client) error {
|
||||
language, err := client.GetLanguage()
|
||||
if err == nil && language.Code != 99 {
|
||||
t.Errorf("Code = %d, want 99", language.Code)
|
||||
}
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Bluetooth info",
|
||||
path: "/bluetoothInfo",
|
||||
response: `<BluetoothInfo BluetoothMACAddress="AABBCCDDEEFF"/>`,
|
||||
call: func(client *Client) error {
|
||||
info, err := client.GetBluetoothInfo()
|
||||
if err == nil && info.BluetoothMACAddress != "AABBCCDDEEFF" {
|
||||
t.Errorf("BluetoothMACAddress = %q", info.BluetoothMACAddress)
|
||||
}
|
||||
return err
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("method = %s, want GET", r.Method)
|
||||
}
|
||||
if r.URL.Path != test.path {
|
||||
t.Errorf("path = %s, want %s", r.URL.Path, test.path)
|
||||
}
|
||||
if got := r.Header.Get("Accept"); got != "application/xml" {
|
||||
t.Errorf("Accept = %q", got)
|
||||
}
|
||||
if got := r.Header.Get("User-Agent"); got != "Bose-SoundTouch-Go-Client/1.0" {
|
||||
t.Errorf("User-Agent = %q", got)
|
||||
}
|
||||
_, _ = io.WriteString(w, test.response)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := test.call(createTestClient(server.URL)); err != nil {
|
||||
t.Fatalf("call(): %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientSystemSettingsGETErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
call func(*Client) error
|
||||
}{
|
||||
{
|
||||
name: "malformed system timeout XML",
|
||||
body: `<systemtimeout><powersaving_enabled>`,
|
||||
call: func(client *Client) error { _, err := client.GetSystemTimeout(); return err },
|
||||
},
|
||||
{
|
||||
name: "incomplete Bluetooth XML",
|
||||
body: `<BluetoothInfo/>`,
|
||||
call: func(client *Client) error { _, err := client.GetBluetoothInfo(); return err },
|
||||
},
|
||||
{
|
||||
name: "language non-200",
|
||||
status: http.StatusNotFound,
|
||||
body: `unsupported`,
|
||||
call: func(client *Client) error { _, err := client.GetLanguage(); return err },
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
status := test.status
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
_, _ = io.WriteString(w, test.body)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := test.call(createTestClient(server.URL)); err == nil {
|
||||
t.Fatal("call() unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientSystemSettingsPOSTs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
body string
|
||||
call func(*Client) error
|
||||
}{
|
||||
{
|
||||
name: "system timeout",
|
||||
path: "/systemtimeout",
|
||||
body: `<systemtimeout><powersaving_enabled>false</powersaving_enabled></systemtimeout>`,
|
||||
call: func(client *Client) error {
|
||||
return client.SetSystemTimeout(&models.SystemTimeout{PowerSavingEnabled: false})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rebroadcast latency",
|
||||
path: "/rebroadcastlatencymode",
|
||||
body: `<rebroadcastlatencymode mode="SYNC_TO_ROOM"></rebroadcastlatencymode>`,
|
||||
call: func(client *Client) error {
|
||||
return client.SetRebroadcastLatencyMode(models.RebroadcastLatencySyncToRoom)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "language",
|
||||
path: "/language",
|
||||
body: `<sysLanguage>3</sysLanguage>`,
|
||||
call: func(client *Client) error { return client.SetLanguage(models.LanguageEnglish) },
|
||||
},
|
||||
{
|
||||
name: "source rename with account",
|
||||
path: "/nameSource",
|
||||
body: `<ContentItem source="AUX" sourceAccount="AUX1"><itemName>Turntable</itemName></ContentItem>`,
|
||||
call: func(client *Client) error { return client.RenameSource("AUX", "AUX1", "Turntable") },
|
||||
},
|
||||
{
|
||||
name: "source rename without account",
|
||||
path: "/nameSource",
|
||||
body: `<ContentItem source="BLUETOOTH"><itemName>Phone</itemName></ContentItem>`,
|
||||
call: func(client *Client) error { return client.RenameSource("BLUETOOTH", "", "Phone") },
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("method = %s, want POST", r.Method)
|
||||
}
|
||||
if r.URL.Path != test.path {
|
||||
t.Errorf("path = %s, want %s", r.URL.Path, test.path)
|
||||
}
|
||||
if got := r.Header.Get("Content-Type"); got != "application/xml" {
|
||||
t.Errorf("Content-Type = %q", got)
|
||||
}
|
||||
if got := r.Header.Get("Accept"); got != "application/xml" {
|
||||
t.Errorf("Accept = %q", got)
|
||||
}
|
||||
if got := r.Header.Get("User-Agent"); got != "Bose-SoundTouch-Go-Client/1.0" {
|
||||
t.Errorf("User-Agent = %q", got)
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Errorf("ReadAll(): %v", err)
|
||||
}
|
||||
if string(body) != test.body {
|
||||
t.Errorf("body = %q, want %q", body, test.body)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := test.call(createTestClient(server.URL)); err != nil {
|
||||
t.Fatalf("call(): %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientSystemSettingsPOSTValidation(t *testing.T) {
|
||||
client := createTestClient("http://127.0.0.1:1")
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
call func() error
|
||||
}{
|
||||
{"nil timeout", func() error { return client.SetSystemTimeout(nil) }},
|
||||
{"unknown latency", func() error { return client.SetRebroadcastLatencyMode("OTHER") }},
|
||||
{"unknown language", func() error { return client.SetLanguage(99) }},
|
||||
{"missing source", func() error { return client.RenameSource("", "", "Name") }},
|
||||
{"missing item name", func() error { return client.RenameSource("AUX", "AUX1", "") }},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if err := test.call(); err == nil {
|
||||
t.Fatal("call() unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientSystemSettingsPOSTNon200(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "rejected", http.StatusBadRequest)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := createTestClient(server.URL).SetLanguage(models.LanguageEnglish)
|
||||
if err == nil || !strings.Contains(err.Error(), "400") {
|
||||
t.Fatalf("SetLanguage() error = %v, want status 400", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientBluetoothMutatingGETs(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
path string
|
||||
call func(*Client) error
|
||||
}{
|
||||
{"enter pairing mode", "/enterPairingMode", func(client *Client) error { return client.EnterPairingMode() }},
|
||||
{"clear paired list", "/clearPairedList", func(client *Client) error { return client.ClearPairedList() }},
|
||||
{"enter Bluetooth pairing", "/enterBluetoothPairing", func(client *Client) error { return client.EnterBluetoothPairing() }},
|
||||
{"clear Bluetooth paired", "/clearBluetoothPaired", func(client *Client) error { return client.ClearBluetoothPaired() }},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
requests := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("method = %s, want GET", r.Method)
|
||||
}
|
||||
if r.URL.Path != test.path {
|
||||
t.Errorf("path = %s, want %s", r.URL.Path, test.path)
|
||||
}
|
||||
if got := r.Header.Get("Accept"); got != "application/xml" {
|
||||
t.Errorf("Accept = %q", got)
|
||||
}
|
||||
if got := r.Header.Get("User-Agent"); got != "Bose-SoundTouch-Go-Client/1.0" {
|
||||
t.Errorf("User-Agent = %q", got)
|
||||
}
|
||||
_, _ = io.WriteString(w, `<status>`+test.path+`</status>`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := test.call(createTestClient(server.URL)); err != nil {
|
||||
t.Fatalf("call(): %v", err)
|
||||
}
|
||||
if requests != 1 {
|
||||
t.Fatalf("requests = %d, want 1", requests)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientMutatingGETDoesNotFollowRedirect(t *testing.T) {
|
||||
requests := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
if r.URL.Path == "/enterBluetoothPairing" {
|
||||
http.Redirect(w, r, "/replayed", http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, `<status>replayed</status>`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := createTestClient(server.URL).EnterBluetoothPairing()
|
||||
if err == nil || !strings.Contains(err.Error(), "307") {
|
||||
t.Fatalf("EnterBluetoothPairing() error = %v, want status 307", err)
|
||||
}
|
||||
if requests != 1 {
|
||||
t.Fatalf("requests = %d, want 1", requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientMutatingGETRejectsErrorEnvelopeWithHTTP200(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, `<errors deviceID="AABBCCDDEEFF"><error value="1029" name="UNKNOWN_ACTION_ERROR">rejected</error></errors>`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := createTestClient(server.URL).EnterBluetoothPairing()
|
||||
var errs *models.ErrorsResponse
|
||||
if !errors.As(err, &errs) {
|
||||
t.Fatalf("EnterBluetoothPairing() error = %T %v, want ErrorsResponse", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientMutatingGETMarksUnstructuredHTTPFailureUnknown(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "internal failure", http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := createTestClient(server.URL).EnterBluetoothPairing()
|
||||
if !errors.Is(err, ErrMutationOutcomeUnknown) {
|
||||
t.Fatalf("EnterBluetoothPairing() error = %v, want ErrMutationOutcomeUnknown", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "status 500") {
|
||||
t.Fatalf("EnterBluetoothPairing() error = %v, want status 500", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientMutatingGETMarksLostResponseUnknown(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
connection, _, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
t.Errorf("Hijack(): %v", err)
|
||||
return
|
||||
}
|
||||
_ = connection.Close()
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := createTestClient(server.URL).EnterBluetoothPairing()
|
||||
if !errors.Is(err, ErrMutationOutcomeUnknown) {
|
||||
t.Fatalf("EnterBluetoothPairing() error = %v, want ErrMutationOutcomeUnknown", err)
|
||||
}
|
||||
}
|
||||
+257
-71
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -16,17 +17,33 @@ import (
|
||||
|
||||
// WebSocketClient handles WebSocket connections to SoundTouch devices
|
||||
type WebSocketClient struct {
|
||||
client *Client
|
||||
conn *websocket.Conn
|
||||
handlers *models.WebSocketEventHandlers
|
||||
mu sync.RWMutex
|
||||
writeMu sync.Mutex // serializes all writes; gorilla/websocket allows one concurrent writer
|
||||
connected bool
|
||||
reconnect bool
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
logger Logger
|
||||
bufferSize int
|
||||
client *Client
|
||||
conn *websocket.Conn
|
||||
connection *webSocketConnection
|
||||
handlers *models.WebSocketEventHandlers
|
||||
mu sync.RWMutex
|
||||
connectMu sync.Mutex // serializes dial attempts without blocking shutdown
|
||||
writeMu sync.Mutex // serializes all writes; gorilla/websocket allows one concurrent writer
|
||||
connected bool
|
||||
reconnect bool
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
logger Logger
|
||||
bufferSize int
|
||||
dialContext webSocketDialContext
|
||||
|
||||
transportHandler func(connected bool, generation uint64)
|
||||
transportGeneration uint64
|
||||
}
|
||||
|
||||
type webSocketDialContext func(context.Context, string, http.Header) (*websocket.Conn, *http.Response, error)
|
||||
|
||||
// webSocketConnection gives each transport generation its own lifecycle so an
|
||||
// old read or ping loop cannot start using a replacement connection.
|
||||
type webSocketConnection struct {
|
||||
conn *websocket.Conn
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// Logger interface for WebSocket logging
|
||||
@@ -159,6 +176,23 @@ func (ws *WebSocketClient) OnBassUpdated(handler models.TypedEventHandler[*model
|
||||
ws.handlers.OnBassUpdated = handler
|
||||
}
|
||||
|
||||
// OnNameUpdated sets a handler for device name update events.
|
||||
func (ws *WebSocketClient) OnNameUpdated(handler models.TypedEventHandler[*models.NameUpdatedEvent]) {
|
||||
ws.mu.Lock()
|
||||
defer ws.mu.Unlock()
|
||||
|
||||
ws.handlers.OnNameUpdated = handler
|
||||
}
|
||||
|
||||
// OnTransportState observes authoritative connection transitions. Generation
|
||||
// numbers let consumers reject callbacks that arrive out of order.
|
||||
func (ws *WebSocketClient) OnTransportState(handler func(connected bool, generation uint64)) {
|
||||
ws.mu.Lock()
|
||||
defer ws.mu.Unlock()
|
||||
|
||||
ws.transportHandler = handler
|
||||
}
|
||||
|
||||
// OnUnknownEvent sets a handler for unknown events
|
||||
func (ws *WebSocketClient) OnUnknownEvent(handler models.EventHandler) {
|
||||
ws.mu.Lock()
|
||||
@@ -198,13 +232,24 @@ func (ws *WebSocketClient) ConnectWithConfig(config *WebSocketConfig) error {
|
||||
}
|
||||
|
||||
func (ws *WebSocketClient) connectWithConfig(config *WebSocketConfig) error {
|
||||
ws.mu.Lock()
|
||||
defer ws.mu.Unlock()
|
||||
ws.connectMu.Lock()
|
||||
defer ws.connectMu.Unlock()
|
||||
|
||||
ws.mu.RLock()
|
||||
|
||||
if ws.connected {
|
||||
ws.mu.RUnlock()
|
||||
return fmt.Errorf("already connected")
|
||||
}
|
||||
|
||||
ctx := ws.ctx
|
||||
dialContext := ws.dialContext
|
||||
ws.mu.RUnlock()
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fmt.Errorf("WebSocket client is closed: %w", err)
|
||||
}
|
||||
|
||||
// Build WebSocket URL
|
||||
// Parse the base URL to extract just the hostname
|
||||
baseURL, err := url.Parse(ws.client.BaseURL())
|
||||
@@ -220,16 +265,20 @@ func (ws *WebSocketClient) connectWithConfig(config *WebSocketConfig) error {
|
||||
|
||||
ws.logger.Printf("Connecting to %s", sanitizeLog(wsURL.String()))
|
||||
|
||||
// Create dialer with custom buffer sizes and "gabbo" protocol
|
||||
dialer := websocket.Dialer{
|
||||
HandshakeTimeout: 10 * time.Second,
|
||||
ReadBufferSize: config.ReadBufferSize,
|
||||
WriteBufferSize: config.WriteBufferSize,
|
||||
Subprotocols: []string{"gabbo"}, // Required by SoundTouch API
|
||||
if dialContext == nil {
|
||||
// Create dialer with custom buffer sizes and "gabbo" protocol.
|
||||
dialer := websocket.Dialer{
|
||||
HandshakeTimeout: 10 * time.Second,
|
||||
ReadBufferSize: config.ReadBufferSize,
|
||||
WriteBufferSize: config.WriteBufferSize,
|
||||
Subprotocols: []string{"gabbo"}, // Required by SoundTouch API
|
||||
}
|
||||
dialContext = dialer.DialContext
|
||||
}
|
||||
|
||||
// Establish connection
|
||||
conn, resp, err := dialer.DialContext(ws.ctx, wsURL.String(), nil)
|
||||
// Dial without holding the state mutex so shutdown can cancel the context
|
||||
// immediately instead of waiting for the handshake timeout.
|
||||
conn, resp, err := dialContext(ctx, wsURL.String(), nil)
|
||||
if resp != nil && resp.Body != nil {
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
}
|
||||
@@ -238,8 +287,55 @@ func (ws *WebSocketClient) connectWithConfig(config *WebSocketConfig) error {
|
||||
return fmt.Errorf("failed to connect to WebSocket: %w", err)
|
||||
}
|
||||
|
||||
ws.mu.Lock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
ws.mu.Unlock()
|
||||
|
||||
_ = conn.Close()
|
||||
|
||||
return fmt.Errorf("WebSocket client closed during connect: %w", err)
|
||||
}
|
||||
|
||||
if ws.connected {
|
||||
ws.mu.Unlock()
|
||||
|
||||
_ = conn.Close()
|
||||
|
||||
return fmt.Errorf("already connected")
|
||||
}
|
||||
|
||||
connection, transportHandler, transportGeneration := ws.activateConnectionLocked(conn)
|
||||
ws.mu.Unlock()
|
||||
|
||||
notifyTransportState(transportHandler, true, transportGeneration)
|
||||
|
||||
go ws.readLoop(config, connection)
|
||||
go ws.pingLoop(config, connection)
|
||||
|
||||
ws.logger.Printf("Connected to %s", sanitizeLog(wsURL.String()))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ws *WebSocketClient) activateConnectionLocked(
|
||||
conn *websocket.Conn,
|
||||
) (*webSocketConnection, func(bool, uint64), uint64) {
|
||||
if ws.connection != nil {
|
||||
ws.connection.cancel()
|
||||
_ = ws.connection.conn.Close()
|
||||
}
|
||||
|
||||
connectionCtx, connectionCancel := context.WithCancel(ws.ctx)
|
||||
connection := &webSocketConnection{
|
||||
conn: conn,
|
||||
ctx: connectionCtx,
|
||||
cancel: connectionCancel,
|
||||
}
|
||||
|
||||
ws.conn = conn
|
||||
ws.connection = connection
|
||||
ws.connected = true
|
||||
ws.transportGeneration++
|
||||
|
||||
// Extend the read deadline on every pong so the connection survives
|
||||
// quiet periods between speaker events. Without this, the 60-second
|
||||
@@ -250,41 +346,100 @@ func (ws *WebSocketClient) connectWithConfig(config *WebSocketConfig) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
// Start background goroutines for connection management
|
||||
go ws.readLoop(config)
|
||||
go ws.pingLoop(config)
|
||||
|
||||
ws.logger.Printf("Connected to %s", sanitizeLog(wsURL.String()))
|
||||
|
||||
return nil
|
||||
return connection, ws.transportHandler, ws.transportGeneration
|
||||
}
|
||||
|
||||
// Disconnect closes the WebSocket connection
|
||||
func (ws *WebSocketClient) Disconnect() error {
|
||||
ws.mu.Lock()
|
||||
defer ws.mu.Unlock()
|
||||
// Cancel first so an in-progress DialContext wakes without waiting for mu.
|
||||
ws.cancel()
|
||||
|
||||
if !ws.connected {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
ws.mu.Lock()
|
||||
wasConnected := ws.connected
|
||||
|
||||
ws.reconnect = false
|
||||
ws.cancel() // Cancel context to stop goroutines
|
||||
if ws.connection != nil {
|
||||
ws.connection.cancel()
|
||||
ws.connection = nil
|
||||
}
|
||||
|
||||
conn := ws.conn
|
||||
ws.conn = nil
|
||||
ws.connected = false
|
||||
|
||||
var (
|
||||
transportHandler func(bool, uint64)
|
||||
transportGeneration uint64
|
||||
)
|
||||
if wasConnected {
|
||||
ws.transportGeneration++
|
||||
transportHandler = ws.transportHandler
|
||||
transportGeneration = ws.transportGeneration
|
||||
}
|
||||
ws.mu.Unlock()
|
||||
|
||||
if conn != nil {
|
||||
err := conn.Close()
|
||||
|
||||
if ws.conn != nil {
|
||||
err := ws.conn.Close()
|
||||
ws.conn = nil
|
||||
ws.connected = false
|
||||
ws.logger.Printf("Disconnected")
|
||||
|
||||
notifyTransportState(transportHandler, false, transportGeneration)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
ws.connected = false
|
||||
notifyTransportState(transportHandler, false, transportGeneration)
|
||||
|
||||
if !wasConnected {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close permanently stops this client and is idempotent. Device registries use
|
||||
// it when removal races an initial dial or an automatic reconnect.
|
||||
func (ws *WebSocketClient) Close() error {
|
||||
ws.cancel()
|
||||
|
||||
ws.mu.Lock()
|
||||
wasConnected := ws.connected
|
||||
|
||||
ws.reconnect = false
|
||||
if ws.connection != nil {
|
||||
ws.connection.cancel()
|
||||
ws.connection = nil
|
||||
}
|
||||
|
||||
conn := ws.conn
|
||||
ws.conn = nil
|
||||
ws.connected = false
|
||||
|
||||
var (
|
||||
transportHandler func(bool, uint64)
|
||||
transportGeneration uint64
|
||||
)
|
||||
if wasConnected {
|
||||
ws.transportGeneration++
|
||||
transportHandler = ws.transportHandler
|
||||
transportGeneration = ws.transportGeneration
|
||||
}
|
||||
ws.mu.Unlock()
|
||||
|
||||
if conn == nil {
|
||||
notifyTransportState(transportHandler, false, transportGeneration)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
err := conn.Close()
|
||||
|
||||
ws.logger.Printf("Disconnected")
|
||||
notifyTransportState(transportHandler, false, transportGeneration)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// IsConnected returns true if the WebSocket is connected
|
||||
func (ws *WebSocketClient) IsConnected() bool {
|
||||
ws.mu.RLock()
|
||||
@@ -293,45 +448,63 @@ func (ws *WebSocketClient) IsConnected() bool {
|
||||
return ws.connected
|
||||
}
|
||||
|
||||
func (ws *WebSocketClient) shouldReconnect() bool {
|
||||
ws.mu.RLock()
|
||||
defer ws.mu.RUnlock()
|
||||
|
||||
return ws.reconnect
|
||||
}
|
||||
|
||||
func notifyTransportState(handler func(bool, uint64), connected bool, generation uint64) {
|
||||
if handler != nil {
|
||||
handler(connected, generation)
|
||||
}
|
||||
}
|
||||
|
||||
// readLoop continuously reads messages from the WebSocket connection
|
||||
func (ws *WebSocketClient) readLoop(config *WebSocketConfig) {
|
||||
func (ws *WebSocketClient) readLoop(config *WebSocketConfig, connection *webSocketConnection) {
|
||||
defer func() {
|
||||
ws.mu.Lock()
|
||||
if ws.connection != connection {
|
||||
ws.mu.Unlock()
|
||||
connection.cancel()
|
||||
_ = connection.conn.Close()
|
||||
|
||||
ws.connected = false
|
||||
if ws.conn != nil {
|
||||
_ = ws.conn.Close()
|
||||
ws.conn = nil
|
||||
return
|
||||
}
|
||||
|
||||
connection.cancel()
|
||||
ws.connection = nil
|
||||
ws.conn = nil
|
||||
ws.connected = false
|
||||
ws.transportGeneration++
|
||||
transportHandler := ws.transportHandler
|
||||
transportGeneration := ws.transportGeneration
|
||||
reconnect := ws.reconnect
|
||||
ws.mu.Unlock()
|
||||
|
||||
_ = connection.conn.Close()
|
||||
|
||||
notifyTransportState(transportHandler, false, transportGeneration)
|
||||
|
||||
// Attempt reconnection if enabled
|
||||
if ws.reconnect {
|
||||
if reconnect {
|
||||
go ws.attemptReconnect(config)
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ws.ctx.Done():
|
||||
case <-connection.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
ws.mu.RLock()
|
||||
conn := ws.conn
|
||||
ws.mu.RUnlock()
|
||||
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Set read deadline
|
||||
_ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
_ = connection.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
|
||||
// Read message
|
||||
messageType, data, err := conn.ReadMessage()
|
||||
messageType, data, err := connection.conn.ReadMessage()
|
||||
if err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||
ws.logger.Printf("WebSocket read error: %v", err)
|
||||
@@ -351,30 +524,20 @@ func (ws *WebSocketClient) readLoop(config *WebSocketConfig) {
|
||||
}
|
||||
|
||||
// pingLoop sends periodic ping messages to keep the connection alive
|
||||
func (ws *WebSocketClient) pingLoop(config *WebSocketConfig) {
|
||||
func (ws *WebSocketClient) pingLoop(config *WebSocketConfig, connection *webSocketConnection) {
|
||||
ticker := time.NewTicker(config.PingInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ws.ctx.Done():
|
||||
case <-connection.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
ws.mu.RLock()
|
||||
conn := ws.conn
|
||||
connected := ws.connected
|
||||
ws.mu.RUnlock()
|
||||
|
||||
if !connected || conn == nil {
|
||||
active, err := ws.writePing(connection)
|
||||
if !active {
|
||||
return
|
||||
}
|
||||
|
||||
// Set write deadline for ping
|
||||
ws.writeMu.Lock()
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
err := conn.WriteMessage(websocket.PingMessage, nil)
|
||||
ws.writeMu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
ws.logger.Printf("Failed to send ping: %v", err)
|
||||
return
|
||||
@@ -383,10 +546,26 @@ func (ws *WebSocketClient) pingLoop(config *WebSocketConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
func (ws *WebSocketClient) writePing(connection *webSocketConnection) (bool, error) {
|
||||
ws.writeMu.Lock()
|
||||
defer ws.writeMu.Unlock()
|
||||
|
||||
ws.mu.RLock()
|
||||
defer ws.mu.RUnlock()
|
||||
|
||||
if connection.ctx.Err() != nil || ws.connection != connection || !ws.connected {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
_ = connection.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
|
||||
return true, connection.conn.WriteMessage(websocket.PingMessage, nil)
|
||||
}
|
||||
|
||||
// attemptReconnect attempts to reconnect to the WebSocket
|
||||
func (ws *WebSocketClient) attemptReconnect(config *WebSocketConfig) {
|
||||
attempt := 0
|
||||
for ws.reconnect && (config.MaxReconnectAttempts == 0 || attempt < config.MaxReconnectAttempts) {
|
||||
for ws.shouldReconnect() && (config.MaxReconnectAttempts == 0 || attempt < config.MaxReconnectAttempts) {
|
||||
select {
|
||||
case <-ws.ctx.Done():
|
||||
return
|
||||
@@ -533,6 +712,13 @@ func (ws *WebSocketClient) dispatchTypedEventContinued(handlers *models.WebSocke
|
||||
|
||||
return true
|
||||
|
||||
case models.EventTypeNameUpdated:
|
||||
if handlers.OnNameUpdated != nil && event.NameUpdated != nil {
|
||||
handlers.OnNameUpdated(event.NameUpdated)
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
case models.EventTypeRecentsUpdated:
|
||||
return true
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -269,6 +270,139 @@ func TestWebSocketClient_Disconnect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSocketClient_DisconnectWhileDisconnectedStopsReconnect(t *testing.T) {
|
||||
client := NewClientFromHost("192.0.2.10")
|
||||
wsClient := client.NewWebSocketClient(nil)
|
||||
|
||||
if err := wsClient.Disconnect(); err == nil {
|
||||
t.Fatal("Disconnect() while disconnected should retain its compatibility error")
|
||||
}
|
||||
|
||||
wsClient.mu.RLock()
|
||||
reconnect := wsClient.reconnect
|
||||
wsClient.mu.RUnlock()
|
||||
if reconnect {
|
||||
t.Fatal("Disconnect() left reconnect enabled")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-wsClient.ctx.Done():
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("Disconnect() did not cancel the WebSocket context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSocketClient_CloseCancelsInProgressDial(t *testing.T) {
|
||||
client := NewClientFromHost("192.0.2.10")
|
||||
wsClient := client.NewWebSocketClient(nil)
|
||||
dialStarted := make(chan struct{})
|
||||
|
||||
wsClient.dialContext = func(ctx context.Context, _ string, _ http.Header) (*websocket.Conn, *http.Response, error) {
|
||||
close(dialStarted)
|
||||
<-ctx.Done()
|
||||
|
||||
return nil, nil, ctx.Err()
|
||||
}
|
||||
|
||||
connectDone := make(chan error, 1)
|
||||
go func() {
|
||||
connectDone <- wsClient.Connect()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-dialStarted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("WebSocket dial did not start")
|
||||
}
|
||||
|
||||
if err := wsClient.Close(); err != nil {
|
||||
t.Fatalf("Close() failed: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-connectDone:
|
||||
if err == nil {
|
||||
t.Fatal("Connect() succeeded after Close() canceled its dial")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("canceled WebSocket dial did not return")
|
||||
}
|
||||
|
||||
if err := wsClient.Close(); err != nil {
|
||||
t.Fatalf("second Close() was not idempotent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSocketClient_StaleGenerationCannotOwnPing(t *testing.T) {
|
||||
client := NewClientFromHost("192.0.2.10")
|
||||
wsClient := client.NewWebSocketClient(nil)
|
||||
oldCtx, oldCancel := context.WithCancel(context.Background())
|
||||
currentCtx, currentCancel := context.WithCancel(context.Background())
|
||||
defer oldCancel()
|
||||
defer currentCancel()
|
||||
|
||||
oldConnection := &webSocketConnection{ctx: oldCtx, cancel: oldCancel}
|
||||
currentConnection := &webSocketConnection{ctx: currentCtx, cancel: currentCancel}
|
||||
wsClient.mu.Lock()
|
||||
wsClient.connection = currentConnection
|
||||
wsClient.connected = true
|
||||
wsClient.mu.Unlock()
|
||||
|
||||
active, err := wsClient.writePing(oldConnection)
|
||||
if err != nil {
|
||||
t.Fatalf("writePing() for stale generation returned error: %v", err)
|
||||
}
|
||||
if active {
|
||||
t.Fatal("replaced connection generation retained ping ownership")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSocketClient_TransportCallbacksCarryMonotonicGenerations(t *testing.T) {
|
||||
server, messagesChan := setupMockWebSocketServer(t)
|
||||
defer server.Close()
|
||||
defer close(messagesChan)
|
||||
|
||||
client := NewClientFromHost("192.0.2.10")
|
||||
wsClient := client.NewWebSocketClient(nil)
|
||||
serverWebSocketURL := strings.Replace(server.URL, "http://", "ws://", 1)
|
||||
wsClient.dialContext = func(ctx context.Context, _ string, header http.Header) (*websocket.Conn, *http.Response, error) {
|
||||
return websocket.DefaultDialer.DialContext(ctx, serverWebSocketURL, header)
|
||||
}
|
||||
|
||||
type transportState struct {
|
||||
connected bool
|
||||
generation uint64
|
||||
}
|
||||
states := make(chan transportState, 2)
|
||||
wsClient.OnTransportState(func(connected bool, generation uint64) {
|
||||
states <- transportState{connected: connected, generation: generation}
|
||||
})
|
||||
nextState := func() transportState {
|
||||
t.Helper()
|
||||
select {
|
||||
case state := <-states:
|
||||
return state
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for transport callback")
|
||||
return transportState{}
|
||||
}
|
||||
}
|
||||
|
||||
if err := wsClient.Connect(); err != nil {
|
||||
t.Fatalf("Connect() failed: %v", err)
|
||||
}
|
||||
if state := nextState(); !state.connected || state.generation != 1 {
|
||||
t.Fatalf("connected state = %+v, want connected generation 1", state)
|
||||
}
|
||||
|
||||
if err := wsClient.Close(); err != nil {
|
||||
t.Fatalf("Close() failed: %v", err)
|
||||
}
|
||||
if state := nextState(); state.connected || state.generation != 2 {
|
||||
t.Fatalf("disconnected state = %+v, want disconnected generation 2", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSocketClient_HandleMessage(t *testing.T) {
|
||||
client := NewClientFromHost("192.0.2.10")
|
||||
wsClient := client.NewWebSocketClient(&WebSocketConfig{
|
||||
@@ -278,6 +412,7 @@ func TestWebSocketClient_HandleMessage(t *testing.T) {
|
||||
var (
|
||||
nowPlayingEvent *models.NowPlayingUpdatedEvent
|
||||
volumeEvent *models.VolumeUpdatedEvent
|
||||
nameEvent *models.NameUpdatedEvent
|
||||
)
|
||||
|
||||
wsClient.OnNowPlaying(func(event *models.NowPlayingUpdatedEvent) {
|
||||
@@ -288,6 +423,10 @@ func TestWebSocketClient_HandleMessage(t *testing.T) {
|
||||
volumeEvent = event
|
||||
})
|
||||
|
||||
wsClient.OnNameUpdated(func(event *models.NameUpdatedEvent) {
|
||||
nameEvent = event
|
||||
})
|
||||
|
||||
t.Run("HandleNowPlayingEvent", func(t *testing.T) {
|
||||
xmlData := []byte(`<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<updates deviceID="689E19B8BB8A">
|
||||
@@ -343,6 +482,20 @@ func TestWebSocketClient_HandleMessage(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HandleNameEvent", func(t *testing.T) {
|
||||
xmlData := []byte(`<updates deviceID="689E19B8BB8A"><nameUpdated deviceID="689E19B8BB8A"><name>Living Room Left</name></nameUpdated></updates>`)
|
||||
|
||||
wsClient.handleMessage(xmlData)
|
||||
|
||||
if nameEvent == nil {
|
||||
t.Fatal("Name event handler was not called")
|
||||
}
|
||||
|
||||
if nameEvent.DeviceID != "689E19B8BB8A" || nameEvent.Name.Value != "Living Room Left" {
|
||||
t.Errorf("Unexpected name event: %+v", nameEvent)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HandleInvalidXML", func(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
wsClient.logger = logger
|
||||
|
||||
@@ -31,6 +31,7 @@ type ClockDisplay struct {
|
||||
XMLName xml.Name `xml:"clockDisplay"`
|
||||
DeviceID string
|
||||
Enabled bool
|
||||
enabledSet bool
|
||||
Format string // public-facing values: "12", "24", "auto"
|
||||
Brightness int
|
||||
AutoDim bool // not on the device's wire format; preserved for API compat
|
||||
@@ -76,6 +77,7 @@ func mapFromWireFormat(wire string) string {
|
||||
// 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 {
|
||||
c.enabledSet = false
|
||||
applyClockDisplayOuterAttrs(c, start.Attr)
|
||||
|
||||
for {
|
||||
@@ -121,6 +123,7 @@ func applyClockDisplayOuterAttrs(c *ClockDisplay, attrs []xml.Attr) {
|
||||
c.DeviceID = attr.Value
|
||||
case "enabled":
|
||||
c.Enabled = attr.Value == "true"
|
||||
c.enabledSet = true
|
||||
case "format":
|
||||
c.Format = attr.Value
|
||||
case "brightness":
|
||||
@@ -143,6 +146,7 @@ func applyClockConfigAttrs(c *ClockDisplay, attrs []xml.Attr) {
|
||||
c.TimeZone = attr.Value
|
||||
case "userEnable":
|
||||
c.Enabled = attr.Value == "true"
|
||||
c.enabledSet = true
|
||||
case "timeFormat":
|
||||
if mapped := mapFromWireFormat(attr.Value); mapped != "" {
|
||||
c.Format = mapped
|
||||
@@ -170,6 +174,11 @@ func (c *ClockDisplay) IsEnabled() bool {
|
||||
return c.Enabled
|
||||
}
|
||||
|
||||
// HasEnabled reports whether the enabled value was present in the XML response.
|
||||
func (c *ClockDisplay) HasEnabled() bool {
|
||||
return c.enabledSet
|
||||
}
|
||||
|
||||
// GetFormat returns the clock display format (12/24 hour)
|
||||
func (c *ClockDisplay) GetFormat() string {
|
||||
if c.Format == "" {
|
||||
|
||||
@@ -98,6 +98,57 @@ func TestClockDisplay_UnmarshalXML(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClockDisplay_UnmarshalXML_EnabledPresence(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
xmlData string
|
||||
wantEnabled bool
|
||||
wantPresent bool
|
||||
}{
|
||||
{
|
||||
name: "nested present true",
|
||||
xmlData: `<clockDisplay><clockConfig userEnable="true"/></clockDisplay>`,
|
||||
wantEnabled: true,
|
||||
wantPresent: true,
|
||||
},
|
||||
{
|
||||
name: "nested present false",
|
||||
xmlData: `<clockDisplay><clockConfig userEnable="false"/></clockDisplay>`,
|
||||
wantEnabled: false,
|
||||
wantPresent: true,
|
||||
},
|
||||
{
|
||||
name: "legacy present",
|
||||
xmlData: `<clockDisplay enabled="true"></clockDisplay>`,
|
||||
wantEnabled: true,
|
||||
wantPresent: true,
|
||||
},
|
||||
{
|
||||
name: "omitted",
|
||||
xmlData: `<clockDisplay><clockConfig brightnessLevel="70"/></clockDisplay>`,
|
||||
wantEnabled: false,
|
||||
wantPresent: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var got ClockDisplay
|
||||
if err := xml.Unmarshal([]byte(tt.xmlData), &got); err != nil {
|
||||
t.Fatalf("Failed to unmarshal XML: %v", err)
|
||||
}
|
||||
|
||||
if got.Enabled != tt.wantEnabled {
|
||||
t.Errorf("Enabled = %v, want %v", got.Enabled, tt.wantEnabled)
|
||||
}
|
||||
|
||||
if got.HasEnabled() != tt.wantPresent {
|
||||
t.Errorf("HasEnabled() = %v, want %v", got.HasEnabled(), tt.wantPresent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClockDisplay_IsEnabled(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
+102
-1
@@ -1,6 +1,10 @@
|
||||
package models
|
||||
|
||||
import "encoding/xml"
|
||||
import (
|
||||
"encoding/xml"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Group represents a stereo pair of two ST10 SoundTouch speakers.
|
||||
type Group struct {
|
||||
@@ -32,3 +36,100 @@ type GroupRole struct {
|
||||
Role string `xml:"role"`
|
||||
IPAddress string `xml:"ipAddress,omitempty"`
|
||||
}
|
||||
|
||||
// SameGroupRoles reports whether two role slices describe the same stereo
|
||||
// pair topology: matching length, no duplicate Role value on either side, and
|
||||
// every role paired by Role with equal DeviceID and IPAddress. It tolerates
|
||||
// any role count rather than assuming exactly LEFT/RIGHT.
|
||||
//
|
||||
// This is the shared core behind both pkg/stereopair's and
|
||||
// pkg/service/datastore's topology-equality checks -- they used to compare
|
||||
// IPAddress independently (one via net.ParseIP, one via plain string
|
||||
// equality), which could disagree about whether two differently-formatted
|
||||
// but equal addresses matched. See i655 code-review finding #10.
|
||||
func SameGroupRoles(a, b []GroupRole) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
|
||||
byRole := make(map[string]GroupRole, len(a))
|
||||
for _, role := range a {
|
||||
if _, duplicate := byRole[role.Role]; duplicate {
|
||||
return false
|
||||
}
|
||||
|
||||
byRole[role.Role] = role
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(b))
|
||||
for _, role := range b {
|
||||
if _, duplicate := seen[role.Role]; duplicate {
|
||||
return false
|
||||
}
|
||||
|
||||
seen[role.Role] = struct{}{}
|
||||
|
||||
other, ok := byRole[role.Role]
|
||||
if !ok || other.DeviceID != role.DeviceID || !sameRoleIPAddress(other.IPAddress, role.IPAddress) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// sameRoleIPAddress treats identical strings (including two empty/unset
|
||||
// addresses) as equal, and otherwise falls back to parsed-IP equality so two
|
||||
// differently-formatted representations of the same address still match. It
|
||||
// never treats one populated and one empty/unparsable address as a match.
|
||||
func sameRoleIPAddress(a, b string) bool {
|
||||
if a == b {
|
||||
return true
|
||||
}
|
||||
|
||||
parsedA, parsedB := net.ParseIP(a), net.ParseIP(b)
|
||||
|
||||
return parsedA != nil && parsedB != nil && parsedA.Equal(parsedB)
|
||||
}
|
||||
|
||||
// SameGroup reports whether left and right describe the same stereo-pair
|
||||
// configuration, comparing role assignments by device ID rather than by
|
||||
// slice order. The device's own /getGroup response and its groupUpdated
|
||||
// WebSocket event both populate Roles.Roles directly from XML unmarshaling
|
||||
// in wire order, so a polled read and a pushed event for the identical pair
|
||||
// are not guaranteed to list roles in the same order -- comparing with
|
||||
// reflect.DeepEqual (order-sensitive) would then report a spurious change
|
||||
// even though nothing about the pair actually changed. Two nil Groups are
|
||||
// equal; exactly one nil is not.
|
||||
//
|
||||
// SameGroup stays a distinct, IP-agnostic implementation from
|
||||
// SameGroupRoles: its callers (event/status projection) need
|
||||
// order-independence without caring about IP, and adding an IP check here
|
||||
// would change that contract.
|
||||
func SameGroup(left, right *Group) bool {
|
||||
if left == nil && right == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
if left == nil || right == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if left.ID != right.ID || left.MasterDeviceID != right.MasterDeviceID ||
|
||||
len(left.Roles.Roles) != len(right.Roles.Roles) {
|
||||
return false
|
||||
}
|
||||
|
||||
rightRoles := make(map[string]string, len(right.Roles.Roles))
|
||||
for _, role := range right.Roles.Roles {
|
||||
rightRoles[strings.TrimSpace(role.DeviceID)] = strings.ToUpper(strings.TrimSpace(role.Role))
|
||||
}
|
||||
|
||||
for _, role := range left.Roles.Roles {
|
||||
if rightRoles[strings.TrimSpace(role.DeviceID)] != strings.ToUpper(strings.TrimSpace(role.Role)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSameGroupRoles(t *testing.T) {
|
||||
base := []GroupRole{
|
||||
{DeviceID: "LEFT-ID", Role: "LEFT", IPAddress: "192.0.2.10"},
|
||||
{DeviceID: "RIGHT-ID", Role: "RIGHT", IPAddress: "192.0.2.11"},
|
||||
}
|
||||
|
||||
t.Run("identical roles match", func(t *testing.T) {
|
||||
if !SameGroupRoles(base, append([]GroupRole(nil), base...)) {
|
||||
t.Fatal("identical roles reported as different")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("role order does not matter", func(t *testing.T) {
|
||||
reordered := []GroupRole{base[1], base[0]}
|
||||
if !SameGroupRoles(base, reordered) {
|
||||
t.Fatal("reordered roles reported as different")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("differently formatted equal IP matches", func(t *testing.T) {
|
||||
other := append([]GroupRole(nil), base...)
|
||||
other[0].IPAddress = "::ffff:192.0.2.10" // IPv4-mapped IPv6 form of the same address
|
||||
if !SameGroupRoles(base, other) {
|
||||
t.Fatal("differently-formatted equal IP addresses reported as different")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("both empty IP addresses match", func(t *testing.T) {
|
||||
noIP := []GroupRole{
|
||||
{DeviceID: "LEFT-ID", Role: "LEFT"},
|
||||
{DeviceID: "RIGHT-ID", Role: "RIGHT"},
|
||||
}
|
||||
if !SameGroupRoles(noIP, append([]GroupRole(nil), noIP...)) {
|
||||
t.Fatal("two roles with unset IP addresses reported as different")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different IP does not match", func(t *testing.T) {
|
||||
other := append([]GroupRole(nil), base...)
|
||||
other[0].IPAddress = "198.51.100.10"
|
||||
if SameGroupRoles(base, other) {
|
||||
t.Fatal("different IP addresses reported as same")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("populated vs empty IP does not match", func(t *testing.T) {
|
||||
other := append([]GroupRole(nil), base...)
|
||||
other[0].IPAddress = ""
|
||||
if SameGroupRoles(base, other) {
|
||||
t.Fatal("populated vs empty IP address reported as same")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different DeviceID does not match", func(t *testing.T) {
|
||||
other := append([]GroupRole(nil), base...)
|
||||
other[0].DeviceID = "OTHER-ID"
|
||||
if SameGroupRoles(base, other) {
|
||||
t.Fatal("different DeviceID reported as same")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different Role does not match", func(t *testing.T) {
|
||||
other := append([]GroupRole(nil), base...)
|
||||
other[0].Role = "RIGHT"
|
||||
if SameGroupRoles(base, other) {
|
||||
t.Fatal("mismatched Role reported as same")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different length does not match", func(t *testing.T) {
|
||||
if SameGroupRoles(base, base[:1]) {
|
||||
t.Fatal("different-length role slices reported as same")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duplicate role on either side does not match", func(t *testing.T) {
|
||||
duplicateA := []GroupRole{base[0], base[0]}
|
||||
duplicateB := []GroupRole{base[1], base[1]}
|
||||
if SameGroupRoles(duplicateA, base) {
|
||||
t.Fatal("duplicate role in first argument reported as same")
|
||||
}
|
||||
if SameGroupRoles(base, duplicateB) {
|
||||
t.Fatal("duplicate role in second argument reported as same")
|
||||
}
|
||||
})
|
||||
}
|
||||
+35
-23
@@ -8,29 +8,30 @@ import (
|
||||
|
||||
// NowPlaying represents the current playback information from /now_playing endpoint
|
||||
type NowPlaying struct {
|
||||
XMLName xml.Name `xml:"nowPlaying"`
|
||||
DeviceID string `xml:"deviceID,attr"`
|
||||
Source string `xml:"source,attr"`
|
||||
SourceAccount string `xml:"sourceAccount,attr,omitempty"`
|
||||
ContentItem *ContentItem `xml:"ContentItem,omitempty"`
|
||||
Track string `xml:"track,omitempty"`
|
||||
Artist string `xml:"artist,omitempty"`
|
||||
Album string `xml:"album,omitempty"`
|
||||
StationName string `xml:"stationName,omitempty"`
|
||||
Art *Art `xml:"art,omitempty"`
|
||||
Time *Time `xml:"time,omitempty"`
|
||||
SkipEnabled *SkipEnabled `xml:"skipEnabled,omitempty"`
|
||||
FavoriteEnabled *FavoriteEnabled `xml:"favoriteEnabled,omitempty"`
|
||||
PlayStatus PlayStatus `xml:"playStatus,omitempty"`
|
||||
ShuffleSetting ShuffleSetting `xml:"shuffleSetting,omitempty"`
|
||||
RepeatSetting RepeatSetting `xml:"repeatSetting,omitempty"`
|
||||
SkipPreviousEnabled *SkipPreviousEnabled `xml:"skipPreviousEnabled,omitempty"`
|
||||
SeekSupported *SeekSupported `xml:"seekSupported,omitempty"`
|
||||
StreamType string `xml:"streamType,omitempty"`
|
||||
TrackID string `xml:"trackID,omitempty"`
|
||||
Position *Position `xml:"position,omitempty"`
|
||||
Description string `xml:"description,omitempty"`
|
||||
StationLocation string `xml:"stationLocation,omitempty"`
|
||||
XMLName xml.Name `xml:"nowPlaying"`
|
||||
DeviceID string `xml:"deviceID,attr"`
|
||||
Source string `xml:"source,attr"`
|
||||
SourceAccount string `xml:"sourceAccount,attr,omitempty"`
|
||||
ContentItem *ContentItem `xml:"ContentItem,omitempty"`
|
||||
Track string `xml:"track,omitempty"`
|
||||
Artist string `xml:"artist,omitempty"`
|
||||
Album string `xml:"album,omitempty"`
|
||||
StationName string `xml:"stationName,omitempty"`
|
||||
Art *Art `xml:"art,omitempty"`
|
||||
Time *Time `xml:"time,omitempty"`
|
||||
SkipEnabled *SkipEnabled `xml:"skipEnabled,omitempty"`
|
||||
FavoriteEnabled *FavoriteEnabled `xml:"favoriteEnabled,omitempty"`
|
||||
PlayStatus PlayStatus `xml:"playStatus,omitempty"`
|
||||
ShuffleSetting ShuffleSetting `xml:"shuffleSetting,omitempty"`
|
||||
RepeatSetting RepeatSetting `xml:"repeatSetting,omitempty"`
|
||||
SkipPreviousEnabled *SkipPreviousEnabled `xml:"skipPreviousEnabled,omitempty"`
|
||||
SeekSupported *SeekSupported `xml:"seekSupported,omitempty"`
|
||||
StreamType string `xml:"streamType,omitempty"`
|
||||
TrackID string `xml:"trackID,omitempty"`
|
||||
Position *Position `xml:"position,omitempty"`
|
||||
Description string `xml:"description,omitempty"`
|
||||
StationLocation string `xml:"stationLocation,omitempty"`
|
||||
ConnectionStatusInfo *ConnectionStatusInfo `xml:"connectionStatusInfo,omitempty"`
|
||||
}
|
||||
|
||||
// ContentItem represents metadata about the currently playing content
|
||||
@@ -44,6 +45,17 @@ type ContentItem struct {
|
||||
ContainerArt string `xml:"containerArt,omitempty"`
|
||||
}
|
||||
|
||||
// ConnectionStatusInfo describes the active Bluetooth connection state.
|
||||
type ConnectionStatusInfo struct {
|
||||
DeviceName string `xml:"deviceName,attr"`
|
||||
Status string `xml:"status,attr"`
|
||||
}
|
||||
|
||||
// IsDiscoverable reports whether the speaker is advertising for pairing.
|
||||
func (c *ConnectionStatusInfo) IsDiscoverable() bool {
|
||||
return c != nil && c.Status == "DISCOVERABLE"
|
||||
}
|
||||
|
||||
// Art represents album artwork information
|
||||
type Art struct {
|
||||
ArtImageStatus string `xml:"artImageStatus,attr"`
|
||||
|
||||
@@ -307,6 +307,35 @@ func TestNowPlaying_UnmarshalXML(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNowPlaying_BluetoothConnectionStatusInfo(t *testing.T) {
|
||||
input := `<nowPlaying source="BLUETOOTH"><connectionStatusInfo deviceName="Phone" status="DISCOVERABLE"></connectionStatusInfo></nowPlaying>`
|
||||
var nowPlaying NowPlaying
|
||||
if err := xml.Unmarshal([]byte(input), &nowPlaying); err != nil {
|
||||
t.Fatalf("xml.Unmarshal(): %v", err)
|
||||
}
|
||||
if nowPlaying.ConnectionStatusInfo == nil {
|
||||
t.Fatal("ConnectionStatusInfo is nil")
|
||||
}
|
||||
if nowPlaying.ConnectionStatusInfo.DeviceName != "Phone" {
|
||||
t.Fatalf("DeviceName = %q, want Phone", nowPlaying.ConnectionStatusInfo.DeviceName)
|
||||
}
|
||||
if nowPlaying.ConnectionStatusInfo.Status != "DISCOVERABLE" {
|
||||
t.Fatalf("Status = %q, want DISCOVERABLE", nowPlaying.ConnectionStatusInfo.Status)
|
||||
}
|
||||
if !nowPlaying.ConnectionStatusInfo.IsDiscoverable() {
|
||||
t.Fatal("IsDiscoverable() = false, want true")
|
||||
}
|
||||
|
||||
nowPlaying.ConnectionStatusInfo.Status = "CONNECTED"
|
||||
if nowPlaying.ConnectionStatusInfo.IsDiscoverable() {
|
||||
t.Fatal("IsDiscoverable() = true for CONNECTED")
|
||||
}
|
||||
var absent *ConnectionStatusInfo
|
||||
if absent.IsDiscoverable() {
|
||||
t.Fatal("nil IsDiscoverable() = true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNowPlaying_RadioStation(t *testing.T) {
|
||||
xmlData := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<nowPlaying deviceID="AABBCCDDEEFF" source="TUNEIN">
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SystemTimeout is the power-saving setting returned by /systemtimeout.
|
||||
type SystemTimeout struct {
|
||||
XMLName xml.Name `xml:"systemtimeout"`
|
||||
PowerSavingEnabled bool `xml:"powersaving_enabled"`
|
||||
}
|
||||
|
||||
// Validate checks whether the update model is present.
|
||||
func (s *SystemTimeout) Validate() error {
|
||||
if s == nil {
|
||||
return fmt.Errorf("system timeout is nil")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnmarshalXML rejects responses that omit the required power-saving value.
|
||||
func (s *SystemTimeout) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
if start.Name.Local != "systemtimeout" {
|
||||
return fmt.Errorf("expected systemtimeout element, got %s", start.Name.Local)
|
||||
}
|
||||
|
||||
var wire struct {
|
||||
PowerSavingEnabled *bool `xml:"powersaving_enabled"`
|
||||
}
|
||||
if err := d.DecodeElement(&wire, &start); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if wire.PowerSavingEnabled == nil {
|
||||
return fmt.Errorf("systemtimeout is missing powersaving_enabled")
|
||||
}
|
||||
|
||||
s.XMLName = start.Name
|
||||
s.PowerSavingEnabled = *wire.PowerSavingEnabled
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RebroadcastLatencyModeValue is a firmware-supported rebroadcast timing mode.
|
||||
type RebroadcastLatencyModeValue string
|
||||
|
||||
const (
|
||||
// RebroadcastLatencySyncToRoom prioritizes the selected room for video sync.
|
||||
RebroadcastLatencySyncToRoom RebroadcastLatencyModeValue = "SYNC_TO_ROOM"
|
||||
// RebroadcastLatencySyncToZone prioritizes synchronization across the zone.
|
||||
RebroadcastLatencySyncToZone RebroadcastLatencyModeValue = "SYNC_TO_ZONE"
|
||||
)
|
||||
|
||||
// Validate rejects values that the SoundTouch firmware does not understand.
|
||||
func (m RebroadcastLatencyModeValue) Validate() error {
|
||||
switch m {
|
||||
case RebroadcastLatencySyncToRoom, RebroadcastLatencySyncToZone:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unknown rebroadcast latency mode %q", m)
|
||||
}
|
||||
}
|
||||
|
||||
// RebroadcastLatencyMode is the setting returned by /rebroadcastlatencymode.
|
||||
// Controllable is response metadata and is not included in update requests.
|
||||
type RebroadcastLatencyMode struct {
|
||||
XMLName xml.Name `xml:"rebroadcastlatencymode"`
|
||||
Mode RebroadcastLatencyModeValue `xml:"mode,attr"`
|
||||
Controllable bool `xml:"controllable,attr"`
|
||||
}
|
||||
|
||||
// Validate checks whether the reported mode is supported.
|
||||
func (r *RebroadcastLatencyMode) Validate() error {
|
||||
if r == nil {
|
||||
return fmt.Errorf("rebroadcast latency mode is nil")
|
||||
}
|
||||
|
||||
return r.Mode.Validate()
|
||||
}
|
||||
|
||||
// UnmarshalXML rejects responses that omit either required attribute.
|
||||
func (r *RebroadcastLatencyMode) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
if start.Name.Local != "rebroadcastlatencymode" {
|
||||
return fmt.Errorf("expected rebroadcastlatencymode element, got %s", start.Name.Local)
|
||||
}
|
||||
|
||||
var wire struct {
|
||||
Mode *RebroadcastLatencyModeValue `xml:"mode,attr"`
|
||||
Controllable *bool `xml:"controllable,attr"`
|
||||
}
|
||||
if err := d.DecodeElement(&wire, &start); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if wire.Mode == nil {
|
||||
return fmt.Errorf("rebroadcastlatencymode is missing mode")
|
||||
}
|
||||
|
||||
if wire.Controllable == nil {
|
||||
return fmt.Errorf("rebroadcastlatencymode is missing controllable")
|
||||
}
|
||||
|
||||
if err := wire.Mode.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.XMLName = start.Name
|
||||
r.Mode = *wire.Mode
|
||||
r.Controllable = *wire.Controllable
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RebroadcastLatencyModeRequest is the update body accepted by the firmware.
|
||||
type RebroadcastLatencyModeRequest struct {
|
||||
XMLName xml.Name `xml:"rebroadcastlatencymode"`
|
||||
Mode RebroadcastLatencyModeValue `xml:"mode,attr"`
|
||||
}
|
||||
|
||||
// Validate checks whether the requested latency mode is known.
|
||||
func (r *RebroadcastLatencyModeRequest) Validate() error {
|
||||
if r == nil {
|
||||
return fmt.Errorf("rebroadcast latency mode request is nil")
|
||||
}
|
||||
|
||||
return r.Mode.Validate()
|
||||
}
|
||||
|
||||
// LanguageCode is a SoundTouch system-language identifier.
|
||||
type LanguageCode int
|
||||
|
||||
// Supported system language codes match the set exposed by Stockholm.
|
||||
const (
|
||||
LanguageDanish LanguageCode = 1
|
||||
LanguageGerman LanguageCode = 2
|
||||
LanguageEnglish LanguageCode = 3
|
||||
LanguageSpanish LanguageCode = 4
|
||||
LanguageFrench LanguageCode = 5
|
||||
LanguageItalian LanguageCode = 6
|
||||
LanguageDutch LanguageCode = 7
|
||||
LanguageSwedish LanguageCode = 8
|
||||
LanguageJapanese LanguageCode = 9
|
||||
LanguageSimplifiedChinese LanguageCode = 10
|
||||
LanguageTraditionalChinese LanguageCode = 11
|
||||
LanguageKorean LanguageCode = 12
|
||||
LanguageThai LanguageCode = 13
|
||||
LanguageCzech LanguageCode = 15
|
||||
LanguageFinnish LanguageCode = 16
|
||||
LanguageGreek LanguageCode = 17
|
||||
LanguageNorwegian LanguageCode = 18
|
||||
LanguagePolish LanguageCode = 19
|
||||
LanguagePortuguese LanguageCode = 20
|
||||
LanguageRomanian LanguageCode = 21
|
||||
LanguageRussian LanguageCode = 22
|
||||
LanguageSlovenian LanguageCode = 23
|
||||
LanguageTurkish LanguageCode = 24
|
||||
LanguageHungarian LanguageCode = 25
|
||||
)
|
||||
|
||||
var knownSystemLanguageNames = map[LanguageCode]string{
|
||||
LanguageDanish: "Dansk",
|
||||
LanguageGerman: "Deutsch",
|
||||
LanguageEnglish: "English",
|
||||
LanguageSpanish: "Español",
|
||||
LanguageFrench: "Français",
|
||||
LanguageItalian: "Italiano",
|
||||
LanguageDutch: "Nederlands",
|
||||
LanguageSwedish: "Svenska",
|
||||
LanguageJapanese: "日本語",
|
||||
LanguageSimplifiedChinese: "简体中文",
|
||||
LanguageTraditionalChinese: "繁體中文",
|
||||
LanguageKorean: "한국어",
|
||||
LanguageThai: "ไทย",
|
||||
LanguageCzech: "Čeština",
|
||||
LanguageFinnish: "Suomi",
|
||||
LanguageGreek: "Ελληνικά",
|
||||
LanguageNorwegian: "Norsk",
|
||||
LanguagePolish: "Polski",
|
||||
LanguagePortuguese: "Português",
|
||||
LanguageRomanian: "Română",
|
||||
LanguageRussian: "Русский",
|
||||
LanguageSlovenian: "Slovenščina",
|
||||
LanguageTurkish: "Türkçe",
|
||||
LanguageHungarian: "Magyar",
|
||||
}
|
||||
|
||||
// SystemLanguageNames returns the language labels and codes used by Stockholm.
|
||||
// Each call returns a copy so callers cannot mutate shared validation state.
|
||||
func SystemLanguageNames() map[LanguageCode]string {
|
||||
names := make(map[LanguageCode]string, len(knownSystemLanguageNames))
|
||||
for code, name := range knownSystemLanguageNames {
|
||||
names[code] = name
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
// Validate rejects language codes that Stockholm does not offer for writes.
|
||||
func (l LanguageCode) Validate() error {
|
||||
if _, ok := knownSystemLanguageNames[l]; !ok {
|
||||
return fmt.Errorf("unknown system language code %d", l)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SystemLanguage is the integer value read from or written to /language.
|
||||
// Unknown values are retained when reading so newer firmware remains usable.
|
||||
type SystemLanguage struct {
|
||||
XMLName xml.Name `xml:"sysLanguage"`
|
||||
Code LanguageCode `xml:",chardata"`
|
||||
}
|
||||
|
||||
// UnmarshalXML retains unknown integer codes for forward compatibility while
|
||||
// rejecting a response that omits the language value entirely.
|
||||
func (l *SystemLanguage) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
if start.Name.Local != "sysLanguage" {
|
||||
return fmt.Errorf("expected sysLanguage element, got %s", start.Name.Local)
|
||||
}
|
||||
|
||||
var raw string
|
||||
if err := d.DecodeElement(&raw, &start); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return fmt.Errorf("sysLanguage is missing its language code")
|
||||
}
|
||||
|
||||
code, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid sysLanguage code %q: %w", raw, err)
|
||||
}
|
||||
|
||||
l.XMLName = start.Name
|
||||
l.Code = LanguageCode(code)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate checks whether this language can be sent to the speaker.
|
||||
func (l *SystemLanguage) Validate() error {
|
||||
if l == nil {
|
||||
return fmt.Errorf("system language is nil")
|
||||
}
|
||||
|
||||
return l.Code.Validate()
|
||||
}
|
||||
|
||||
// BluetoothInfo is the speaker Bluetooth adapter information.
|
||||
type BluetoothInfo struct {
|
||||
XMLName xml.Name `xml:"BluetoothInfo"`
|
||||
BluetoothMACAddress string `xml:"BluetoothMACAddress,attr"`
|
||||
}
|
||||
|
||||
// Validate requires the adapter address returned by the firmware.
|
||||
func (b *BluetoothInfo) Validate() error {
|
||||
if b == nil {
|
||||
return fmt.Errorf("bluetooth info is nil")
|
||||
}
|
||||
|
||||
if b.BluetoothMACAddress == "" {
|
||||
return fmt.Errorf("bluetooth info is missing BluetoothMACAddress")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnmarshalXML rejects responses without the adapter address.
|
||||
func (b *BluetoothInfo) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
if start.Name.Local != "BluetoothInfo" {
|
||||
return fmt.Errorf("expected BluetoothInfo element, got %s", start.Name.Local)
|
||||
}
|
||||
|
||||
var wire struct {
|
||||
BluetoothMACAddress string `xml:"BluetoothMACAddress,attr"`
|
||||
}
|
||||
if err := d.DecodeElement(&wire, &start); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b.XMLName = start.Name
|
||||
b.BluetoothMACAddress = wire.BluetoothMACAddress
|
||||
|
||||
return b.Validate()
|
||||
}
|
||||
|
||||
// SourceRenameRequest is the exact update body accepted by /nameSource.
|
||||
type SourceRenameRequest struct {
|
||||
XMLName xml.Name `xml:"ContentItem"`
|
||||
Source string `xml:"source,attr"`
|
||||
SourceAccount string `xml:"sourceAccount,attr,omitempty"`
|
||||
ItemName string `xml:"itemName"`
|
||||
}
|
||||
|
||||
// Validate requires the source identity and replacement display name.
|
||||
func (r *SourceRenameRequest) Validate() error {
|
||||
if r == nil {
|
||||
return fmt.Errorf("source rename request is nil")
|
||||
}
|
||||
|
||||
if r.Source == "" {
|
||||
return fmt.Errorf("source is required")
|
||||
}
|
||||
|
||||
if r.ItemName == "" {
|
||||
return fmt.Errorf("item name is required")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSystemTimeoutXML(t *testing.T) {
|
||||
for _, enabled := range []bool{true, false} {
|
||||
input := `<systemtimeout><powersaving_enabled>` + map[bool]string{true: "true", false: "false"}[enabled] + `</powersaving_enabled></systemtimeout>`
|
||||
var setting SystemTimeout
|
||||
if err := xml.Unmarshal([]byte(input), &setting); err != nil {
|
||||
t.Fatalf("xml.Unmarshal(%q): %v", input, err)
|
||||
}
|
||||
if setting.PowerSavingEnabled != enabled {
|
||||
t.Fatalf("PowerSavingEnabled = %t, want %t", setting.PowerSavingEnabled, enabled)
|
||||
}
|
||||
|
||||
got, err := xml.Marshal(setting)
|
||||
if err != nil {
|
||||
t.Fatalf("xml.Marshal(): %v", err)
|
||||
}
|
||||
if string(got) != input {
|
||||
t.Fatalf("xml.Marshal() = %q, want %q", got, input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemTimeoutRejectsInvalidXML(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
`<systemtimeout/>`,
|
||||
`<systemtimeout><powersaving_enabled>maybe</powersaving_enabled></systemtimeout>`,
|
||||
`<wrong><powersaving_enabled>true</powersaving_enabled></wrong>`,
|
||||
} {
|
||||
var setting SystemTimeout
|
||||
if err := xml.Unmarshal([]byte(input), &setting); err == nil {
|
||||
t.Errorf("xml.Unmarshal(%q) unexpectedly succeeded", input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRebroadcastLatencyModeXML(t *testing.T) {
|
||||
input := `<rebroadcastlatencymode mode="SYNC_TO_ZONE" controllable="true"></rebroadcastlatencymode>`
|
||||
var setting RebroadcastLatencyMode
|
||||
if err := xml.Unmarshal([]byte(input), &setting); err != nil {
|
||||
t.Fatalf("xml.Unmarshal(): %v", err)
|
||||
}
|
||||
if setting.Mode != RebroadcastLatencySyncToZone || !setting.Controllable {
|
||||
t.Fatalf("setting = %#v", setting)
|
||||
}
|
||||
if err := setting.Validate(); err != nil {
|
||||
t.Fatalf("Validate(): %v", err)
|
||||
}
|
||||
|
||||
request := RebroadcastLatencyModeRequest{Mode: RebroadcastLatencySyncToRoom}
|
||||
if err := request.Validate(); err != nil {
|
||||
t.Fatalf("Validate(): %v", err)
|
||||
}
|
||||
got, err := xml.Marshal(request)
|
||||
if err != nil {
|
||||
t.Fatalf("xml.Marshal(): %v", err)
|
||||
}
|
||||
if want := `<rebroadcastlatencymode mode="SYNC_TO_ROOM"></rebroadcastlatencymode>`; string(got) != want {
|
||||
t.Fatalf("xml.Marshal() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRebroadcastLatencyModeValidation(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
`<rebroadcastlatencymode controllable="true"/>`,
|
||||
`<rebroadcastlatencymode mode="SYNC_TO_ROOM"/>`,
|
||||
`<rebroadcastlatencymode mode="OTHER" controllable="true"/>`,
|
||||
`<rebroadcastlatencymode mode="SYNC_TO_ROOM" controllable="maybe"/>`,
|
||||
} {
|
||||
var setting RebroadcastLatencyMode
|
||||
if err := xml.Unmarshal([]byte(input), &setting); err == nil {
|
||||
t.Errorf("xml.Unmarshal(%q) unexpectedly succeeded", input)
|
||||
}
|
||||
}
|
||||
|
||||
request := RebroadcastLatencyModeRequest{Mode: "OTHER"}
|
||||
if err := request.Validate(); err == nil {
|
||||
t.Error("Validate() unexpectedly accepted an unknown mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemLanguageCodesMatchStockholm(t *testing.T) {
|
||||
want := map[LanguageCode]string{
|
||||
1: "Dansk", 2: "Deutsch", 3: "English", 4: "Español", 5: "Français",
|
||||
6: "Italiano", 7: "Nederlands", 8: "Svenska", 9: "日本語", 10: "简体中文",
|
||||
11: "繁體中文", 12: "한국어", 13: "ไทย", 15: "Čeština", 16: "Suomi",
|
||||
17: "Ελληνικά", 18: "Norsk", 19: "Polski", 20: "Português", 21: "Română",
|
||||
22: "Русский", 23: "Slovenščina", 24: "Türkçe", 25: "Magyar",
|
||||
}
|
||||
if !reflect.DeepEqual(SystemLanguageNames(), want) {
|
||||
t.Fatalf("SystemLanguageNames() = %#v, want %#v", SystemLanguageNames(), want)
|
||||
}
|
||||
if LanguageEnglish != 3 || LanguageCzech != 15 {
|
||||
t.Fatalf("English/Czech codes = %d/%d, want 3/15", LanguageEnglish, LanguageCzech)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemLanguageReadAndWriteValidation(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
input string
|
||||
code LanguageCode
|
||||
}{
|
||||
{`<sysLanguage>15</sysLanguage>`, LanguageCzech},
|
||||
{`<sysLanguage>99</sysLanguage>`, 99},
|
||||
} {
|
||||
var language SystemLanguage
|
||||
if err := xml.Unmarshal([]byte(test.input), &language); err != nil {
|
||||
t.Fatalf("xml.Unmarshal(%q): %v", test.input, err)
|
||||
}
|
||||
if language.Code != test.code {
|
||||
t.Fatalf("Code = %d, want %d", language.Code, test.code)
|
||||
}
|
||||
}
|
||||
|
||||
known := SystemLanguage{Code: LanguageEnglish}
|
||||
if err := known.Validate(); err != nil {
|
||||
t.Fatalf("Validate(English): %v", err)
|
||||
}
|
||||
got, err := xml.Marshal(known)
|
||||
if err != nil {
|
||||
t.Fatalf("xml.Marshal(): %v", err)
|
||||
}
|
||||
if want := `<sysLanguage>3</sysLanguage>`; string(got) != want {
|
||||
t.Fatalf("xml.Marshal() = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
unknown := SystemLanguage{Code: 99}
|
||||
if err := unknown.Validate(); err == nil {
|
||||
t.Error("Validate() unexpectedly accepted unknown code 99")
|
||||
}
|
||||
names := SystemLanguageNames()
|
||||
names[99] = "Future language"
|
||||
if err := unknown.Validate(); err == nil {
|
||||
t.Error("Validate() was widened by a caller-modified display map")
|
||||
}
|
||||
if _, ok := SystemLanguageNames()[99]; ok {
|
||||
t.Error("SystemLanguageNames() returned shared mutable state")
|
||||
}
|
||||
var missing SystemLanguage
|
||||
if err := xml.Unmarshal([]byte(`<sysLanguage/>`), &missing); err == nil {
|
||||
t.Error("xml.Unmarshal() unexpectedly accepted a missing language code")
|
||||
}
|
||||
var malformed SystemLanguage
|
||||
if err := xml.Unmarshal([]byte(`<sysLanguage>English</sysLanguage>`), &malformed); err == nil {
|
||||
t.Error("xml.Unmarshal() unexpectedly accepted a non-integer language")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBluetoothInfoXML(t *testing.T) {
|
||||
input := `<BluetoothInfo BluetoothMACAddress="AABBCCDDEEFF"></BluetoothInfo>`
|
||||
var info BluetoothInfo
|
||||
if err := xml.Unmarshal([]byte(input), &info); err != nil {
|
||||
t.Fatalf("xml.Unmarshal(): %v", err)
|
||||
}
|
||||
if info.BluetoothMACAddress != "AABBCCDDEEFF" {
|
||||
t.Fatalf("BluetoothMACAddress = %q", info.BluetoothMACAddress)
|
||||
}
|
||||
if err := info.Validate(); err != nil {
|
||||
t.Fatalf("Validate(): %v", err)
|
||||
}
|
||||
if err := (*BluetoothInfo)(nil).Validate(); err == nil {
|
||||
t.Fatal("nil Validate() unexpectedly succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceRenameRequestXMLAndValidation(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
request SourceRenameRequest
|
||||
want string
|
||||
}{
|
||||
{
|
||||
request: SourceRenameRequest{Source: "AUX", SourceAccount: "AUX1", ItemName: "Turntable"},
|
||||
want: `<ContentItem source="AUX" sourceAccount="AUX1"><itemName>Turntable</itemName></ContentItem>`,
|
||||
},
|
||||
{
|
||||
request: SourceRenameRequest{Source: "BLUETOOTH", ItemName: "Phone"},
|
||||
want: `<ContentItem source="BLUETOOTH"><itemName>Phone</itemName></ContentItem>`,
|
||||
},
|
||||
} {
|
||||
if err := test.request.Validate(); err != nil {
|
||||
t.Fatalf("Validate(): %v", err)
|
||||
}
|
||||
got, err := xml.Marshal(test.request)
|
||||
if err != nil {
|
||||
t.Fatalf("xml.Marshal(): %v", err)
|
||||
}
|
||||
if string(got) != test.want {
|
||||
t.Fatalf("xml.Marshal() = %q, want %q", got, test.want)
|
||||
}
|
||||
}
|
||||
|
||||
for _, request := range []*SourceRenameRequest{
|
||||
nil,
|
||||
{ItemName: "Name"},
|
||||
{Source: "AUX"},
|
||||
} {
|
||||
if err := request.Validate(); err == nil {
|
||||
t.Errorf("Validate(%#v) unexpectedly succeeded", request)
|
||||
}
|
||||
}
|
||||
}
|
||||
+221
-84
@@ -99,6 +99,18 @@ func isTuneInOpmlURI(rawURL string) bool {
|
||||
return strings.EqualFold(u.Hostname(), "opml.radiotime.com")
|
||||
}
|
||||
|
||||
// tuneInRawItems extracts a response's item list, trying "Items" (the
|
||||
// v1.3 search/profiles API shape) first, then falling back to "body"
|
||||
// (the legacy/OPML shape).
|
||||
func tuneInRawItems(data map[string]interface{}) []interface{} {
|
||||
items, ok := data["Items"].([]interface{})
|
||||
if !ok {
|
||||
items, _ = data["body"].([]interface{})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
// tuneInRenderJSONURI returns the URL with render=json set as a query parameter,
|
||||
// replacing any existing render value instead of appending a duplicate.
|
||||
func tuneInRenderJSONURI(rawURL string) string {
|
||||
@@ -211,37 +223,41 @@ func tuneInSectionsAshx(tuneInURI string, subsection *int) ([]models.BmxNavSecti
|
||||
}
|
||||
|
||||
itemType, _ := m["type"].(string)
|
||||
|
||||
if children, ok := m["children"].([]interface{}); ok && len(children) > 0 {
|
||||
name, _ := m["text"].(string)
|
||||
|
||||
section := models.BmxNavSection{
|
||||
Name: name,
|
||||
Items: make([]models.BmxNavItem, 0, len(children)),
|
||||
}
|
||||
|
||||
for _, child := range children {
|
||||
cm, ok := child.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
childType, _ := cm["type"].(string)
|
||||
if childType == "audio" {
|
||||
section.Items = append(section.Items, tuneInNavigatePlayItem(cm))
|
||||
} else {
|
||||
section.Items = append(section.Items, tuneInNavigateLink(cm))
|
||||
}
|
||||
}
|
||||
|
||||
sections = append(sections, section)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
switch itemType {
|
||||
case "link":
|
||||
if children, ok := m["children"].([]interface{}); ok && len(children) > 0 {
|
||||
name, _ := m["text"].(string)
|
||||
|
||||
section := models.BmxNavSection{
|
||||
Name: name,
|
||||
Items: make([]models.BmxNavItem, 0, len(children)),
|
||||
}
|
||||
for _, child := range children {
|
||||
cm, ok := child.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
childType, _ := cm["type"].(string)
|
||||
if childType == "audio" {
|
||||
section.Items = append(section.Items, tuneInNavigatePlayItem(cm))
|
||||
} else {
|
||||
section.Items = append(section.Items, tuneInNavigateLink(cm))
|
||||
}
|
||||
}
|
||||
|
||||
sections = append(sections, section)
|
||||
} else {
|
||||
topItems = append(topItems, tuneInNavigateLink(m))
|
||||
}
|
||||
topItems = append(topItems, tuneInNavigateLink(m))
|
||||
case "audio":
|
||||
topItems = append(topItems, tuneInNavigatePlayItem(m))
|
||||
case "text":
|
||||
// Ignore info text
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,11 +362,7 @@ func TuneInSearch(query string) (*models.BmxNavResponse, error) {
|
||||
Layout: "classic",
|
||||
}
|
||||
|
||||
// Try "Items" (v1.3) first, then "body" (legacy)
|
||||
items, ok := data["Items"].([]interface{})
|
||||
if !ok {
|
||||
items, _ = data["body"].([]interface{})
|
||||
}
|
||||
items := tuneInRawItems(data)
|
||||
|
||||
for idx, item := range items {
|
||||
m, ok := item.(map[string]interface{})
|
||||
@@ -389,20 +401,12 @@ func tuneInSearchSection(item map[string]interface{}, idx int, query, layout str
|
||||
|
||||
// Pivots.More.Url is the "load more" cursor from the TuneIn profiles API.
|
||||
// It is only present when there are more results beyond the first page.
|
||||
if pivots, ok := item["Pivots"].(map[string]interface{}); ok {
|
||||
if more, ok := pivots["More"].(map[string]interface{}); ok {
|
||||
if containerURL, _ := more["Url"].(string); strings.Contains(containerURL, "itemToken") {
|
||||
if u, err := url.Parse(containerURL); err == nil && allowedTuneInHosts[u.Hostname()] {
|
||||
encoded := base64.RawURLEncoding.EncodeToString([]byte(containerURL))
|
||||
|
||||
if section.Links == nil {
|
||||
section.Links = &models.Links{}
|
||||
}
|
||||
|
||||
section.Links.BmxNext = &models.Link{Href: "/v1/search/next?cursor=" + encoded}
|
||||
}
|
||||
}
|
||||
if next := tuneInMoreCursorLink(item); next != nil {
|
||||
if section.Links == nil {
|
||||
section.Links = &models.Links{}
|
||||
}
|
||||
|
||||
section.Links.BmxNext = next
|
||||
}
|
||||
|
||||
for _, child := range children {
|
||||
@@ -411,25 +415,42 @@ func tuneInSearchSection(item map[string]interface{}, idx int, query, layout str
|
||||
continue
|
||||
}
|
||||
|
||||
typeStr, _ := cm["Type"].(string)
|
||||
if typeStr == "" {
|
||||
typeStr, _ = cm["className"].(string)
|
||||
}
|
||||
|
||||
switch typeStr {
|
||||
case "Station", "PlayItem", "Topic":
|
||||
// Topics are single podcast episodes (t<N>) — Tune.ashx
|
||||
// accepts them just like station IDs, so the same play-link
|
||||
// shape works.
|
||||
section.Items = append(section.Items, tuneInSearchPlayItem(cm))
|
||||
case "Program", "Profile":
|
||||
section.Items = append(section.Items, tuneInSearchProfile(cm, name))
|
||||
}
|
||||
section.Items = append(section.Items, tuneInClassifyItem(cm))
|
||||
}
|
||||
|
||||
return section
|
||||
}
|
||||
|
||||
// tuneInMoreCursorLink builds a BmxNext pagination link from item's
|
||||
// Pivots.More.Url, the "load more" cursor the TuneIn search/profiles API
|
||||
// attaches once there are more results than fit on the first page. Returns
|
||||
// nil if there's nothing more to load.
|
||||
func tuneInMoreCursorLink(item map[string]interface{}) *models.Link {
|
||||
pivots, ok := item["Pivots"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
more, ok := pivots["More"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
containerURL, _ := more["Url"].(string)
|
||||
if !strings.Contains(containerURL, "itemToken") {
|
||||
return nil
|
||||
}
|
||||
|
||||
u, err := url.Parse(containerURL)
|
||||
if err != nil || !allowedTuneInHosts[u.Hostname()] {
|
||||
return nil
|
||||
}
|
||||
|
||||
encoded := base64.RawURLEncoding.EncodeToString([]byte(containerURL))
|
||||
|
||||
return &models.Link{Href: "/v1/search/next?cursor=" + encoded}
|
||||
}
|
||||
|
||||
// TuneInSearchNext fetches the remaining results for a section using the opaque
|
||||
// cursor produced by TuneInSearch. The cursor URL returns a flat Items[] list
|
||||
// (not nested containers), so we parse items directly rather than via
|
||||
@@ -453,10 +474,7 @@ func TuneInSearchNext(encodedCursor string) (*models.BmxNavResponse, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rawItems, ok := data["Items"].([]interface{})
|
||||
if !ok {
|
||||
rawItems, _ = data["body"].([]interface{})
|
||||
}
|
||||
rawItems := tuneInRawItems(data)
|
||||
|
||||
navItems := make([]models.BmxNavItem, 0, len(rawItems))
|
||||
for _, raw := range rawItems {
|
||||
@@ -465,13 +483,7 @@ func TuneInSearchNext(encodedCursor string) (*models.BmxNavResponse, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
typeStr, _ := m["Type"].(string)
|
||||
switch typeStr {
|
||||
case "Station", "PlayItem", "Topic":
|
||||
navItems = append(navItems, tuneInSearchPlayItem(m))
|
||||
case "Program", "Profile":
|
||||
navItems = append(navItems, tuneInSearchProfile(m, ""))
|
||||
}
|
||||
navItems = append(navItems, tuneInClassifyItem(m))
|
||||
}
|
||||
|
||||
return &models.BmxNavResponse{
|
||||
@@ -548,7 +560,7 @@ func tuneInSearchProfile(item map[string]interface{}, _ string) models.BmxNavIte
|
||||
// Artists/Stations/etc are typically navigated first.
|
||||
if typeStr, _ := item["Type"].(string); typeStr == "Program" {
|
||||
if guideID, _ := item["GuideId"].(string); guideID != "" {
|
||||
encodedName := base64.URLEncoding.EncodeToString([]byte(profileName))
|
||||
encodedName := base64.RawURLEncoding.EncodeToString([]byte(profileName))
|
||||
playbackHref := fmt.Sprintf("/v1/playback/episodes/%s?encoded_name=%s", guideID, encodedName)
|
||||
|
||||
return models.BmxNavItem{
|
||||
@@ -561,7 +573,7 @@ func tuneInSearchProfile(item map[string]interface{}, _ string) models.BmxNavIte
|
||||
Type: "tracklisturl",
|
||||
},
|
||||
BmxNavigate: &models.Link{
|
||||
Href: "/v1/navigate/profiles/" + base64.URLEncoding.EncodeToString([]byte(href)),
|
||||
Href: "/v1/navigate/profiles/" + base64.RawURLEncoding.EncodeToString([]byte(href)),
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -596,46 +608,171 @@ func TuneInNavigateProfile(encodedURI string) (*models.BmxNavResponse, error) {
|
||||
|
||||
navResp := &models.BmxNavResponse{
|
||||
Links: &models.Links{
|
||||
Self: &models.Link{Href: "/v1/navigate/profile/" + encodedURI},
|
||||
Self: &models.Link{Href: "/v1/navigate/profiles/" + encodedURI},
|
||||
},
|
||||
Layout: "classic",
|
||||
}
|
||||
|
||||
// Profiles contain "pivots" (sections like "Programs", "Related", etc.)
|
||||
pivots, _ := data["pivots"].([]interface{})
|
||||
for _, p := range pivots {
|
||||
// The profiles API (api.radiotime.com/profiles/...) nests everything under
|
||||
// "Item", and its pivots ("Contents", "Related", etc.) are an *object*
|
||||
// keyed by pivot name, e.g.:
|
||||
// {"Item": {..., "Pivots": {"Contents": {"DisplayName": "Broadcasts", "Url": "..."}}}}
|
||||
// (Earlier code assumed a top-level "pivots" array with "text"/"URL"
|
||||
// fields, which never matched this response and left bmx_sections empty.)
|
||||
item, _ := data["Item"].(map[string]interface{})
|
||||
pivots, _ := item["Pivots"].(map[string]interface{})
|
||||
|
||||
for pivotName, p := range pivots {
|
||||
// We only care about the "Contents" pivot for now (the main list).
|
||||
if !strings.EqualFold(pivotName, "contents") {
|
||||
continue
|
||||
}
|
||||
|
||||
pivot, ok := p.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
pivotName, _ := pivot["text"].(string)
|
||||
pivotURL, _ := pivot["URL"].(string)
|
||||
|
||||
// We only care about the "Contents" pivot for now (the main list)
|
||||
if !strings.EqualFold(pivotName, "contents") {
|
||||
pivotURL, _ := pivot["Url"].(string)
|
||||
if pivotURL == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
displayName, _ := pivot["DisplayName"].(string)
|
||||
if displayName == "" {
|
||||
displayName = pivotName
|
||||
}
|
||||
|
||||
contents, err := fetchJSON(tuneInRenderJSONURI(pivotURL))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, _ := contents["body"].([]interface{})
|
||||
for idx, item := range body {
|
||||
m, ok := item.(map[string]interface{})
|
||||
rawItems := tuneInRawItems(contents)
|
||||
|
||||
// The Contents pivot doesn't return playable items directly: each
|
||||
// top-level entry is a "Container" (identified by a "ContainerType"
|
||||
// field, e.g. GuideId "v5", Title "Episodes") whose real payload is
|
||||
// its own "Children" (or legacy lowercase "children") array. A
|
||||
// Container also carries a "Pivots.More" cursor once there are more
|
||||
// children than fit on this page. Build one BmxNavSection per
|
||||
// container (falling back to treating the entry itself as a leaf
|
||||
// item only when it isn't a Container at all, in case some profile
|
||||
// types ever return a flat list here).
|
||||
for _, raw := range rawItems {
|
||||
m, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
navResp.BmxSections = append(navResp.BmxSections, tuneInSearchSection(m, idx, "", "list"))
|
||||
children, hasChildren := m["Children"].([]interface{})
|
||||
if !hasChildren {
|
||||
children, hasChildren = m["children"].([]interface{})
|
||||
}
|
||||
|
||||
if !hasChildren || len(children) == 0 {
|
||||
if _, isContainer := m["ContainerType"].(string); isContainer {
|
||||
// An empty container (e.g. no episodes published yet)
|
||||
// has nothing playable to show; skip it rather than
|
||||
// mistakenly treating its own GuideId (which identifies
|
||||
// the container, not a track) as a playback link.
|
||||
continue
|
||||
}
|
||||
|
||||
navResp.BmxSections = append(navResp.BmxSections, models.BmxNavSection{
|
||||
Name: displayName,
|
||||
Layout: "list",
|
||||
Items: []models.BmxNavItem{tuneInClassifyItem(m)},
|
||||
})
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
sectionName, _ := m["Title"].(string)
|
||||
if sectionName == "" {
|
||||
sectionName = displayName
|
||||
}
|
||||
|
||||
navItems := make([]models.BmxNavItem, 0, len(children))
|
||||
|
||||
for _, child := range children {
|
||||
cm, ok := child.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
navItems = append(navItems, tuneInClassifyItem(cm))
|
||||
}
|
||||
|
||||
section := models.BmxNavSection{
|
||||
Name: sectionName,
|
||||
Layout: "list",
|
||||
Items: navItems,
|
||||
}
|
||||
|
||||
if next := tuneInMoreCursorLink(m); next != nil {
|
||||
section.Links = &models.Links{BmxNext: next}
|
||||
}
|
||||
|
||||
navResp.BmxSections = append(navResp.BmxSections, section)
|
||||
}
|
||||
}
|
||||
|
||||
return navResp, nil
|
||||
}
|
||||
|
||||
// tuneInClassifyItem maps a single TuneIn item (a search/search-next
|
||||
// result, a section child, or a profile Contents/Container child) to a
|
||||
// BmxNavItem based on its "Type". Program/Profile items navigate to
|
||||
// another profile page; Station/PlayItem/Topic are played directly by
|
||||
// their own GuideId via Tune.ashx (Topics are single on-demand episodes/
|
||||
// broadcasts (t<N>) — Tune.ashx accepts them just like station IDs, so the
|
||||
// same play-link shape works).
|
||||
//
|
||||
// This intentionally does NOT use /v1/playback/episodes/{GuideId}
|
||||
// (tracklisturl) for any of these: that route is backed by
|
||||
// TuneInPodcastInfo, which is a stub that always returns an empty track
|
||||
// list (see its doc comment) - a speaker given that location has nothing
|
||||
// to play. /v1/playback/station/{GuideId} (bmx.TuneInPlayback) is the one
|
||||
// path in this codebase proven to resolve a raw TuneIn GuideId - including
|
||||
// a "t"-prefixed topic id - to an actual stream, via Tune.ashx;
|
||||
// resolveTuneInProgramLatestEpisode+TuneInPlayback uses the exact same
|
||||
// call for a program's latest topic.
|
||||
//
|
||||
// A type this code doesn't otherwise recognize is *also* given a playback
|
||||
// link rather than silently dropped: TuneIn's type list isn't guaranteed
|
||||
// stable, and dropping the item entirely (as this code used to for search
|
||||
// and search-next results) hides real content with no trace it existed.
|
||||
// Its Subtitle is marked instead, so a playback attempt that doesn't pan
|
||||
// out reads as "this content type isn't fully supported yet" rather than
|
||||
// a mystery broken link.
|
||||
func tuneInClassifyItem(m map[string]interface{}) models.BmxNavItem {
|
||||
typeStr, _ := m["Type"].(string)
|
||||
if typeStr == "" {
|
||||
typeStr, _ = m["className"].(string)
|
||||
}
|
||||
|
||||
switch typeStr {
|
||||
case "Program", "Profile":
|
||||
name, _ := m["Title"].(string)
|
||||
|
||||
return tuneInSearchProfile(m, name)
|
||||
case "Station", "PlayItem", "Topic":
|
||||
return tuneInSearchPlayItem(m)
|
||||
default:
|
||||
item := tuneInSearchPlayItem(m)
|
||||
|
||||
const uncertainNote = "Unrecognized type, may not play"
|
||||
if item.Subtitle == "" {
|
||||
item.Subtitle = uncertainNote
|
||||
} else {
|
||||
item.Subtitle = item.Subtitle + " (" + uncertainNote + ")"
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
func parseTuneInStreamBody(body []byte, guideID string) ([]string, error) {
|
||||
// TuneIn sometimes returns plain text with URLs or comments,
|
||||
// especially for .ashx or error responses.
|
||||
|
||||
@@ -2,10 +2,80 @@ package bmx
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
// TestTuneInSectionsAshx_UntypedContainerSurfacesStations is a regression
|
||||
// test for a real-world bug: TuneIn's Browse.ashx?render=json responses
|
||||
// often wrap the actual stations for a category in a container object that
|
||||
// has "children" but no "type" field at all (unlike navigable sub-categories,
|
||||
// which are always type:"link"). The original parser's switch only ever
|
||||
// extracted "children" when itemType == "link", so these untyped containers
|
||||
// -- and every station nested inside them -- were silently dropped: browse
|
||||
// showed only category links, never any actual stations. Reproduces the
|
||||
// shape of a real captured Jazz-genre browse response.
|
||||
func TestTuneInSectionsAshx_UntypedContainerSurfacesStations(t *testing.T) {
|
||||
const wantStationName = "SmoothJazz.com.pl (Poland)"
|
||||
|
||||
payload := `{
|
||||
"head": {"status": "200", "title": "Jazz"},
|
||||
"body": [
|
||||
{
|
||||
"text": "Stations",
|
||||
"key": "stations",
|
||||
"children": [
|
||||
{
|
||||
"type": "audio",
|
||||
"text": "` + wantStationName + `",
|
||||
"URL": "http://opml.radiotime.com/Tune.ashx?id=s106565",
|
||||
"guide_id": "s106565",
|
||||
"subtext": "Smooth Jazz"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(payload))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
parsed, err := url.Parse(ts.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("could not parse test server URL: %v", err)
|
||||
}
|
||||
|
||||
allowedTuneInHosts[parsed.Hostname()] = true
|
||||
defer delete(allowedTuneInHosts, parsed.Hostname())
|
||||
|
||||
sections, err := tuneInSectionsAshx(ts.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("tuneInSectionsAshx returned error: %v", err)
|
||||
}
|
||||
|
||||
for _, section := range sections {
|
||||
for _, item := range section.Items {
|
||||
if item.Name == wantStationName {
|
||||
if item.Links == nil || item.Links.BmxPlayback == nil {
|
||||
t.Errorf("station %q was surfaced but has no BmxPlayback link: %+v", wantStationName, item)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("expected station %q to be surfaced from the untyped container, got sections: %+v", wantStationName, sections)
|
||||
}
|
||||
|
||||
func TestTuneInRenderJSONURI(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -452,3 +522,202 @@ func TestParseTuneInProgramContents(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTuneInNavigateProfileHandlesContainerShapes is a regression test for
|
||||
// four bugs found reviewing PR #677's profile-navigate fix:
|
||||
// - an empty Container (no children, identified by "ContainerType") was
|
||||
// misread as a leaf item and turned into a bogus playback link keyed by
|
||||
// the container's own non-playable GuideId (it checked "Type", which
|
||||
// only leaf items carry, instead of "ContainerType");
|
||||
// - the legacy lowercase "children" key (as opposed to "Children") was no
|
||||
// longer read at all, silently hiding any container using it;
|
||||
// - the Pivots.More.Url "load more" pagination cursor was dropped
|
||||
// entirely, so a container's BmxNext link was never built; and
|
||||
// - the response's own self link used "/v1/navigate/profile/" (singular),
|
||||
// which none of the route dispatchers that recognize "profiles"
|
||||
// (plural) actually match, breaking re-navigation via that link.
|
||||
func TestTuneInNavigateProfileHandlesContainerShapes(t *testing.T) {
|
||||
var contentsURL string
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
switch r.URL.Path {
|
||||
case "/profile":
|
||||
_, _ = w.Write([]byte(`{
|
||||
"Item": {
|
||||
"Pivots": {
|
||||
"Contents": {"DisplayName": "Broadcasts", "Url": "` + contentsURL + `"}
|
||||
}
|
||||
}
|
||||
}`))
|
||||
case "/contents":
|
||||
_, _ = w.Write([]byte(`{
|
||||
"Items": [
|
||||
{
|
||||
"Title": "Episodes",
|
||||
"GuideId": "v5",
|
||||
"ContainerType": "Topics",
|
||||
"Children": [
|
||||
{"Type": "Topic", "Title": "Ep 1", "GuideId": "t100"}
|
||||
],
|
||||
"Pivots": {"More": {"Url": "` + contentsURL + `?itemToken=abc"}}
|
||||
},
|
||||
{
|
||||
"Title": "Empty Container",
|
||||
"GuideId": "v6",
|
||||
"ContainerType": "Topics",
|
||||
"Children": []
|
||||
},
|
||||
{
|
||||
"Title": "Legacy Children",
|
||||
"GuideId": "v7",
|
||||
"ContainerType": "Topics",
|
||||
"children": [
|
||||
{"Type": "Topic", "Title": "Ep 2", "GuideId": "t200"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Type": "Station",
|
||||
"Title": "Flat Leaf",
|
||||
"GuideId": "s999"
|
||||
}
|
||||
]
|
||||
}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
contentsURL = ts.URL + "/contents"
|
||||
|
||||
parsed, err := url.Parse(ts.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("could not parse test server URL: %v", err)
|
||||
}
|
||||
|
||||
allowedTuneInHosts[parsed.Hostname()] = true
|
||||
defer delete(allowedTuneInHosts, parsed.Hostname())
|
||||
|
||||
encodedURI := base64.RawURLEncoding.EncodeToString([]byte(ts.URL + "/profile"))
|
||||
|
||||
navResp, err := TuneInNavigateProfile(encodedURI)
|
||||
if err != nil {
|
||||
t.Fatalf("TuneInNavigateProfile returned error: %v", err)
|
||||
}
|
||||
|
||||
if navResp.Links == nil || navResp.Links.Self == nil {
|
||||
t.Fatalf("response has no self link: %+v", navResp)
|
||||
}
|
||||
|
||||
if want := "/v1/navigate/profiles/" + encodedURI; navResp.Links.Self.Href != want {
|
||||
t.Errorf("self link = %q, want %q (must match the \"profiles\" prefix the dispatchers recognize)", navResp.Links.Self.Href, want)
|
||||
}
|
||||
|
||||
byName := make(map[string]models.BmxNavSection, len(navResp.BmxSections))
|
||||
for _, section := range navResp.BmxSections {
|
||||
byName[section.Name] = section
|
||||
}
|
||||
|
||||
if _, found := byName["Empty Container"]; found {
|
||||
t.Errorf("empty container was surfaced as a section, want it skipped: %+v", navResp.BmxSections)
|
||||
}
|
||||
|
||||
episodes, ok := byName["Episodes"]
|
||||
if !ok {
|
||||
t.Fatalf("no \"Episodes\" section found: %+v", navResp.BmxSections)
|
||||
}
|
||||
|
||||
if len(episodes.Items) != 1 || episodes.Items[0].Name != "Ep 1" {
|
||||
t.Errorf("Episodes items = %+v, want exactly [Ep 1]", episodes.Items)
|
||||
}
|
||||
|
||||
if episodes.Links == nil || episodes.Links.BmxNext == nil || !strings.Contains(episodes.Links.BmxNext.Href, "/v1/search/next?cursor=") {
|
||||
t.Errorf("Episodes section missing BmxNext pagination link: %+v", episodes.Links)
|
||||
}
|
||||
|
||||
legacy, ok := byName["Legacy Children"]
|
||||
if !ok {
|
||||
t.Fatalf("no \"Legacy Children\" section found (lowercase \"children\" fallback not applied): %+v", navResp.BmxSections)
|
||||
}
|
||||
|
||||
if len(legacy.Items) != 1 || legacy.Items[0].Name != "Ep 2" {
|
||||
t.Errorf("Legacy Children items = %+v, want exactly [Ep 2]", legacy.Items)
|
||||
}
|
||||
|
||||
broadcasts, ok := byName["Broadcasts"]
|
||||
if !ok {
|
||||
t.Fatalf("no \"Broadcasts\" (flat leaf, pivot display name) section found: %+v", navResp.BmxSections)
|
||||
}
|
||||
|
||||
if len(broadcasts.Items) != 1 || broadcasts.Items[0].Name != "Flat Leaf" {
|
||||
t.Errorf("Broadcasts items = %+v, want exactly [Flat Leaf]", broadcasts.Items)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTuneInClassifyItemMarksUnrecognizedTypesInsteadOfDroppingThem covers
|
||||
// the shared classifier used by tuneInSearchSection, TuneInSearchNext, and
|
||||
// TuneInNavigateProfile's container children. Previously, tuneInSearchSection
|
||||
// and TuneInSearchNext each had their own switch with no default case, so an
|
||||
// item whose "Type" wasn't one of the 5 known values was silently omitted --
|
||||
// TuneIn's type list isn't guaranteed stable, and this hid real content with
|
||||
// no trace it existed. An unrecognized type now still gets a playback link,
|
||||
// with its Subtitle marked so a playback attempt that doesn't pan out reads
|
||||
// as "this content type isn't fully supported yet" rather than a mystery
|
||||
// broken link.
|
||||
func TestTuneInClassifyItemMarksUnrecognizedTypesInsteadOfDroppingThem(t *testing.T) {
|
||||
t.Run("known playable type is unmarked", func(t *testing.T) {
|
||||
item := tuneInClassifyItem(map[string]interface{}{
|
||||
"Type": "Station", "Title": "Jazz FM", "GuideId": "s123", "Subtitle": "Smooth Jazz",
|
||||
})
|
||||
|
||||
if item.Subtitle != "Smooth Jazz" {
|
||||
t.Errorf("Subtitle = %q, want unmodified %q", item.Subtitle, "Smooth Jazz")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Program/Profile type navigates instead of playing", func(t *testing.T) {
|
||||
item := tuneInClassifyItem(map[string]interface{}{
|
||||
"Type": "Program", "Title": "Some Show", "GuideId": "p123",
|
||||
})
|
||||
|
||||
if item.Links == nil || item.Links.BmxNavigate == nil {
|
||||
t.Errorf("Program item has no BmxNavigate link: %+v", item)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unrecognized type without existing subtitle", func(t *testing.T) {
|
||||
item := tuneInClassifyItem(map[string]interface{}{
|
||||
"Type": "SomeFutureType", "Title": "Mystery Item", "GuideId": "x123",
|
||||
})
|
||||
|
||||
if item.Links == nil || item.Links.BmxPlayback == nil {
|
||||
t.Fatalf("unrecognized-type item has no playback link, want it still playable: %+v", item)
|
||||
}
|
||||
|
||||
if item.Subtitle != "Unrecognized type, may not play" {
|
||||
t.Errorf("Subtitle = %q, want the unrecognized-type marker", item.Subtitle)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unrecognized type with existing subtitle appends the marker", func(t *testing.T) {
|
||||
item := tuneInClassifyItem(map[string]interface{}{
|
||||
"Type": "SomeFutureType", "Title": "Mystery Item", "GuideId": "x123", "Subtitle": "From Mystery Network",
|
||||
})
|
||||
|
||||
if !strings.Contains(item.Subtitle, "From Mystery Network") || !strings.Contains(item.Subtitle, "Unrecognized type") {
|
||||
t.Errorf("Subtitle = %q, want both the original subtitle and the unrecognized-type marker", item.Subtitle)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty type falls back to className, still unrecognized if className is also unknown", func(t *testing.T) {
|
||||
item := tuneInClassifyItem(map[string]interface{}{
|
||||
"className": "weirdLegacyThing", "Title": "Legacy Item", "GuideId": "y123",
|
||||
})
|
||||
|
||||
if !strings.Contains(item.Subtitle, "Unrecognized type") {
|
||||
t.Errorf("Subtitle = %q, want the unrecognized-type marker", item.Subtitle)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,764 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func lifecycleTestGroup(master, left, right, name string) models.Group {
|
||||
return models.Group{
|
||||
Name: name,
|
||||
MasterDeviceID: master,
|
||||
Roles: models.GroupRoles{Roles: []models.GroupRole{
|
||||
{DeviceID: left, Role: "LEFT"},
|
||||
{DeviceID: right, Role: "RIGHT"},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func countLifecycleGroupFiles(t *testing.T, ds *DataStore, account string) int {
|
||||
t.Helper()
|
||||
|
||||
entries, err := os.ReadDir(ds.AccountDevicesDir(account))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0
|
||||
}
|
||||
|
||||
t.Fatalf("read account devices directory: %v", err)
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && strings.HasPrefix(entry.Name(), "Group_") && strings.HasSuffix(entry.Name(), ".xml") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
func writeLifecycleGroup(t *testing.T, ds *DataStore, account, groupID string, group models.Group) {
|
||||
t.Helper()
|
||||
|
||||
group.ID = groupID
|
||||
|
||||
data, err := xml.MarshalIndent(&group, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal group %s: %v", groupID, err)
|
||||
}
|
||||
|
||||
if err := ds.rootMkdirAll(ds.AccountDevicesDir(account), 0755); err != nil {
|
||||
t.Fatalf("create account %s devices directory: %v", account, err)
|
||||
}
|
||||
|
||||
if err := ds.atomicWriteFile(ds.groupFilePath(account, groupID), append([]byte(xml.Header), data...)); err != nil {
|
||||
t.Fatalf("write group %s: %v", groupID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupGenerationReservationsAreGlobal(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
|
||||
writeLifecycleGroup(t, ds, "ACCOUNT1", "1234567",
|
||||
lifecycleTestGroup("MASTER1", "MASTER1", "SLAVE1", "Active pair"))
|
||||
|
||||
if err := ds.rootMkdirAll(ds.AccountDevicesDir("ACCOUNT2"), 0755); err != nil {
|
||||
t.Fatalf("create tombstone account: %v", err)
|
||||
}
|
||||
if err := ds.atomicWriteFile(ds.retiredGroupFilePath("ACCOUNT2", "7654321"), []byte("retired\n")); err != nil {
|
||||
t.Fatalf("write cross-account tombstone: %v", err)
|
||||
}
|
||||
|
||||
locations, err := ds.loadGroupGenerationLocationsNoLock()
|
||||
if err != nil {
|
||||
t.Fatalf("load generation reservations: %v", err)
|
||||
}
|
||||
|
||||
if got := locations["1234567"]; len(got.active) != 1 || got.active[0].account != "ACCOUNT1" {
|
||||
t.Fatalf("active reservation = %#v, want ACCOUNT1", got)
|
||||
}
|
||||
if got := locations["7654321"]; len(got.retired) != 1 || got.retired[0].account != "ACCOUNT2" {
|
||||
t.Fatalf("retired reservation = %#v, want ACCOUNT2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddGroupReusesStoredStereoPair(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
const account = "ACCOUNT1"
|
||||
|
||||
original := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Original name")
|
||||
original.SenderIPAddress = "192.0.2.10"
|
||||
|
||||
firstID, err := ds.AddGroup(account, &original)
|
||||
if err != nil {
|
||||
t.Fatalf("add original group: %v", err)
|
||||
}
|
||||
|
||||
retry := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Retry name")
|
||||
retry.Roles.Roles[0].IPAddress = "198.51.100.10"
|
||||
|
||||
retryID, err := ds.AddGroup(account, &retry)
|
||||
if err != nil {
|
||||
t.Fatalf("retry group creation: %v", err)
|
||||
}
|
||||
|
||||
if retryID != firstID {
|
||||
t.Fatalf("retry ID = %q, want stored ID %q", retryID, firstID)
|
||||
}
|
||||
|
||||
if retry.ID != firstID || retry.Name != original.Name || retry.SenderIPAddress != original.SenderIPAddress {
|
||||
t.Fatalf("retry returned %#v, want unchanged stored group %#v", retry, original)
|
||||
}
|
||||
|
||||
if got := countLifecycleGroupFiles(t, ds, account); got != 1 {
|
||||
t.Fatalf("stored group files = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddGroupRejectsExistingDeviceMembership(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
const account = "ACCOUNT1"
|
||||
|
||||
original := lifecycleTestGroup("MASTER1", "MASTER1", "SHARED", "First pair")
|
||||
if _, err := ds.AddGroup(account, &original); err != nil {
|
||||
t.Fatalf("add original group: %v", err)
|
||||
}
|
||||
|
||||
conflicting := lifecycleTestGroup("MASTER2", "MASTER2", "SHARED", "Conflicting pair")
|
||||
_, err := ds.AddGroup(account, &conflicting)
|
||||
if !errors.Is(err, ErrGroupMembershipConflict) {
|
||||
t.Fatalf("conflicting add error = %v, want ErrGroupMembershipConflict", err)
|
||||
}
|
||||
|
||||
if got := countLifecycleGroupFiles(t, ds, account); got != 1 {
|
||||
t.Fatalf("stored group files = %d after conflict, want 1", got)
|
||||
}
|
||||
|
||||
if group, getErr := ds.GetGroupForDevice(account, "SHARED"); getErr != nil || group.ID != original.ID {
|
||||
t.Fatalf("stored group changed after conflict: group=%#v err=%v", group, getErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddGroupRejectsCrossAccountMembership(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
requested models.Group
|
||||
}{
|
||||
{
|
||||
name: "same stereo pair",
|
||||
requested: lifecycleTestGroup("MASTER1", "MASTER1", "SLAVE1", "Same pair in another account"),
|
||||
},
|
||||
{
|
||||
name: "shared member",
|
||||
requested: lifecycleTestGroup("MASTER2", "MASTER2", "SLAVE1", "Conflicting pair in another account"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
stored := lifecycleTestGroup("MASTER1", "MASTER1", "SLAVE1", "Stored pair")
|
||||
writeLifecycleGroup(t, ds, "ACCOUNT1", "1234567", stored)
|
||||
|
||||
_, err := ds.AddGroup("ACCOUNT2", &test.requested)
|
||||
if !errors.Is(err, ErrGroupMembershipConflict) {
|
||||
t.Fatalf("cross-account add error = %v, want ErrGroupMembershipConflict", err)
|
||||
}
|
||||
if got := countLifecycleGroupFiles(t, ds, "ACCOUNT1"); got != 1 {
|
||||
t.Fatalf("source account group files = %d after conflict, want 1", got)
|
||||
}
|
||||
if got := countLifecycleGroupFiles(t, ds, "ACCOUNT2"); got != 0 {
|
||||
t.Fatalf("requested account group files = %d after conflict, want 0", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddGroupDoesNotReuseMalformedSuperset(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
const account = "ACCOUNT1"
|
||||
|
||||
original := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Pair")
|
||||
if _, err := ds.AddGroup(account, &original); err != nil {
|
||||
t.Fatalf("add original group: %v", err)
|
||||
}
|
||||
|
||||
malformed := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Pair")
|
||||
malformed.Roles.Roles = append(malformed.Roles.Roles, models.GroupRole{DeviceID: "EXTRA", Role: "CENTER"})
|
||||
if _, err := ds.AddGroup(account, &malformed); !errors.Is(err, ErrGroupMembershipConflict) {
|
||||
t.Fatalf("malformed superset error = %v, want ErrGroupMembershipConflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteGroupGenerationForDevice(t *testing.T) {
|
||||
t.Run("removes only the exact generation containing the device", func(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
const (
|
||||
account = "ACCOUNT1"
|
||||
deviceID = "SLAVE1"
|
||||
)
|
||||
|
||||
first := lifecycleTestGroup("MASTER1", "MASTER1", "SLAVE1", "First pair")
|
||||
second := lifecycleTestGroup("MASTER2", "MASTER2", "SLAVE2", "Second pair")
|
||||
if _, err := ds.AddGroup(account, &first); err != nil {
|
||||
t.Fatalf("add first group: %v", err)
|
||||
}
|
||||
if _, err := ds.AddGroup(account, &second); err != nil {
|
||||
t.Fatalf("add second group: %v", err)
|
||||
}
|
||||
|
||||
if err := ds.DeleteGroupGenerationForDevice(deviceID, first.ID, &first); err != nil {
|
||||
t.Fatalf("delete exact generation: %v", err)
|
||||
}
|
||||
|
||||
if _, err := ds.GetGroupForDevice(account, deviceID); !errors.Is(err, ErrGroupNotFound) {
|
||||
t.Fatalf("deleted device lookup error = %v, want ErrGroupNotFound", err)
|
||||
}
|
||||
if group, err := ds.GetGroupForDevice(account, "SLAVE2"); err != nil || group.ID != second.ID {
|
||||
t.Fatalf("unrelated group was not preserved: group=%#v err=%v", group, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stale generation is an idempotent no-op", func(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
const account = "ACCOUNT1"
|
||||
|
||||
group := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Current pair")
|
||||
if _, err := ds.AddGroup(account, &group); err != nil {
|
||||
t.Fatalf("add group: %v", err)
|
||||
}
|
||||
|
||||
if err := ds.DeleteGroupGenerationForDevice("MASTER", "OLDER-ID", nil); err != nil {
|
||||
t.Fatalf("delete stale generation: %v", err)
|
||||
}
|
||||
|
||||
if current, err := ds.GetGroupForDevice(account, "MASTER"); err != nil || current.ID != group.ID {
|
||||
t.Fatalf("current generation changed: group=%#v err=%v", current, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing generation is idempotent", func(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
if err := ds.DeleteGroupGenerationForDevice("MASTER", "PAIR-ID", nil); err != nil {
|
||||
t.Fatalf("delete missing generation: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ambiguous duplicate generation fails closed", func(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
const deviceID = "MASTER"
|
||||
|
||||
first := lifecycleTestGroup(deviceID, deviceID, "SLAVE", "First pair")
|
||||
if _, err := ds.AddGroup("ACCOUNT1", &first); err != nil {
|
||||
t.Fatalf("add first group: %v", err)
|
||||
}
|
||||
|
||||
second := lifecycleTestGroup(deviceID, deviceID, "SLAVE", "Second pair")
|
||||
writeLifecycleGroup(t, ds, "ACCOUNT2", first.ID, second)
|
||||
|
||||
err := ds.DeleteGroupGenerationForDevice(deviceID, first.ID, &first)
|
||||
if !errors.Is(err, ErrGroupDeleteAmbiguous) {
|
||||
t.Fatalf("ambiguous delete error = %v, want ErrGroupDeleteAmbiguous", err)
|
||||
}
|
||||
if got := countLifecycleGroupFiles(t, ds, "ACCOUNT1") + countLifecycleGroupFiles(t, ds, "ACCOUNT2"); got != 2 {
|
||||
t.Fatalf("stored group files = %d after ambiguity, want 2", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same ID for another device fails closed", func(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
group := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Current pair")
|
||||
if _, err := ds.AddGroup("ACCOUNT1", &group); err != nil {
|
||||
t.Fatalf("add group: %v", err)
|
||||
}
|
||||
|
||||
err := ds.DeleteGroupGenerationForDevice("OTHER", group.ID, &group)
|
||||
if !errors.Is(err, ErrGroupDeleteAmbiguous) {
|
||||
t.Fatalf("wrong-device delete error = %v, want ErrGroupDeleteAmbiguous", err)
|
||||
}
|
||||
if !ds.rootExists(ds.groupFilePath("ACCOUNT1", group.ID)) {
|
||||
t.Fatal("wrong-device delete retired the active group")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("submitted topology must match the stored generation", func(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
stored := lifecycleTestGroup("MASTER", "MASTER", "REAL-SLAVE", "Current pair")
|
||||
if _, err := ds.AddGroup("ACCOUNT1", &stored); err != nil {
|
||||
t.Fatalf("add group: %v", err)
|
||||
}
|
||||
|
||||
submitted := stored
|
||||
submitted.Roles.Roles = append([]models.GroupRole(nil), stored.Roles.Roles...)
|
||||
submitted.Roles.Roles[1].DeviceID = "SUBSTITUTE-SLAVE"
|
||||
err := ds.DeleteGroupGenerationForDevice("MASTER", stored.ID, &submitted)
|
||||
if !errors.Is(err, ErrGroupDeleteAmbiguous) {
|
||||
t.Fatalf("topology mismatch error = %v, want ErrGroupDeleteAmbiguous", err)
|
||||
}
|
||||
if !ds.rootExists(ds.groupFilePath("ACCOUNT1", stored.ID)) {
|
||||
t.Fatal("topology mismatch retired the active group")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stored name drift does not prevent exact topology deletion", func(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
stored := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Renamed pair")
|
||||
stored.Roles.Roles[0].IPAddress = "192.0.2.10"
|
||||
stored.Roles.Roles[1].IPAddress = "192.0.2.11"
|
||||
if _, err := ds.AddGroup("ACCOUNT1", &stored); err != nil {
|
||||
t.Fatalf("add group: %v", err)
|
||||
}
|
||||
|
||||
expected := stored
|
||||
expected.Name = "Original snapshot"
|
||||
if err := ds.DeleteGroupGenerationForDevice("MASTER", stored.ID, &expected); err != nil {
|
||||
t.Fatalf("delete generation after name drift: %v", err)
|
||||
}
|
||||
if ds.rootExists(ds.groupFilePath("ACCOUNT1", stored.ID)) {
|
||||
t.Fatal("name drift prevented retirement of the exact topology")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRenameGroupGenerationForDevice(t *testing.T) {
|
||||
t.Run("renames an exact generation across accounts", func(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
|
||||
unrelated := lifecycleTestGroup("OTHER-MASTER", "OTHER-MASTER", "OTHER-SLAVE", "Unrelated pair")
|
||||
if _, err := ds.AddGroup("ACCOUNT1", &unrelated); err != nil {
|
||||
t.Fatalf("add unrelated group: %v", err)
|
||||
}
|
||||
|
||||
group := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Original name")
|
||||
group.Roles.Roles[0].IPAddress = "192.0.2.10"
|
||||
group.Roles.Roles[1].IPAddress = "192.0.2.11"
|
||||
if _, err := ds.AddGroup("ACCOUNT2", &group); err != nil {
|
||||
t.Fatalf("add group: %v", err)
|
||||
}
|
||||
|
||||
updated, err := ds.RenameGroupGenerationForDevice("MASTER", group.ID, &group, "Renamed pair")
|
||||
if err != nil {
|
||||
t.Fatalf("rename exact generation: %v", err)
|
||||
}
|
||||
if updated.ID != group.ID || updated.Name != "Renamed pair" {
|
||||
t.Fatalf("updated group = %#v, want ID %q and renamed name", updated, group.ID)
|
||||
}
|
||||
|
||||
stored, err := ds.GetGroupForDevice("ACCOUNT2", "MASTER")
|
||||
if err != nil || !reflect.DeepEqual(stored, updated) {
|
||||
t.Fatalf("stored renamed group = %#v err=%v, want %#v", stored, err, updated)
|
||||
}
|
||||
if current, err := ds.GetGroupForDevice("ACCOUNT1", "OTHER-MASTER"); err != nil || current.Name != unrelated.Name {
|
||||
t.Fatalf("unrelated group changed: group=%#v err=%v", current, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("retry allows the stored name to differ from expected", func(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
group := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Original name")
|
||||
if _, err := ds.AddGroup("ACCOUNT", &group); err != nil {
|
||||
t.Fatalf("add group: %v", err)
|
||||
}
|
||||
|
||||
if _, err := ds.RenameGroupGenerationForDevice("MASTER", group.ID, &group, "Renamed pair"); err != nil {
|
||||
t.Fatalf("first rename: %v", err)
|
||||
}
|
||||
|
||||
updated, err := ds.RenameGroupGenerationForDevice("MASTER", group.ID, &group, "Renamed pair")
|
||||
if err != nil {
|
||||
t.Fatalf("idempotent rename retry: %v", err)
|
||||
}
|
||||
if updated.Name != "Renamed pair" {
|
||||
t.Fatalf("retry returned name %q, want Renamed pair", updated.Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("topology mismatch does not write", func(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
stored := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Original name")
|
||||
stored.Roles.Roles[0].IPAddress = "192.0.2.10"
|
||||
stored.Roles.Roles[1].IPAddress = "192.0.2.11"
|
||||
if _, err := ds.AddGroup("ACCOUNT", &stored); err != nil {
|
||||
t.Fatalf("add group: %v", err)
|
||||
}
|
||||
|
||||
before, err := ds.rootReadFile(ds.groupFilePath("ACCOUNT", stored.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("read group before rename: %v", err)
|
||||
}
|
||||
|
||||
expected := stored
|
||||
expected.Roles.Roles = append([]models.GroupRole(nil), stored.Roles.Roles...)
|
||||
expected.Roles.Roles[1].IPAddress = "198.51.100.11"
|
||||
_, err = ds.RenameGroupGenerationForDevice("MASTER", stored.ID, &expected, "Renamed pair")
|
||||
if !errors.Is(err, ErrGroupDeleteAmbiguous) {
|
||||
t.Fatalf("topology mismatch error = %v, want ErrGroupDeleteAmbiguous", err)
|
||||
}
|
||||
|
||||
after, err := ds.rootReadFile(ds.groupFilePath("ACCOUNT", stored.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("read group after rename: %v", err)
|
||||
}
|
||||
if !bytes.Equal(after, before) {
|
||||
t.Fatal("topology mismatch rewrote the active group")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unrelated device does not write", func(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
group := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Original name")
|
||||
if _, err := ds.AddGroup("ACCOUNT", &group); err != nil {
|
||||
t.Fatalf("add group: %v", err)
|
||||
}
|
||||
|
||||
before, err := ds.rootReadFile(ds.groupFilePath("ACCOUNT", group.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("read group before rename: %v", err)
|
||||
}
|
||||
|
||||
_, err = ds.RenameGroupGenerationForDevice("OTHER", group.ID, &group, "Renamed pair")
|
||||
if !errors.Is(err, ErrGroupDeleteAmbiguous) {
|
||||
t.Fatalf("unrelated-device error = %v, want ErrGroupDeleteAmbiguous", err)
|
||||
}
|
||||
|
||||
after, err := ds.rootReadFile(ds.groupFilePath("ACCOUNT", group.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("read group after rename: %v", err)
|
||||
}
|
||||
if !bytes.Equal(after, before) {
|
||||
t.Fatal("unrelated-device rename rewrote the active group")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ambiguous duplicate generation does not write", func(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
group := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Original name")
|
||||
writeLifecycleGroup(t, ds, "ACCOUNT1", "1234567", group)
|
||||
writeLifecycleGroup(t, ds, "ACCOUNT2", "1234567", group)
|
||||
group.ID = "1234567"
|
||||
|
||||
_, err := ds.RenameGroupGenerationForDevice("MASTER", group.ID, &group, "Renamed pair")
|
||||
if !errors.Is(err, ErrGroupDeleteAmbiguous) {
|
||||
t.Fatalf("ambiguous rename error = %v, want ErrGroupDeleteAmbiguous", err)
|
||||
}
|
||||
|
||||
for _, account := range []string{"ACCOUNT1", "ACCOUNT2"} {
|
||||
stored, getErr := ds.GetGroupForDevice(account, "MASTER")
|
||||
if getErr != nil || stored.Name != "Original name" {
|
||||
t.Fatalf("group in %s changed after ambiguity: group=%#v err=%v", account, stored, getErr)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty name is rejected", func(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
group := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Original name")
|
||||
if _, err := ds.AddGroup("ACCOUNT", &group); err != nil {
|
||||
t.Fatalf("add group: %v", err)
|
||||
}
|
||||
|
||||
_, err := ds.RenameGroupGenerationForDevice("MASTER", group.ID, &group, "")
|
||||
if !errors.Is(err, ErrGroupDeleteAmbiguous) {
|
||||
t.Fatalf("empty-name error = %v, want ErrGroupDeleteAmbiguous", err)
|
||||
}
|
||||
if current, getErr := ds.GetGroupForDevice("ACCOUNT", "MASTER"); getErr != nil || current.Name != group.Name {
|
||||
t.Fatalf("group changed after empty name: group=%#v err=%v", current, getErr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-master device is rejected", func(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
group := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Original name")
|
||||
if _, err := ds.AddGroup("ACCOUNT", &group); err != nil {
|
||||
t.Fatalf("add group: %v", err)
|
||||
}
|
||||
|
||||
_, err := ds.RenameGroupGenerationForDevice("SLAVE", group.ID, &group, "Renamed pair")
|
||||
if !errors.Is(err, ErrGroupDeleteAmbiguous) {
|
||||
t.Fatalf("non-master error = %v, want ErrGroupDeleteAmbiguous", err)
|
||||
}
|
||||
if current, getErr := ds.GetGroupForDevice("ACCOUNT", "MASTER"); getErr != nil || current.Name != group.Name {
|
||||
t.Fatalf("group changed after non-master rename: group=%#v err=%v", current, getErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestEnsureNoGroupsForDevicesReportsStaleGroupsAcrossAccountsWithoutMutation(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
const (
|
||||
firstID = "1234567"
|
||||
secondID = "7654321"
|
||||
)
|
||||
|
||||
first := lifecycleTestGroup("MOVED", "MOVED", "OLD-SLAVE-1", "First stale pair")
|
||||
writeLifecycleGroup(t, ds, "ACCOUNT1", firstID, first)
|
||||
|
||||
second := lifecycleTestGroup("MOVED", "MOVED", "OLD-SLAVE-2", "Second stale pair")
|
||||
writeLifecycleGroup(t, ds, "ACCOUNT2", secondID, second)
|
||||
|
||||
unrelated := lifecycleTestGroup("OTHER-MASTER", "OTHER-MASTER", "OTHER-SLAVE", "Unrelated pair")
|
||||
if _, err := ds.AddGroup("ACCOUNT3", &unrelated); err != nil {
|
||||
t.Fatalf("add unrelated group: %v", err)
|
||||
}
|
||||
|
||||
if firstID == unrelated.ID || secondID == unrelated.ID {
|
||||
t.Fatalf("active generation IDs are not globally unique: %q %q %q", firstID, secondID, unrelated.ID)
|
||||
}
|
||||
|
||||
firstBefore, err := ds.rootReadFile(ds.groupFilePath("ACCOUNT1", firstID))
|
||||
if err != nil {
|
||||
t.Fatalf("read first group before check: %v", err)
|
||||
}
|
||||
secondBefore, err := ds.rootReadFile(ds.groupFilePath("ACCOUNT2", secondID))
|
||||
if err != nil {
|
||||
t.Fatalf("read second group before check: %v", err)
|
||||
}
|
||||
|
||||
err = ds.EnsureNoGroupsForDevices([]string{"MOVED"})
|
||||
if !errors.Is(err, ErrGroupMembershipConflict) {
|
||||
t.Fatalf("cross-account check error = %v, want ErrGroupMembershipConflict", err)
|
||||
}
|
||||
|
||||
var conflict *GroupMembershipConflictError
|
||||
if !errors.As(err, &conflict) {
|
||||
t.Fatalf("cross-account check error type = %T, want *GroupMembershipConflictError", err)
|
||||
}
|
||||
wantGenerations := []GroupGeneration{
|
||||
{Account: "ACCOUNT1", ID: firstID},
|
||||
{Account: "ACCOUNT2", ID: secondID},
|
||||
}
|
||||
if !reflect.DeepEqual(conflict.Generations, wantGenerations) {
|
||||
t.Fatalf("conflicting generations = %#v, want %#v", conflict.Generations, wantGenerations)
|
||||
}
|
||||
|
||||
firstAfter, err := ds.rootReadFile(ds.groupFilePath("ACCOUNT1", firstID))
|
||||
if err != nil {
|
||||
t.Fatalf("read first group after check: %v", err)
|
||||
}
|
||||
secondAfter, err := ds.rootReadFile(ds.groupFilePath("ACCOUNT2", secondID))
|
||||
if err != nil {
|
||||
t.Fatalf("read second group after check: %v", err)
|
||||
}
|
||||
if !bytes.Equal(firstAfter, firstBefore) || !bytes.Equal(secondAfter, secondBefore) {
|
||||
t.Fatal("read-only group check changed active group data")
|
||||
}
|
||||
if ds.rootExists(ds.retiredGroupFilePath("ACCOUNT1", firstID)) ||
|
||||
ds.rootExists(ds.retiredGroupFilePath("ACCOUNT2", secondID)) {
|
||||
t.Fatal("read-only group check created a tombstone")
|
||||
}
|
||||
if got := countLifecycleGroupFiles(t, ds, "ACCOUNT3"); got != 1 {
|
||||
t.Fatalf("unrelated active group files = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetireGroupAtomicallyRenamesActiveXML(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
const (
|
||||
account = "ACCOUNT1"
|
||||
groupID = "1234567"
|
||||
)
|
||||
|
||||
group := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Pair")
|
||||
writeLifecycleGroup(t, ds, account, groupID, group)
|
||||
|
||||
activePath := ds.groupFilePath(account, groupID)
|
||||
retiredPath := ds.retiredGroupFilePath(account, groupID)
|
||||
activeInfo, err := os.Stat(activePath)
|
||||
if err != nil {
|
||||
t.Fatalf("stat active group: %v", err)
|
||||
}
|
||||
activeXML, err := os.ReadFile(activePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read active group: %v", err)
|
||||
}
|
||||
|
||||
if err := ds.DeleteGroup(account, groupID); err != nil {
|
||||
t.Fatalf("retire group: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(activePath); !os.IsNotExist(err) {
|
||||
t.Fatalf("active path stat error = %v, want not exist", err)
|
||||
}
|
||||
retiredInfo, err := os.Stat(retiredPath)
|
||||
if err != nil {
|
||||
t.Fatalf("stat retired group: %v", err)
|
||||
}
|
||||
if !os.SameFile(activeInfo, retiredInfo) {
|
||||
t.Fatal("retired group is not the renamed active file")
|
||||
}
|
||||
retiredXML, err := os.ReadFile(retiredPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read retired group: %v", err)
|
||||
}
|
||||
if !bytes.Equal(retiredXML, activeXML) {
|
||||
t.Fatal("retired group did not preserve the active XML contents")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureNoGroupsForDevicesRejectsActiveTombstoneAmbiguity(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
|
||||
group := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Ambiguous pair")
|
||||
if _, err := ds.AddGroup("ACCOUNT1", &group); err != nil {
|
||||
t.Fatalf("add active group: %v", err)
|
||||
}
|
||||
|
||||
if err := ds.rootMkdirAll(ds.AccountDevicesDir("ACCOUNT2"), 0755); err != nil {
|
||||
t.Fatalf("create tombstone account: %v", err)
|
||||
}
|
||||
if err := ds.atomicWriteFile(ds.retiredGroupFilePath("ACCOUNT2", group.ID), []byte("retired\n")); err != nil {
|
||||
t.Fatalf("write conflicting tombstone: %v", err)
|
||||
}
|
||||
|
||||
err := ds.EnsureNoGroupsForDevices([]string{"MASTER"})
|
||||
if !errors.Is(err, ErrGroupDeleteAmbiguous) {
|
||||
t.Fatalf("ambiguous check error = %v, want ErrGroupDeleteAmbiguous", err)
|
||||
}
|
||||
if !ds.rootExists(ds.groupFilePath("ACCOUNT1", group.ID)) {
|
||||
t.Fatal("ambiguous check removed the active group")
|
||||
}
|
||||
if _, readErr := ds.rootReadFile(ds.retiredGroupFilePath("ACCOUNT2", group.ID)); readErr != nil {
|
||||
t.Fatalf("ambiguous check changed the tombstone: %v", readErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupReadsFailClosedOnMalformedOrUnreadableData(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(t *testing.T, ds *DataStore) string
|
||||
}{
|
||||
{
|
||||
name: "malformed XML",
|
||||
setup: func(t *testing.T, ds *DataStore) string {
|
||||
t.Helper()
|
||||
|
||||
path := ds.groupFilePath("ACCOUNT1", "1234567")
|
||||
if err := ds.rootMkdirAll(ds.AccountDevicesDir("ACCOUNT1"), 0755); err != nil {
|
||||
t.Fatalf("create account directory: %v", err)
|
||||
}
|
||||
if err := ds.atomicWriteFile(path, []byte("<group>")); err != nil {
|
||||
t.Fatalf("write malformed group: %v", err)
|
||||
}
|
||||
|
||||
return path
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unreadable group",
|
||||
setup: func(t *testing.T, ds *DataStore) string {
|
||||
t.Helper()
|
||||
|
||||
if err := ds.rootMkdirAll(ds.AccountDevicesDir("ACCOUNT1"), 0755); err != nil {
|
||||
t.Fatalf("create account directory: %v", err)
|
||||
}
|
||||
path := ds.groupFilePath("ACCOUNT1", "1234567")
|
||||
if err := os.Symlink("missing-group-target", path); err != nil {
|
||||
t.Fatalf("create unreadable group symlink: %v", err)
|
||||
}
|
||||
|
||||
return path
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
path := test.setup(t, ds)
|
||||
|
||||
if err := ds.EnsureNoGroupsForDevices([]string{"MASTER"}); err == nil {
|
||||
t.Fatal("EnsureNoGroupsForDevices error = nil, want datastore error")
|
||||
}
|
||||
if _, err := ds.GetGroupForDevice("ACCOUNT1", "MASTER"); err == nil || errors.Is(err, ErrGroupNotFound) {
|
||||
t.Fatalf("GetGroupForDevice error = %v, want datastore error", err)
|
||||
}
|
||||
if _, err := os.Lstat(path); err != nil {
|
||||
t.Fatalf("fail-closed reads mutated group path: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGroupForDeviceFailsClosedOnDuplicateMembership(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
first := lifecycleTestGroup("MASTER1", "MASTER1", "SHARED", "First pair")
|
||||
second := lifecycleTestGroup("MASTER2", "MASTER2", "SHARED", "Second pair")
|
||||
|
||||
writeLifecycleGroup(t, ds, "ACCOUNT1", "1234567", first)
|
||||
writeLifecycleGroup(t, ds, "ACCOUNT1", "7654321", second)
|
||||
|
||||
group, err := ds.GetGroupForDevice("ACCOUNT1", "SHARED")
|
||||
if group != nil || !errors.Is(err, ErrGroupMembershipConflict) {
|
||||
t.Fatalf("group=%#v error=%v, want membership conflict", group, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteGroupClassifiesMissingAndAmbiguousGenerations(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
|
||||
if err := ds.DeleteGroup("ACCOUNT1", "1234567"); !errors.Is(err, ErrGroupNotFound) {
|
||||
t.Fatalf("missing delete error = %v, want ErrGroupNotFound", err)
|
||||
}
|
||||
|
||||
group := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Ambiguous pair")
|
||||
writeLifecycleGroup(t, ds, "ACCOUNT1", "7654321", group)
|
||||
if err := ds.atomicWriteFile(ds.retiredGroupFilePath("ACCOUNT1", "7654321"), []byte("retired\n")); err != nil {
|
||||
t.Fatalf("write conflicting tombstone: %v", err)
|
||||
}
|
||||
|
||||
err := ds.DeleteGroup("ACCOUNT1", "7654321")
|
||||
if !errors.Is(err, ErrGroupDeleteAmbiguous) {
|
||||
t.Fatalf("ambiguous delete error = %v, want ErrGroupDeleteAmbiguous", err)
|
||||
}
|
||||
if !ds.rootExists(ds.groupFilePath("ACCOUNT1", "7654321")) {
|
||||
t.Fatal("ambiguous delete removed the active group")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetiredStereoPairGetsFreshGeneration(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
const account = "ACCOUNT1"
|
||||
|
||||
first := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Pair")
|
||||
firstID, err := ds.AddGroup(account, &first)
|
||||
if err != nil {
|
||||
t.Fatalf("add first generation: %v", err)
|
||||
}
|
||||
if err := ds.DeleteGroupGenerationForDevice("MASTER", firstID, &first); err != nil {
|
||||
t.Fatalf("retire first generation: %v", err)
|
||||
}
|
||||
if !ds.rootExists(ds.retiredGroupFilePath(account, firstID)) {
|
||||
t.Fatalf("retired generation %q has no tombstone", firstID)
|
||||
}
|
||||
tombstone, err := ds.rootReadFile(ds.retiredGroupFilePath(account, firstID))
|
||||
if err != nil {
|
||||
t.Fatalf("read retired generation: %v", err)
|
||||
}
|
||||
var retired models.Group
|
||||
if err := xml.Unmarshal(tombstone, &retired); err != nil {
|
||||
t.Fatalf("retired generation does not contain group XML: %v", err)
|
||||
}
|
||||
if retired.ID != firstID {
|
||||
t.Fatalf("retired generation ID = %q, want %q", retired.ID, firstID)
|
||||
}
|
||||
if err := ds.DeleteGroup(account, firstID); err != nil {
|
||||
t.Fatalf("repeat exact generation delete should be idempotent: %v", err)
|
||||
}
|
||||
|
||||
second := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Pair")
|
||||
secondID, err := ds.AddGroup(account, &second)
|
||||
if err != nil {
|
||||
t.Fatalf("add second generation: %v", err)
|
||||
}
|
||||
if secondID == firstID {
|
||||
t.Fatalf("new physical generation reused retired ID %q", firstID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
)
|
||||
|
||||
func TestReadPresetSnapshotStates(t *testing.T) {
|
||||
account := "1234567"
|
||||
device := "DEVICE01"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
write []byte
|
||||
want PresetSnapshotState
|
||||
}{
|
||||
{name: "missing", want: PresetSnapshotMissing},
|
||||
{name: "empty", write: []byte(" \n"), want: PresetSnapshotEmpty},
|
||||
{name: "malformed", write: []byte("<presets>"), want: PresetSnapshotMalformed},
|
||||
{name: "valid empty", write: []byte("<presets></presets>"), want: PresetSnapshotValid},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
if tt.write != nil {
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile)
|
||||
if err := ds.MkdirAllUnderBase(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAllUnderBase: %v", err)
|
||||
}
|
||||
if err := ds.WriteFileUnderBase(path, tt.write, 0o644); err != nil {
|
||||
t.Fatalf("WriteFileUnderBase: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
snapshot, err := ds.ReadPresetSnapshot(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPresetSnapshot: %v", err)
|
||||
}
|
||||
if snapshot.State != tt.want {
|
||||
t.Fatalf("state = %q, want %q", snapshot.State, tt.want)
|
||||
}
|
||||
if tt.want == PresetSnapshotValid && len(snapshot.Presets) != 0 {
|
||||
t.Fatalf("valid empty snapshot returned %d presets", len(snapshot.Presets))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadPresetSnapshotReturnsPersistedPresets(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
account := "1234567"
|
||||
device := "DEVICE01"
|
||||
want := models.ServicePreset{
|
||||
ServiceContentItem: models.ServiceContentItem{Name: "Radio", Location: "http://radio.example/stream"},
|
||||
ID: "1",
|
||||
ButtonNumber: "1",
|
||||
}
|
||||
|
||||
if err := ds.SavePresets(account, device, []models.ServicePreset{want}); err != nil {
|
||||
t.Fatalf("SavePresets: %v", err)
|
||||
}
|
||||
|
||||
snapshot, err := ds.ReadPresetSnapshot(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPresetSnapshot: %v", err)
|
||||
}
|
||||
if snapshot.State != PresetSnapshotValid {
|
||||
t.Fatalf("state = %q, want %q", snapshot.State, PresetSnapshotValid)
|
||||
}
|
||||
if len(snapshot.Presets) != 1 || snapshot.Presets[0].Name != want.Name || snapshot.Presets[0].Location != want.Location {
|
||||
t.Fatalf("presets = %+v, want Radio at %s", snapshot.Presets, want.Location)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package datastore
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
@@ -19,6 +20,10 @@ func TestIsSafeIdentifier(t *testing.T) {
|
||||
{"abc-123", true},
|
||||
{"abc.123", true},
|
||||
{"00:11:22:33:44:55", true},
|
||||
// #634: third-party/manual pairing tools (e.g. the USB-stick
|
||||
// SSH-enable method) can report a non-numeric margeAccountUUID.
|
||||
{"stick@local", true},
|
||||
{strings.Repeat("a", maxSafeIdentifierLength), true},
|
||||
{"", false},
|
||||
{"/", false},
|
||||
{"\\", false},
|
||||
@@ -30,7 +35,6 @@ func TestIsSafeIdentifier(t *testing.T) {
|
||||
{"a..b", false},
|
||||
{"a b", false},
|
||||
{"a!b", false},
|
||||
{"a@b", false},
|
||||
{"a#b", false},
|
||||
{"a$b", false},
|
||||
{"a%b", false},
|
||||
@@ -39,12 +43,17 @@ func TestIsSafeIdentifier(t *testing.T) {
|
||||
{"a*b", false},
|
||||
{"a(b", false},
|
||||
{"a)b", false},
|
||||
{"a<b", false},
|
||||
{"a>b", false},
|
||||
{`a"b`, false},
|
||||
{"a'b", false},
|
||||
{strings.Repeat("a", maxSafeIdentifierLength+1), false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
result := isSafeIdentifier(test.id)
|
||||
result := IsSafeIdentifier(test.id)
|
||||
if result != test.expected {
|
||||
t.Errorf("isSafeIdentifier(%q) = %v; expected %v", test.id, result, test.expected)
|
||||
t.Errorf("IsSafeIdentifier(%q) = %v; expected %v", test.id, result, test.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,6 +81,8 @@ func TestSaveDeviceInfo_Validation(t *testing.T) {
|
||||
{"acc1", "dev/1", true, "invalid device ID"},
|
||||
{"acc..1", "dev1", true, "invalid account ID"},
|
||||
{"acc1", "dev..1", true, "invalid device ID"},
|
||||
// #634: a non-numeric margeAccountUUID is now accepted.
|
||||
{"stick@local", "dev1", false, ""},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
@@ -85,3 +96,40 @@ func TestSaveDeviceInfo_Validation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAccountInfo_Validation(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "datastore-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
ds := NewDataStore(tmpDir)
|
||||
|
||||
tests := []struct {
|
||||
account string
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{"acc1", false, ""},
|
||||
// #634: a non-numeric margeAccountUUID reported via
|
||||
// POST /streaming/account (see HandleMargeCreateAccount) must
|
||||
// be validated the same way SaveDeviceInfo already validates
|
||||
// device-reported account IDs.
|
||||
{"stick@local", false, ""},
|
||||
{"acc/1", true, "invalid account ID"},
|
||||
{"acc..1", true, "invalid account ID"},
|
||||
{"a<b", true, "invalid account ID"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
err := ds.SaveAccountInfo(test.account, &models.ServiceAccountInfo{AccountID: test.account})
|
||||
if (err != nil) != test.wantErr {
|
||||
t.Errorf("SaveAccountInfo(%q) error = %v, wantErr %v", test.account, err, test.wantErr)
|
||||
continue
|
||||
}
|
||||
if test.wantErr && err.Error() != test.errMsg {
|
||||
t.Errorf("SaveAccountInfo(%q) error message = %q, want %q", test.account, err.Error(), test.errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +203,9 @@ func TestHandleTuneInToken(t *testing.T) {
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
// Even when the speaker presents a refresh_token from a prior session,
|
||||
// the handler always mints its own token rather than echoing the input
|
||||
// back verbatim.
|
||||
payload := `{"grant_type":"refresh_token","refresh_token":"test-refresh-token"}`
|
||||
res, err := http.Post(ts.URL+"/bmx/tunein/v1/token", "application/json", strings.NewReader(payload))
|
||||
if err != nil {
|
||||
@@ -214,16 +217,98 @@ func TestHandleTuneInToken(t *testing.T) {
|
||||
t.Errorf("Expected status 200, got %v", res.Status)
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if resp["access_token"] != "test-refresh-token" {
|
||||
t.Errorf("Expected access_token 'test-refresh-token', got %v", resp["access_token"])
|
||||
accessToken, _ := resp["access_token"].(string)
|
||||
refreshToken, _ := resp["refresh_token"].(string)
|
||||
|
||||
if accessToken == "" {
|
||||
t.Error("Expected a non-empty access_token")
|
||||
}
|
||||
if resp["refresh_token"] != "test-refresh-token" {
|
||||
t.Errorf("Expected refresh_token 'test-refresh-token', got %v", resp["refresh_token"])
|
||||
if refreshToken == "" {
|
||||
t.Error("Expected a non-empty refresh_token")
|
||||
}
|
||||
if accessToken != refreshToken {
|
||||
t.Errorf("Expected access_token and refresh_token to match, got %q and %q", accessToken, refreshToken)
|
||||
}
|
||||
if accessToken == "test-refresh-token" {
|
||||
t.Error("Expected a minted token, not an echo of the request's refresh_token")
|
||||
}
|
||||
|
||||
embedded, ok := resp["_embedded"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected _embedded object in response, got %v", resp["_embedded"])
|
||||
}
|
||||
if _, ok := embedded["bmx_account"]; !ok {
|
||||
t.Error("Expected _embedded.bmx_account in response")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleTuneInToken_Bootstrap covers the real-world trigger of the
|
||||
// original bug: a speaker's very first TUNEIN token request, made under
|
||||
// authenticationModel.anonymousAccount (autoCreate: true), has no prior
|
||||
// refresh_token to present at all. The old handler echoed back whatever
|
||||
// (possibly empty/absent) refresh_token it received, so this exact request
|
||||
// used to round-trip an empty token and the speaker would reject every
|
||||
// subsequent TUNEIN ContentItem selection with INVALID_SOURCE — even though
|
||||
// browse and search worked fine and the same stream URL played successfully
|
||||
// via Play URL/LOCAL_INTERNET_RADIO.
|
||||
func TestHandleTuneInToken_Bootstrap(t *testing.T) {
|
||||
r, _ := setupRouter("http://localhost:8001", nil)
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
payload := `{"grant_type":"refresh_token"}`
|
||||
res, err := http.Post(ts.URL+"/bmx/tunein/v1/token", "application/json", strings.NewReader(payload))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %v", res.Status)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
accessToken, _ := resp["access_token"].(string)
|
||||
refreshToken, _ := resp["refresh_token"].(string)
|
||||
|
||||
if accessToken == "" {
|
||||
t.Error("Bootstrap request (no refresh_token) must still receive a non-empty access_token")
|
||||
}
|
||||
if refreshToken == "" {
|
||||
t.Error("Bootstrap request (no refresh_token) must still receive a non-empty refresh_token")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleTuneInToken_MalformedBodyRejected covers the request-validation
|
||||
// path that stayed in place alongside the unconditional-mint fix: a body
|
||||
// that isn't even valid JSON is not a normal bootstrap call (which is still
|
||||
// well-formed JSON, just with an empty/absent refresh_token — see
|
||||
// TestHandleTuneInToken_Bootstrap), so it should be rejected rather than
|
||||
// silently minting a token anyway.
|
||||
func TestHandleTuneInToken_MalformedBodyRejected(t *testing.T) {
|
||||
r, _ := setupRouter("http://localhost:8001", nil)
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
res, err := http.Post(ts.URL+"/bmx/tunein/v1/token", "application/json", strings.NewReader("not json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400 for a malformed body, got %v", res.Status)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,10 +9,11 @@ import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/bmx"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/stations"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
@@ -118,8 +119,32 @@ func (s *Server) HandleTuneInPlaybackPodcast(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInToken returns a TuneIn access token.
|
||||
// HandleTuneInToken returns an anonymous TuneIn access token.
|
||||
//
|
||||
// The registry advertises TUNEIN with authenticationModel.anonymousAccount
|
||||
// (autoCreate: true) — see bmx_services.json — so the speaker's very first
|
||||
// call here is a bootstrap request with no prior refresh_token to present.
|
||||
// This handler used to echo back whatever refresh_token the speaker sent
|
||||
// (mirroring an authenticated-refresh recording), which meant that very
|
||||
// first bootstrap call round-tripped an empty token. The speaker never
|
||||
// obtained a usable TuneIn account and subsequently rejected every TUNEIN
|
||||
// ContentItem selection with INVALID_SOURCE, even though /sources reported
|
||||
// TUNEIN as READY (READY only reflects registry presence, not a live
|
||||
// account). Match HandleOrionToken's unconditional-generation shape
|
||||
// instead: always mint a token, regardless of what the speaker sent.
|
||||
//
|
||||
// The token itself is a stable, constant value (datastore.GenerateSerialSecret
|
||||
// is a pure function of the hardcoded "tunein" literal), not a fresh or
|
||||
// per-device secret — it's the same value for every device and every call.
|
||||
// That's fine today only because the Authorization gate is disabled for all
|
||||
// TuneIn handlers (see HandleTuneInReport below) and nothing validates the
|
||||
// token's uniqueness; if either of those ever changes, this would need a
|
||||
// real per-device/per-session token instead.
|
||||
func (s *Server) HandleTuneInToken(w http.ResponseWriter, r *http.Request) {
|
||||
// The unconditional mint above means we never use the decoded values,
|
||||
// but we still decode the body so a genuinely malformed request (not a
|
||||
// normal bootstrap call, which is valid JSON with an empty/absent
|
||||
// refresh_token) gets a 400 instead of silently succeeding.
|
||||
var req struct {
|
||||
GrantType string `json:"grant_type"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
@@ -130,18 +155,23 @@ func (s *Server) HandleTuneInToken(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// For now, we return the provided refresh_token as access_token and refresh_token,
|
||||
// mirroring the behavior seen in the recordings.
|
||||
resp := map[string]string{
|
||||
"access_token": req.RefreshToken,
|
||||
"refresh_token": req.RefreshToken,
|
||||
token := datastore.GenerateSerialSecret("tunein")
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"_embedded": map[string]interface{}{
|
||||
"bmx_account": map[string]string{
|
||||
"displayName": "",
|
||||
"username": "",
|
||||
},
|
||||
},
|
||||
"access_token": token,
|
||||
"refresh_token": token,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,7 +223,7 @@ func (s *Server) HandleTuneInReport(w http.ResponseWriter, r *http.Request) {
|
||||
// - (empty) → top-level browse
|
||||
// - {encodedURI} → browse the given TuneIn URI
|
||||
// - sub/{n}/{encodedURI} → single subsection of a browse page
|
||||
// - profiles/{type}/{id}/{encodedURI} → artist/program profile page
|
||||
// - profiles/{encodedURI} → artist/program profile page
|
||||
func (s *Server) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request) {
|
||||
// Authorization gate temporarily disabled (was: 401 if header missing).
|
||||
// The Stockholm browser proxy doesn't inject Authorization for requests
|
||||
@@ -206,7 +236,7 @@ func (s *Server) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
wildcard := chi.URLParam(r, "*")
|
||||
|
||||
resp, err := parseTuneInNavigatePath(wildcard)
|
||||
resp, err := stations.Navigate(stations.ProviderTuneIn, wildcard)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -219,47 +249,6 @@ func (s *Server) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func parseTuneInNavigatePath(wildcard string) (interface{}, error) {
|
||||
if wildcard == "" {
|
||||
return bmx.TuneInNavigate("", nil)
|
||||
}
|
||||
|
||||
firstSlash := strings.Index(wildcard, "/")
|
||||
if firstSlash == -1 {
|
||||
return bmx.TuneInNavigate(wildcard, nil)
|
||||
}
|
||||
|
||||
prefix := wildcard[:firstSlash]
|
||||
rest := wildcard[firstSlash+1:]
|
||||
|
||||
switch prefix {
|
||||
case "sub":
|
||||
secondSlash := strings.Index(rest, "/")
|
||||
if secondSlash == -1 {
|
||||
return bmx.TuneInNavigate(rest, nil)
|
||||
}
|
||||
|
||||
n, err := strconv.Atoi(rest[:secondSlash])
|
||||
if err != nil {
|
||||
return bmx.TuneInNavigate(wildcard, nil)
|
||||
}
|
||||
|
||||
return bmx.TuneInNavigate(rest[secondSlash+1:], &n)
|
||||
|
||||
case "profiles":
|
||||
// profiles/{type}/{id}/{encodedURI}
|
||||
parts := strings.SplitN(rest, "/", 3)
|
||||
if len(parts) < 3 {
|
||||
return bmx.TuneInNavigate(wildcard, nil)
|
||||
}
|
||||
|
||||
return bmx.TuneInNavigateProfile(parts[2])
|
||||
|
||||
default:
|
||||
return bmx.TuneInNavigate(wildcard, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInSearch returns live TuneIn search results for the given query.
|
||||
func (s *Server) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) {
|
||||
// Authorization gate temporarily disabled (was: 401 if header missing).
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -255,7 +256,12 @@ func (s *Server) addServiceHTTP(tw *tar.Writer, client *http.Client, devices []m
|
||||
if !seenAccounts[dev.AccountID] {
|
||||
seenAccounts[dev.AccountID] = true
|
||||
pfx := "http/service/account-" + dev.AccountID
|
||||
acct := base + "/streaming/account/" + dev.AccountID
|
||||
// url.PathEscape, not raw concatenation: account/device IDs can
|
||||
// contain characters like '@' (#634) that are safe as datastore
|
||||
// keys but would otherwise need escaping to survive as URL path
|
||||
// segments intact (e.g. a literal '?' or '#' would truncate the
|
||||
// path here, though IsSafeIdentifier already excludes those).
|
||||
acct := base + "/streaming/account/" + url.PathEscape(dev.AccountID)
|
||||
tryAdd(pfx+"/full.xml", acct+"/full")
|
||||
tryAdd(pfx+"/sources.xml", acct+"/sources")
|
||||
tryAdd(pfx+"/presets.xml", acct+"/presets")
|
||||
@@ -266,7 +272,7 @@ func (s *Server) addServiceHTTP(tw *tar.Writer, client *http.Client, devices []m
|
||||
}
|
||||
|
||||
dpfx := "http/service/account-" + dev.AccountID + "/device-" + dev.DeviceID
|
||||
dpath := base + "/streaming/account/" + dev.AccountID + "/device/" + dev.DeviceID
|
||||
dpath := base + "/streaming/account/" + url.PathEscape(dev.AccountID) + "/device/" + url.PathEscape(dev.DeviceID)
|
||||
tryAdd(dpfx+"/presets.xml", dpath+"/presets")
|
||||
tryAdd(dpfx+"/recents.xml", dpath+"/recents")
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
|
||||
"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/marge"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@@ -64,6 +66,11 @@ func (s *Server) HandleMargeCreateAccount(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
}
|
||||
|
||||
if !datastore.IsSafeIdentifier(id) {
|
||||
http.Error(w, "Invalid account ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
info := &models.ServiceAccountInfo{
|
||||
AccountID: id,
|
||||
PreferredLanguage: req.PreferredLanguage,
|
||||
@@ -813,17 +820,21 @@ func (s *Server) HandleMargeDeviceGroup(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
group, err := s.ds.GetGroupForDevice(account, device)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(constants.XMLHeader + `<group/>`))
|
||||
if errors.Is(err, datastore.ErrGroupNotFound) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(constants.XMLHeader + `<group/>`))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
data, err := xml.Marshal(group)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(constants.XMLHeader + `<group/>`))
|
||||
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -864,7 +875,13 @@ func (s *Server) HandleMargeAddGroup(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
id, err := s.ds.AddGroup(account, &group)
|
||||
if err != nil {
|
||||
if errors.Is(err, datastore.ErrGroupMembershipConflict) {
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -931,7 +948,15 @@ func (s *Server) HandleMargeDeleteGroup(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
if err := s.ds.DeleteGroup(account, groupID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
switch {
|
||||
case errors.Is(err, datastore.ErrGroupNotFound):
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
case errors.Is(err, datastore.ErrGroupDeleteAmbiguous):
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
default:
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -940,10 +965,10 @@ func (s *Server) HandleMargeDeleteGroup(w http.ResponseWriter, r *http.Request)
|
||||
_, _ = w.Write([]byte(constants.XMLHeader + `<status>Group deleted successfully</status>`))
|
||||
}
|
||||
|
||||
// HandleMargeDeleteAccountGroups removes all stereo groups stored for an
|
||||
// account. Speakers send DELETE /streaming/account/{id}/group/ (trailing
|
||||
// slash, no group ID) during stereo-pair teardown. Master and slave often
|
||||
// live in different accounts, so each speaker deletes its own copy here.
|
||||
// HandleMargeDeleteAccountGroups handles legacy speaker teardown callbacks
|
||||
// that carry no group ID (e.g. factory reset). It deletes every stored group
|
||||
// for the account, mirroring the real firmware expectation that this call
|
||||
// clears all group state so a later Create isn't blocked by a stale record.
|
||||
func (s *Server) HandleMargeDeleteAccountGroups(w http.ResponseWriter, r *http.Request) {
|
||||
account := chi.URLParam(r, "account")
|
||||
|
||||
@@ -959,7 +984,7 @@ func (s *Server) HandleMargeDeleteAccountGroups(w http.ResponseWriter, r *http.R
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(constants.XMLHeader + `<status>Group deleted successfully</status>`))
|
||||
_, _ = w.Write([]byte(constants.XMLHeader + `<status>Group teardown acknowledged</status>`))
|
||||
}
|
||||
|
||||
// HandleMusicProviderIsEligible returns the music provider eligibility.
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func margeLifecycleRouter(ds *datastore.DataStore) http.Handler {
|
||||
server := NewServer(ds, nil, "http://localhost:8001", false, false, false)
|
||||
router := chi.NewRouter()
|
||||
router.Use(clientIPMiddleware(false, nil, nil))
|
||||
router.Get("/streaming/account/{account}/device/{device}/group", server.HandleMargeDeviceGroup)
|
||||
router.Post("/streaming/account/{account}/group/", server.HandleMargeAddGroup)
|
||||
router.Delete("/streaming/account/{account}/group/", server.HandleMargeDeleteAccountGroups)
|
||||
router.Delete("/streaming/account/{account}/group/{groupId}", server.HandleMargeDeleteGroup)
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
func margeLifecycleGroupXML(master, left, right, name string) string {
|
||||
return fmt.Sprintf(`<group><name>%s</name><masterDeviceId>%s</masterDeviceId><roles>`+
|
||||
`<groupRole><deviceId>%s</deviceId><role>LEFT</role></groupRole>`+
|
||||
`<groupRole><deviceId>%s</deviceId><role>RIGHT</role></groupRole>`+
|
||||
`</roles></group>`, name, master, left, right)
|
||||
}
|
||||
|
||||
func margeLifecycleRequest(t *testing.T, handler http.Handler, method, path, remoteAddr, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
request := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
request.RemoteAddr = remoteAddr
|
||||
request.Header.Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
return recorder
|
||||
}
|
||||
|
||||
func margeLifecycleGroup(master, left, right, name string) models.Group {
|
||||
return models.Group{
|
||||
Name: name,
|
||||
MasterDeviceID: master,
|
||||
Roles: models.GroupRoles{Roles: []models.GroupRole{
|
||||
{DeviceID: left, Role: "LEFT"},
|
||||
{DeviceID: right, Role: "RIGHT"},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func countMargeLifecycleGroupFiles(t *testing.T, ds *datastore.DataStore, account string) int {
|
||||
t.Helper()
|
||||
|
||||
entries, err := os.ReadDir(ds.AccountDevicesDir(account))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0
|
||||
}
|
||||
|
||||
t.Fatalf("read account devices directory: %v", err)
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && strings.HasPrefix(entry.Name(), "Group_") && strings.HasSuffix(entry.Name(), ".xml") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
func TestMargeAddGroupRetryReusesStoredGroup(t *testing.T) {
|
||||
ds := datastore.NewDataStore(t.TempDir())
|
||||
handler := margeLifecycleRouter(ds)
|
||||
const (
|
||||
account = "ACCOUNT1"
|
||||
path = "/streaming/account/" + account + "/group/"
|
||||
)
|
||||
|
||||
first := margeLifecycleRequest(t, handler, http.MethodPost, path, "192.0.2.10:1234",
|
||||
margeLifecycleGroupXML("MASTER", "MASTER", "SLAVE", "Original name"))
|
||||
if first.Code != http.StatusCreated {
|
||||
t.Fatalf("first POST status = %d, want 201; body=%s", first.Code, first.Body.String())
|
||||
}
|
||||
|
||||
var firstGroup models.Group
|
||||
if err := xml.Unmarshal(first.Body.Bytes(), &firstGroup); err != nil {
|
||||
t.Fatalf("decode first response: %v; body=%s", err, first.Body.String())
|
||||
}
|
||||
firstLocation := first.Header().Get("Location")
|
||||
if firstGroup.ID == "" || !strings.HasSuffix(firstLocation, "/group/"+firstGroup.ID) {
|
||||
t.Fatalf("first response ID=%q Location=%q", firstGroup.ID, firstLocation)
|
||||
}
|
||||
|
||||
retry := margeLifecycleRequest(t, handler, http.MethodPost, path, "192.0.2.10:1234",
|
||||
margeLifecycleGroupXML("MASTER", "MASTER", "SLAVE", "Retry name"))
|
||||
if retry.Code != http.StatusCreated {
|
||||
t.Fatalf("retry POST status = %d, want 201; body=%s", retry.Code, retry.Body.String())
|
||||
}
|
||||
|
||||
var retryGroup models.Group
|
||||
if err := xml.Unmarshal(retry.Body.Bytes(), &retryGroup); err != nil {
|
||||
t.Fatalf("decode retry response: %v; body=%s", err, retry.Body.String())
|
||||
}
|
||||
if retryGroup.ID != firstGroup.ID || retryGroup.Name != firstGroup.Name {
|
||||
t.Fatalf("retry group = %#v, want stored group %#v", retryGroup, firstGroup)
|
||||
}
|
||||
if got := retry.Header().Get("Location"); got != firstLocation {
|
||||
t.Fatalf("retry Location = %q, want %q", got, firstLocation)
|
||||
}
|
||||
if got := retry.Header().Get("Content-Type"); got != "application/vnd.bose.streaming-v1.2+xml" {
|
||||
t.Fatalf("retry Content-Type = %q", got)
|
||||
}
|
||||
if got := countMargeLifecycleGroupFiles(t, ds, account); got != 1 {
|
||||
t.Fatalf("stored group files = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMargeAddGroupMembershipConflictReturns409(t *testing.T) {
|
||||
ds := datastore.NewDataStore(t.TempDir())
|
||||
handler := margeLifecycleRouter(ds)
|
||||
const (
|
||||
account = "ACCOUNT1"
|
||||
path = "/streaming/account/" + account + "/group/"
|
||||
)
|
||||
|
||||
first := margeLifecycleRequest(t, handler, http.MethodPost, path, "192.0.2.10:1234",
|
||||
margeLifecycleGroupXML("MASTER1", "MASTER1", "SHARED", "First pair"))
|
||||
if first.Code != http.StatusCreated {
|
||||
t.Fatalf("first POST status = %d, want 201; body=%s", first.Code, first.Body.String())
|
||||
}
|
||||
|
||||
conflict := margeLifecycleRequest(t, handler, http.MethodPost, path, "192.0.2.20:1234",
|
||||
margeLifecycleGroupXML("MASTER2", "MASTER2", "SHARED", "Conflicting pair"))
|
||||
if conflict.Code != http.StatusConflict {
|
||||
t.Fatalf("conflicting POST status = %d, want 409; body=%s", conflict.Code, conflict.Body.String())
|
||||
}
|
||||
if got := countMargeLifecycleGroupFiles(t, ds, account); got != 1 {
|
||||
t.Fatalf("stored group files = %d after conflict, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMargeDeviceGroupReturnsEmptyGroupOnlyWhenNotFound(t *testing.T) {
|
||||
ds := datastore.NewDataStore(t.TempDir())
|
||||
response := margeLifecycleRequest(t, margeLifecycleRouter(ds), http.MethodGet,
|
||||
"/streaming/account/ACCOUNT1/device/MASTER/group", "192.0.2.10:1234", "")
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("missing group GET status = %d, want 200; body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if got := response.Body.String(); got != constants.XMLHeader+`<group/>` {
|
||||
t.Fatalf("missing group GET body = %q, want empty group", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMargeDeviceGroupReturns500ForMalformedOrUnreadableData(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(t *testing.T, path string)
|
||||
}{
|
||||
{
|
||||
name: "malformed XML",
|
||||
setup: func(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
|
||||
if err := os.WriteFile(path, []byte("<group>"), 0600); err != nil {
|
||||
t.Fatalf("write malformed group: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unreadable group",
|
||||
setup: func(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
|
||||
if err := os.Symlink("missing-group-target", path); err != nil {
|
||||
t.Fatalf("create unreadable group symlink: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ds := datastore.NewDataStore(t.TempDir())
|
||||
if err := os.MkdirAll(ds.AccountDevicesDir("ACCOUNT1"), 0755); err != nil {
|
||||
t.Fatalf("create account directory: %v", err)
|
||||
}
|
||||
test.setup(t, filepath.Join(ds.AccountDevicesDir("ACCOUNT1"), "Group_1234567.xml"))
|
||||
|
||||
response := margeLifecycleRequest(t, margeLifecycleRouter(ds), http.MethodGet,
|
||||
"/streaming/account/ACCOUNT1/device/MASTER/group", "192.0.2.10:1234", "")
|
||||
if response.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("invalid group GET status = %d, want 500; body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if strings.Contains(response.Body.String(), "<group/>") {
|
||||
t.Fatalf("invalid group GET returned empty-group success: %s", response.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMargeDeleteAccountGroupsDeletesAllGroupsForAccount(t *testing.T) {
|
||||
ds := datastore.NewDataStore(t.TempDir())
|
||||
handler := margeLifecycleRouter(ds)
|
||||
const account = "ACCOUNT1"
|
||||
const otherAccount = "ACCOUNT2"
|
||||
|
||||
group := margeLifecycleGroup("MASTER", "MASTER", "SLAVE", "Current pair")
|
||||
if _, err := ds.AddGroup(account, &group); err != nil {
|
||||
t.Fatalf("add group: %v", err)
|
||||
}
|
||||
|
||||
otherGroup := margeLifecycleGroup("OTHER-MASTER", "OTHER-MASTER", "OTHER-SLAVE", "Other account pair")
|
||||
if _, err := ds.AddGroup(otherAccount, &otherGroup); err != nil {
|
||||
t.Fatalf("add group in other account: %v", err)
|
||||
}
|
||||
|
||||
response := margeLifecycleRequest(t, handler, http.MethodDelete,
|
||||
"/streaming/account/"+account+"/group/", "192.0.2.10:1234", "")
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("DELETE status = %d, want 200; body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
|
||||
if _, err := ds.GetGroupForDevice(account, "MASTER"); !errors.Is(err, datastore.ErrGroupNotFound) {
|
||||
t.Fatalf("generation-less teardown did not delete stored group: err=%v", err)
|
||||
}
|
||||
|
||||
if current, err := ds.GetGroupForDevice(otherAccount, "OTHER-MASTER"); err != nil || current.ID != otherGroup.ID {
|
||||
t.Fatalf("teardown affected a different account's group: group=%#v err=%v", current, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMargeDeleteGroupDoesNotHideAmbiguousActiveGeneration(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
ds := datastore.NewDataStore(baseDir)
|
||||
handler := margeLifecycleRouter(ds)
|
||||
const account = "ACCOUNT1"
|
||||
|
||||
group := margeLifecycleGroup("MASTER", "MASTER", "SLAVE", "Current pair")
|
||||
if _, err := ds.AddGroup(account, &group); err != nil {
|
||||
t.Fatalf("add group: %v", err)
|
||||
}
|
||||
|
||||
retiredPath := filepath.Join(ds.AccountDevicesDir(account), "Group_"+group.ID+".retired")
|
||||
if err := os.WriteFile(retiredPath, []byte("retired\n"), 0600); err != nil {
|
||||
t.Fatalf("create conflicting tombstone: %v", err)
|
||||
}
|
||||
|
||||
response := margeLifecycleRequest(t, handler, http.MethodDelete,
|
||||
"/streaming/account/"+account+"/group/"+group.ID, "192.0.2.10:1234", "")
|
||||
if response.Code != http.StatusConflict {
|
||||
t.Fatalf("ambiguous DELETE status = %d, want 409; body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if current, err := ds.GetGroupForDevice(account, "MASTER"); err != nil || current.ID != group.ID {
|
||||
t.Fatalf("ambiguous DELETE changed active generation: group=%#v err=%v", current, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMargeDeleteMissingGroupReturns404(t *testing.T) {
|
||||
ds := datastore.NewDataStore(t.TempDir())
|
||||
response := margeLifecycleRequest(t, margeLifecycleRouter(ds), http.MethodDelete,
|
||||
"/streaming/account/ACCOUNT1/group/MISSING", "192.0.2.10:1234", "")
|
||||
if response.Code != http.StatusNotFound {
|
||||
t.Fatalf("missing DELETE status = %d, want 404; body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func TestHandleMigrateDeviceMapsMigrationDataNotReadyToConflict(t *testing.T) {
|
||||
const (
|
||||
accountID = "1234567"
|
||||
deviceID = "DEVICE01"
|
||||
)
|
||||
|
||||
speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/info" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(w, `<info deviceID="%s"><name>Test Speaker</name><margeAccountUUID>%s</margeAccountUUID></info>`, deviceID, accountID)
|
||||
}))
|
||||
defer speaker.Close()
|
||||
|
||||
ds := datastore.NewDataStore(t.TempDir())
|
||||
deviceIP := strings.TrimPrefix(speaker.URL, "http://")
|
||||
if err := ds.SaveDeviceInfo(accountID, deviceID, &models.ServiceDeviceInfo{
|
||||
DeviceID: deviceID,
|
||||
AccountID: accountID,
|
||||
IPAddress: deviceIP,
|
||||
Name: "Test Speaker",
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
manager := setup.NewManager("http://aftertouch.example:8000", ds, nil)
|
||||
server := NewServer(ds, manager, manager.ServerURL, false, false, false)
|
||||
router := chi.NewRouter()
|
||||
router.Post("/migrate/{deviceId}", server.HandleMigrateDevice)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/migrate/"+deviceID+"?method=telnet", nil)
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusConflict, recorder.Body.String())
|
||||
}
|
||||
|
||||
var response struct {
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if response.OK {
|
||||
t.Fatal("response ok = true, want false")
|
||||
}
|
||||
if !strings.Contains(response.Message, "Data Sync") {
|
||||
t.Fatalf("message = %q, want actionable Data Sync guidance", response.Message)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/health"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -61,12 +62,12 @@ type pairAccountResponse struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// HandlePairAccount associates the device with the supplied 7-digit account ID,
|
||||
// HandlePairAccount associates the device with the supplied account ID,
|
||||
// trying HTTP /setMargeAccount first and falling back to telnet
|
||||
// `envswitch accountid set`.
|
||||
//
|
||||
// Query params:
|
||||
// - account_id (required) — must pass setup.IsValidAccountID
|
||||
// - account_id (required) — must pass datastore.IsSafeIdentifier
|
||||
func (s *Server) HandlePairAccount(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
@@ -75,8 +76,8 @@ func (s *Server) HandlePairAccount(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
accountID := r.URL.Query().Get("account_id")
|
||||
if !setup.IsValidAccountID(accountID) {
|
||||
writeJSONError(w, http.StatusBadRequest, "account_id must be exactly 7 digits")
|
||||
if !datastore.IsSafeIdentifier(accountID) {
|
||||
writeJSONError(w, http.StatusBadRequest, "account_id must be a non-empty, path-safe identifier")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -145,7 +146,7 @@ func (s *Server) completeSpeakerPairingFix(target health.Target) (string, error)
|
||||
}
|
||||
|
||||
accountID := target.Account
|
||||
if !setup.IsValidAccountID(accountID) {
|
||||
if !datastore.IsSafeIdentifier(accountID) {
|
||||
known, _ := s.ds.ListAccounts()
|
||||
|
||||
generated, genErr := setup.GenerateAccountID(known)
|
||||
|
||||
@@ -3,6 +3,8 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -12,8 +14,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fmt"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
@@ -719,8 +719,19 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
output, err := s.sm.MigrateSpeaker(deviceIP, targetURL, proxyURL, options, method)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
|
||||
var notReady *setup.MigrationDataNotReadyError
|
||||
|
||||
switch {
|
||||
case errors.As(err, ¬Ready):
|
||||
status = http.StatusConflict
|
||||
case errors.Is(err, setup.ErrInvalidTelnetURL):
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.WriteHeader(status)
|
||||
|
||||
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error(), "output": output}); encodeErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
@@ -738,7 +749,9 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// HandleRevertMigration reverts the migration for a device.
|
||||
// HandleRevertMigration reverts the migration for a device. The existing
|
||||
// no-query path restores SSH/filesystem backups; method=telnet restores only
|
||||
// the four canonical Bose service URLs and accepts the migration URL overrides.
|
||||
func (s *Server) HandleRevertMigration(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
@@ -766,10 +779,50 @@ func (s *Server) HandleRevertMigration(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
output, err := s.sm.RevertMigration(deviceIP)
|
||||
if err != nil {
|
||||
method := r.URL.Query().Get("method")
|
||||
if (method == "" || method == "ssh") && len(presentTelnetURLOverrides(r.URL.Query())) > 0 {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
||||
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"message": "Telnet URL overrides require method=telnet",
|
||||
}); encodeErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var output string
|
||||
|
||||
switch method {
|
||||
case "", "ssh":
|
||||
output, err = s.sm.RevertMigration(deviceIP)
|
||||
case string(setup.MigrationMethodTelnet):
|
||||
output, err = s.sm.RevertTelnetURLs(deviceIP, parseMigrationOptions(r.URL.Query()))
|
||||
default:
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
||||
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"message": fmt.Sprintf("Unsupported revert method %q; expected ssh or telnet", method),
|
||||
}); encodeErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, setup.ErrInvalidTelnetURL) {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
|
||||
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error(), "output": output}); encodeErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
@@ -1274,7 +1327,16 @@ func (s *Server) HandleTestDNSRedirection(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
}
|
||||
|
||||
// HandleInitialSync fetches presets, recents and sources from the device and saves them to the datastore.
|
||||
// HandleInitialSync fetches presets, recents and sources from the device
|
||||
// and saves them to the datastore.
|
||||
//
|
||||
// If applying the fetched presets/recents would shrink what's already
|
||||
// stored, the sync is not applied — the response comes back 409 with the
|
||||
// diff describing what would be removed — unless the caller passes
|
||||
// ?confirmed=true, in which case it's applied unconditionally. Every call
|
||||
// re-fetches live from the speaker at that moment (see
|
||||
// setup.SyncDeviceData), so a confirmed retry re-checks current reality
|
||||
// rather than replaying a possibly-stale earlier response.
|
||||
func (s *Server) HandleInitialSync(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
@@ -1288,13 +1350,25 @@ func (s *Server) HandleInitialSync(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.sm.SyncDeviceData(deviceIP); err != nil {
|
||||
confirmed := r.URL.Query().Get("confirmed") == "true"
|
||||
|
||||
result, err := s.sm.SyncDeviceData(deviceIP, confirmed)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok": true}`))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if !result.Applied {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
if encodeErr := json.NewEncoder(w).Encode(result); encodeErr != nil {
|
||||
log.Printf("HandleInitialSync: failed to encode result for device %s: %s", sanitizeLog(deviceID), sanitizeErr(encodeErr))
|
||||
}
|
||||
}
|
||||
|
||||
// HandleRebootDevice reboots a device.
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -704,6 +705,10 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
sm.NewSSH = func(host string) setup.SSHClient {
|
||||
return &mockSSH{host: host}
|
||||
}
|
||||
telnetMock := &mockSetupTelnet{}
|
||||
sm.NewTelnet = func(string) setup.TelnetClient {
|
||||
return telnetMock
|
||||
}
|
||||
|
||||
// Mock HTTPGet to avoid real network timeouts
|
||||
sm.HTTPGet = func(url string) (*http.Response, error) {
|
||||
@@ -714,6 +719,12 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
Body: io.NopCloser(strings.NewReader(xml)),
|
||||
}, nil
|
||||
}
|
||||
if strings.HasSuffix(url, "/presets") {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`<presets/>`)),
|
||||
}, nil
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNotFound,
|
||||
Body: io.NopCloser(strings.NewReader("Not Found")),
|
||||
@@ -732,6 +743,7 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
IPAddress: "192.0.2.10",
|
||||
AccountID: "default",
|
||||
})
|
||||
_ = ds.SavePresets("default", "192.0.2.10", nil)
|
||||
|
||||
// 1. Test GET /setup/ca.crt
|
||||
res, err := http.Get(ts.URL + "/setup/ca.crt")
|
||||
@@ -769,6 +781,22 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
t.Errorf("Migrate: Expected output field in response")
|
||||
}
|
||||
|
||||
// Unsafe telnet migration input is a client error and never reaches the speaker.
|
||||
unsafeMigrateCommandCount := len(telnetMock.commands)
|
||||
unsafeTarget := url.QueryEscape("http://192.0.2.100:8000\r\nsys reboot")
|
||||
res, err = http.Post(ts.URL+"/setup/migrate/192.0.2.10?method=telnet&target_url="+unsafeTarget, "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("Unsafe telnet migration: expected status 400, got %v", res.Status)
|
||||
}
|
||||
if len(telnetMock.commands) != unsafeMigrateCommandCount {
|
||||
t.Errorf("Unsafe telnet migration sent commands: before=%d after=%d", unsafeMigrateCommandCount, len(telnetMock.commands))
|
||||
}
|
||||
|
||||
// 3. Test POST /setup/trust-ca/{deviceIP}
|
||||
res, err = http.Post(ts.URL+"/setup/trust-ca/192.0.2.10", "application/json", nil)
|
||||
if err != nil {
|
||||
@@ -831,6 +859,81 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
if _, ok := result["output"]; !ok {
|
||||
t.Errorf("RemoveRemote: Expected output field in response")
|
||||
}
|
||||
|
||||
// 6. Telnet-only revert uses the dedicated URL restore path.
|
||||
res, err = http.Post(ts.URL+"/setup/revert/192.0.2.10?method=telnet", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Telnet revert: expected status OK, got %v", res.Status)
|
||||
}
|
||||
if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("Telnet revert: failed to decode response: %v", err)
|
||||
}
|
||||
if result["ok"] != true {
|
||||
t.Errorf("Telnet revert: expected ok=true, got %v", result["ok"])
|
||||
}
|
||||
|
||||
commands := strings.Join(telnetMock.commands, "\n")
|
||||
for _, want := range []string{
|
||||
"sys configuration margeServerUrl https://streaming.bose.com",
|
||||
"sys configuration statsServerUrl https://events.api.bosecm.com",
|
||||
"sys configuration swUpdateUrl https://worldwide.bose.com/updates/soundtouch",
|
||||
"sys configuration bmxRegistryUrl https://content.api.bose.io/bmx/registry/v1/services",
|
||||
"envswitch boseurls set https://streaming.bose.com https://worldwide.bose.com/updates/soundtouch",
|
||||
"getpdo CurrentSystemConfiguration",
|
||||
} {
|
||||
if !strings.Contains(commands, want) {
|
||||
t.Errorf("Telnet revert commands missing %q:\n%s", want, commands)
|
||||
}
|
||||
}
|
||||
|
||||
// 7. SSH revert rejects telnet-only URL overrides instead of ignoring them.
|
||||
commandCount := len(telnetMock.commands)
|
||||
res, err = http.Post(ts.URL+"/setup/revert/192.0.2.10?method=ssh&marge_url=https%3A%2F%2Foverride.example%2Fmarge", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("SSH revert with telnet overrides: expected status 400, got %v", res.Status)
|
||||
}
|
||||
if len(telnetMock.commands) != commandCount {
|
||||
t.Errorf("SSH revert with telnet overrides sent telnet commands: before=%d after=%d", commandCount, len(telnetMock.commands))
|
||||
}
|
||||
|
||||
// 8. Unsafe telnet URL input fails before any command is sent.
|
||||
unsafeURL := url.QueryEscape("https://override.example/marge\r\nsys reboot")
|
||||
res, err = http.Post(ts.URL+"/setup/revert/192.0.2.10?method=telnet&marge_url="+unsafeURL, "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("Unsafe telnet URL: expected status 400, got %v", res.Status)
|
||||
}
|
||||
if len(telnetMock.commands) != commandCount {
|
||||
t.Errorf("Unsafe telnet URL sent commands: before=%d after=%d", commandCount, len(telnetMock.commands))
|
||||
}
|
||||
|
||||
// 9. Unknown revert methods fail before touching either transport.
|
||||
res, err = http.Post(ts.URL+"/setup/revert/192.0.2.10?method=invalid", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("Invalid revert method: expected status 400, got %v", res.Status)
|
||||
}
|
||||
if len(telnetMock.commands) != commandCount {
|
||||
t.Errorf("Invalid revert method sent telnet commands: before=%d after=%d", commandCount, len(telnetMock.commands))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveDevice(t *testing.T) {
|
||||
@@ -932,6 +1035,40 @@ type mockSSH struct {
|
||||
uploaded map[string][]byte
|
||||
}
|
||||
|
||||
type mockSetupTelnet struct {
|
||||
commands []string
|
||||
}
|
||||
|
||||
func (m *mockSetupTelnet) Dial() error { return nil }
|
||||
|
||||
func (m *mockSetupTelnet) Probe() (string, error) { return "->", nil }
|
||||
|
||||
func (m *mockSetupTelnet) SendCommand(command string) (string, error) {
|
||||
m.commands = append(m.commands, command)
|
||||
if command == "getpdo CurrentSystemConfiguration" {
|
||||
return `margeServerUrl {
|
||||
text: "https://streaming.bose.com"
|
||||
}
|
||||
statsServerUrl {
|
||||
text: "https://events.api.bosecm.com"
|
||||
}
|
||||
swUpdateUrl {
|
||||
text: "https://worldwide.bose.com/updates/soundtouch"
|
||||
}
|
||||
bmxRegistryUrl {
|
||||
text: "https://content.api.bose.io/bmx/registry/v1/services"
|
||||
}`, nil
|
||||
}
|
||||
if fields := strings.Fields(command); len(fields) == 5 &&
|
||||
fields[0] == "envswitch" && fields[1] == "boseurls" && fields[2] == "set" {
|
||||
return "Setting Bose Server URLs to " + fields[3] + " and " + fields[4] + "\n->OK\n->", nil
|
||||
}
|
||||
|
||||
return "OK", nil
|
||||
}
|
||||
|
||||
func (m *mockSetupTelnet) Close() error { return nil }
|
||||
|
||||
func (m *mockSSH) Run(command string) (string, error) {
|
||||
if strings.Contains(command, "cat /etc/hosts") {
|
||||
m.runCount++
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// TestHandleInitialSync_DestructiveSyncReturns409ThenAppliesWhenConfirmed is
|
||||
// an HTTP-level regression test for #614's Sync-button data-loss bug (see
|
||||
// setup.TestSyncDeviceData_DestructiveSyncRequiresConfirmation for the
|
||||
// lower-level coverage of the same fix): a device already has more presets
|
||||
// stored than the mock speaker's live /presets now reports. The first,
|
||||
// unconfirmed sync request must come back 409 with the diff and must not
|
||||
// write anything; a retry with ?confirmed=true must apply it.
|
||||
func TestHandleInitialSync_DestructiveSyncReturns409ThenAppliesWhenConfirmed(t *testing.T) {
|
||||
const (
|
||||
accountID = "1234567"
|
||||
deviceID = "AABBCCDDEEFF"
|
||||
)
|
||||
|
||||
// A real local server, not a black-hole IP: notifySpeakerSourcesUpdated
|
||||
// (part of the confirmed-apply path) uses its own HTTP client rather
|
||||
// than the injectable sm.HTTPGet, so it needs somewhere real to fail
|
||||
// fast against (404) instead of timing out.
|
||||
mockDevice := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/info":
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprintf(w, `<?xml version="1.0" encoding="UTF-8"?><info deviceID="%s"><name>Test Device</name><type>SoundTouch 20</type><margeAccountUUID>%s</margeAccountUUID></info>`, deviceID, accountID)
|
||||
case "/presets":
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?><presets><preset id="1"><ContentItem source="LOCAL_INTERNET_RADIO" type="stationurl" location="/x" isPresetable="true"><itemName>Station 1</itemName></ContentItem></preset></presets>`)
|
||||
case "/recents":
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?><recents></recents>`)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer mockDevice.Close()
|
||||
|
||||
deviceIP := mockDevice.Listener.Addr().String()
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "handlers-sync-destructive-guard-*")
|
||||
if err != nil {
|
||||
t.Fatalf("tempdir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
|
||||
seeded := []models.ServicePreset{
|
||||
{ID: "1", ButtonNumber: "1", ServiceContentItem: models.ServiceContentItem{Name: "Station 1"}},
|
||||
{ID: "2", ButtonNumber: "2", ServiceContentItem: models.ServiceContentItem{Name: "Station 2"}},
|
||||
}
|
||||
if err := ds.SavePresets(accountID, deviceID, seeded); err != nil {
|
||||
t.Fatalf("seed SavePresets: %v", err)
|
||||
}
|
||||
|
||||
if err := ds.SaveDeviceInfo(accountID, deviceID, &models.ServiceDeviceInfo{
|
||||
DeviceID: deviceID,
|
||||
AccountID: accountID,
|
||||
IPAddress: deviceIP,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
sm := setup.NewManager("http://localhost:8000", ds, nil)
|
||||
|
||||
server := NewServer(ds, sm, "http://localhost:8000", false, false, false)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/setup/sync/{deviceId}", server.HandleInitialSync)
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
// First, unconfirmed request: must be refused with 409.
|
||||
resp, err := http.Post(ts.URL+"/api/setup/sync/"+deviceID, "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("POST sync (unconfirmed): %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 409 for a destructive unconfirmed sync, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var result setup.SyncResult
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("decode 409 body: %v", err)
|
||||
}
|
||||
|
||||
if result.Applied {
|
||||
t.Fatal("expected Applied=false in the 409 response")
|
||||
}
|
||||
|
||||
if !result.Destructive {
|
||||
t.Fatal("expected Destructive=true in the 409 response")
|
||||
}
|
||||
|
||||
presetsAfterRefusal, err := ds.GetPresets(accountID, deviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPresets after refused sync: %v", err)
|
||||
}
|
||||
|
||||
if len(presetsAfterRefusal) != 2 {
|
||||
t.Fatalf("expected the original 2 presets to survive the refused sync, got %d", len(presetsAfterRefusal))
|
||||
}
|
||||
|
||||
// Retry, confirmed: must apply.
|
||||
resp2, err := http.Post(ts.URL+"/api/setup/sync/"+deviceID+"?confirmed=true", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("POST sync (confirmed): %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp2.Body.Close() }()
|
||||
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp2.Body)
|
||||
t.Fatalf("expected 200 for a confirmed sync, got %d: %s", resp2.StatusCode, body)
|
||||
}
|
||||
|
||||
var confirmedResult setup.SyncResult
|
||||
if err := json.NewDecoder(resp2.Body).Decode(&confirmedResult); err != nil {
|
||||
t.Fatalf("decode 200 body: %v", err)
|
||||
}
|
||||
|
||||
if !confirmedResult.Applied {
|
||||
t.Fatal("expected Applied=true after confirming")
|
||||
}
|
||||
|
||||
presetsAfterConfirm, err := ds.GetPresets(accountID, deviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPresets after confirmed sync: %v", err)
|
||||
}
|
||||
|
||||
if len(presetsAfterConfirm) != 1 {
|
||||
t.Fatalf("expected confirmed sync to shrink to 1 preset, got %d", len(presetsAfterConfirm))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
)
|
||||
|
||||
// TestIssue634_NonNumericMargeAccountUUIDDoesNotLoseDevice reproduces
|
||||
// https://github.com/gesellix/Bose-SoundTouch/issues/634
|
||||
//
|
||||
// A SoundTouch 10 had SSH enabled via the USB-stick method (rather than
|
||||
// AfterTouch's own telnet-based enable-ssh flow) and, when discovered,
|
||||
// reported a `margeAccountUUID` of `stick@local` instead of the usual
|
||||
// 7-digit numeric Bose account ID. `handleDiscoveredDevice`
|
||||
// (pkg/service/handlers/server.go) passes MargeAccountUUID straight
|
||||
// through to DataStore.SaveDeviceInfo, which used to reject anything
|
||||
// containing "@" as an "invalid account ID" via isSafeIdentifier's
|
||||
// strict alnum-only allowlist. The device was never persisted at all.
|
||||
//
|
||||
// The fix widened datastore.IsSafeIdentifier to accept any device-reported
|
||||
// identifier that's safe to use as a path component / XML value /
|
||||
// telnet-command token, rather than requiring Bose's own 7-digit numeric
|
||||
// format. setup's separate, stricter 7-digit-only IsValidAccountID was
|
||||
// deleted outright in favor of calling datastore.IsSafeIdentifier directly
|
||||
// everywhere an account ID needs validating — one validator, not two. So
|
||||
// handleDiscoveredDevice needed no changes: it already passed
|
||||
// MargeAccountUUID through unmodified, and now the datastore accepts it.
|
||||
//
|
||||
// What this test locks in:
|
||||
//
|
||||
// - A speaker reporting a non-numeric margeAccountUUID is saved
|
||||
// under that account verbatim (not coerced to "default" — "default"
|
||||
// remains reserved for a genuinely empty/unpaired margeAccountUUID).
|
||||
//
|
||||
// What this test would catch if it flipped:
|
||||
//
|
||||
// - If IsSafeIdentifier's allowlist regresses to reject "@" again,
|
||||
// GetDeviceInfo below would error with "invalid account ID" instead
|
||||
// of returning the device — the #634 symptom.
|
||||
func TestIssue634_NonNumericMargeAccountUUIDDoesNotLoseDevice(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "issue634-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
const deviceInfoXML = `<info deviceID="001122334455">
|
||||
<name>Kitchen SoundTouch</name>
|
||||
<type>SoundTouch 10</type>
|
||||
<margeAccountUUID>stick@local</margeAccountUUID>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<softwareVersion>27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29</softwareVersion>
|
||||
<serialNumber>I6332527703739342000020</serialNumber>
|
||||
</component>
|
||||
<component>
|
||||
<componentCategory>PackagedProduct</componentCategory>
|
||||
<softwareVersion>27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29</softwareVersion>
|
||||
<serialNumber>069231P63364828AE</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
<margeURL>https://streaming.bose.com</margeURL>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>001122334455</macAddress>
|
||||
<ipAddress>203.0.113.10</ipAddress>
|
||||
</networkInfo>
|
||||
<moduleType>sm2</moduleType>
|
||||
<variant>rhino</variant>
|
||||
<variantMode>normal</variantMode>
|
||||
<countryCode>US</countryCode>
|
||||
<regionCode>US</regionCode>
|
||||
</info>`
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/info" {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, deviceInfoXML)
|
||||
} else {
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
deviceIP := server.URL[len("http://"):]
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
sm := setup.NewManager(server.URL, ds, nil)
|
||||
|
||||
srv := NewServer(ds, sm, server.URL, false, false, false)
|
||||
|
||||
discoveredDevice := models.DiscoveredDevice{
|
||||
Host: deviceIP,
|
||||
Name: "Legacy Discovery Name",
|
||||
ModelID: "SoundTouch 10",
|
||||
SerialNo: "",
|
||||
DiscoveryMethod: "UPnP",
|
||||
}
|
||||
|
||||
t.Logf("Test scenario: /info reports non-numeric margeAccountUUID %q", "stick@local")
|
||||
|
||||
srv.handleDiscoveredDevice(discoveredDevice)
|
||||
|
||||
const (
|
||||
expectedAccountID = "stick@local"
|
||||
expectedDeviceID = "001122334455"
|
||||
)
|
||||
|
||||
deviceInfo, err := ds.GetDeviceInfo(expectedAccountID, expectedDeviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("device was not saved under account %q: %v (this is the #634 symptom — "+
|
||||
"SaveDeviceInfo rejects the raw margeAccountUUID as an invalid account ID)",
|
||||
expectedAccountID, err)
|
||||
}
|
||||
|
||||
if deviceInfo.Name != "Kitchen SoundTouch" {
|
||||
t.Errorf("Name = %q, want %q", deviceInfo.Name, "Kitchen SoundTouch")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,13 @@ package handlers
|
||||
|
||||
import "net/url"
|
||||
|
||||
var telnetMigrationURLKeys = []string{
|
||||
"marge_url",
|
||||
"stats_url",
|
||||
"sw_update_url",
|
||||
"bmx_url",
|
||||
}
|
||||
|
||||
// migrationOptionKeys is the allow-list of query parameters carried into
|
||||
// the migration manager's options map. Two families coexist:
|
||||
//
|
||||
@@ -43,3 +50,15 @@ func parseMigrationOptions(query url.Values) map[string]string {
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func presentTelnetURLOverrides(query url.Values) []string {
|
||||
var present []string
|
||||
|
||||
for _, key := range telnetMigrationURLKeys {
|
||||
if _, ok := query[key]; ok {
|
||||
present = append(present, key)
|
||||
}
|
||||
}
|
||||
|
||||
return present
|
||||
}
|
||||
|
||||
@@ -226,23 +226,28 @@ 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. Uses HTTPS so the NotApplicable
|
||||
// short-circuit doesn't fire.
|
||||
res := Check443Reachability(8443, "https://1.2.3.4:8443", func(string) (string, error) {
|
||||
return "1.2.3.4", nil
|
||||
// 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. Uses HTTPS so the NotApplicable short-circuit
|
||||
// doesn't fire.
|
||||
//
|
||||
// Deliberately NOT a real routable address like 1.2.3.4: probing an
|
||||
// arbitrary internet destination's reachability depends on the tester's
|
||||
// own network path (transparent proxies, DPI middleboxes, or sinkholed
|
||||
// "known test IP" blocklists can all make it appear reachable), which
|
||||
// is exactly what made this test fail outside CI (#683).
|
||||
res := Check443Reachability(8443, "https://127.0.0.1:8443", func(string) (string, error) {
|
||||
return "127.0.0.1", 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)
|
||||
if res.LANHost != "127.0.0.1" {
|
||||
t.Errorf("expected LANHost=127.0.0.1, got %q", res.LANHost)
|
||||
}
|
||||
|
||||
// In any sane CI environment nothing is listening on :443, so both
|
||||
|
||||
@@ -1352,6 +1352,19 @@ func (s *Server) GetSettings() (string, string) {
|
||||
return s.serverURL, s.httpsServerURL
|
||||
}
|
||||
|
||||
// DNSHijackEnabled reports whether this server's DNS-hijack redirection
|
||||
// (SetDNSSettings) is currently active. DNS-level migration never changes a
|
||||
// speaker's own reported MargeURL -- only how a Bose cloud hostname resolves
|
||||
// on the network -- so callers use this alongside
|
||||
// discovery.InterceptedBoseHosts to recognize such a speaker as effectively
|
||||
// local despite its MargeURL literally being a Bose hostname.
|
||||
func (s *Server) DNSHijackEnabled() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
return s.dnsEnabled
|
||||
}
|
||||
|
||||
// IsSpotifyConfigured returns whether Spotify integration is configured.
|
||||
func (s *Server) IsSpotifyConfigured() bool {
|
||||
s.mu.RLock()
|
||||
|
||||
@@ -129,6 +129,20 @@ pre { background-color: #eee; padding: 10px; overflow-x: auto; font-size: 12px;
|
||||
background-color: #d32f2f;
|
||||
}
|
||||
|
||||
/* .btn-primary marks the one "do the thing" confirm action of a panel
|
||||
(Save Settings, Apply Suggested/Custom Plan, Enable SSH, …). Everything
|
||||
else stays the plain default button so color consistently signals the
|
||||
same two meanings everywhere: primary = confirm, danger = destructive. */
|
||||
.btn-primary {
|
||||
background-color: #2196f3;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background-color: #1769aa;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
|
||||
@@ -533,7 +533,7 @@
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px">
|
||||
<button onclick="updateSettings()">Save Settings</button>
|
||||
<button class="btn-primary" onclick="updateSettings()">Save Settings</button>
|
||||
<span
|
||||
id="settings-status"
|
||||
style="margin-left: 10px; font-size: 0.9em"
|
||||
@@ -691,9 +691,27 @@
|
||||
class="summary-box"
|
||||
style="display: none"
|
||||
>
|
||||
<h3>
|
||||
Migration Summary for
|
||||
<span id="summary-device-display"></span>
|
||||
<h3 style="display: flex; align-items: baseline; justify-content: space-between">
|
||||
<span>
|
||||
Migration Summary for
|
||||
<span id="summary-device-display"></span>
|
||||
</span>
|
||||
<span style="display: flex; gap: 6px">
|
||||
<button
|
||||
type="button"
|
||||
onclick="refreshSummary()"
|
||||
title="Reload summary for this device"
|
||||
aria-label="Reload summary"
|
||||
style="padding: 2px 8px; font-size: 0.85em; line-height: 1; cursor: pointer; font-weight: normal"
|
||||
>↻ Reload</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick="document.getElementById('migration-summary').style.display = 'none'"
|
||||
title="Hide this summary — doesn't change anything on the speaker"
|
||||
aria-label="Hide summary"
|
||||
style="padding: 2px 8px; font-size: 0.85em; line-height: 1; cursor: pointer; font-weight: normal"
|
||||
>✕ Hide</button>
|
||||
</span>
|
||||
</h3>
|
||||
<input type="hidden" id="summary-device-id"/>
|
||||
<p>Migration Status: <span id="migration-status"></span></p>
|
||||
@@ -739,7 +757,8 @@
|
||||
<button
|
||||
id="trust-ca-btn"
|
||||
type="button"
|
||||
style="display: none; background-color: #607d8b; color: white; border: none; padding: 2px 8px; font-size: 0.85em"
|
||||
class="btn-primary"
|
||||
style="display: none; padding: 2px 8px; font-size: 0.85em"
|
||||
>Trust CA Now</button>
|
||||
<a
|
||||
href="/setup/ca.crt"
|
||||
@@ -760,7 +779,24 @@
|
||||
<tbody>
|
||||
<tr style="border-top: 1px solid #eee">
|
||||
<td style="padding: 4px 8px; width: 170px; color: #555" title="The remote_services file controls whether SSH is available after reboot">SSH (remote_services)</td>
|
||||
<td id="state-remote-services-cell" style="padding: 4px 8px"></td>
|
||||
<td id="state-remote-services-cell" style="padding: 4px 8px">
|
||||
<span id="state-remote-services-line"></span>
|
||||
<span style="margin-left: 12px; white-space: nowrap">
|
||||
<button
|
||||
id="ensure-remote-btn"
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
style="padding: 2px 8px; font-size: 0.85em"
|
||||
>Enable SSH (Persist remote_services)</button>
|
||||
<button
|
||||
id="remove-remote-btn"
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
title="Removes the remote_services file — SSH will be disabled after the next reboot"
|
||||
style="margin-left: 6px; padding: 2px 8px; font-size: 0.85em"
|
||||
>Disable SSH (Remove remote_services)</button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="border-top: 1px solid #eee">
|
||||
<td style="padding: 4px 8px; color: #555">Account paired</td>
|
||||
@@ -775,6 +811,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Speaker controls: real device actions that don't depend on
|
||||
the Customize form below, kept always visible rather than
|
||||
behind its collapse (see #621 — Reboot was previously
|
||||
reachable only after expanding "Customize this migration"
|
||||
and scrolling past it). -->
|
||||
<div style="margin: 0 0 16px 0">
|
||||
<h4 style="margin: 0 0 6px 0; font-size: 0.95em">Speaker controls</h4>
|
||||
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap">
|
||||
<button
|
||||
id="revert-migrate-btn"
|
||||
class="btn-danger"
|
||||
style="padding: 10px 20px; display: none"
|
||||
>
|
||||
Revert to Defaults
|
||||
</button>
|
||||
<button
|
||||
id="revert-telnet-btn"
|
||||
class="btn-danger"
|
||||
style="padding: 10px 20px; display: none"
|
||||
>
|
||||
Restore Bose URLs via Telnet
|
||||
</button>
|
||||
<button
|
||||
id="reboot-speaker-btn"
|
||||
style="padding: 10px 20px"
|
||||
>
|
||||
Reboot Speaker
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pre-flight panel: appears when the user clicks Apply,
|
||||
runs the configured checks live, then auto-proceeds on
|
||||
success or surfaces failures with override buttons. -->
|
||||
@@ -808,7 +875,9 @@
|
||||
background-color: #eefbff;
|
||||
"
|
||||
>
|
||||
<strong>HTTPS Connection Test:</strong><br/>
|
||||
<strong>HTTPS Connection Test:</strong>
|
||||
<span id="connection-test-relevance-note" style="font-size: 0.85em"></span>
|
||||
<br/>
|
||||
<span style="font-size: 0.85em; color: #555"
|
||||
>Verify the device can reach the server over
|
||||
HTTPS.</span
|
||||
@@ -819,25 +888,13 @@
|
||||
<div style="margin-top: 10px">
|
||||
<button
|
||||
id="test-connection-explicit-btn"
|
||||
style="
|
||||
background-color: #607d8b;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
font-size: 0.9em;
|
||||
"
|
||||
style="font-size: 0.9em"
|
||||
>
|
||||
Test with Explicit CA.crt
|
||||
</button>
|
||||
<button
|
||||
id="test-connection-trusted-btn"
|
||||
style="
|
||||
background-color: #607d8b;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
font-size: 0.9em;
|
||||
"
|
||||
style="font-size: 0.9em"
|
||||
>
|
||||
Test with Shared Trust Store
|
||||
</button>
|
||||
@@ -879,13 +936,7 @@
|
||||
<div style="margin-top: 10px">
|
||||
<button
|
||||
id="test-dns-btn"
|
||||
style="
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
font-size: 0.9em;
|
||||
"
|
||||
style="font-size: 0.9em"
|
||||
>
|
||||
Test DNS Redirection
|
||||
</button>
|
||||
@@ -963,7 +1014,7 @@
|
||||
<input
|
||||
type="text"
|
||||
id="plan-marge-url"
|
||||
oninput="validatePlanURLs()"
|
||||
oninput="onPlanURLFieldEdited(this)"
|
||||
style="width: 100%; font-family: monospace; font-size: 0.85em; box-sizing: border-box"
|
||||
/>
|
||||
</td>
|
||||
@@ -975,7 +1026,7 @@
|
||||
<input
|
||||
type="text"
|
||||
id="plan-stats-url"
|
||||
oninput="validatePlanURLs()"
|
||||
oninput="onPlanURLFieldEdited(this)"
|
||||
style="width: 100%; font-family: monospace; font-size: 0.85em; box-sizing: border-box"
|
||||
/>
|
||||
</td>
|
||||
@@ -987,7 +1038,7 @@
|
||||
<input
|
||||
type="text"
|
||||
id="plan-sw_update-url"
|
||||
oninput="validatePlanURLs()"
|
||||
oninput="onPlanURLFieldEdited(this)"
|
||||
style="width: 100%; font-family: monospace; font-size: 0.85em; box-sizing: border-box"
|
||||
/>
|
||||
</td>
|
||||
@@ -999,7 +1050,7 @@
|
||||
<input
|
||||
type="text"
|
||||
id="plan-bmx-url"
|
||||
oninput="validatePlanURLs()"
|
||||
oninput="onPlanURLFieldEdited(this)"
|
||||
style="width: 100%; font-family: monospace; font-size: 0.85em; box-sizing: border-box"
|
||||
/>
|
||||
</td>
|
||||
@@ -1067,6 +1118,7 @@
|
||||
<button
|
||||
type="button"
|
||||
id="plan-apply-btn"
|
||||
class="btn-primary"
|
||||
onclick="applySuggestedPlan()"
|
||||
style="font-size: 0.95em"
|
||||
>Apply Suggested Plan</button>
|
||||
@@ -1150,8 +1202,9 @@
|
||||
<button
|
||||
type="button"
|
||||
id="customize-apply-btn"
|
||||
class="btn-primary"
|
||||
onclick="applyCustomPlan()"
|
||||
style="background-color: #4caf50; color: white; border: none; padding: 8px 14px; font-size: 0.95em"
|
||||
style="padding: 8px 14px; font-size: 0.95em"
|
||||
>Apply Custom Plan</button>
|
||||
<span id="customize-apply-status" style="margin-left: 10px; font-size: 0.9em"></span>
|
||||
</div>
|
||||
@@ -1236,64 +1289,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 15px">
|
||||
<button
|
||||
id="revert-migrate-btn"
|
||||
style="
|
||||
background-color: #ff9800;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
display: none;
|
||||
"
|
||||
>
|
||||
Revert to Defaults
|
||||
</button>
|
||||
<button
|
||||
id="reboot-speaker-btn"
|
||||
style="
|
||||
background-color: #607d8b;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
"
|
||||
>
|
||||
Reboot Speaker
|
||||
</button>
|
||||
<button
|
||||
id="ensure-remote-btn"
|
||||
style="
|
||||
background-color: #2196f3;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
"
|
||||
>
|
||||
Enable SSH (Persist remote_services)
|
||||
</button>
|
||||
<button
|
||||
id="remove-remote-btn"
|
||||
title="Removes the remote_services file — SSH will be disabled after the next reboot"
|
||||
style="
|
||||
background-color: #f44336;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
"
|
||||
>
|
||||
Disable SSH (Remove remote_services)
|
||||
</button>
|
||||
<button
|
||||
onclick="
|
||||
document.getElementById(
|
||||
'migration-summary',
|
||||
).style.display = 'none'
|
||||
"
|
||||
style="padding: 10px 20px"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -598,7 +598,19 @@ async function fetchDevices() {
|
||||
if (devices.length === 0) {
|
||||
container.innerHTML = "No devices known yet.";
|
||||
} else {
|
||||
let html = "<table><tr><th>Name & Model</th><th>IP Address</th><th>Device & Account ID</th><th>Firmware & Serial</th><th>Method</th><th>Action</th></tr>";
|
||||
// Built via DOM APIs rather than innerHTML/template strings: device
|
||||
// fields (name, IDs, serials, ...) come from speakers and third-party
|
||||
// pairing tools (see #634) and are not restricted to HTML/JS-safe
|
||||
// characters, so they must never be parsed as markup or concatenated
|
||||
// into inline event-handler attributes.
|
||||
const table = document.createElement("table");
|
||||
const headerRow = document.createElement("tr");
|
||||
for (const label of ["Name & Model", "IP Address", "Device & Account ID", "Firmware & Serial", "Method", "Action"]) {
|
||||
const th = document.createElement("th");
|
||||
th.textContent = label;
|
||||
headerRow.appendChild(th);
|
||||
}
|
||||
table.appendChild(headerRow);
|
||||
|
||||
// Clear and repopulate selectors
|
||||
const currentSyncVal = syncSelector.value;
|
||||
@@ -612,25 +624,83 @@ async function fetchDevices() {
|
||||
|
||||
devices.forEach((d) => {
|
||||
const methodLabel = d.discovery_method === "manual" ? "👤 Manual" : "🔍 Auto";
|
||||
html += `
|
||||
<tr id="device-row-${d.device_id}">
|
||||
<td class="col-name-model"><div class="col-name">${d.name}</div><div class="col-model" style="font-size: 0.8em; color: #666;">${d.product_code}</div></td>
|
||||
<td class="col-ip">${d.ip_address}</td>
|
||||
<td class="col-ids"><div class="col-deviceid">${d.device_id}</div><div class="col-accountid" style="font-size: 0.8em; color: #666;">${d.account_id || "default"}</div></td>
|
||||
<td class="col-fw-serial"><div class="col-firmware">${d.firmware_version || "0.0.0"}</div><div class="col-serial" style="font-size: 0.8em; color: #666;">${d.device_serial_number}</div></td>
|
||||
<td class="col-method">${methodLabel}</td>
|
||||
<td>
|
||||
<button onclick="toggleDeviceSummary('${d.device_id}')">Inspect</button>
|
||||
<button onclick="prepareSync('${d.device_id}')">Sync Data</button>
|
||||
<button onclick="prepareMigration('${d.device_id}')">Migrate</button>
|
||||
<button id="prime-spotify-${d.device_id}" class="btn-spotify" style="display: none;" onclick="primeSpotify('${d.device_id}')">Prime Spotify</button>
|
||||
<button class="btn-danger" onclick="removeDevice('${d.device_id}', '${d.name}')">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="device-summary-${d.device_id}" style="display: none;">
|
||||
<td colspan="6" id="device-summary-cell-${d.device_id}" style="background: #fafafa; padding: 12px;"></td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
const nameModelCell = document.createElement("td");
|
||||
nameModelCell.className = "col-name-model";
|
||||
const nameDiv = document.createElement("div");
|
||||
nameDiv.className = "col-name";
|
||||
nameDiv.textContent = d.name;
|
||||
const modelDiv = document.createElement("div");
|
||||
modelDiv.className = "col-model";
|
||||
modelDiv.style.cssText = "font-size: 0.8em; color: #666;";
|
||||
modelDiv.textContent = d.product_code;
|
||||
nameModelCell.append(nameDiv, modelDiv);
|
||||
|
||||
const ipCell = document.createElement("td");
|
||||
ipCell.className = "col-ip";
|
||||
ipCell.textContent = d.ip_address;
|
||||
|
||||
const idsCell = document.createElement("td");
|
||||
idsCell.className = "col-ids";
|
||||
const deviceIdDiv = document.createElement("div");
|
||||
deviceIdDiv.className = "col-deviceid";
|
||||
deviceIdDiv.textContent = d.device_id;
|
||||
const accountIdDiv = document.createElement("div");
|
||||
accountIdDiv.className = "col-accountid";
|
||||
accountIdDiv.style.cssText = "font-size: 0.8em; color: #666;";
|
||||
accountIdDiv.textContent = d.account_id || "default";
|
||||
idsCell.append(deviceIdDiv, accountIdDiv);
|
||||
|
||||
const fwCell = document.createElement("td");
|
||||
fwCell.className = "col-fw-serial";
|
||||
const fwDiv = document.createElement("div");
|
||||
fwDiv.className = "col-firmware";
|
||||
fwDiv.textContent = d.firmware_version || "0.0.0";
|
||||
const serialDiv = document.createElement("div");
|
||||
serialDiv.className = "col-serial";
|
||||
serialDiv.style.cssText = "font-size: 0.8em; color: #666;";
|
||||
serialDiv.textContent = d.device_serial_number;
|
||||
fwCell.append(fwDiv, serialDiv);
|
||||
|
||||
const methodCell = document.createElement("td");
|
||||
methodCell.className = "col-method";
|
||||
methodCell.textContent = methodLabel;
|
||||
|
||||
const makeActionButton = (label, onClick, extra) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.textContent = label;
|
||||
btn.addEventListener("click", onClick);
|
||||
if (extra) Object.assign(btn, extra);
|
||||
return btn;
|
||||
};
|
||||
|
||||
const actionCell = document.createElement("td");
|
||||
actionCell.append(
|
||||
makeActionButton("Inspect", () => toggleDeviceSummary(d.device_id)),
|
||||
makeActionButton("Sync Data", () => prepareSync(d.device_id)),
|
||||
makeActionButton("Migrate", () => prepareMigration(d.device_id)),
|
||||
makeActionButton("Prime Spotify", () => primeSpotify(d.device_id), {
|
||||
id: `prime-spotify-${d.device_id}`,
|
||||
className: "btn-spotify",
|
||||
}),
|
||||
makeActionButton("Remove", () => removeDevice(d.device_id, d.name), {className: "btn-danger"}),
|
||||
);
|
||||
actionCell.querySelector(".btn-spotify").style.display = "none";
|
||||
|
||||
const row = document.createElement("tr");
|
||||
row.id = `device-row-${d.device_id}`;
|
||||
row.append(nameModelCell, ipCell, idsCell, fwCell, methodCell, actionCell);
|
||||
|
||||
const summaryRow = document.createElement("tr");
|
||||
summaryRow.id = `device-summary-${d.device_id}`;
|
||||
summaryRow.style.display = "none";
|
||||
const summaryCell = document.createElement("td");
|
||||
summaryCell.colSpan = 6;
|
||||
summaryCell.id = `device-summary-cell-${d.device_id}`;
|
||||
summaryCell.style.cssText = "background: #fafafa; padding: 12px;";
|
||||
summaryRow.appendChild(summaryCell);
|
||||
|
||||
table.append(row, summaryRow);
|
||||
|
||||
const optSync = document.createElement("option");
|
||||
optSync.value = d.device_id;
|
||||
@@ -649,8 +719,7 @@ async function fetchDevices() {
|
||||
eventSelector.appendChild(optEvent);
|
||||
}
|
||||
});
|
||||
html += "</table>";
|
||||
container.innerHTML = html;
|
||||
container.replaceChildren(table);
|
||||
|
||||
if (currentSyncVal) syncSelector.value = currentSyncVal;
|
||||
if (currentMigrationVal) migrationSelector.value = currentMigrationVal;
|
||||
@@ -807,6 +876,57 @@ function getDeviceDisplayName(deviceId) {
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
// buildSyncConfirmMessage renders a human-readable summary of a destructive
|
||||
// SyncResult (see setup.SyncResult/SyncResourceDiff) for window.confirm() —
|
||||
// e.g. "Sync would remove 1 preset: Ici Roussillon. Continue?".
|
||||
function buildSyncConfirmMessage(result) {
|
||||
const lines = ["This Data Sync would remove data that's currently stored:"];
|
||||
for (const diff of result.diffs || []) {
|
||||
if (!diff.destructive) {
|
||||
continue;
|
||||
}
|
||||
const removedNote = diff.removed && diff.removed.length ? ": " + diff.removed.join(", ") : "";
|
||||
lines.push("- " + diff.resource + ": " + diff.currentCount + " → " + diff.incomingCount + removedNote);
|
||||
}
|
||||
lines.push("This usually means the speaker's own live data was incomplete at this moment. Continue anyway?");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// renderSyncResultList builds a <ul> summarising a successful SyncResult —
|
||||
// one <li> per resource, e.g. "presets: 6 → 6", plus a sources count. Built
|
||||
// via DOM APIs (not innerHTML string concatenation) since preset/recent
|
||||
// names ultimately come from user-editable station names on the speaker.
|
||||
function renderSyncResultList(result) {
|
||||
const ul = document.createElement("ul");
|
||||
|
||||
for (const diff of result.diffs || []) {
|
||||
const li = document.createElement("li");
|
||||
li.textContent = diff.resource + ": " + diff.currentCount + " → " + diff.incomingCount;
|
||||
ul.appendChild(li);
|
||||
}
|
||||
|
||||
const sourcesLi = document.createElement("li");
|
||||
sourcesLi.textContent = "sources: " + (result.sourcesCount >= 0 ? result.sourcesCount : "sync failed");
|
||||
ul.appendChild(sourcesLi);
|
||||
|
||||
return ul;
|
||||
}
|
||||
|
||||
async function requestSync(deviceId, confirmed) {
|
||||
let url = "/api/setup/sync/" + encodeURIComponent(deviceId);
|
||||
if (confirmed) {
|
||||
url += "?confirmed=true";
|
||||
}
|
||||
const response = await fetch(url, {method: "POST"});
|
||||
let result = null;
|
||||
try {
|
||||
result = await response.clone().json();
|
||||
} catch (e) {
|
||||
// Non-JSON error body (e.g. a plain-text 500) — handled below via response.text().
|
||||
}
|
||||
return {response, result};
|
||||
}
|
||||
|
||||
async function startSync() {
|
||||
const deviceId = document.getElementById("sync-device-list").value;
|
||||
if (!deviceId) {
|
||||
@@ -826,14 +946,30 @@ async function startSync() {
|
||||
log.innerHTML = "";
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/setup/sync/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||||
if (response.ok) {
|
||||
let {response, result} = await requestSync(deviceId, false);
|
||||
|
||||
if (response.status === 409 && result) {
|
||||
if (!confirm(buildSyncConfirmMessage(result))) {
|
||||
status.style.backgroundColor = "#eef";
|
||||
status.textContent = "Sync cancelled for " + display + " — nothing was changed.";
|
||||
return;
|
||||
}
|
||||
|
||||
({response, result} = await requestSync(deviceId, true));
|
||||
}
|
||||
|
||||
if (response.ok && result) {
|
||||
status.style.backgroundColor = "#dfd";
|
||||
status.textContent = "✅ Sync completed successfully for " + display + "!";
|
||||
results.style.display = "block";
|
||||
log.textContent = "Data fetched and saved to local datastore for " + display + ".\nPresets: OK\nRecents: OK\nSources: OK";
|
||||
|
||||
log.innerHTML = "";
|
||||
const intro = document.createElement("p");
|
||||
intro.textContent = "Data fetched and saved to local datastore for " + display + ".";
|
||||
log.appendChild(intro);
|
||||
log.appendChild(renderSyncResultList(result));
|
||||
} else {
|
||||
const err = await response.text();
|
||||
const err = result ? JSON.stringify(result) : await response.text();
|
||||
throw new Error(err);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -924,7 +1060,14 @@ async function fetchAccountList() {
|
||||
const data = await response.json();
|
||||
const selector = document.getElementById("account-selector");
|
||||
if (selector) {
|
||||
selector.innerHTML = data.accounts.map(acc => `<option value="${acc}">${acc}</option>`).join("");
|
||||
// Account IDs can contain non-alphanumeric characters (e.g.
|
||||
// "stick@local", #634) — built via DOM APIs, not innerHTML.
|
||||
selector.replaceChildren(...data.accounts.map(acc => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = acc;
|
||||
opt.textContent = acc;
|
||||
return opt;
|
||||
}));
|
||||
if (data.accounts.length > 0) {
|
||||
fetchAccountDetails(selector.value);
|
||||
}
|
||||
@@ -949,7 +1092,7 @@ async function fetchAccountDetails(accountId) {
|
||||
try {
|
||||
const response = await fetch(`/api/mgmt/accounts/${encodeURIComponent(accountId)}`);
|
||||
if (!response.ok) {
|
||||
if (metadataEl) metadataEl.innerHTML = `<span style="color:red">Failed to load account details: ${response.statusText}</span>`;
|
||||
if (metadataEl) metadataEl.innerHTML = `<span style="color:red">Failed to load account details: ${escapeHtml(response.statusText)}</span>`;
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
@@ -964,7 +1107,7 @@ async function fetchAccountDetails(accountId) {
|
||||
metadataEl.innerHTML = `
|
||||
${warningNotice}
|
||||
<table style="width: 100%; font-size: 0.9em;">
|
||||
<tr><td style="padding: 4px"><strong>Account ID:</strong></td><td style="padding: 4px">${data.account.account_id}</td></tr>
|
||||
<tr><td style="padding: 4px"><strong>Account ID:</strong></td><td style="padding: 4px">${escapeHtml(data.account.account_id)}</td></tr>
|
||||
<tr><td style="padding: 4px"><strong>Language:</strong></td><td style="padding: 4px">
|
||||
<select id="account-language-select" style="font-size: 0.9em; padding: 2px;">
|
||||
<option value="en" ${data.account.preferred_language === "en" || !data.account.preferred_language ? "selected" : ""}>en</option>
|
||||
@@ -983,7 +1126,7 @@ async function fetchAccountDetails(accountId) {
|
||||
}, {});
|
||||
return Object.entries(grouped).map(([pName, settings]) => `
|
||||
<div style="margin-bottom: 8px;">
|
||||
<strong>${pName}</strong>
|
||||
<strong>${escapeHtml(pName)}</strong>
|
||||
<ul style="margin: 2px 0 0 0; padding-left: 20px; list-style-type: disc;">
|
||||
${settings.map(s => {
|
||||
if ((s.provider_name === "SPOTIFY" || s.provider_id === "15") && s.key_name === "STREAMING_QUALITY") {
|
||||
@@ -991,9 +1134,9 @@ async function fetchAccountDetails(accountId) {
|
||||
<li style="margin-bottom: 4px;">
|
||||
Music Streaming Quality:
|
||||
<select class="provider-setting-select"
|
||||
data-account-id="${data.account.account_id}"
|
||||
data-provider-id="${s.provider_id}"
|
||||
data-key="${s.key_name}"
|
||||
data-account-id="${escapeHtml(data.account.account_id)}"
|
||||
data-provider-id="${escapeHtml(s.provider_id)}"
|
||||
data-key="${escapeHtml(s.key_name)}"
|
||||
style="font-size: 0.9em; padding: 2px; margin-left: 4px;">
|
||||
<option value="1" ${s.value === "1" ? "selected" : ""}>Fastest Streaming - up to 128 kbit/s</option>
|
||||
<option value="2" ${s.value === "2" ? "selected" : ""}>Balanced Quality and Speed - up to 192 kbit/s</option>
|
||||
@@ -1003,7 +1146,7 @@ async function fetchAccountDetails(accountId) {
|
||||
</li>
|
||||
`;
|
||||
}
|
||||
return `<li>${s.key_name}: ${s.value}</li>`;
|
||||
return `<li>${escapeHtml(s.key_name)}: ${escapeHtml(s.value)}</li>`;
|
||||
}).join("")}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1024,7 +1167,7 @@ async function fetchAccountDetails(accountId) {
|
||||
statusEl.style.color = "#666";
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/api/mgmt/accounts/${data.account.account_id}/language`, {
|
||||
const response = await fetch(`/api/mgmt/accounts/${encodeURIComponent(data.account.account_id)}/language`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -1068,7 +1211,7 @@ async function fetchAccountDetails(accountId) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/mgmt/accounts/${accID}/provider-settings`, {
|
||||
const response = await fetch(`/api/mgmt/accounts/${encodeURIComponent(accID)}/provider-settings`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -1110,27 +1253,27 @@ async function fetchAccountDetails(accountId) {
|
||||
|
||||
devicesEl.innerHTML = data.devices.map(device => `
|
||||
<div class="summary-box" style="margin-bottom: 15px; border-left: 5px solid #007bff; padding: 15px;">
|
||||
<div style="display: flex; justify-content: space-between; cursor: pointer; align-items: center;" onclick="toggleInfo('device-details-${device.device_id}')">
|
||||
<h4 style="margin: 0">${device.name || "Unnamed Device"} (${device.product_code})</h4>
|
||||
<div class="device-summary-header" data-toggle-target="device-details-${escapeHtml(device.device_id)}" style="display: flex; justify-content: space-between; cursor: pointer; align-items: center;">
|
||||
<h4 style="margin: 0">${escapeHtml(device.name || "Unnamed Device")} (${escapeHtml(device.product_code)})</h4>
|
||||
<div style="font-size: 0.8em; color: #666">
|
||||
${device.ip_address} | ${device.device_id} <span style="font-size: 1.2em; vertical-align: middle;">▾</span>
|
||||
${escapeHtml(device.ip_address)} | ${escapeHtml(device.device_id)} <span style="font-size: 1.2em; vertical-align: middle;">▾</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="device-details-${device.device_id}" style="display: none; margin-top: 15px; padding-top: 10px; border-top: 1px solid #eee">
|
||||
<div id="device-details-${escapeHtml(device.device_id)}" style="display: none; margin-top: 15px; padding-top: 10px; border-top: 1px solid #eee">
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px">
|
||||
<div>
|
||||
<h5 style="margin: 10px 0 5px 0">Device Metadata</h5>
|
||||
<div style="font-size: 0.85em; background: #f8f9fa; padding: 8px; border-radius: 4px; border: 1px solid #e9ecef">
|
||||
<strong>Serial:</strong> ${device.device_serial_number || device.serial_number || "N/A"}<br>
|
||||
<strong>MAC:</strong> ${device.mac_address || "N/A"}<br>
|
||||
<strong>Version:</strong> ${device.firmware_version || "N/A"}<br>
|
||||
<strong>Discovery:</strong> ${device.discovery_method || "N/A"}
|
||||
<strong>Serial:</strong> ${escapeHtml(device.device_serial_number || device.serial_number || "N/A")}<br>
|
||||
<strong>MAC:</strong> ${escapeHtml(device.mac_address || "N/A")}<br>
|
||||
<strong>Version:</strong> ${escapeHtml(device.firmware_version || "N/A")}<br>
|
||||
<strong>Discovery:</strong> ${escapeHtml(device.discovery_method || "N/A")}
|
||||
</div>
|
||||
|
||||
<h5 style="margin: 15px 0 5px 0">Hardware Components</h5>
|
||||
<ul style="font-size: 0.8em; padding-left: 20px; margin: 0">
|
||||
${device.components ? device.components.map(c => `<li><strong>${c.category || c.type || 'Component'}</strong>: ${c.firmware_version || 'N/A'} <br><small style="color:#777">S/N: ${c.serial_number || 'N/A'}</small></li>`).join("") : "<li>No components found</li>"}
|
||||
${device.components ? device.components.map(c => `<li><strong>${escapeHtml(c.category || c.type || 'Component')}</strong>: ${escapeHtml(c.firmware_version || 'N/A')} <br><small style="color:#777">S/N: ${escapeHtml(c.serial_number || 'N/A')}</small></li>`).join("") : "<li>No components found</li>"}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1150,14 +1293,14 @@ async function fetchAccountDetails(accountId) {
|
||||
const account = (s.account && s.account !== s.username && s.account !== name) ? ` [${s.account}]` : "";
|
||||
const finalName = name || s.type || "Unknown Source";
|
||||
if (finalName) {
|
||||
sourceLabel = `<br><small style="color: #666; font-size: 0.85em;">via ${finalName}${account}</small>`;
|
||||
sourceLabel = `<br><small style="color: #666; font-size: 0.85em;">via ${escapeHtml(finalName)}${escapeHtml(account)}</small>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
<div style="border: 1px solid #ddd; padding: 5px; font-size: 0.8em; background: ${p ? "#e6ffed" : "#f8f9fa"}; border-radius: 3px;">
|
||||
<strong>#${i + 1}</strong>: ${itemName}${sourceLabel}
|
||||
<strong>#${i + 1}</strong>: ${escapeHtml(itemName)}${sourceLabel}
|
||||
</div>
|
||||
`;
|
||||
}).join("")}
|
||||
@@ -1175,13 +1318,13 @@ async function fetchAccountDetails(accountId) {
|
||||
const account = (s.account && s.account !== s.username && s.account !== sName) ? ` [${s.account}]` : "";
|
||||
const finalSName = sName || s.type || "Unknown Source";
|
||||
if (finalSName) {
|
||||
sourceLabel = `<br><small style="color: #666; font-size: 0.9em;">via ${finalSName}${account}</small>`;
|
||||
sourceLabel = `<br><small style="color: #666; font-size: 0.9em;">via ${escapeHtml(finalSName)}${escapeHtml(account)}</small>`;
|
||||
}
|
||||
}
|
||||
const dateRaw = r.last_played_at || r.created_on;
|
||||
const dateObj = dateRaw ? (isNaN(Number(dateRaw)) ? new Date(dateRaw) : new Date(Number(dateRaw) * 1000)) : null;
|
||||
const dateStr = dateObj ? dateObj.toLocaleString('sv-SE') : 'N/A'; // sv-SE produces YYYY-MM-DD HH:MM:SS with 24h time
|
||||
return `<li>${name}${sourceLabel} <br><small style="color:#888">${dateStr}</small></li>`;
|
||||
return `<li>${escapeHtml(name)}${sourceLabel} <br><small style="color:#888">${escapeHtml(dateStr)}</small></li>`;
|
||||
}).join("") : "<li>No recents</li>"}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1196,8 +1339,8 @@ async function fetchAccountDetails(accountId) {
|
||||
const usernameSuffix = (s.username && s.username !== "Local") ? ` (${s.username})` : "";
|
||||
const accountSuffix = (s.account && s.account !== s.username && s.account !== sourceName) ? ` [${s.account}]` : "";
|
||||
return `
|
||||
<span style="background: #eefbff; color: #0056b3; border: 1px solid #b8daff; padding: 2px 8px; border-radius: 12px; font-size: 0.75em" title="Source Type: ${s.type}">
|
||||
${sourceName}${usernameSuffix}${accountSuffix}
|
||||
<span style="background: #eefbff; color: #0056b3; border: 1px solid #b8daff; padding: 2px 8px; border-radius: 12px; font-size: 0.75em" title="Source Type: ${escapeHtml(s.type)}">
|
||||
${escapeHtml(sourceName)}${escapeHtml(usernameSuffix)}${escapeHtml(accountSuffix)}
|
||||
</span>
|
||||
`;
|
||||
}).join("") : "<small style='color:#999'>None</small>"}
|
||||
@@ -1206,10 +1349,17 @@ async function fetchAccountDetails(accountId) {
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
|
||||
// data-toggle-target (not an inline onclick) avoids re-embedding
|
||||
// speaker-controlled device_id inside a JS-string-in-HTML-attribute
|
||||
// context, which HTML-escaping alone cannot make safe.
|
||||
devicesEl.querySelectorAll(".device-summary-header").forEach(el => {
|
||||
el.addEventListener("click", () => toggleInfo(el.dataset.toggleTarget));
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
if (metadataEl) metadataEl.innerHTML = `<span style="color:red">Error: ${error.message}</span>`;
|
||||
if (metadataEl) metadataEl.innerHTML = `<span style="color:red">Error: ${escapeHtml(error.message)}</span>`;
|
||||
console.error("Failed to fetch account details", error);
|
||||
}
|
||||
}
|
||||
@@ -1511,7 +1661,6 @@ async function fetchInteractions() {
|
||||
const path = i.path || i.Path || "";
|
||||
const status = i.status || i.Status || "";
|
||||
const category = i.category || i.Category || "";
|
||||
const session = i.session || i.Session || "";
|
||||
const file = i.file || i.File || "";
|
||||
const scmudcData = i.scmudc_data || i.SCMUDCData || null;
|
||||
|
||||
@@ -1767,7 +1916,7 @@ async function fetchDeviceEvents(deviceId) {
|
||||
list.innerHTML = '<tr><td colspan="3" style="padding: 20px; text-align: center; color: #666;">Loading events...</td></tr>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/setup/devices/${deviceId}/events`);
|
||||
const response = await fetch(`/api/setup/devices/${encodeURIComponent(deviceId)}/events`);
|
||||
const data = await response.json();
|
||||
const events = data.events;
|
||||
|
||||
@@ -1907,7 +2056,7 @@ async function removeDevice(deviceId, name) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/setup/devices/${deviceId}`, {
|
||||
const response = await fetch(`/api/setup/devices/${encodeURIComponent(deviceId)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
@@ -2096,7 +2245,7 @@ async function showSummary(deviceId) {
|
||||
if (accountIdEl && summary.account_id) accountIdEl.innerText = summary.account_id;
|
||||
}
|
||||
|
||||
renderMigrationState(summary);
|
||||
renderMigrationState(summary, targetUrl);
|
||||
renderPlan(summary);
|
||||
renderPlanCurrentURLs(summary);
|
||||
renderPlanPairing(summary, deviceId);
|
||||
@@ -2150,6 +2299,20 @@ async function showSummary(deviceId) {
|
||||
connectionTestPane.style.display = summary.ssh_success ? "block" : "none";
|
||||
}
|
||||
|
||||
// Stays visible either way (the user may still want to check it),
|
||||
// but the default Suggested Plan never needs HTTPS — only note it
|
||||
// as required when the Target URL itself is https://.
|
||||
const connectionTestNote = document.getElementById("connection-test-relevance-note");
|
||||
if (connectionTestNote) {
|
||||
if (isHttpsTarget(targetUrl)) {
|
||||
connectionTestNote.innerText = "Required for your current plan (HTTPS)";
|
||||
connectionTestNote.style.color = "#c62828";
|
||||
} else {
|
||||
connectionTestNote.innerText = "Optional for your current plan (HTTP)";
|
||||
connectionTestNote.style.color = "#666";
|
||||
}
|
||||
}
|
||||
|
||||
const currentConfigElem = document.getElementById("current-config");
|
||||
currentConfigElem.innerText = summary.current_config;
|
||||
currentConfigElem.style.color = summary.ssh_success ? "black" : "red";
|
||||
@@ -2201,6 +2364,11 @@ async function showSummary(deviceId) {
|
||||
revertBtn.disabled = !summary.ssh_success;
|
||||
revertBtn.style.display = summary.original_config ? "inline-block" : "none";
|
||||
|
||||
const revertTelnetBtn = document.getElementById("revert-telnet-btn");
|
||||
revertTelnetBtn.onclick = () => revertTelnetURLs(deviceId);
|
||||
revertTelnetBtn.disabled = !summary.telnet_reachable;
|
||||
revertTelnetBtn.style.display = summary.telnet_revert_available ? "inline-block" : "none";
|
||||
|
||||
const rebootBtn = document.getElementById("reboot-speaker-btn");
|
||||
rebootBtn.onclick = () => reboot(deviceId, ip);
|
||||
rebootBtn.disabled = !anyTransport;
|
||||
@@ -2280,6 +2448,53 @@ async function revert(deviceId, ip) {
|
||||
}
|
||||
}
|
||||
|
||||
async function revertTelnetURLs(deviceId) {
|
||||
if (!deviceId) {
|
||||
alert("Please select a device.");
|
||||
return;
|
||||
}
|
||||
|
||||
const display = getDeviceDisplayName(deviceId);
|
||||
if (!confirm(
|
||||
"Restore the canonical Bose service URLs on " + display + " via Telnet? " +
|
||||
"This changes only the four URL fields; it does not restore filesystem, DNS, CA, SSH, or account state. " +
|
||||
"The writes are sequential, so inspect all four fields if an error occurs.",
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const revertTelnetBtn = document.getElementById("revert-telnet-btn");
|
||||
revertTelnetBtn.disabled = true;
|
||||
|
||||
const statusDiv = document.getElementById("status");
|
||||
statusDiv.style.display = "block";
|
||||
statusDiv.style.backgroundColor = "#ffffcc";
|
||||
statusDiv.textContent = "Restoring canonical Bose URLs on " + display + " via Telnet...";
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
"/api/setup/revert/" + encodeURIComponent(deviceId) + "?method=telnet",
|
||||
{method: "POST"},
|
||||
);
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = "#ccffcc";
|
||||
statusDiv.textContent = "Restored canonical Bose URLs on " + display +
|
||||
". Reboot the speaker, then verify all four persisted URL fields.";
|
||||
} else {
|
||||
revertTelnetBtn.disabled = false;
|
||||
statusDiv.style.backgroundColor = "#ffcccc";
|
||||
statusDiv.textContent = "Telnet URL restore failed for " + display + ": " +
|
||||
(result.message || "Unknown error");
|
||||
}
|
||||
} catch (error) {
|
||||
revertTelnetBtn.disabled = false;
|
||||
statusDiv.style.backgroundColor = "#ffcccc";
|
||||
statusDiv.textContent = "Error restoring Bose URLs on " + display + ": " + error;
|
||||
}
|
||||
}
|
||||
|
||||
async function reboot(deviceId, ip) {
|
||||
if (!deviceId) {
|
||||
alert("Please select a device.");
|
||||
@@ -2558,18 +2773,15 @@ async function migrate(deviceId, ip, method) {
|
||||
}),
|
||||
);
|
||||
|
||||
// Make reboot button available and prominent
|
||||
// Make reboot button available and prominent. It lives in the
|
||||
// always-visible "Speaker controls" row (see #621 — it used to
|
||||
// be reachable only after expanding "Customize this migration"),
|
||||
// so no need to force any collapsed container open here.
|
||||
const rebootBtn = document.getElementById("reboot-speaker-btn");
|
||||
rebootBtn.style.display = "inline-block";
|
||||
rebootBtn.disabled = false;
|
||||
rebootBtn.style.border = "2px solid #000";
|
||||
|
||||
// The Reboot button now lives inside the "Customize this
|
||||
// migration" <details>; expand it so the post-migration
|
||||
// reboot affordance is reachable from the Plan flow too.
|
||||
const customize = rebootBtn.closest("details");
|
||||
if (customize) customize.open = true;
|
||||
|
||||
// Re-show summary but with prominence on reboot
|
||||
summaryDiv.style.display = "block";
|
||||
} else {
|
||||
@@ -2776,6 +2988,26 @@ function onPlanTargetURLChange() {
|
||||
saved.innerText = "✏️ unsaved change — click \"Save as default\" to persist";
|
||||
saved.style.color = "#bf6900";
|
||||
}
|
||||
|
||||
// Re-derive the four service URL fields from the new Target URL, same
|
||||
// as the initial pre-fill on summary render. fillPlanURLInputs still
|
||||
// only overwrites fields the user hasn't hand-edited (tracked via
|
||||
// dataset.autofilled), so this doesn't clobber genuinely manual edits.
|
||||
// Without this, changing Target Domain to e.g. localhost left the four
|
||||
// fields pointed at a stale default with no warning until the user
|
||||
// edited them by hand (#621 follow-up).
|
||||
const soundcork = document.getElementById("plan-soundcork-mode") &&
|
||||
document.getElementById("plan-soundcork-mode").checked;
|
||||
fillPlanURLInputs(defaultServiceURLs(v, {soundcorkMode: soundcork}));
|
||||
}
|
||||
|
||||
// onPlanURLFieldEdited marks a Plan-card URL input as manually edited so
|
||||
// fillPlanURLInputs stops treating it as an auto-fillable default, then
|
||||
// re-validates. Wired from each of the four fields' oninput instead of
|
||||
// calling validatePlanURLs() directly.
|
||||
function onPlanURLFieldEdited(el) {
|
||||
el.dataset.autofilled = "";
|
||||
validatePlanURLs();
|
||||
}
|
||||
|
||||
// saveTargetURLAsDefault posts the current plan-target-url value to
|
||||
@@ -2838,8 +3070,12 @@ function defaultServiceURLs(targetUrl, options = {}) {
|
||||
|
||||
// fillPlanURLInputs writes the four URLs into the Plan card inputs.
|
||||
// force=true overwrites existing values (used by Reset and the
|
||||
// Soundcork toggle); force=false only fills empties (used on summary
|
||||
// render so manual edits survive a refresh).
|
||||
// Soundcork toggle); force=false only fills empties and fields still
|
||||
// flagged dataset.autofilled=true (used on summary render and on Target
|
||||
// URL changes, so manual edits survive but a still-default value tracks
|
||||
// Target URL). Every field this function writes to is (re-)flagged
|
||||
// autofilled; onPlanURLFieldEdited clears the flag the moment a user
|
||||
// types into a field directly.
|
||||
function fillPlanURLInputs(urls, {force = false} = {}) {
|
||||
const fields = [
|
||||
["plan-marge-url", urls.marge],
|
||||
@@ -2850,7 +3086,10 @@ function fillPlanURLInputs(urls, {force = false} = {}) {
|
||||
for (const [id, value] of fields) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) continue;
|
||||
if (force || !el.value) el.value = value;
|
||||
if (force || !el.value || el.dataset.autofilled === "true") {
|
||||
el.value = value;
|
||||
el.dataset.autofilled = "true";
|
||||
}
|
||||
}
|
||||
validatePlanURLs();
|
||||
}
|
||||
@@ -3802,7 +4041,11 @@ function looksTransient(msg) {
|
||||
// DNS interception, CA/TLS), and preconditions (remote_services,
|
||||
// pairing, backup). Reads only fields the backend already exposes —
|
||||
// is_migrated remains the OR of the per-axis booleans.
|
||||
function renderMigrationState(summary) {
|
||||
//
|
||||
// targetUrl is the current Target Domain value, used only to judge
|
||||
// whether CA/TLS is actually relevant to the current plan (see
|
||||
// isHttpsTarget) — the default Suggested Plan never needs it.
|
||||
function renderMigrationState(summary, targetUrl) {
|
||||
// --- Transports ---
|
||||
setStateChip("state-ssh", summary.ssh_success, "Reachable", "Unreachable");
|
||||
setStateChip("state-telnet", summary.telnet_reachable, "Reachable", "Unreachable");
|
||||
@@ -3883,16 +4126,19 @@ function renderMigrationState(summary) {
|
||||
const caLine = document.getElementById("state-ca-line");
|
||||
if (caLine) {
|
||||
caLine.replaceChildren();
|
||||
const v = caVerdict(summary);
|
||||
const v = caVerdict(summary, isHttpsTarget(targetUrl));
|
||||
caLine.appendChild(stateLine(v.icon, v.text, v.note));
|
||||
}
|
||||
|
||||
// --- Preconditions ---
|
||||
const remoteCell = document.getElementById("state-remote-services-cell");
|
||||
if (remoteCell) {
|
||||
remoteCell.replaceChildren();
|
||||
// Like CA/TLS above, the cell also hosts the Enable/Disable SSH
|
||||
// buttons as siblings of this line — only rewrite the verdict span so
|
||||
// they stay put across re-renders.
|
||||
const remoteLine = document.getElementById("state-remote-services-line");
|
||||
if (remoteLine) {
|
||||
remoteLine.replaceChildren();
|
||||
const v = remoteServicesVerdict(summary);
|
||||
remoteCell.appendChild(stateLine(v.icon, v.text, v.note));
|
||||
remoteLine.appendChild(stateLine(v.icon, v.text, v.note));
|
||||
}
|
||||
|
||||
const pairedCell = document.getElementById("state-paired");
|
||||
@@ -4014,9 +4260,22 @@ function dnsInterceptionVerdict(summary) {
|
||||
return {icon: "⚠️", text: "/etc/hosts redirects", note: "(deprecated method)"};
|
||||
}
|
||||
|
||||
function caVerdict(summary) {
|
||||
// isHttpsTarget reports whether a target/service URL uses the https
|
||||
// scheme. Used to distinguish "CA/TLS optional" (the default Suggested
|
||||
// Plan for both XML-over-SSH and Telnet migrates over plain HTTP, no CA
|
||||
// involved) from "CA/TLS required" (Target Domain is https://, or the
|
||||
// Customize form's DNS-interception method is chosen — that one always
|
||||
// targets https://*.bose.com).
|
||||
function isHttpsTarget(url) {
|
||||
return /^https:/i.test((url || "").trim());
|
||||
}
|
||||
|
||||
function caVerdict(summary, httpsRelevant) {
|
||||
if (summary.ca_cert_trusted) return {icon: "✅", text: "Local root CA installed", note: ""};
|
||||
return {icon: "❌", text: "Not installed", note: "(HTTPS to local service will fail TLS validation until injected via SSH)"};
|
||||
if (httpsRelevant) {
|
||||
return {icon: "❌", text: "Not installed", note: "(required — your Target URL is HTTPS; install it before migrating, or click Trust CA Now)"};
|
||||
}
|
||||
return {icon: "⚪", text: "Not installed", note: "(not needed — your Target URL is HTTP; only required if you switch to HTTPS or use the DNS-interception method)"};
|
||||
}
|
||||
|
||||
function remoteServicesVerdict(summary) {
|
||||
@@ -4746,14 +5005,14 @@ async function toggleDeviceSummary(deviceId) {
|
||||
const resp = await fetch(`/api/setup/device-summary/${encodeURIComponent(deviceId)}`);
|
||||
if (!resp.ok) {
|
||||
const txt = await resp.text();
|
||||
cell.innerHTML = `<span style="color:#c62828;">Summary failed: ${resp.status} ${escapeHTML(txt)}</span>`;
|
||||
cell.innerHTML = `<span style="color:#c62828;">Summary failed: ${resp.status} ${escapeHtml(txt)}</span>`;
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
cell.innerHTML = "";
|
||||
cell.appendChild(renderDeviceSummary(data));
|
||||
} catch (e) {
|
||||
cell.innerHTML = `<span style="color:#c62828;">Summary failed: ${escapeHTML(e.message || String(e))}</span>`;
|
||||
cell.innerHTML = `<span style="color:#c62828;">Summary failed: ${escapeHtml(e.message || String(e))}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4958,12 +5217,3 @@ function unreachableBlock(probe) {
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function escapeHTML(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
@@ -215,7 +215,7 @@ func detectOrphanDefaultEntries(ds *datastore.DataStore, paired []models.Service
|
||||
|
||||
if speakerAccount != info.account {
|
||||
log.Printf("[Health] consistency: speaker %s reports margeAccountUUID=%s but ListAllDevices picked %s — preferring the speaker's answer for orphan-deletion suggestions",
|
||||
deviceID, speakerAccount, info.account)
|
||||
sanitizeLog(deviceID), sanitizeLog(speakerAccount), sanitizeLog(info.account))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,21 +289,21 @@ func deleteOrphanAccountEntry(ds *datastore.DataStore, target Target) (string, e
|
||||
if speakerAccount := fetchSpeakerMargeAccount(ctx, speakerIP); speakerAccount != "" {
|
||||
if speakerAccount == target.Account {
|
||||
return "", fmt.Errorf("speaker %s reports margeAccountUUID=%s — refusing to delete <data-dir>/accounts/%s/devices/%s because it's the speaker's currently-active binding (re-paired since the consistency check ran?)",
|
||||
target.Device, speakerAccount, target.Account, target.Device)
|
||||
sanitizeLog(target.Device), sanitizeLog(speakerAccount), sanitizeLog(target.Account), sanitizeLog(target.Device))
|
||||
}
|
||||
|
||||
log.Printf("[Health] deleteOrphanAccountEntry: speaker %s confirmed margeAccountUUID=%s; target account %s is stale, proceeding with delete",
|
||||
target.Device, speakerAccount, target.Account)
|
||||
sanitizeLog(target.Device), sanitizeLog(speakerAccount), sanitizeLog(target.Account))
|
||||
} else {
|
||||
log.Printf("[Health] deleteOrphanAccountEntry: speaker %s at %s not reachable for re-confirmation; relying on operator's Confirm click",
|
||||
target.Device, speakerIP)
|
||||
sanitizeLog(target.Device), sanitizeLog(speakerIP))
|
||||
}
|
||||
} else {
|
||||
log.Printf("[Health] deleteOrphanAccountEntry: no IP recorded for device %s — skipping speaker re-probe", target.Device)
|
||||
log.Printf("[Health] deleteOrphanAccountEntry: no IP recorded for device %s — skipping speaker re-probe", sanitizeLog(target.Device))
|
||||
}
|
||||
|
||||
if target.Account == accountIDDefaultPlaceholder {
|
||||
log.Printf("[Health] deleteOrphanAccountEntry: deleting the \"default\" placeholder entry for device %s; this is normal after pairing completed", target.Device)
|
||||
log.Printf("[Health] deleteOrphanAccountEntry: deleting the \"default\" placeholder entry for device %s; this is normal after pairing completed", sanitizeLog(target.Device))
|
||||
}
|
||||
|
||||
path := ds.AccountDeviceDir(target.Account, target.Device)
|
||||
@@ -316,7 +316,7 @@ func deleteOrphanAccountEntry(ds *datastore.DataStore, target Target) (string, e
|
||||
}
|
||||
|
||||
log.Printf("[Health] Removed orphan account entry %s (account=%s device=%s) at operator request",
|
||||
path, target.Account, target.Device)
|
||||
path, sanitizeLog(target.Account), sanitizeLog(target.Device))
|
||||
|
||||
return fmt.Sprintf("Removed stale account entry %s for device %s.", target.Account, target.Device), nil
|
||||
}
|
||||
@@ -479,7 +479,7 @@ func reclassifyCanonicalSourceIDs(ds *datastore.DataStore, target Target) (strin
|
||||
for i := range sources {
|
||||
if newID, ok := rename[sources[i].ID]; ok {
|
||||
log.Printf("[Health] Re-classify %s: id %s → %s (account=%s device=%s)",
|
||||
sources[i].SourceKeyType, sources[i].ID, newID, target.Account, target.Device)
|
||||
sanitizeLog(sources[i].SourceKeyType), sanitizeLog(sources[i].ID), sanitizeLog(newID), sanitizeLog(target.Account), sanitizeLog(target.Device))
|
||||
|
||||
sources[i].ID = newID
|
||||
|
||||
|
||||
@@ -30,9 +30,12 @@ func suggestAccountForPairing(ds *datastore.DataStore, deviceID string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// isSevenDigitAccountID mirrors setup.IsValidAccountID without
|
||||
// importing the setup package (which would pull in SSH/telnet/certmgr
|
||||
// transitively — see the boundary comment near speakerInfoXML).
|
||||
// isSevenDigitAccountID is intentionally narrower than
|
||||
// datastore.IsSafeIdentifier: it filters suggestAccountForPairing's
|
||||
// candidates down to directories that look like a real Bose-issued
|
||||
// account, not merely safe-to-use ones (a device-reported value like
|
||||
// "stick@local", #634, is a safe identifier but not something to
|
||||
// suggest as a pre-existing "real" account to reuse).
|
||||
func isSevenDigitAccountID(s string) bool {
|
||||
if len(s) != 7 {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package health
|
||||
|
||||
import "strings"
|
||||
|
||||
// sanitizeLog strips newline characters from s to prevent log-injection
|
||||
// (CodeQL go/log-injection). Values from speakers (e.g. margeAccountUUID
|
||||
// read live via :8090/info) may contain attacker-controlled newlines.
|
||||
func sanitizeLog(s string) string {
|
||||
s = strings.ReplaceAll(s, "\n", `\n`)
|
||||
s = strings.ReplaceAll(s, "\r", `\r`)
|
||||
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package marge
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// TestConcurrentUpdatePresetNoLostUpdates is a regression test for #614's
|
||||
// 2026-08-23 reproduction: a reporter's script stored six presets via rapid,
|
||||
// overlapping PUT .../preset/N requests (visible in the speaker's own log as
|
||||
// interleaved connection IDs, never waiting for one PUT to complete before
|
||||
// firing the next). One preset silently vanished from Presets.xml.
|
||||
//
|
||||
// UpdatePreset used to do GetPresets, mutate one slot, SavePresets as three
|
||||
// separate steps with no lock spanning them — a classic lost-update race:
|
||||
// two concurrent calls can each read the same starting list, mutate
|
||||
// different slots, and the second writer's SavePresets clobbers the first
|
||||
// writer's update. Fixed by routing the write through
|
||||
// datastore.MutatePresets, which holds a single write lock for the whole
|
||||
// read-mutate-write cycle.
|
||||
func TestConcurrentUpdatePresetNoLostUpdates(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "marge-concurrent-update-preset-*")
|
||||
if err != nil {
|
||||
t.Fatalf("tempdir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
|
||||
account := "1234567"
|
||||
device := "B0D5CC25479C"
|
||||
|
||||
const presetCount = 6
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
errs := make([]error, presetCount)
|
||||
|
||||
for i := 1; i <= presetCount; i++ {
|
||||
wg.Add(1)
|
||||
|
||||
go func(presetNumber int) {
|
||||
defer wg.Done()
|
||||
|
||||
// sourceid 10003 is the canonical LOCAL_INTERNET_RADIO built-in
|
||||
// (see CanonicalSourceByID) — same shape as Henri's own repro
|
||||
// script, which stored six LOCAL_INTERNET_RADIO presets.
|
||||
putXML := []byte(fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<preset>
|
||||
<name>Station %d</name>
|
||||
<sourceid>10003</sourceid>
|
||||
<location>/custom/v1/playback/station%d</location>
|
||||
<contentItemType>stationurl</contentItemType>
|
||||
</preset>`, presetNumber, presetNumber))
|
||||
|
||||
_, err := UpdatePreset(ds, account, device, presetNumber, putXML)
|
||||
errs[presetNumber-1] = err
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
for i, err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("UpdatePreset(preset=%d) returned error: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
presets, err := ds.GetPresets(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPresets: %v", err)
|
||||
}
|
||||
|
||||
if len(presets) != presetCount {
|
||||
t.Fatalf("expected %d presets after %d concurrent UpdatePreset calls, got %d: %+v", presetCount, presetCount, len(presets), presets)
|
||||
}
|
||||
|
||||
for i, p := range presets {
|
||||
want := fmt.Sprintf("Station %d", i+1)
|
||||
if p.Name != want {
|
||||
t.Errorf("preset slot %d: expected name %q, got %q — a concurrent update was lost", i+1, want, p.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
+152
-91
@@ -695,6 +695,10 @@ func APIVersionsToXML() ([]byte, error) {
|
||||
|
||||
// CreateAccountDevice creates an AccountDevice model for the given account and device.
|
||||
func CreateAccountDevice(ds *datastore.DataStore, account, deviceID string) (models.AccountDevice, error) {
|
||||
return createAccountDevice(ds, account, deviceID, ds.GetPresets)
|
||||
}
|
||||
|
||||
func createAccountDevice(ds *datastore.DataStore, account, deviceID string, readPresets func(string, string) ([]models.ServicePreset, error)) (models.AccountDevice, error) {
|
||||
info, err := ds.GetDeviceInfo(account, deviceID)
|
||||
if err != nil {
|
||||
return models.AccountDevice{}, err
|
||||
@@ -746,12 +750,19 @@ func CreateAccountDevice(ds *datastore.DataStore, account, deviceID string) (mod
|
||||
return models.AccountDevice{}, err
|
||||
}
|
||||
|
||||
presets, _ := ds.GetPresets(account, deviceID)
|
||||
presets, _ := readPresets(account, deviceID)
|
||||
recents, _ := ds.GetRecents(account, deviceID)
|
||||
|
||||
device.Presets = mapPresetsToFullResponse(presets, sources)
|
||||
device.Recents = mapRecentsToFullResponse(recents, sources)
|
||||
|
||||
if len(device.Presets) != len(presets) {
|
||||
log.Printf("[Marge] /full: device %s — read %d preset(s) from disk, embedding %d after source mapping",
|
||||
sanitizeLog(deviceID), len(presets), len(device.Presets))
|
||||
} else {
|
||||
log.Printf("[Marge] /full: device %s — embedding %d preset(s)", sanitizeLog(deviceID), len(device.Presets))
|
||||
}
|
||||
|
||||
return device, nil
|
||||
}
|
||||
|
||||
@@ -1254,6 +1265,10 @@ func fillAccountInfo(ds *datastore.DataStore, account string, resp *models.Accou
|
||||
}
|
||||
|
||||
func getAccountDevices(ds *datastore.DataStore, account string, entries []os.DirEntry) ([]models.AccountDevice, string) {
|
||||
return getAccountDevicesWithPresetReader(ds, account, entries, ds.GetPresets)
|
||||
}
|
||||
|
||||
func getAccountDevicesWithPresetReader(ds *datastore.DataStore, account string, entries []os.DirEntry, readPresets func(string, string) ([]models.ServicePreset, error)) ([]models.AccountDevice, string) {
|
||||
var (
|
||||
devices []models.AccountDevice
|
||||
lastDeviceID string
|
||||
@@ -1267,7 +1282,7 @@ func getAccountDevices(ds *datastore.DataStore, account string, entries []os.Dir
|
||||
deviceID := entry.Name()
|
||||
lastDeviceID = deviceID
|
||||
|
||||
dev, err := CreateAccountDevice(ds, account, deviceID)
|
||||
dev, err := createAccountDevice(ds, account, deviceID, readPresets)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -1462,6 +1477,16 @@ func AccountDevicesToXML(ds *datastore.DataStore, account string) ([]byte, error
|
||||
|
||||
// AccountFullToXML generates a complete account XML with devices, presets, and recents.
|
||||
func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) {
|
||||
return accountFullToXML(ds, account, ds.GetPresets)
|
||||
}
|
||||
|
||||
// AccountFullToXMLReadOnly generates the same account XML without rewriting
|
||||
// legacy preset snapshots while traversing account devices.
|
||||
func AccountFullToXMLReadOnly(ds *datastore.DataStore, account string) ([]byte, error) {
|
||||
return accountFullToXML(ds, account, ds.GetPresetsReadOnly)
|
||||
}
|
||||
|
||||
func accountFullToXML(ds *datastore.DataStore, account string, readPresets func(string, string) ([]models.ServicePreset, error)) ([]byte, error) {
|
||||
devicesDir := ds.AccountDevicesDir(account)
|
||||
|
||||
resp := models.AccountFullResponse{
|
||||
@@ -1479,7 +1504,7 @@ func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
devices, lastDeviceID := getAccountDevices(ds, account, entries)
|
||||
devices, lastDeviceID := getAccountDevicesWithPresetReader(ds, account, entries, readPresets)
|
||||
resp.Devices = devices
|
||||
resp.Sources = getAccountSources(ds, account, lastDeviceID)
|
||||
|
||||
@@ -1501,19 +1526,18 @@ func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) {
|
||||
|
||||
// RemovePreset clears a preset for the specified account and device.
|
||||
func RemovePreset(ds *datastore.DataStore, account, device string, presetNumber int) error {
|
||||
presets, err := ds.GetPresets(account, device)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := ds.MutatePresets(account, device, func(presets []models.ServicePreset) ([]models.ServicePreset, error) {
|
||||
if presetNumber < 1 || presetNumber > len(presets) {
|
||||
// Preset doesn't exist or index out of range, nothing to do
|
||||
return presets, nil
|
||||
}
|
||||
|
||||
if presetNumber < 1 || presetNumber > len(presets) {
|
||||
// Preset doesn't exist or index out of range, nothing to do
|
||||
return nil
|
||||
}
|
||||
presets[presetNumber-1] = models.ServicePreset{}
|
||||
|
||||
presets[presetNumber-1] = models.ServicePreset{}
|
||||
return presets, nil
|
||||
})
|
||||
|
||||
return ds.SavePresets(account, device, presets)
|
||||
return err
|
||||
}
|
||||
|
||||
// resolvePresetSource resolves the source a preset PUT is referencing,
|
||||
@@ -1522,42 +1546,74 @@ func RemovePreset(ds *datastore.DataStore, account, device string, presetNumber
|
||||
// returns the matched source plus the possibly-extended sources slice
|
||||
// (since auto-add appends). Returns (nil, sources) when no match could be
|
||||
// resolved — UpdatePreset turns that into a 500 with a diagnostic log line.
|
||||
func resolvePresetSource(ds *datastore.DataStore, account, device string, sources []models.ConfiguredSource, sourceID string, presetNumber int) (*models.ConfiguredSource, []models.ConfiguredSource) {
|
||||
// findConfiguredSource looks up sourceID in sources, first by exact ID and
|
||||
// then — since the speaker sometimes sends the symbolic provider name (e.g.
|
||||
// <sourceid>TUNEIN</sourceid>) instead of a numeric ID — by SourceKeyType
|
||||
// for the handful of providers known to do that.
|
||||
func findConfiguredSource(sources []models.ConfiguredSource, sourceID string) *models.ConfiguredSource {
|
||||
for i := range sources {
|
||||
if sources[i].ID == sourceID {
|
||||
return &sources[i], sources
|
||||
return &sources[i]
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: SourceID is the symbolic provider name (the speaker
|
||||
// sometimes sends e.g. <sourceid>TUNEIN</sourceid> instead of a
|
||||
// numeric ID); match by SourceKeyType.
|
||||
if sourceID == constants.ProviderInternetRadio || sourceID == constants.ProviderTunein || sourceID == constants.ProviderSpotify || sourceID == constants.ProviderAmazon {
|
||||
for i := range sources {
|
||||
if sources[i].SourceKeyType == sourceID {
|
||||
return &sources[i], sources
|
||||
return &sources[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolvePresetSource(ds *datastore.DataStore, account, device string, sources []models.ConfiguredSource, sourceID string, presetNumber int) (*models.ConfiguredSource, []models.ConfiguredSource) {
|
||||
if src := findConfiguredSource(sources, sourceID); src != nil {
|
||||
return src, sources
|
||||
}
|
||||
|
||||
// Auto-add a canonical built-in source the speaker referenced but
|
||||
// AfterTouch hasn't been told about (post-factory-reset state). For
|
||||
// account-bound sources (Spotify, Amazon) we can't synthesise
|
||||
// credentials, so the caller will reject the PUT instead.
|
||||
if canonical, ok := ds.CanonicalSourceByID(sourceID); ok {
|
||||
log.Printf("[Marge] UpdatePreset(preset=%d): auto-adding canonical source id=%s type=%s providerid=%s — speaker referenced a built-in source not yet in AfterTouch's configured-sources list; saving so the preset can land",
|
||||
presetNumber, sanitizeLog(canonical.ID), sanitizeLog(canonical.SourceKeyType), sanitizeLog(canonical.SourceProviderID))
|
||||
canonical, ok := ds.CanonicalSourceByID(sourceID)
|
||||
if !ok {
|
||||
return nil, sources
|
||||
}
|
||||
|
||||
log.Printf("[Marge] UpdatePreset(preset=%d): auto-adding canonical source id=%s type=%s providerid=%s — speaker referenced a built-in source not yet in AfterTouch's configured-sources list; saving so the preset can land",
|
||||
presetNumber, sanitizeLog(canonical.ID), sanitizeLog(canonical.SourceKeyType), sanitizeLog(canonical.SourceProviderID))
|
||||
|
||||
// Read-mutate-write atomically against the persisted list, not the
|
||||
// possibly-stale `sources` snapshot the caller already read — a
|
||||
// concurrent PUT for a different preset could be auto-adding (or have
|
||||
// just added) a source at the same time, and a plain Get+Save here
|
||||
// would silently lose whichever write landed second.
|
||||
updated, saveErr := ds.MutateConfiguredSources(account, device, func(current []models.ConfiguredSource) ([]models.ConfiguredSource, error) {
|
||||
if src := findConfiguredSource(current, sourceID); src != nil {
|
||||
// Another concurrent caller already added it; nothing to do.
|
||||
return current, nil
|
||||
}
|
||||
|
||||
return append(current, canonical), nil
|
||||
})
|
||||
if saveErr != nil {
|
||||
log.Printf("[Marge] UpdatePreset(preset=%d): SaveConfiguredSources after auto-add failed: %s — the preset will land but the source may not survive a service restart",
|
||||
presetNumber, sanitizeErr(saveErr))
|
||||
|
||||
sources = append(sources, canonical)
|
||||
if saveErr := ds.SaveConfiguredSources(account, device, sources); saveErr != nil {
|
||||
log.Printf("[Marge] UpdatePreset(preset=%d): SaveConfiguredSources after auto-add failed: %s — the preset will land but the source may not survive a service restart",
|
||||
presetNumber, sanitizeErr(saveErr))
|
||||
}
|
||||
|
||||
return &sources[len(sources)-1], sources
|
||||
}
|
||||
|
||||
return nil, sources
|
||||
if src := findConfiguredSource(updated, sourceID); src != nil {
|
||||
return src, updated
|
||||
}
|
||||
|
||||
updated = append(updated, canonical)
|
||||
|
||||
return &updated[len(updated)-1], updated
|
||||
}
|
||||
|
||||
// UpdatePreset updates or creates a preset for the specified account and device.
|
||||
@@ -1567,11 +1623,6 @@ func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber
|
||||
return nil, err
|
||||
}
|
||||
|
||||
presets, err := ds.GetPresets(account, device)
|
||||
if err != nil {
|
||||
presets = []models.ServicePreset{}
|
||||
}
|
||||
|
||||
var newPresetElem struct {
|
||||
Name string `xml:"name"`
|
||||
Username string `xml:"username"`
|
||||
@@ -1637,14 +1688,19 @@ func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber
|
||||
Username: newPresetElem.Name,
|
||||
}
|
||||
|
||||
// Ensure presets list is large enough
|
||||
for len(presets) < presetNumber {
|
||||
presets = append(presets, models.ServicePreset{})
|
||||
}
|
||||
// Read-mutate-write atomically: a concurrent PUT for a different preset
|
||||
// number racing this one must not be able to clobber it. See
|
||||
// MutatePresets — this is the exact interleave that dropped a preset
|
||||
// during #614's rapid-fire repro.
|
||||
if _, err = ds.MutatePresets(account, device, func(presets []models.ServicePreset) ([]models.ServicePreset, error) {
|
||||
for len(presets) < presetNumber {
|
||||
presets = append(presets, models.ServicePreset{})
|
||||
}
|
||||
|
||||
presets[presetNumber-1] = presetObj
|
||||
presets[presetNumber-1] = presetObj
|
||||
|
||||
if err = ds.SavePresets(account, device, presets); err != nil {
|
||||
return presets, nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1773,11 +1829,6 @@ func AddRecent(ds *datastore.DataStore, account, device string, sourceXML []byte
|
||||
return nil, err
|
||||
}
|
||||
|
||||
recents, err := ds.GetRecents(account, device)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var input recentInput
|
||||
if err := xml.Unmarshal(sourceXML, &input); err != nil {
|
||||
return nil, err
|
||||
@@ -1822,9 +1873,20 @@ func AddRecent(ds *datastore.DataStore, account, device string, sourceXML []byte
|
||||
syncMatchingSource(matchingSrc, input)
|
||||
|
||||
utcTime := parseLastPlayedAt(input.LastPlayedAt)
|
||||
recentObj, recents := updateOrCreateRecent(recents, input.Name, matchingSrc, input.ContentItemType, input.Location, device, utcTime)
|
||||
|
||||
if err := ds.SaveRecents(account, device, recents); err != nil {
|
||||
// Read-mutate-write atomically: a concurrent AddRecent/preset call for
|
||||
// the same device racing this one must not be able to clobber it. See
|
||||
// MutatePresets/MutateRecents for why a plain GetRecents+SaveRecents
|
||||
// isn't safe here.
|
||||
var recentObj *models.ServiceRecent
|
||||
|
||||
if _, err := ds.MutateRecents(account, device, func(recents []models.ServiceRecent) ([]models.ServiceRecent, error) {
|
||||
var updated []models.ServiceRecent
|
||||
|
||||
recentObj, updated = updateOrCreateRecent(recents, input.Name, matchingSrc, input.ContentItemType, input.Location, device, utcTime)
|
||||
|
||||
return updated, nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1852,7 +1914,7 @@ func learnSource(ds *datastore.DataStore, account, device string, sources []mode
|
||||
matchingSrc.SecretType = constants.CredentialTypeToken
|
||||
}
|
||||
|
||||
persistLearnedSource(ds, account, device, sources, matchingSrc)
|
||||
persistLearnedSource(ds, account, device, matchingSrc)
|
||||
}
|
||||
|
||||
return matchingSrc, sourceLearned
|
||||
@@ -2002,26 +2064,24 @@ func updateSourceFields(src *models.ConfiguredSource, credentialValue, sourceNam
|
||||
return learned
|
||||
}
|
||||
|
||||
func persistLearnedSource(ds *datastore.DataStore, account, device string, sources []models.ConfiguredSource, matchingSrc *models.ConfiguredSource) {
|
||||
updatedSources := make([]models.ConfiguredSource, len(sources))
|
||||
copy(updatedSources, sources)
|
||||
func persistLearnedSource(ds *datastore.DataStore, account, device string, matchingSrc *models.ConfiguredSource) {
|
||||
// Read-mutate-write atomically against the persisted list, not a
|
||||
// snapshot the caller read earlier — AddRecent and UpdatePreset can
|
||||
// both be learning/auto-adding sources for the same device
|
||||
// concurrently, and a plain Get+Save here would silently lose
|
||||
// whichever write landed second.
|
||||
_, err := ds.MutateConfiguredSources(account, device, func(sources []models.ConfiguredSource) ([]models.ConfiguredSource, error) {
|
||||
for i := range sources {
|
||||
if sources[i].ID == matchingSrc.ID {
|
||||
sources[i] = *matchingSrc
|
||||
|
||||
found := false
|
||||
|
||||
for i := range updatedSources {
|
||||
if updatedSources[i].ID == matchingSrc.ID {
|
||||
updatedSources[i] = *matchingSrc
|
||||
found = true
|
||||
|
||||
break
|
||||
return sources, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
updatedSources = append(updatedSources, *matchingSrc)
|
||||
}
|
||||
|
||||
if err := ds.SaveConfiguredSources(account, device, updatedSources); err != nil {
|
||||
return append(sources, *matchingSrc), nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[MARGE_ERR] Failed to persist learned source for %s: %s", sanitizeLog(device), sanitizeErr(err))
|
||||
}
|
||||
}
|
||||
@@ -2407,7 +2467,6 @@ func AddSource(ds *datastore.DataStore, account, username, providerID, secret, s
|
||||
}
|
||||
|
||||
devID := entry.Name()
|
||||
sources, _ := ds.GetConfiguredSources(account, devID)
|
||||
|
||||
newSrc := models.ConfiguredSource{
|
||||
ID: sourceID,
|
||||
@@ -2437,37 +2496,39 @@ func AddSource(ds *datastore.DataStore, account, username, providerID, secret, s
|
||||
|
||||
PrepareConfiguredSource(&newSrc)
|
||||
|
||||
// Update or append. Most providers are singletons (one account each), so
|
||||
// the same provider replaces the existing entry. STORED_MUSIC is the
|
||||
// exception: each DLNA media server is a separate account (username =
|
||||
// "<UDN>/0"), so it must only replace when the account also matches.
|
||||
// Otherwise registering a second media server overwrites the first, which
|
||||
// then vanishes from /full + /sources and the speaker drops it (only one
|
||||
// media server could ever stay registered).
|
||||
replaced := false
|
||||
// Read-mutate-write atomically against the persisted list, not a
|
||||
// snapshot read before the loop body — see MutateConfiguredSources.
|
||||
_, err := ds.MutateConfiguredSources(account, devID, func(sources []models.ConfiguredSource) ([]models.ConfiguredSource, error) {
|
||||
// Update or append. Most providers are singletons (one account
|
||||
// each), so the same provider replaces the existing entry.
|
||||
// STORED_MUSIC is the exception: each DLNA media server is a
|
||||
// separate account (username = "<UDN>/0"), so it must only
|
||||
// replace when the account also matches. Otherwise registering
|
||||
// a second media server overwrites the first, which then
|
||||
// vanishes from /full + /sources and the speaker drops it
|
||||
// (only one media server could ever stay registered).
|
||||
for i := range sources {
|
||||
sameProvider := sources[i].SourceProviderID == providerID
|
||||
if providerID == strconv.Itoa(constants.StoredMusicProviderID) {
|
||||
// Match on the persisted account identity
|
||||
// (SourceKey.Account), not Username, which does not
|
||||
// round-trip through the datastore.
|
||||
sameProvider = sameProvider && sources[i].SourceKey.Account == username
|
||||
}
|
||||
|
||||
for i := range sources {
|
||||
sameProvider := sources[i].SourceProviderID == providerID
|
||||
if providerID == strconv.Itoa(constants.StoredMusicProviderID) {
|
||||
// Match on the persisted account identity (SourceKey.Account),
|
||||
// not Username, which does not round-trip through the datastore.
|
||||
sameProvider = sameProvider && sources[i].SourceKey.Account == username
|
||||
if sameProvider ||
|
||||
(providerID == strconv.Itoa(constants.SpotifyProviderID) && sources[i].SourceKey.Type == constants.ProviderSpotify) {
|
||||
sources[i] = newSrc
|
||||
|
||||
return sources, nil
|
||||
}
|
||||
}
|
||||
|
||||
if sameProvider ||
|
||||
(providerID == strconv.Itoa(constants.SpotifyProviderID) && sources[i].SourceKey.Type == constants.ProviderSpotify) {
|
||||
sources[i] = newSrc
|
||||
replaced = true
|
||||
|
||||
break
|
||||
}
|
||||
return append(sources, newSrc), nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[Marge] AddSource: failed to save source %s for device %s: %s", sanitizeLog(newSrc.SourceKey.Type), sanitizeLog(devID), sanitizeErr(err))
|
||||
}
|
||||
|
||||
if !replaced {
|
||||
sources = append(sources, newSrc)
|
||||
}
|
||||
|
||||
_ = ds.SaveConfiguredSources(account, devID, sources)
|
||||
}
|
||||
|
||||
return sourceID, nil
|
||||
|
||||
@@ -104,6 +104,9 @@ func (m *Manager) runTelnetInjection(deviceIP string, forbidQuote, cmds []string
|
||||
}
|
||||
}
|
||||
|
||||
unlock := m.lockTelnetURLMutation(deviceIP)
|
||||
defer unlock()
|
||||
|
||||
var logs strings.Builder
|
||||
|
||||
t := m.NewTelnet(deviceIP)
|
||||
@@ -154,6 +157,9 @@ func (m *Manager) setBoseURLsViaTelnet(deviceIP, marge, swUpdate string) (string
|
||||
return "", errors.New("boseurls values must not contain a double quote")
|
||||
}
|
||||
|
||||
unlock := m.lockTelnetURLMutation(deviceIP)
|
||||
defer unlock()
|
||||
|
||||
var logs strings.Builder
|
||||
|
||||
t := m.NewTelnet(deviceIP)
|
||||
@@ -183,6 +189,54 @@ func (m *Manager) setBoseURLsViaTelnet(deviceIP, marge, swUpdate string) (string
|
||||
return logs.String(), nil
|
||||
}
|
||||
|
||||
// setAllBoseURLsViaTelnet writes all four boseurls (bmx, stats, marge,
|
||||
// swUpdate) to the runtime layer via `sys configuration ...`, then commits
|
||||
// them with `envswitch boseurls set`, over the port-17000 shell. Unlike
|
||||
// setBoseURLsViaTelnet (which only issues the envswitch commit, used by the
|
||||
// #471 SSH-bootstrap/reset flows that need that specific two-argument
|
||||
// injection), this mirrors telnetURLs.Commands()'s full sequence so the
|
||||
// envswitch commit captures fresh values for all four fields, not just two.
|
||||
func (m *Manager) setAllBoseURLsViaTelnet(deviceIP string, urls telnetURLs) (string, error) {
|
||||
if err := urls.validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if m.NewTelnet == nil {
|
||||
return "", errors.New("telnet not configured: Manager.NewTelnet is nil")
|
||||
}
|
||||
|
||||
unlock := m.lockTelnetURLMutation(deviceIP)
|
||||
defer unlock()
|
||||
|
||||
var logs strings.Builder
|
||||
|
||||
t := m.NewTelnet(deviceIP)
|
||||
if err := t.Dial(); err != nil {
|
||||
return logs.String(), fmt.Errorf("telnet dial %s:17000: %w", deviceIP, err)
|
||||
}
|
||||
|
||||
defer func() { _ = t.Close() }()
|
||||
|
||||
if banner, _ := t.Probe(); banner != "" {
|
||||
fmt.Fprintf(&logs, "Telnet banner: %q\n", strings.TrimSpace(banner))
|
||||
}
|
||||
|
||||
for _, cmd := range urls.Commands() {
|
||||
resp, err := t.SendCommand(cmd)
|
||||
if err != nil {
|
||||
return logs.String(), fmt.Errorf("telnet command %q failed: %w", cmd, err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(&logs, "→ %s\n%s\n", cmd, strings.TrimRight(resp, "\r\n"))
|
||||
|
||||
if isCommandNotFound(resp) {
|
||||
return logs.String(), fmt.Errorf("device rejected %q (firmware does not expose this command)", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
return logs.String(), nil
|
||||
}
|
||||
|
||||
// fwScript is the speaker's persistent iptables script; appending here makes a
|
||||
// rule survive reboot (it is re-applied on boot).
|
||||
const fwScript = "/etc/init.d/Firewalls/update_iptables"
|
||||
|
||||
@@ -157,6 +157,59 @@ func TestSetBoseURLs_RejectsDoubleQuote(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetAllBoseURLsViaTelnet_WritesAllFourBeforeEnvswitch is the regression
|
||||
// test for the stale statsServerUrl/bmxRegistryUrl bug reported in #621: the
|
||||
// XML migration's telnet resync used to commit `envswitch boseurls set` with
|
||||
// only marge/swUpdate as arguments, silently freezing whatever stats/bmx
|
||||
// happened to still be in the runtime layer at that moment. This asserts all
|
||||
// four `sys configuration` writes land before the single `envswitch` commit,
|
||||
// matching telnetURLs.Commands()'s known-good sequence.
|
||||
func TestSetAllBoseURLsViaTelnet_WritesAllFourBeforeEnvswitch(t *testing.T) {
|
||||
const targetURL = "http://localhost:8000"
|
||||
|
||||
urls := telnetURLs{
|
||||
Marge: targetURL,
|
||||
Stats: targetURL,
|
||||
SwUpdate: targetURL + "/updates/soundtouch",
|
||||
BmxRegistry: targetURL + "/bmx/registry/v1/services",
|
||||
}
|
||||
|
||||
want := urls.Commands()
|
||||
|
||||
resp := make(map[string]string, len(want))
|
||||
for _, c := range want {
|
||||
resp[c] = "OK\n"
|
||||
}
|
||||
|
||||
f := &fakeTelnet{responses: resp}
|
||||
m := newFakeTelnetManager(f)
|
||||
|
||||
if _, err := m.setAllBoseURLsViaTelnet("192.0.2.10", urls); err != nil {
|
||||
t.Fatalf("setAllBoseURLsViaTelnet: %v", err)
|
||||
}
|
||||
|
||||
if len(f.commands) != len(want) {
|
||||
t.Fatalf("sent %d commands %q\n want %d %q", len(f.commands), f.commands, len(want), want)
|
||||
}
|
||||
|
||||
for i, c := range want {
|
||||
if f.commands[i] != c {
|
||||
t.Errorf("command %d = %q\n want %q", i, f.commands[i], c)
|
||||
}
|
||||
}
|
||||
|
||||
envswitchIdx := len(want) - 1
|
||||
for i, c := range f.commands[:envswitchIdx] {
|
||||
if !strings.HasPrefix(c, "sys configuration ") {
|
||||
t.Errorf("command %d = %q, want a `sys configuration ...` runtime write before the envswitch commit", i, c)
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(f.commands[envswitchIdx], "envswitch boseurls set ") {
|
||||
t.Errorf("last command = %q, want the envswitch commit last", f.commands[envswitchIdx])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClose17000_RunsFirewallSteps(t *testing.T) {
|
||||
var ran []string
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// InitPlan describes everything required to take a factory-reset (or
|
||||
@@ -199,7 +202,7 @@ func (m *Manager) ExecuteInitPlan(ctx context.Context, plan InitPlan, progress P
|
||||
}
|
||||
|
||||
// applyInitPlanDefaults validates required fields and fills in defaults
|
||||
// from Manager.ServerURL / sysLanguage 2 / DefaultMargeAuthToken.
|
||||
// from Manager.ServerURL / LanguageEnglish / DefaultMargeAuthToken.
|
||||
func applyInitPlanDefaults(plan InitPlan, serverURL string) (InitPlan, error) {
|
||||
if plan.DeviceIP == "" {
|
||||
return plan, errors.New("InitPlan.DeviceIP is required")
|
||||
@@ -217,6 +220,10 @@ func applyInitPlanDefaults(plan InitPlan, serverURL string) (InitPlan, error) {
|
||||
plan.Language = LanguageEnglish
|
||||
}
|
||||
|
||||
if err := models.LanguageCode(plan.Language).Validate(); err != nil {
|
||||
return plan, fmt.Errorf("InitPlan.Language: %w", err)
|
||||
}
|
||||
|
||||
if plan.AuthToken == "" {
|
||||
plan.AuthToken = DefaultMargeAuthToken
|
||||
}
|
||||
@@ -235,7 +242,7 @@ func (m *Manager) runURLRewrite(plan InitPlan, emit func(StepKind, string, StepS
|
||||
emit(StepURLRewrite, "telnet URL rewrite", StatusRunning, nil)
|
||||
|
||||
urls := defaultTelnetURLs(plan.ServiceURL)
|
||||
if _, rwErr := m.migrateViaTelnet(plan.DeviceIP, plan.ServiceURL, urls); rwErr != nil {
|
||||
if _, rwErr := m.migrateViaTelnet(plan.DeviceIP, urls); rwErr != nil {
|
||||
emit(StepURLRewrite, "telnet URL rewrite", StatusFailed, rwErr)
|
||||
return fmt.Errorf("URL rewrite: %w", rwErr)
|
||||
}
|
||||
@@ -250,8 +257,8 @@ func (m *Manager) runURLRewrite(plan InitPlan, emit func(StepKind, string, StepS
|
||||
// ID, or validating a user-supplied value.
|
||||
func (m *Manager) resolveAccountID(plan InitPlan, info *DeviceInfoXML, emit func(StepKind, string, StepStatus, error)) (InitPlan, error) {
|
||||
if plan.AccountID != "" {
|
||||
if !IsValidAccountID(plan.AccountID) {
|
||||
invalidErr := fmt.Errorf("invalid AccountID %q: must be exactly 7 digits", plan.AccountID)
|
||||
if !datastore.IsSafeIdentifier(plan.AccountID) {
|
||||
invalidErr := fmt.Errorf("invalid AccountID %q: must be a non-empty, path-safe identifier", plan.AccountID)
|
||||
emit(StepGenerateAccountID, "validate account ID", StatusFailed, invalidErr)
|
||||
|
||||
return plan, invalidErr
|
||||
@@ -260,7 +267,7 @@ func (m *Manager) resolveAccountID(plan InitPlan, info *DeviceInfoXML, emit func
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
if info.MargeAccountUUID != "" && IsValidAccountID(info.MargeAccountUUID) {
|
||||
if info.MargeAccountUUID != "" && datastore.IsSafeIdentifier(info.MargeAccountUUID) {
|
||||
plan.AccountID = info.MargeAccountUUID
|
||||
emit(StepGenerateAccountID, "reuse existing margeAccountUUID="+plan.AccountID, StatusOK, nil)
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// fakeSession is a StateMachine that records the order of
|
||||
@@ -143,7 +145,7 @@ func TestExecuteInitPlan_FactoryReset_GeneratesAccountAndRunsAllSteps(t *testing
|
||||
wantCalls := []string{
|
||||
"Start",
|
||||
"IdentifyEnter(300000)",
|
||||
"SetLanguage(2)",
|
||||
"SetLanguage(3)",
|
||||
"Enter",
|
||||
"IdentifyLeave",
|
||||
"SetName(Living Room)",
|
||||
@@ -170,6 +172,24 @@ func TestExecuteInitPlan_FactoryReset_GeneratesAccountAndRunsAllSteps(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteInitPlanRejectsInvalidLanguageBeforeAnyStep(t *testing.T) {
|
||||
manager := &Manager{ServerURL: "http://aftertouch.example"}
|
||||
var events []StepEvent
|
||||
|
||||
_, err := manager.ExecuteInitPlan(context.Background(), InitPlan{
|
||||
DeviceIP: "192.0.2.10",
|
||||
Language: 14,
|
||||
}, func(event StepEvent) {
|
||||
events = append(events, event)
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid language to be rejected")
|
||||
}
|
||||
if len(events) != 0 {
|
||||
t.Fatalf("invalid plan started %d steps: %+v", len(events), events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteInitPlan_ReusesExistingAccountUUID(t *testing.T) {
|
||||
info := &fakeInfoResponder{
|
||||
deviceID: "AABBCCDDEEFF",
|
||||
@@ -195,11 +215,13 @@ func TestExecuteInitPlan_ReusesExistingAccountUUID(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExecuteInitPlan_GeneratesAccountWhenDeviceUUIDInvalid(t *testing.T) {
|
||||
// Devices that report a non-7-digit UUID (e.g. a stale local value) must
|
||||
// not be reused — we treat them as factory-reset for ID purposes.
|
||||
// Devices that report an unsafe/malformed UUID (e.g. containing a path
|
||||
// separator) must not be reused — we treat them as factory-reset for ID
|
||||
// purposes. A merely non-numeric UUID (e.g. "stick@local", #634) IS
|
||||
// reused now; see resolveAccountID/datastore.IsSafeIdentifier.
|
||||
info := &fakeInfoResponder{
|
||||
deviceID: "AABBCCDDEEFF",
|
||||
paired: "not-7-digits",
|
||||
paired: "not/valid",
|
||||
postInitPaired: "", // we'll learn the generated ID from the result
|
||||
}
|
||||
sess := &fakeSession{}
|
||||
@@ -220,11 +242,11 @@ func TestExecuteInitPlan_GeneratesAccountWhenDeviceUUIDInvalid(t *testing.T) {
|
||||
t.Fatalf("ExecuteInitPlan: %v", err)
|
||||
}
|
||||
|
||||
if !IsValidAccountID(got.AccountID) {
|
||||
t.Errorf("got.AccountID = %q, want a valid 7-digit ID", got.AccountID)
|
||||
if !datastore.IsSafeIdentifier(got.AccountID) {
|
||||
t.Errorf("got.AccountID = %q, want a valid generated ID", got.AccountID)
|
||||
}
|
||||
|
||||
if got.AccountID == "not-7-digits" {
|
||||
if got.AccountID == "not/valid" {
|
||||
t.Error("orchestrator should not reuse an invalid UUID")
|
||||
}
|
||||
}
|
||||
@@ -236,7 +258,7 @@ func TestExecuteInitPlan_RejectsInvalidSuppliedAccountID(t *testing.T) {
|
||||
|
||||
plan := InitPlan{
|
||||
DeviceIP: "192.0.2.10",
|
||||
AccountID: "abc",
|
||||
AccountID: "abc/def",
|
||||
SkipURLRewrite: true,
|
||||
}
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ func TestIssue234_FactoryResetSpeakerSyncsReducedSources(t *testing.T) {
|
||||
// SyncDeviceData derives accountID/deviceID from /info; with
|
||||
// an empty margeAccountUUID the account falls through to
|
||||
// "default".
|
||||
if err := m.SyncDeviceData(deviceIP); err != nil {
|
||||
if _, err := m.SyncDeviceData(deviceIP, false); err != nil {
|
||||
t.Fatalf("SyncDeviceData: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
@@ -11,6 +12,8 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// PairAccountTimeouts bounds every step of the pairing call so a wedged
|
||||
@@ -44,8 +47,8 @@ func (m *Manager) PairAccount(deviceIP, accountID string, t TelnetClient) (PairA
|
||||
logs strings.Builder
|
||||
)
|
||||
|
||||
if !IsValidAccountID(accountID) {
|
||||
return result, "", fmt.Errorf("invalid account ID %q: must be exactly 7 digits", accountID)
|
||||
if !datastore.IsSafeIdentifier(accountID) {
|
||||
return result, "", fmt.Errorf("invalid account ID %q: must be a non-empty, path-safe identifier", accountID)
|
||||
}
|
||||
|
||||
supported, supportedErr := m.probeSetMargeAccount(deviceIP)
|
||||
@@ -84,6 +87,9 @@ func (m *Manager) PairAccount(deviceIP, accountID string, t TelnetClient) (PairA
|
||||
|
||||
result.TelnetAttempted = true
|
||||
|
||||
// Safe to concatenate: datastore.IsSafeIdentifier (checked above) rejects
|
||||
// any whitespace or control characters, so accountID can't smuggle extra
|
||||
// tokens into this single-line telnet command.
|
||||
cmd := "envswitch accountid set " + accountID
|
||||
|
||||
resp, err := t.SendCommand(cmd)
|
||||
@@ -135,8 +141,8 @@ func (m *Manager) EnsureMargeAccountPaired(deviceIP, wantAccountID string, t Tel
|
||||
}
|
||||
|
||||
target = generated
|
||||
} else if !IsValidAccountID(target) {
|
||||
return "", false, "", fmt.Errorf("invalid account id %q: must be exactly 7 digits", target)
|
||||
} else if !datastore.IsSafeIdentifier(target) {
|
||||
return "", false, "", fmt.Errorf("invalid account id %q: must be a non-empty, path-safe identifier", target)
|
||||
}
|
||||
|
||||
_, pairLogs, pairErr := m.PairAccount(deviceIP, target, t)
|
||||
@@ -196,9 +202,18 @@ func (m *Manager) probeSetMargeAccount(deviceIP string) (bool, error) {
|
||||
func (m *Manager) postSetMargeAccount(deviceIP, accountID string) error {
|
||||
url := buildDeviceURL(deviceIP, "/setMargeAccount")
|
||||
|
||||
// accountID is XML-escaped rather than interpolated raw:
|
||||
// datastore.IsSafeIdentifier already excludes '<', '>', '&', '\'', '"'
|
||||
// (see #634), but escaping here too means this stays well-formed even
|
||||
// if that gate is ever bypassed.
|
||||
var escapedAccountID bytes.Buffer
|
||||
if err := xml.EscapeText(&escapedAccountID, []byte(accountID)); err != nil {
|
||||
return fmt.Errorf("escape account ID: %w", err)
|
||||
}
|
||||
|
||||
body := fmt.Sprintf(
|
||||
`<PairDeviceWithAccount><accountId>%s</accountId><userAuthToken>aftertouch</userAuthToken></PairDeviceWithAccount>`,
|
||||
accountID,
|
||||
escapedAccountID.String(),
|
||||
)
|
||||
|
||||
client := &http.Client{
|
||||
@@ -225,6 +240,82 @@ func (m *Manager) postSetMargeAccount(deviceIP, accountID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConfigurationStatus values reported by GET /soundTouchConfigurationStatus.
|
||||
// See issue #615: a speaker can be reachable, named, and already
|
||||
// account-paired yet still report SOUNDTOUCH_NOT_CONFIGURED, which leaves
|
||||
// the firmware nagging the owner to install the Bose app. Only a full pass
|
||||
// through the WebSocket setup state machine (ExecuteInitPlan) clears it.
|
||||
const (
|
||||
ConfigurationStatusConfigured = "SOUNDTOUCH_CONFIGURED"
|
||||
ConfigurationStatusNotConfigured = "SOUNDTOUCH_NOT_CONFIGURED"
|
||||
)
|
||||
|
||||
// ReadConfigurationStatus fetches /soundTouchConfigurationStatus and returns
|
||||
// its raw status attribute (e.g. "SOUNDTOUCH_CONFIGURED").
|
||||
func (m *Manager) ReadConfigurationStatus(deviceIP string) (string, error) {
|
||||
url := buildDeviceURL(deviceIP, "/soundTouchConfigurationStatus")
|
||||
|
||||
client := &http.Client{Timeout: supportedURLsTimeout}
|
||||
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("GET %s: %w", url, err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("GET %s returned %d", url, resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read %s: %w", url, err)
|
||||
}
|
||||
|
||||
var doc struct {
|
||||
Status string `xml:"status,attr"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(body, &doc); err != nil {
|
||||
return "", fmt.Errorf("parse %s: %w", url, err)
|
||||
}
|
||||
|
||||
return doc.Status, nil
|
||||
}
|
||||
|
||||
// PreflightInitPlan reports whether ExecuteInitPlan should be run against
|
||||
// deviceIP, gated on the two conditions from issue #615: /setMargeAccount
|
||||
// must be listed in /supportedURLs, and the device's current
|
||||
// /soundTouchConfigurationStatus must be exactly SOUNDTOUCH_NOT_CONFIGURED.
|
||||
// needed=false with a nil error means "already configured, nothing to do."
|
||||
// Any other outcome (unsupported route, unrecognised status value) is
|
||||
// treated as unknown and returned as an error rather than guessed at.
|
||||
func (m *Manager) PreflightInitPlan(deviceIP string) (needed bool, status string, err error) {
|
||||
supported, probeErr := m.probeSetMargeAccount(deviceIP)
|
||||
if probeErr != nil {
|
||||
return false, "", fmt.Errorf("supportedURLs probe: %w", probeErr)
|
||||
}
|
||||
|
||||
if !supported {
|
||||
return false, "", errors.New("/setMargeAccount is not listed in /supportedURLs — device does not support this pairing path")
|
||||
}
|
||||
|
||||
status, err = m.ReadConfigurationStatus(deviceIP)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("read /soundTouchConfigurationStatus: %w", err)
|
||||
}
|
||||
|
||||
switch status {
|
||||
case ConfigurationStatusConfigured:
|
||||
return false, status, nil
|
||||
case ConfigurationStatusNotConfigured:
|
||||
return true, status, nil
|
||||
default:
|
||||
return false, status, fmt.Errorf("unexpected /soundTouchConfigurationStatus value %q", status)
|
||||
}
|
||||
}
|
||||
|
||||
// buildDeviceURL builds a URL for a SoundTouch device's HTTP API. If
|
||||
// deviceIP already includes a port (test scenarios using httptest) it is
|
||||
// reused as-is; otherwise the canonical port 8090 is appended.
|
||||
@@ -236,23 +327,6 @@ func buildDeviceURL(deviceIP, path string) string {
|
||||
return "http://" + deviceIP + ":8090" + path
|
||||
}
|
||||
|
||||
// IsValidAccountID reports whether s is a syntactically valid SoundTouch
|
||||
// account ID — exactly 7 numeric digits, the format used by every
|
||||
// Bose-cloud-issued ID we have observed in captures.
|
||||
func IsValidAccountID(s string) bool {
|
||||
if len(s) != 7 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, ch := range s {
|
||||
if ch < '0' || ch > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// GenerateAccountID returns a fresh 7-digit account ID that does not collide
|
||||
// with any value in known. It uses crypto/rand and re-rolls on collision.
|
||||
func GenerateAccountID(known []string) (string, error) {
|
||||
|
||||
@@ -10,19 +10,22 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// fakeDevice spins up an httptest.Server that pretends to be the SoundTouch
|
||||
// device's :8090 HTTP API. It records POSTs to /setMargeAccount so tests
|
||||
// can assert on the body.
|
||||
type fakeDevice struct {
|
||||
srv *httptest.Server
|
||||
addr string // "host:port" usable as deviceIP
|
||||
supportsSetMarge bool
|
||||
postStatus int // status code returned for POST /setMargeAccount
|
||||
postDelay time.Duration
|
||||
gotPostBody string
|
||||
margeAccountUUID string // served by /info; empty means "unpaired"
|
||||
srv *httptest.Server
|
||||
addr string // "host:port" usable as deviceIP
|
||||
supportsSetMarge bool
|
||||
postStatus int // status code returned for POST /setMargeAccount
|
||||
postDelay time.Duration
|
||||
gotPostBody string
|
||||
margeAccountUUID string // served by /info; empty means "unpaired"
|
||||
configurationStatus string // served by /soundTouchConfigurationStatus; empty = route not served (404)
|
||||
}
|
||||
|
||||
func newFakeDevice(t *testing.T) *fakeDevice {
|
||||
@@ -62,6 +65,16 @@ func newFakeDevice(t *testing.T) *fakeDevice {
|
||||
fmt.Fprintf(w, `<info deviceID="AABBCCDDEE0A"><margeAccountUUID>%s</margeAccountUUID></info>`, d.margeAccountUUID)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/soundTouchConfigurationStatus", func(w http.ResponseWriter, _ *http.Request) {
|
||||
if d.configurationStatus == "" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprintf(w, `<SoundTouchConfigurationStatus status="%s" />`, d.configurationStatus)
|
||||
})
|
||||
|
||||
d.srv = httptest.NewServer(mux)
|
||||
|
||||
u := d.srv.URL[len("http://"):]
|
||||
@@ -302,7 +315,7 @@ func TestEnsureMargeAccountPaired_UnpairedGeneratesAndPairs(t *testing.T) {
|
||||
t.Error("alreadyPaired should be false for an unpaired device")
|
||||
}
|
||||
|
||||
if !IsValidAccountID(accountID) {
|
||||
if !datastore.IsSafeIdentifier(accountID) {
|
||||
t.Errorf("accountID %q is not a valid generated ID", accountID)
|
||||
}
|
||||
|
||||
@@ -341,7 +354,7 @@ func TestEnsureMargeAccountPaired_RejectsInvalidWantAccountID(t *testing.T) {
|
||||
|
||||
m := NewManager("", nil, nil)
|
||||
|
||||
_, _, _, err := m.EnsureMargeAccountPaired(d.addr, "not-7-digits", nil)
|
||||
_, _, _, err := m.EnsureMargeAccountPaired(d.addr, "not/valid", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for an invalid --account value")
|
||||
}
|
||||
@@ -360,36 +373,121 @@ func TestEnsureMargeAccountPaired_PropagatesPairingFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidAccountID(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"1234567", true},
|
||||
{"0000000", true},
|
||||
{"9999999", true},
|
||||
{"", false},
|
||||
{"123456", false},
|
||||
{"12345678", false},
|
||||
{"123456a", false},
|
||||
{"-123456", false},
|
||||
{" 123456", false},
|
||||
func TestReadConfigurationStatus_ReturnsRawStatus(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.configurationStatus = ConfigurationStatusConfigured
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
status, err := m.ReadConfigurationStatus(d.addr)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadConfigurationStatus: %v", err)
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := IsValidAccountID(tc.in); got != tc.want {
|
||||
t.Errorf("IsValidAccountID(%q) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
if status != ConfigurationStatusConfigured {
|
||||
t.Errorf("status = %q, want %q", status, ConfigurationStatusConfigured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadConfigurationStatus_ErrorsWhenRouteUnsupported(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.configurationStatus = ""
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
if _, err := m.ReadConfigurationStatus(d.addr); err == nil {
|
||||
t.Fatal("expected an error when the route is unsupported (404)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightInitPlan_NotConfiguredNeedsRepair(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.configurationStatus = ConfigurationStatusNotConfigured
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
needed, status, err := m.PreflightInitPlan(d.addr)
|
||||
if err != nil {
|
||||
t.Fatalf("PreflightInitPlan: %v", err)
|
||||
}
|
||||
|
||||
if !needed {
|
||||
t.Error("needed should be true for SOUNDTOUCH_NOT_CONFIGURED")
|
||||
}
|
||||
|
||||
if status != ConfigurationStatusNotConfigured {
|
||||
t.Errorf("status = %q, want %q", status, ConfigurationStatusNotConfigured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightInitPlan_AlreadyConfiguredIsNoOp(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.configurationStatus = ConfigurationStatusConfigured
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
needed, status, err := m.PreflightInitPlan(d.addr)
|
||||
if err != nil {
|
||||
t.Fatalf("PreflightInitPlan: %v", err)
|
||||
}
|
||||
|
||||
if needed {
|
||||
t.Error("needed should be false for SOUNDTOUCH_CONFIGURED")
|
||||
}
|
||||
|
||||
if status != ConfigurationStatusConfigured {
|
||||
t.Errorf("status = %q, want %q", status, ConfigurationStatusConfigured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightInitPlan_UnsupportedSetMargeAccountFailsClosed(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.supportsSetMarge = false
|
||||
d.configurationStatus = ConfigurationStatusNotConfigured
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
needed, _, err := m.PreflightInitPlan(d.addr)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when /setMargeAccount is not listed in /supportedURLs")
|
||||
}
|
||||
|
||||
if needed {
|
||||
t.Error("needed should be false when preflight fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightInitPlan_UnrecognisedStatusFailsClosed(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.configurationStatus = "SOMETHING_UNEXPECTED"
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
needed, status, err := m.PreflightInitPlan(d.addr)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for an unrecognised status value")
|
||||
}
|
||||
|
||||
if needed {
|
||||
t.Error("needed should be false when the status is unrecognised")
|
||||
}
|
||||
|
||||
if status != "SOMETHING_UNEXPECTED" {
|
||||
t.Errorf("status = %q, want the raw unrecognised value returned alongside the error", status)
|
||||
}
|
||||
}
|
||||
|
||||
// Account-ID format validation is now solely datastore.IsSafeIdentifier's
|
||||
// responsibility (see datastore.TestIsSafeIdentifier); setup no longer has
|
||||
// its own account-ID validator to test.
|
||||
|
||||
func TestGenerateAccountID_AvoidsCollisions(t *testing.T) {
|
||||
id, err := GenerateAccountID(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAccountID(nil): %v", err)
|
||||
}
|
||||
|
||||
if !IsValidAccountID(id) {
|
||||
if !datastore.IsSafeIdentifier(id) {
|
||||
t.Errorf("generated ID %q is not valid", id)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/marge"
|
||||
)
|
||||
|
||||
// MigrationDataNotReadyError means migration was refused because the service
|
||||
// cannot prove that its rendered account data preserves the speaker's presets.
|
||||
type MigrationDataNotReadyError struct {
|
||||
Reason string
|
||||
Action string
|
||||
}
|
||||
|
||||
func (e *MigrationDataNotReadyError) Error() string {
|
||||
action := e.Action
|
||||
if action == "" {
|
||||
action = "Run Data Sync for this device and retry migration."
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Migration data is not ready: %s. %s", e.Reason, action)
|
||||
}
|
||||
|
||||
func migrationDataNotReadyf(format string, args ...any) error {
|
||||
return &MigrationDataNotReadyError{Reason: fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
// checkMigrationDataReady proves that redirecting the speaker to this service
|
||||
// will not replace its live presets with missing, stale, or filtered account
|
||||
// data. Every operation in this check is read-only.
|
||||
//
|
||||
// It returns warnings for conditions worth telling the user about but not
|
||||
// worth refusing over, and an error only for the ones it can actually prove.
|
||||
func (m *Manager) checkMigrationDataReady(deviceIP string) ([]string, error) {
|
||||
if m.DataStore == nil {
|
||||
// CLI callers do not own the service datastore and cannot enforce this
|
||||
// check. ExecuteInitPlan is a separate onboarding flow which establishes
|
||||
// account state only after its intentional URL rewrite.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
info, err := m.GetLiveDeviceInfo(deviceIP)
|
||||
if err != nil {
|
||||
return nil, migrationDataNotReadyf("cannot read live /info: %v", err)
|
||||
}
|
||||
|
||||
deviceID := strings.TrimSpace(info.DeviceID)
|
||||
accountID := strings.TrimSpace(info.MargeAccountUUID)
|
||||
|
||||
if deviceID == "" {
|
||||
return nil, migrationDataNotReadyf("live /info has no deviceID")
|
||||
}
|
||||
|
||||
if accountID == "" {
|
||||
// An unpaired speaker, typically factory-reset, has no account data to
|
||||
// preserve, so there is nothing for this check to compare and nothing
|
||||
// to lose. Refusing here would also break the documented onboarding
|
||||
// order: the admin UI migrates first and pairs afterwards (see the
|
||||
// "Pairing runs after the URL flip" comment in the setup page), and
|
||||
// MIGRATION-GUIDE.md tells the user to Generate an account ID on a
|
||||
// factory-reset device. Data Sync cannot unblock it either, since it
|
||||
// files an account-less device under "default", which never matches an
|
||||
// empty live account.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if !datastore.IsSafeIdentifier(accountID) || !datastore.IsSafeIdentifier(deviceID) {
|
||||
return nil, migrationDataNotReadyf("live /info contains an invalid account or device identifier")
|
||||
}
|
||||
|
||||
persistedInfo, err := m.DataStore.GetExactDeviceInfo(accountID, deviceID)
|
||||
if err != nil {
|
||||
// Report the cause: a malformed file or an I/O error is not a missing
|
||||
// sync, and Data Sync is then the wrong remedy to suggest.
|
||||
return nil, migrationDataNotReadyf("cannot read DeviceInfo.xml under account %q and device %q: %v", accountID, deviceID, err)
|
||||
}
|
||||
|
||||
if persistedInfo.DeviceID != deviceID {
|
||||
return nil, migrationDataNotReadyf("persisted DeviceInfo.xml identifies device %q instead of %q", persistedInfo.DeviceID, deviceID)
|
||||
}
|
||||
|
||||
snapshot, err := m.DataStore.ReadPresetSnapshot(accountID, deviceID)
|
||||
if err != nil {
|
||||
return nil, migrationDataNotReadyf("cannot read the persisted preset snapshot: %v", err)
|
||||
}
|
||||
|
||||
if snapshot.State != datastore.PresetSnapshotValid {
|
||||
return nil, migrationDataNotReadyf("persisted Presets.xml is %s", snapshot.State)
|
||||
}
|
||||
|
||||
if snapshot.NeedsRewrite {
|
||||
return nil, migrationDataNotReadyf("persisted Presets.xml uses a legacy format that must be refreshed")
|
||||
}
|
||||
|
||||
livePresets, err := m.fetchLivePresets(deviceIP)
|
||||
if err != nil {
|
||||
return nil, migrationDataNotReadyf("cannot read live /presets: %v", err)
|
||||
}
|
||||
|
||||
fullXML, err := marge.AccountFullToXMLReadOnly(m.DataStore, accountID)
|
||||
if err != nil {
|
||||
return nil, migrationDataNotReadyf("cannot render account /full: %v", err)
|
||||
}
|
||||
|
||||
fullPresets, accountDeviceCount, err := migrationFullPresets(fullXML, deviceID)
|
||||
if err != nil {
|
||||
return nil, migrationDataNotReadyf("rendered account /full is incomplete: %v", err)
|
||||
}
|
||||
|
||||
var warnings []string
|
||||
|
||||
// Not a refusal. One account holding every speaker in the household is the
|
||||
// normal Bose topology, so blocking it would block most setups, and issue
|
||||
// #614 concluded the shared-account preset wipe is empirical rather than a
|
||||
// proven mechanism with an open root cause. A stale duplicate entry left
|
||||
// by a failed /info read or a DHCP lease change would also trip it on a
|
||||
// genuinely single-speaker setup. Say what was found and let the user
|
||||
// decide.
|
||||
if accountDeviceCount != 1 {
|
||||
warnings = append(warnings, fmt.Sprintf(
|
||||
"migrating into an account that contains %d devices; some firmware has been reported to wipe presets after a reboot-triggered resync of a shared account (issue #614, root cause open)",
|
||||
accountDeviceCount))
|
||||
}
|
||||
|
||||
persisted := migrationPresetIdentities(snapshot.Presets)
|
||||
live := migrationPresetIdentities(livePresets)
|
||||
|
||||
if mismatch := compareMigrationPresets("persisted snapshot", persisted, "rendered /full", fullPresets); mismatch != nil {
|
||||
return nil, mismatch.err()
|
||||
}
|
||||
|
||||
if mismatch := compareMigrationPresets("live /presets", live, "rendered /full", fullPresets); mismatch != nil {
|
||||
return nil, mismatch.err()
|
||||
}
|
||||
|
||||
return warnings, nil
|
||||
}
|
||||
|
||||
type migrationPresetIdentity struct {
|
||||
Slot string
|
||||
Name string
|
||||
Location string
|
||||
}
|
||||
|
||||
func migrationPresetIdentities(presets []models.ServicePreset) []migrationPresetIdentity {
|
||||
result := make([]migrationPresetIdentity, 0, len(presets))
|
||||
for i := range presets {
|
||||
slot := presets[i].ButtonNumber
|
||||
if slot == "" {
|
||||
slot = presets[i].ID
|
||||
}
|
||||
|
||||
// Clearing a slot through the Marge API leaves a zero-value entry in
|
||||
// the list (RemovePreset assigns models.ServicePreset{}), persisted as
|
||||
// <preset id="">. The rendered /full drops it, so counting it here
|
||||
// would report a mismatch for a datastore that is perfectly in sync.
|
||||
// An empty slot carries no identity to compare either way.
|
||||
if slot == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
result = append(result, migrationPresetIdentity{
|
||||
Slot: slot,
|
||||
Name: presets[i].Name,
|
||||
Location: presets[i].Location,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func migrationFullPresets(fullXML []byte, deviceID string) ([]migrationPresetIdentity, int, error) {
|
||||
var full struct {
|
||||
Devices []struct {
|
||||
DeviceID string `xml:"deviceid,attr"`
|
||||
Presets []struct {
|
||||
Slot string `xml:"buttonNumber,attr"`
|
||||
Name string `xml:"name"`
|
||||
Location string `xml:"location"`
|
||||
} `xml:"presets>preset"`
|
||||
} `xml:"devices>device"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(fullXML, &full); err != nil {
|
||||
return nil, 0, fmt.Errorf("malformed XML: %w", err)
|
||||
}
|
||||
|
||||
for i := range full.Devices {
|
||||
if full.Devices[i].DeviceID != deviceID {
|
||||
continue
|
||||
}
|
||||
|
||||
presets := make([]migrationPresetIdentity, 0, len(full.Devices[i].Presets))
|
||||
for _, preset := range full.Devices[i].Presets {
|
||||
presets = append(presets, migrationPresetIdentity{
|
||||
Slot: preset.Slot,
|
||||
Name: preset.Name,
|
||||
Location: preset.Location,
|
||||
})
|
||||
}
|
||||
|
||||
return presets, len(full.Devices), nil
|
||||
}
|
||||
|
||||
return nil, len(full.Devices), fmt.Errorf("target device %q is missing", deviceID)
|
||||
}
|
||||
|
||||
// migrationPresetMismatch describes why two preset views disagree.
|
||||
type migrationPresetMismatch struct {
|
||||
Reason string
|
||||
// DroppedByFull marks the case where the speaker's own view holds a slot
|
||||
// the rendered /full does not. mapPresetsToFullResponse omits a preset
|
||||
// whose source is absent from the account's configured sources and cannot
|
||||
// be synthesised, so this is not a stale snapshot and re-syncing cannot
|
||||
// fix it: the source itself has to come back.
|
||||
DroppedByFull bool
|
||||
}
|
||||
|
||||
func (m *migrationPresetMismatch) err() error {
|
||||
if !m.DroppedByFull {
|
||||
return migrationDataNotReadyf("%s", m.Reason)
|
||||
}
|
||||
|
||||
return &MigrationDataNotReadyError{
|
||||
Reason: m.Reason,
|
||||
Action: "The rendered account omits a preset whose music service source is missing, so Data Sync cannot restore it. " +
|
||||
"Re-link or repopulate that source for this account, then retry migration.",
|
||||
}
|
||||
}
|
||||
|
||||
func compareMigrationPresets(leftName string, left []migrationPresetIdentity, rightName string, right []migrationPresetIdentity) *migrationPresetMismatch {
|
||||
if len(left) != len(right) {
|
||||
return &migrationPresetMismatch{
|
||||
Reason: fmt.Sprintf("%s has %d preset(s), but %s has %d", leftName, len(left), rightName, len(right)),
|
||||
DroppedByFull: len(left) > len(right),
|
||||
}
|
||||
}
|
||||
|
||||
leftBySlot, problem := indexMigrationPresets(leftName, left)
|
||||
if problem != "" {
|
||||
return &migrationPresetMismatch{Reason: problem}
|
||||
}
|
||||
|
||||
rightBySlot, problem := indexMigrationPresets(rightName, right)
|
||||
if problem != "" {
|
||||
return &migrationPresetMismatch{Reason: problem}
|
||||
}
|
||||
|
||||
for slot, leftPreset := range leftBySlot {
|
||||
rightPreset, ok := rightBySlot[slot]
|
||||
if !ok {
|
||||
return &migrationPresetMismatch{
|
||||
Reason: fmt.Sprintf("preset slot %s from %s is missing from %s", slot, leftName, rightName),
|
||||
DroppedByFull: true,
|
||||
}
|
||||
}
|
||||
|
||||
if leftPreset.Name != rightPreset.Name || leftPreset.Location != rightPreset.Location {
|
||||
return &migrationPresetMismatch{
|
||||
Reason: fmt.Sprintf("preset slot %s differs between %s and %s", slot, leftName, rightName),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func indexMigrationPresets(name string, presets []migrationPresetIdentity) (map[string]migrationPresetIdentity, string) {
|
||||
bySlot := make(map[string]migrationPresetIdentity, len(presets))
|
||||
for _, preset := range presets {
|
||||
if preset.Slot == "" {
|
||||
return nil, fmt.Sprintf("%s contains a preset without a slot", name)
|
||||
}
|
||||
|
||||
if _, exists := bySlot[preset.Slot]; exists {
|
||||
return nil, fmt.Sprintf("%s contains duplicate preset slot %s", name, preset.Slot)
|
||||
}
|
||||
|
||||
bySlot[preset.Slot] = preset
|
||||
}
|
||||
|
||||
return bySlot, ""
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
const (
|
||||
readinessAccount = "1234567"
|
||||
readinessDevice = "DEVICE01"
|
||||
)
|
||||
|
||||
func newMigrationReadinessFixture(t *testing.T, livePresetsXML string) (*Manager, *datastore.DataStore, string) {
|
||||
t.Helper()
|
||||
|
||||
speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/info":
|
||||
_, _ = fmt.Fprintf(w, `<info deviceID="%s"><name>Test Speaker</name><margeAccountUUID>%s</margeAccountUUID></info>`, readinessDevice, readinessAccount)
|
||||
case "/presets":
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(livePresetsXML))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(speaker.Close)
|
||||
|
||||
ds := datastore.NewDataStore(t.TempDir())
|
||||
deviceIP := strings.TrimPrefix(speaker.URL, "http://")
|
||||
if err := ds.SaveDeviceInfo(readinessAccount, readinessDevice, &models.ServiceDeviceInfo{
|
||||
DeviceID: readinessDevice,
|
||||
AccountID: readinessAccount,
|
||||
IPAddress: deviceIP,
|
||||
Name: "Test Speaker",
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
return NewManager("http://aftertouch.example:8000", ds, nil), ds, deviceIP
|
||||
}
|
||||
|
||||
func readinessPreset(slot, name, location string) models.ServicePreset {
|
||||
return models.ServicePreset{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
Name: name,
|
||||
Source: "LOCAL_INTERNET_RADIO",
|
||||
Type: "stationurl",
|
||||
ContentItemType: "stationurl",
|
||||
Location: location,
|
||||
SourceID: "10003",
|
||||
IsPresetable: "true",
|
||||
},
|
||||
ID: slot,
|
||||
ButtonNumber: slot,
|
||||
}
|
||||
}
|
||||
|
||||
func livePresetsXML(presets ...models.ServicePreset) string {
|
||||
var xml strings.Builder
|
||||
xml.WriteString(`<presets deviceID="` + readinessDevice + `">`)
|
||||
for _, preset := range presets {
|
||||
fmt.Fprintf(&xml, `<preset id="%s"><ContentItem source="%s" type="%s" location="%s" isPresetable="true"><itemName>%s</itemName></ContentItem></preset>`,
|
||||
preset.ButtonNumber, preset.Source, preset.Type, preset.Location, preset.Name)
|
||||
}
|
||||
xml.WriteString(`</presets>`)
|
||||
|
||||
return xml.String()
|
||||
}
|
||||
|
||||
func discardWarnings(_ []string, err error) error { return err }
|
||||
|
||||
func requireMigrationNotReady(t *testing.T, err error) *MigrationDataNotReadyError {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected migration data readiness error")
|
||||
}
|
||||
|
||||
var notReady *MigrationDataNotReadyError
|
||||
if !errors.As(err, ¬Ready) {
|
||||
t.Fatalf("error type = %T, want *MigrationDataNotReadyError: %v", err, err)
|
||||
}
|
||||
if notReady.Action == "" && !strings.Contains(err.Error(), "Data Sync") {
|
||||
t.Fatalf("default error is not actionable: %v", err)
|
||||
}
|
||||
if notReady.Action != "" && !strings.Contains(err.Error(), notReady.Action) {
|
||||
t.Fatalf("custom action is missing from error: %v", err)
|
||||
}
|
||||
|
||||
return notReady
|
||||
}
|
||||
|
||||
func TestMigrateSpeakerMissingSnapshotBlocksBeforeTelnet(t *testing.T) {
|
||||
m, _, deviceIP := newMigrationReadinessFixture(t, `<presets/>`)
|
||||
telnetCalls := 0
|
||||
m.NewTelnet = func(string) TelnetClient {
|
||||
telnetCalls++
|
||||
return &fakeTelnet{}
|
||||
}
|
||||
|
||||
_, err := m.MigrateSpeaker(deviceIP, "", "", nil, MigrationMethodTelnet)
|
||||
notReady := requireMigrationNotReady(t, err)
|
||||
if !strings.Contains(notReady.Reason, "missing") {
|
||||
t.Fatalf("reason = %q, want missing snapshot", notReady.Reason)
|
||||
}
|
||||
if telnetCalls != 0 {
|
||||
t.Fatalf("telnet factory called %d times, want zero", telnetCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationDataReadinessBlocksPartialAndFilteredPresets(t *testing.T) {
|
||||
t.Run("partial live list", func(t *testing.T) {
|
||||
one := readinessPreset("1", "One", "http://radio.example/one")
|
||||
two := readinessPreset("2", "Two", "http://radio.example/two")
|
||||
m, ds, deviceIP := newMigrationReadinessFixture(t, livePresetsXML(one))
|
||||
if err := ds.SavePresets(readinessAccount, readinessDevice, []models.ServicePreset{one, two}); err != nil {
|
||||
t.Fatalf("SavePresets: %v", err)
|
||||
}
|
||||
|
||||
requireMigrationNotReady(t, discardWarnings(m.checkMigrationDataReady(deviceIP)))
|
||||
})
|
||||
|
||||
t.Run("preset filtered from full", func(t *testing.T) {
|
||||
filtered := readinessPreset("1", "Spotify", "spotify:track:missing")
|
||||
filtered.Source = "SPOTIFY"
|
||||
filtered.SourceID = "missing-spotify-source"
|
||||
m, ds, deviceIP := newMigrationReadinessFixture(t, livePresetsXML(filtered))
|
||||
if err := ds.SavePresets(readinessAccount, readinessDevice, []models.ServicePreset{filtered}); err != nil {
|
||||
t.Fatalf("SavePresets: %v", err)
|
||||
}
|
||||
|
||||
notReady := requireMigrationNotReady(t, discardWarnings(m.checkMigrationDataReady(deviceIP)))
|
||||
if !strings.Contains(notReady.Reason, "rendered /full") {
|
||||
t.Fatalf("reason = %q, want rendered /full mismatch", notReady.Reason)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("shared account warns but does not refuse", func(t *testing.T) {
|
||||
m, ds, deviceIP := newMigrationReadinessFixture(t, `<presets/>`)
|
||||
if err := ds.SavePresets(readinessAccount, readinessDevice, nil); err != nil {
|
||||
t.Fatalf("SavePresets: %v", err)
|
||||
}
|
||||
|
||||
if err := ds.SaveDeviceInfo(readinessAccount, "SIBLING01", &models.ServiceDeviceInfo{
|
||||
DeviceID: "SIBLING01",
|
||||
AccountID: readinessAccount,
|
||||
Name: "Sibling Speaker",
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDeviceInfo sibling: %v", err)
|
||||
}
|
||||
|
||||
warnings, err := m.checkMigrationDataReady(deviceIP)
|
||||
if err != nil {
|
||||
t.Fatalf("shared account refused migration: %v", err)
|
||||
}
|
||||
|
||||
if len(warnings) != 1 || !strings.Contains(warnings[0], "2 devices") {
|
||||
t.Fatalf("warnings = %v, want one naming the device count", warnings)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid empty", func(t *testing.T) {
|
||||
m, ds, deviceIP := newMigrationReadinessFixture(t, `<presets/>`)
|
||||
if err := ds.SavePresets(readinessAccount, readinessDevice, nil); err != nil {
|
||||
t.Fatalf("SavePresets: %v", err)
|
||||
}
|
||||
|
||||
if _, err := m.checkMigrationDataReady(deviceIP); err != nil {
|
||||
t.Fatalf("checkMigrationDataReady: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fully synced", func(t *testing.T) {
|
||||
preset := readinessPreset("1", "Radio", "http://radio.example/stream")
|
||||
m, ds, deviceIP := newMigrationReadinessFixture(t, livePresetsXML(preset))
|
||||
if err := ds.SavePresets(readinessAccount, readinessDevice, []models.ServicePreset{preset}); err != nil {
|
||||
t.Fatalf("SavePresets: %v", err)
|
||||
}
|
||||
|
||||
if _, err := m.checkMigrationDataReady(deviceIP); err != nil {
|
||||
t.Fatalf("checkMigrationDataReady: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMigrationDataReadinessDoesNotRewritePresetSnapshots(t *testing.T) {
|
||||
t.Run("ready single device", func(t *testing.T) {
|
||||
preset := readinessPreset("1", "Target Radio", "http://radio.example/target")
|
||||
m, ds, deviceIP := newMigrationReadinessFixture(t, livePresetsXML(preset))
|
||||
if err := ds.SavePresets(readinessAccount, readinessDevice, []models.ServicePreset{preset}); err != nil {
|
||||
t.Fatalf("SavePresets target: %v", err)
|
||||
}
|
||||
targetPath := filepath.Join(ds.AccountDeviceDir(readinessAccount, readinessDevice), constants.PresetsFile)
|
||||
before, err := os.ReadFile(targetPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read target snapshot: %v", err)
|
||||
}
|
||||
|
||||
if _, err = m.checkMigrationDataReady(deviceIP); err != nil {
|
||||
t.Fatalf("checkMigrationDataReady: %v", err)
|
||||
}
|
||||
assertPresetSnapshotUnchanged(t, targetPath, before)
|
||||
})
|
||||
|
||||
t.Run("shared account leaves a sibling snapshot alone", func(t *testing.T) {
|
||||
m, ds, deviceIP := newMigrationReadinessFixture(t, `<presets/>`)
|
||||
if err := ds.SavePresets(readinessAccount, readinessDevice, nil); err != nil {
|
||||
t.Fatalf("SavePresets target: %v", err)
|
||||
}
|
||||
|
||||
const siblingDevice = "SIBLING01"
|
||||
if err := ds.SaveDeviceInfo(readinessAccount, siblingDevice, &models.ServiceDeviceInfo{
|
||||
DeviceID: siblingDevice,
|
||||
AccountID: readinessAccount,
|
||||
Name: "Sibling Speaker",
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDeviceInfo sibling: %v", err)
|
||||
}
|
||||
|
||||
legacy := []byte(`<presets><preset id="1"><ContentItem source="LOCAL_INTERNET_RADIO" type="stationurl" location="http://radio.example/sibling"><itemName>Sibling Radio</itemName></ContentItem></preset></presets>`)
|
||||
siblingPath := filepath.Join(ds.AccountDeviceDir(readinessAccount, siblingDevice), constants.PresetsFile)
|
||||
if err := ds.WriteFileUnderBase(siblingPath, legacy, 0o644); err != nil {
|
||||
t.Fatalf("write legacy sibling snapshot: %v", err)
|
||||
}
|
||||
|
||||
// Reading the sibling's account to count devices must not canonicalise
|
||||
// its legacy Presets.xml, which is the point of this case; the shared
|
||||
// account itself is only a warning.
|
||||
warnings, err := m.checkMigrationDataReady(deviceIP)
|
||||
if err != nil {
|
||||
t.Fatalf("shared account refused migration: %v", err)
|
||||
}
|
||||
if len(warnings) != 1 || !strings.Contains(warnings[0], "2 devices") {
|
||||
t.Fatalf("warnings = %v, want one naming the device count", warnings)
|
||||
}
|
||||
|
||||
assertPresetSnapshotUnchanged(t, siblingPath, legacy)
|
||||
})
|
||||
}
|
||||
|
||||
func assertPresetSnapshotUnchanged(t *testing.T, path string, want []byte) {
|
||||
t.Helper()
|
||||
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read preset snapshot: %v", err)
|
||||
}
|
||||
if !bytes.Equal(after, want) {
|
||||
t.Fatalf("readiness preflight rewrote Presets.xml\n got: %s\nwant: %s", after, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrationDataReadinessAllowsUnpairedSpeaker: a factory-reset speaker has
|
||||
// no account data to preserve, and the admin UI migrates before it pairs, so
|
||||
// refusing here would make onboarding impossible. Data Sync could not unblock
|
||||
// it either: an account-less device is filed under "default", which never
|
||||
// matches an empty live account.
|
||||
func TestMigrationDataReadinessAllowsUnpairedSpeaker(t *testing.T) {
|
||||
speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/info" {
|
||||
_, _ = fmt.Fprintf(w, `<info deviceID="%s"><name>Fresh Speaker</name><margeAccountUUID></margeAccountUUID></info>`, readinessDevice)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer speaker.Close()
|
||||
|
||||
m := NewManager("http://aftertouch.example:8000", datastore.NewDataStore(t.TempDir()), nil)
|
||||
|
||||
if _, err := m.checkMigrationDataReady(strings.TrimPrefix(speaker.URL, "http://")); err != nil {
|
||||
t.Fatalf("unpaired speaker was refused migration: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrationDataReadinessIgnoresClearedPresetSlots: clearing a slot through
|
||||
// the Marge API leaves a zero-value entry that /full drops. Counting it would
|
||||
// refuse migration for a datastore that is otherwise perfectly in sync.
|
||||
func TestMigrationDataReadinessIgnoresClearedPresetSlots(t *testing.T) {
|
||||
kept := readinessPreset("1", "Kept Station", "http://radio.example/kept")
|
||||
|
||||
m, ds, deviceIP := newMigrationReadinessFixture(t, livePresetsXML(kept))
|
||||
if err := ds.SavePresets(readinessAccount, readinessDevice, []models.ServicePreset{
|
||||
kept,
|
||||
{}, // slot 2, cleared through RemovePreset
|
||||
}); err != nil {
|
||||
t.Fatalf("SavePresets: %v", err)
|
||||
}
|
||||
|
||||
if _, err := m.checkMigrationDataReady(deviceIP); err != nil {
|
||||
t.Fatalf("a cleared preset slot blocked migration: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrationDataReadinessExplainsPresetsDroppedByFull: a preset whose music
|
||||
// service source is missing from the account is omitted from the rendered
|
||||
// /full on purpose. Telling the user to run Data Sync sends them in a loop,
|
||||
// since syncing cannot bring the source back.
|
||||
func TestMigrationDataReadinessExplainsPresetsDroppedByFull(t *testing.T) {
|
||||
kept := readinessPreset("1", "Kept Station", "http://radio.example/kept")
|
||||
|
||||
dropped := readinessPreset("2", "Spotify Mix", "spotify:playlist:x")
|
||||
dropped.Source = "SPOTIFY"
|
||||
dropped.SourceID = "99999"
|
||||
dropped.SourceAccount = "someone"
|
||||
|
||||
m, ds, deviceIP := newMigrationReadinessFixture(t, livePresetsXML(kept, dropped))
|
||||
if err := ds.SavePresets(readinessAccount, readinessDevice, []models.ServicePreset{kept, dropped}); err != nil {
|
||||
t.Fatalf("SavePresets: %v", err)
|
||||
}
|
||||
|
||||
_, err := m.checkMigrationDataReady(deviceIP)
|
||||
notReady := requireMigrationNotReady(t, err)
|
||||
|
||||
if strings.Contains(notReady.Action, "Run Data Sync") {
|
||||
t.Errorf("action = %q, want it not to prescribe a sync that cannot help", notReady.Action)
|
||||
}
|
||||
if !strings.Contains(notReady.Action, "source") {
|
||||
t.Errorf("action = %q, want it to point at the missing source", notReady.Action)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrationSummaryReportsDataReadiness: the pre-flight panel must show a
|
||||
// refusal before the user commits, rather than showing all-green and failing
|
||||
// with a 409 at Apply.
|
||||
func TestMigrationSummaryReportsDataReadiness(t *testing.T) {
|
||||
t.Run("refusal", func(t *testing.T) {
|
||||
m, _, deviceIP := newMigrationReadinessFixture(t, `<presets/>`)
|
||||
|
||||
// No persisted snapshot, so the check refuses.
|
||||
summary, err := m.GetMigrationSummary(deviceIP, "http://aftertouch.example:8000", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMigrationSummary: %v", err)
|
||||
}
|
||||
|
||||
if summary.DataReadyError == "" {
|
||||
t.Error("summary hid a refusal that Apply would hit")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("warning", func(t *testing.T) {
|
||||
m, ds, deviceIP := newMigrationReadinessFixture(t, `<presets/>`)
|
||||
if err := ds.SavePresets(readinessAccount, readinessDevice, nil); err != nil {
|
||||
t.Fatalf("SavePresets: %v", err)
|
||||
}
|
||||
if err := ds.SaveDeviceInfo(readinessAccount, "SIBLING01", &models.ServiceDeviceInfo{
|
||||
DeviceID: "SIBLING01",
|
||||
AccountID: readinessAccount,
|
||||
Name: "Sibling Speaker",
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDeviceInfo sibling: %v", err)
|
||||
}
|
||||
|
||||
summary, err := m.GetMigrationSummary(deviceIP, "http://aftertouch.example:8000", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMigrationSummary: %v", err)
|
||||
}
|
||||
|
||||
if summary.DataReadyError != "" {
|
||||
t.Errorf("summary reported a refusal for a shared account: %q", summary.DataReadyError)
|
||||
}
|
||||
if len(summary.DataReadyWarnings) != 1 {
|
||||
t.Errorf("warnings = %v, want one about the device count", summary.DataReadyWarnings)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -29,6 +29,58 @@ func TestIsTelnetMigrated_DifferentHostname(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckIsMigratedFromProbe_FormerBackendOffersTelnetRevert(t *testing.T) {
|
||||
m := &Manager{ServerURL: "http://current.example:8000"}
|
||||
summary := &MigrationSummary{
|
||||
TelnetReachable: true,
|
||||
TelnetVerifiedConfig: flatGetpdoResponse(telnetURLs{
|
||||
Marge: "http://former.example:8000/marge",
|
||||
Stats: "http://former.example:8000",
|
||||
SwUpdate: "http://former.example:8000/updates/soundtouch",
|
||||
BmxRegistry: "http://former.example:8000/bmx/registry/v1/services",
|
||||
}),
|
||||
}
|
||||
|
||||
m.checkIsMigratedFromProbe(summary, &speakerProbe{})
|
||||
|
||||
if summary.TelnetMigrated {
|
||||
t.Error("TelnetMigrated = true, want false for a former backend")
|
||||
}
|
||||
|
||||
if !summary.TelnetRevertAvailable {
|
||||
t.Error("TelnetRevertAvailable = false, want rollback for a former backend")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckIsMigratedFromProbe_CanonicalBoseURLsDoNotOfferTelnetRevert(t *testing.T) {
|
||||
m := &Manager{ServerURL: "http://current.example:8000"}
|
||||
summary := &MigrationSummary{
|
||||
TelnetReachable: true,
|
||||
TelnetVerifiedConfig: flatGetpdoResponse(canonicalBoseTelnetURLs()),
|
||||
}
|
||||
|
||||
m.checkIsMigratedFromProbe(summary, &speakerProbe{})
|
||||
|
||||
if summary.TelnetRevertAvailable {
|
||||
t.Error("TelnetRevertAvailable = true, want false for canonical Bose URLs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckIsMigratedFromProbe_PartialNonCanonicalConfigOffersTelnetRevert(t *testing.T) {
|
||||
m := &Manager{ServerURL: "http://current.example:8000"}
|
||||
summary := &MigrationSummary{
|
||||
TelnetReachable: true,
|
||||
TelnetVerifiedConfig: "margeServerUrl=https://streaming.bose.com\n" +
|
||||
"statsServerUrl=http://legacy.example:8000\n",
|
||||
}
|
||||
|
||||
m.checkIsMigratedFromProbe(summary, &speakerProbe{})
|
||||
|
||||
if !summary.TelnetRevertAvailable {
|
||||
t.Error("TelnetRevertAvailable = false, want rollback for partial non-canonical config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTelnetMigrated_EmptyVerifiedConfig(t *testing.T) {
|
||||
m := &Manager{ServerURL: "http://example:8000"}
|
||||
|
||||
|
||||
@@ -77,6 +77,10 @@ func TestGetMigrationSummary_TelnetSucceedsSSHFails(t *testing.T) {
|
||||
if !strings.Contains(summary.TelnetVerifiedConfig, target) {
|
||||
t.Errorf("TelnetVerifiedConfig = %q, want it to contain %q", summary.TelnetVerifiedConfig, target)
|
||||
}
|
||||
|
||||
if !summary.TelnetRevertAvailable {
|
||||
t.Error("TelnetRevertAvailable = false, want rollback for live non-canonical URLs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMigrationSummary_TelnetFailsSSHFails(t *testing.T) {
|
||||
|
||||
+335
-65
@@ -14,6 +14,7 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
@@ -80,6 +81,12 @@ type MigrationSummary struct {
|
||||
CurrentResolvConf string `json:"current_resolv_conf,omitempty"`
|
||||
PlannedResolv string `json:"planned_resolv,omitempty"`
|
||||
IsMigrated bool `json:"is_migrated"`
|
||||
// Data readiness, so the pre-flight panel can show a refusal before the
|
||||
// user commits to Apply instead of only surfacing it as a 409 afterwards.
|
||||
// DataReadyError is the reason migration will be refused; empty means it
|
||||
// will proceed. DataReadyWarnings are advisory and do not block.
|
||||
DataReadyError string `json:"data_ready_error,omitempty"`
|
||||
DataReadyWarnings []string `json:"data_ready_warnings,omitempty"`
|
||||
// Per-axis migration signals — IsMigrated is the OR of these. The UI
|
||||
// displays them individually so users can see partial states (e.g.
|
||||
// URLs flipped via telnet but the on-disk XML hasn't caught up, or
|
||||
@@ -107,10 +114,11 @@ type MigrationSummary struct {
|
||||
|
||||
// Telnet (port 17000) preflight state — populated when the user is about to
|
||||
// or has just used MigrationMethodTelnet.
|
||||
TelnetReachable bool `json:"telnet_reachable"`
|
||||
TelnetBanner string `json:"telnet_banner,omitempty"`
|
||||
TelnetVerifiedConfig string `json:"telnet_verified_config,omitempty"`
|
||||
TelnetProbeError string `json:"telnet_probe_error,omitempty"`
|
||||
TelnetReachable bool `json:"telnet_reachable"`
|
||||
TelnetBanner string `json:"telnet_banner,omitempty"`
|
||||
TelnetVerifiedConfig string `json:"telnet_verified_config,omitempty"`
|
||||
TelnetProbeError string `json:"telnet_probe_error,omitempty"`
|
||||
TelnetRevertAvailable bool `json:"telnet_revert_available"`
|
||||
|
||||
// KnownAccountIDs are accountIDs already present in the local datastore;
|
||||
// the UI offers them as choices when pairing a fresh device.
|
||||
@@ -155,6 +163,11 @@ type Manager struct {
|
||||
NewSSH func(host string) SSHClient
|
||||
NewTelnet func(host string) TelnetClient
|
||||
|
||||
// URL-changing telnet operations are multi-command sequences. Keep each
|
||||
// speaker's sequence contiguous while allowing different speakers to run
|
||||
// independently.
|
||||
telnetURLMutationLocks sync.Map // device IP -> *sync.Mutex
|
||||
|
||||
// NewSession opens the WebSocket setup state-machine session used
|
||||
// by ExecuteInitPlan. Tests inject an in-memory fake; the production
|
||||
// default is DialSession.
|
||||
@@ -171,6 +184,19 @@ type Manager struct {
|
||||
MgmtPassword string
|
||||
}
|
||||
|
||||
func (m *Manager) lockTelnetURLMutation(deviceIP string) func() {
|
||||
value, _ := m.telnetURLMutationLocks.LoadOrStore(deviceIP, &sync.Mutex{})
|
||||
|
||||
mu, ok := value.(*sync.Mutex)
|
||||
if !ok {
|
||||
panic("setup: telnet URL mutation lock has unexpected type")
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
|
||||
return mu.Unlock
|
||||
}
|
||||
|
||||
// NewManager creates a new Manager with the given base server URL.
|
||||
func NewManager(serverURL string, ds *datastore.DataStore, cm *certmanager.CertificateManager) *Manager {
|
||||
return &Manager{
|
||||
@@ -303,6 +329,13 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
|
||||
SSHSuccess: false,
|
||||
}
|
||||
|
||||
// Same read-only check MigrateSpeaker runs, reported rather than enforced.
|
||||
if warnings, err := m.checkMigrationDataReady(deviceIP); err != nil {
|
||||
summary.DataReadyError = err.Error()
|
||||
} else {
|
||||
summary.DataReadyWarnings = warnings
|
||||
}
|
||||
|
||||
// Run the telnet preflight in parallel with the SSH-based probes below.
|
||||
// Both transports are queried independently: SSH gives access to
|
||||
// /etc/hosts, /etc/resolv.conf and the on-device XML config; telnet's
|
||||
@@ -526,6 +559,7 @@ func (m *Manager) buildServerHTTPSURL(targetURL string) string {
|
||||
// up in `getpdo CurrentSystemConfiguration`.
|
||||
func (m *Manager) checkIsMigrated(summary *MigrationSummary, deviceIP string) {
|
||||
summary.TelnetMigrated = m.isTelnetMigrated(summary)
|
||||
summary.TelnetRevertAvailable = telnetRevertAvailable(summary.TelnetVerifiedConfig)
|
||||
|
||||
if summary.SSHSuccess {
|
||||
client := m.NewSSH(deviceIP)
|
||||
@@ -873,6 +907,18 @@ func (m *Manager) firstCACertBodyLine() (string, bool) {
|
||||
|
||||
// MigrateSpeaker configures the speaker at the given IP to use this service.
|
||||
func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options map[string]string, method MigrationMethod) (string, error) {
|
||||
readinessWarnings, err := m.checkMigrationDataReady(deviceIP)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Surfaced in the migration log the UI shows, alongside the other
|
||||
// "Warning:" lines, so an advisory reaches the user without blocking them.
|
||||
var preflightLogs string
|
||||
for _, warning := range readinessWarnings {
|
||||
preflightLogs += fmt.Sprintf("Warning: %s\n", warning)
|
||||
}
|
||||
|
||||
if targetURL == "" {
|
||||
targetURL = m.ServerURL
|
||||
}
|
||||
@@ -886,10 +932,12 @@ func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options m
|
||||
// rooted via remote_services.
|
||||
if method == MigrationMethodTelnet {
|
||||
urls := telnetURLsFromOptions(targetURL, options)
|
||||
return m.migrateViaTelnet(deviceIP, targetURL, urls)
|
||||
telnetLogs, telnetErr := m.migrateViaTelnet(deviceIP, urls)
|
||||
|
||||
return preflightLogs + telnetLogs, telnetErr
|
||||
}
|
||||
|
||||
var logs string
|
||||
logs := preflightLogs
|
||||
|
||||
// 0. Off-device backup for safety
|
||||
if backupErr := m.BackupConfigOffDevice(deviceIP); backupErr != nil {
|
||||
@@ -1089,33 +1137,55 @@ func (m *Manager) migrateViaXML(deviceIP, targetURL, proxyURL string, options ma
|
||||
}
|
||||
}
|
||||
|
||||
logs += m.resyncBoseURLsAfterXML(deviceIP, cfg.MargeServerUrl, cfg.SwUpdateUrl)
|
||||
logs += m.resyncBoseURLsAfterXML(deviceIP, telnetURLs{
|
||||
Marge: cfg.MargeServerUrl,
|
||||
Stats: cfg.StatsServerUrl,
|
||||
SwUpdate: cfg.SwUpdateUrl,
|
||||
BmxRegistry: cfg.BmxRegistryUrl,
|
||||
})
|
||||
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
// resyncBoseURLsAfterXML re-applies the boseurls over telnet so the runtime
|
||||
// URL layer matches the XML just written by migrateViaXML.
|
||||
// resyncBoseURLsAfterXML re-applies all four boseurls over telnet so the
|
||||
// runtime URL layer matches the XML just written by migrateViaXML.
|
||||
//
|
||||
// The XML migration only updates the persisted SoundTouchSdkPrivateCfg.xml; it
|
||||
// does not touch the runtime/persistence layer that `getpdo
|
||||
// CurrentSystemConfiguration` reports. When SSH was bootstrapped via #471
|
||||
// (`enable-ssh`), that layer still points at the placeholder boseurls
|
||||
// (https://aftertouch.invalid), so the preflight cross-check keeps warning that
|
||||
// margeServerUrl/swUpdateUrl differ between transports until a reboot.
|
||||
// Re-applying the real boseurls over telnet :17000 reconciles it immediately.
|
||||
// the URLs differ between transports until a reboot.
|
||||
//
|
||||
// All four fields are re-applied, not just marge/swUpdate: the closing
|
||||
// `envswitch boseurls set` commit persists whatever is currently in the
|
||||
// runtime layer at the moment it runs, not only its own two arguments (see
|
||||
// docs/content/docs/analysis/TELNET-COMMAND-REFERENCE.md). Committing while
|
||||
// stats/bmx are still stale in the runtime layer freezes those stale values
|
||||
// into the persistence layer permanently — a later reboot loads that frozen
|
||||
// persistence layer, not the XML file, so nothing short of a factory reset
|
||||
// clears it again. Re-applying the real boseurls over telnet :17000
|
||||
// reconciles all four immediately.
|
||||
//
|
||||
// Best-effort: telnet may be unavailable (no port 17000, or it was closed via
|
||||
// --close-17000), in which case a reboot still reconciles the layers, so this
|
||||
// only returns a note and never fails the migration. Returns the log lines to
|
||||
// append.
|
||||
func (m *Manager) resyncBoseURLsAfterXML(deviceIP, marge, swUpdate string) string {
|
||||
func (m *Manager) resyncBoseURLsAfterXML(deviceIP string, urls telnetURLs) string {
|
||||
if m.NewTelnet == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
rlogs, rerr := m.setBoseURLsViaTelnet(deviceIP, marge, swUpdate)
|
||||
rlogs, rerr := m.setAllBoseURLsViaTelnet(deviceIP, urls)
|
||||
if rerr != nil {
|
||||
// A rejected URL is not the device being unreachable, and saying so
|
||||
// would send the user looking at telnet. The XML write has already
|
||||
// happened with this value, so the URL itself is what needs attention.
|
||||
if errors.Is(rerr, ErrInvalidTelnetURL) {
|
||||
return fmt.Sprintf("Note: skipped the telnet boseurls re-sync because a URL was rejected (%v); "+
|
||||
"the XML configuration was still written, and a device reboot will reconcile the runtime layer.\n", rerr)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Note: could not re-sync boseurls over telnet (%v); a device reboot will reconcile the runtime layer.\n", rerr)
|
||||
}
|
||||
|
||||
@@ -2240,6 +2310,10 @@ func (m *Manager) rebootViaTelnet(deviceIP string) (string, error) {
|
||||
return "", errors.New("telnet reboot not configured: Manager.NewTelnet is nil")
|
||||
}
|
||||
|
||||
// Do not let a reboot cut through a multi-command URL mutation.
|
||||
unlock := m.lockTelnetURLMutation(deviceIP)
|
||||
defer unlock()
|
||||
|
||||
fmt.Printf("Rebooting speaker at %s via telnet\n", deviceIP)
|
||||
|
||||
t := m.NewTelnet(deviceIP)
|
||||
@@ -2621,12 +2695,51 @@ func (m *Manager) resolveIP(host string, client SSHClient) (string, error) {
|
||||
ErrResolvedFromServiceOnly, host, resolved)
|
||||
}
|
||||
|
||||
// SyncDeviceData fetches presets, recents and sources from the device and saves them to the datastore.
|
||||
func (m *Manager) SyncDeviceData(deviceIP string) error {
|
||||
// SyncResourceDiff describes what a Data Sync would change for one
|
||||
// datastore resource (presets or recents): what's currently stored versus
|
||||
// what the speaker's own live :8090 API returned just now.
|
||||
type SyncResourceDiff struct {
|
||||
Resource string `json:"resource"`
|
||||
CurrentCount int `json:"currentCount"`
|
||||
IncomingCount int `json:"incomingCount"`
|
||||
Removed []string `json:"removed,omitempty"`
|
||||
Destructive bool `json:"destructive"`
|
||||
}
|
||||
|
||||
// SyncResult is the outcome of a SyncDeviceData call: whether it actually
|
||||
// wrote anything, and the per-resource diff that led to that decision.
|
||||
type SyncResult struct {
|
||||
Applied bool `json:"applied"`
|
||||
Destructive bool `json:"destructive"`
|
||||
Diffs []SyncResourceDiff `json:"diffs"`
|
||||
// SourcesCount is the number of configured sources saved for this
|
||||
// device, or -1 if the sources fetch failed. Sources are synced
|
||||
// unconditionally (see syncSources) — there's no diff/confirm gate for
|
||||
// them — so this is a plain count rather than a SyncResourceDiff.
|
||||
SourcesCount int `json:"sourcesCount"`
|
||||
}
|
||||
|
||||
// SyncDeviceData fetches presets, recents and sources from the device and
|
||||
// saves them to the datastore.
|
||||
//
|
||||
// Presets and recents are fetched live from the speaker's own :8090 API and
|
||||
// would previously overwrite the datastore unconditionally — including with
|
||||
// an empty or shrunk list if the speaker's own local cache happened to be
|
||||
// stale or incomplete at that exact moment (e.g. right after a burst of
|
||||
// preset writes, or shortly after a reboot before the speaker has resynced
|
||||
// with Marge). That's a real, confirmed mechanism for #614's "Sync wipes my
|
||||
// presets" reports. Now: if applying would shrink either list relative to
|
||||
// what's already stored, SyncDeviceData does NOT write — it reports the
|
||||
// diff instead — unless confirmed is true. There is no cached "preview"
|
||||
// state: every call (confirmed or not) re-fetches live from the speaker at
|
||||
// that moment, so confirming re-checks reality rather than replaying a
|
||||
// possibly-stale earlier snapshot. Sources are left unconditional, as
|
||||
// before — a source-list change is comparatively low-risk and self-healing.
|
||||
func (m *Manager) SyncDeviceData(deviceIP string, confirmed bool) (SyncResult, error) {
|
||||
// 1. Fetch info to get Serial Number (account identifier)
|
||||
info, err := m.GetLiveDeviceInfo(deviceIP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get device info: %w", err)
|
||||
return SyncResult{}, fmt.Errorf("failed to get device info: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Starting sync for device at %s: Name='%s', DeviceID='%s', SerialNumber='%s'",
|
||||
@@ -2638,7 +2751,7 @@ func (m *Manager) SyncDeviceData(deviceIP string) error {
|
||||
deviceID := info.DeviceID
|
||||
if deviceID == "" {
|
||||
log.Printf("No deviceID found in /info response for device '%s' at %s", sanitizeLog(info.Name), sanitizeLog(deviceIP))
|
||||
return fmt.Errorf("no deviceID found in /info response for device at %s - cannot sync without canonical device identifier", deviceIP)
|
||||
return SyncResult{}, fmt.Errorf("no deviceID found in /info response for device at %s - cannot sync without canonical device identifier", deviceIP)
|
||||
}
|
||||
|
||||
log.Printf("Using deviceID '%s' for sync operations (MAC address from /info)", sanitizeLog(deviceID))
|
||||
@@ -2662,14 +2775,42 @@ func (m *Manager) SyncDeviceData(deviceIP string) error {
|
||||
accountID = "default"
|
||||
}
|
||||
|
||||
// 2. Fetch Presets from :8090
|
||||
m.syncPresets(deviceIP, accountID, deviceID)
|
||||
// 2. Diff presets and recents against a fresh live fetch, before writing
|
||||
// anything.
|
||||
presetDiff, incomingPresets, presetErr := m.presetSyncDiff(deviceIP, accountID, deviceID)
|
||||
if presetErr != nil {
|
||||
log.Printf("[SYNC_ERR] Failed to fetch presets for %s: %v", sanitizeLog(deviceIP), presetErr)
|
||||
}
|
||||
|
||||
// 3. Fetch Recents from :8090
|
||||
m.syncRecents(deviceIP, accountID, deviceID)
|
||||
recentDiff, incomingRecents, recentErr := m.recentSyncDiff(deviceIP, accountID, deviceID)
|
||||
if recentErr != nil {
|
||||
log.Printf("[SYNC_ERR] Failed to fetch recents for %s: %v", sanitizeLog(deviceIP), recentErr)
|
||||
}
|
||||
|
||||
result := SyncResult{
|
||||
Diffs: []SyncResourceDiff{presetDiff, recentDiff},
|
||||
Destructive: presetDiff.Destructive || recentDiff.Destructive,
|
||||
}
|
||||
|
||||
if result.Destructive && !confirmed {
|
||||
log.Printf("[SYNC] Sync for %s would shrink stored data (presets %d->%d, recents %d->%d) — awaiting confirmation, not writing anything",
|
||||
sanitizeLog(deviceIP), presetDiff.CurrentCount, presetDiff.IncomingCount, recentDiff.CurrentCount, recentDiff.IncomingCount)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 3. Apply presets/recents (skip whichever one failed to fetch, leaving
|
||||
// the existing stored data untouched rather than wiping it).
|
||||
if presetErr == nil {
|
||||
_ = m.DataStore.SavePresets(accountID, deviceID, incomingPresets)
|
||||
}
|
||||
|
||||
if recentErr == nil {
|
||||
_ = m.DataStore.SaveRecents(accountID, deviceID, incomingRecents)
|
||||
}
|
||||
|
||||
// 4. Fetch Sources
|
||||
m.syncSources(deviceIP, accountID, deviceID)
|
||||
result.SourcesCount = m.syncSources(deviceIP, accountID, deviceID)
|
||||
|
||||
// 5. Nudge the device to re-render its source list. After a factory
|
||||
// reset (issue #234) the speaker's /sources only lists the always-on
|
||||
@@ -2683,28 +2824,117 @@ func (m *Manager) SyncDeviceData(deviceIP string) error {
|
||||
// 6. Create off-device backup of system configuration
|
||||
_ = m.BackupConfigOffDevice(deviceIP)
|
||||
|
||||
return nil
|
||||
result.Applied = true
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *Manager) syncPresets(deviceIP, accountID, deviceID string) {
|
||||
// presetSyncDiff fetches the live preset list from the speaker and compares
|
||||
// it against what's currently stored, without writing anything.
|
||||
func (m *Manager) presetSyncDiff(deviceIP, accountID, deviceID string) (SyncResourceDiff, []models.ServicePreset, error) {
|
||||
current, _ := m.DataStore.GetPresets(accountID, deviceID)
|
||||
|
||||
incoming, err := m.fetchLivePresets(deviceIP)
|
||||
if err != nil {
|
||||
return SyncResourceDiff{Resource: "presets", CurrentCount: len(current), IncomingCount: len(current)}, nil, err
|
||||
}
|
||||
|
||||
return diffPresets(current, incoming), incoming, nil
|
||||
}
|
||||
|
||||
// recentSyncDiff fetches the live recents list from the speaker and
|
||||
// compares it against what's currently stored, without writing anything.
|
||||
func (m *Manager) recentSyncDiff(deviceIP, accountID, deviceID string) (SyncResourceDiff, []models.ServiceRecent, error) {
|
||||
current, _ := m.DataStore.GetRecents(accountID, deviceID)
|
||||
|
||||
incoming, err := m.fetchLiveRecents(deviceIP)
|
||||
if err != nil {
|
||||
return SyncResourceDiff{Resource: "recents", CurrentCount: len(current), IncomingCount: len(current)}, nil, err
|
||||
}
|
||||
|
||||
return diffRecents(current, incoming), incoming, nil
|
||||
}
|
||||
|
||||
// diffPresets compares a stored preset list against a freshly-fetched one.
|
||||
// Removed lists the names of presets present in current but absent (by
|
||||
// button/slot ID) from incoming — this is what tells an operator "Sync
|
||||
// would remove preset 6: Ici Roussillon" instead of just a bare count.
|
||||
func diffPresets(current, incoming []models.ServicePreset) SyncResourceDiff {
|
||||
incomingIDs := make(map[string]bool, len(incoming))
|
||||
for i := range incoming {
|
||||
if incoming[i].ID != "" {
|
||||
incomingIDs[incoming[i].ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
var removed []string
|
||||
|
||||
for i := range current {
|
||||
if current[i].ID != "" && current[i].Name != "" && !incomingIDs[current[i].ID] {
|
||||
removed = append(removed, current[i].Name)
|
||||
}
|
||||
}
|
||||
|
||||
return SyncResourceDiff{
|
||||
Resource: "presets",
|
||||
CurrentCount: len(current),
|
||||
IncomingCount: len(incoming),
|
||||
Removed: removed,
|
||||
Destructive: len(incoming) < len(current),
|
||||
}
|
||||
}
|
||||
|
||||
// diffRecents compares a stored recents list against a freshly-fetched one.
|
||||
// Recents have no stable per-entry ID the way presets do (they're an
|
||||
// ordered, time-sorted, size-capped list), so entries are matched by
|
||||
// content Location instead.
|
||||
func diffRecents(current, incoming []models.ServiceRecent) SyncResourceDiff {
|
||||
incomingLocations := make(map[string]bool, len(incoming))
|
||||
for i := range incoming {
|
||||
if incoming[i].Location != "" {
|
||||
incomingLocations[incoming[i].Location] = true
|
||||
}
|
||||
}
|
||||
|
||||
var removed []string
|
||||
|
||||
for i := range current {
|
||||
if current[i].Location != "" && current[i].Name != "" && !incomingLocations[current[i].Location] {
|
||||
removed = append(removed, current[i].Name)
|
||||
}
|
||||
}
|
||||
|
||||
return SyncResourceDiff{
|
||||
Resource: "recents",
|
||||
CurrentCount: len(current),
|
||||
IncomingCount: len(incoming),
|
||||
Removed: removed,
|
||||
Destructive: len(incoming) < len(current),
|
||||
}
|
||||
}
|
||||
|
||||
// fetchLivePresets fetches the current preset list straight from the
|
||||
// speaker's own local :8090 API. It does not touch the datastore.
|
||||
func (m *Manager) fetchLivePresets(deviceIP string) ([]models.ServicePreset, error) {
|
||||
presetsURL := fmt.Sprintf("http://%s:8090/presets", deviceIP)
|
||||
if _, _, splitErr := net.SplitHostPort(deviceIP); splitErr == nil {
|
||||
presetsURL = fmt.Sprintf("http://%s/presets", deviceIP)
|
||||
}
|
||||
|
||||
log.Printf("[SYNC] Syncing presets for %s", sanitizeLog(deviceIP))
|
||||
|
||||
resp, err := m.HTTPGet(presetsURL)
|
||||
if err != nil {
|
||||
log.Printf("[SYNC_ERR] Failed to fetch presets for %s: %v", sanitizeLog(deviceIP), err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, fmt.Errorf("GET %s returned %d", presetsURL, resp.StatusCode)
|
||||
}
|
||||
|
||||
var ps models.Presets
|
||||
if decodeErr := xml.NewDecoder(resp.Body).Decode(&ps); decodeErr != nil {
|
||||
return
|
||||
return nil, decodeErr
|
||||
}
|
||||
|
||||
var servicePresets []models.ServicePreset
|
||||
@@ -2748,10 +2978,28 @@ func (m *Manager) syncPresets(deviceIP, accountID, deviceID string) {
|
||||
})
|
||||
}
|
||||
|
||||
_ = m.DataStore.SavePresets(accountID, deviceID, servicePresets)
|
||||
return servicePresets, nil
|
||||
}
|
||||
|
||||
func (m *Manager) syncRecents(deviceIP, accountID, deviceID string) {
|
||||
// syncPresets fetches the live preset list and unconditionally persists it.
|
||||
// Used directly by tests exercising the raw fetch+save behaviour; the
|
||||
// button-driven path goes through SyncDeviceData's diff/confirm guard
|
||||
// instead.
|
||||
func (m *Manager) syncPresets(deviceIP, accountID, deviceID string) {
|
||||
log.Printf("[SYNC] Syncing presets for %s", sanitizeLog(deviceIP))
|
||||
|
||||
presets, err := m.fetchLivePresets(deviceIP)
|
||||
if err != nil {
|
||||
log.Printf("[SYNC_ERR] Failed to fetch presets for %s: %v", sanitizeLog(deviceIP), err)
|
||||
return
|
||||
}
|
||||
|
||||
_ = m.DataStore.SavePresets(accountID, deviceID, presets)
|
||||
}
|
||||
|
||||
// fetchLiveRecents fetches the current recents list straight from the
|
||||
// speaker's own local :8090 API. It does not touch the datastore.
|
||||
func (m *Manager) fetchLiveRecents(deviceIP string) ([]models.ServiceRecent, error) {
|
||||
recentsURL := fmt.Sprintf("http://%s:8090/recents", deviceIP)
|
||||
if _, _, splitErr := net.SplitHostPort(deviceIP); splitErr == nil {
|
||||
recentsURL = fmt.Sprintf("http://%s/recents", deviceIP)
|
||||
@@ -2759,14 +3007,14 @@ func (m *Manager) syncRecents(deviceIP, accountID, deviceID string) {
|
||||
|
||||
resp, err := m.HTTPGet(recentsURL)
|
||||
if err != nil {
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var rr models.RecentsResponse
|
||||
if decodeErr := xml.NewDecoder(resp.Body).Decode(&rr); decodeErr != nil {
|
||||
return
|
||||
return nil, decodeErr
|
||||
}
|
||||
|
||||
var serviceRecents []models.ServiceRecent
|
||||
@@ -2793,10 +3041,28 @@ func (m *Manager) syncRecents(deviceIP, accountID, deviceID string) {
|
||||
})
|
||||
}
|
||||
|
||||
_ = m.DataStore.SaveRecents(accountID, deviceID, serviceRecents)
|
||||
return serviceRecents, nil
|
||||
}
|
||||
|
||||
func (m *Manager) syncSources(deviceIP, accountID, deviceID string) {
|
||||
// syncRecents fetches the live recents list and unconditionally persists
|
||||
// it. Used directly by tests exercising the raw fetch+save behaviour; the
|
||||
// button-driven path goes through SyncDeviceData's diff/confirm guard
|
||||
// instead.
|
||||
func (m *Manager) syncRecents(deviceIP, accountID, deviceID string) {
|
||||
recents, err := m.fetchLiveRecents(deviceIP)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = m.DataStore.SaveRecents(accountID, deviceID, recents)
|
||||
}
|
||||
|
||||
// syncSources fetches the device's configured sources (via SSH first, then
|
||||
// falling back to :8090/sources) and persists them. It returns the number
|
||||
// of sources actually saved, or -1 if neither path produced anything to
|
||||
// save (so the caller/UI can distinguish "synced zero sources" from "sync
|
||||
// didn't run").
|
||||
func (m *Manager) syncSources(deviceIP, accountID, deviceID string) int {
|
||||
client := m.NewSSH(deviceIP)
|
||||
|
||||
sourcesXML, err := client.Run("cat /mnt/nv/BoseApp-Persistence/1/Sources.xml")
|
||||
@@ -2821,7 +3087,7 @@ func (m *Manager) syncSources(deviceIP, accountID, deviceID string) {
|
||||
|
||||
_ = m.DataStore.SaveConfiguredSources(accountID, deviceID, srs.Sources)
|
||||
|
||||
return
|
||||
return len(srs.Sources)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2833,46 +3099,50 @@ func (m *Manager) syncSources(deviceIP, accountID, deviceID string) {
|
||||
|
||||
resp, err := m.HTTPGet(sourcesURL)
|
||||
if err != nil {
|
||||
return
|
||||
return -1
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var srs models.Sources
|
||||
if decodeErr := xml.NewDecoder(resp.Body).Decode(&srs); decodeErr == nil {
|
||||
var configuredSources []models.ConfiguredSource
|
||||
if decodeErr := xml.NewDecoder(resp.Body).Decode(&srs); decodeErr != nil {
|
||||
return -1
|
||||
}
|
||||
|
||||
for _, s := range srs.SourceItem {
|
||||
cs := models.ConfiguredSource{
|
||||
DisplayName: s.DisplayName,
|
||||
Secret: "",
|
||||
SecretType: "",
|
||||
}
|
||||
if s.Status == "READY" {
|
||||
cs.SecretType = "token"
|
||||
}
|
||||
var configuredSources []models.ConfiguredSource
|
||||
|
||||
if s.Source == constants.ProviderSpotify {
|
||||
cs.SecretType = "token_version_3"
|
||||
}
|
||||
|
||||
cs.SourceKey.Type = s.Source
|
||||
cs.SourceKey.Account = s.SourceAccount
|
||||
// Also set legacy fields for now
|
||||
cs.SourceKeyType = s.Source
|
||||
cs.SourceKeyAccount = s.SourceAccount
|
||||
|
||||
configuredSources = append(configuredSources, cs)
|
||||
for _, s := range srs.SourceItem {
|
||||
cs := models.ConfiguredSource{
|
||||
DisplayName: s.DisplayName,
|
||||
Secret: "",
|
||||
SecretType: "",
|
||||
}
|
||||
if s.Status == "READY" {
|
||||
cs.SecretType = "token"
|
||||
}
|
||||
|
||||
// Drop device-local/transient sources without a resolvable
|
||||
// sourceproviderid (e.g. STORED_MUSIC_MEDIA_RENDERER, UPNP).
|
||||
// Persisting them causes /full to emit an empty <sourceproviderid>
|
||||
// which the speaker rejects as INVALID_SOURCE (#334).
|
||||
configuredSources = filterServableSources(configuredSources, deviceID)
|
||||
if s.Source == constants.ProviderSpotify {
|
||||
cs.SecretType = "token_version_3"
|
||||
}
|
||||
|
||||
_ = m.DataStore.SaveConfiguredSources(accountID, deviceID, configuredSources)
|
||||
cs.SourceKey.Type = s.Source
|
||||
cs.SourceKey.Account = s.SourceAccount
|
||||
// Also set legacy fields for now
|
||||
cs.SourceKeyType = s.Source
|
||||
cs.SourceKeyAccount = s.SourceAccount
|
||||
|
||||
configuredSources = append(configuredSources, cs)
|
||||
}
|
||||
|
||||
// Drop device-local/transient sources without a resolvable
|
||||
// sourceproviderid (e.g. STORED_MUSIC_MEDIA_RENDERER, UPNP).
|
||||
// Persisting them causes /full to emit an empty <sourceproviderid>
|
||||
// which the speaker rejects as INVALID_SOURCE (#334).
|
||||
configuredSources = filterServableSources(configuredSources, deviceID)
|
||||
|
||||
_ = m.DataStore.SaveConfiguredSources(accountID, deviceID, configuredSources)
|
||||
|
||||
return len(configuredSources)
|
||||
}
|
||||
|
||||
// filterServableSources returns a copy of srcs containing only sources that
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
@@ -18,9 +19,9 @@ const (
|
||||
defaultSetupStepTimeout = 8 * time.Second
|
||||
setupHandshakeTimeout = 10 * time.Second
|
||||
|
||||
// LanguageEnglish is the sysLanguage code for English. ‹2› is the
|
||||
// value the official Bose app sends during English-locale setup.
|
||||
LanguageEnglish = 2
|
||||
// LanguageEnglish is the sysLanguage code used by Stockholm and the
|
||||
// speaker firmware for English.
|
||||
LanguageEnglish = int(models.LanguageEnglish)
|
||||
|
||||
// DefaultMargeAuthToken is the placeholder userAuthToken sent in
|
||||
// <PairDeviceWithAccount> when the caller didn't supply one. The
|
||||
@@ -271,9 +272,14 @@ func (s *Session) IdentifyEnter(ctx context.Context, timeoutMs int) error {
|
||||
return s.sendStep(ctx, "setup", "POST", body)
|
||||
}
|
||||
|
||||
// SetLanguage POSTs sysLanguage. Code 2 = English.
|
||||
// SetLanguage POSTs a validated sysLanguage code.
|
||||
func (s *Session) SetLanguage(ctx context.Context, code int) error {
|
||||
if err := models.LanguageCode(code).Validate(); err != nil {
|
||||
return fmt.Errorf("SetLanguage: %w", err)
|
||||
}
|
||||
|
||||
body := fmt.Sprintf(`<sysLanguage>%d</sysLanguage>`, code)
|
||||
|
||||
return s.sendStep(ctx, "language", "POST", body)
|
||||
}
|
||||
|
||||
|
||||
@@ -215,6 +215,20 @@ func TestSession_RequestIDsAreUniquePerStep(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSession_SetLanguageRejectsUnknownCodeBeforeWrite(t *testing.T) {
|
||||
f := newFakeSpeaker(t)
|
||||
s := dialFakeSession(t, f, "X")
|
||||
|
||||
err := s.SetLanguage(context.Background(), 14)
|
||||
if err == nil {
|
||||
t.Fatal("expected unknown language code to be rejected")
|
||||
}
|
||||
|
||||
if frames := f.recordedFrames(); len(frames) != 0 {
|
||||
t.Fatalf("invalid language wrote %d frames: %v", len(frames), frames)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSession_IgnoresUpdatesFramesBeforeAck(t *testing.T) {
|
||||
f := newFakeSpeaker(t)
|
||||
f.reply = func(frame string) []string {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user