mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-31 14:57:17 +00:00
Compare commits
@@ -86,7 +86,7 @@ body:
|
||||
attributes:
|
||||
label: AfterTouch version
|
||||
description: Shown in the admin UI footer, or via the binary's `--version`.
|
||||
placeholder: "v0.111.2"
|
||||
placeholder: "v0.123.0"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
|
||||
@@ -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,49 @@
|
||||
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 with a
|
||||
# clear message here 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
|
||||
@@ -197,7 +197,7 @@ jobs:
|
||||
- name: Check documentation links
|
||||
run: |
|
||||
npm install -g markdown-link-check
|
||||
find . -name "*.md" -not -path "./tests/*" -not -path "./node_modules/*" -print0 | xargs -0 -n1 markdown-link-check -q -v -c .github/markdown-link-check.json
|
||||
./scripts/check-doc-links.sh
|
||||
|
||||
- name: Warn on pending images
|
||||
run: |
|
||||
@@ -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@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
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@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
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.
|
||||
@@ -414,12 +424,62 @@ 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
|
||||
with:
|
||||
@@ -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@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
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.5-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.27.0-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-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,15 @@ 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/...
|
||||
|
||||
check: fmt vet test test-http-client
|
||||
|
||||
# Archive any existing tests/integration/testdata/ to a timestamped sibling
|
||||
@@ -243,7 +252,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 +510,7 @@ 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-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
|
||||
@@ -113,6 +112,7 @@ See the [API Reference](https://gesellix.github.io/Bose-SoundTouch/docs/referenc
|
||||
- **[SoundTouch Plus](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus)** (Todd Lucas) — Home Assistant integration; extensive undocumented API documentation
|
||||
- **[ÜberBöse API](https://github.com/julius-d/ueberboese-api)** (Julius) — API research and advanced endpoint discovery
|
||||
- **[Bose SoundTouch Hook](https://github.com/CodeFinder2/bose-soundtouch-hook)** (Adrian Böckenkamp) — `LD_PRELOAD` hooking for reverse engineering device internals
|
||||
- **[STR, SoundTouch Reborn](https://github.com/JRpersonal/streborn)** ([st-reborn.de](https://st-reborn.de)) — on-device agent plus desktop app; its published `iptables` REDIRECT technique is what makes AfterTouch's on-device install reachable over the LAN on co-processor chassis (see [Model Support Matrix](https://gesellix.github.io/Bose-SoundTouch/docs/reference/MODEL-SUPPORT-MATRIX/))
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// updateCheckRepo is the GitHub repo checked for newer releases, matching
|
||||
// soundtouch-service's periodic background check (#591,
|
||||
// _/i591/design-update-check.md).
|
||||
const updateCheckRepo = "gesellix/Bose-SoundTouch"
|
||||
|
||||
// updateCheckCommand assembles the on-demand `soundtouch-backup
|
||||
// update-check` command, the CLI-side answer to that design doc's open
|
||||
// question 2 (CLI-only users get no update notice from the service's
|
||||
// background checker). Unlike the service's opt-in periodic check, running
|
||||
// this command *is* the opt-in: no config flag, no persisted state, just
|
||||
// one GitHub API request each time it's invoked.
|
||||
func updateCheckCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "update-check",
|
||||
Usage: "Check GitHub for a newer soundtouch-backup release",
|
||||
Action: runUpdateCheck,
|
||||
}
|
||||
}
|
||||
|
||||
func runUpdateCheck(c *cli.Context) error {
|
||||
checker := updatecheck.NewChecker(nil, updateCheckRepo, version)
|
||||
|
||||
result, err := checker.CheckNow(c.Context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update check failed: %w", err)
|
||||
}
|
||||
|
||||
printUpdateCheckResult(result)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func printUpdateCheckResult(result updatecheck.Result) {
|
||||
if result.LatestVersion == "" {
|
||||
fmt.Printf("Running %s, not a released version, skipping comparison.\n", result.CurrentVersion)
|
||||
return
|
||||
}
|
||||
|
||||
if result.Available {
|
||||
fmt.Printf("A newer version is available: %s (you're on %s)\n", result.LatestVersion, result.CurrentVersion)
|
||||
fmt.Println(result.ReleaseURL)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("You're on the latest version (%s).\n", result.CurrentVersion)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
|
||||
)
|
||||
|
||||
// TestUpdateCheckCommand_Registered checks the command is wired up with the
|
||||
// expected name and an Action, without making any real GitHub API calls.
|
||||
func TestUpdateCheckCommand_Registered(t *testing.T) {
|
||||
cmd := updateCheckCommand()
|
||||
|
||||
if cmd.Name != "update-check" {
|
||||
t.Errorf("command name = %q; want %q", cmd.Name, "update-check")
|
||||
}
|
||||
|
||||
if cmd.Action == nil {
|
||||
t.Error("expected an Action to be set")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintUpdateCheckResult_DoesNotPanic exercises all three result shapes
|
||||
// (unparseable current version, update available, up to date) purely for
|
||||
// the "does not panic" guarantee; updatecheck.Checker's own tests already
|
||||
// cover the comparison logic itself.
|
||||
func TestPrintUpdateCheckResult_DoesNotPanic(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
result updatecheck.Result
|
||||
}{
|
||||
{"unparseable current version", updatecheck.Result{CurrentVersion: "dev"}},
|
||||
{"update available", updatecheck.Result{CurrentVersion: "v1.0.0", LatestVersion: "v1.1.0", Available: true, ReleaseURL: "https://example.invalid"}},
|
||||
{"up to date", updatecheck.Result{CurrentVersion: "v1.1.0", LatestVersion: "v1.1.0", Available: false}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
printUpdateCheckResult(tc.result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ func main() {
|
||||
allCommand(),
|
||||
cloudCommand(),
|
||||
localCommand(),
|
||||
updateCheckCommand(),
|
||||
},
|
||||
}
|
||||
if err := app.Run(os.Args); err != nil {
|
||||
|
||||
+259
-13
@@ -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"
|
||||
@@ -52,10 +53,12 @@ func setupCommand() *cli.Command {
|
||||
setupRemoteServicesCmd(),
|
||||
setupInstallCACmd(),
|
||||
setupMigrateCmd(),
|
||||
setupRevertCmd(),
|
||||
setupRebootCmd(),
|
||||
setupVerifyCmd(),
|
||||
setupPlanCmd(),
|
||||
setupPairCmd(),
|
||||
setupSyncCmd(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -517,8 +520,13 @@ func setupSSHCheckCmd() *cli.Command {
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("port 22 not reachable: %v", err))
|
||||
fmt.Println()
|
||||
fmt.Println("Modern SoundTouch firmware (27.x) does not let us enable SSH from")
|
||||
fmt.Println("telnet — those commands were removed. To enable SSH on the speaker:")
|
||||
fmt.Println("Try enabling it over telnet first — this works on many (not all) FW 27.x")
|
||||
fmt.Println("speakers via the port-17000 envswitch trick (#471):")
|
||||
fmt.Println(" soundtouch-cli setup enable-ssh")
|
||||
fmt.Println("For stubborn devices (ST Portable, CineMate 520) where the default")
|
||||
fmt.Println("injection is accepted but sshd never starts, add --full-config.")
|
||||
fmt.Println()
|
||||
fmt.Println("If enable-ssh doesn't work on this device, fall back to the USB-stick method:")
|
||||
fmt.Println(" 1. Format a FAT32 USB stick.")
|
||||
fmt.Println(" 2. Create an empty file named `remote_services` at its root.")
|
||||
fmt.Println(" 3. Plug the stick into the speaker (rear USB port) while it is on.")
|
||||
@@ -541,17 +549,23 @@ func setupSSHCheckCmd() *cli.Command {
|
||||
// runEnableSSHInjection runs the port-17000 SSH-enable injection over telnet,
|
||||
// printing the device transcript as it goes. With fullConfig it sends the
|
||||
// #515 sequence (all four config URLs with the injection on margeServerUrl, not
|
||||
// just envswitch) and reboots afterwards; otherwise it sends the single-
|
||||
// envswitch default that fires on the speaker's next boseurls check.
|
||||
func runEnableSSHInjection(m *setup.Manager, host, serviceURL string, fullConfig bool) error {
|
||||
// just envswitch), pausing commandDelay between each of the 6 steps (5
|
||||
// commands + reboot) — see setup.DefaultTelnetCommandDelay for why the pause
|
||||
// exists — then reboots; otherwise it sends the single-envswitch default that
|
||||
// fires on the speaker's next boseurls check (no pause needed, it's one
|
||||
// command).
|
||||
func runEnableSSHInjection(m *setup.Manager, host, serviceURL string, fullConfig bool, commandDelay time.Duration) error {
|
||||
var (
|
||||
logs string
|
||||
err error
|
||||
)
|
||||
|
||||
if fullConfig {
|
||||
fmt.Printf("Enabling SSH on %s via telnet :17000 (full #515 sequence: all four config URLs with the injection on margeServerUrl, then reboot)...\n", host)
|
||||
logs, err = m.EnableSSHViaTelnetFullConfig(host, serviceURL)
|
||||
// 6 steps total (5 commands + reboot), so 6 gaps between/around them.
|
||||
fmt.Printf("Enabling SSH on %s via telnet :17000 (full #515 sequence: all four config URLs with "+
|
||||
"the injection on margeServerUrl, %s between each of 6 steps — about %s before the reboot fires "+
|
||||
"— then reboot)...\n", host, commandDelay, 6*commandDelay)
|
||||
logs, err = m.EnableSSHViaTelnetFullConfig(host, serviceURL, commandDelay)
|
||||
} else {
|
||||
fmt.Printf("Enabling SSH on %s via telnet :17000 (runs on the speaker's next boseurls check, up to ~60s)...\n", host)
|
||||
logs, err = m.EnableSSHViaTelnet(host, serviceURL)
|
||||
@@ -570,6 +584,10 @@ func runEnableSSHInjection(m *setup.Manager, host, serviceURL string, fullConfig
|
||||
return nil
|
||||
}
|
||||
|
||||
if commandDelay > 0 {
|
||||
time.Sleep(commandDelay)
|
||||
}
|
||||
|
||||
fmt.Println("Rebooting the speaker to apply the new configuration...")
|
||||
|
||||
rlogs, rerr := m.Reboot(host, setup.RebootMethodTelnet)
|
||||
@@ -585,6 +603,41 @@ func runEnableSSHInjection(m *setup.Manager, host, serviceURL string, fullConfig
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureMargeAccountPaired checks /info and pairs an unpaired device before
|
||||
// the SSH-enable injection runs — see setup.EnsureMargeAccountPaired for why.
|
||||
// Pairing failure is logged as a warning, not fatal: the claim that an
|
||||
// unpaired device never polls margeServerUrl is not yet confirmed on every
|
||||
// device this command targets, so the injection is still worth attempting
|
||||
// even if the pairing step itself couldn't be verified.
|
||||
func ensureMargeAccountPaired(m *setup.Manager, deviceIP, wantAccountID string) {
|
||||
var t setup.TelnetClient
|
||||
|
||||
if m.NewTelnet != nil {
|
||||
t = m.NewTelnet(deviceIP)
|
||||
|
||||
if dialErr := t.Dial(); dialErr != nil {
|
||||
t = nil
|
||||
} else {
|
||||
defer func() { _ = t.Close() }()
|
||||
}
|
||||
}
|
||||
|
||||
accountID, alreadyPaired, logs, err := m.EnsureMargeAccountPaired(deviceIP, wantAccountID, t)
|
||||
if logs != "" {
|
||||
fmt.Print(logs)
|
||||
}
|
||||
|
||||
switch {
|
||||
case err != nil:
|
||||
PrintWarning(fmt.Sprintf("Pairing check failed (%v) — continuing anyway; the SSH-enable injection may not "+
|
||||
"fire on an unpaired device (#515).", err))
|
||||
case alreadyPaired:
|
||||
fmt.Printf("Device already paired (margeAccountUUID=%s).\n", accountID)
|
||||
default:
|
||||
fmt.Printf("Device was unpaired — paired it with generated account %s so margeServerUrl gets polled (#515).\n", accountID)
|
||||
}
|
||||
}
|
||||
|
||||
func setupEnableSSHCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "enable-ssh",
|
||||
@@ -608,6 +661,24 @@ func setupEnableSSHCmd() *cli.Command {
|
||||
Usage: "For stubborn devices (ST Portable, CineMate 520) where the default single-envswitch injection is accepted but sshd never starts: " +
|
||||
"replicate the #515 manual sequence — write all four sys configuration URL keys with the SSH-enable injection on margeServerUrl (not just envswitch), then reboot",
|
||||
},
|
||||
&cli.DurationFlag{
|
||||
Name: "command-delay",
|
||||
Value: setup.DefaultTelnetCommandDelay,
|
||||
Usage: "Only affects --full-config: pause between each of its 6 steps (5 commands + reboot). " +
|
||||
"Raise this if the default doesn't work on your device; 0 sends everything back-to-back",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "no-auto-pair",
|
||||
Usage: "Skip the automatic pairing check: by default, enable-ssh reads /info first and pairs an unpaired " +
|
||||
"(factory-reset) device with an account ID, since an unpaired device reportedly never " +
|
||||
"polls margeServerUrl at all (#515) — the injection would have nothing to fire on otherwise",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "account",
|
||||
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",
|
||||
Usage: "Skip restoring clean boseurls after SSH is up (leaves the injected marge URL in place)",
|
||||
@@ -641,7 +712,11 @@ func setupEnableSSHCmd() *cli.Command {
|
||||
serviceURL = "https://aftertouch.invalid"
|
||||
}
|
||||
|
||||
if err := runEnableSSHInjection(m, cfg.Host, serviceURL, c.Bool("full-config")); err != nil {
|
||||
if !c.Bool("no-auto-pair") {
|
||||
ensureMargeAccountPaired(m, cfg.Host, c.String("account"))
|
||||
}
|
||||
|
||||
if err := runEnableSSHInjection(m, cfg.Host, serviceURL, c.Bool("full-config"), c.Duration("command-delay")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -941,6 +1016,116 @@ func promptBasicAuth() (string, string, error) {
|
||||
return user, string(pass), nil
|
||||
}
|
||||
|
||||
// setupSyncCmd wraps POST /api/setup/sync/{deviceId} — the same operation
|
||||
// as the web UI's Devices → Sync Data button. It only reads from the
|
||||
// speaker (presets, recents, sources) into AfterTouch's datastore; it never
|
||||
// writes anything back to the speaker. Useful for scripting or reproducing
|
||||
// what Sync does in isolation (see issue #614: Sync's own code cannot wipe
|
||||
// the speaker's preset table, since it never sends anything back).
|
||||
func setupSyncCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "sync",
|
||||
Usage: "Pull presets/recents/sources from the speaker into AfterTouch's datastore (same as the web UI's \"Sync Data\" button)",
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "service-url", Required: true, Usage: "AfterTouch base URL"},
|
||||
&cli.StringFlag{Name: "auth", Usage: "Basic-auth credentials for AfterTouch as user:pass (omit to be prompted on 401)"},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
cfg := GetClientConfig(c)
|
||||
serviceURL := strings.TrimRight(c.String("service-url"), "/")
|
||||
|
||||
if err := validateServiceURL(serviceURL); err != nil {
|
||||
PrintError(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
client, err := CreateSoundTouchClient(cfg)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
deviceInfo, err := client.GetDeviceInfo()
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to get device info from speaker: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if deviceInfo.DeviceID == "" {
|
||||
err := fmt.Errorf("speaker at %s did not report a DeviceID", cfg.Host)
|
||||
PrintError(err.Error())
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Syncing %s into AfterTouch", deviceInfo.DeviceID), cfg.Host, cfg.Port)
|
||||
|
||||
if err := postSetupSync(serviceURL, deviceInfo.DeviceID, c.String("auth")); err != nil {
|
||||
PrintError(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess(fmt.Sprintf("Synced presets, recents, and sources for %s.", deviceInfo.DeviceID))
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// postSetupSync POSTs to AfterTouch's /api/setup/sync/{deviceId}, prompting
|
||||
// for basic-auth credentials on 401 (matches fetchCACert's pattern).
|
||||
func postSetupSync(serviceURL, deviceID, authFlag string) error {
|
||||
endpoint := fmt.Sprintf("%s/api/setup/sync/%s", serviceURL, deviceID)
|
||||
|
||||
doRequest := func(user, pass string) (*http.Response, error) {
|
||||
req, err := http.NewRequest(http.MethodPost, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if user != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
user, pass := splitAuth(authFlag)
|
||||
|
||||
resp, err := doRequest(user, pass)
|
||||
if err != nil {
|
||||
return fmt.Errorf("POST %s: %w", endpoint, err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
_ = resp.Body.Close()
|
||||
|
||||
fmt.Printf("%s requires basic auth.\n", endpoint)
|
||||
|
||||
user, pass, err = promptBasicAuth()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err = doRequest(user, pass)
|
||||
if err != nil {
|
||||
return fmt.Errorf("POST %s (with auth): %w", endpoint, err)
|
||||
}
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("POST %s returned %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupMigrateCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "migrate",
|
||||
@@ -951,6 +1136,10 @@ func setupMigrateCmd() *cli.Command {
|
||||
&cli.StringFlag{Name: "method", Value: string(setup.MigrationMethodTelnet), Usage: "telnet | hosts | resolv | xml"},
|
||||
&cli.StringFlag{Name: "proxy-url", Usage: "Optional upstream proxy URL (for --method=xml)"},
|
||||
&cli.BoolFlag{Name: "skip-preflight", Usage: "Skip the AfterTouch settings preflight (use when AfterTouch's settings endpoint is unreachable)"},
|
||||
&cli.StringFlag{Name: "marge-url", Usage: "Override margeServerUrl instead of deriving it from --service-url (e.g. to restore the original Bose cloud URL). Applies to --method=telnet and --method=xml"},
|
||||
&cli.StringFlag{Name: "stats-url", Usage: "Override statsServerUrl (telnet/xml)"},
|
||||
&cli.StringFlag{Name: "sw-update-url", Usage: "Override swUpdateUrl (telnet/xml)"},
|
||||
&cli.StringFlag{Name: "bmx-url", Usage: "Override bmxRegistryUrl (telnet/xml)"},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
cfg := GetClientConfig(c)
|
||||
@@ -962,6 +1151,13 @@ func setupMigrateCmd() *cli.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
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"),
|
||||
}
|
||||
|
||||
m := setup.NewManager(serviceURL, nil, nil)
|
||||
|
||||
// For DNS-redirect methods check that AfterTouch's DNS listener
|
||||
@@ -985,7 +1181,7 @@ func setupMigrateCmd() *cli.Command {
|
||||
|
||||
fmt.Printf("Migrating %s → %s using method=%s\n", cfg.Host, serviceURL, method)
|
||||
|
||||
logs, err := m.MigrateSpeaker(cfg.Host, serviceURL, c.String("proxy-url"), nil, method)
|
||||
logs, err := m.MigrateSpeaker(cfg.Host, serviceURL, c.String("proxy-url"), options, method)
|
||||
if logs != "" {
|
||||
fmt.Print(logs)
|
||||
}
|
||||
@@ -1314,6 +1510,45 @@ 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.
|
||||
//
|
||||
// Deliberately out of scope (matches the web UI button): SSH/remote_services
|
||||
// persistence (use `setup remote-services --remove`) and account pairing
|
||||
// (use `account unpair`) — see #614 self-test notes for the full checklist.
|
||||
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",
|
||||
Before: RequireHost,
|
||||
Action: func(c *cli.Context) error {
|
||||
cfg := GetClientConfig(c)
|
||||
m := setup.NewManager("", nil, nil)
|
||||
|
||||
fmt.Printf("Reverting migration on %s...\n", cfg.Host)
|
||||
|
||||
logs, err := m.RevertMigration(cfg.Host)
|
||||
if logs != "" {
|
||||
fmt.Print(logs)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
PrintError(err.Error())
|
||||
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.")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func setupRebootCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "reboot",
|
||||
@@ -1740,7 +1975,7 @@ 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)"},
|
||||
@@ -1764,8 +1999,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 {
|
||||
@@ -1847,6 +2082,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"),
|
||||
@@ -1860,7 +2106,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)
|
||||
|
||||
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -43,6 +45,46 @@ func captureStdout(t *testing.T, fn func()) string {
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestPostSetupSync_PostsToDeviceScopedURL(t *testing.T) {
|
||||
var gotMethod, gotPath string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotMethod = r.Method
|
||||
gotPath = r.URL.Path
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok": true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if err := postSetupSync(srv.URL, "DEVICEID01", ""); err != nil {
|
||||
t.Fatalf("postSetupSync: %v", err)
|
||||
}
|
||||
|
||||
if gotMethod != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", gotMethod)
|
||||
}
|
||||
|
||||
if want := "/api/setup/sync/DEVICEID01"; gotPath != want {
|
||||
t.Errorf("expected path %q, got %q", want, gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostSetupSync_PropagatesServerError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "device not found", http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := postSetupSync(srv.URL, "DEVICEID01", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for a 404 response")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "device not found") {
|
||||
t.Errorf("expected error to include server body, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSourceTable_AlignsColumnsAndDedupsDisplayName(t *testing.T) {
|
||||
items := []models.SourceItem{
|
||||
// displayName != account → kept as "AUX (AUX IN)"
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// updateCheckRepo is the GitHub repo checked for newer releases, matching
|
||||
// soundtouch-service's periodic background check (#591,
|
||||
// _/i591/design-update-check.md).
|
||||
const updateCheckRepo = "gesellix/Bose-SoundTouch"
|
||||
|
||||
// updateCheckCommand assembles the on-demand `soundtouch-cli update-check`
|
||||
// command, the CLI-side answer to that design doc's open question 2
|
||||
// (CLI-only users get no update notice from the service's background
|
||||
// checker). Unlike the service's opt-in periodic check, running this
|
||||
// command *is* the opt-in: no config flag, no persisted state, just one
|
||||
// GitHub API request each time it's invoked.
|
||||
func updateCheckCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "update-check",
|
||||
Usage: "Check GitHub for a newer soundtouch-cli release",
|
||||
Action: runUpdateCheck,
|
||||
}
|
||||
}
|
||||
|
||||
func runUpdateCheck(c *cli.Context) error {
|
||||
checker := updatecheck.NewChecker(nil, updateCheckRepo, version)
|
||||
|
||||
result, err := checker.CheckNow(c.Context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update check failed: %w", err)
|
||||
}
|
||||
|
||||
printUpdateCheckResult(result)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func printUpdateCheckResult(result updatecheck.Result) {
|
||||
if result.LatestVersion == "" {
|
||||
fmt.Printf("Running %s, not a released version, skipping comparison.\n", result.CurrentVersion)
|
||||
return
|
||||
}
|
||||
|
||||
if result.Available {
|
||||
fmt.Printf("A newer version is available: %s (you're on %s)\n", result.LatestVersion, result.CurrentVersion)
|
||||
fmt.Println(result.ReleaseURL)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("You're on the latest version (%s).\n", result.CurrentVersion)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
|
||||
)
|
||||
|
||||
// TestUpdateCheckCommand_Registered checks the command is wired up with the
|
||||
// expected name and an Action, without making any real GitHub API calls.
|
||||
func TestUpdateCheckCommand_Registered(t *testing.T) {
|
||||
cmd := updateCheckCommand()
|
||||
|
||||
if cmd.Name != "update-check" {
|
||||
t.Errorf("command name = %q; want %q", cmd.Name, "update-check")
|
||||
}
|
||||
|
||||
if cmd.Action == nil {
|
||||
t.Error("expected an Action to be set")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintUpdateCheckResult_DoesNotPanic exercises all three result shapes
|
||||
// (unparseable current version, update available, up to date) purely for
|
||||
// the "does not panic" guarantee; updatecheck.Checker's own tests already
|
||||
// cover the comparison logic itself.
|
||||
func TestPrintUpdateCheckResult_DoesNotPanic(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
result updatecheck.Result
|
||||
}{
|
||||
{"unparseable current version", updatecheck.Result{CurrentVersion: "dev"}},
|
||||
{"update available", updatecheck.Result{CurrentVersion: "v1.0.0", LatestVersion: "v1.1.0", Available: true, ReleaseURL: "https://example.invalid"}},
|
||||
{"up to date", updatecheck.Result{CurrentVersion: "v1.1.0", LatestVersion: "v1.1.0", Available: false}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
printUpdateCheckResult(tc.result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2339,6 +2339,9 @@ func main() {
|
||||
// Defined in cmd_library.go.
|
||||
app.Commands = append(app.Commands, libraryCommand())
|
||||
|
||||
// On-demand GitHub release check (#591). Defined in cmd_updatecheck.go.
|
||||
app.Commands = append(app.Commands, updateCheckCommand())
|
||||
|
||||
// Sort commands alphabetically (including subcommands and flags recursively)
|
||||
sortCommands(app.Commands)
|
||||
|
||||
|
||||
@@ -88,7 +88,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 +137,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:
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
+617
-356
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,199 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// newTestServiceContext builds a real *cli.Context against serviceFlags (the
|
||||
// exact flags soundtouch-service registers), so loadConfig tests exercise the
|
||||
// same parsing/env-var wiring production code does, instead of a hand-rolled
|
||||
// stand-in that could silently drift from it.
|
||||
func newTestServiceContext(t *testing.T, args ...string) *cli.Context {
|
||||
t.Helper()
|
||||
|
||||
app := &cli.App{Flags: serviceFlags}
|
||||
set := flag.NewFlagSet("test", flag.ContinueOnError)
|
||||
|
||||
for _, f := range serviceFlags {
|
||||
if err := f.Apply(set); err != nil {
|
||||
t.Fatalf("apply flag %v: %v", f.Names(), err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := set.Parse(args); err != nil {
|
||||
t.Fatalf("parse args %v: %v", args, err)
|
||||
}
|
||||
|
||||
return cli.NewContext(app, set, nil)
|
||||
}
|
||||
|
||||
func TestResolveFallbackHost(t *testing.T) {
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "localhost"
|
||||
}
|
||||
|
||||
hostname = strings.ToLower(hostname)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
deploymentMode string
|
||||
wantHost string
|
||||
wantWarn bool
|
||||
}{
|
||||
{"on-device uses localhost, no warning", "on-device", "localhost", false},
|
||||
{"public-network returns no fallback, no warning (caller must fail fast)", "public-network", "", false},
|
||||
{"private-network uses this host's own hostname, with warning", "private-network", hostname, true},
|
||||
{"unset/legacy behaves like private-network", "", hostname, true},
|
||||
{"unrecognized mode behaves like private-network", "some-typo", hostname, true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gotHost, gotWarn := resolveFallbackHost(tc.deploymentMode)
|
||||
if gotHost != tc.wantHost {
|
||||
t.Errorf("host: got %q, want %q", gotHost, tc.wantHost)
|
||||
}
|
||||
|
||||
if gotWarn != tc.wantWarn {
|
||||
t.Errorf("warnOnUse: got %v, want %v", gotWarn, tc.wantWarn)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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"))
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig: unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if config.serverURL != "http://localhost:8000" {
|
||||
t.Errorf("serverURL: got %q, want %q", config.serverURL, "http://localhost:8000")
|
||||
}
|
||||
|
||||
if config.httpsDefaultURL != "https://localhost:8443" {
|
||||
t.Errorf("httpsDefaultURL: got %q, want %q", config.httpsDefaultURL, "https://localhost:8443")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("public-network with no --server-url fails fast instead of guessing", func(t *testing.T) {
|
||||
_, err := loadConfig(newTestServiceContext(t, "--deployment-mode=public-network"))
|
||||
if err == nil {
|
||||
t.Fatal("expected an error, got nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "public-network") {
|
||||
t.Errorf("expected error to mention public-network, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("public-network with an explicit --server-url succeeds", func(t *testing.T) {
|
||||
config, err := loadConfig(newTestServiceContext(t,
|
||||
"--deployment-mode=public-network", "--server-url=https://soundtouch.example.com"))
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig: unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if config.serverURL != "https://soundtouch.example.com" {
|
||||
t.Errorf("serverURL: got %q, want %q", config.serverURL, "https://soundtouch.example.com")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unset deployment-mode with no --server-url keeps today's hostname fallback", func(t *testing.T) {
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "localhost"
|
||||
}
|
||||
|
||||
hostname = strings.ToLower(hostname)
|
||||
|
||||
config, err := loadConfig(newTestServiceContext(t, "--port=8000"))
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig: unexpected error: %v", err)
|
||||
}
|
||||
|
||||
want := "http://" + hostname + ":8000"
|
||||
if config.serverURL != want {
|
||||
t.Errorf("serverURL: got %q, want %q (legacy installs must keep working without --deployment-mode)", config.serverURL, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit --server-url always wins regardless of deployment-mode", func(t *testing.T) {
|
||||
for _, mode := range []string{"", "on-device", "private-network", "public-network"} {
|
||||
config, err := loadConfig(newTestServiceContext(t,
|
||||
"--deployment-mode="+mode, "--server-url=http://198.51.100.7:8000"))
|
||||
if err != nil {
|
||||
t.Fatalf("mode %q: loadConfig: unexpected error: %v", mode, err)
|
||||
}
|
||||
|
||||
if config.serverURL != "http://198.51.100.7:8000" {
|
||||
t.Errorf("mode %q: serverURL: got %q, want explicit override unchanged", mode, config.serverURL)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplyPersistedSettings(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "main-test")
|
||||
if err != nil {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+2
-1
@@ -46,6 +46,7 @@ GET /api/control/devices/{id}/power-status soundtouch
|
||||
GET /api/control/devices/{id}/recents soundtouchweb.(*WebApp).HandleDeviceRecents-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,7 +170,7 @@ 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
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
|
||||
)
|
||||
|
||||
func TestShouldCheckImmediately(t *testing.T) {
|
||||
now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC)
|
||||
interval := 24 * time.Hour
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
lastCheckedAt time.Time
|
||||
want bool
|
||||
}{
|
||||
{"never checked", time.Time{}, true},
|
||||
{"stale (older than interval)", now.Add(-25 * time.Hour), true},
|
||||
{"exactly one interval ago", now.Add(-interval), true},
|
||||
{"recent (within interval)", now.Add(-1 * time.Hour), false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := shouldCheckImmediately(tc.lastCheckedAt, interval, now); got != tc.want {
|
||||
t.Errorf("%s: shouldCheckImmediately() = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldSkipDueToBackoff(t *testing.T) {
|
||||
now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
lastErrorAt time.Time
|
||||
want bool
|
||||
}{
|
||||
{"no recent failure", time.Time{}, false},
|
||||
{"failed 30 minutes ago", now.Add(-30 * time.Minute), true},
|
||||
{"failed exactly 1 hour ago", now.Add(-time.Hour), false},
|
||||
{"failed 2 hours ago", now.Add(-2 * time.Hour), false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := shouldSkipDueToBackoff(tc.lastErrorAt, now); got != tc.want {
|
||||
t.Errorf("%s: shouldSkipDueToBackoff() = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogUpdateIfNewlyAvailable(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
result updatecheck.Result
|
||||
lastLoggedVersion string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "nothing available",
|
||||
result: updatecheck.Result{Available: false},
|
||||
lastLoggedVersion: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "newly available",
|
||||
result: updatecheck.Result{Available: true, LatestVersion: "v1.1.0"},
|
||||
lastLoggedVersion: "",
|
||||
want: "v1.1.0",
|
||||
},
|
||||
{
|
||||
name: "already logged this version",
|
||||
result: updatecheck.Result{Available: true, LatestVersion: "v1.1.0"},
|
||||
lastLoggedVersion: "v1.1.0",
|
||||
want: "v1.1.0",
|
||||
},
|
||||
{
|
||||
name: "a newer version than what was logged",
|
||||
result: updatecheck.Result{Available: true, LatestVersion: "v1.2.0"},
|
||||
lastLoggedVersion: "v1.1.0",
|
||||
want: "v1.2.0",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := logUpdateIfNewlyAvailable(tc.result, tc.lastLoggedVersion); got != tc.want {
|
||||
t.Errorf("%s: logUpdateIfNewlyAvailable() = %q, want %q", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRandomJitter(t *testing.T) {
|
||||
if got := randomJitter(0); got != 0 {
|
||||
t.Errorf("randomJitter(0) = %v, want 0", got)
|
||||
}
|
||||
|
||||
upperBound := 5 * time.Minute
|
||||
for i := 0; i < 20; i++ {
|
||||
got := randomJitter(upperBound)
|
||||
if got < 0 || got >= upperBound {
|
||||
t.Fatalf("randomJitter(%v) = %v, want in [0, %v)", upperBound, got, upperBound)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// There is deliberately no test for startUpdateCheck itself, matching
|
||||
// startDeviceDiscovery (its equally untested sibling): both are thin,
|
||||
// forever-looping goroutine wrappers whose only decisions live in pure
|
||||
// helpers, which is what the tests above and below cover. The former
|
||||
// TestStartUpdateCheck_DisabledIsANoOp asserted a contract that no longer
|
||||
// exists — the goroutine now always starts, precisely so that enabling the
|
||||
// check from the Settings page takes effect without a restart, and an
|
||||
// early return for "disabled" would defeat that.
|
||||
func TestShouldRunUpdateCheckNow(t *testing.T) {
|
||||
now := time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC)
|
||||
interval := 24 * time.Hour
|
||||
stale := now.Add(-25 * time.Hour)
|
||||
fresh := now.Add(-1 * time.Hour)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
enabled bool
|
||||
lastCheckedAt time.Time
|
||||
interval time.Duration
|
||||
lastErrorAt time.Time
|
||||
want bool
|
||||
}{
|
||||
{"disabled, never checked", false, time.Time{}, interval, time.Time{}, false},
|
||||
{"disabled, due", false, stale, interval, time.Time{}, false},
|
||||
{"enabled, never checked", true, time.Time{}, interval, time.Time{}, true},
|
||||
{"enabled, due", true, stale, interval, time.Time{}, true},
|
||||
{"enabled, not due yet", true, fresh, interval, time.Time{}, false},
|
||||
{"enabled and due, but in error backoff", true, stale, interval, now.Add(-30 * time.Minute), false},
|
||||
{"enabled and due, backoff expired", true, stale, interval, now.Add(-2 * time.Hour), true},
|
||||
// A zero interval must not turn every poll tick into a GitHub request.
|
||||
{"enabled with a zero interval", true, stale, 0, time.Time{}, false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got := shouldRunUpdateCheckNow(tc.enabled, tc.lastCheckedAt, tc.interval, tc.lastErrorAt, now)
|
||||
if got != tc.want {
|
||||
t.Errorf("%s: shouldRunUpdateCheckNow() = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateCheckPollTickIsShorterThanTheDefaultInterval guards the property
|
||||
// that makes the Settings-page toggle feel live: the goroutine must re-read
|
||||
// the settings far more often than the check interval itself, otherwise
|
||||
// switching the check on would appear to do nothing for up to a day.
|
||||
func TestUpdateCheckPollTickIsShorterThanTheDefaultInterval(t *testing.T) {
|
||||
if updateCheckPollTick >= 24*time.Hour {
|
||||
t.Errorf("updateCheckPollTick = %v, want well below the 24h default interval", updateCheckPollTick)
|
||||
}
|
||||
}
|
||||
@@ -8,3 +8,4 @@ parity_mismatches/
|
||||
stats/
|
||||
patterns.json
|
||||
settings.json
|
||||
update-check.json
|
||||
|
||||
@@ -35,7 +35,7 @@ services:
|
||||
start_period: 3s
|
||||
|
||||
spotify-mock:
|
||||
image: golang:1.26.5-alpine
|
||||
image: golang:1.27.0-alpine
|
||||
container_name: spotify-mock
|
||||
working_dir: /app
|
||||
volumes:
|
||||
@@ -53,7 +53,7 @@ services:
|
||||
start_period: 3s
|
||||
|
||||
amazon-mock:
|
||||
image: golang:1.26.5-alpine
|
||||
image: golang:1.27.0-alpine
|
||||
container_name: amazon-mock
|
||||
working_dir: /app
|
||||
volumes:
|
||||
@@ -71,7 +71,7 @@ services:
|
||||
start_period: 3s
|
||||
|
||||
tunein-mock:
|
||||
image: golang:1.26.5-alpine
|
||||
image: golang:1.27.0-alpine
|
||||
container_name: tunein-mock
|
||||
working_dir: /app
|
||||
volumes:
|
||||
|
||||
@@ -112,6 +112,14 @@ 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`.
|
||||
|
||||
```
|
||||
SETUP_START
|
||||
SETUP_IDENTIFY_DEVICE_ENTER
|
||||
|
||||
@@ -100,16 +100,16 @@ also visible on the ST 20/300/Wave captures in #221. Different from the
|
||||
`sys presetkey N p` form (S4) — the `key prefix_N` shape on FW 27 is what
|
||||
the device's own remote sends.
|
||||
|
||||
| Command | Effect | Source |
|
||||
|---------------------------------|---------------------------------------------------------------------------------------|--------|
|
||||
| `key prefix_1` … `key prefix_6` | Triggers preset 1–6 (same as a remote preset press). | S5 |
|
||||
| `key play` | Begin / resume playback. | S5 |
|
||||
| `key pause` | Pause playback. | S5 |
|
||||
| `key stop` | Stop playback (does **not** terminate the underlying stream). | S5 |
|
||||
| `key prev` | Restart current song / previous track. | S5 |
|
||||
| `key next` | Next track. | S5 |
|
||||
| `key aux` | Toggle Bluetooth / AUX input. | S5 |
|
||||
| `key power` | Echoes "OK" but no observable effect on FW 27.x — possibly handled at a higher layer. | S5 |
|
||||
| Command | Effect | Source |
|
||||
|---------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------|
|
||||
| `key prefix_1` … `key prefix_6` | Triggers preset 1–6 (same as a remote preset press). | S5 |
|
||||
| `key play` | Begin / resume playback. | S5 |
|
||||
| `key pause` | Pause playback. | S5 |
|
||||
| `key stop` | Stop playback (does **not** terminate the underlying stream). | S5 |
|
||||
| `key prev` | Restart current song / previous track. | S5 |
|
||||
| `key next` | Next track. | S5 |
|
||||
| `key aux` | Toggle Bluetooth / AUX input. | S5 |
|
||||
| `key power` | Echoes "OK" but no observable effect on FW 27.x — possibly handled at a higher layer. On Lifestyle/CineMate console devices this is **not** a no-op: it puts the console into standby and, on waking, returns it to the console's own input rather than SoundTouch — see [Lifestyle / Console Device Behavior](../guides/TROUBLESHOOTING.md#lifestyle-console-devices) and #597. | S5 |
|
||||
|
||||
The S4 `bose` script's `sys presetkey N p` form still works, but `key prefix_N` is shorter and matches what the remote already does on FW 27.x.
|
||||
|
||||
@@ -150,11 +150,15 @@ Each `sys configuration` setter is reported by users to return `OK` on success.
|
||||
|
||||
`envswitch` writes to a separate, lower-level persistence store that **wins on next reboot** if the corresponding `sys configuration` value differs. So our migration writes both — see TELNET-MIGRATION-METHOD.md §2.1.
|
||||
|
||||
| Command | Purpose | Source |
|
||||
|---------------------------------------------------|-----------------------------------------------------------------------------------------------|---------|
|
||||
| `envswitch boseurls set <margeUrl> <swUpdateUrl>` | Persist the marge and update URLs. **Two arguments**, in that order. | S6 |
|
||||
| `envswitch accountid set <numeric-id>` | Equivalent to the HTTP `/setMargeAccount` POST. Used as fallback in our `PairAccount` helper. | S6 |
|
||||
| `envswitch accountid get` | Plausible by symmetry but **not yet confirmed** across firmwares; we probe it best-effort. | (probe) |
|
||||
**It's a commit point, not just a two-field setter.** `envswitch boseurls set` persists whatever is currently in the runtime layer at the moment it runs — not only its own two arguments. Confirmed on five variants (`lisa`, `mojo`, `spotty`, `ginger`, `taigan`; [#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)): a `sys configuration` write survives a reboot **if and only if** an `envswitch boseurls set` runs after it. The same command sequence in reverse order silently loses the later `sys configuration` values on reboot — every command still answers, nothing looks wrong until the reboot. This is why our migration and SSH-enable sequences always issue all four `sys configuration` writes first and `envswitch boseurls set` last (see `telnetURLs.Commands()` / `EnableSSHViaTelnetFullConfig`).
|
||||
|
||||
**It does not acknowledge with `OK`.** Unlike `sys configuration` (which does), `envswitch boseurls set` responds with a different string (observed: `Setting Bose Server URLs to <a> and <b> ->`, no `OK` substring). An implementation that waits for the literal token `OK` will hit its own timeout on this exact command. Our `pkg/telnet.Client.SendCommand` doesn't string-match at all — it reads until the connection goes idle — so this only matters if you're hand-typing the sequence or reimplementing the client elsewhere.
|
||||
|
||||
| Command | Purpose | Source |
|
||||
|-------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------|
|
||||
| `envswitch boseurls set <margeUrl> <swUpdateUrl>` | Persist the marge and update URLs, committing the runtime layer as it stands (see above). **Two arguments**, in that order. | S6 |
|
||||
| `envswitch accountid set <numeric-id>` | Equivalent to the HTTP `/setMargeAccount` POST. Used as fallback in our `PairAccount` helper. | S6 |
|
||||
| `envswitch accountid get`, bare `envswitch`, `envswitch boseurls` | **Confirmed unsupported** — all answer `Invalid Command Option` on `lisa`/`mojo`/`spotty` ([#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)). `envswitch` has no read form on any variant tested; the persisted layer can only be written, then observed indirectly after a reboot (e.g. via `getpdo`, which then reflects the *new* value). | (probe) |
|
||||
|
||||
---
|
||||
|
||||
@@ -166,6 +170,8 @@ Each `sys configuration` setter is reported by users to return `OK` on success.
|
||||
|-------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------|
|
||||
| `getpdo CurrentSystemConfiguration` | Echoes the resolved URL set, including margeServerUrl/bmxRegistryUrl/statsServerUrl/swUpdateUrl. We grep our targetURL out of this to confirm a successful migration. | S6 |
|
||||
|
||||
**The two layers are inverted in `getpdo` visibility around a reboot** ([#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)): *before* a reboot, `getpdo` shows the runtime (`sys configuration`) values immediately, while an `envswitch`-written value isn't visible yet; *after* a reboot, the `sys configuration` values are gone and the `envswitch`-persisted values are what's now applied. So a `getpdo` check run before rebooting confirms the writes were accepted, but it is **not** evidence the configuration will survive the reboot — only the `envswitch` write (in the right order, see above) determines that. This is why our own migration verification (`migrateViaTelnet`) checks `getpdo` before reboot only to confirm the runtime layer accepted the values, and never claims persistence from it.
|
||||
|
||||
---
|
||||
|
||||
## The `scm` family — service control
|
||||
@@ -295,6 +301,14 @@ sys reboot
|
||||
|
||||
**Which devices need `--full-config`:** observed on the **SoundTouch Portable (Series I, model 412540, FW `27.0.6.46330.5043500`)** (#515) and on some **CineMate 520** units where the default path leaves `sshd` down. The structural differences from the default path that appear to matter are (1) the injection riding `sys configuration margeServerUrl`, not just `envswitch`, and (2) the explicit `sys reboot`. The `--full-config` automation is **candidate behaviour awaiting reporter confirmation** — the manual sequence is confirmed working on the ST Portable, but the flag that automates it has not yet been re-confirmed on hardware. Not every device responds even to the manual sequence (some ST10 and CineMate 520 units never start `sshd` over telnet at all and need the serial / U-Boot route).
|
||||
|
||||
**On the `--command-delay` between steps:** originally added because a reporter's back-to-back run left `sshd` down while a ~7s-gapped run succeeded ([#515 comment 5228449448](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5228449448)). That theory was **retracted** by the same reporter after a controlled A/B across three variants showed identical outcomes at 0s and 5s gaps ([comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)) — the delay itself doesn't appear to matter. The default is kept small and non-zero (`setup.DefaultTelnetCommandDelay`) as a low-cost hedge for untested variants, not because the delay is known to help.
|
||||
|
||||
**The account-pairing precondition** (raised by `Henri-be`, [#515 comment 5230785528](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5230785528), tracing back to [#471 comment 4903016740](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-4903016740); confirmed empirically by `bitranox`, [#515 comment 5232241580](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5232241580)): a genuinely unpaired (factory-reset, empty `margeAccountUUID`) device does not poll `margeServerUrl` **at all** — confirmed by pointing a reset device's marge URL at a listener and observing zero requests over 10+ minutes. The SSH-enable injection has no read cycle to fire on until the device is paired. `enable-ssh` handles this automatically by default (`EnsureMargeAccountPaired`, `--no-auto-pair` to skip).
|
||||
|
||||
**Factory reset does not remove root access, if it was ever persisted.** Confirmed on a genuinely factory-reset `spotty` ([#471 comment 5232232575](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-5232232575)): after the reset, `margeAccountUUID` was empty, all four service URLs were back to `streaming.bose.com`, and presets were gone — but `/etc/remote_services` and `/mnt/nv/remote_services` **survived**, and SSH (:22) and telnet (:17000) stayed open. So once a device has been through `setup enable-ssh` with persistence (`EnsureRemoteServices`, the default), a later factory reset only wipes configuration, not root access — recovery is re-migrate + re-pair + rename + restore presets, with **no USB stick and no re-running the injection**.
|
||||
|
||||
**Readiness after a reboot is per-port, not a single moment.** `JRpersonal` first measured that the firmware needs roughly 60s after a cold boot before `:8090`'s `/info` answers and marge state is ready — a booting device answers a bare `HTTP 400` with an empty body before its services are up, which is easy to misread as a rejection rather than "too early" ([#471 comment 5231997551](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-5231997551)). `bitranox` refined this across three variants: `:8090` and the diagnostic `:17000` shell (and the config subsystem behind it that `getpdo` reads) do **not** become ready at the same time — waiting for `:8090` and then immediately reading over `:17000` returned an empty response even though the box was otherwise up. Ten observed reboots: down in 2.3–5.3s, ready (able to answer `getpdo` correctly) in 55.1–91.8s, median ~69.8s ([#471 comment 5232046477](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-5232046477)). Anything automated should wait for the specific interface it's about to use, not for a different port to answer first — see the troubleshooting guide's [power-cycle retry note](../guides/TROUBLESHOOTING.md) for the user-facing version of this.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope here, but worth recording
|
||||
|
||||
@@ -82,6 +82,16 @@ Three important details from the discussion:
|
||||
silently restored on reboot — i.e. there is a parallel "envswitch" persistence
|
||||
layer that wins on next boot if you don't also write to it. **We must always
|
||||
issue both.**
|
||||
|
||||
A later, more precise measurement ([#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569), confirmed on
|
||||
five variants: `lisa`/`mojo`/`spotty`/`ginger`/`taigan`) explains *why*
|
||||
order matters: `envswitch boseurls set` is not just a two-field setter, it
|
||||
**commits whatever is currently in the runtime layer at the moment it
|
||||
runs**. A `sys configuration` write only survives a reboot if `envswitch
|
||||
boseurls set` runs **after** it; the same commands in reverse order lose
|
||||
the `sys configuration` values silently on reboot, with every individual
|
||||
command still answering normally. This is why the sequence above is
|
||||
ordered all-four-`sys-configuration`-then-`envswitch`, never the reverse.
|
||||
2. **margeServerUrl path is bare for `soundtouch-service`.** We mount the marge
|
||||
endpoints at the **root** of port 8000, matching what the existing XML
|
||||
migration writes (`Manager.migrateViaXML` in `pkg/service/setup/setup.go`
|
||||
@@ -91,8 +101,15 @@ Three important details from the discussion:
|
||||
routes marge under that sub-path. **For our service: bare URL. For users
|
||||
redirecting to soundcork: append `/marge`** to both `margeServerUrl` and
|
||||
the first argument of `envswitch boseurls set`.
|
||||
3. **Each command must be sent one at a time, waiting for the device's `OK`
|
||||
response** before sending the next one (`foob61451`'s explicit warning).
|
||||
3. **Each command must be sent one at a time, waiting for the device's
|
||||
response** before sending the next one (`foob61451`'s original warning).
|
||||
Note the exception: `sys configuration` commands ack with `OK`, but
|
||||
`envswitch boseurls set` does **not** — it acks with a different string
|
||||
entirely (observed: `Setting Bose Server URLs to <a> and <b> ->`, no `OK`
|
||||
substring; [#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)). An implementation that waits for the
|
||||
literal token `OK` will time out on exactly that command. Wait for the
|
||||
shell's prompt (or, as our own `pkg/telnet.Client` does, for the
|
||||
connection to go idle) rather than string-matching `OK`.
|
||||
|
||||
### 2.2 Account pairing fallback
|
||||
|
||||
@@ -106,7 +123,11 @@ in-band equivalent to the HTTP `/setMargeAccount` call, useful when the
|
||||
about.
|
||||
- Useful read-only verification command: `getpdo CurrentSystemConfiguration` —
|
||||
prints the URLs after the changes have been applied so we can verify before
|
||||
rebooting.
|
||||
rebooting. **It only reflects the runtime (`sys configuration`) layer, not
|
||||
the `envswitch`-persisted layer, so a matching `getpdo` here confirms the
|
||||
writes were accepted, not that they will survive the reboot** — see the
|
||||
layer-visibility caveat in
|
||||
[TELNET-COMMAND-REFERENCE.md](TELNET-COMMAND-REFERENCE.md).
|
||||
- `sys reboot` is the trigger that re-reads both layers.
|
||||
|
||||
### 2.4 What Telnet:17000 cannot do
|
||||
@@ -145,6 +166,22 @@ The values are not validated by the local service, so any numeric `accountId`
|
||||
will work — soundcork's runbook (#228) literally calls the token
|
||||
`soundcorkdoesntcare` to make the point.
|
||||
|
||||
> **Booby trap, confirmed on hardware: never send an empty or truncated body
|
||||
> to this endpoint.** On one firmware, a `POST /setMargeAccount` with an
|
||||
> empty body returned `HTTP 200` and cleared `margeAccountUUID`, un-pairing
|
||||
> an already-working speaker
|
||||
> ([#471 comment 5231977172](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-5231977172)).
|
||||
> A later retry on the same device instead returned `400` and changed
|
||||
> nothing, so the same reporter corrected the finding to
|
||||
> **state-dependent, not a reliable rule you can rely on either way**
|
||||
> ([#471 comment 5232232575](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-5232232575)). A `400` is not proof the
|
||||
> endpoint rejected a bad request (a booting device also answers a bare
|
||||
> `400` with an empty body before its services are ready, per
|
||||
> `JRpersonal`), and a `200` is not proof it did what you wanted. Practical
|
||||
> takeaway: our own `postSetMargeAccount` always sends a well-formed XML
|
||||
> body, so this doesn't affect the CLI/service — but don't probe this
|
||||
> endpoint by hand against a speaker that currently works.
|
||||
|
||||
### 3.2 Why it's broken in practice
|
||||
|
||||
There are **three independent failure modes** observed:
|
||||
@@ -189,10 +226,16 @@ control:
|
||||
recipes).
|
||||
3. **Randomize.** A "Generate" button that picks a 7-digit number and
|
||||
re-rolls if it collides with an existing account in the local datastore.
|
||||
- **Telnet read-back (best-effort).** `envswitch accountid get` is plausible by
|
||||
symmetry with `envswitch accountid set` (#221) but is not yet confirmed
|
||||
across firmwares. We will probe it during preflight; if it returns a value
|
||||
we cross-check it against `:8090/info` and warn on mismatch.
|
||||
- **Telnet read-back: confirmed unsupported.** `envswitch accountid get` was
|
||||
originally listed as "plausible by symmetry with `envswitch accountid set`
|
||||
(#221), not yet confirmed." It's now confirmed the other way: on
|
||||
`lisa`/`mojo`/`spotty`, `envswitch` has **no read form at all** — both bare
|
||||
`envswitch` and `envswitch boseurls` answer `Invalid Command Option`
|
||||
([#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)).
|
||||
The persisted layer can only be written, then
|
||||
observed indirectly after a reboot (e.g. via `getpdo`, mindful of the
|
||||
layer-visibility caveat in
|
||||
[TELNET-COMMAND-REFERENCE.md](TELNET-COMMAND-REFERENCE.md)).
|
||||
|
||||
This means the user is never *forced* to invent a number — the common path is
|
||||
"the device already has an ID, reuse it" — and the manual/randomize controls
|
||||
|
||||
@@ -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. Render stereo pairs as a single device (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,30 @@ 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.
|
||||
|
||||
**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.
|
||||
**Note:** Pair lifecycle (create / rename / remove) remains available through
|
||||
the existing client and CLI group operations. The player intentionally does
|
||||
not expose a "Dissolve pair" action yet: its current remove operation deletes
|
||||
one physical registry record rather than performing an atomic pair lifecycle
|
||||
operation.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -592,6 +592,9 @@ soundtouch-cli --host <device> account remove-amazon --user <USER>
|
||||
soundtouch-cli --host <device> account remove-deezer --user <USER>
|
||||
soundtouch-cli --host <device> account remove-iheart --user <USER>
|
||||
soundtouch-cli --host <device> account remove-nas --user <GUID/0> [--name <NAME>]
|
||||
|
||||
# Unpair the device from its Marge cloud account entirely
|
||||
soundtouch-cli --host <device> account unpair
|
||||
```
|
||||
|
||||
**Supported Services:**
|
||||
@@ -648,6 +651,11 @@ soundtouch-cli --host 192.0.2.10 account remove \
|
||||
- Network music libraries (STORED_MUSIC) don't require passwords, only the UPnP server GUID
|
||||
- After adding an account, use `source list` to verify it appears as available
|
||||
- Some services may require additional authentication steps through their mobile apps
|
||||
- `account unpair` is different from the above: it sends `UnPairDeviceWithAccount`
|
||||
over the speaker's own local WebSocket to remove its **Marge cloud account**
|
||||
pairing entirely (`margeAccountUUID`), not a single streaming-service login.
|
||||
See `setup revert` for the related "undo a migration" operation, which
|
||||
deliberately does *not* call this — the two are separate steps.
|
||||
|
||||
### Bass Control
|
||||
|
||||
@@ -1157,6 +1165,290 @@ soundtouch-cli --host 192.0.2.10 events subscribe --filter zone --no-reconnect
|
||||
- Events are displayed in real-time with emoji indicators
|
||||
- Verbose mode shows additional technical details
|
||||
|
||||
### Update Check
|
||||
|
||||
#### `update-check`
|
||||
|
||||
Check GitHub Releases for a newer `soundtouch-cli` version. Unlike
|
||||
`soundtouch-service`'s periodic background check, this doesn't need a
|
||||
`--host` or any device on the network: it's a single, on-demand GitHub API
|
||||
request. Running the command is itself the opt-in, so there's no config
|
||||
flag or persisted state.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
soundtouch-cli update-check
|
||||
```
|
||||
|
||||
**Example output:**
|
||||
```
|
||||
A newer version is available: v1.3.0 (you're on v1.2.0)
|
||||
https://github.com/gesellix/Bose-SoundTouch/releases/tag/v1.3.0
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- `soundtouch-backup` has the same `update-check` command.
|
||||
- If the running binary isn't a released version (e.g. a dev build),
|
||||
the command reports that and skips the comparison.
|
||||
|
||||
### Setup & Migration
|
||||
|
||||
The `setup <subcommand>` group provisions a speaker end-to-end: enabling
|
||||
SSH, factory-reset + Wi-Fi re-provisioning, pointing it at AfterTouch, CA
|
||||
trust, account pairing, reverting, and one-shot data sync. Each subcommand
|
||||
wraps an existing `pkg/service/setup` helper directly — there's no separate
|
||||
business logic in the CLI layer. Manual provisioning-loop background:
|
||||
[docs/analysis/SETUP-WEBSOCKET-EXPERIMENT.md](../analysis/SETUP-WEBSOCKET-EXPERIMENT.md)
|
||||
and [Device Initial Setup](DEVICE-INITIAL-SETUP.md).
|
||||
|
||||
#### `setup inspect`
|
||||
|
||||
Non-destructive snapshot of the speaker: identity, pairing state, Wi-Fi,
|
||||
sources, presets, and (with `--telnet`) the runtime URL configuration via
|
||||
`getpdo`. Good first command to run against an unfamiliar speaker.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup inspect
|
||||
soundtouch-cli --host <device> setup inspect --telnet # also reads runtime URLs (slower)
|
||||
```
|
||||
|
||||
#### `setup ssh-check`
|
||||
|
||||
Probes whether port 22 is reachable. On failure, prints the `enable-ssh`
|
||||
suggestion and the USB-stick fallback procedure.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup ssh-check [--timeout 3s]
|
||||
```
|
||||
|
||||
#### `setup enable-ssh`
|
||||
|
||||
Bootstraps SSH on a speaker with no prior access, via the port-17000
|
||||
`envswitch` trick (#471) — no USB stick needed. Auto-pairs an unpaired
|
||||
(factory-reset) device first by default (the injection needs something to
|
||||
poll), waits for `:22`, and persists the `remote_services` marker so SSH
|
||||
survives a reboot.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup enable-ssh
|
||||
soundtouch-cli --host <device> setup enable-ssh --service-url https://192.0.2.10:8443
|
||||
```
|
||||
|
||||
Flags:
|
||||
- `--service-url` — optional; only the vehicle for the injection, no live
|
||||
server required. Set the real URL later via `setup migrate`.
|
||||
- `--wait` (default `90s`) — how long to wait for `:22` after injection.
|
||||
- `--full-config` — for stubborn devices (ST Portable, CineMate 520) where
|
||||
the default injection is accepted but `sshd` never starts: writes all
|
||||
four config URLs (the #515 sequence) and reboots.
|
||||
- `--command-delay` — only affects `--full-config`; pause between its 6
|
||||
steps.
|
||||
- `--no-auto-pair` / `--account` — skip or control the automatic pairing
|
||||
check.
|
||||
- `--no-reset-urls` — skip restoring clean `boseurls` after SSH is up.
|
||||
- `--no-persist` — skip persisting `remote_services` (SSH won't survive a
|
||||
reboot).
|
||||
- `--authorized-key` — opt-in hardening: install an SSH public key instead
|
||||
of relying on the empty-password login.
|
||||
- `--close-17000` — opt-in hardening: firewall off port 17000 from the LAN
|
||||
(loopback access kept).
|
||||
|
||||
#### `setup remote-services`
|
||||
|
||||
Enables (default) or removes the `remote_services` SSH-enablement marker.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup remote-services # ensure it's present
|
||||
soundtouch-cli --host <device> setup remote-services --remove # disable SSH after next reboot
|
||||
```
|
||||
|
||||
#### `setup factory-reset`
|
||||
|
||||
Issues `sys factorydefault` over telnet — wipes account, presets, and
|
||||
Wi-Fi, and reboots the speaker into its own setup-mode AP. Prints the next
|
||||
steps (`wait-ap`, then `wifi-push`).
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup factory-reset
|
||||
```
|
||||
|
||||
> **Heads-up:** just before resetting, the speaker sends
|
||||
> `DELETE /streaming/account/{id}/device/{id}` to whatever `margeURL` is
|
||||
> *currently* configured. If that still points at `streaming.bose.com`
|
||||
> (not AfterTouch), AfterTouch keeps a stale datastore entry — migrate
|
||||
> first if you want a clean record.
|
||||
|
||||
#### `setup wait-ap`
|
||||
|
||||
Polls the speaker's setup-mode AP (default `192.0.2.1`) until `/info`
|
||||
responds, after a factory reset.
|
||||
|
||||
```bash
|
||||
soundtouch-cli setup wait-ap [--ap-host 192.0.2.1] [--interval 2s] [--timeout 5m]
|
||||
```
|
||||
|
||||
#### `setup wifi-push`
|
||||
|
||||
POSTs `AddWirelessProfile` to the speaker's setup-mode endpoint — pushes
|
||||
your home Wi-Fi credentials while connected to the speaker's AP.
|
||||
|
||||
```bash
|
||||
soundtouch-cli setup wifi-push --ssid="YourHomeSSID" --pass='your-password'
|
||||
```
|
||||
|
||||
Flags: `--security` (default `wpa_or_wpa2`), `--ap-host` (default
|
||||
`192.0.2.1`), `--request-timeout` (default `30s` — the speaker can be slow
|
||||
to ACK before tearing down AP mode; 10s often races).
|
||||
|
||||
#### `setup wait-online`
|
||||
|
||||
Polls mDNS until a speaker matching `--match` comes online on the home
|
||||
network — run this after switching back from the speaker's AP.
|
||||
|
||||
```bash
|
||||
soundtouch-cli setup wait-online --match=<last-6-hex-of-deviceID>
|
||||
```
|
||||
|
||||
`--match` is empty by default (first speaker seen); `--interval` (`3s`) and
|
||||
`--timeout` (`5m`) control the poll.
|
||||
|
||||
#### `setup install-ca`
|
||||
|
||||
Fetches AfterTouch's CA cert from `/api/setup/ca.crt` and injects it into
|
||||
the speaker's trust store via SSH.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup install-ca --service-url https://192.0.2.10:8443
|
||||
```
|
||||
|
||||
`--auth` (`user:pass`) supplies basic-auth credentials up front; omit it to
|
||||
be prompted interactively if the endpoint returns 401.
|
||||
|
||||
#### `setup migrate`
|
||||
|
||||
Applies a migration method to point the speaker at AfterTouch — the CLI
|
||||
equivalent of the web UI's Migrate tab.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup migrate --service-url http://192.0.2.10:8000 --method telnet
|
||||
```
|
||||
|
||||
`--method` is one of `telnet` (default) | `hosts` | `resolv` | `xml`.
|
||||
`--proxy-url` sets an optional upstream proxy (only used by `--method=xml`).
|
||||
`--skip-preflight` skips AfterTouch's settings preflight check (useful when
|
||||
that endpoint is unreachable).
|
||||
|
||||
`--marge-url`/`--stats-url`/`--sw-update-url`/`--bmx-url` override the
|
||||
corresponding field instead of deriving it from `--service-url` (applies to
|
||||
both `--method=telnet` and `--method=xml`). Useful beyond soundcork-style
|
||||
setups: e.g. pointing a speaker back at the **original Bose cloud URLs**
|
||||
without a full `setup revert` — telnet writes both the runtime and
|
||||
persisted layers in a single connection, no SSH or `.original` backup
|
||||
needed:
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup migrate --method telnet \
|
||||
--service-url https://streaming.bose.com \
|
||||
--marge-url https://streaming.bose.com \
|
||||
--stats-url https://events.api.bosecm.com \
|
||||
--sw-update-url https://worldwide.bose.com/updates/soundtouch \
|
||||
--bmx-url https://content.api.bose.io/bmx/registry/v1/services
|
||||
```
|
||||
|
||||
#### `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.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup revert
|
||||
```
|
||||
|
||||
**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
|
||||
separately if you want a fully clean speaker.
|
||||
|
||||
#### `setup reboot`
|
||||
|
||||
Reboots the speaker — useful to force the envswitch parallel-persistence
|
||||
layer to apply after a migration.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup reboot [--method telnet|ssh]
|
||||
```
|
||||
|
||||
`--method` defaults to `telnet`, which works without SSH on modern
|
||||
firmware.
|
||||
|
||||
#### `setup verify`
|
||||
|
||||
Read-only status probe across every migration axis (transports, URL
|
||||
configuration, DNS interception, CA/TLS, pairing) — doubles as a preflight
|
||||
check before applying changes and a verification step afterward. Exits
|
||||
non-zero if nothing reports migrated, so it's usable as a CI gate.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup verify --service-url http://192.0.2.10:8000
|
||||
```
|
||||
|
||||
#### `setup plan`
|
||||
|
||||
Recommends the next setup/migration steps based on `inspect` + `verify`
|
||||
state — prints a ready-to-run command for each recommended step.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup plan --service-url http://192.0.2.10:8000
|
||||
soundtouch-cli --host <device> setup plan --service-url http://192.0.2.10:8000 --reset # plan a full factory-reset → Wi-Fi → migrate → pair flow
|
||||
```
|
||||
|
||||
`--wifi-ssid` overrides the SSID used for the `wifi-push` step in a reset
|
||||
plan (default: reuse the SSID `inspect` found). `--include-pair` (default
|
||||
`true`) can be disabled if you'll pair manually.
|
||||
|
||||
#### `setup pair`
|
||||
|
||||
Pairs the speaker with an account via the WebSocket `SETUP` state machine
|
||||
(`--mode=full`, matching the Bose app's own flow) or a minimal
|
||||
`setMargeAccount`-only call (`--mode=bare`, the same underlying call the
|
||||
Health tab's "empty margeAccountUUID" QuickFix uses).
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup pair --mode=full --account=1111111 --service-url http://192.0.2.10:8000
|
||||
soundtouch-cli --host <device> setup pair --mode=bare --account=1111111 --service-url http://192.0.2.10:8000
|
||||
```
|
||||
|
||||
`--account` empty generates a fresh 7-digit ID. `--name` sets the speaker
|
||||
name during pairing (empty keeps current). `--language` defaults to `2`
|
||||
(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
|
||||
datastore — the CLI equivalent of the web UI's Devices → Sync Data button.
|
||||
Read-only towards the speaker: it never writes anything back.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup sync --service-url http://192.0.2.10:8000
|
||||
```
|
||||
|
||||
`--auth` (`user:pass`) supplies basic-auth credentials up front; omit it to
|
||||
be prompted interactively if the endpoint returns 401.
|
||||
|
||||
## Common Usage Patterns
|
||||
|
||||
### Quick Device Setup
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -99,18 +99,28 @@ After factory restore the speaker enters setup mode automatically; no power-cycl
|
||||
|
||||
## 6. AP Mode Wi-Fi Provisioning via Console
|
||||
|
||||
When BLE is unavailable (e.g. when using an Android emulator), use AP mode to push Wi-Fi credentials from the Mac command line.
|
||||
When BLE is unavailable (e.g. when using an Android emulator), use AP mode to push Wi-Fi credentials from the command line. The HTTP steps below (6.2, 6.3) are OS-agnostic; only the Wi-Fi-network-switching commands (6.1, 6.4) are platform-specific — macOS is shown inline, with Linux and Windows equivalents alongside.
|
||||
|
||||
### 6.1 Connect Mac to Speaker AP
|
||||
### 6.1 Connect your machine to the Speaker AP
|
||||
|
||||
After factory reset the speaker broadcasts an SSID like `Bose SoundTouch XXXX`. Connect the Mac to it:
|
||||
After factory reset the speaker broadcasts an SSID like `Bose SoundTouch XXXX`. Connect to it:
|
||||
|
||||
```bash
|
||||
# List nearby SSIDs — use System Settings → Wi-Fi (the airport command was removed in macOS Sequoia+)
|
||||
# Connect (replace with actual SSID)
|
||||
# macOS — list nearby SSIDs via System Settings → Wi-Fi (the `airport`
|
||||
# command was removed in macOS Sequoia+); connect (replace with actual SSID):
|
||||
networksetup -setairportnetwork en0 "Bose SoundTouch XXXX"
|
||||
```
|
||||
|
||||
```bash
|
||||
# Linux (NetworkManager) — one-shot connect, no password (open AP):
|
||||
nmcli device wifi connect "Bose SoundTouch XXXX"
|
||||
```
|
||||
|
||||
```powershell
|
||||
# Windows — connect via the built-in Wi-Fi menu, or from PowerShell:
|
||||
netsh wlan connect name="Bose SoundTouch XXXX"
|
||||
```
|
||||
|
||||
The speaker's web UI gateway is at `192.0.2.1` (verified: ST10 assigns `192.0.2.2` to the client via DHCP).
|
||||
|
||||
```bash
|
||||
@@ -143,20 +153,37 @@ Expected response: `<?xml version="1.0" encoding="UTF-8" ?><AddWirelessProfileRe
|
||||
|
||||
The speaker will disconnect from AP mode and join the home network within ~15–30 s.
|
||||
|
||||
### 6.4 Reconnect Mac to Home Network
|
||||
### 6.4 Reconnect to your Home Network
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
networksetup -setairportnetwork en0 "MyHomeNetwork" "MyPassword"
|
||||
```
|
||||
|
||||
```bash
|
||||
# Linux (NetworkManager) — assumes the connection profile already exists
|
||||
# (e.g. from a prior manual connect); use `nmcli device wifi connect
|
||||
# "MyHomeNetwork" password "MyPassword"` instead for a first-time connect.
|
||||
nmcli connection up "MyHomeNetwork"
|
||||
```
|
||||
|
||||
```powershell
|
||||
# Windows
|
||||
netsh wlan connect name="MyHomeNetwork"
|
||||
```
|
||||
|
||||
Wait ~15 s for the speaker to join the home network, then verify:
|
||||
|
||||
```bash
|
||||
# Discover the speaker's new IP via mDNS
|
||||
dns-sd -B _soundtouch._tcp local &
|
||||
sleep 5 ; kill %1
|
||||
# macOS/Linux — discover the speaker's new IP via mDNS.
|
||||
# macOS: dns-sd ships with the OS. Linux: use avahi-browse (avahi-utils package).
|
||||
dns-sd -B _soundtouch._tcp local & # macOS
|
||||
avahi-browse -r _soundtouch._tcp # Linux — Ctrl-C to stop
|
||||
sleep 5 ; kill %1 2>/dev/null # only needed for the dns-sd form
|
||||
```
|
||||
|
||||
Windows has no equivalent built-in mDNS browser; use `soundtouch-cli discover devices` (this repo's own mDNS/UPnP discovery, cross-platform) or check your router's DHCP client list instead.
|
||||
|
||||
---
|
||||
|
||||
## Comparison: Initial Setup vs. Migration
|
||||
|
||||
@@ -40,7 +40,7 @@ systemd unit that starts on boot.
|
||||
To pin a specific version instead of the latest:
|
||||
|
||||
```bash
|
||||
sudo bash install.sh v0.111.3
|
||||
sudo bash install.sh v0.123.0
|
||||
```
|
||||
|
||||
Check that the service is running:
|
||||
@@ -278,7 +278,7 @@ curl -s http://192.0.2.1:8090/presets
|
||||
|
||||
```bash
|
||||
sudo bash install.sh # updates to latest release
|
||||
sudo bash install.sh v0.111.3 # updates to a specific version
|
||||
sudo bash install.sh v0.123.0 # updates to a specific version
|
||||
```
|
||||
|
||||
The installer stops the service, downloads the new binary, and restarts
|
||||
|
||||
@@ -107,6 +107,10 @@ Open `http://<server>:8000` and go to the **Settings** tab.
|
||||
|
||||
Set the **Target Domain** to the address your speakers can reach — for example `https://soundtouch.fritz.box` or `http://192.0.2.100:8000`. This must be the host's address on your local network, not `localhost`.
|
||||
|
||||
> **Changing this later?** Saving Settings only updates AfterTouch's own record of its address — it does **not** reach out to any already-migrated speaker. Each speaker only learns a new address when you (re-)run Migrate for it (Step 5 below), regardless of migration method. If you change Target Domain after some speakers are already migrated, re-migrate each of them too, or they'll keep using whatever address they were originally migrated with. See [Troubleshooting: Changing Target Domain doesn't change what a speaker actually uses](TROUBLESHOOTING.md#settings-vs-migrate).
|
||||
|
||||
> **On-device install:** this "not `localhost`" rule is for the local-network-host and cloud/VPS scenarios above, where the service runs on a *different* machine than the speaker. If you're running AfterTouch directly on the speaker itself (see the [On-Device Install Walkthrough](ON-DEVICE-INSTALL-WALKTHROUGH.md)), the speaker and the service are the same machine — `http://localhost:8000` is exactly right there, and is the recommended value: it needs no DNS/mDNS to resolve and survives DHCP address changes since it never depends on the LAN address at all. Installs built after issue #546's fix set this automatically (via `DEPLOYMENT_MODE=on-device`); on older installs, or if the field still shows the speaker's own unresolvable Linux hostname (e.g. `http://spotty:8000`), set it here by hand.
|
||||
|
||||
If you plan to use DNS/DHCP redirect, enable the **DNS Discovery Server** and set the **DNS Bind Address** to `:53`. The upstream DNS should be your router's IP, not the service's own address.
|
||||
|
||||
> **Tip**: If you change settings and they don't seem to take effect, check `data/settings.json` — settings saved in the UI take precedence over environment variables.
|
||||
@@ -127,6 +131,14 @@ The XML migration writes updated configuration to the speaker's filesystem, whic
|
||||
4. Power-cycle the speaker (unplug the power cable, wait 10 seconds, reconnect).
|
||||
5. After boot, root SSH is available with no password: `ssh -oHostKeyAlgorithms=+ssh-rsa root@<SPEAKER-IP>`
|
||||
|
||||
**Or, without a USB stick:** `soundtouch-cli setup enable-ssh` (#471) bootstraps SSH purely over the network, using the speaker's telnet:17000 diagnostic shell (open by default on most firmware) to inject the SSH-enable command:
|
||||
|
||||
```shell
|
||||
soundtouch-cli --host <SPEAKER-IP> setup enable-ssh
|
||||
```
|
||||
|
||||
It waits for `:22` to come up and persists the change (survives a reboot) by default. Falls back to the USB-stick method above if telnet:17000 is closed or the injection doesn't take on your model.
|
||||
|
||||
You only need to do this once per speaker. SSH can remain enabled for future maintenance or be disabled after migration — your choice.
|
||||
|
||||
**To disable SSH after migration:**
|
||||
|
||||
@@ -14,7 +14,9 @@ documenting a successful fresh installation on a SoundTouch 20 Series I.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- SSH enabled on the speaker (the usual "Stick with remote_services" procedure).
|
||||
- SSH enabled on the speaker — either the usual "USB stick with
|
||||
`remote_services`" procedure, or `soundtouch-cli setup enable-ssh`
|
||||
(no stick needed, see Step 1).
|
||||
- Your machine can reach the speaker on the LAN.
|
||||
- The speaker's LAN IP address — replace `192.0.2.1` throughout with the
|
||||
actual address shown in your router or `arp -a`.
|
||||
@@ -29,6 +31,23 @@ documenting a successful fresh installation on a SoundTouch 20 Series I.
|
||||
|
||||
## Step 1 — Connect to the speaker via SSH
|
||||
|
||||
If SSH isn't enabled yet, you don't need a USB stick: `soundtouch-cli` can
|
||||
bootstrap it purely over the network (#471), using the speaker's
|
||||
telnet:17000 diagnostic shell (open by default on most firmware) to inject
|
||||
the SSH-enable command:
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host 192.0.2.1 setup enable-ssh
|
||||
```
|
||||
|
||||
This waits for `:22` to come up and persists it (survives a reboot) by
|
||||
default. The USB-stick method (format FAT32, create an empty
|
||||
`remote_services` file in its root, insert, power-cycle) still works as a
|
||||
fallback if telnet:17000 is closed or the injection doesn't take on your
|
||||
model.
|
||||
|
||||
Either way, connect the same way:
|
||||
|
||||
```bash
|
||||
ssh -oHostKeyAlgorithms=+ssh-rsa root@192.0.2.1
|
||||
```
|
||||
@@ -65,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.
|
||||
|
||||
@@ -85,11 +104,14 @@ By default this installs the **latest release** — the script resolves it from
|
||||
GitHub's `releases/latest` redirect. To target a specific version instead:
|
||||
|
||||
```bash
|
||||
# Via environment variable (works with pipe-to-sh)
|
||||
VERSION=0.111.3 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
|
||||
# Via environment variable — note it goes on `sh`, not `curl`: shell
|
||||
# variable-assignment prefixes only apply to the one command they're
|
||||
# attached to, and in a pipe each command is a separate process.
|
||||
# `VERSION=0.123.0 curl ... | sh` silently does NOT set it for `sh`.
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | VERSION=0.123.0 sh
|
||||
|
||||
# Via command-line flag (pass args after sh -s --)
|
||||
curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.111.3
|
||||
curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.123.0
|
||||
```
|
||||
|
||||
Verify the installed version:
|
||||
@@ -98,7 +120,7 @@ Verify the installed version:
|
||||
wget -qO- http://localhost:8000/health
|
||||
```
|
||||
|
||||
The JSON response should include `"version":"v0.111.3"` (or whichever
|
||||
The JSON response should include `"version":"v0.123.0"` (or whichever
|
||||
version you installed).
|
||||
|
||||
---
|
||||
@@ -131,13 +153,69 @@ ssh -oHostKeyAlgorithms=+ssh-rsa -L 8000:localhost:8000 root@192.0.2.1
|
||||
Keep this terminal open. Navigate to **http://localhost:8000** in your
|
||||
browser.
|
||||
|
||||
> Skip this step if your speaker's firmware exposes port 8000 on the LAN
|
||||
> directly — you can reach `http://192.0.2.1:8000` without a tunnel in that
|
||||
> case.
|
||||
> **You may not need the tunnel at all.** Try `http://192.0.2.1:8000` first.
|
||||
> If that doesn't load, try **`http://192.0.2.1:17008`**: on speakers whose
|
||||
> Wi-Fi co-processor refuses to pass `:8000` through (the ST20 and likely
|
||||
> others), the installer automatically redirects port `17008` to AfterTouch,
|
||||
> so the Admin UI is reachable from the LAN without any tunnel. Check with
|
||||
> `/etc/init.d/aftertouch status` on the speaker, which reports the LAN port
|
||||
> when the redirect is active. Details and per-model status:
|
||||
> [Model Support Matrix](../reference/MODEL-SUPPORT-MATRIX.md).
|
||||
>
|
||||
> Keep the tunnel in mind anyway for **linking music-service accounts**:
|
||||
> Spotify only accepts `https://` or *loopback* OAuth redirect URIs, so
|
||||
> `http://localhost:8000` through a tunnel succeeds where a plain LAN
|
||||
> address is rejected.
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Run the Health QuickFix for empty `margeAccountUUID`
|
||||
## Step 6 — Migrate (point the speaker at itself)
|
||||
|
||||
The speaker isn't pointed at the AfterTouch instance you just installed yet
|
||||
— this step does that. On-device, the speaker and the AfterTouch instance
|
||||
are the same machine, so **loopback is the correct and recommended Target
|
||||
Domain value**: `http://localhost:8000`. This is the one case where the
|
||||
general migration guide's "must not be `localhost`" warning does not
|
||||
apply — that warning is about the external-host/cloud scenarios, where
|
||||
`localhost` would resolve on the wrong machine (the service host, not the
|
||||
speaker). Here there is no wrong machine to resolve on.
|
||||
|
||||
> **Note:** as of the fix for issue #546, the on-device init script already
|
||||
> sets `DEPLOYMENT_MODE=on-device`, so a fresh (or reinstalled/updated)
|
||||
> on-device install's own Target Domain already defaults to
|
||||
> `http://localhost:8000` automatically — no manual Settings-tab step
|
||||
> needed for that part. Older installs still default to the speaker's own
|
||||
> unresolvable Linux hostname (e.g. `http://spotty:8000`) until reinstalled
|
||||
> with a build that includes the fix, or until the Target Domain is
|
||||
> corrected by hand. Either way, you still need to run Migrate below — that
|
||||
> step tells the *speaker* to use this address, which is separate from what
|
||||
> the service defaults its own identity to.
|
||||
|
||||
**Via the Admin UI:**
|
||||
|
||||
1. Go to **Settings**, set **Target Domain** to `http://localhost:8000`.
|
||||
2. Go to **Devices**, find your speaker (it self-discovers on its own LAN
|
||||
IP), click **Migrate**.
|
||||
3. Accept the suggested plan and let it apply.
|
||||
4. Reboot to apply the change:
|
||||
```bash
|
||||
sync
|
||||
reboot
|
||||
```
|
||||
|
||||
**Or via the CLI** (equivalent, no browser needed — grab `soundtouch-cli`
|
||||
from Step 9 below first if you want this path):
|
||||
|
||||
```bash
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 setup migrate \
|
||||
--service-url http://localhost:8000 --method telnet
|
||||
sync
|
||||
reboot
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Run the Health QuickFix for empty `margeAccountUUID`
|
||||
|
||||
In the AfterTouch UI:
|
||||
|
||||
@@ -148,6 +226,14 @@ In the AfterTouch UI:
|
||||
4. Click the **QuickFix** button (labelled "Fix", "Pair account", or
|
||||
"Apply QuickFix" depending on the version) and confirm.
|
||||
|
||||
Or via the CLI (same underlying pairing call, `--mode=bare` matches what
|
||||
the QuickFix does — see Step 9 to grab `soundtouch-cli` first):
|
||||
|
||||
```bash
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 setup pair \
|
||||
--mode=bare --account=1111111 --service-url http://localhost:8000
|
||||
```
|
||||
|
||||
Then reboot again to let the pairing take effect:
|
||||
|
||||
```bash
|
||||
@@ -157,7 +243,7 @@ reboot
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Verify pairing and sources
|
||||
## Step 8 — Verify pairing and sources
|
||||
|
||||
After the reboot reconnect via SSH and check:
|
||||
|
||||
@@ -171,32 +257,37 @@ wget -qO- http://localhost:8090/info | grep margeAccountUUID
|
||||
wget -qO- http://localhost:8090/sources
|
||||
```
|
||||
|
||||
If `margeAccountUUID` is still empty, re-run the Health QuickFix (Step 6)
|
||||
If `margeAccountUUID` is still empty, re-run the Health QuickFix (Step 7)
|
||||
and reboot again.
|
||||
|
||||
---
|
||||
|
||||
## Step 8 — Download soundtouch-cli (optional, for preset setup)
|
||||
## Step 9 — Download soundtouch-cli (optional, for preset setup)
|
||||
|
||||
If you want to program preset buttons from the command line, download the
|
||||
CLI binary to the speaker's `/tmp` (tmpfs, so it survives only until the
|
||||
next reboot — which is fine for a one-time setup run):
|
||||
CLI binary to `/mnt/nv/aftertouch` (the same persistent partition
|
||||
AfterTouch itself lives on) rather than `/tmp`: `/tmp` is tmpfs and gets
|
||||
wiped on every reboot, and if you used the CLI alternatives in Steps 6/7
|
||||
above, it needs to survive those steps' reboots too, not just the final
|
||||
one:
|
||||
|
||||
```bash
|
||||
cd /tmp
|
||||
cd /mnt/nv/aftertouch
|
||||
|
||||
curl -L --fail -o soundtouch-cli \
|
||||
https://github.com/gesellix/Bose-SoundTouch/releases/download/v0.111.3/soundtouch-cli-v0.111.3-linux-armv7
|
||||
https://github.com/gesellix/Bose-SoundTouch/releases/download/v0.123.0/soundtouch-cli-v0.123.0-linux-armv7
|
||||
chmod +x soundtouch-cli
|
||||
|
||||
/tmp/soundtouch-cli --version
|
||||
/mnt/nv/aftertouch/soundtouch-cli --version
|
||||
```
|
||||
|
||||
Replace `v0.111.3` with the version you installed.
|
||||
Replace `v0.123.0` with the version you installed. If you want the CLI
|
||||
alternatives in Steps 6/7, download it here first, before doing those
|
||||
steps — it'll be in place and already persistent either way.
|
||||
|
||||
---
|
||||
|
||||
## Step 9 — Store custom radio streams to preset buttons
|
||||
## Step 10 — Store custom radio streams to preset buttons
|
||||
|
||||
Each station must be playing before it can be saved. The `sleep 5` gives
|
||||
the speaker time to buffer and confirm the stream before storing.
|
||||
@@ -206,52 +297,52 @@ the speaker time to buffer and confirm the stream before storing.
|
||||
|
||||
```bash
|
||||
# Preset 1 — Hitradio OE3
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
--url "http://orf-live.ors-shoutcast.at/oe3-q2a" \
|
||||
--name "Hitradio OE3" \
|
||||
--service-url "http://localhost:8000"
|
||||
sleep 5
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 preset store-current --slot 1
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 preset store-current --slot 1
|
||||
|
||||
# Preset 2 — Lounge FM
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
--url "http://188.138.9.183/digital.mp3" \
|
||||
--name "Lounge FM" \
|
||||
--service-url "http://localhost:8000"
|
||||
sleep 5
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 preset store-current --slot 2
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 preset store-current --slot 2
|
||||
|
||||
# Preset 3 — Country Nonstop
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
--url "https://stream.laut.fm/country-nonstop" \
|
||||
--name "Country Nonstop" \
|
||||
--service-url "http://localhost:8000"
|
||||
sleep 5
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 preset store-current --slot 3
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 preset store-current --slot 3
|
||||
|
||||
# Preset 4 — Radio Piterpan
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
--url "https://klasse1.fluidstream.eu/piterpan.mp3?FLID=8" \
|
||||
--name "Radio Piterpan" \
|
||||
--service-url "http://localhost:8000"
|
||||
sleep 5
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 preset store-current --slot 4
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 preset store-current --slot 4
|
||||
|
||||
# Preset 5 — kronehit
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
--url "https://secureonair.krone.at/kronehit-hp.mp3" \
|
||||
--name "kronehit" \
|
||||
--service-url "http://localhost:8000"
|
||||
sleep 5
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 preset store-current --slot 5
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 preset store-current --slot 5
|
||||
|
||||
# Preset 6 — Radio Niederösterreich
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
--url "http://orf-live.ors-shoutcast.at/noe-q2a" \
|
||||
--name "Radio Niederoesterreich" \
|
||||
--service-url "http://localhost:8000"
|
||||
sleep 5
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 preset store-current --slot 6
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 preset store-current --slot 6
|
||||
```
|
||||
|
||||
These are the stations from weissigera's setup (Austrian public and
|
||||
@@ -260,7 +351,7 @@ pattern is the same regardless of station.
|
||||
|
||||
---
|
||||
|
||||
## Step 10 — Verify presets and final reboot
|
||||
## Step 11 — Verify presets and final reboot
|
||||
|
||||
```bash
|
||||
wget -qO- http://localhost:8090/presets
|
||||
@@ -286,7 +377,7 @@ should start playing the corresponding stream.
|
||||
| SSH "no matching host key type" | Add `-oHostKeyAlgorithms=+ssh-rsa` |
|
||||
| Port 8000 not reachable from LAN | Use the SSH tunnel (Step 5) |
|
||||
| `margeAccountUUID` still empty after reboot | Re-run Health QuickFix, reboot again |
|
||||
| Radio source error 1005 | `margeAccountUUID` is empty — complete Step 6 first |
|
||||
| Radio source error 1005 | `margeAccountUUID` is empty — complete Step 7 first |
|
||||
| `http://localhost:8000` not responding after install | `logread \| grep aftertouch \| tail -20` |
|
||||
| No space left on device during install | Run the cleanup in Step 2; check `df -h /mnt/nv` |
|
||||
|
||||
@@ -305,14 +396,21 @@ older artefacts to keep `/mnt/nv` free:
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
|
||||
|
||||
# Update to a specific version — three equivalent forms
|
||||
VERSION=0.111.3 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | VERSION=0.123.0 sh
|
||||
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.111.3
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.123.0
|
||||
|
||||
curl -sSLo install.sh https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh
|
||||
sh install.sh --version 0.111.3
|
||||
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
|
||||
@@ -322,6 +420,39 @@ cp /mnt/nv/aftertouch/aftertouch-service.<old-version>.backup \
|
||||
/etc/init.d/aftertouch restart
|
||||
```
|
||||
|
||||
**Testing a pre-release build (from `main`, not yet tagged):** `install.sh`
|
||||
only ever downloads from GitHub Releases, so there's no one-line installer
|
||||
for an unreleased commit. Cross-compile and swap the binary manually
|
||||
instead — this is a direct extension of the rollback procedure above:
|
||||
|
||||
```bash
|
||||
# On your own machine, from a checkout of the branch/commit you want:
|
||||
make build-linux-armv7 # builds build/soundtouch-service-linux-armv7,
|
||||
# build/soundtouch-cli-linux-armv7, and
|
||||
# build/soundtouch-backup-linux-armv7
|
||||
|
||||
scp build/soundtouch-service-linux-armv7 root@192.0.2.1:/mnt/nv/aftertouch/aftertouch-service.new
|
||||
ssh -oHostKeyAlgorithms=+ssh-rsa root@192.0.2.1
|
||||
|
||||
rw
|
||||
/etc/init.d/aftertouch stop
|
||||
cp /mnt/nv/aftertouch/aftertouch-service /mnt/nv/aftertouch/aftertouch-service.pre-test.backup
|
||||
mv /mnt/nv/aftertouch/aftertouch-service.new /mnt/nv/aftertouch/aftertouch-service
|
||||
chmod +x /mnt/nv/aftertouch/aftertouch-service
|
||||
/etc/init.d/aftertouch start
|
||||
```
|
||||
|
||||
If you're testing an unreleased `soundtouch-cli` change (not just the
|
||||
service), swap that binary too — same idea, and it lands in the same
|
||||
`/mnt/nv/aftertouch` directory Step 9 above uses:
|
||||
|
||||
```bash
|
||||
scp build/soundtouch-cli-linux-armv7 root@192.0.2.1:/mnt/nv/aftertouch/soundtouch-cli
|
||||
ssh -oHostKeyAlgorithms=+ssh-rsa root@192.0.2.1 chmod +x /mnt/nv/aftertouch/soundtouch-cli
|
||||
```
|
||||
|
||||
Roll back the same way as above, using the `.pre-test.backup` file.
|
||||
|
||||
---
|
||||
|
||||
## Service management
|
||||
|
||||
@@ -40,14 +40,14 @@ sudo bash install.sh
|
||||
Install a specific version:
|
||||
|
||||
```bash
|
||||
sudo bash install.sh v0.111.3
|
||||
sudo bash install.sh v0.123.0
|
||||
```
|
||||
|
||||
Override defaults at install time:
|
||||
|
||||
```bash
|
||||
sudo \
|
||||
VERSION=v0.111.3 \
|
||||
VERSION=v0.123.0 \
|
||||
HOSTNAME_FQDN=soundtouch.local \
|
||||
HTTP_PORT=80 \
|
||||
HTTPS_PORT=443 \
|
||||
@@ -105,7 +105,7 @@ journalctl -u soundtouch-service -b # this boot only
|
||||
|
||||
```bash
|
||||
sudo bash install.sh # update to latest release
|
||||
sudo bash install.sh v0.111.3 # update to a specific version
|
||||
sudo bash install.sh v0.123.0 # update to a specific version
|
||||
```
|
||||
|
||||
The script stops the service, downloads the new binary (backs up the old one to
|
||||
@@ -157,14 +157,14 @@ sudo bash install-player.sh
|
||||
Install a specific version:
|
||||
|
||||
```bash
|
||||
sudo bash install-player.sh v0.111.3
|
||||
sudo bash install-player.sh v0.123.0
|
||||
```
|
||||
|
||||
Override defaults at install time:
|
||||
|
||||
```bash
|
||||
sudo \
|
||||
VERSION=v0.111.3 \
|
||||
VERSION=v0.123.0 \
|
||||
HTTP_PORT=8081 \
|
||||
bash install-player.sh
|
||||
```
|
||||
@@ -252,7 +252,7 @@ journalctl -u soundtouch-player -f
|
||||
|
||||
```bash
|
||||
sudo bash install-player.sh # update to latest release
|
||||
sudo bash install-player.sh v0.111.3 # update to a specific version
|
||||
sudo bash install-player.sh v0.123.0 # update to a specific version
|
||||
```
|
||||
|
||||
### Removal
|
||||
|
||||
@@ -156,30 +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` |
|
||||
| `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` |
|
||||
| `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
|
||||
|
||||
|
||||
@@ -456,6 +456,63 @@ client.SelectAux()
|
||||
|
||||
---
|
||||
|
||||
## 🎛️ **Lifestyle / Console Device Behavior** {#lifestyle-console-devices}
|
||||
|
||||
### ❌ "Console-style device (Lifestyle, CineMate) plays the first test station but every later one reports INVALID_SOURCE"
|
||||
|
||||
On a Bose Lifestyle or CineMate console, the SoundTouch module is one input
|
||||
among several (TV, AUX, Bluetooth, ...). As already established in #160,
|
||||
the console's active input cannot be switched from the SoundTouch side —
|
||||
there is no API call that forces it back onto SoundTouch.
|
||||
|
||||
**Symptoms:**
|
||||
- `/now_playing` reports `source="LOCAL"` with an empty `ContentItem`:
|
||||
```xml
|
||||
<nowPlaying deviceID="..." source="LOCAL">
|
||||
<ContentItem source="LOCAL" isPresetable="true" />
|
||||
</nowPlaying>
|
||||
```
|
||||
- `LOCAL` does not appear in `/sources` at all.
|
||||
- `POST /select` and `POST /key` (e.g. `PRESET_1`) are accepted
|
||||
(`<status>/select</status>`) but have no observable effect.
|
||||
|
||||
This means the console is sitting on its own (non-SoundTouch) input, not
|
||||
that the content/station itself is invalid. The input has to be selected
|
||||
on the console's own remote or front panel; there is no way to do it via
|
||||
the SoundTouch API.
|
||||
|
||||
**The trap:** `POST /key POWER` does not behave like it does on a plain
|
||||
speaker. On a speaker, `POWER` is a harmless way to stop playback between
|
||||
test runs. On a console, it puts the whole unit into standby — and on
|
||||
waking, the console returns to **its own** input, not back to SoundTouch.
|
||||
A test loop that stops playback with `POWER` between trials silently
|
||||
switches the device off SoundTouch after the *first* trial, so every
|
||||
station from the second one onward reports `INVALID_SOURCE` — including
|
||||
stations that would otherwise play perfectly fine. This is easy to
|
||||
misread as a per-station problem (e.g. "this console can't handle TLS/
|
||||
https streams") when it is actually a test-methodology artifact: whichever
|
||||
station happens to run first in the loop is the only one actually tested
|
||||
against SoundTouch input.
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. Before testing anything, select the SoundTouch input on the console
|
||||
itself (remote or front panel), not via the API.
|
||||
2. Do not use `POST /key POWER` to stop playback between trials on these
|
||||
devices. If you need to interrupt playback, use a different key
|
||||
(e.g. `PAUSE`/`STOP`) or simply move directly to selecting the next
|
||||
station.
|
||||
3. If `/now_playing` shows `source="LOCAL"` with `LOCAL` absent from
|
||||
`/sources`, treat that as "console is on a different input" — re-select
|
||||
SoundTouch on the console and retest before concluding anything about
|
||||
the station or migration itself.
|
||||
|
||||
See #597 for the original report, including a packet capture confirming a
|
||||
station that appeared to fail actually completed a full TLS handshake and
|
||||
streamed normally once the console was back on the SoundTouch input.
|
||||
|
||||
---
|
||||
|
||||
## 🎶 **Music Service & Preset Issues**
|
||||
|
||||
### ❌ Spotify preset fails with "Current content cannot be saved as preset"
|
||||
@@ -537,6 +594,53 @@ 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:**
|
||||
|
||||
- You update **Settings → Target Domain / Server URL** (via the Admin UI, `SERVER_URL`, or `--deployment-mode`), and the Admin UI confirms the new value with no warning.
|
||||
- An already-migrated speaker's own behavior is unchanged: playback/BMX requests still go to the *old* address, and `soundtouch-cli setup inspect --telnet` still shows the old `margeServerUrl`/`statsServerUrl`/`bmxRegistryUrl`/`swUpdateUrl`.
|
||||
|
||||
**Cause:** Settings only updates the *service's own* record of its address (`s.serverURL`, persisted to `settings.json`) — the save handler never contacts any device. A speaker only learns a new address at migrate time: the telnet method writes it via `sys configuration ...` plus a closing `envswitch boseurls set ...` for the reboot-persisted layer; the XML/SSH method uploads a fresh `SoundTouchSdkPrivateCfg.xml`. Both write **once**, with no mechanism for a speaker to later re-fetch its own config from the service — this is equally true for either migration method. A "Sync" or `sourcesUpdated` notification only refreshes the speaker's source *list*, not its server URL configuration.
|
||||
|
||||
**Fix:** Any Target Domain change that needs to reach an already-migrated speaker requires a fresh Migrate afterward — Settings alone is never enough for a speaker that's been migrated before:
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <speaker-ip> setup migrate --method telnet --service-url <new-target-domain>
|
||||
```
|
||||
|
||||
Confirm it took:
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <speaker-ip> setup inspect --telnet
|
||||
```
|
||||
|
||||
`margeServerUrl`/`statsServerUrl`/`bmxRegistryUrl`/`swUpdateUrl` should all match the new value. Repeat per speaker — Settings is one service-wide value, but each speaker keeps its own independently-migrated copy, so a multi-speaker household needs a re-migrate for each one.
|
||||
|
||||
This also applies to a freshly-fixed on-device default (see `DEPLOYMENT_MODE`, #546): the installer now gets the *default* right for new installs automatically, but an install that was already migrated before you updated still needs the explicit re-migrate above — the fix only stops a *new* bad value from being written, it doesn't retroactively correct an already-migrated speaker.
|
||||
|
||||
### ❌ Radio sources never activate after an in-place migration {#radio-sources-after-migration}
|
||||
|
||||
**Symptoms:**
|
||||
@@ -571,12 +675,92 @@ 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.
|
||||
|
||||
After this the radio sources activate normally. Note the factory reset rewrites the speaker's `Sources.xml` to defaults, so any **account-bound** source (for example a music-streaming login) has to be re-added afterwards; your presets for it come back once the source is present again.
|
||||
|
||||
### ❌ `setup enable-ssh` (or a telnet command) fails right after a power-cycle, but works if you wait
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- You power-cycled the speaker — as our own retry guidance suggests after a `setup enable-ssh` timeout — and immediately re-ran the command (or a telnet migration/pairing step).
|
||||
- You get `telnet dial <ip>:17000: connection refused` or the command otherwise fails as if the port were closed.
|
||||
- Running the exact same command again a minute or two later works fine, on the same device.
|
||||
|
||||
**Cause:**
|
||||
|
||||
Confirmed on hardware across five device variants (2026-08-09): different ports on the same speaker become ready at very different times after a cold boot. HTTP `:8090` typically answers first, but the diagnostic telnet shell on `:17000` — and the config subsystem behind it that `getpdo` reads — takes longer: 55–92 seconds observed, median ~70s. "The box answers on one port" is a weaker signal than "the box can answer on the specific port you need." See [TELNET-COMMAND-REFERENCE.md](../analysis/TELNET-COMMAND-REFERENCE.md) for the underlying mechanism.
|
||||
|
||||
**Fix:** After a power-cycle, wait at least 90 seconds before retrying any telnet-based command. If it still fails after that, wait a full 2 minutes before assuming the port is genuinely closed on that firmware rather than just slow to come up.
|
||||
|
||||
### ❌ Speaker gets slower/less responsive over time after `setup enable-ssh` with no `--service-url`
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- You ran `soundtouch-cli setup enable-ssh` without `--service-url` (or via the Admin UI's equivalent) to bootstrap SSH, and never followed up with a real `setup migrate`.
|
||||
- Over time (hours to days), the speaker becomes progressively less responsive — slow to answer `:8090`, SSH connections time out, the Admin UI shows it as flaky or offline.
|
||||
|
||||
**Cause:**
|
||||
|
||||
`enable-ssh` without `--service-url` writes a deliberately-invalid placeholder (`https://aftertouch.invalid`) into `margeServerUrl`/`swUpdateUrl`/etc — by design, since the SSH-enable injection only needs *a* URL to round-trip through, not a working one. But unless you run `setup migrate` (or the Admin UI's Migrate step) afterward, that placeholder **stays persisted** — the command's own success message says so explicitly. The firmware then retries a failing DNS/curl lookup against it on a background loop (same class of failure as the `mojo`/`taigan` unresolvable-hostname case, #546) — an ongoing resource drain that isn't dramatic on its own, but confirmed on real hardware (2026-08-16) to compound badly if anything else (e.g. a burst of SSH connections — see the `setup revert` entry below) puts the speaker under load at the same time.
|
||||
|
||||
**Fix:** Always follow `enable-ssh` (when run without `--service-url`) with a real `setup migrate` before walking away. If you're recovering a speaker that's already stuck like this: power-cycle it, confirm it's reachable (`ping`, `curl :8090/info`, a single plain `ssh ... echo ok`) before doing anything else, then run `setup migrate` with the real URLs. If you want to point it back at the **original Bose cloud** URLs instead of AfterTouch (e.g. to fully decommission it), use the per-field overrides on `--method=telnet` — see the `setup migrate` section of [CLI-REFERENCE.md](CLI-REFERENCE.md) — which writes over a single telnet connection, no SSH required:
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <SPEAKER-IP> setup migrate --method telnet \
|
||||
--service-url https://streaming.bose.com \
|
||||
--marge-url https://streaming.bose.com \
|
||||
--stats-url https://events.api.bosecm.com \
|
||||
--sw-update-url https://worldwide.bose.com/updates/soundtouch \
|
||||
--bmx-url https://content.api.bose.io/bmx/registry/v1/services
|
||||
```
|
||||
|
||||
### ❌ `setup revert` (or the Admin UI's "Revert to Defaults") fails with "backup .original not found" even though the file exists
|
||||
|
||||
**Status: fixed** (branch `docs-ondevice-install-gaps`, not yet in a numbered release as of this writing) — kept below for anyone hitting this on an older build, and because the underlying "don't hammer a struggling speaker" advice is still good practice generally.
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- You confirm via a separate SSH session that `/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml.original` genuinely exists.
|
||||
- `setup revert` (or clicking "Revert to Defaults") still reports `backup .../SoundTouchSdkPrivateCfg.xml.original not found, cannot revert`.
|
||||
- A follow-up plain SSH command to the same speaker fails with `Operation timed out` at the TCP level — not an auth or shell error.
|
||||
|
||||
**Cause:** `RevertMigration`'s full call graph opened **17 separate SSH connections** in rapid succession (`pkg/ssh.Client.Run()` dialed fresh every call, with no connection reuse across `revertXMLConfig`/`revertHosts`/`revertResolvConf`/`revertAftertouchHook`/`removeRcLocalHooks`/`revertCACert`). Hitting a resource-constrained embedded speaker with that many rapid reconnects could overwhelm it — confirmed on real hardware (2026-08-16), where the speaker became unreachable shortly after. On top of that, `revertXMLConfig`'s error handling collapses *any* non-nil error from its file-existence check into "not found," so a dial failure got misreported as a missing backup — the message didn't mean what it said.
|
||||
|
||||
**Fix:** `pkg/ssh.Client` now supports an opt-in persistent connection (`Connect()`/`Close()`) that `RevertMigration` uses to collapse those 17 connections into 1 — confirmed on the same real hardware (2026-08-16): a subsequent `setup revert` completed quickly, and the restored config file diffed byte-identical against `.original`. If you're on a build that predates this fix, don't retry `setup revert` back-to-back — if it fails, wait a minute and confirm the speaker is reachable again (`ping`, a single plain `ssh ... echo ok`) before retrying. If all you actually need is to point the speaker's URLs somewhere else (back to AfterTouch, or back to the original Bose cloud), the lighter-weight `setup migrate --method telnet` with explicit URL overrides (previous entry) uses one telnet connection instead of SSH entirely.
|
||||
|
||||
### ❌ On-device install: AfterTouch answers on the speaker but not from other machines on the LAN
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- On the speaker itself, `curl http://localhost:8000/health` works and `/etc/init.d/aftertouch status` is green.
|
||||
- From any other machine, `http://<speaker-ip>:8000` fails immediately (connection refused/reset, not a timeout).
|
||||
- SSH to the same speaker works fine, so it is clearly reachable in general.
|
||||
|
||||
**Cause:**
|
||||
|
||||
Some SoundTouch chassis carry a BCO ("SMSC") Wi-Fi/Bluetooth co-processor, and inbound LAN traffic reaches the main Linux SoC only for a fixed set of Bose's *own* service ports, a list that appears to be compiled into the co-processor's firmware. AfterTouch's `:8000` was never part of that original design, so the connection never arrives at the SoC at all. Confirmed on an ST20 (`spotty`, FW 27.0.6) in 2026-08: `tcpdump -i eth0` on the speaker saw **zero packets** for `:8000` while Bose's `:8090`/`:8091`/`:17000` answered normally from the same client. This is not a firewall (the speaker's `iptables` is empty) and not a binding problem (the service does listen on `0.0.0.0:8000`).
|
||||
|
||||
**Fix:**
|
||||
|
||||
The on-device installer handles this automatically: on an affected speaker it redirects a relayed Bose port to AfterTouch, so use:
|
||||
|
||||
```
|
||||
http://<speaker-ip>:17008
|
||||
```
|
||||
|
||||
To check or change it, on the speaker:
|
||||
|
||||
```bash
|
||||
/etc/init.d/aftertouch status # reports the LAN port when active
|
||||
iptables -t nat -S PREROUTING # shows the redirect rule
|
||||
```
|
||||
|
||||
Set `AFTERTOUCH_LAN_PORT` in `/opt/aftertouch/aftertouch.conf` to a different port, or to `none` to disable the redirect and use an SSH tunnel instead; then `/etc/init.d/aftertouch restart`. Note that **linking music-service accounts still works best through the tunnel** (`http://localhost:8000`), because Spotify only accepts `https://` or loopback OAuth redirect URIs. If you also run the `streborn` project on the same speaker, note it defaults to the same port, so change one of them. Which models are affected is tracked in [MODEL-SUPPORT-MATRIX.md](../reference/MODEL-SUPPORT-MATRIX.md).
|
||||
|
||||
## 🔊 **Volume & Audio Issues**
|
||||
|
||||
### ❌ "Volume control not working"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
title: "Model Support Matrix"
|
||||
---
|
||||
A living record of how individual SoundTouch models behave with AfterTouch,
|
||||
built up from things actually observed on hardware.
|
||||
|
||||
**This table only claims what someone has verified.** Anything not tested is
|
||||
marked `?` rather than inferred from a similar-looking model. Bose used
|
||||
several different chassis designs across the SoundTouch line, and at least
|
||||
one behaviour (LAN reachability, below) differs between them in a way that is
|
||||
invisible from the outside. If you have a model that isn't filled in yet,
|
||||
[the commands below](#how-to-fill-in-a-row) produce everything a row needs.
|
||||
|
||||
## What the columns mean
|
||||
|
||||
- **variant / moduleType**: the speaker's own identifiers, straight out of
|
||||
`/info`. `variant` is Bose's internal codename for the product; `moduleType`
|
||||
distinguishes chassis generations (`scm` and `sm2` are the two seen so far).
|
||||
- **BCO**: whether the board carries a BCO co-processor (Bose's internal name
|
||||
for the SMSC Wi-Fi/Bluetooth combo chip that also handles AirPlay). Bose's
|
||||
own `has-bco` helper on the device is simply
|
||||
`[ "$(cat /proc/module_type)" = scm ]`.
|
||||
- **`:8000` from LAN**: whether AfterTouch's own port is reachable from
|
||||
another machine on the network *without* any workaround.
|
||||
- **Entry port**: when `:8000` isn't reachable, the port AfterTouch redirects
|
||||
to itself so the admin UI still works. See
|
||||
[LAN access on co-processor chassis](#lan-access-on-co-processor-chassis).
|
||||
|
||||
## Matrix
|
||||
|
||||
| Model | variant | moduleType | BCO | On-device install | `:8000` from LAN | Entry port | Evidence |
|
||||
|---------------------|----------|------------|-----|-------------------|------------------|------------|-----------------------------------------------------------------------|
|
||||
| SoundTouch 20 | `spotty` | `scm` | yes | works | ✗ blocked | `17008` | verified on hardware 2026-08-16 (FW 27.0.6), redirect survives reboot |
|
||||
| SoundTouch 10 | ? | ? | ? | reported working | ? | ? | not tested for LAN reachability |
|
||||
| SoundTouch 30 | ? | ? | ? | reported working | ? | ? | not tested for LAN reachability |
|
||||
| SoundTouch Portable | ? | ? | ? | ? | ? | ? | not tested |
|
||||
| Wave / SA-4 | ? | ? | ? | ? | ? | ? | not tested |
|
||||
|
||||
Not every SoundTouch shares one firmware image, so treat a `?` as genuinely
|
||||
unknown. In particular, do not assume a model is unaffected just because it is
|
||||
newer or older than a model that is.
|
||||
|
||||
## LAN access on co-processor chassis
|
||||
|
||||
On chassis with a BCO co-processor, inbound LAN traffic reaches the speaker's
|
||||
main Linux SoC only for a fixed set of Bose's *own* service ports. That list
|
||||
appears to be compiled into the co-processor's firmware, and AfterTouch's
|
||||
`:8000` is not on it, so a connection attempt never arrives at the SoC at
|
||||
all. On a verified ST20, `tcpdump -i eth0` on the speaker recorded **zero
|
||||
packets** for `:8000` while Bose's `:8090`, `:8091`, `:8200`, `:82`, `:8080`
|
||||
and `:17000` all answered normally from the same client.
|
||||
|
||||
This is not a firewall, and not something AfterTouch can fix by binding
|
||||
differently: the service already listens on `0.0.0.0:8000`, and the speaker's
|
||||
`iptables` is empty (there is no `nft` or `ebtables` at all).
|
||||
|
||||
The on-device installer works around it by redirecting one of the relayed
|
||||
ports to AfterTouch. **Credit for this technique goes to the
|
||||
[STR / SoundTouch Reborn](https://github.com/JRpersonal/streborn) project**,
|
||||
which documented and shipped it first (their agent uses the same entry port
|
||||
for the same reason); finding their prior art is what turned this from an
|
||||
apparent hardware dead end into a one-line fix:
|
||||
|
||||
```
|
||||
iptables -t nat -I PREROUTING 1 ! -i lo -p tcp --dport 17008 -j REDIRECT --to-ports 8000
|
||||
```
|
||||
|
||||
`17008` is Bose's `SoftwareUpdate` listener. Its cloud service no longer
|
||||
exists, so taking over its inbound traffic costs nothing in practice. Only
|
||||
external traffic is matched (`! -i lo`), so anything running on the speaker
|
||||
still reaches AfterTouch on `:8000` exactly as before.
|
||||
|
||||
The rule is re-applied by the init script on every start, so it survives
|
||||
reboots (confirmed on the ST20) without any background watchdog. It is
|
||||
removed again on `stop` and on uninstall.
|
||||
|
||||
The redirect is applied automatically on chassis that need it, and configured
|
||||
via `AFTERTOUCH_LAN_PORT` in `/opt/aftertouch/aftertouch.conf`:
|
||||
|
||||
| Value | Effect |
|
||||
|------------|-----------------------------------------------------------------|
|
||||
| `auto` | *(default)* redirect only where the co-processor blocks `:8000` |
|
||||
| `none` | never redirect; use an SSH tunnel instead |
|
||||
| *(a port)* | always redirect that inbound port to AfterTouch |
|
||||
|
||||
Two caveats worth knowing:
|
||||
|
||||
- **Account linking still prefers the SSH tunnel.** Spotify only accepts
|
||||
`https://` or *loopback* OAuth redirect URIs, so `http://localhost:8000`
|
||||
through a tunnel works for linking where a plain LAN address does not.
|
||||
- **The `streborn` project defaults to the same port** for the same reason. If
|
||||
you run both on one speaker, change `AFTERTOUCH_LAN_PORT`.
|
||||
|
||||
## How to fill in a row
|
||||
|
||||
Run these from a machine on the same network (replace the address), then open
|
||||
an issue or PR with the output:
|
||||
|
||||
```bash
|
||||
# variant, moduleType, and whether an SCM/SMSC component is listed
|
||||
curl -s http://<speaker-ip>:8090/info
|
||||
|
||||
# is AfterTouch's own port reachable directly? (only meaningful once
|
||||
# AfterTouch is installed on the device)
|
||||
curl -v --max-time 5 http://<speaker-ip>:8000/health
|
||||
|
||||
# which Bose ports the chassis relays at all
|
||||
for p in 82 8080 8090 8091 8200 17000 17008; do
|
||||
printf '%s: ' "$p"
|
||||
curl -s -o /dev/null -w '%{http_code}\n' --max-time 3 "http://<speaker-ip>:$p/" || echo unreachable
|
||||
done
|
||||
```
|
||||
|
||||
And on the speaker itself, if you have SSH access:
|
||||
|
||||
```bash
|
||||
has-bco; echo "has-bco exit status: $?" # 0 = BCO co-processor present
|
||||
cat /proc/module_type /proc/variant
|
||||
```
|
||||
@@ -1,8 +1,8 @@
|
||||
module navigation-station-demo
|
||||
|
||||
go 1.26.5
|
||||
go 1.27.0
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.118.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.5
|
||||
go 1.27.0
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.118.0
|
||||
require github.com/gesellix/bose-soundtouch v0.128.0
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
module github.com/gesellix/bose-soundtouch
|
||||
|
||||
go 1.26.5
|
||||
go 1.27.0
|
||||
|
||||
require (
|
||||
filippo.io/age v1.3.1
|
||||
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.54.0
|
||||
golang.org/x/net v0.57.0
|
||||
golang.org/x/crypto v0.55.0
|
||||
golang.org/x/mod v0.40.0
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/term v0.45.0
|
||||
)
|
||||
|
||||
@@ -31,10 +32,7 @@ require (
|
||||
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.44.0 // indirect
|
||||
golang.org/x/mod v0.38.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/image v0.45.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/tools v0.48.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
)
|
||||
|
||||
@@ -17,8 +17,8 @@ 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-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-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg=
|
||||
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
@@ -27,8 +27,6 @@ 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.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
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/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.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
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=
|
||||
@@ -86,11 +84,9 @@ golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
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"
|
||||
}
|
||||
|
||||
+12
-4
@@ -1428,10 +1428,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
|
||||
|
||||
|
||||
@@ -59,8 +59,9 @@ func TestClient_Post_ErrorsResponse(t *testing.T) {
|
||||
t.Errorf("expected message '%s', got '%s'", expectedMsg, errs.Errors[0].Message)
|
||||
}
|
||||
|
||||
if err.Error() != expectedMsg {
|
||||
t.Errorf("expected Error() to return '%s', got '%s'", expectedMsg, err.Error())
|
||||
expectedErr := "UNKNOWN_ACTION_ERROR: " + expectedMsg
|
||||
if err.Error() != expectedErr {
|
||||
t.Errorf("expected Error() to return '%s', got '%s'", expectedErr, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+24
-1
@@ -2,6 +2,8 @@ package models
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -80,7 +82,7 @@ type ErrorsResponse struct {
|
||||
// Error implements the error interface for ErrorsResponse
|
||||
func (e *ErrorsResponse) Error() string {
|
||||
if len(e.Errors) > 0 {
|
||||
return e.Errors[0].Message
|
||||
return e.Errors[0].Error()
|
||||
}
|
||||
|
||||
return "unknown API error"
|
||||
@@ -93,6 +95,27 @@ type DeviceError struct {
|
||||
Message string `xml:",chardata"`
|
||||
}
|
||||
|
||||
// Error implements the error interface for DeviceError. Some speakers
|
||||
// return a Message that just restates Value as text (e.g. a bare "1047"
|
||||
// for an error the firmware has no localized string for) — Name is the
|
||||
// only informative part in that case, so it's always included unless
|
||||
// Message already carries it.
|
||||
func (e DeviceError) Error() string {
|
||||
if e.Name == "" {
|
||||
if e.Message == "" {
|
||||
return fmt.Sprintf("device error %d", e.Value)
|
||||
}
|
||||
|
||||
return e.Message
|
||||
}
|
||||
|
||||
if e.Message == "" || e.Message == e.Name || e.Message == strconv.Itoa(e.Value) {
|
||||
return fmt.Sprintf("%s (%d)", e.Name, e.Value)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s: %s", e.Name, e.Message)
|
||||
}
|
||||
|
||||
// DiscoveredDevice represents a device found through network discovery
|
||||
type DiscoveredDevice struct {
|
||||
Name string `json:"name"`
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDeviceError_Error(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err DeviceError
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "message repeats the numeric value (real speaker case)",
|
||||
err: DeviceError{Value: 1047, Name: "SOURCE_ALREADY_REMOVED", Message: "1047"},
|
||||
expected: "SOURCE_ALREADY_REMOVED (1047)",
|
||||
},
|
||||
{
|
||||
name: "message is empty",
|
||||
err: DeviceError{Value: 1047, Name: "SOURCE_ALREADY_REMOVED", Message: ""},
|
||||
expected: "SOURCE_ALREADY_REMOVED (1047)",
|
||||
},
|
||||
{
|
||||
name: "message is meaningful and distinct from name",
|
||||
err: DeviceError{Value: 1029, Name: "UNKNOWN_ACTION_ERROR", Message: "This version of SCM does not support spotify create account functionality."},
|
||||
expected: "UNKNOWN_ACTION_ERROR: This version of SCM does not support spotify create account functionality.",
|
||||
},
|
||||
{
|
||||
name: "name is empty, message carries the detail",
|
||||
err: DeviceError{Value: 500, Name: "", Message: "internal error"},
|
||||
expected: "internal error",
|
||||
},
|
||||
{
|
||||
name: "both name and message are empty",
|
||||
err: DeviceError{Value: 500, Name: "", Message: ""},
|
||||
expected: "device error 500",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.err.Error(); got != tt.expected {
|
||||
t.Errorf("expected %q, got %q", tt.expected, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorsResponse_Error(t *testing.T) {
|
||||
t.Run("delegates to the first DeviceError", func(t *testing.T) {
|
||||
errs := &ErrorsResponse{
|
||||
Errors: []DeviceError{
|
||||
{Value: 1047, Name: "SOURCE_ALREADY_REMOVED", Message: "1047"},
|
||||
},
|
||||
}
|
||||
|
||||
expected := "SOURCE_ALREADY_REMOVED (1047)"
|
||||
if got := errs.Error(); got != expected {
|
||||
t.Errorf("expected %q, got %q", expected, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no errors", func(t *testing.T) {
|
||||
errs := &ErrorsResponse{}
|
||||
|
||||
expected := "unknown API error"
|
||||
if got := errs.Error(); got != expected {
|
||||
t.Errorf("expected %q, got %q", expected, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
+41
-1
@@ -1,6 +1,9 @@
|
||||
package models
|
||||
|
||||
import "encoding/xml"
|
||||
import (
|
||||
"encoding/xml"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Group represents a stereo pair of two ST10 SoundTouch speakers.
|
||||
type Group struct {
|
||||
@@ -32,3 +35,40 @@ type GroupRole struct {
|
||||
Role string `xml:"role"`
|
||||
IPAddress string `xml:"ipAddress,omitempty"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
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
|
||||
}
|
||||
|
||||
+30
-26
@@ -211,37 +211,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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,78 @@ package bmx
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 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
|
||||
|
||||
@@ -33,11 +33,30 @@ func exists(path string) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// isSafeIdentifier returns true if the given identifier is safe to use
|
||||
// as a single path component (for account IDs, device IDs, etc.).
|
||||
// It rejects empty strings, path separators, and parent directory references.
|
||||
func isSafeIdentifier(id string) bool {
|
||||
if id == "" {
|
||||
// maxSafeIdentifierLength bounds account/device IDs accepted from a
|
||||
// speaker or third-party pairing tool. Well under typical filesystem
|
||||
// path-component limits (255 bytes); generous for any realistic
|
||||
// margeAccountUUID or MAC-derived device ID.
|
||||
const maxSafeIdentifierLength = 128
|
||||
|
||||
// IsSafeIdentifier returns true if the given identifier is safe to use
|
||||
// as a single path component (for account IDs, device IDs, etc.), and
|
||||
// safe to embed in the other places these values end up: XML sent to a
|
||||
// speaker, log lines, and datastore-key comparisons. It rejects empty
|
||||
// or overlong strings, path separators, and parent directory
|
||||
// references.
|
||||
//
|
||||
// The allowed character set intentionally excludes XML/HTML-special
|
||||
// characters (`< > & " '`), whitespace, and shell/URL metacharacters
|
||||
// (see #634's `postSetMargeAccount`, which interpolates an account ID
|
||||
// into an XML body, and `PairAccount`, which interpolates one into a
|
||||
// literal `envswitch accountid set <id>` telnet command line) even
|
||||
// though it accepts more than Bose's own 7-digit account format —
|
||||
// devices paired via third-party or manual tooling (e.g. the
|
||||
// USB-stick SSH-enable method) can report arbitrary margeAccountUUID
|
||||
// values such as "stick@local".
|
||||
func IsSafeIdentifier(id string) bool {
|
||||
if id == "" || len(id) > maxSafeIdentifierLength {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -46,14 +65,17 @@ func isSafeIdentifier(id string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Allow a conservative set of characters commonly found in IDs:
|
||||
// letters, digits, underscore, dash, dot, and colon (for MAC-like IDs).
|
||||
// Letters, digits, and a conservative set of punctuation seen in
|
||||
// real-world IDs: underscore, dash, dot, colon (MAC-like IDs), and
|
||||
// '@' (e.g. "stick@local"). Everything else — including all XML,
|
||||
// HTML, shell, and URL metacharacters, whitespace, and control
|
||||
// characters — is rejected.
|
||||
for i := 0; i < len(id); i++ {
|
||||
c := id[i]
|
||||
if (c >= 'a' && c <= 'z') ||
|
||||
(c >= 'A' && c <= 'Z') ||
|
||||
(c >= '0' && c <= '9') ||
|
||||
c == '_' || c == '-' || c == '.' || c == ':' {
|
||||
c == '_' || c == '-' || c == '.' || c == ':' || c == '@' {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -928,7 +950,10 @@ func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo
|
||||
|
||||
// GetPresets retrieves all presets for the specified account and device.
|
||||
func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset, error) {
|
||||
presets, needsRewrite, err := ds.readPresetsLocked(account, device)
|
||||
ds.fileMutex.RLock()
|
||||
presets, needsRewrite, err := ds.readPresetsNoLock(account, device)
|
||||
ds.fileMutex.RUnlock()
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -944,18 +969,48 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
|
||||
return presets, nil
|
||||
}
|
||||
|
||||
// readPresetsLocked is the locked read half of GetPresets. It returns the
|
||||
// parsed presets and a flag indicating whether the on-disk file used the
|
||||
// legacy <ContentItem> (capital C) format that needs rewriting.
|
||||
func (ds *DataStore) readPresetsLocked(account, device string) ([]models.ServicePreset, bool, error) {
|
||||
ds.fileMutex.RLock()
|
||||
defer ds.fileMutex.RUnlock()
|
||||
// MutatePresets atomically reads the current preset list, transforms it via
|
||||
// mutate, and persists the result — holding a single write lock for the
|
||||
// entire read-mutate-write cycle. Calling GetPresets followed by a separate
|
||||
// SavePresets leaves a lost-update window open: two concurrent callers can
|
||||
// each read the same starting list, mutate different entries, and the
|
||||
// second writer's SavePresets silently clobbers the first writer's update.
|
||||
// That's the exact interleave that dropped a preset during #614's rapid-fire
|
||||
// repro (overlapping PUT .../preset/N requests). Callers that read-then-write
|
||||
// a single device's presets should use this instead of GetPresets+SavePresets.
|
||||
func (ds *DataStore) MutatePresets(account, device string, mutate func(current []models.ServicePreset) ([]models.ServicePreset, error)) ([]models.ServicePreset, error) {
|
||||
ds.fileMutex.Lock()
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
current, _, err := ds.readPresetsNoLock(account, device)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
next, err := mutate(current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := ds.savePresetsNoLock(account, device, next); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// readPresetsNoLock is the lock-free read half of GetPresets/MutatePresets.
|
||||
// Callers must already hold ds.fileMutex (for reading or writing). It
|
||||
// returns the parsed presets and a flag indicating whether the on-disk file
|
||||
// used the legacy <ContentItem> (capital C) format that needs rewriting.
|
||||
func (ds *DataStore) readPresetsNoLock(account, device string) ([]models.ServicePreset, bool, error) {
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile)
|
||||
|
||||
data, err := ds.rootReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
log.Printf("[Datastore] readPresetsNoLock: no Presets.xml at %s — reporting no presets", sanitizeLog(path))
|
||||
|
||||
return []models.ServicePreset{}, false, nil
|
||||
}
|
||||
|
||||
@@ -967,7 +1022,7 @@ func (ds *DataStore) readPresetsLocked(account, device string) ([]models.Service
|
||||
// error, so the device-level /presets endpoint returns an empty list
|
||||
// instead of HTTP 500. See #458.
|
||||
if len(bytes.TrimSpace(data)) == 0 {
|
||||
log.Printf("[Datastore] readPresetsLocked: empty/0-byte Presets.xml at %s — treating as no presets (#458)", sanitizeLog(path))
|
||||
log.Printf("[Datastore] readPresetsNoLock: empty/0-byte Presets.xml at %s — treating as no presets (#458)", sanitizeLog(path))
|
||||
|
||||
return []models.ServicePreset{}, false, nil
|
||||
}
|
||||
@@ -999,7 +1054,7 @@ func (ds *DataStore) readPresetsLocked(account, device string) ([]models.Service
|
||||
needsRewrite := !bytes.Equal(normalized, data)
|
||||
|
||||
if err := xml.Unmarshal(normalized, &presetsWrap); err != nil {
|
||||
log.Printf("[Datastore] readPresetsLocked: malformed Presets.xml at %s (%s) — treating as no presets (#458)", sanitizeLog(path), sanitizeErr(err))
|
||||
log.Printf("[Datastore] readPresetsNoLock: malformed Presets.xml at %s (%s) — treating as no presets (#458)", sanitizeLog(path), sanitizeErr(err))
|
||||
|
||||
return []models.ServicePreset{}, false, nil
|
||||
}
|
||||
@@ -1053,7 +1108,7 @@ func repairLeakedSource(account, device, label, persistedSource, sourceID string
|
||||
return persistedSource
|
||||
}
|
||||
|
||||
sources, err := ds.getConfiguredSourcesLocked(account, device)
|
||||
sources, err := ds.getConfiguredSourcesNoLock(account, device)
|
||||
if err != nil {
|
||||
return persistedSource
|
||||
}
|
||||
@@ -1079,11 +1134,11 @@ func isLeakedSourceValue(s string) bool {
|
||||
return s == "" || s == "Audio"
|
||||
}
|
||||
|
||||
// getConfiguredSourcesLocked is GetConfiguredSources without the
|
||||
// getConfiguredSourcesNoLock is GetConfiguredSources without the
|
||||
// fileMutex.RLock() — callers must already hold it. Used by
|
||||
// repairLeakedSource from within GetPresets/GetRecents which already
|
||||
// hold the lock.
|
||||
func (ds *DataStore) getConfiguredSourcesLocked(account, device string) ([]models.ConfiguredSource, error) {
|
||||
func (ds *DataStore) getConfiguredSourcesNoLock(account, device string) ([]models.ConfiguredSource, error) {
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
|
||||
|
||||
data, err := ds.rootReadFile(path)
|
||||
@@ -1131,6 +1186,12 @@ func (ds *DataStore) SavePresets(account, device string, presets []models.Servic
|
||||
ds.fileMutex.Lock()
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
return ds.savePresetsNoLock(account, device, presets)
|
||||
}
|
||||
|
||||
// savePresetsNoLock is the lock-free write half of
|
||||
// SavePresets/MutatePresets. Callers must already hold ds.fileMutex.Lock().
|
||||
func (ds *DataStore) savePresetsNoLock(account, device string, presets []models.ServicePreset) error {
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile)
|
||||
if err := ds.rootMkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
@@ -1294,11 +1355,45 @@ func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent,
|
||||
ds.fileMutex.RLock()
|
||||
defer ds.fileMutex.RUnlock()
|
||||
|
||||
return ds.readRecentsNoLock(account, device)
|
||||
}
|
||||
|
||||
// MutateRecents atomically reads the current recents list, transforms it
|
||||
// via mutate, and persists the result — holding a single write lock for the
|
||||
// entire read-mutate-write cycle. See MutatePresets for why this matters: a
|
||||
// separate GetRecents followed by SaveRecents leaves a lost-update window
|
||||
// open between concurrent callers.
|
||||
func (ds *DataStore) MutateRecents(account, device string, mutate func(current []models.ServiceRecent) ([]models.ServiceRecent, error)) ([]models.ServiceRecent, error) {
|
||||
ds.fileMutex.Lock()
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
current, err := ds.readRecentsNoLock(account, device)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
next, err := mutate(current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := ds.saveRecentsNoLock(account, device, next); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// readRecentsNoLock is the lock-free read half of GetRecents/MutateRecents.
|
||||
// Callers must already hold ds.fileMutex (for reading or writing).
|
||||
func (ds *DataStore) readRecentsNoLock(account, device string) ([]models.ServiceRecent, error) {
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.RecentsFile)
|
||||
|
||||
data, err := ds.rootReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
log.Printf("[Datastore] GetRecents: no Recents.xml at %s — reporting no recents", sanitizeLog(path))
|
||||
|
||||
return []models.ServiceRecent{}, nil
|
||||
}
|
||||
|
||||
@@ -1404,6 +1499,12 @@ func (ds *DataStore) SaveRecents(account, device string, recents []models.Servic
|
||||
ds.fileMutex.Lock()
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
return ds.saveRecentsNoLock(account, device, recents)
|
||||
}
|
||||
|
||||
// saveRecentsNoLock is the lock-free write half of
|
||||
// SaveRecents/MutateRecents. Callers must already hold ds.fileMutex.Lock().
|
||||
func (ds *DataStore) saveRecentsNoLock(account, device string, recents []models.ServiceRecent) error {
|
||||
dir := ds.AccountDeviceDir(account, device)
|
||||
if err := ds.rootMkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
@@ -1509,7 +1610,7 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
|
||||
return fmt.Errorf("device ID/name cannot be empty")
|
||||
}
|
||||
|
||||
if !isSafeIdentifier(device) {
|
||||
if !IsSafeIdentifier(device) {
|
||||
return fmt.Errorf("invalid device ID")
|
||||
}
|
||||
|
||||
@@ -1517,7 +1618,7 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
|
||||
return fmt.Errorf("account ID cannot be empty")
|
||||
}
|
||||
|
||||
if !isSafeIdentifier(account) {
|
||||
if !IsSafeIdentifier(account) {
|
||||
return fmt.Errorf("invalid account ID")
|
||||
}
|
||||
|
||||
@@ -1705,6 +1806,10 @@ func (ds *DataStore) SaveAccountInfo(accountID string, info *models.ServiceAccou
|
||||
return nil
|
||||
}
|
||||
|
||||
if !IsSafeIdentifier(accountID) {
|
||||
return fmt.Errorf("invalid account ID")
|
||||
}
|
||||
|
||||
dir := ds.AccountDir(accountID)
|
||||
if err := ds.rootMkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
@@ -1905,6 +2010,40 @@ func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.Conf
|
||||
ds.fileMutex.RLock()
|
||||
defer ds.fileMutex.RUnlock()
|
||||
|
||||
return ds.readConfiguredSourcesNoLock(account, device)
|
||||
}
|
||||
|
||||
// MutateConfiguredSources atomically reads the current configured-source
|
||||
// list, transforms it via mutate, and persists the result — holding a
|
||||
// single write lock for the entire read-mutate-write cycle. See
|
||||
// MutatePresets for why this matters: a separate GetConfiguredSources
|
||||
// followed by SaveConfiguredSources leaves a lost-update window open
|
||||
// between concurrent callers.
|
||||
func (ds *DataStore) MutateConfiguredSources(account, device string, mutate func(current []models.ConfiguredSource) ([]models.ConfiguredSource, error)) ([]models.ConfiguredSource, error) {
|
||||
ds.fileMutex.Lock()
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
current, err := ds.readConfiguredSourcesNoLock(account, device)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
next, err := mutate(current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := ds.saveConfiguredSourcesNoLock(account, device, next); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// readConfiguredSourcesNoLock is the lock-free read half of
|
||||
// GetConfiguredSources/MutateConfiguredSources. Callers must already hold
|
||||
// ds.fileMutex (for reading or writing).
|
||||
func (ds *DataStore) readConfiguredSourcesNoLock(account, device string) ([]models.ConfiguredSource, error) {
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
|
||||
|
||||
// defaultSources is the fallback used whenever there is no usable
|
||||
@@ -2076,6 +2215,13 @@ func (ds *DataStore) SaveConfiguredSources(account, device string, sources []mod
|
||||
ds.fileMutex.Lock()
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
return ds.saveConfiguredSourcesNoLock(account, device, sources)
|
||||
}
|
||||
|
||||
// saveConfiguredSourcesNoLock is the lock-free write half of
|
||||
// SaveConfiguredSources/MutateConfiguredSources. Callers must already hold
|
||||
// ds.fileMutex.Lock().
|
||||
func (ds *DataStore) saveConfiguredSourcesNoLock(account, device string, sources []models.ConfiguredSource) error {
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
|
||||
if err := ds.rootMkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
@@ -2594,6 +2740,8 @@ type Settings struct {
|
||||
RecordInteractions bool `json:"record_interactions"`
|
||||
DiscoveryInterval string `json:"discovery_interval,omitempty"`
|
||||
DiscoveryEnabled bool `json:"discovery_enabled"`
|
||||
UpdateCheckInterval string `json:"update_check_interval,omitempty"`
|
||||
UpdateCheckEnabled bool `json:"update_check_enabled"`
|
||||
DNSEnabled bool `json:"dns_enabled"`
|
||||
DNSUpstream []string `json:"dns_upstream,omitempty"`
|
||||
DNSBindAddr string `json:"dns_bind_addr,omitempty"`
|
||||
@@ -2720,6 +2868,63 @@ func (ds *DataStore) SaveSettings(settings Settings) error {
|
||||
return ds.atomicWriteFile(path, data)
|
||||
}
|
||||
|
||||
// UpdateCheckState is the small persisted state for the opt-in periodic
|
||||
// update check (#591, _/i591/design-update-check.md): when it last ran and
|
||||
// what it last saw, so a restart doesn't lose the "already logged this
|
||||
// version" and "don't hammer GitHub on every startup" context. Separate
|
||||
// from Settings, which is operator-editable config, not runtime state.
|
||||
type UpdateCheckState struct {
|
||||
LastCheckedAt string `json:"last_checked_at,omitempty"`
|
||||
LastSeenVersion string `json:"last_seen_version,omitempty"`
|
||||
LastReleaseURL string `json:"last_release_url,omitempty"`
|
||||
}
|
||||
|
||||
// GetUpdateCheckState retrieves the persisted update-check state. Same
|
||||
// missing-file-is-not-an-error shape as GetSettings — a fresh install (or
|
||||
// one that has never had the check enabled) has no file yet.
|
||||
func (ds *DataStore) GetUpdateCheckState() (UpdateCheckState, error) {
|
||||
if ds == nil || ds.DataDir == "" {
|
||||
return UpdateCheckState{}, nil
|
||||
}
|
||||
|
||||
path := filepath.Join(ds.DataDir, "update-check.json")
|
||||
if !ds.rootExists(path) {
|
||||
return UpdateCheckState{}, nil
|
||||
}
|
||||
|
||||
data, err := ds.rootReadFile(path)
|
||||
if err != nil {
|
||||
return UpdateCheckState{}, err
|
||||
}
|
||||
|
||||
var state UpdateCheckState
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return UpdateCheckState{}, err
|
||||
}
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// SaveUpdateCheckState persists the update-check state.
|
||||
func (ds *DataStore) SaveUpdateCheckState(state UpdateCheckState) error {
|
||||
if ds == nil || ds.DataDir == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := ds.rootMkdirAll(ds.DataDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create data directory: %w", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(ds.DataDir, "update-check.json")
|
||||
|
||||
data, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return ds.atomicWriteFile(path, data)
|
||||
}
|
||||
|
||||
// SaveUsageStats saves usage statistics to the datastore.
|
||||
func (ds *DataStore) SaveUsageStats(stats models.UsageStats) error {
|
||||
dir := filepath.Join(ds.DataDir, "stats", "usage")
|
||||
@@ -2762,7 +2967,10 @@ func (ds *DataStore) RecordActivity(kind, id string, detail map[string]interface
|
||||
|
||||
// The random suffix guards against two events for the same id landing in
|
||||
// the same nanosecond (observed as flaky on coarser-resolution clocks)
|
||||
// silently overwriting one another instead of both being recorded.
|
||||
// silently overwriting one another instead of both being recorded. Not
|
||||
// a security-sensitive use of randomness — only affects filename
|
||||
// uniqueness, not any value that's compared or kept secret.
|
||||
// nosemgrep: go.lang.security.audit.crypto.math_random.math-random-used
|
||||
filename := fmt.Sprintf("%d_%d_%s.json", now.UnixNano(), rand.Int63n(1_000_000), id) //nolint:gosec
|
||||
path := filepath.Join(dir, filename)
|
||||
|
||||
|
||||
@@ -428,10 +428,12 @@ func TestSettingsPersistence(t *testing.T) {
|
||||
ds := NewDataStore(tempDir)
|
||||
|
||||
settings := Settings{
|
||||
ServerURL: "http://myserver:8000",
|
||||
LogBodies: true,
|
||||
DiscoveryInterval: "10m",
|
||||
DiscoveryEnabled: true,
|
||||
ServerURL: "http://myserver:8000",
|
||||
LogBodies: true,
|
||||
DiscoveryInterval: "10m",
|
||||
DiscoveryEnabled: true,
|
||||
UpdateCheckInterval: "12h",
|
||||
UpdateCheckEnabled: true,
|
||||
}
|
||||
|
||||
err = ds.SaveSettings(settings)
|
||||
@@ -456,6 +458,64 @@ func TestSettingsPersistence(t *testing.T) {
|
||||
if loaded.DiscoveryEnabled != settings.DiscoveryEnabled {
|
||||
t.Errorf("Expected DiscoveryEnabled %v, got %v", settings.DiscoveryEnabled, loaded.DiscoveryEnabled)
|
||||
}
|
||||
if loaded.UpdateCheckInterval != settings.UpdateCheckInterval {
|
||||
t.Errorf("Expected UpdateCheckInterval %s, got %s", settings.UpdateCheckInterval, loaded.UpdateCheckInterval)
|
||||
}
|
||||
if loaded.UpdateCheckEnabled != settings.UpdateCheckEnabled {
|
||||
t.Errorf("Expected UpdateCheckEnabled %v, got %v", settings.UpdateCheckEnabled, loaded.UpdateCheckEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateCheckState_MissingFileReturnsZeroValue verifies a fresh install
|
||||
// (or one where the update check has never run) gets a zero-value state,
|
||||
// not an error — same shape as GetSettings on a missing settings.json.
|
||||
func TestUpdateCheckState_MissingFileReturnsZeroValue(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "update-check-missing-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
|
||||
state, err := ds.GetUpdateCheckState()
|
||||
if err != nil {
|
||||
t.Fatalf("GetUpdateCheckState on a fresh install should not error, got: %v", err)
|
||||
}
|
||||
if state != (UpdateCheckState{}) {
|
||||
t.Errorf("Expected zero-value state, got %+v", state)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateCheckState_Persistence is the roundtrip test, mirroring
|
||||
// TestSettingsPersistence.
|
||||
func TestUpdateCheckState_Persistence(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "update-check-persist-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
|
||||
state := UpdateCheckState{
|
||||
LastCheckedAt: "2026-08-09T12:00:00Z",
|
||||
LastSeenVersion: "v0.122.0",
|
||||
LastReleaseURL: "https://github.com/gesellix/Bose-SoundTouch/releases/tag/v0.122.0",
|
||||
}
|
||||
|
||||
if err := ds.SaveUpdateCheckState(state); err != nil {
|
||||
t.Fatalf("SaveUpdateCheckState failed: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := ds.GetUpdateCheckState()
|
||||
if err != nil {
|
||||
t.Fatalf("GetUpdateCheckState failed: %v", err)
|
||||
}
|
||||
|
||||
if loaded != state {
|
||||
t.Errorf("Expected %+v, got %+v", state, loaded)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordActivity_EmptyKindReturnsNilNotError verifies GetActivityRecords
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -22,12 +23,62 @@ const (
|
||||
// _/i419/design-admin-area-auth-gate.md. ShowWhile lets an entry key off
|
||||
// live server state (e.g. "only while the admin-area gate hasn't been
|
||||
// decided yet"); nil means always show (until dismissed).
|
||||
//
|
||||
// MessageFunc and DismissKeyFunc (added for #591,
|
||||
// _/i591/design-update-check.md) are the dynamic counterparts of Message
|
||||
// and ID: nil means "use the static field", as before; set means "compute
|
||||
// it from live state". The update-check notice needs both — its text names
|
||||
// a specific version, and dismissing the notice for v1.2.0 must not
|
||||
// suppress a later notice for v1.3.0, so its dismissal key has to change
|
||||
// with the detected version.
|
||||
type Announcement struct {
|
||||
ID string
|
||||
Message string
|
||||
Level string
|
||||
Targets []string
|
||||
ShowWhile func(*Server) bool
|
||||
ID string
|
||||
Message string
|
||||
MessageFunc func(*Server) string
|
||||
Level string
|
||||
Targets []string
|
||||
ShowWhile func(*Server) bool
|
||||
DismissKeyFunc func(*Server) string
|
||||
// LinkText/LinkURL add an optional link alongside Message — e.g. a
|
||||
// release's notes, or a docs page for a future announcement. LinkURLFunc
|
||||
// is the dynamic counterpart of LinkURL (nil = use the static field),
|
||||
// for links whose target depends on live state (e.g. which version was
|
||||
// detected). LinkText has no *Func counterpart: nothing here needs
|
||||
// dynamic link *text*, only a dynamic *URL* — add one only once
|
||||
// something actually needs it, per this project's KISS convention.
|
||||
LinkText string
|
||||
LinkURL string
|
||||
LinkURLFunc func(*Server) string
|
||||
}
|
||||
|
||||
// message returns the effective text: MessageFunc(s) if set, else the
|
||||
// static Message.
|
||||
func (a Announcement) message(s *Server) string {
|
||||
if a.MessageFunc != nil {
|
||||
return a.MessageFunc(s)
|
||||
}
|
||||
|
||||
return a.Message
|
||||
}
|
||||
|
||||
// linkURL returns the effective link URL: LinkURLFunc(s) if set, else the
|
||||
// static LinkURL (which may be "" — no link).
|
||||
func (a Announcement) linkURL(s *Server) string {
|
||||
if a.LinkURLFunc != nil {
|
||||
return a.LinkURLFunc(s)
|
||||
}
|
||||
|
||||
return a.LinkURL
|
||||
}
|
||||
|
||||
// dismissKey returns the effective dismissal/DTO id: DismissKeyFunc(s) if
|
||||
// set, else the static ID.
|
||||
func (a Announcement) dismissKey(s *Server) string {
|
||||
if a.DismissKeyFunc != nil {
|
||||
return a.DismissKeyFunc(s)
|
||||
}
|
||||
|
||||
return a.ID
|
||||
}
|
||||
|
||||
// announcements is the full, in-code list. announcementTargetChooser is
|
||||
@@ -43,20 +94,46 @@ var announcements = []Announcement{
|
||||
Targets: []string{announcementTargetAdmin},
|
||||
Message: "A future release will require login for this entire admin area by default (today, only " +
|
||||
"Spotify/Amazon linking and the Local Account tab do). You can opt in now in Settings, or " +
|
||||
"dismiss this once you've decided. See issue #419 for details.",
|
||||
"dismiss this once you've decided.",
|
||||
LinkText: "Issue #419",
|
||||
LinkURL: "https://github.com/gesellix/Bose-SoundTouch/issues/419",
|
||||
ShowWhile: func(s *Server) bool {
|
||||
return s.AdminAreaAuthMode() == ""
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "update-available",
|
||||
Level: "info",
|
||||
Targets: []string{announcementTargetApp, announcementTargetAdmin},
|
||||
ShowWhile: func(s *Server) bool {
|
||||
return s.UpdateCheckResult().Available
|
||||
},
|
||||
MessageFunc: func(s *Server) string {
|
||||
r := s.UpdateCheckResult()
|
||||
|
||||
return fmt.Sprintf("AfterTouch %s is available (you're on %s).", r.LatestVersion, r.CurrentVersion)
|
||||
},
|
||||
LinkText: "Release notes",
|
||||
LinkURLFunc: func(s *Server) string {
|
||||
return s.UpdateCheckResult().ReleaseURL
|
||||
},
|
||||
// Per-version, not per-family: dismissing the notice for one version
|
||||
// must not silently suppress a later, different version's notice.
|
||||
DismissKeyFunc: func(s *Server) string {
|
||||
return "update-available-" + s.UpdateCheckResult().LatestVersion
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// announcementDTO is the JSON shape returned by HandleListAnnouncements —
|
||||
// deliberately smaller than Announcement (no ShowWhile func, no Targets;
|
||||
// the caller already asked for a specific target).
|
||||
type announcementDTO struct {
|
||||
ID string `json:"id"`
|
||||
Message string `json:"message"`
|
||||
Level string `json:"level"`
|
||||
ID string `json:"id"`
|
||||
Message string `json:"message"`
|
||||
Level string `json:"level"`
|
||||
LinkText string `json:"link_text,omitempty"`
|
||||
LinkURL string `json:"link_url,omitempty"`
|
||||
}
|
||||
|
||||
func containsString(haystack []string, needle string) bool {
|
||||
@@ -88,7 +165,9 @@ func (s *Server) HandleListAnnouncements(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
active := make([]announcementDTO, 0, len(announcements))
|
||||
|
||||
for _, a := range announcements {
|
||||
for i := range announcements {
|
||||
a := &announcements[i]
|
||||
|
||||
if !containsString(a.Targets, target) {
|
||||
continue
|
||||
}
|
||||
@@ -97,11 +176,18 @@ func (s *Server) HandleListAnnouncements(w http.ResponseWriter, r *http.Request)
|
||||
continue
|
||||
}
|
||||
|
||||
if s.IsAnnouncementDismissed(a.ID) {
|
||||
key := a.dismissKey(s)
|
||||
if s.IsAnnouncementDismissed(key) {
|
||||
continue
|
||||
}
|
||||
|
||||
active = append(active, announcementDTO{ID: a.ID, Message: a.Message, Level: a.Level})
|
||||
active = append(active, announcementDTO{
|
||||
ID: key,
|
||||
Message: a.message(s),
|
||||
Level: a.Level,
|
||||
LinkText: a.LinkText,
|
||||
LinkURL: a.linkURL(s),
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -123,8 +209,8 @@ func (s *Server) HandleDismissAnnouncement(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
found := false
|
||||
|
||||
for _, a := range announcements {
|
||||
if a.ID == id {
|
||||
for i := range announcements {
|
||||
if announcements[i].dismissKey(s) == id {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
@@ -163,3 +165,123 @@ func TestHandleDismissAnnouncement_Success(t *testing.T) {
|
||||
t.Errorf("expected the notice to be gone from the list after dismissal, got %+v", active)
|
||||
}
|
||||
}
|
||||
|
||||
// newServerWithUpdateAvailable builds a Server whose registered
|
||||
// updatecheck.Checker reports a newer version than currentVersion, via the
|
||||
// same persisted-state-seeding path a real restart would use (not a mock —
|
||||
// exercises the real NewChecker/UpdateCheckResult round trip).
|
||||
func newServerWithUpdateAvailable(t *testing.T, currentVersion, latestVersion string) *Server {
|
||||
t.Helper()
|
||||
|
||||
s := newAnnouncementsTestServer(t)
|
||||
|
||||
ds := datastore.NewDataStore(t.TempDir())
|
||||
if err := ds.SaveUpdateCheckState(datastore.UpdateCheckState{
|
||||
LastCheckedAt: "2026-08-09T00:00:00Z",
|
||||
LastSeenVersion: latestVersion,
|
||||
LastReleaseURL: "https://example.invalid/releases/" + latestVersion,
|
||||
}); err != nil {
|
||||
t.Fatalf("Failed to seed update-check state: %v", err)
|
||||
}
|
||||
|
||||
s.SetUpdateChecker(updatecheck.NewChecker(ds, "owner/repo", currentVersion))
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// TestHandleListAnnouncements_UpdateAvailable is the regression test for
|
||||
// #591's reuse of the #419 announcements mechanism: the update-available
|
||||
// entry's dynamic message/target/dismissal behavior end to end.
|
||||
func TestHandleListAnnouncements_UpdateAvailable(t *testing.T) {
|
||||
t.Run("visible for both admin and app targets when available", func(t *testing.T) {
|
||||
s := newServerWithUpdateAvailable(t, "v1.0.0", "v1.2.0")
|
||||
|
||||
for _, target := range []string{announcementTargetAdmin, announcementTargetApp} {
|
||||
_, active := listAnnouncements(t, s, target)
|
||||
|
||||
var found *announcementDTO
|
||||
for i := range active {
|
||||
if active[i].ID == "update-available-v1.2.0" {
|
||||
found = &active[i]
|
||||
}
|
||||
}
|
||||
|
||||
if found == nil {
|
||||
t.Fatalf("target=%s: expected an update-available-v1.2.0 entry, got %+v", target, active)
|
||||
}
|
||||
if found.Message == "" {
|
||||
t.Errorf("target=%s: expected a non-empty dynamic message", target)
|
||||
}
|
||||
// The release URL belongs in the structured link field, not
|
||||
// embedded as text in the message — the message must stay
|
||||
// generic across other future announcements too.
|
||||
if strings.Contains(found.Message, "http") {
|
||||
t.Errorf("target=%s: expected the URL out of Message, got %q", target, found.Message)
|
||||
}
|
||||
if found.LinkURL == "" {
|
||||
t.Errorf("target=%s: expected a non-empty LinkURL", target)
|
||||
}
|
||||
if found.LinkText == "" {
|
||||
t.Errorf("target=%s: expected a non-empty LinkText", target)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not visible when already up to date", func(t *testing.T) {
|
||||
s := newServerWithUpdateAvailable(t, "v1.2.0", "v1.2.0")
|
||||
|
||||
_, active := listAnnouncements(t, s, announcementTargetAdmin)
|
||||
if containsAnnouncementID(active, "update-available-v1.2.0") {
|
||||
t.Errorf("expected no update-available entry when up to date, got %+v", active)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dismissing one version does not suppress a later version", func(t *testing.T) {
|
||||
s := newServerWithUpdateAvailable(t, "v1.0.0", "v1.2.0")
|
||||
|
||||
if err := s.RecordDismissal("update-available-v1.2.0"); err != nil {
|
||||
t.Fatalf("RecordDismissal failed: %v", err)
|
||||
}
|
||||
|
||||
_, active := listAnnouncements(t, s, announcementTargetAdmin)
|
||||
if containsAnnouncementID(active, "update-available-v1.2.0") {
|
||||
t.Error("expected the v1.2.0 notice to be dismissed")
|
||||
}
|
||||
|
||||
// A later check finds a newer version still: must reappear under a
|
||||
// DIFFERENT dismissal key, not stay suppressed.
|
||||
newDS := datastore.NewDataStore(t.TempDir())
|
||||
if err := newDS.SaveUpdateCheckState(datastore.UpdateCheckState{
|
||||
LastCheckedAt: "2026-08-10T00:00:00Z",
|
||||
LastSeenVersion: "v1.3.0",
|
||||
}); err != nil {
|
||||
t.Fatalf("Failed to seed newer state: %v", err)
|
||||
}
|
||||
s.SetUpdateChecker(updatecheck.NewChecker(newDS, "owner/repo", "v1.0.0"))
|
||||
|
||||
_, active = listAnnouncements(t, s, announcementTargetAdmin)
|
||||
if !containsAnnouncementID(active, "update-available-v1.3.0") {
|
||||
t.Errorf("expected the v1.3.0 notice to appear despite v1.2.0 being dismissed, got %+v", active)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleDismissAnnouncement_UpdateAvailable(t *testing.T) {
|
||||
s := newServerWithUpdateAvailable(t, "v1.0.0", "v1.2.0")
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/announcements/{id}/dismiss", s.HandleDismissAnnouncement)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/announcements/update-available-v1.2.0/dismiss", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
|
||||
_, active := listAnnouncements(t, s, announcementTargetAdmin)
|
||||
if containsAnnouncementID(active, "update-available-v1.2.0") {
|
||||
t.Errorf("expected the notice to be gone after dismissal, got %+v", active)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/bmx"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
@@ -47,7 +48,7 @@ func (s *Server) HandleTuneInPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
sanitizeLog(r.URL.Path), sanitizeLog(r.UserAgent()))
|
||||
}
|
||||
|
||||
stationID := chi.URLParam(r, "stationID")
|
||||
stationID := strings.TrimSpace(chi.URLParam(r, "stationID"))
|
||||
|
||||
resp, err := bmx.TuneInPlayback(stationID, s.tuneInStreamFormats())
|
||||
if err != nil {
|
||||
@@ -74,8 +75,8 @@ func (s *Server) HandleTuneInPodcastInfo(w http.ResponseWriter, r *http.Request)
|
||||
sanitizeLog(r.URL.Path), sanitizeLog(r.UserAgent()))
|
||||
}
|
||||
|
||||
podcastID := chi.URLParam(r, "podcastID")
|
||||
encodedName := r.URL.Query().Get("encoded_name")
|
||||
podcastID := strings.TrimSpace(chi.URLParam(r, "podcastID"))
|
||||
encodedName := strings.TrimSpace(r.URL.Query().Get("encoded_name"))
|
||||
|
||||
resp, err := bmx.TuneInPodcastInfo(podcastID, encodedName)
|
||||
if err != nil {
|
||||
@@ -102,7 +103,7 @@ func (s *Server) HandleTuneInPlaybackPodcast(w http.ResponseWriter, r *http.Requ
|
||||
sanitizeLog(r.URL.Path), sanitizeLog(r.UserAgent()))
|
||||
}
|
||||
|
||||
podcastID := chi.URLParam(r, "podcastID")
|
||||
podcastID := strings.TrimSpace(chi.URLParam(r, "podcastID"))
|
||||
|
||||
resp, err := bmx.TuneInPlaybackPodcast(podcastID, s.tuneInStreamFormats())
|
||||
if err != nil {
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,7 +301,7 @@ func (s *Server) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) {
|
||||
sanitizeLog(r.URL.Path), sanitizeLog(r.UserAgent()))
|
||||
}
|
||||
|
||||
query := r.URL.Query().Get("q")
|
||||
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
if query == "" {
|
||||
http.Error(w, "query parameter 'q' is required", http.StatusBadRequest)
|
||||
return
|
||||
@@ -298,7 +328,7 @@ func (s *Server) HandleTuneInSearchNext(w http.ResponseWriter, r *http.Request)
|
||||
sanitizeLog(r.URL.Path), sanitizeLog(r.UserAgent()))
|
||||
}
|
||||
|
||||
cursor := r.URL.Query().Get("cursor")
|
||||
cursor := strings.TrimSpace(r.URL.Query().Get("cursor"))
|
||||
if cursor == "" {
|
||||
http.Error(w, "cursor parameter required", http.StatusBadRequest)
|
||||
return
|
||||
@@ -319,7 +349,7 @@ func (s *Server) HandleTuneInSearchNext(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// HandleTuneInFavorite handles POST /bmx/tunein/v1/favorite/{stationID}.
|
||||
func (s *Server) HandleTuneInFavorite(w http.ResponseWriter, r *http.Request) {
|
||||
stationID := chi.URLParam(r, "stationID")
|
||||
stationID := strings.TrimSpace(chi.URLParam(r, "stationID"))
|
||||
if err := s.ds.SaveTuneInFavorite(stationID); err != nil {
|
||||
log.Printf("Failed to persist TuneIn favorite %s: %s", sanitizeLog(stationID), sanitizeErr(err))
|
||||
}
|
||||
@@ -331,7 +361,7 @@ func (s *Server) HandleTuneInFavorite(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// HandleTuneInDeleteFavorite handles DELETE /bmx/tunein/v1/favorite/{stationID}.
|
||||
func (s *Server) HandleTuneInDeleteFavorite(w http.ResponseWriter, r *http.Request) {
|
||||
stationID := chi.URLParam(r, "stationID")
|
||||
stationID := strings.TrimSpace(chi.URLParam(r, "stationID"))
|
||||
if err := s.ds.DeleteTuneInFavorite(stationID); err != nil {
|
||||
log.Printf("Failed to delete TuneIn favorite %s: %s", sanitizeLog(stationID), sanitizeErr(err))
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -14,6 +14,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 +65,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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -167,6 +167,11 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
|
||||
httpsOverride := s.httpsOverride
|
||||
discoveryInterval := s.discoveryInterval.String()
|
||||
discoveryEnabled := s.discoveryEnabled
|
||||
// Read the update-check fields directly rather than via
|
||||
// GetUpdateCheckSettings(): that getter takes s.mu.RLock itself, and Go's
|
||||
// sync.RWMutex is not reentrant-safe against a concurrent writer.
|
||||
updateCheckInterval := s.updateCheckInterval.String()
|
||||
updateCheckEnabled := s.updateCheckEnabled
|
||||
dnsEnabled := s.dnsEnabled
|
||||
dnsUpstream := s.dnsUpstream
|
||||
dnsBindAddr := s.dnsBindAddr
|
||||
@@ -248,6 +253,8 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
|
||||
"https_443_lan_host": probe443.LANHost,
|
||||
"discovery_interval": discoveryInterval,
|
||||
"discovery_enabled": discoveryEnabled,
|
||||
"update_check_interval": updateCheckInterval,
|
||||
"update_check_enabled": updateCheckEnabled,
|
||||
"dns_enabled": dnsEnabled,
|
||||
"dns_running": dnsRunning,
|
||||
"dns_actual_bind": actualBind,
|
||||
@@ -300,6 +307,40 @@ func parseDNSUpstreamList(dnsUpstream string) []string {
|
||||
return upstreamList
|
||||
}
|
||||
|
||||
// parseOptionalDuration parses a duration string that the client is allowed to
|
||||
// omit. An empty value yields a zero duration and no error, so callers can
|
||||
// treat "field omitted" as "keep the current value" while still rejecting a
|
||||
// value that was supplied but is unparseable.
|
||||
func parseOptionalDuration(value string) (time.Duration, error) {
|
||||
if value == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
return time.ParseDuration(value)
|
||||
}
|
||||
|
||||
// resolvePeriodicSetting computes the new (interval, enabled) pair for one of
|
||||
// the background pollers (device discovery, update check) from a settings
|
||||
// request. When the request omitted the interval, the current one is kept. A
|
||||
// zero interval always forces the task off: both pollers treat zero as
|
||||
// "always due", so leaving the task enabled would make their poll tick the
|
||||
// work rate.
|
||||
func resolvePeriodicSetting(
|
||||
currentInterval, requestedInterval time.Duration,
|
||||
requestedIntervalProvided, requestedEnabled bool,
|
||||
) (time.Duration, bool) {
|
||||
interval := currentInterval
|
||||
if requestedIntervalProvided {
|
||||
interval = requestedInterval
|
||||
}
|
||||
|
||||
if interval == 0 {
|
||||
return interval, false
|
||||
}
|
||||
|
||||
return interval, requestedEnabled
|
||||
}
|
||||
|
||||
// HandleUpdateSettings updates the service settings.
|
||||
func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var settings struct {
|
||||
@@ -307,6 +348,8 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
HTTPSServerURLOverride *string `json:"https_server_url_override"`
|
||||
DiscoveryInterval string `json:"discovery_interval"`
|
||||
DiscoveryEnabled bool `json:"discovery_enabled"`
|
||||
UpdateCheckInterval string `json:"update_check_interval"`
|
||||
UpdateCheckEnabled bool `json:"update_check_enabled"`
|
||||
DNSEnabled bool `json:"dns_enabled"`
|
||||
DNSUpstream string `json:"dns_upstream"`
|
||||
DNSBindAddr string `json:"dns_bind_addr"`
|
||||
@@ -370,12 +413,18 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
interval, err := time.ParseDuration(settings.DiscoveryInterval)
|
||||
if err != nil && settings.DiscoveryInterval != "" {
|
||||
interval, err := parseOptionalDuration(settings.DiscoveryInterval)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid discovery interval: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
updateCheckInterval, err := parseOptionalDuration(settings.UpdateCheckInterval)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid update check interval: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
|
||||
// Guard rail: refuse to enable the admin-area gate while the Management
|
||||
@@ -396,14 +445,11 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
// the Target Domain (which the derived URL follows) may have changed.
|
||||
s.applyHTTPSOverrideLocked(settings.HTTPSServerURLOverride)
|
||||
|
||||
s.discoveryEnabled = settings.DiscoveryEnabled
|
||||
if settings.DiscoveryInterval != "" {
|
||||
s.discoveryInterval = interval
|
||||
}
|
||||
s.discoveryInterval, s.discoveryEnabled = resolvePeriodicSetting(
|
||||
s.discoveryInterval, interval, settings.DiscoveryInterval != "", settings.DiscoveryEnabled)
|
||||
|
||||
if s.discoveryInterval == 0 {
|
||||
s.discoveryEnabled = false
|
||||
}
|
||||
s.updateCheckInterval, s.updateCheckEnabled = resolvePeriodicSetting(
|
||||
s.updateCheckInterval, updateCheckInterval, settings.UpdateCheckInterval != "", settings.UpdateCheckEnabled)
|
||||
|
||||
s.dnsEnabled = settings.DNSEnabled
|
||||
s.dnsUpstream = parseDNSUpstreamList(settings.DNSUpstream)
|
||||
@@ -469,6 +515,8 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
persisted.RecordInteractions = currentRecord
|
||||
persisted.DiscoveryInterval = s.discoveryInterval.String()
|
||||
persisted.DiscoveryEnabled = s.discoveryEnabled
|
||||
persisted.UpdateCheckInterval = s.updateCheckInterval.String()
|
||||
persisted.UpdateCheckEnabled = s.updateCheckEnabled
|
||||
persisted.DNSEnabled = s.dnsEnabled
|
||||
persisted.DNSUpstream = s.dnsUpstream
|
||||
persisted.DNSBindAddr = s.dnsBindAddr
|
||||
@@ -1226,7 +1274,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 == "" {
|
||||
@@ -1240,13 +1297,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.
|
||||
@@ -1388,14 +1457,22 @@ func (s *Server) HandleGetVersionInfo(w http.ResponseWriter, _ *http.Request) {
|
||||
releaseURL = fmt.Sprintf("%s/releases/tag/%s", repoURL, version)
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{
|
||||
"version": version,
|
||||
"commit": commit,
|
||||
"date": date,
|
||||
"repo_url": repoURL,
|
||||
"release_url": releaseURL,
|
||||
"commit_url": commitURL,
|
||||
"data_dir": dataDir,
|
||||
// Opt-in periodic update check (#591) — UpdateCheckResult is nil-safe and
|
||||
// returns the zero value (Available: false) when the check was never
|
||||
// enabled, which is the common case.
|
||||
updateCheck := s.UpdateCheckResult()
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"version": version,
|
||||
"commit": commit,
|
||||
"date": date,
|
||||
"repo_url": repoURL,
|
||||
"release_url": releaseURL,
|
||||
"commit_url": commitURL,
|
||||
"data_dir": dataDir,
|
||||
"update_available": updateCheck.Available,
|
||||
"latest_version": updateCheck.LatestVersion,
|
||||
"latest_release_url": updateCheck.ReleaseURL,
|
||||
}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
|
||||
@@ -443,6 +444,170 @@ func TestAdminAreaAuthRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolvePeriodicSetting covers the shared interval/enabled resolution
|
||||
// used by both background pollers (device discovery, update check).
|
||||
func TestResolvePeriodicSetting(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
current time.Duration
|
||||
requested time.Duration
|
||||
provided bool
|
||||
enabled bool
|
||||
wantInterval time.Duration
|
||||
wantEnabledState bool
|
||||
}{
|
||||
{"interval omitted keeps the current one", 24 * time.Hour, 0, false, true, 24 * time.Hour, true},
|
||||
{"interval supplied replaces the current one", 24 * time.Hour, 6 * time.Hour, true, true, 6 * time.Hour, true},
|
||||
{"disabling keeps the interval", 24 * time.Hour, 0, false, false, 24 * time.Hour, false},
|
||||
{"a zero interval forces it off", 24 * time.Hour, 0, true, true, 0, false},
|
||||
{"a zero current interval forces it off too", 0, 0, false, true, 0, false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
gotInterval, gotEnabled := resolvePeriodicSetting(tc.current, tc.requested, tc.provided, tc.enabled)
|
||||
if gotInterval != tc.wantInterval || gotEnabled != tc.wantEnabledState {
|
||||
t.Errorf("%s: resolvePeriodicSetting() = %v/%v, want %v/%v",
|
||||
tc.name, gotInterval, gotEnabled, tc.wantInterval, tc.wantEnabledState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseOptionalDuration verifies an omitted duration is not an error,
|
||||
// while a supplied-but-invalid one is.
|
||||
func TestParseOptionalDuration(t *testing.T) {
|
||||
if d, err := parseOptionalDuration(""); err != nil || d != 0 {
|
||||
t.Errorf("parseOptionalDuration(\"\") = %v/%v, want 0/nil", d, err)
|
||||
}
|
||||
|
||||
if d, err := parseOptionalDuration("90m"); err != nil || d != 90*time.Minute {
|
||||
t.Errorf("parseOptionalDuration(\"90m\") = %v/%v, want 1h30m0s/nil", d, err)
|
||||
}
|
||||
|
||||
if _, err := parseOptionalDuration("nope"); err == nil {
|
||||
t.Error("parseOptionalDuration(\"nope\") = nil error, want a parse error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateCheckSettingsRoundTrip covers the Settings-page control for the
|
||||
// opt-in update check (#591 follow-up): POST /setup/settings must update the
|
||||
// live values the background poller reads, persist them, and hand them back
|
||||
// on GET so the UI reflects what was saved.
|
||||
func TestUpdateCheckSettingsRoundTrip(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "update-check-settings-roundtrip-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
r, server := setupRouter("http://127.0.0.1:8000", ds)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
// Default state: opted out, with a nonzero interval so enabling it later
|
||||
// doesn't need an interval to be supplied.
|
||||
if interval, enabled := server.GetUpdateCheckSettings(); enabled || interval == 0 {
|
||||
t.Fatalf("Expected the check to default to disabled with a nonzero interval, got %v/%v", interval, enabled)
|
||||
}
|
||||
|
||||
enableBody, err := json.Marshal(map[string]interface{}{
|
||||
"server_url": "http://127.0.0.1:8000",
|
||||
"update_check_enabled": true,
|
||||
"update_check_interval": "6h",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal request body: %v", err)
|
||||
}
|
||||
|
||||
res, err := http.Post(ts.URL+"/setup/settings", "application/json", bytes.NewBuffer(enableBody))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("POST /setup/settings (enable): expected 200, got %v", res.Status)
|
||||
}
|
||||
|
||||
interval, enabled := server.GetUpdateCheckSettings()
|
||||
if !enabled || interval != 6*time.Hour {
|
||||
t.Errorf("Expected live settings 6h/true, got %v/%v", interval, enabled)
|
||||
}
|
||||
|
||||
persisted, err := ds.GetSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to reload settings: %v", err)
|
||||
}
|
||||
if !persisted.UpdateCheckEnabled || persisted.UpdateCheckInterval != "6h0m0s" {
|
||||
t.Errorf("Expected persisted 6h0m0s/true, got %q/%v",
|
||||
persisted.UpdateCheckInterval, persisted.UpdateCheckEnabled)
|
||||
}
|
||||
|
||||
res, err = http.Get(ts.URL + "/setup/settings")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
var got map[string]interface{}
|
||||
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("Failed to decode GET /setup/settings: %v", err)
|
||||
}
|
||||
if got["update_check_enabled"] != true {
|
||||
t.Errorf("GET /setup/settings: expected update_check_enabled true, got %+v", got["update_check_enabled"])
|
||||
}
|
||||
if got["update_check_interval"] != "6h0m0s" {
|
||||
t.Errorf("GET /setup/settings: expected update_check_interval 6h0m0s, got %+v", got["update_check_interval"])
|
||||
}
|
||||
|
||||
// An unparseable interval must be rejected before anything is applied.
|
||||
badBody, err := json.Marshal(map[string]interface{}{
|
||||
"server_url": "http://127.0.0.1:8000",
|
||||
"update_check_enabled": true,
|
||||
"update_check_interval": "not-a-duration",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal request body: %v", err)
|
||||
}
|
||||
|
||||
res, err = http.Post(ts.URL+"/setup/settings", "application/json", bytes.NewBuffer(badBody))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("POST /setup/settings (bad interval): expected 400, got %v", res.Status)
|
||||
}
|
||||
|
||||
// A zero interval must force the check off rather than leave the poller
|
||||
// hitting GitHub on every tick.
|
||||
zeroBody, err := json.Marshal(map[string]interface{}{
|
||||
"server_url": "http://127.0.0.1:8000",
|
||||
"update_check_enabled": true,
|
||||
"update_check_interval": "0s",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal request body: %v", err)
|
||||
}
|
||||
|
||||
res, err = http.Post(ts.URL+"/setup/settings", "application/json", bytes.NewBuffer(zeroBody))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("POST /setup/settings (zero interval): expected 200, got %v", res.Status)
|
||||
}
|
||||
|
||||
if _, enabled := server.GetUpdateCheckSettings(); enabled {
|
||||
t.Error("Expected a zero interval to disable the update check")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleGetVersionInfo_IncludesAbsoluteDataDir verifies /api/setup/version
|
||||
// reports the actual data directory in use, resolved to an absolute path —
|
||||
// added so operators running the service locally (not in Docker, where the
|
||||
@@ -468,7 +633,7 @@ func TestHandleGetVersionInfo_IncludesAbsoluteDataDir(t *testing.T) {
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
var got map[string]string
|
||||
var got map[string]interface{}
|
||||
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
@@ -483,6 +648,45 @@ func TestHandleGetVersionInfo_IncludesAbsoluteDataDir(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleGetVersionInfo_UpdateCheckFields verifies the #591 fields are
|
||||
// present and reflect a nil-checker default (Available: false) when the
|
||||
// update check was never enabled — the common case.
|
||||
func TestHandleGetVersionInfo_UpdateCheckFields(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "version-info-updatecheck-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
r, _ := setupRouter("http://127.0.0.1:8000", ds)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
res, err := http.Get(ts.URL + "/setup/version")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
var got map[string]interface{}
|
||||
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if got["update_available"] != false {
|
||||
t.Errorf("Expected update_available=false by default, got %v", got["update_available"])
|
||||
}
|
||||
if _, ok := got["latest_version"]; !ok {
|
||||
t.Error("Expected a latest_version key in the response")
|
||||
}
|
||||
if _, ok := got["latest_release_url"]; !ok {
|
||||
t.Error("Expected a latest_release_url key in the response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationAndCA(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "handlers-test")
|
||||
if err != nil {
|
||||
@@ -761,3 +965,9 @@ func (m *mockSSH) UploadContent(content []byte, remotePath string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Connect/Close are no-ops here — the mock has no real connection to
|
||||
// reuse, and every test call already goes through Run/UploadContent above
|
||||
// regardless of whether Connect was called first.
|
||||
func (m *mockSSH) Connect() error { return nil }
|
||||
func (m *mockSSH) Close() error { return nil }
|
||||
|
||||
@@ -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,34 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
|
||||
)
|
||||
|
||||
// TestUpdateCheckResult_NilCheckerIsSafe verifies the default (opt-in
|
||||
// checker never registered) returns a safe zero value rather than
|
||||
// panicking — the common case, since UPDATE_CHECK_ENABLED defaults to
|
||||
// false.
|
||||
func TestUpdateCheckResult_NilCheckerIsSafe(t *testing.T) {
|
||||
s := NewServer(nil, nil, "http://localhost", false, false, false)
|
||||
|
||||
result := s.UpdateCheckResult()
|
||||
if result.Available {
|
||||
t.Error("Expected a nil checker to report Available=false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateCheckResult_ReflectsRegisteredChecker verifies SetUpdateChecker
|
||||
// wires the checker in and UpdateCheckResult reads through to it.
|
||||
func TestUpdateCheckResult_ReflectsRegisteredChecker(t *testing.T) {
|
||||
s := NewServer(nil, nil, "http://localhost", false, false, false)
|
||||
|
||||
checker := updatecheck.NewChecker(nil, "owner/repo", "v1.0.0")
|
||||
s.SetUpdateChecker(checker)
|
||||
|
||||
result := s.UpdateCheckResult()
|
||||
if result.CurrentVersion != "v1.0.0" {
|
||||
t.Errorf("Expected UpdateCheckResult to read through to the registered checker, got %+v", result)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/tts"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/ssh"
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
@@ -51,6 +52,8 @@ type Server struct {
|
||||
recordEnabled bool
|
||||
discoveryInterval time.Duration
|
||||
discoveryEnabled bool
|
||||
updateCheckInterval time.Duration // live update-check interval; see SetUpdateCheckSettings
|
||||
updateCheckEnabled bool // live update-check opt-in; defaults off (#591)
|
||||
dnsEnabled bool
|
||||
dnsUpstream []string
|
||||
dnsBindAddr string
|
||||
@@ -70,6 +73,7 @@ type Server struct {
|
||||
mgmtPassword string
|
||||
adminAreaAuth string // "" (unset) / "enabled" / "disabled" — see datastore.Settings.AdminAreaAuth
|
||||
dismissedAnnouncements map[string]time.Time // announcement id -> most recent dismissal; see RecordDismissal
|
||||
updateChecker *updatecheck.Checker // the HTTP-checking object; nil unless SetUpdateChecker was called
|
||||
spotifyClientID string
|
||||
spotifyClientSecret string
|
||||
spotifyRedirectURI string
|
||||
@@ -139,10 +143,14 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red
|
||||
recordEnabled: recordEnabled,
|
||||
discoveryInterval: 5 * time.Minute,
|
||||
discoveryEnabled: true,
|
||||
peerObserver: newPeerObserver(),
|
||||
healthRegistry: health.NewRegistry(),
|
||||
authProbes: newAuthProbeRegistry(defaultAuthProbeTTL),
|
||||
deprecatedRoutes: newDeprecatedRouteTracker(),
|
||||
// The update check is opt-in (#591): only the interval gets a default,
|
||||
// updateCheckEnabled stays false so no install starts making outbound
|
||||
// GitHub calls without an explicit yes.
|
||||
updateCheckInterval: 24 * time.Hour,
|
||||
peerObserver: newPeerObserver(),
|
||||
healthRegistry: health.NewRegistry(),
|
||||
authProbes: newAuthProbeRegistry(defaultAuthProbeTTL),
|
||||
deprecatedRoutes: newDeprecatedRouteTracker(),
|
||||
}
|
||||
|
||||
health.RegisterSourcesXMLPresent(s.healthRegistry, ds)
|
||||
@@ -581,6 +589,28 @@ func (s *Server) SetDiscoverySettings(interval time.Duration, enabled bool) {
|
||||
s.discoveryEnabled = enabled
|
||||
}
|
||||
|
||||
// SetUpdateCheckSettings sets the live update-check settings for the server.
|
||||
//
|
||||
// Kept adjacent to its getter (rather than next to GetDiscoverySettings
|
||||
// further down) so the pair reads as one unit; the background goroutine in
|
||||
// soundtouch-service re-reads them on every poll, which is what makes the
|
||||
// Settings-page toggle take effect without a restart.
|
||||
func (s *Server) SetUpdateCheckSettings(interval time.Duration, enabled bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.updateCheckInterval = interval
|
||||
s.updateCheckEnabled = enabled
|
||||
}
|
||||
|
||||
// GetUpdateCheckSettings returns the current update-check interval and enabled state.
|
||||
func (s *Server) GetUpdateCheckSettings() (time.Duration, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
return s.updateCheckInterval, s.updateCheckEnabled
|
||||
}
|
||||
|
||||
// SetDevicesChangedHook registers a callback fired after the known device set
|
||||
// changes (a discovery sweep or a manual add). The embedded web UI uses it to
|
||||
// re-sync its registry from the shared datastore — the single source of truth —
|
||||
@@ -1126,6 +1156,34 @@ func (s *Server) IsAnnouncementDismissed(id string) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
// SetUpdateChecker registers the update checker (#591). The checker itself is
|
||||
// always constructed and registered, regardless of whether the periodic check
|
||||
// is enabled, so /api/setup/version and the Announcements banner can read
|
||||
// LastResult() (e.g. a result persisted by an earlier run) even before the
|
||||
// periodic check has ever run. Only the periodic background check is gated by
|
||||
// the live enabled setting — see SetUpdateCheckSettings. Callers that leave
|
||||
// this nil are still safe: UpdateCheckResult returns the zero value.
|
||||
func (s *Server) SetUpdateChecker(c *updatecheck.Checker) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.updateChecker = c
|
||||
}
|
||||
|
||||
// UpdateCheckResult returns the last known update-check result, or the
|
||||
// zero value (Available: false) if the check was never enabled.
|
||||
func (s *Server) UpdateCheckResult() updatecheck.Result {
|
||||
s.mu.RLock()
|
||||
checker := s.updateChecker
|
||||
s.mu.RUnlock()
|
||||
|
||||
if checker == nil {
|
||||
return updatecheck.Result{}
|
||||
}
|
||||
|
||||
return checker.LastResult()
|
||||
}
|
||||
|
||||
// SetInternalPaths sets the internal paths for the server.
|
||||
func (s *Server) SetInternalPaths(paths []string) {
|
||||
s.mu.Lock()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -308,6 +308,35 @@
|
||||
<div style="margin-left: 20px">
|
||||
<label for="discovery-interval">Discovery Interval:</label>
|
||||
<input type="text" id="discovery-interval" placeholder="5m" style="width: 100px"/>
|
||||
<span class="info-toggle" onclick="toggleInfo('discovery-interval-info')">ⓘ</span>
|
||||
<div id="discovery-interval-info" class="info-details">
|
||||
Go duration syntax: a number followed by a unit, optionally combined
|
||||
(e.g. <code>5m</code>, <code>90s</code>, <code>1h30m</code>).<br/>
|
||||
Units: <code>h</code> (hours), <code>m</code> (minutes), <code>s</code> (seconds).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px">
|
||||
<strong>Update Check:</strong>
|
||||
<div style="margin-top: 5px">
|
||||
<label style="display: block; margin-bottom: 5px">
|
||||
<input type="checkbox" id="update-check-enabled"/> Check GitHub for new releases
|
||||
</label>
|
||||
<div style="margin-left: 20px">
|
||||
<label for="update-check-interval">Check Interval:</label>
|
||||
<input type="text" id="update-check-interval" placeholder="24h" style="width: 100px"/>
|
||||
<span class="info-toggle" onclick="toggleInfo('update-check-interval-info')">ⓘ</span>
|
||||
<div id="update-check-interval-info" class="info-details">
|
||||
Go duration syntax: a number followed by a unit, optionally combined
|
||||
(e.g. <code>24h</code>, <code>90m</code>, <code>1h30m</code>, <code>45s</code>).<br/>
|
||||
Units: <code>h</code> (hours), <code>m</code> (minutes), <code>s</code> (seconds).
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size: 0.8em; color: #666; margin-top: 4px;">
|
||||
Makes one unauthenticated GET request to api.github.com per interval when enabled.
|
||||
No other data leaves this install. Applies live, no restart needed — takes effect
|
||||
within a minute (worst case).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -504,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"
|
||||
@@ -662,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>
|
||||
@@ -710,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"
|
||||
@@ -731,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>
|
||||
@@ -746,6 +811,30 @@
|
||||
</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="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. -->
|
||||
@@ -779,7 +868,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
|
||||
@@ -790,25 +881,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>
|
||||
@@ -850,13 +929,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>
|
||||
@@ -934,7 +1007,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>
|
||||
@@ -946,7 +1019,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>
|
||||
@@ -958,7 +1031,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>
|
||||
@@ -970,7 +1043,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>
|
||||
@@ -1038,6 +1111,7 @@
|
||||
<button
|
||||
type="button"
|
||||
id="plan-apply-btn"
|
||||
class="btn-primary"
|
||||
onclick="applySuggestedPlan()"
|
||||
style="font-size: 0.95em"
|
||||
>Apply Suggested Plan</button>
|
||||
@@ -1121,8 +1195,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>
|
||||
@@ -1207,64 +1282,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>
|
||||
|
||||
@@ -354,6 +354,12 @@ async function fetchSettings() {
|
||||
if (settings.discovery_enabled !== undefined) {
|
||||
document.getElementById("discovery-enabled").checked = settings.discovery_enabled;
|
||||
}
|
||||
if (settings.update_check_interval) {
|
||||
document.getElementById("update-check-interval").value = settings.update_check_interval;
|
||||
}
|
||||
if (settings.update_check_enabled !== undefined) {
|
||||
document.getElementById("update-check-enabled").checked = settings.update_check_enabled;
|
||||
}
|
||||
if (settings.default_landing) {
|
||||
document.getElementById("default-landing").value = settings.default_landing;
|
||||
}
|
||||
@@ -504,6 +510,8 @@ async function updateSettings() {
|
||||
admin_area_auth: document.getElementById("admin-area-auth").value,
|
||||
discovery_interval: document.getElementById("discovery-interval").value,
|
||||
discovery_enabled: document.getElementById("discovery-enabled").checked,
|
||||
update_check_interval: document.getElementById("update-check-interval").value,
|
||||
update_check_enabled: document.getElementById("update-check-enabled").checked,
|
||||
dns_enabled: document.getElementById("dns-enabled").checked,
|
||||
dns_upstream: document.getElementById("dns-upstream").value,
|
||||
dns_bind_addr: document.getElementById("dns-bind").value,
|
||||
@@ -590,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;
|
||||
@@ -604,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;
|
||||
@@ -641,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;
|
||||
@@ -799,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) {
|
||||
@@ -818,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) {
|
||||
@@ -851,7 +995,7 @@ async function fetchAnnouncements() {
|
||||
|
||||
container.innerHTML = announcements.map(a => `
|
||||
<div class="announcement-banner announcement-${a.level || "info"}" data-announcement-id="${a.id}" style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; padding:10px 14px; margin-bottom:10px; border-radius:4px; background:#e7f3ff; border:1px solid #b6d9f7; color:#1a4a6e; font-size:0.9em;">
|
||||
<span>${a.message}</span>
|
||||
<span>${escapeHtml(a.message)}${a.link_url ? ` <a href="${escapeHtml(a.link_url)}" target="_blank" rel="noopener" style="color:inherit; text-decoration:underline;">${escapeHtml(a.link_text || a.link_url)}</a>` : ""}</span>
|
||||
<button onclick="dismissAnnouncement('${a.id}')" title="Dismiss" style="background:none; border:none; cursor:pointer; font-size:1.1em; line-height:1; color:inherit; flex-shrink:0;">×</button>
|
||||
</div>
|
||||
`).join("");
|
||||
@@ -916,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);
|
||||
}
|
||||
@@ -941,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();
|
||||
@@ -956,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>
|
||||
@@ -975,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") {
|
||||
@@ -983,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>
|
||||
@@ -995,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>
|
||||
@@ -1016,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",
|
||||
@@ -1060,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",
|
||||
@@ -1102,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>
|
||||
|
||||
@@ -1142,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("")}
|
||||
@@ -1167,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>
|
||||
@@ -1188,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>"}
|
||||
@@ -1198,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);
|
||||
}
|
||||
}
|
||||
@@ -1503,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;
|
||||
|
||||
@@ -1759,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;
|
||||
|
||||
@@ -1899,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",
|
||||
});
|
||||
|
||||
@@ -2088,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);
|
||||
@@ -2142,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";
|
||||
@@ -2550,18 +2721,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 {
|
||||
@@ -2768,6 +2936,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
|
||||
@@ -2830,8 +3018,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],
|
||||
@@ -2842,7 +3034,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();
|
||||
}
|
||||
@@ -2874,7 +3069,16 @@ function readPlanURLOptions() {
|
||||
// on the speaker itself). For the typical "AfterTouch on a separate
|
||||
// host" deployment, the speaker can't reach loopback on a different
|
||||
// machine, so the URL must be a LAN-reachable IP or hostname.
|
||||
function validateURL(value) {
|
||||
//
|
||||
// referenceOrigin (optional) is the plan's own Target URL origin. A
|
||||
// loopback value that matches it is exempted from the warning: it means
|
||||
// this is exactly what the service itself is already configured to
|
||||
// answer as (e.g. an on-device install's `http://localhost:8000`,
|
||||
// auto-set since #546), not a mistaken paste. Without this exemption,
|
||||
// every on-device install's Suggested Plan fails validation by
|
||||
// default and silently disables Apply/Pre-flight before the user does
|
||||
// anything (#546 follow-up, reported via #621).
|
||||
function validateURL(value, referenceOrigin) {
|
||||
const v = (value || "").trim();
|
||||
if (!v) return {ok: true, error: ""};
|
||||
|
||||
@@ -2891,7 +3095,8 @@ function validateURL(value) {
|
||||
|
||||
if (!u.hostname) return {ok: false, error: "hostname is empty"};
|
||||
|
||||
if (u.hostname === "localhost" || u.hostname === "127.0.0.1") {
|
||||
const isLoopback = u.hostname === "localhost" || u.hostname === "127.0.0.1";
|
||||
if (isLoopback && u.origin !== referenceOrigin) {
|
||||
return {ok: false, error: "loopback URL — speakers can only reach this if AfterTouch is installed on the speaker itself (on-device install). For the typical multi-device setup, use a LAN-reachable IP or hostname."};
|
||||
}
|
||||
|
||||
@@ -2910,12 +3115,23 @@ function validatePlanURLs() {
|
||||
["bmxRegistryUrl", "plan-bmx-url"],
|
||||
];
|
||||
|
||||
const targetUrl = (document.getElementById("plan-target-url") || {}).value || "";
|
||||
let referenceOrigin = "";
|
||||
try {
|
||||
referenceOrigin = new URL(targetUrl).origin;
|
||||
} catch (e) {
|
||||
// Target URL isn't a valid absolute URL yet (e.g. empty) — leave
|
||||
// referenceOrigin empty, so a loopback field simply won't match
|
||||
// it and falls back to today's warning, same as before this
|
||||
// exemption existed.
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
|
||||
for (const [name, elemId] of fields) {
|
||||
const el = document.getElementById(elemId);
|
||||
if (!el) continue;
|
||||
const v = validateURL(el.value);
|
||||
const v = validateURL(el.value, referenceOrigin);
|
||||
el.style.borderColor = v.ok ? "" : "#c62828";
|
||||
if (!v.ok) errors.push(`${name}: ${v.error}`);
|
||||
}
|
||||
@@ -3773,7 +3989,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");
|
||||
@@ -3854,16 +4074,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");
|
||||
@@ -3985,9 +4208,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) {
|
||||
@@ -4717,14 +4953,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>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4929,12 +5165,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
|
||||
|
||||
|
||||
@@ -4,8 +4,12 @@ import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
@@ -13,6 +17,11 @@ import (
|
||||
// preset count check.
|
||||
const CheckIDPresetsCount = "speaker_presets_count"
|
||||
|
||||
// FixIDRestorePresetsToSpeaker is the quick-fix that replays the
|
||||
// service's stored presets onto the speaker via its :8090/storePreset
|
||||
// endpoint, without requiring a reboot or re-entering them by hand.
|
||||
const FixIDRestorePresetsToSpeaker = "restore_presets_to_speaker"
|
||||
|
||||
// speakerPresetsXML mirrors just enough of the speaker's :8090/presets
|
||||
// XML to count slots. The schema is the same as on the service side
|
||||
// but with <ContentItem> (capitalised) inside <preset>.
|
||||
@@ -37,6 +46,21 @@ func RegisterPresetsCountCheck(r *Registry, ds *datastore.DataStore) {
|
||||
return runPresetsCountCheck(ds)
|
||||
},
|
||||
})
|
||||
|
||||
r.RegisterFix(CheckIDPresetsCount, FixIDRestorePresetsToSpeaker, func(target Target) (string, error) {
|
||||
return restorePresetsToSpeaker(ds, target)
|
||||
})
|
||||
|
||||
// Same underlying nudge as the refresh_sources check (FixIDPostSourcesUpdated,
|
||||
// checks_refresh_sources.go): POSTs sourcesUpdated so the speaker re-fetches
|
||||
// /full. Confirmed that /full carries presets alongside sources
|
||||
// (marge.AccountFullToXML); NOT confirmed that firmware re-applies the
|
||||
// presets section locally (see issue253_regression_test.go — that exact
|
||||
// link is documented as untested). Offered as a cheap, non-destructive
|
||||
// thing to try before the guaranteed-but-heavier restore-to-speaker push.
|
||||
r.RegisterFix(CheckIDPresetsCount, FixIDPostSourcesUpdated, func(target Target) (string, error) {
|
||||
return postSourcesUpdated(ds, target)
|
||||
})
|
||||
}
|
||||
|
||||
func runPresetsCountCheck(ds *datastore.DataStore) []Finding {
|
||||
@@ -127,11 +151,27 @@ func comparePresetsForDeviceWithURL(ds *datastore.DataStore, account, deviceID,
|
||||
}
|
||||
|
||||
severity := SeverityInfo
|
||||
|
||||
var quickFixes []QuickFix
|
||||
|
||||
if speakerCount == 0 && serviceCount > 0 {
|
||||
// Speaker shows nothing while the service has presets —
|
||||
// this is the post-reset preset-loss class from
|
||||
// discussion #295 and #235.
|
||||
// the post-reset preset-loss pattern confirmed in #614
|
||||
// (reboot and/or Sync leaving the speaker's own preset
|
||||
// slots empty while the service's Presets.xml is untouched).
|
||||
severity = SeverityWarning
|
||||
quickFixes = []QuickFix{
|
||||
{
|
||||
ID: FixIDPostSourcesUpdated,
|
||||
Label: "Try a sourcesUpdated nudge first",
|
||||
Confirm: "Asks the speaker to re-fetch /full (the same nudge used to refresh sources). /full does include presets, but whether the speaker applies them back to its own preset table isn't confirmed — this is free and non-destructive, worth trying before the push below.",
|
||||
},
|
||||
{
|
||||
ID: FixIDRestorePresetsToSpeaker,
|
||||
Label: "Restore presets to speaker",
|
||||
Confirm: "This pushes AfterTouch's stored presets onto the speaker's own preset slots, one at a time. Doesn't require a reboot.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return []Finding{{
|
||||
@@ -141,10 +181,86 @@ func comparePresetsForDeviceWithURL(ds *datastore.DataStore, account, deviceID,
|
||||
"Speaker shows %d preset slot(s); service Presets.xml has %d.",
|
||||
speakerCount, serviceCount,
|
||||
),
|
||||
Details: "If the speaker shows fewer than the service, a power-cycle or a sourcesUpdated notification usually re-syncs. If it shows more, the service may have stale entries or the speaker is still holding pre-migration state.",
|
||||
Details: "If the speaker shows fewer than the service, a sourcesUpdated notification sometimes re-syncs it. Don't power-cycle as a fix for this — it has itself been reported to wipe the speaker's presets (#614), so it may make things worse. If the speaker shows more than the service, the service may have stale entries or the speaker is still holding pre-migration state.",
|
||||
QuickFixes: quickFixes,
|
||||
}}
|
||||
}
|
||||
|
||||
// restorePresetsToSpeaker replays every preset in the service's
|
||||
// Presets.xml onto the live speaker via :8090/storePreset, one slot
|
||||
// at a time. Unlike Sync (which only ever reads from the speaker),
|
||||
// this is the one direction that can put presets back after they've
|
||||
// been wiped, without needing to re-enter them by hand — see #614.
|
||||
func restorePresetsToSpeaker(ds *datastore.DataStore, target Target) (string, error) {
|
||||
if target.Account == "" || target.Device == "" {
|
||||
return "", fmt.Errorf("account and device are required")
|
||||
}
|
||||
|
||||
dev, err := ds.GetDeviceInfo(target.Account, target.Device)
|
||||
if err != nil || dev == nil {
|
||||
return "", fmt.Errorf("device %s not found in datastore", target.Device)
|
||||
}
|
||||
|
||||
if dev.IPAddress == "" {
|
||||
return "", fmt.Errorf("device %s has no IP address recorded", target.Device)
|
||||
}
|
||||
|
||||
presets, err := ds.GetPresets(target.Account, target.Device)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read service Presets.xml: %w", err)
|
||||
}
|
||||
|
||||
if len(presets) == 0 {
|
||||
return "", fmt.Errorf("service has no presets recorded for %s", target.Device)
|
||||
}
|
||||
|
||||
c := client.NewClientFromHost(dev.IPAddress)
|
||||
|
||||
restored := 0
|
||||
|
||||
var failures []string
|
||||
|
||||
for i := range presets {
|
||||
p := &presets[i]
|
||||
|
||||
slot, atoiErr := strconv.Atoi(p.ID)
|
||||
if atoiErr != nil || slot < 1 || slot > 6 {
|
||||
failures = append(failures, fmt.Sprintf("slot %q: invalid preset id", p.ID))
|
||||
continue
|
||||
}
|
||||
|
||||
isPresetable, _ := strconv.ParseBool(p.IsPresetable)
|
||||
|
||||
ci := &models.ContentItem{
|
||||
Source: p.Source,
|
||||
Type: p.Type,
|
||||
Location: p.Location,
|
||||
SourceAccount: p.SourceAccount,
|
||||
IsPresetable: isPresetable,
|
||||
ItemName: p.Name,
|
||||
ContainerArt: p.ContainerArt,
|
||||
}
|
||||
|
||||
if storeErr := c.StorePreset(slot, ci); storeErr != nil {
|
||||
failures = append(failures, fmt.Sprintf("slot %d: %v", slot, storeErr))
|
||||
continue
|
||||
}
|
||||
|
||||
restored++
|
||||
}
|
||||
|
||||
if restored == 0 {
|
||||
return "", fmt.Errorf("failed to restore any presets: %s", strings.Join(failures, "; "))
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("Restored %d/%d preset(s) to %s.", restored, len(presets), displayName(dev.Name, target.Device))
|
||||
if len(failures) > 0 {
|
||||
msg += " Some slots failed: " + strings.Join(failures, "; ")
|
||||
}
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// countNonEmpty returns the number of <preset> entries with a
|
||||
// non-empty id. Empty slots in the speaker's response (e.g. the
|
||||
// six fixed buttons with no programmed preset) are not counted.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -174,6 +175,121 @@ func TestPresetsCount_UnreachableSpeaker(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresetsCount_SpeakerEmptyOffersRestoreQuickFix(t *testing.T) {
|
||||
account, device := "1000001", "DEVICEID01"
|
||||
|
||||
ds := newPresetsCountDS(t, account, device)
|
||||
writeServicePresets(t, ds, account, device, 3)
|
||||
|
||||
probeURL := stubSpeakerPresetsServer(t, 0)
|
||||
|
||||
got := comparePresetsForDeviceWithURL(ds, account, device, probeURL)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected one finding, got %+v", got)
|
||||
}
|
||||
|
||||
// Offers both the cheap, unconfirmed pull-style nudge (sourcesUpdated,
|
||||
// which makes the speaker re-fetch /full — /full does carry presets,
|
||||
// but firmware re-applying them locally is unconfirmed) and the
|
||||
// guaranteed push (restore_presets_to_speaker) — see #614 discussion.
|
||||
fixIDs := map[string]bool{}
|
||||
for _, qf := range got[0].QuickFixes {
|
||||
fixIDs[qf.ID] = true
|
||||
}
|
||||
|
||||
if len(got[0].QuickFixes) != 2 || !fixIDs[FixIDRestorePresetsToSpeaker] || !fixIDs[FixIDPostSourcesUpdated] {
|
||||
t.Errorf("expected both the sourcesUpdated nudge and the restore-presets QuickFix, got %+v", got[0].QuickFixes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresetsCount_SpeakerHasMoreOffersNoQuickFix(t *testing.T) {
|
||||
account, device := "1000001", "DEVICEID01"
|
||||
|
||||
ds := newPresetsCountDS(t, account, device)
|
||||
writeServicePresets(t, ds, account, device, 1)
|
||||
|
||||
probeURL := stubSpeakerPresetsServer(t, 3)
|
||||
|
||||
got := comparePresetsForDeviceWithURL(ds, account, device, probeURL)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected one finding, got %+v", got)
|
||||
}
|
||||
|
||||
if len(got[0].QuickFixes) != 0 {
|
||||
t.Errorf("expected no QuickFix when speaker has more than the service, got %+v", got[0].QuickFixes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestorePresetsToSpeaker_PushesEachSlot(t *testing.T) {
|
||||
account, device := "1000001", "DEVICEID01"
|
||||
|
||||
ds := newPresetsCountDS(t, account, device)
|
||||
writeServicePresets(t, ds, account, device, 3)
|
||||
|
||||
var storedSlots []string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/storePreset" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
storedSlots = append(storedSlots, string(body))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
if err := ds.SaveDeviceInfo(account, device, &models.ServiceDeviceInfo{
|
||||
DeviceID: device,
|
||||
AccountID: account,
|
||||
IPAddress: u.Host,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
msg, err := restorePresetsToSpeaker(ds, Target{Account: account, Device: device})
|
||||
if err != nil {
|
||||
t.Fatalf("restorePresetsToSpeaker: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(msg, "3/3") {
|
||||
t.Errorf("expected message to report 3/3 restored, got %q", msg)
|
||||
}
|
||||
|
||||
if len(storedSlots) != 3 {
|
||||
t.Fatalf("expected 3 /storePreset calls, got %d", len(storedSlots))
|
||||
}
|
||||
|
||||
for i, body := range storedSlots {
|
||||
if !strings.Contains(body, `id="`+itoa(i+1)+`"`) {
|
||||
t.Errorf("call %d: expected preset id %d in body, got %q", i, i+1, body)
|
||||
}
|
||||
|
||||
if !strings.Contains(body, `source="TUNEIN"`) {
|
||||
t.Errorf("call %d: expected source TUNEIN in body, got %q", i, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestorePresetsToSpeaker_NoServicePresets(t *testing.T) {
|
||||
account, device := "1000001", "DEVICEID01"
|
||||
ds := newPresetsCountDS(t, account, device)
|
||||
|
||||
if err := ds.SaveDeviceInfo(account, device, &models.ServiceDeviceInfo{
|
||||
DeviceID: device,
|
||||
AccountID: account,
|
||||
IPAddress: "127.0.0.1:1",
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
if _, err := restorePresetsToSpeaker(ds, Target{Account: account, Device: device}); err == nil {
|
||||
t.Error("expected an error when the service has no presets to restore")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresetsCount_MalformedXML(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("nope"))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+131
-88
@@ -752,6 +752,13 @@ func CreateAccountDevice(ds *datastore.DataStore, account, deviceID string) (mod
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1501,19 +1508,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 +1528,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 +1605,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 +1670,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 +1811,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 +1855,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 +1896,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 +2046,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 +2449,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 +2478,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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package marge
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
@@ -784,3 +786,172 @@ func TestAccountFullToXML_WithBackupStructure(t *testing.T) {
|
||||
t.Errorf("Expected <name/> or <name></name> or fallback name, got %s", string(fullXML2))
|
||||
}
|
||||
}
|
||||
|
||||
// extractSourceFragment returns the raw `<source id="id" ...>...</source>`
|
||||
// substring for one source out of a rendered account document. Tests need the
|
||||
// raw wire text, not an unmarshaled struct, because Go's XML decoder can't
|
||||
// distinguish "element present but empty" from "element absent" — and that
|
||||
// distinction is exactly what has broken parsing on real speakers before
|
||||
// (issue #195, #334).
|
||||
func extractSourceFragment(t *testing.T, doc, id string) string {
|
||||
t.Helper()
|
||||
|
||||
marker := `<source id="` + id + `"`
|
||||
|
||||
start := strings.Index(doc, marker)
|
||||
if start < 0 {
|
||||
t.Fatalf("source id=%q not found in document:\n%s", id, doc)
|
||||
}
|
||||
|
||||
end := strings.Index(doc[start:], "</source>")
|
||||
if end < 0 {
|
||||
t.Fatalf("source id=%q has no closing </source>:\n%s", id, doc)
|
||||
}
|
||||
|
||||
return doc[start : start+end+len("</source>")]
|
||||
}
|
||||
|
||||
// xmlElementNames returns the ordered sequence of start-tag element names in
|
||||
// an XML fragment (attributes and closing tags are ignored). Used to compare
|
||||
// the "shape" of two rendered <source> entries without caring about their
|
||||
// differing content.
|
||||
func xmlElementNames(fragment string) []string {
|
||||
var out []string
|
||||
|
||||
for _, part := range strings.Split(fragment, "<") {
|
||||
if i := strings.IndexAny(part, " >/"); i > 0 {
|
||||
out = append(out, part[:i])
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// TestSourceXMLShapeConsistencyAcrossTypes guards against a known Bose
|
||||
// firmware failure mode: a <source> entry that omits an element the firmware
|
||||
// expects makes the speaker reject the *whole* account document, not just
|
||||
// that entry (see the AccountFullToXML sourceproviderid comment above, and
|
||||
// issues #195/#334). A newly added source type (here STORED_MUSIC, as used by
|
||||
// the DLNA/UPnP media-library feature) must render with the exact same
|
||||
// element set, in the same order, as an existing known-good default source —
|
||||
// content may legitimately differ, element names may not.
|
||||
func TestSourceXMLShapeConsistencyAcrossTypes(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "marge-test-shape-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
account := "7654321"
|
||||
device := "AABBCCDDEE0B"
|
||||
|
||||
info := &models.ServiceDeviceInfo{
|
||||
DeviceID: device,
|
||||
Name: "Office SoundTouch",
|
||||
}
|
||||
_ = ds.SaveDeviceInfo(account, device, info)
|
||||
_ = ds.SavePresets(account, device, []models.ServicePreset{})
|
||||
_ = ds.SaveRecents(account, device, []models.ServiceRecent{})
|
||||
|
||||
// A STORED_MUSIC entry as HandleAddLibraryServer's registration flow would
|
||||
// produce it: no SourceProviderID set explicitly, so PrepareConfiguredSource
|
||||
// must resolve it via constants.StaticProviders at render time, exactly like
|
||||
// a freshly registered DLNA media server would.
|
||||
stored := models.ConfiguredSource{
|
||||
ID: "20001",
|
||||
DisplayName: "FRITZ!Mediaserver",
|
||||
Type: "Audio",
|
||||
Name: "FRITZ!Mediaserver",
|
||||
SourceName: constants.ProviderStoredMusic,
|
||||
Username: "fa095ecc-uuid/0",
|
||||
}
|
||||
stored.SourceKey.Type = constants.ProviderStoredMusic
|
||||
stored.SourceKey.Account = "fa095ecc-uuid/0"
|
||||
stored.SourceKeyType = constants.ProviderStoredMusic
|
||||
stored.SourceKeyAccount = "fa095ecc-uuid/0"
|
||||
|
||||
if err := ds.SaveConfiguredSources(account, device, []models.ConfiguredSource{stored}); err != nil {
|
||||
t.Fatalf("SaveConfiguredSources: %v", err)
|
||||
}
|
||||
|
||||
tunein := strconv.Itoa(constants.TuneinProviderID)
|
||||
storedMusicID := strconv.Itoa(constants.StoredMusicProviderID)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
render func() ([]byte, error)
|
||||
}{
|
||||
{"AccountFullToXML", func() ([]byte, error) { return AccountFullToXML(ds, account) }},
|
||||
{"AccountSourcesToXML", func() ([]byte, error) { return AccountSourcesToXML(ds, account) }},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
data, err := tc.render()
|
||||
if err != nil {
|
||||
t.Fatalf("%s failed: %v", tc.name, err)
|
||||
}
|
||||
|
||||
var sources []models.FullResponseSource
|
||||
|
||||
switch tc.name {
|
||||
case "AccountFullToXML":
|
||||
var resp models.AccountFullResponse
|
||||
if uerr := xml.Unmarshal(data, &resp); uerr != nil {
|
||||
t.Fatalf("%s: whole-document unmarshal failed: %v\n%s", tc.name, uerr, data)
|
||||
}
|
||||
sources = resp.Sources
|
||||
case "AccountSourcesToXML":
|
||||
var resp models.AccountSourcesResponse
|
||||
if uerr := xml.Unmarshal(data, &resp); uerr != nil {
|
||||
t.Fatalf("%s: whole-document unmarshal failed: %v\n%s", tc.name, uerr, data)
|
||||
}
|
||||
sources = resp.Sources
|
||||
}
|
||||
|
||||
// Defaults minus AUX (filtered out of /full and /sources on purpose,
|
||||
// see the getAccountSources comment) plus our one extra STORED_MUSIC entry.
|
||||
wantCount := len(ds.GetInitialSources()) - 1 + 1
|
||||
if len(sources) != wantCount {
|
||||
t.Fatalf("%s: got %d sources, want %d:\n%s", tc.name, len(sources), wantCount, data)
|
||||
}
|
||||
|
||||
var tuneinSource, storedMusicSource *models.FullResponseSource
|
||||
for i := range sources {
|
||||
switch sources[i].SourceProviderID {
|
||||
case tunein:
|
||||
tuneinSource = &sources[i]
|
||||
case storedMusicID:
|
||||
storedMusicSource = &sources[i]
|
||||
}
|
||||
}
|
||||
|
||||
if tuneinSource == nil {
|
||||
t.Fatalf("%s: TUNEIN source missing from rendered document:\n%s", tc.name, data)
|
||||
}
|
||||
if storedMusicSource == nil {
|
||||
t.Fatalf("%s: STORED_MUSIC source missing, or its sourceproviderid did not resolve to %q:\n%s", tc.name, storedMusicID, data)
|
||||
}
|
||||
if storedMusicSource.ID == tuneinSource.ID {
|
||||
t.Errorf("%s: STORED_MUSIC source id %q collides with a default source id", tc.name, storedMusicSource.ID)
|
||||
}
|
||||
|
||||
tuneinFragment := extractSourceFragment(t, string(data), tuneinSource.ID)
|
||||
storedFragment := extractSourceFragment(t, string(data), storedMusicSource.ID)
|
||||
|
||||
wantShape := xmlElementNames(tuneinFragment)
|
||||
gotShape := xmlElementNames(storedFragment)
|
||||
|
||||
if len(wantShape) != len(gotShape) {
|
||||
t.Fatalf("%s: element count differs from a known-good default source:\n default (TUNEIN): %v\n STORED_MUSIC: %v", tc.name, wantShape, gotShape)
|
||||
}
|
||||
|
||||
for i := range wantShape {
|
||||
if wantShape[i] != gotShape[i] {
|
||||
t.Errorf("%s: element %d differs: default (TUNEIN) %q, STORED_MUSIC %q", tc.name, i, wantShape[i], gotShape[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,21 @@ func (m *Manager) ResetBoseURLs(deviceIP, serviceURL string) (string, error) {
|
||||
return m.setBoseURLsViaTelnet(deviceIP, serviceURL, serviceURL+"/update")
|
||||
}
|
||||
|
||||
// DefaultTelnetCommandDelay is the pause between successive commands in
|
||||
// EnableSSHViaTelnetFullConfig's sequence. Originally set based on #515
|
||||
// comment 5228449448 (same six commands, back-to-back left sshd down after
|
||||
// reboot but succeeded with ~7s gaps). That inter-command-delay theory was
|
||||
// RETRACTED by the same reporter after a controlled A/B on three variants
|
||||
// (issue comment 5231931569): back-to-back and 5s-gapped runs produced
|
||||
// identical results (all writes applied, confirmed via verified reboots),
|
||||
// so the delay itself does not appear to be the mechanism — the likely real
|
||||
// gate was the account-pairing precondition (see EnsureMargeAccountPaired),
|
||||
// fixed independently. The flag is kept at a small non-zero default (3s)
|
||||
// as a low-cost hedge for firmware variants nobody has A/B-tested yet
|
||||
// (only lisa/mojo/spotty/ginger/taigan are confirmed); 0 sends everything
|
||||
// back-to-back.
|
||||
const DefaultTelnetCommandDelay = 3 * time.Second
|
||||
|
||||
// EnableSSHViaTelnetFullConfig is the #515 variant of EnableSSHViaTelnet for
|
||||
// devices where the single-envswitch injection is accepted and persisted but
|
||||
// sshd never starts (ST Portable, CineMate 520; see also memory note #471). It
|
||||
@@ -43,13 +58,15 @@ func (m *Manager) ResetBoseURLs(deviceIP, serviceURL string) (string, error) {
|
||||
// writes all four `sys configuration` URL keys with the remote_services
|
||||
// injection on margeServerUrl (the runtime layer, not just the envswitch
|
||||
// persistence layer), mirrors the injection into `envswitch boseurls set`, and
|
||||
// verifies with getpdo. The caller should reboot afterwards (the injection
|
||||
// fires on the speaker's next full config re-parse at boot) and then
|
||||
// WaitForSSHPort.
|
||||
// verifies with getpdo. The caller should pause commandDelay again, reboot
|
||||
// (the injection fires on the speaker's next full config re-parse at boot),
|
||||
// and then WaitForSSHPort.
|
||||
//
|
||||
// serviceURL is the AfterTouch service base the speaker should point at
|
||||
// (e.g. https://192.0.2.10:8443). It must not contain a double quote.
|
||||
func (m *Manager) EnableSSHViaTelnetFullConfig(deviceIP, serviceURL string) (string, error) {
|
||||
// commandDelay is the pause between each command (see
|
||||
// DefaultTelnetCommandDelay); 0 sends them back-to-back.
|
||||
func (m *Manager) EnableSSHViaTelnetFullConfig(deviceIP, serviceURL string, commandDelay time.Duration) (string, error) {
|
||||
u := defaultTelnetURLs(serviceURL)
|
||||
margeInjected := serviceURL + remoteServicesInjection
|
||||
|
||||
@@ -65,16 +82,18 @@ func (m *Manager) EnableSSHViaTelnetFullConfig(deviceIP, serviceURL string) (str
|
||||
`envswitch boseurls set "` + margeInjected + `" "` + u.SwUpdate + `"`,
|
||||
}
|
||||
|
||||
return m.runTelnetInjection(deviceIP, []string{serviceURL, u.SwUpdate}, cmds)
|
||||
return m.runTelnetInjection(deviceIP, []string{serviceURL, u.SwUpdate}, cmds, commandDelay)
|
||||
}
|
||||
|
||||
// runTelnetInjection opens the port-17000 shell, runs an ordered list of
|
||||
// commands (aborting on the first transport error or "command not found"
|
||||
// rejection), then logs a getpdo verification. forbidQuote values are checked
|
||||
// for an embedded double quote, which would break the command parsing.
|
||||
// Verification is best-effort (logged, never fatal) to match enable-ssh's
|
||||
// forgiving philosophy and tolerate the aftertouch.invalid placeholder.
|
||||
func (m *Manager) runTelnetInjection(deviceIP string, forbidQuote, cmds []string) (string, error) {
|
||||
// rejection), pausing commandDelay after each one (see
|
||||
// DefaultTelnetCommandDelay), then logs a getpdo verification. forbidQuote
|
||||
// values are checked for an embedded double quote, which would break the
|
||||
// command parsing. Verification is best-effort (logged, never fatal) to
|
||||
// match enable-ssh's forgiving philosophy and tolerate the
|
||||
// aftertouch.invalid placeholder.
|
||||
func (m *Manager) runTelnetInjection(deviceIP string, forbidQuote, cmds []string, commandDelay time.Duration) (string, error) {
|
||||
if m.NewTelnet == nil {
|
||||
return "", errors.New("telnet not configured: Manager.NewTelnet is nil")
|
||||
}
|
||||
@@ -109,10 +128,14 @@ func (m *Manager) runTelnetInjection(deviceIP string, forbidQuote, cmds []string
|
||||
if isCommandNotFound(resp) {
|
||||
return logs.String(), fmt.Errorf("device rejected %q (firmware does not expose this command)", cmd)
|
||||
}
|
||||
|
||||
if commandDelay > 0 {
|
||||
time.Sleep(commandDelay)
|
||||
}
|
||||
}
|
||||
|
||||
if verify, err := t.SendCommand("getpdo CurrentSystemConfiguration"); err == nil {
|
||||
fmt.Fprintf(&logs, "→ getpdo CurrentSystemConfiguration\n%s\n", strings.TrimRight(verify, "\r\n"))
|
||||
fmt.Fprintf(&logs, "→ getpdo CurrentSystemConfiguration (runtime layer only — confirms the writes were accepted, not that they'll survive a reboot)\n%s\n", strings.TrimRight(verify, "\r\n"))
|
||||
}
|
||||
|
||||
return logs.String(), nil
|
||||
@@ -160,6 +183,47 @@ 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 m.NewTelnet == nil {
|
||||
return "", errors.New("telnet not configured: Manager.NewTelnet is nil")
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
@@ -3,6 +3,7 @@ package setup
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEnableSSHViaTelnet_BuildsInjectedCommand(t *testing.T) {
|
||||
@@ -44,7 +45,7 @@ func TestEnableSSHViaTelnetFullConfig_BuildsInjectedSequence(t *testing.T) {
|
||||
f := &fakeTelnet{responses: resp}
|
||||
m := newFakeTelnetManager(f)
|
||||
|
||||
if _, err := m.EnableSSHViaTelnetFullConfig("192.0.2.10", svc); err != nil {
|
||||
if _, err := m.EnableSSHViaTelnetFullConfig("192.0.2.10", svc, 0); err != nil {
|
||||
t.Fatalf("EnableSSHViaTelnetFullConfig: %v", err)
|
||||
}
|
||||
|
||||
@@ -59,6 +60,78 @@ func TestEnableSSHViaTelnetFullConfig_BuildsInjectedSequence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// fullConfigResponses builds the {command: "OK"} map for
|
||||
// EnableSSHViaTelnetFullConfig's fixed 6-step sequence (5 commands + the
|
||||
// getpdo verification) against svc, matching
|
||||
// TestEnableSSHViaTelnetFullConfig_BuildsInjectedSequence's command list.
|
||||
func fullConfigResponses(svc string) map[string]string {
|
||||
injected := svc + `;touch /tmp/remote_services;/etc/init.d/sshd start`
|
||||
|
||||
cmds := []string{
|
||||
`sys configuration bmxRegistryUrl "` + svc + `/bmx/registry/v1/services"`,
|
||||
`sys configuration statsServerUrl "` + svc + `"`,
|
||||
`sys configuration margeServerUrl "` + injected + `"`,
|
||||
`sys configuration swUpdateUrl "` + svc + `/updates/soundtouch"`,
|
||||
`envswitch boseurls set "` + injected + `" "` + svc + `/updates/soundtouch"`,
|
||||
`getpdo CurrentSystemConfiguration`,
|
||||
}
|
||||
|
||||
resp := make(map[string]string, len(cmds))
|
||||
for _, c := range cmds {
|
||||
resp[c] = "OK\n"
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
// TestEnableSSHViaTelnetFullConfig_PausesBetweenCommands is the regression
|
||||
// test for #515 comment 5228449448: the same commands sent back-to-back
|
||||
// left sshd down on a real device, but succeeded sent one at a time with
|
||||
// gaps. Uses a small real duration rather than a fake clock/injectable
|
||||
// sleeper — simplest thing that actually proves time.Sleep is in the loop,
|
||||
// and small enough (5 gaps x 5ms) not to slow the suite down.
|
||||
func TestEnableSSHViaTelnetFullConfig_PausesBetweenCommands(t *testing.T) {
|
||||
const svc = "https://192.0.2.10:8443"
|
||||
const delay = 5 * time.Millisecond
|
||||
|
||||
f := &fakeTelnet{responses: fullConfigResponses(svc)}
|
||||
m := newFakeTelnetManager(f)
|
||||
|
||||
start := time.Now()
|
||||
|
||||
if _, err := m.EnableSSHViaTelnetFullConfig("192.0.2.10", svc, delay); err != nil {
|
||||
t.Fatalf("EnableSSHViaTelnetFullConfig: %v", err)
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
// 5 real commands = 5 gaps (see runTelnetInjection: delay after each
|
||||
// command in the loop, including before the getpdo verification).
|
||||
wantMin := 5 * delay
|
||||
|
||||
if elapsed < wantMin {
|
||||
t.Errorf("elapsed %v, want at least %v (delay not applied between commands)", elapsed, wantMin)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnableSSHViaTelnetFullConfig_ZeroDelayIsInstant verifies 0 keeps the
|
||||
// old back-to-back behavior — no accidental minimum sleep.
|
||||
func TestEnableSSHViaTelnetFullConfig_ZeroDelayIsInstant(t *testing.T) {
|
||||
const svc = "https://192.0.2.10:8443"
|
||||
|
||||
f := &fakeTelnet{responses: fullConfigResponses(svc)}
|
||||
m := newFakeTelnetManager(f)
|
||||
|
||||
start := time.Now()
|
||||
|
||||
if _, err := m.EnableSSHViaTelnetFullConfig("192.0.2.10", svc, 0); err != nil {
|
||||
t.Fatalf("EnableSSHViaTelnetFullConfig: %v", err)
|
||||
}
|
||||
|
||||
if elapsed := time.Since(start); elapsed > 50*time.Millisecond {
|
||||
t.Errorf("elapsed %v with a 0 delay, expected near-instant", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetBoseURLs_BuildsCleanCommand(t *testing.T) {
|
||||
const svc = "https://192.0.2.10:8443"
|
||||
|
||||
@@ -84,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,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// InitPlan describes everything required to take a factory-reset (or
|
||||
@@ -250,8 +252,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 +262,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
|
||||
@@ -195,11 +197,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 +224,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 +240,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)
|
||||
@@ -108,6 +114,45 @@ func (m *Manager) PairAccount(deviceIP, accountID string, t TelnetClient) (PairA
|
||||
return result, logs.String(), nil
|
||||
}
|
||||
|
||||
// EnsureMargeAccountPaired reads the device's /info and, if margeAccountUUID
|
||||
// is empty (a genuinely unpaired, factory-reset device), pairs it via
|
||||
// PairAccount using wantAccountID if given, otherwise a freshly generated ID.
|
||||
// See #515 comment 5230833551: on an unpaired device, margeServerUrl is
|
||||
// reportedly never polled at all, so the boseurls SSH-enable injection has no
|
||||
// read cycle to fire on regardless of command delay — pairing first gives it
|
||||
// one. accountID is empty when GetLiveDeviceInfo itself fails; otherwise it
|
||||
// is either the device's existing margeAccountUUID (alreadyPaired=true) or
|
||||
// the account ID just paired with.
|
||||
func (m *Manager) EnsureMargeAccountPaired(deviceIP, wantAccountID string, t TelnetClient) (accountID string, alreadyPaired bool, logs string, err error) {
|
||||
info, infoErr := m.GetLiveDeviceInfo(deviceIP)
|
||||
if infoErr != nil {
|
||||
return "", false, "", fmt.Errorf("read /info: %w", infoErr)
|
||||
}
|
||||
|
||||
if info.MargeAccountUUID != "" {
|
||||
return info.MargeAccountUUID, true, "", nil
|
||||
}
|
||||
|
||||
target := wantAccountID
|
||||
if target == "" {
|
||||
generated, genErr := GenerateAccountID(nil)
|
||||
if genErr != nil {
|
||||
return "", false, "", fmt.Errorf("generate account id: %w", genErr)
|
||||
}
|
||||
|
||||
target = generated
|
||||
} 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)
|
||||
if pairErr != nil {
|
||||
return target, false, pairLogs, pairErr
|
||||
}
|
||||
|
||||
return target, false, pairLogs, nil
|
||||
}
|
||||
|
||||
// probeSetMargeAccount fetches /supportedURLs and reports whether
|
||||
// /setMargeAccount is in the listing.
|
||||
func (m *Manager) probeSetMargeAccount(deviceIP string) (bool, error) {
|
||||
@@ -157,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{
|
||||
@@ -186,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.
|
||||
@@ -197,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) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package setup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -9,18 +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
|
||||
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 {
|
||||
@@ -55,6 +60,21 @@ func newFakeDevice(t *testing.T) *fakeDevice {
|
||||
w.WriteHeader(d.postStatus)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/info", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
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://"):]
|
||||
@@ -254,36 +274,220 @@ func TestPairAccount_TelnetTransportErrorReturned(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 TestEnsureMargeAccountPaired_AlreadyPairedSkipsPairing(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.margeAccountUUID = "1234567"
|
||||
|
||||
f := &fakeTelnet{}
|
||||
|
||||
m := NewManager("", nil, nil)
|
||||
|
||||
accountID, alreadyPaired, _, err := m.EnsureMargeAccountPaired(d.addr, "", f)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureMargeAccountPaired: %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 !alreadyPaired {
|
||||
t.Error("alreadyPaired should be true")
|
||||
}
|
||||
|
||||
if accountID != "1234567" {
|
||||
t.Errorf("accountID = %q, want the existing margeAccountUUID", accountID)
|
||||
}
|
||||
|
||||
if len(f.commands) != 0 || d.gotPostBody != "" {
|
||||
t.Error("pairing should not have been attempted for an already-paired device")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureMargeAccountPaired_UnpairedGeneratesAndPairs(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.margeAccountUUID = ""
|
||||
|
||||
m := NewManager("", nil, nil)
|
||||
|
||||
accountID, alreadyPaired, _, err := m.EnsureMargeAccountPaired(d.addr, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureMargeAccountPaired: %v", err)
|
||||
}
|
||||
|
||||
if alreadyPaired {
|
||||
t.Error("alreadyPaired should be false for an unpaired device")
|
||||
}
|
||||
|
||||
if !datastore.IsSafeIdentifier(accountID) {
|
||||
t.Errorf("accountID %q is not a valid generated ID", accountID)
|
||||
}
|
||||
|
||||
if !strings.Contains(d.gotPostBody, "<accountId>"+accountID+"</accountId>") {
|
||||
t.Errorf("device received %q, want it to be paired with the generated %q", d.gotPostBody, accountID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureMargeAccountPaired_UnpairedUsesWantAccountID(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.margeAccountUUID = ""
|
||||
|
||||
m := NewManager("", nil, nil)
|
||||
|
||||
accountID, alreadyPaired, _, err := m.EnsureMargeAccountPaired(d.addr, "7654321", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureMargeAccountPaired: %v", err)
|
||||
}
|
||||
|
||||
if alreadyPaired {
|
||||
t.Error("alreadyPaired should be false for an unpaired device")
|
||||
}
|
||||
|
||||
if accountID != "7654321" {
|
||||
t.Errorf("accountID = %q, want the requested 7654321", accountID)
|
||||
}
|
||||
|
||||
if !strings.Contains(d.gotPostBody, "<accountId>7654321</accountId>") {
|
||||
t.Errorf("device received %q, want the requested account id", d.gotPostBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureMargeAccountPaired_RejectsInvalidWantAccountID(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.margeAccountUUID = ""
|
||||
|
||||
m := NewManager("", nil, nil)
|
||||
|
||||
_, _, _, err := m.EnsureMargeAccountPaired(d.addr, "not/valid", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for an invalid --account value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureMargeAccountPaired_PropagatesPairingFailure(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.margeAccountUUID = ""
|
||||
d.supportsSetMarge = false
|
||||
|
||||
m := NewManager("", nil, nil)
|
||||
|
||||
_, _, _, err := m.EnsureMargeAccountPaired(d.addr, "1234567", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when HTTP pairing is unsupported and no telnet client is given")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
+285
-59
@@ -124,9 +124,17 @@ type MigrationSummary struct {
|
||||
}
|
||||
|
||||
// SSHClient defines the interface for SSH operations.
|
||||
//
|
||||
// Connect/Close are optional: Run/UploadContent both work standalone
|
||||
// (dialing their own one-off connection each time, as they always have).
|
||||
// Call Connect first when making several calls in a row — e.g.
|
||||
// RevertMigration's ~17 commands — so they reuse one connection instead of
|
||||
// dialing fresh every time; defer Close to release it afterward.
|
||||
type SSHClient interface {
|
||||
Run(command string) (string, error)
|
||||
UploadContent(content []byte, remotePath string) error
|
||||
Connect() error
|
||||
Close() error
|
||||
}
|
||||
|
||||
// TelnetClient defines the interface for the device's port-17000 diagnostic
|
||||
@@ -1081,32 +1089,46 @@ 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 {
|
||||
return fmt.Sprintf("Note: could not re-sync boseurls over telnet (%v); a device reboot will reconcile the runtime layer.\n", rerr)
|
||||
}
|
||||
@@ -1869,6 +1891,18 @@ func (m *Manager) patchUdhcpcScript(client SSHClient, targetScript, hookMarker s
|
||||
// RevertMigration reverts the speaker to its original Bose cloud configuration.
|
||||
func (m *Manager) RevertMigration(deviceIP string) (string, error) {
|
||||
client := m.NewSSH(deviceIP)
|
||||
|
||||
// This function alone makes ~17 client.Run/UploadContent calls across
|
||||
// its sub-steps below. Dialing a fresh SSH connection per call (the
|
||||
// default when Connect isn't used) was confirmed on real hardware to
|
||||
// overwhelm a resource-constrained speaker; Connect+defer Close keeps
|
||||
// it to one connection for the whole revert instead.
|
||||
if err := client.Connect(); err != nil {
|
||||
return "", fmt.Errorf("failed to connect for revert: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = client.Close() }()
|
||||
|
||||
rwCmd := "(rw || mount -o remount,rw /)"
|
||||
|
||||
var logs string
|
||||
@@ -2601,12 +2635,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'",
|
||||
@@ -2618,7 +2691,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))
|
||||
@@ -2642,14 +2715,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
|
||||
@@ -2663,28 +2764,113 @@ 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() }()
|
||||
|
||||
var ps models.Presets
|
||||
if decodeErr := xml.NewDecoder(resp.Body).Decode(&ps); decodeErr != nil {
|
||||
return
|
||||
return nil, decodeErr
|
||||
}
|
||||
|
||||
var servicePresets []models.ServicePreset
|
||||
@@ -2728,10 +2914,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)
|
||||
@@ -2739,14 +2943,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
|
||||
@@ -2773,10 +2977,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")
|
||||
@@ -2801,7 +3023,7 @@ func (m *Manager) syncSources(deviceIP, accountID, deviceID string) {
|
||||
|
||||
_ = m.DataStore.SaveConfiguredSources(accountID, deviceID, srs.Sources)
|
||||
|
||||
return
|
||||
return len(srs.Sources)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2813,46 +3035,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
|
||||
|
||||
@@ -132,6 +132,12 @@ func (m *mockSSH) UploadContent(content []byte, remotePath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Connect/Close are no-ops here — the mock has no real connection to
|
||||
// reuse, and every test call already goes through Run/UploadContent above
|
||||
// regardless of whether Connect was called first.
|
||||
func (m *mockSSH) Connect() error { return nil }
|
||||
func (m *mockSSH) Close() error { return nil }
|
||||
|
||||
func TestMigrateViaHosts(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "setup-test")
|
||||
if err != nil {
|
||||
@@ -2096,25 +2102,41 @@ func TestMigrateViaXML_ReappliesBoseURLsOverTelnet(t *testing.T) {
|
||||
return &mockSSH{runFunc: func(string) (string, error) { return "", nil }}
|
||||
}
|
||||
|
||||
ft := &fakeTelnet{banner: "->", responses: map[string]string{}}
|
||||
wantCmds := telnetURLs{
|
||||
Marge: target,
|
||||
Stats: target,
|
||||
SwUpdate: target + "/updates/soundtouch",
|
||||
BmxRegistry: target + "/bmx/registry/v1/services",
|
||||
}.Commands()
|
||||
|
||||
resp := make(map[string]string, len(wantCmds))
|
||||
for _, c := range wantCmds {
|
||||
resp[c] = "OK\n"
|
||||
}
|
||||
|
||||
ft := &fakeTelnet{banner: "->", responses: resp}
|
||||
m.NewTelnet = func(string) TelnetClient { return ft }
|
||||
|
||||
if _, err := m.MigrateSpeaker("192.0.2.10", target, "", nil, MigrationMethodXML); err != nil {
|
||||
t.Fatalf("MigrateSpeaker: %v", err)
|
||||
}
|
||||
|
||||
want := `envswitch boseurls set "` + target + `" "` + target + `/updates/soundtouch"`
|
||||
|
||||
var found bool
|
||||
for _, c := range ft.commands {
|
||||
if c == want {
|
||||
found = true
|
||||
break
|
||||
// All four `sys configuration` writes must land before the envswitch
|
||||
// commit — see enable_ssh.go's setAllBoseURLsViaTelnet — otherwise the
|
||||
// commit freezes whatever stale value was still in the runtime layer for
|
||||
// any field not passed to it (the #621 statsServerUrl/bmxRegistryUrl bug).
|
||||
for _, want := range wantCmds {
|
||||
var found bool
|
||||
for _, c := range ft.commands {
|
||||
if c == want {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Errorf("expected boseurls re-apply %q after XML migration; sent: %v", want, ft.commands)
|
||||
if !found {
|
||||
t.Errorf("expected boseurls re-apply command %q after XML migration; sent: %v", want, ft.commands)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// TestSyncDeviceData_DestructiveSyncRequiresConfirmation is a regression
|
||||
// test for #614's 2026-08-23 finding: SyncDeviceData used to overwrite the
|
||||
// datastore unconditionally with whatever the speaker's live :8090 API
|
||||
// returned, even if that snapshot had fewer presets than what was already
|
||||
// stored — e.g. because the speaker's own local cache was stale or
|
||||
// incomplete at that exact moment. This is a real, confirmed mechanism for
|
||||
// "Sync wipes my presets" reports.
|
||||
//
|
||||
// A device already has 3 stored presets. The mock speaker's live /presets
|
||||
// only reports 1. The first (unconfirmed) sync must NOT write anything and
|
||||
// must report the shrink; a confirmed retry must apply it.
|
||||
func TestSyncDeviceData_DestructiveSyncRequiresConfirmation(t *testing.T) {
|
||||
const (
|
||||
accountID = "1234567"
|
||||
deviceID = "AABBCCDDEEFF"
|
||||
)
|
||||
|
||||
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":
|
||||
// Only one preset survived on the speaker's own live cache —
|
||||
// the datastore already has three (seeded below).
|
||||
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="/custom/v1/playback/station1" 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()
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "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"}},
|
||||
{ID: "3", ButtonNumber: "3", ServiceContentItem: models.ServiceContentItem{Name: "Station 3"}},
|
||||
}
|
||||
if err := ds.SavePresets(accountID, deviceID, seeded); err != nil {
|
||||
t.Fatalf("seed SavePresets: %v", err)
|
||||
}
|
||||
|
||||
m := NewManager("http://localhost:8000", ds, nil)
|
||||
deviceIP := mockDevice.Listener.Addr().String()
|
||||
|
||||
// Unconfirmed: must refuse to write and report the shrink.
|
||||
result, err := m.SyncDeviceData(deviceIP, false)
|
||||
if err != nil {
|
||||
t.Fatalf("SyncDeviceData(confirmed=false): %v", err)
|
||||
}
|
||||
|
||||
if result.Applied {
|
||||
t.Fatal("expected unconfirmed destructive sync to NOT apply")
|
||||
}
|
||||
|
||||
if !result.Destructive {
|
||||
t.Fatal("expected result.Destructive=true for a 3->1 preset shrink")
|
||||
}
|
||||
|
||||
var presetDiff *SyncResourceDiff
|
||||
for i := range result.Diffs {
|
||||
if result.Diffs[i].Resource == "presets" {
|
||||
presetDiff = &result.Diffs[i]
|
||||
}
|
||||
}
|
||||
|
||||
if presetDiff == nil {
|
||||
t.Fatal("expected a presets diff in the result")
|
||||
}
|
||||
|
||||
if presetDiff.CurrentCount != 3 || presetDiff.IncomingCount != 1 {
|
||||
t.Errorf("expected presets diff 3->1, got %d->%d", presetDiff.CurrentCount, presetDiff.IncomingCount)
|
||||
}
|
||||
|
||||
if len(presetDiff.Removed) != 2 {
|
||||
t.Errorf("expected 2 removed preset names (slots 2 and 3), got %v", presetDiff.Removed)
|
||||
}
|
||||
|
||||
presetsAfterRefusal, err := ds.GetPresets(accountID, deviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPresets after refused sync: %v", err)
|
||||
}
|
||||
|
||||
if len(presetsAfterRefusal) != 3 {
|
||||
t.Fatalf("expected the original 3 presets to survive an unconfirmed destructive sync, got %d", len(presetsAfterRefusal))
|
||||
}
|
||||
|
||||
// Confirmed: must re-check fresh state and apply.
|
||||
result, err = m.SyncDeviceData(deviceIP, true)
|
||||
if err != nil {
|
||||
t.Fatalf("SyncDeviceData(confirmed=true): %v", err)
|
||||
}
|
||||
|
||||
if !result.Applied {
|
||||
t.Fatal("expected confirmed destructive sync to apply")
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func TestSyncDeviceData_UsesDeviceID(t *testing.T) {
|
||||
manager := NewManager("http://localhost:8000", ds, cm)
|
||||
|
||||
// Test SyncDeviceData
|
||||
err := manager.SyncDeviceData(serverHost)
|
||||
_, err := manager.SyncDeviceData(serverHost, false)
|
||||
if err != nil {
|
||||
t.Fatalf("SyncDeviceData failed: %v", err)
|
||||
}
|
||||
@@ -130,7 +130,7 @@ func TestSyncDeviceData_NoDeviceID_ShouldFail(t *testing.T) {
|
||||
manager := NewManager("http://localhost:8000", ds, cm)
|
||||
|
||||
// Test SyncDeviceData - should fail
|
||||
err := manager.SyncDeviceData(serverHost)
|
||||
_, err := manager.SyncDeviceData(serverHost, false)
|
||||
if err == nil {
|
||||
t.Fatal("SyncDeviceData should have failed when deviceID is empty")
|
||||
}
|
||||
@@ -221,7 +221,7 @@ func TestSyncDeviceData_FallbackToExistingDeviceMapping(t *testing.T) {
|
||||
manager := NewManager("http://localhost:8000", ds, cm)
|
||||
|
||||
// Sync should work and use MAC address
|
||||
err := manager.SyncDeviceData(serverHost)
|
||||
_, err := manager.SyncDeviceData(serverHost, false)
|
||||
if err != nil {
|
||||
t.Fatalf("SyncDeviceData failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -279,7 +279,7 @@ func TestSyncSources_Format(t *testing.T) {
|
||||
deviceIP := mockDevice.Listener.Addr().String()
|
||||
accountID := "1234567"
|
||||
deviceID := "001122334455"
|
||||
err = m.SyncDeviceData(deviceIP)
|
||||
_, err = m.SyncDeviceData(deviceIP, false)
|
||||
if err != nil {
|
||||
t.Fatalf("SyncDeviceData failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -130,13 +130,13 @@ func (m *Manager) migrateViaTelnet(deviceIP, targetURL string, urls telnetURLs)
|
||||
return logs.String(), fmt.Errorf("verification command failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(&logs, "→ getpdo CurrentSystemConfiguration\n%s\n", strings.TrimRight(verify, "\r\n"))
|
||||
fmt.Fprintf(&logs, "→ getpdo CurrentSystemConfiguration (runtime layer only — confirms the writes were accepted, not that they'll survive a reboot)\n%s\n", strings.TrimRight(verify, "\r\n"))
|
||||
|
||||
if !strings.Contains(verify, targetURL) {
|
||||
return logs.String(), fmt.Errorf("verification failed: getpdo response does not contain %q (device may have rejected the new URLs)", targetURL)
|
||||
}
|
||||
|
||||
logs.WriteString("Telnet migration succeeded. Reboot the device to apply.\n")
|
||||
logs.WriteString("Telnet writes accepted (runtime layer). Reboot the device so the envswitch-persisted layer takes over.\n")
|
||||
|
||||
return logs.String(), nil
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user