mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +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
|
||||
|
||||
|
||||
@@ -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,7 +40,7 @@ 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@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -51,6 +51,6 @@ jobs:
|
||||
run: go build ./...
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
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@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
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
|
||||
|
||||
@@ -243,7 +243,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:
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -672,9 +675,9 @@ func setupEnableSSHCmd() *cli.Command {
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "account",
|
||||
Usage: "Only used when the device is unpaired and --no-auto-pair is not set: 7-digit account ID to pair " +
|
||||
"with (empty = generate one). Use this if you already know which account this device should end up " +
|
||||
"on (e.g. to match one already in the datastore) rather than getting a random one now",
|
||||
Usage: "Only used when the device is unpaired and --no-auto-pair is not set: account ID to pair with " +
|
||||
"(empty = generate a fresh 7-digit one). Use this if you already know which account this device " +
|
||||
"should end up on (e.g. to match one already in the datastore) rather than getting a random one now",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "no-reset-urls",
|
||||
@@ -1013,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",
|
||||
@@ -1023,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)
|
||||
@@ -1034,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
|
||||
@@ -1057,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)
|
||||
}
|
||||
@@ -1386,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",
|
||||
@@ -1812,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)"},
|
||||
@@ -1836,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 {
|
||||
@@ -1919,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"),
|
||||
@@ -1932,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")
|
||||
}
|
||||
|
||||
+378
-284
@@ -224,6 +224,258 @@ func logBufferCapacityFromEnv(defaultCap int) int {
|
||||
return v
|
||||
}
|
||||
|
||||
// serviceFlags is the full flag/env-var surface for soundtouch-service.
|
||||
// Extracted to a package-level var (rather than inlined in main()'s
|
||||
// cli.App literal) so tests can build a real *cli.Context against the
|
||||
// exact same flags loadConfig reads, instead of hand-duplicating them.
|
||||
var serviceFlags = []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "port",
|
||||
Aliases: []string{"p"},
|
||||
Usage: "HTTP port to bind the service to",
|
||||
Value: "8000",
|
||||
EnvVars: []string{"PORT"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "bind",
|
||||
Usage: "Network interface to bind to",
|
||||
EnvVars: []string{"BIND_ADDR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "data-dir",
|
||||
Usage: "Directory for persistent data",
|
||||
Value: "data",
|
||||
EnvVars: []string{"DATA_DIR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "server-url",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "External URL of this service",
|
||||
EnvVars: []string{"SERVER_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "deployment-mode",
|
||||
Usage: "Where this service runs: on-device, private-network, or public-network " +
|
||||
"- informs the server-url fallback when --server-url isn't set",
|
||||
EnvVars: []string{"DEPLOYMENT_MODE"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "https-port",
|
||||
Usage: "HTTPS port to bind the service to",
|
||||
Value: "8443",
|
||||
EnvVars: []string{"HTTPS_PORT"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "https-server-url",
|
||||
Aliases: []string{"S"},
|
||||
Usage: "External HTTPS URL",
|
||||
EnvVars: []string{"HTTPS_SERVER_URL"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "redact-logs",
|
||||
Usage: "Redact sensitive data in proxy logs",
|
||||
Value: true,
|
||||
EnvVars: []string{"REDACT_PROXY_LOGS"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "log-bodies",
|
||||
Usage: "Log full request/response bodies",
|
||||
EnvVars: []string{"LOG_PROXY_BODY"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "record-interactions",
|
||||
Usage: "Record HTTP interactions to disk",
|
||||
Value: true,
|
||||
EnvVars: []string{"RECORD_INTERACTIONS"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "discovery-enabled",
|
||||
Usage: "Enable periodic device discovery",
|
||||
Value: true,
|
||||
EnvVars: []string{"DISCOVERY_ENABLED"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "discovery-interval",
|
||||
Usage: "Device discovery interval",
|
||||
Value: "5m",
|
||||
EnvVars: []string{"DISCOVERY_INTERVAL"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "update-check-enabled",
|
||||
Usage: "Periodically check GitHub for a newer release (opt-in; the only network call this makes beyond speaker/provider traffic)",
|
||||
Value: false,
|
||||
EnvVars: []string{"UPDATE_CHECK_ENABLED"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "update-check-interval",
|
||||
Usage: "Update check interval",
|
||||
Value: "24h",
|
||||
EnvVars: []string{"UPDATE_CHECK_INTERVAL"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "dns-discovery",
|
||||
Usage: "Enable DNS discovery server",
|
||||
EnvVars: []string{"ENABLE_DNS_DISCOVERY"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "dns-upstream",
|
||||
Usage: "Upstream DNS server(s) for non-Bose queries (comma-separated). If empty, /etc/resolv.conf is used.",
|
||||
Value: "",
|
||||
EnvVars: []string{"DNS_UPSTREAM"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "dns-bind",
|
||||
Usage: "Bind address for the DNS discovery server",
|
||||
Value: ":53",
|
||||
EnvVars: []string{"DNS_BIND_ADDR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-client-id",
|
||||
Usage: "Spotify OAuth client ID",
|
||||
EnvVars: []string{"SPOTIFY_CLIENT_ID"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-client-secret",
|
||||
Usage: "Spotify OAuth client secret",
|
||||
EnvVars: []string{"SPOTIFY_CLIENT_SECRET"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-redirect-uri",
|
||||
Usage: "Spotify OAuth redirect URI (defaults to <server-url>/mgmt/spotify/callback)",
|
||||
EnvVars: []string{"SPOTIFY_REDIRECT_URI"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-token-url",
|
||||
Usage: "Spotify OAuth token URL (for testing)",
|
||||
EnvVars: []string{"SPOTIFY_TOKEN_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-api-base",
|
||||
Usage: "Spotify API base URL (for testing)",
|
||||
EnvVars: []string{"SPOTIFY_API_BASE"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-client-id",
|
||||
Usage: "Amazon LWA OAuth client ID",
|
||||
EnvVars: []string{"AMAZON_CLIENT_ID"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-client-secret",
|
||||
Usage: "Amazon LWA OAuth client secret",
|
||||
EnvVars: []string{"AMAZON_CLIENT_SECRET"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-redirect-uri",
|
||||
Usage: "Amazon LWA OAuth redirect URI (defaults to <server-url>/mgmt/amazon/callback)",
|
||||
EnvVars: []string{"AMAZON_REDIRECT_URI"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-token-url",
|
||||
Usage: "Amazon LWA token URL (for testing)",
|
||||
EnvVars: []string{"AMAZON_TOKEN_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-profile-url",
|
||||
Usage: "Amazon LWA profile URL (for testing)",
|
||||
EnvVars: []string{"AMAZON_PROFILE_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tunein-opml-url",
|
||||
Usage: "TuneIn OPML base URL, covering Tune.ashx/describe.ashx/navigate (for testing / local mock; defaults to opml.radiotime.com)",
|
||||
EnvVars: []string{"TUNEIN_OPML_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tunein-api-url",
|
||||
Usage: "TuneIn API base URL, covering search and profile contents (for testing / local mock; defaults to api.radiotime.com)",
|
||||
EnvVars: []string{"TUNEIN_API_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-provider",
|
||||
Usage: "Text-to-speech provider: 'translate' (Google Translate, no credentials, default) or 'google-cloud' (Google Cloud TTS, needs an API key). Empty falls back to translate; leave unset to let a value saved in the settings UI take effect",
|
||||
EnvVars: []string{"TTS_PROVIDER"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-google-api-key",
|
||||
Usage: "Google Cloud Text-to-Speech API key (required when --tts-provider=google-cloud)",
|
||||
EnvVars: []string{"TTS_GOOGLE_API_KEY"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-google-endpoint",
|
||||
Usage: "Google Cloud TTS synthesize endpoint override (for testing)",
|
||||
EnvVars: []string{"TTS_GOOGLE_ENDPOINT"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-language",
|
||||
Usage: "Default TTS language code. Provider-specific: 'EN'/'DE' for translate, BCP-47 like 'en-US' for google-cloud",
|
||||
EnvVars: []string{"TTS_LANGUAGE"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-voice",
|
||||
Usage: "Default Google Cloud TTS voice name (e.g. en-US-Neural2-C); ignored by the translate provider",
|
||||
EnvVars: []string{"TTS_VOICE"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-app-key",
|
||||
Usage: "Bose /speaker app_key used to play TTS notifications on speakers",
|
||||
EnvVars: []string{"TTS_APP_KEY"},
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "tts-volume",
|
||||
Usage: "Default TTS playback volume (0-100, 0 = keep current volume)",
|
||||
Value: 0,
|
||||
EnvVars: []string{"TTS_VOLUME"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "mgmt-username",
|
||||
Usage: "Management API username for HTTP Basic Auth",
|
||||
Value: "admin",
|
||||
EnvVars: []string{"MGMT_USERNAME"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "mgmt-password",
|
||||
Usage: "Management API password for HTTP Basic Auth",
|
||||
Value: "change_me!",
|
||||
EnvVars: []string{"MGMT_PASSWORD"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "base-url",
|
||||
Usage: "External base URL for OAuth callbacks behind reverse proxy",
|
||||
EnvVars: []string{"BASE_URL"},
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "internal-paths",
|
||||
Usage: "Paths for internal requests (comma-separated or multiple flags)",
|
||||
EnvVars: []string{"INTERNAL_PATHS"},
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "tls-extra-host",
|
||||
Usage: "Additional DNS name or IP to include in the server TLS certificate SAN list (repeatable)",
|
||||
EnvVars: []string{"TLS_EXTRA_HOST"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "migration-enabled",
|
||||
Usage: "Enable device directory migration from serial to MAC-based structure",
|
||||
Value: true,
|
||||
EnvVars: []string{"MIGRATION_ENABLED"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "migration-dry-run",
|
||||
Usage: "Log what would be migrated without actually doing it",
|
||||
EnvVars: []string{"MIGRATION_DRY_RUN"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "stockholm-dir",
|
||||
Usage: "Path to the extracted Stockholm frontend directory (enables Stockholm UI when set)",
|
||||
EnvVars: []string{"STOCKHOLM_DIR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "stockholm-base-path",
|
||||
Usage: "URL prefix under which the Stockholm UI is served (e.g. /stockholm). Empty serves at root.",
|
||||
Value: "/stockholm",
|
||||
EnvVars: []string{"STOCKHOLM_BASE_PATH"},
|
||||
},
|
||||
}
|
||||
|
||||
func main() {
|
||||
updateBuildInfo()
|
||||
|
||||
@@ -250,249 +502,13 @@ func main() {
|
||||
Name: "Tobias Gesellchen, and the Bose-SoundTouch Contributors",
|
||||
},
|
||||
},
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "port",
|
||||
Aliases: []string{"p"},
|
||||
Usage: "HTTP port to bind the service to",
|
||||
Value: "8000",
|
||||
EnvVars: []string{"PORT"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "bind",
|
||||
Usage: "Network interface to bind to",
|
||||
EnvVars: []string{"BIND_ADDR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "data-dir",
|
||||
Usage: "Directory for persistent data",
|
||||
Value: "data",
|
||||
EnvVars: []string{"DATA_DIR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "server-url",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "External URL of this service",
|
||||
EnvVars: []string{"SERVER_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "https-port",
|
||||
Usage: "HTTPS port to bind the service to",
|
||||
Value: "8443",
|
||||
EnvVars: []string{"HTTPS_PORT"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "https-server-url",
|
||||
Aliases: []string{"S"},
|
||||
Usage: "External HTTPS URL",
|
||||
EnvVars: []string{"HTTPS_SERVER_URL"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "redact-logs",
|
||||
Usage: "Redact sensitive data in proxy logs",
|
||||
Value: true,
|
||||
EnvVars: []string{"REDACT_PROXY_LOGS"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "log-bodies",
|
||||
Usage: "Log full request/response bodies",
|
||||
EnvVars: []string{"LOG_PROXY_BODY"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "record-interactions",
|
||||
Usage: "Record HTTP interactions to disk",
|
||||
Value: true,
|
||||
EnvVars: []string{"RECORD_INTERACTIONS"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "discovery-enabled",
|
||||
Usage: "Enable periodic device discovery",
|
||||
Value: true,
|
||||
EnvVars: []string{"DISCOVERY_ENABLED"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "discovery-interval",
|
||||
Usage: "Device discovery interval",
|
||||
Value: "5m",
|
||||
EnvVars: []string{"DISCOVERY_INTERVAL"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "update-check-enabled",
|
||||
Usage: "Periodically check GitHub for a newer release (opt-in; the only network call this makes beyond speaker/provider traffic)",
|
||||
Value: false,
|
||||
EnvVars: []string{"UPDATE_CHECK_ENABLED"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "update-check-interval",
|
||||
Usage: "Update check interval",
|
||||
Value: "24h",
|
||||
EnvVars: []string{"UPDATE_CHECK_INTERVAL"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "dns-discovery",
|
||||
Usage: "Enable DNS discovery server",
|
||||
EnvVars: []string{"ENABLE_DNS_DISCOVERY"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "dns-upstream",
|
||||
Usage: "Upstream DNS server(s) for non-Bose queries (comma-separated). If empty, /etc/resolv.conf is used.",
|
||||
Value: "",
|
||||
EnvVars: []string{"DNS_UPSTREAM"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "dns-bind",
|
||||
Usage: "Bind address for the DNS discovery server",
|
||||
Value: ":53",
|
||||
EnvVars: []string{"DNS_BIND_ADDR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-client-id",
|
||||
Usage: "Spotify OAuth client ID",
|
||||
EnvVars: []string{"SPOTIFY_CLIENT_ID"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-client-secret",
|
||||
Usage: "Spotify OAuth client secret",
|
||||
EnvVars: []string{"SPOTIFY_CLIENT_SECRET"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-redirect-uri",
|
||||
Usage: "Spotify OAuth redirect URI (defaults to <server-url>/mgmt/spotify/callback)",
|
||||
EnvVars: []string{"SPOTIFY_REDIRECT_URI"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-token-url",
|
||||
Usage: "Spotify OAuth token URL (for testing)",
|
||||
EnvVars: []string{"SPOTIFY_TOKEN_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-api-base",
|
||||
Usage: "Spotify API base URL (for testing)",
|
||||
EnvVars: []string{"SPOTIFY_API_BASE"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-client-id",
|
||||
Usage: "Amazon LWA OAuth client ID",
|
||||
EnvVars: []string{"AMAZON_CLIENT_ID"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-client-secret",
|
||||
Usage: "Amazon LWA OAuth client secret",
|
||||
EnvVars: []string{"AMAZON_CLIENT_SECRET"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-redirect-uri",
|
||||
Usage: "Amazon LWA OAuth redirect URI (defaults to <server-url>/mgmt/amazon/callback)",
|
||||
EnvVars: []string{"AMAZON_REDIRECT_URI"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-token-url",
|
||||
Usage: "Amazon LWA token URL (for testing)",
|
||||
EnvVars: []string{"AMAZON_TOKEN_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-profile-url",
|
||||
Usage: "Amazon LWA profile URL (for testing)",
|
||||
EnvVars: []string{"AMAZON_PROFILE_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tunein-opml-url",
|
||||
Usage: "TuneIn OPML base URL, covering Tune.ashx/describe.ashx/navigate (for testing / local mock; defaults to opml.radiotime.com)",
|
||||
EnvVars: []string{"TUNEIN_OPML_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tunein-api-url",
|
||||
Usage: "TuneIn API base URL, covering search and profile contents (for testing / local mock; defaults to api.radiotime.com)",
|
||||
EnvVars: []string{"TUNEIN_API_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-provider",
|
||||
Usage: "Text-to-speech provider: 'translate' (Google Translate, no credentials, default) or 'google-cloud' (Google Cloud TTS, needs an API key). Empty falls back to translate; leave unset to let a value saved in the settings UI take effect",
|
||||
EnvVars: []string{"TTS_PROVIDER"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-google-api-key",
|
||||
Usage: "Google Cloud Text-to-Speech API key (required when --tts-provider=google-cloud)",
|
||||
EnvVars: []string{"TTS_GOOGLE_API_KEY"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-google-endpoint",
|
||||
Usage: "Google Cloud TTS synthesize endpoint override (for testing)",
|
||||
EnvVars: []string{"TTS_GOOGLE_ENDPOINT"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-language",
|
||||
Usage: "Default TTS language code. Provider-specific: 'EN'/'DE' for translate, BCP-47 like 'en-US' for google-cloud",
|
||||
EnvVars: []string{"TTS_LANGUAGE"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-voice",
|
||||
Usage: "Default Google Cloud TTS voice name (e.g. en-US-Neural2-C); ignored by the translate provider",
|
||||
EnvVars: []string{"TTS_VOICE"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-app-key",
|
||||
Usage: "Bose /speaker app_key used to play TTS notifications on speakers",
|
||||
EnvVars: []string{"TTS_APP_KEY"},
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "tts-volume",
|
||||
Usage: "Default TTS playback volume (0-100, 0 = keep current volume)",
|
||||
Value: 0,
|
||||
EnvVars: []string{"TTS_VOLUME"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "mgmt-username",
|
||||
Usage: "Management API username for HTTP Basic Auth",
|
||||
Value: "admin",
|
||||
EnvVars: []string{"MGMT_USERNAME"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "mgmt-password",
|
||||
Usage: "Management API password for HTTP Basic Auth",
|
||||
Value: "change_me!",
|
||||
EnvVars: []string{"MGMT_PASSWORD"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "base-url",
|
||||
Usage: "External base URL for OAuth callbacks behind reverse proxy",
|
||||
EnvVars: []string{"BASE_URL"},
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "internal-paths",
|
||||
Usage: "Paths for internal requests (comma-separated or multiple flags)",
|
||||
EnvVars: []string{"INTERNAL_PATHS"},
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "tls-extra-host",
|
||||
Usage: "Additional DNS name or IP to include in the server TLS certificate SAN list (repeatable)",
|
||||
EnvVars: []string{"TLS_EXTRA_HOST"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "migration-enabled",
|
||||
Usage: "Enable device directory migration from serial to MAC-based structure",
|
||||
Value: true,
|
||||
EnvVars: []string{"MIGRATION_ENABLED"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "migration-dry-run",
|
||||
Usage: "Log what would be migrated without actually doing it",
|
||||
EnvVars: []string{"MIGRATION_DRY_RUN"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "stockholm-dir",
|
||||
Usage: "Path to the extracted Stockholm frontend directory (enables Stockholm UI when set)",
|
||||
EnvVars: []string{"STOCKHOLM_DIR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "stockholm-base-path",
|
||||
Usage: "URL prefix under which the Stockholm UI is served (e.g. /stockholm). Empty serves at root.",
|
||||
Value: "/stockholm",
|
||||
EnvVars: []string{"STOCKHOLM_BASE_PATH"},
|
||||
},
|
||||
},
|
||||
Flags: serviceFlags,
|
||||
Action: func(c *cli.Context) error {
|
||||
config := loadConfig(c)
|
||||
config, err := loadConfig(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ds := initDataStore(config.dataDir)
|
||||
|
||||
// Detect a genuinely fresh data dir by the ABSENCE of settings.json,
|
||||
@@ -517,13 +533,11 @@ func main() {
|
||||
persisted = createDefaultSettings(ds, config)
|
||||
}
|
||||
|
||||
// Recalculate domains if settings changed
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "localhost"
|
||||
}
|
||||
|
||||
config.domains = getDomains(config.serverURL, config.httpsServerURL, hostname, config.tlsExtraHosts)
|
||||
// Recalculate domains if settings changed. Reuses the same mode-aware
|
||||
// fallback host loadConfig already resolved, rather than a raw
|
||||
// os.Hostname() call, so an on-device install doesn't leak its
|
||||
// unresolvable variant codename back in here (see issue #546).
|
||||
config.domains = getDomains(config.serverURL, config.httpsServerURL, config.hostname, config.tlsExtraHosts)
|
||||
|
||||
cm := initCertificateManager(config.dataDir, config.hostname)
|
||||
sm := setup.NewManager(config.serverURL, ds, cm)
|
||||
@@ -537,6 +551,7 @@ func main() {
|
||||
server.SetExpectedHosts(config.domains)
|
||||
server.SetVersionInfo(version, commit, date, repoURL)
|
||||
server.SetDiscoverySettings(config.discoveryInterval, config.discoveryEnabled)
|
||||
server.SetUpdateCheckSettings(config.updateCheckInterval, config.updateCheckEnabled)
|
||||
server.SetDNSSettings(persisted.DNSEnabled, strings.Join(persisted.DNSUpstream, ","), persisted.DNSBindAddr)
|
||||
server.SetInternalPaths(persisted.InternalPaths)
|
||||
server.SetSpotifyConfig(config.spotifyClientID, config.spotifyClientSecret, config.spotifyRedirectURI)
|
||||
@@ -616,7 +631,7 @@ func main() {
|
||||
|
||||
updateChecker := updatecheck.NewChecker(ds, "gesellix/Bose-SoundTouch", version)
|
||||
server.SetUpdateChecker(updateChecker)
|
||||
startUpdateCheck(updateChecker, config.updateCheckEnabled, config.updateCheckInterval)
|
||||
startUpdateCheck(server, updateChecker)
|
||||
|
||||
var stockholmHandler *stockholm.Handler
|
||||
|
||||
@@ -751,7 +766,32 @@ type serviceConfig struct {
|
||||
stockholmBasePath string
|
||||
}
|
||||
|
||||
func loadConfig(c *cli.Context) serviceConfig {
|
||||
// resolveFallbackHost picks the host used to guess a server URL when
|
||||
// --server-url/SERVER_URL isn't set, based on where this service runs.
|
||||
// On-device (running on the speaker's own Linux) is the one case where
|
||||
// os.Hostname() is guaranteed useless: it returns the speaker's internal
|
||||
// variant codename (e.g. "spotty", "mojo"), which nothing can resolve, not
|
||||
// even the speaker itself (see issue #546). warnOnUse reports whether
|
||||
// falling back to the returned host is risky enough to warrant a startup
|
||||
// warning.
|
||||
func resolveFallbackHost(deploymentMode string) (host string, warnOnUse bool) {
|
||||
switch deploymentMode {
|
||||
case "on-device":
|
||||
return "localhost", false
|
||||
case "public-network":
|
||||
// Caller must refuse to guess a publicly reachable address.
|
||||
return "", false
|
||||
default: // "private-network", "", or any unrecognized value: today's behavior.
|
||||
h, _ := os.Hostname()
|
||||
if h == "" {
|
||||
h = "localhost"
|
||||
}
|
||||
|
||||
return strings.ToLower(h), true
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfig(c *cli.Context) (serviceConfig, error) {
|
||||
port := c.String("port")
|
||||
bindAddr := c.String("bind")
|
||||
|
||||
@@ -762,16 +802,23 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
|
||||
dataDir := c.String("data-dir")
|
||||
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "localhost"
|
||||
}
|
||||
|
||||
hostname = strings.ToLower(hostname)
|
||||
deploymentMode := c.String("deployment-mode")
|
||||
fallbackHost, warnOnFallback := resolveFallbackHost(deploymentMode)
|
||||
|
||||
serverURL := c.String("server-url")
|
||||
if serverURL == "" {
|
||||
serverURL = "http://" + hostname + ":" + port
|
||||
if deploymentMode == "public-network" {
|
||||
return serviceConfig{}, fmt.Errorf(
|
||||
"--server-url (or SERVER_URL) is required when --deployment-mode=public-network; refusing to guess a public address")
|
||||
}
|
||||
|
||||
serverURL = "http://" + fallbackHost + ":" + port
|
||||
|
||||
if warnOnFallback {
|
||||
log.Printf("Warning: --server-url not set; defaulting to %s using this host's own hostname. "+
|
||||
"If your SoundTouch speakers can't reach this address, set --server-url/SERVER_URL explicitly, "+
|
||||
"or pass --deployment-mode=on-device if this runs on the speaker itself.", sanitizeLog(serverURL))
|
||||
}
|
||||
}
|
||||
// Strip a trailing slash so it cannot leak into the BMX registry base or the
|
||||
// margeServerUrl/bmxRegistryUrl pushed to speakers during migration.
|
||||
@@ -786,14 +833,14 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
|
||||
// The HTTPS URL is an override (from the flag/env); when empty it is
|
||||
// derived from serverURL + https port so one setting (Target Domain)
|
||||
// drives both. httpsDefaultURL is the hostname-based fallback used
|
||||
// drives both. httpsDefaultURL is the same mode-aware fallback used
|
||||
// before a Target Domain is configured.
|
||||
httpsOverride := c.String("https-server-url")
|
||||
httpsDefaultURL := "https://" + hostname + ":" + httpsPort
|
||||
httpsDefaultURL := "https://" + fallbackHost + ":" + httpsPort
|
||||
httpsServerURL := handlers.DeriveHTTPSURL(serverURL, httpsOverride, httpsPort, httpsDefaultURL)
|
||||
|
||||
tlsExtraHosts := c.StringSlice("tls-extra-host")
|
||||
domains := getDomains(serverURL, httpsServerURL, hostname, tlsExtraHosts)
|
||||
domains := getDomains(serverURL, httpsServerURL, fallbackHost, tlsExtraHosts)
|
||||
|
||||
redact := c.Bool("redact-logs")
|
||||
logBody := c.Bool("log-bodies")
|
||||
@@ -855,7 +902,7 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
bindAddr: bindAddr,
|
||||
addr: addr,
|
||||
dataDir: dataDir,
|
||||
hostname: hostname,
|
||||
hostname: fallbackHost,
|
||||
serverURL: serverURL,
|
||||
httpsServerURL: httpsServerURL,
|
||||
httpsOverride: httpsOverride,
|
||||
@@ -900,7 +947,7 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
migrationDryRun: migrationDryRun,
|
||||
stockholmDir: stockholmDir,
|
||||
stockholmBasePath: stockholmBasePath,
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getDomains(serverURL, httpsServerURL, hostname string, extraHosts []string) []string {
|
||||
@@ -1021,6 +1068,22 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
|
||||
}
|
||||
}
|
||||
|
||||
// Installs upgraded from a build that predates the Settings-page toggle
|
||||
// have no update_check_* keys at all, and an absent JSON bool decodes as
|
||||
// false — taking it at face value would silently switch the check off for
|
||||
// everyone who had opted in via UPDATE_CHECK_ENABLED. The interval is
|
||||
// always written together with the flag (createDefaultSettings and
|
||||
// HandleUpdateSettings both set both, and a time.Duration never
|
||||
// stringifies to ""), so a non-empty interval is the marker for
|
||||
// "settings.json genuinely carries an update-check preference".
|
||||
if persisted.UpdateCheckInterval != "" {
|
||||
config.updateCheckEnabled = persisted.UpdateCheckEnabled
|
||||
|
||||
if d, durErr := time.ParseDuration(persisted.UpdateCheckInterval); durErr == nil {
|
||||
config.updateCheckInterval = d
|
||||
}
|
||||
}
|
||||
|
||||
config.redact = persisted.RedactLogs
|
||||
config.logBody = persisted.LogBodies
|
||||
config.record = persisted.RecordInteractions
|
||||
@@ -1138,10 +1201,15 @@ func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datast
|
||||
RecordInteractions: config.record,
|
||||
DiscoveryEnabled: config.discoveryEnabled,
|
||||
DiscoveryInterval: config.discoveryInterval.String(),
|
||||
DNSEnabled: config.dnsEnabled,
|
||||
DNSUpstream: strings.Split(config.dnsUpstream, ","),
|
||||
DNSBindAddr: config.dnsBind,
|
||||
InternalPaths: config.internalPaths,
|
||||
// Seed the update-check preference from the CLI/env flags so a fresh
|
||||
// install's settings.json matches what the operator asked for (and so
|
||||
// the Settings page shows it) instead of silently reverting to off.
|
||||
UpdateCheckEnabled: config.updateCheckEnabled,
|
||||
UpdateCheckInterval: config.updateCheckInterval.String(),
|
||||
DNSEnabled: config.dnsEnabled,
|
||||
DNSUpstream: strings.Split(config.dnsUpstream, ","),
|
||||
DNSBindAddr: config.dnsBind,
|
||||
InternalPaths: config.internalPaths,
|
||||
Shortcuts: map[string]int{
|
||||
"/.well-known/appspecific/com.chrome.devtools.json": http.StatusNotFound,
|
||||
"/sw.js": http.StatusNotFound,
|
||||
@@ -1230,16 +1298,45 @@ func startDeviceDiscovery(server *handlers.Server) {
|
||||
}()
|
||||
}
|
||||
|
||||
// startUpdateCheck runs the opt-in periodic check against GitHub Releases
|
||||
// in the background (#591, _/i591/design-update-check.md). Unlike
|
||||
// discovery, enabled/interval are fixed at startup (env-only for v1, no
|
||||
// Settings-tab control — see the design doc's answer on that trade-off),
|
||||
// so they're plain arguments, not read live from Settings each tick.
|
||||
func startUpdateCheck(checker *updatecheck.Checker, enabled bool, interval time.Duration) {
|
||||
if !enabled {
|
||||
return
|
||||
}
|
||||
// updateCheckPollTick is how often the background update-check goroutine
|
||||
// re-reads the live settings. It is deliberately much shorter than the
|
||||
// check interval itself: sleeping a full (possibly 24h) interval between
|
||||
// reads would make flipping the Settings-page toggle on appear to do
|
||||
// nothing for up to a day.
|
||||
const updateCheckPollTick = time.Minute
|
||||
|
||||
// shouldRunUpdateCheckNow reports whether the background goroutine should
|
||||
// perform a real GitHub request on this poll tick. Pure/testable — no
|
||||
// sleeping, no I/O.
|
||||
//
|
||||
// A zero interval is treated as "don't check": with interval 0 every tick
|
||||
// would look due (shouldCheckImmediately), so an enabled check would hit
|
||||
// GitHub once a minute forever. HandleUpdateSettings also refuses to keep
|
||||
// the check enabled with a zero interval; this is the same guard for values
|
||||
// that arrive via the CLI flag or a hand-edited settings.json.
|
||||
func shouldRunUpdateCheckNow(
|
||||
enabled bool,
|
||||
lastCheckedAt time.Time,
|
||||
interval time.Duration,
|
||||
lastErrorAt, now time.Time,
|
||||
) bool {
|
||||
return enabled &&
|
||||
interval > 0 &&
|
||||
shouldCheckImmediately(lastCheckedAt, interval, now) &&
|
||||
!shouldSkipDueToBackoff(lastErrorAt, now)
|
||||
}
|
||||
|
||||
// startUpdateCheck runs the opt-in periodic check against GitHub Releases in
|
||||
// the background (#591, _/i591/design-update-check.md). Unlike the original
|
||||
// v1 design, enabled/interval are now live-reloadable from Settings (see
|
||||
// handlers.Server.SetUpdateCheckSettings) — so this goroutine always runs,
|
||||
// mirroring startDeviceDiscovery's live-settings pattern, and re-reads the
|
||||
// current settings every updateCheckPollTick. It only performs the actual
|
||||
// GitHub request when the check is enabled and the configured interval has
|
||||
// elapsed since the last check, so "always running" does not mean "always
|
||||
// talking to GitHub": with the check disabled it does nothing but wake up
|
||||
// once a minute and go back to sleep.
|
||||
func startUpdateCheck(server *handlers.Server, checker *updatecheck.Checker) {
|
||||
go func() {
|
||||
time.Sleep(randomJitter(5 * time.Minute))
|
||||
|
||||
@@ -1248,18 +1345,15 @@ func startUpdateCheck(checker *updatecheck.Checker, enabled bool, interval time.
|
||||
|
||||
var lastErrorAt time.Time
|
||||
|
||||
if shouldCheckImmediately(lastResult.CheckedAt, interval, time.Now()) {
|
||||
lastErrorAt, lastLoggedVersion = runUpdateCheckTick(checker, lastLoggedVersion)
|
||||
}
|
||||
|
||||
for {
|
||||
time.Sleep(interval)
|
||||
interval, enabled := server.GetUpdateCheckSettings()
|
||||
|
||||
if shouldSkipDueToBackoff(lastErrorAt, time.Now()) {
|
||||
continue
|
||||
if shouldRunUpdateCheckNow(enabled, lastResult.CheckedAt, interval, lastErrorAt, time.Now()) {
|
||||
lastErrorAt, lastLoggedVersion = runUpdateCheckTick(checker, lastLoggedVersion)
|
||||
lastResult = checker.LastResult()
|
||||
}
|
||||
|
||||
lastErrorAt, lastLoggedVersion = runUpdateCheckTick(checker, lastLoggedVersion)
|
||||
time.Sleep(updateCheckPollTick)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -1,13 +1,147 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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_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 {
|
||||
|
||||
+1
-1
@@ -169,7 +169,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
|
||||
|
||||
@@ -104,9 +104,53 @@ func TestRandomJitter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartUpdateCheck_DisabledIsANoOp(t *testing.T) {
|
||||
// Must return immediately without spawning anything that could touch a
|
||||
// nil-repo Checker or block — disabled is the default, so this path
|
||||
// runs on every install that hasn't opted in.
|
||||
startUpdateCheck(nil, false, time.Hour)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 . .
|
||||
|
||||
@@ -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,32 +156,33 @@ 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` |
|
||||
| `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. | `false` |
|
||||
| `UPDATE_CHECK_INTERVAL` | `--update-check-interval` | Update check interval | `24h` |
|
||||
| `MGMT_USERNAME` | `--mgmt-username` | Username for HTTP Basic Auth on the Management API (`/api/mgmt/*`, `/mgmt/*`) — Spotify/Amazon account linking, Local Accounts | `admin` |
|
||||
| `MGMT_PASSWORD` | `--mgmt-password` | Password for the same Management API Basic Auth. **Change this if AfterTouch is reachable beyond a trusted LAN** — the default is published in this doc. | `change_me!` |
|
||||
| `STOCKHOLM_DIR` | `--stockholm-dir` | Path to extracted Stockholm frontend directory — enables the Stockholm UI when set | *(disabled)* |
|
||||
| `MARGE_URL` | | Streaming/marge base URL used when rewriting `stockholm/json/config.json`. Defaults to `SERVER_URL`. Set to `SERVER_URL/marge` only when using a soundcork backend. | *(same as `SERVER_URL`)* |
|
||||
| `MARGE_AUTH_TOKEN` | | Pre-seeds the Stockholm `margeAuthToken` state (skips the login step for the first session) | *(empty)* |
|
||||
| `MARGE_ACCOUNT_ID` | | Pre-seeds the Stockholm `margeAccountID` state (used to filter device-discovery results by account) | *(empty)* |
|
||||
| Variable | Flag | Description | Default |
|
||||
|------------------------------------|----------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------|
|
||||
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
|
||||
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
|
||||
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
|
||||
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
|
||||
| `DEPLOYMENT_MODE` | `--deployment-mode` | Where this service runs: `on-device`, `private-network`, or `public-network`. Only changes behavior when `SERVER_URL` is *not* set: `on-device` defaults to `http://localhost:<port>` instead of guessing a hostname (the speaker's own Linux hostname is never resolvable — see issue #546); `public-network` refuses to start rather than guess a publicly reachable address; unset/`private-network` keeps the previous hostname-guessing behavior, now with a startup warning. The on-device install script sets this automatically. | unset (legacy hostname guess, with warning) |
|
||||
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
|
||||
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL. An override: when empty it is derived from `SERVER_URL` (same host, `https`, on `HTTPS_PORT`), and can also be viewed/overridden in Settings. | derived from `SERVER_URL` |
|
||||
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
|
||||
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
|
||||
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
|
||||
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
|
||||
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
|
||||
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
|
||||
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
|
||||
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for DNS/DHCP migration) | `:53` |
|
||||
| `INTERNAL_PATHS` | `--internal-paths` | Paths for internal requests to exclude from recording (e.g., `/setup/*`, `/web/*`) | `[]` |
|
||||
| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` |
|
||||
| `UPDATE_CHECK_ENABLED` | `--update-check-enabled` | Periodically check GitHub Releases for a newer version and show a dismissible notice in the admin UI and Player when one is found. **Opt-in**: this is the only network call AfterTouch makes beyond speaker/provider traffic when enabled, so it defaults off. One unauthenticated `GET` per interval to `api.github.com`, nothing else leaves the box. Also available as an "Update Check" toggle on the admin Settings page, which applies without a restart; the env var/flag is the seed value for a fresh install with no `settings.json` yet. | `false` |
|
||||
| `UPDATE_CHECK_INTERVAL` | `--update-check-interval` | Update check interval. Also editable on the admin Settings page (applies without a restart). | `24h` |
|
||||
| `MGMT_USERNAME` | `--mgmt-username` | Username for HTTP Basic Auth on the Management API (`/api/mgmt/*`, `/mgmt/*`) — Spotify/Amazon account linking, Local Accounts | `admin` |
|
||||
| `MGMT_PASSWORD` | `--mgmt-password` | Password for the same Management API Basic Auth. **Change this if AfterTouch is reachable beyond a trusted LAN** — the default is published in this doc. | `change_me!` |
|
||||
| `STOCKHOLM_DIR` | `--stockholm-dir` | Path to extracted Stockholm frontend directory — enables the Stockholm UI when set | *(disabled)* |
|
||||
| `MARGE_URL` | | Streaming/marge base URL used when rewriting `stockholm/json/config.json`. Defaults to `SERVER_URL`. Set to `SERVER_URL/marge` only when using a soundcork backend. | *(same as `SERVER_URL`)* |
|
||||
| `MARGE_AUTH_TOKEN` | | Pre-seeds the Stockholm `margeAuthToken` state (skips the login step for the first session) | *(empty)* |
|
||||
| `MARGE_ACCOUNT_ID` | | Pre-seeds the Stockholm `margeAccountID` state (used to filter device-discovery results by account) | *(empty)* |
|
||||
|
||||
### Configuration Examples
|
||||
|
||||
|
||||
@@ -594,6 +594,31 @@ 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.
|
||||
|
||||
### ❌ 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:**
|
||||
@@ -628,7 +653,8 @@ Notes:
|
||||
|
||||
If the telnet method isn't available for your model, factory reset the speaker, then re-migrate it:
|
||||
|
||||
1. Factory reset (on most models: hold `1` + `−` for ~10 seconds).
|
||||
1. Factory reset (on most models: hold `1` + `−` for ~10 seconds — confirmed
|
||||
identical on the SoundTouch 30 Series III, not just the original ST30).
|
||||
2. Reconnect the speaker to your network.
|
||||
3. Re-migrate it in AfterTouch.
|
||||
|
||||
@@ -648,6 +674,71 @@ Confirmed on hardware across five device variants (2026-08-09): different ports
|
||||
|
||||
**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,23 +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/mod v0.38.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
|
||||
)
|
||||
|
||||
@@ -32,9 +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/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=
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,12 @@ 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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -800,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,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")
|
||||
}
|
||||
}
|
||||
@@ -52,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
|
||||
@@ -71,7 +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 // nil unless SetUpdateChecker was called (opt-in, see #591)
|
||||
updateChecker *updatecheck.Checker // the HTTP-checking object; nil unless SetUpdateChecker was called
|
||||
spotifyClientID string
|
||||
spotifyClientSecret string
|
||||
spotifyRedirectURI string
|
||||
@@ -141,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)
|
||||
@@ -583,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 —
|
||||
@@ -1128,9 +1156,13 @@ func (s *Server) IsAnnouncementDismissed(id string) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
// SetUpdateChecker registers the opt-in periodic update checker (#591). Not
|
||||
// called at all unless UPDATE_CHECK_ENABLED — leave nil otherwise;
|
||||
// UpdateCheckResult handles that safely.
|
||||
// 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()
|
||||
|
||||
@@ -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) {
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1759,7 +1917,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 +2057,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 +2246,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 +2300,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 +2722,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 +2937,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 +3019,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 +3035,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 +3070,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 +3096,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 +3116,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 +3990,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 +4075,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 +4209,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 +4954,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 +5166,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])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,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"
|
||||
|
||||
@@ -157,6 +157,59 @@ func TestSetBoseURLs_RejectsDoubleQuote(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetAllBoseURLsViaTelnet_WritesAllFourBeforeEnvswitch is the regression
|
||||
// test for the stale statsServerUrl/bmxRegistryUrl bug reported in #621: the
|
||||
// XML migration's telnet resync used to commit `envswitch boseurls set` with
|
||||
// only marge/swUpdate as arguments, silently freezing whatever stats/bmx
|
||||
// happened to still be in the runtime layer at that moment. This asserts all
|
||||
// four `sys configuration` writes land before the single `envswitch` commit,
|
||||
// matching telnetURLs.Commands()'s known-good sequence.
|
||||
func TestSetAllBoseURLsViaTelnet_WritesAllFourBeforeEnvswitch(t *testing.T) {
|
||||
const targetURL = "http://localhost:8000"
|
||||
|
||||
urls := telnetURLs{
|
||||
Marge: targetURL,
|
||||
Stats: targetURL,
|
||||
SwUpdate: targetURL + "/updates/soundtouch",
|
||||
BmxRegistry: targetURL + "/bmx/registry/v1/services",
|
||||
}
|
||||
|
||||
want := urls.Commands()
|
||||
|
||||
resp := make(map[string]string, len(want))
|
||||
for _, c := range want {
|
||||
resp[c] = "OK\n"
|
||||
}
|
||||
|
||||
f := &fakeTelnet{responses: resp}
|
||||
m := newFakeTelnetManager(f)
|
||||
|
||||
if _, err := m.setAllBoseURLsViaTelnet("192.0.2.10", urls); err != nil {
|
||||
t.Fatalf("setAllBoseURLsViaTelnet: %v", err)
|
||||
}
|
||||
|
||||
if len(f.commands) != len(want) {
|
||||
t.Fatalf("sent %d commands %q\n want %d %q", len(f.commands), f.commands, len(want), want)
|
||||
}
|
||||
|
||||
for i, c := range want {
|
||||
if f.commands[i] != c {
|
||||
t.Errorf("command %d = %q\n want %q", i, f.commands[i], c)
|
||||
}
|
||||
}
|
||||
|
||||
envswitchIdx := len(want) - 1
|
||||
for i, c := range f.commands[:envswitchIdx] {
|
||||
if !strings.HasPrefix(c, "sys configuration ") {
|
||||
t.Errorf("command %d = %q, want a `sys configuration ...` runtime write before the envswitch commit", i, c)
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(f.commands[envswitchIdx], "envswitch boseurls set ") {
|
||||
t.Errorf("last command = %q, want the envswitch commit last", f.commands[envswitchIdx])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClose17000_RunsFirewallSteps(t *testing.T) {
|
||||
var ran []string
|
||||
|
||||
|
||||
@@ -5,6 +5,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)
|
||||
@@ -135,8 +141,8 @@ func (m *Manager) EnsureMargeAccountPaired(deviceIP, wantAccountID string, t Tel
|
||||
}
|
||||
|
||||
target = generated
|
||||
} else if !IsValidAccountID(target) {
|
||||
return "", false, "", fmt.Errorf("invalid account id %q: must be exactly 7 digits", target)
|
||||
} else if !datastore.IsSafeIdentifier(target) {
|
||||
return "", false, "", fmt.Errorf("invalid account id %q: must be a non-empty, path-safe identifier", target)
|
||||
}
|
||||
|
||||
_, pairLogs, pairErr := m.PairAccount(deviceIP, target, t)
|
||||
@@ -196,9 +202,18 @@ func (m *Manager) probeSetMargeAccount(deviceIP string) (bool, error) {
|
||||
func (m *Manager) postSetMargeAccount(deviceIP, accountID string) error {
|
||||
url := buildDeviceURL(deviceIP, "/setMargeAccount")
|
||||
|
||||
// accountID is XML-escaped rather than interpolated raw:
|
||||
// datastore.IsSafeIdentifier already excludes '<', '>', '&', '\'', '"'
|
||||
// (see #634), but escaping here too means this stays well-formed even
|
||||
// if that gate is ever bypassed.
|
||||
var escapedAccountID bytes.Buffer
|
||||
if err := xml.EscapeText(&escapedAccountID, []byte(accountID)); err != nil {
|
||||
return fmt.Errorf("escape account ID: %w", err)
|
||||
}
|
||||
|
||||
body := fmt.Sprintf(
|
||||
`<PairDeviceWithAccount><accountId>%s</accountId><userAuthToken>aftertouch</userAuthToken></PairDeviceWithAccount>`,
|
||||
accountID,
|
||||
escapedAccountID.String(),
|
||||
)
|
||||
|
||||
client := &http.Client{
|
||||
@@ -225,6 +240,82 @@ func (m *Manager) postSetMargeAccount(deviceIP, accountID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConfigurationStatus values reported by GET /soundTouchConfigurationStatus.
|
||||
// See issue #615: a speaker can be reachable, named, and already
|
||||
// account-paired yet still report SOUNDTOUCH_NOT_CONFIGURED, which leaves
|
||||
// the firmware nagging the owner to install the Bose app. Only a full pass
|
||||
// through the WebSocket setup state machine (ExecuteInitPlan) clears it.
|
||||
const (
|
||||
ConfigurationStatusConfigured = "SOUNDTOUCH_CONFIGURED"
|
||||
ConfigurationStatusNotConfigured = "SOUNDTOUCH_NOT_CONFIGURED"
|
||||
)
|
||||
|
||||
// ReadConfigurationStatus fetches /soundTouchConfigurationStatus and returns
|
||||
// its raw status attribute (e.g. "SOUNDTOUCH_CONFIGURED").
|
||||
func (m *Manager) ReadConfigurationStatus(deviceIP string) (string, error) {
|
||||
url := buildDeviceURL(deviceIP, "/soundTouchConfigurationStatus")
|
||||
|
||||
client := &http.Client{Timeout: supportedURLsTimeout}
|
||||
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("GET %s: %w", url, err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("GET %s returned %d", url, resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read %s: %w", url, err)
|
||||
}
|
||||
|
||||
var doc struct {
|
||||
Status string `xml:"status,attr"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(body, &doc); err != nil {
|
||||
return "", fmt.Errorf("parse %s: %w", url, err)
|
||||
}
|
||||
|
||||
return doc.Status, nil
|
||||
}
|
||||
|
||||
// PreflightInitPlan reports whether ExecuteInitPlan should be run against
|
||||
// deviceIP, gated on the two conditions from issue #615: /setMargeAccount
|
||||
// must be listed in /supportedURLs, and the device's current
|
||||
// /soundTouchConfigurationStatus must be exactly SOUNDTOUCH_NOT_CONFIGURED.
|
||||
// needed=false with a nil error means "already configured, nothing to do."
|
||||
// Any other outcome (unsupported route, unrecognised status value) is
|
||||
// treated as unknown and returned as an error rather than guessed at.
|
||||
func (m *Manager) PreflightInitPlan(deviceIP string) (needed bool, status string, err error) {
|
||||
supported, probeErr := m.probeSetMargeAccount(deviceIP)
|
||||
if probeErr != nil {
|
||||
return false, "", fmt.Errorf("supportedURLs probe: %w", probeErr)
|
||||
}
|
||||
|
||||
if !supported {
|
||||
return false, "", errors.New("/setMargeAccount is not listed in /supportedURLs — device does not support this pairing path")
|
||||
}
|
||||
|
||||
status, err = m.ReadConfigurationStatus(deviceIP)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("read /soundTouchConfigurationStatus: %w", err)
|
||||
}
|
||||
|
||||
switch status {
|
||||
case ConfigurationStatusConfigured:
|
||||
return false, status, nil
|
||||
case ConfigurationStatusNotConfigured:
|
||||
return true, status, nil
|
||||
default:
|
||||
return false, status, fmt.Errorf("unexpected /soundTouchConfigurationStatus value %q", status)
|
||||
}
|
||||
}
|
||||
|
||||
// buildDeviceURL builds a URL for a SoundTouch device's HTTP API. If
|
||||
// deviceIP already includes a port (test scenarios using httptest) it is
|
||||
// reused as-is; otherwise the canonical port 8090 is appended.
|
||||
@@ -236,23 +327,6 @@ func buildDeviceURL(deviceIP, path string) string {
|
||||
return "http://" + deviceIP + ":8090" + path
|
||||
}
|
||||
|
||||
// IsValidAccountID reports whether s is a syntactically valid SoundTouch
|
||||
// account ID — exactly 7 numeric digits, the format used by every
|
||||
// Bose-cloud-issued ID we have observed in captures.
|
||||
func IsValidAccountID(s string) bool {
|
||||
if len(s) != 7 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, ch := range s {
|
||||
if ch < '0' || ch > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// GenerateAccountID returns a fresh 7-digit account ID that does not collide
|
||||
// with any value in known. It uses crypto/rand and re-rolls on collision.
|
||||
func GenerateAccountID(known []string) (string, error) {
|
||||
|
||||
@@ -10,19 +10,22 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// fakeDevice spins up an httptest.Server that pretends to be the SoundTouch
|
||||
// device's :8090 HTTP API. It records POSTs to /setMargeAccount so tests
|
||||
// can assert on the body.
|
||||
type fakeDevice struct {
|
||||
srv *httptest.Server
|
||||
addr string // "host:port" usable as deviceIP
|
||||
supportsSetMarge bool
|
||||
postStatus int // status code returned for POST /setMargeAccount
|
||||
postDelay time.Duration
|
||||
gotPostBody string
|
||||
margeAccountUUID string // served by /info; empty means "unpaired"
|
||||
srv *httptest.Server
|
||||
addr string // "host:port" usable as deviceIP
|
||||
supportsSetMarge bool
|
||||
postStatus int // status code returned for POST /setMargeAccount
|
||||
postDelay time.Duration
|
||||
gotPostBody string
|
||||
margeAccountUUID string // served by /info; empty means "unpaired"
|
||||
configurationStatus string // served by /soundTouchConfigurationStatus; empty = route not served (404)
|
||||
}
|
||||
|
||||
func newFakeDevice(t *testing.T) *fakeDevice {
|
||||
@@ -62,6 +65,16 @@ func newFakeDevice(t *testing.T) *fakeDevice {
|
||||
fmt.Fprintf(w, `<info deviceID="AABBCCDDEE0A"><margeAccountUUID>%s</margeAccountUUID></info>`, d.margeAccountUUID)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/soundTouchConfigurationStatus", func(w http.ResponseWriter, _ *http.Request) {
|
||||
if d.configurationStatus == "" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprintf(w, `<SoundTouchConfigurationStatus status="%s" />`, d.configurationStatus)
|
||||
})
|
||||
|
||||
d.srv = httptest.NewServer(mux)
|
||||
|
||||
u := d.srv.URL[len("http://"):]
|
||||
@@ -302,7 +315,7 @@ func TestEnsureMargeAccountPaired_UnpairedGeneratesAndPairs(t *testing.T) {
|
||||
t.Error("alreadyPaired should be false for an unpaired device")
|
||||
}
|
||||
|
||||
if !IsValidAccountID(accountID) {
|
||||
if !datastore.IsSafeIdentifier(accountID) {
|
||||
t.Errorf("accountID %q is not a valid generated ID", accountID)
|
||||
}
|
||||
|
||||
@@ -341,7 +354,7 @@ func TestEnsureMargeAccountPaired_RejectsInvalidWantAccountID(t *testing.T) {
|
||||
|
||||
m := NewManager("", nil, nil)
|
||||
|
||||
_, _, _, err := m.EnsureMargeAccountPaired(d.addr, "not-7-digits", nil)
|
||||
_, _, _, err := m.EnsureMargeAccountPaired(d.addr, "not/valid", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for an invalid --account value")
|
||||
}
|
||||
@@ -360,36 +373,121 @@ func TestEnsureMargeAccountPaired_PropagatesPairingFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidAccountID(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"1234567", true},
|
||||
{"0000000", true},
|
||||
{"9999999", true},
|
||||
{"", false},
|
||||
{"123456", false},
|
||||
{"12345678", false},
|
||||
{"123456a", false},
|
||||
{"-123456", false},
|
||||
{" 123456", false},
|
||||
func TestReadConfigurationStatus_ReturnsRawStatus(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.configurationStatus = ConfigurationStatusConfigured
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
status, err := m.ReadConfigurationStatus(d.addr)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadConfigurationStatus: %v", err)
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := IsValidAccountID(tc.in); got != tc.want {
|
||||
t.Errorf("IsValidAccountID(%q) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
if status != ConfigurationStatusConfigured {
|
||||
t.Errorf("status = %q, want %q", status, ConfigurationStatusConfigured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadConfigurationStatus_ErrorsWhenRouteUnsupported(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.configurationStatus = ""
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
if _, err := m.ReadConfigurationStatus(d.addr); err == nil {
|
||||
t.Fatal("expected an error when the route is unsupported (404)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightInitPlan_NotConfiguredNeedsRepair(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.configurationStatus = ConfigurationStatusNotConfigured
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
needed, status, err := m.PreflightInitPlan(d.addr)
|
||||
if err != nil {
|
||||
t.Fatalf("PreflightInitPlan: %v", err)
|
||||
}
|
||||
|
||||
if !needed {
|
||||
t.Error("needed should be true for SOUNDTOUCH_NOT_CONFIGURED")
|
||||
}
|
||||
|
||||
if status != ConfigurationStatusNotConfigured {
|
||||
t.Errorf("status = %q, want %q", status, ConfigurationStatusNotConfigured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightInitPlan_AlreadyConfiguredIsNoOp(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.configurationStatus = ConfigurationStatusConfigured
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
needed, status, err := m.PreflightInitPlan(d.addr)
|
||||
if err != nil {
|
||||
t.Fatalf("PreflightInitPlan: %v", err)
|
||||
}
|
||||
|
||||
if needed {
|
||||
t.Error("needed should be false for SOUNDTOUCH_CONFIGURED")
|
||||
}
|
||||
|
||||
if status != ConfigurationStatusConfigured {
|
||||
t.Errorf("status = %q, want %q", status, ConfigurationStatusConfigured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightInitPlan_UnsupportedSetMargeAccountFailsClosed(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.supportsSetMarge = false
|
||||
d.configurationStatus = ConfigurationStatusNotConfigured
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
needed, _, err := m.PreflightInitPlan(d.addr)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when /setMargeAccount is not listed in /supportedURLs")
|
||||
}
|
||||
|
||||
if needed {
|
||||
t.Error("needed should be false when preflight fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightInitPlan_UnrecognisedStatusFailsClosed(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.configurationStatus = "SOMETHING_UNEXPECTED"
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
needed, status, err := m.PreflightInitPlan(d.addr)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for an unrecognised status value")
|
||||
}
|
||||
|
||||
if needed {
|
||||
t.Error("needed should be false when the status is unrecognised")
|
||||
}
|
||||
|
||||
if status != "SOMETHING_UNEXPECTED" {
|
||||
t.Errorf("status = %q, want the raw unrecognised value returned alongside the error", status)
|
||||
}
|
||||
}
|
||||
|
||||
// Account-ID format validation is now solely datastore.IsSafeIdentifier's
|
||||
// responsibility (see datastore.TestIsSafeIdentifier); setup no longer has
|
||||
// its own account-ID validator to test.
|
||||
|
||||
func TestGenerateAccountID_AvoidsCollisions(t *testing.T) {
|
||||
id, err := GenerateAccountID(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAccountID(nil): %v", err)
|
||||
}
|
||||
|
||||
if !IsValidAccountID(id) {
|
||||
if !datastore.IsSafeIdentifier(id) {
|
||||
t.Errorf("generated ID %q is not valid", id)
|
||||
}
|
||||
|
||||
|
||||
+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)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package soundtouchweb
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -10,12 +11,18 @@ import (
|
||||
"github.com/gesellix/bose-soundtouch/pkg/config"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/speaker"
|
||||
)
|
||||
|
||||
// NewDiscoveryService loads config and returns a unified discovery service
|
||||
// preconfigured for the web UI's use (10 s discovery timeout, cache on).
|
||||
// When discoveryInterface is non-empty, mDNS/UPnP are pinned to that NIC.
|
||||
func NewDiscoveryService(discoveryInterface string) *discovery.UnifiedDiscoveryService {
|
||||
// configuredHosts (e.g. from --devices) are folded into cfg.PreferredDevices
|
||||
// alongside any already loaded from PREFERRED_DEVICES (deduplicated by
|
||||
// host), so they're retried on every subsequent DiscoverDevices pass, not
|
||||
// just once at startup -- a host that's offline now still gets picked up
|
||||
// once it comes online.
|
||||
func NewDiscoveryService(discoveryInterface string, configuredHosts ...string) *discovery.UnifiedDiscoveryService {
|
||||
cfg, err := config.LoadFromEnv()
|
||||
if err != nil {
|
||||
log.Printf("Failed to load config: %v, using defaults", err)
|
||||
@@ -30,6 +37,23 @@ func NewDiscoveryService(discoveryInterface string) *discovery.UnifiedDiscoveryS
|
||||
cfg.DiscoveryInterface = discoveryInterface
|
||||
}
|
||||
|
||||
existingHosts := make(map[string]bool, len(cfg.PreferredDevices))
|
||||
for _, d := range cfg.PreferredDevices {
|
||||
existingHosts[d.Host] = true
|
||||
}
|
||||
|
||||
for _, host := range configuredHosts {
|
||||
if host == "" || existingHosts[host] {
|
||||
continue
|
||||
}
|
||||
|
||||
cfg.PreferredDevices = append(cfg.PreferredDevices, config.DeviceConfig{
|
||||
Host: host,
|
||||
Port: speaker.HTTPPort,
|
||||
})
|
||||
existingHosts[host] = true
|
||||
}
|
||||
|
||||
return discovery.NewUnifiedDiscoveryService(cfg)
|
||||
}
|
||||
|
||||
@@ -164,6 +188,21 @@ func (app *WebApp) DiscoverDevices(ctx context.Context, discoveryService *discov
|
||||
log.Printf("Found %d devices", len(devices))
|
||||
|
||||
for _, device := range devices {
|
||||
app.AddDeviceByHost(device.Host, device.Port, "discovered")
|
||||
app.AddDeviceByHost(device.Host, device.Port, classifySource(device.DiscoveryMethod))
|
||||
}
|
||||
}
|
||||
|
||||
// classifySource labels a discovered device "manual" if it came from (at
|
||||
// least in part) a configured host, "discovered" otherwise. discoveryMethod
|
||||
// can be a "+"-joined composite (e.g. "Configuration+mDNS/Bonjour") when
|
||||
// mergeDeviceData combines a configured host with the same device found via
|
||||
// mDNS/UPnP in the same sweep -- match by substring, not exact equality, so
|
||||
// a manually configured host that's also independently discoverable still
|
||||
// gets labeled "manual".
|
||||
func classifySource(discoveryMethod string) string {
|
||||
if strings.Contains(discoveryMethod, "Configuration") {
|
||||
return "manual"
|
||||
}
|
||||
|
||||
return "discovered"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package soundtouchweb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDiscoverDevicesRetriesConfiguredHosts(t *testing.T) {
|
||||
var available atomic.Bool
|
||||
var infoRequests atomic.Int32
|
||||
|
||||
// NewTestServer (Go 1.27) registers its own t.Cleanup(Close) instead of
|
||||
// needing a manual defer, and fails the test on a handler panic. It
|
||||
// defaults to an in-memory transport reachable only via Server.Client(),
|
||||
// but our production client.NewClient dials a real address, so Start()
|
||||
// (rather than Client()) is used here to get a real loopback listener,
|
||||
// same as the old NewServer -- see https://pkg.go.dev/net/http/httptest#NewTestServer.
|
||||
server := httptest.NewTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/info" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
infoRequests.Add(1)
|
||||
if !available.Load() {
|
||||
http.Error(w, "offline", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(`<info deviceID="TESTDEVICE"><name>Configured speaker</name><type>SoundTouch 10</type></info>`))
|
||||
}))
|
||||
server.Start()
|
||||
|
||||
t.Setenv("UPNP_ENABLED", "false")
|
||||
t.Setenv("MDNS_ENABLED", "false")
|
||||
t.Setenv("PREFERRED_DEVICES", "")
|
||||
|
||||
configuredHost := strings.TrimPrefix(server.URL, "http://")
|
||||
discoveryService := NewDiscoveryService("", configuredHost)
|
||||
app := NewWebApp()
|
||||
|
||||
app.DiscoverDevices(context.Background(), discoveryService)
|
||||
if got := app.DeviceCount(); got != 0 {
|
||||
t.Fatalf("device count after offline probe = %d, want 0", got)
|
||||
}
|
||||
|
||||
available.Store(true)
|
||||
app.DiscoverDevices(context.Background(), discoveryService)
|
||||
if got := app.DeviceCount(); got != 1 {
|
||||
t.Fatalf("device count after retry = %d, want 1", got)
|
||||
}
|
||||
if got := infoRequests.Load(); got != 2 {
|
||||
t.Fatalf("/info request count = %d, want 2", got)
|
||||
}
|
||||
|
||||
if !app.RemoveDevice(configuredHost) {
|
||||
t.Fatal("configured device was not registered under its host")
|
||||
}
|
||||
|
||||
// AddDeviceByHost spawns a one-shot status-update goroutine and a 30s-
|
||||
// ticker poll loop on successful registration. RemoveDevice signals the
|
||||
// ticker loop to exit via conn.Done() but doesn't wait for it to actually
|
||||
// observe the close, and the one-shot goroutine has no cancellation at
|
||||
// all. Give them a moment to finish before the deferred server.Close()
|
||||
// runs, so a still-in-flight request against the closing httptest server
|
||||
// doesn't produce log noise or -race flakiness.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
// TestClassifySource covers the case the test above can't reach without a
|
||||
// real mDNS/UPnP sweep: mergeDeviceData joins discovery methods with "+"
|
||||
// when a configured host is also found via mDNS/UPnP in the same pass (see
|
||||
// pkg/discovery/unified.go), so DiscoveryMethod is not always exactly
|
||||
// "Configuration" for a manually configured device.
|
||||
func TestClassifySource(t *testing.T) {
|
||||
tests := []struct {
|
||||
discoveryMethod string
|
||||
want string
|
||||
}{
|
||||
{"Configuration", "manual"},
|
||||
{"Configuration+mDNS/Bonjour", "manual"},
|
||||
{"mDNS/Bonjour+Configuration", "manual"},
|
||||
{"mDNS/Bonjour", "discovered"},
|
||||
{"SSDP/UPnP", "discovered"},
|
||||
{"", "discovered"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := classifySource(tt.discoveryMethod); got != tt.want {
|
||||
t.Errorf("classifySource(%q) = %q, want %q", tt.discoveryMethod, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
|
||||
@@ -55,10 +57,17 @@ func normalizeUDN(s string) string {
|
||||
}
|
||||
|
||||
// HandleDiscoverLibraryServers performs a LAN-wide SSDP sweep for DLNA media
|
||||
// servers and returns them as a JSON array. An optional ?timeout= query
|
||||
// parameter (in seconds, integer) overrides the default 5-second budget.
|
||||
// servers, plus a query to every paired speaker's own /listMediaServers, and
|
||||
// returns the merged set as a JSON array. An optional ?timeout= query
|
||||
// parameter (in seconds, integer) overrides the default 5-second SSDP budget.
|
||||
// This handler is global (not device-scoped) and lives under
|
||||
// /api/control/providers/library/servers.
|
||||
//
|
||||
// The two sources see different networks: our SSDP sweep runs from the
|
||||
// AfterTouch service host, while each speaker's /listMediaServers reflects
|
||||
// what that speaker sees on its own LAN segment. They can disagree when the
|
||||
// service isn't co-located with the speaker (different subnet/VLAN), so a
|
||||
// server invisible to one path may still be visible via the other.
|
||||
func (app *WebApp) HandleDiscoverLibraryServers(w http.ResponseWriter, r *http.Request) {
|
||||
timeout := 5 * time.Second
|
||||
|
||||
@@ -74,15 +83,44 @@ func (app *WebApp) HandleDiscoverLibraryServers(w http.ResponseWriter, r *http.R
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]libraryServer, 0, len(servers))
|
||||
byUDN := make(map[string]libraryServer, len(servers))
|
||||
order := make([]string, 0, len(servers))
|
||||
|
||||
for _, s := range servers {
|
||||
out = append(out, libraryServer{
|
||||
UDN: normalizeUDN(s.UDN),
|
||||
udn := normalizeUDN(s.UDN)
|
||||
byUDN[udn] = libraryServer{
|
||||
UDN: udn,
|
||||
Name: s.FriendlyName,
|
||||
Manufacturer: s.Manufacturer,
|
||||
Model: s.ModelName,
|
||||
CDSControlURL: s.CDSControlURL,
|
||||
})
|
||||
}
|
||||
order = append(order, udn)
|
||||
}
|
||||
|
||||
deviceFound := app.discoverDeviceMediaServers()
|
||||
for i := range deviceFound {
|
||||
found := &deviceFound[i]
|
||||
|
||||
udn := normalizeUDN(found.ID)
|
||||
if _, exists := byUDN[udn]; exists {
|
||||
// Already found via SSDP, which carries CDSControlURL; keep that
|
||||
// entry since registration itself only needs the UDN and name.
|
||||
continue
|
||||
}
|
||||
|
||||
byUDN[udn] = libraryServer{
|
||||
UDN: udn,
|
||||
Name: found.FriendlyName,
|
||||
Manufacturer: found.Manufacturer,
|
||||
Model: found.ModelName,
|
||||
}
|
||||
order = append(order, udn)
|
||||
}
|
||||
|
||||
out := make([]libraryServer, 0, len(order))
|
||||
for _, udn := range order {
|
||||
out = append(out, byUDN[udn])
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -92,6 +130,71 @@ func (app *WebApp) HandleDiscoverLibraryServers(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
}
|
||||
|
||||
// discoverDeviceMediaServers queries every paired speaker's own
|
||||
// /listMediaServers endpoint concurrently and returns the union of what they
|
||||
// report, deduplicated by UDN. A speaker that is offline, times out, or runs
|
||||
// firmware without the endpoint is skipped silently: this is a best-effort
|
||||
// second discovery path, and one unreachable speaker must not fail or delay
|
||||
// the overall response.
|
||||
func (app *WebApp) discoverDeviceMediaServers() []models.MediaServerInfo {
|
||||
devices := app.DeviceSnapshot()
|
||||
|
||||
type result struct {
|
||||
servers []models.MediaServerInfo
|
||||
}
|
||||
|
||||
results := make(chan result, len(devices))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, entry := range devices {
|
||||
deviceClient := entry.Device.Client
|
||||
if deviceClient == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
|
||||
go func(c *client.Client) {
|
||||
defer wg.Done()
|
||||
|
||||
resp, err := c.ListMediaServers()
|
||||
if err != nil || resp == nil {
|
||||
results <- result{}
|
||||
return
|
||||
}
|
||||
|
||||
results <- result{servers: resp.MediaServers}
|
||||
}(deviceClient)
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(results)
|
||||
}()
|
||||
|
||||
seen := make(map[string]bool)
|
||||
|
||||
out := make([]models.MediaServerInfo, 0, len(devices))
|
||||
|
||||
for r := range results {
|
||||
for i := range r.servers {
|
||||
s := &r.servers[i]
|
||||
|
||||
udn := normalizeUDN(s.ID)
|
||||
if udn == "" || seen[udn] {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[udn] = true
|
||||
|
||||
out = append(out, *s)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// HandleDeviceLibraryServers returns the STORED_MUSIC sources currently
|
||||
// registered on a specific speaker. Each source corresponds to one DLNA
|
||||
// server that has been paired with that device.
|
||||
|
||||
@@ -733,3 +733,118 @@ func TestHandleAddLibraryServer_AlreadyRegistered(t *testing.T) {
|
||||
t.Errorf("expected success=true when error contains 1024, got error=%s", resp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- discoverDeviceMediaServers (speaker-side /listMediaServers) --------
|
||||
|
||||
// cannedListMediaServersResponse is a minimal /listMediaServers XML response
|
||||
// with one server, used to exercise the speaker-side discovery merge path
|
||||
// that complements our own SSDP sweep.
|
||||
const cannedListMediaServersResponse = `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<ListMediaServersResponse>
|
||||
<media_server id="uuid:fa095ecc-uuid" ip="198.51.100.10" manufacturer="AVM" model_name="FRITZ!Mediaserver" friendly_name="FRITZ!Mediaserver"/>
|
||||
</ListMediaServersResponse>`
|
||||
|
||||
// newMediaServerTestDevice registers a device backed by speakerURL under id,
|
||||
// mirroring newLibraryTestApp's setup but for tests that need more than one
|
||||
// device on the same WebApp.
|
||||
func newMediaServerTestDevice(app *WebApp, id, speakerURL string) {
|
||||
c := client.NewClient(&client.Config{Host: speakerURL})
|
||||
info := &models.DeviceInfo{DeviceID: id}
|
||||
app.AddDevice(id, webtypes.NewDeviceConnection(c, info))
|
||||
}
|
||||
|
||||
// TestDiscoverDeviceMediaServers_QueriesEveryPairedSpeaker verifies that
|
||||
// discoverDeviceMediaServers calls /listMediaServers on every paired device
|
||||
// and returns the union of what they report. This is the path that lets
|
||||
// discovery see a server the AfterTouch service's own SSDP sweep might miss
|
||||
// because it isn't co-located with the speaker's LAN segment.
|
||||
func TestDiscoverDeviceMediaServers_QueriesEveryPairedSpeaker(t *testing.T) {
|
||||
speakerA, _ := setupSpeakerMock(t, map[string]string{
|
||||
"/listMediaServers": cannedListMediaServersResponse,
|
||||
})
|
||||
defer speakerA.Close()
|
||||
|
||||
speakerB, _ := setupSpeakerMock(t, map[string]string{
|
||||
"/listMediaServers": `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<ListMediaServersResponse>
|
||||
<media_server id="uuid:other-udn" ip="198.51.100.20" manufacturer="Synology" model_name="DS220" friendly_name="NAS"/>
|
||||
</ListMediaServersResponse>`,
|
||||
})
|
||||
defer speakerB.Close()
|
||||
|
||||
app := NewWebApp()
|
||||
newMediaServerTestDevice(app, "dev-a", speakerA.URL)
|
||||
newMediaServerTestDevice(app, "dev-b", speakerB.URL)
|
||||
|
||||
got := app.discoverDeviceMediaServers()
|
||||
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 servers across both speakers, got %d: %+v", len(got), got)
|
||||
}
|
||||
|
||||
udns := map[string]bool{}
|
||||
for _, s := range got {
|
||||
udns[normalizeUDN(s.ID)] = true
|
||||
}
|
||||
|
||||
if !udns["fa095ecc-uuid"] || !udns["other-udn"] {
|
||||
t.Errorf("expected both UDNs present, got %+v", udns)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverDeviceMediaServers_DedupesAcrossSpeakers verifies that the same
|
||||
// server reported by two speakers (e.g. two boxes on the same LAN both seeing
|
||||
// one NAS) is returned only once, keyed by normalized UDN, even when the two
|
||||
// speakers report the UDN in different forms (with/without "uuid:" prefix).
|
||||
func TestDiscoverDeviceMediaServers_DedupesAcrossSpeakers(t *testing.T) {
|
||||
speakerA, _ := setupSpeakerMock(t, map[string]string{
|
||||
"/listMediaServers": cannedListMediaServersResponse,
|
||||
})
|
||||
defer speakerA.Close()
|
||||
|
||||
speakerB, _ := setupSpeakerMock(t, map[string]string{
|
||||
"/listMediaServers": `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<ListMediaServersResponse>
|
||||
<media_server id="fa095ecc-uuid" ip="198.51.100.10" manufacturer="AVM" model_name="FRITZ!Mediaserver" friendly_name="FRITZ!Mediaserver"/>
|
||||
</ListMediaServersResponse>`,
|
||||
})
|
||||
defer speakerB.Close()
|
||||
|
||||
app := NewWebApp()
|
||||
newMediaServerTestDevice(app, "dev-a", speakerA.URL)
|
||||
newMediaServerTestDevice(app, "dev-b", speakerB.URL)
|
||||
|
||||
got := app.discoverDeviceMediaServers()
|
||||
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 deduplicated server, got %d: %+v", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverDeviceMediaServers_UnreachableSpeakerSkippedSilently verifies
|
||||
// that a speaker whose /listMediaServers call fails (offline, old firmware
|
||||
// without the endpoint) does not prevent results from other, reachable
|
||||
// speakers, and does not error the overall call.
|
||||
func TestDiscoverDeviceMediaServers_UnreachableSpeakerSkippedSilently(t *testing.T) {
|
||||
speakerA, _ := setupSpeakerMock(t, map[string]string{
|
||||
"/listMediaServers": cannedListMediaServersResponse,
|
||||
})
|
||||
defer speakerA.Close()
|
||||
|
||||
speakerB, _ := setupSpeakerMock(t, nil)
|
||||
speakerB.Close() // closed before use: every request to it fails outright.
|
||||
|
||||
app := NewWebApp()
|
||||
newMediaServerTestDevice(app, "dev-a", speakerA.URL)
|
||||
newMediaServerTestDevice(app, "dev-b", speakerB.URL)
|
||||
|
||||
got := app.discoverDeviceMediaServers()
|
||||
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 server from the reachable speaker, got %d: %+v", len(got), got)
|
||||
}
|
||||
|
||||
if normalizeUDN(got[0].ID) != "fa095ecc-uuid" {
|
||||
t.Errorf("expected the reachable speaker's server, got %+v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export function Announcements() {
|
||||
${a.message}
|
||||
${a.link_url ? html` <a href=${a.link_url} target="_blank" rel="noopener">${a.link_text || a.link_url}</a>` : null}
|
||||
</span>
|
||||
<button class="announcement-dismiss" onClick=${() => dismiss(a.id)} title="Dismiss">×</button>
|
||||
<button class="announcement-dismiss" onClick=${() => dismiss(a.id)} title="Dismiss">×</button>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
|
||||
+69
-12
@@ -14,6 +14,15 @@ import (
|
||||
type Client struct {
|
||||
Host string
|
||||
User string
|
||||
|
||||
// conn is non-nil once Connect has been called, and is then reused by
|
||||
// Run/UploadContent until Close. Left nil, each Run/UploadContent call
|
||||
// dials its own one-off connection as before — Connect is opt-in for
|
||||
// callers making several calls in a row (e.g. RevertMigration's ~17
|
||||
// commands), where dialing fresh every time is both slow and, on a
|
||||
// resource-constrained speaker, has been observed to overwhelm the
|
||||
// device (#614 self-test, 2026-08-16).
|
||||
conn *ssh.Client
|
||||
}
|
||||
|
||||
// NewClient creates a new SSH client for the given host. The default user is "root".
|
||||
@@ -66,21 +75,71 @@ func (c *Client) getConfig() *ssh.ClientConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// Connect opens a persistent SSH connection reused by subsequent
|
||||
// Run/UploadContent calls, instead of each dialing its own. Call Close when
|
||||
// done with it. Idempotent — calling Connect again while already connected
|
||||
// is a no-op. Skip this for a single (or a rare few) command — dialing
|
||||
// once and reusing it is only worth the extra Close bookkeeping when
|
||||
// several calls follow in quick succession.
|
||||
func (c *Client) Connect() error {
|
||||
if c.conn != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
conn, err := ssh.Dial("tcp", c.Host+":22", c.getConfig())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to dial: %w", err)
|
||||
}
|
||||
|
||||
c.conn = conn
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the persistent connection opened by Connect, if any. Safe
|
||||
// to call even when Connect was never called (e.g. every Run/UploadContent
|
||||
// call so far used its own one-off connection).
|
||||
func (c *Client) Close() error {
|
||||
if c.conn == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := c.conn.Close()
|
||||
c.conn = nil
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// dial returns the persistent connection from Connect if one is open,
|
||||
// otherwise dials a fresh one-off connection for the caller to close via
|
||||
// the returned closeFunc (a no-op when reusing the persistent connection —
|
||||
// that one is only closed by an explicit Close call).
|
||||
func (c *Client) dial() (conn *ssh.Client, closeFunc func(), err error) {
|
||||
if c.conn != nil {
|
||||
return c.conn, func() {}, nil
|
||||
}
|
||||
|
||||
conn, err = ssh.Dial("tcp", c.Host+":22", c.getConfig())
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to dial: %w", err)
|
||||
}
|
||||
|
||||
return conn, func() { _ = conn.Close() }, nil
|
||||
}
|
||||
|
||||
// Run executes a command on the remote host and returns the combined stdout and stderr.
|
||||
//
|
||||
// command MUST be a hardcoded shell literal or constructed entirely from
|
||||
// internal, service-controlled values — never from user-supplied HTTP input.
|
||||
func (c *Client) Run(command string) (string, error) {
|
||||
config := c.getConfig()
|
||||
|
||||
client, err := ssh.Dial("tcp", c.Host+":22", config)
|
||||
conn, closeConn, err := c.dial()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to dial: %w", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
defer func() { _ = client.Close() }()
|
||||
defer closeConn()
|
||||
|
||||
session, err := client.NewSession()
|
||||
session, err := conn.NewSession()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
@@ -133,16 +192,14 @@ func (c *Client) ReadDir(remotePath string) (map[string][]byte, error) {
|
||||
|
||||
// UploadContent uploads the given content to a file on the remote host using stdin piping.
|
||||
func (c *Client) UploadContent(content []byte, remotePath string) error {
|
||||
config := c.getConfig()
|
||||
|
||||
client, err := ssh.Dial("tcp", c.Host+":22", config)
|
||||
conn, closeConn, err := c.dial()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to dial: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() { _ = client.Close() }()
|
||||
defer closeConn()
|
||||
|
||||
session, err := client.NewSession()
|
||||
session, err := conn.NewSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
|
||||
@@ -44,3 +44,33 @@ func TestRun_DialFailure(t *testing.T) {
|
||||
t.Errorf("Expected 'failed to dial' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClose_NoOpWithoutConnect(t *testing.T) {
|
||||
client := NewClient("127.0.0.1")
|
||||
|
||||
if err := client.Close(); err != nil {
|
||||
t.Errorf("Close on a never-connected client should be a no-op, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnect_DialFailureLeavesConnNil(t *testing.T) {
|
||||
client := NewClient("127.0.0.1:0")
|
||||
|
||||
err := client.Connect()
|
||||
if err == nil {
|
||||
t.Fatal("Expected dial failure, got nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "failed to dial") {
|
||||
t.Errorf("Expected 'failed to dial' error, got: %v", err)
|
||||
}
|
||||
|
||||
if client.conn != nil {
|
||||
t.Error("Connect should leave conn nil after a dial failure, so Run/UploadContent still fall back to their own one-off dial")
|
||||
}
|
||||
|
||||
// Close after a failed Connect should still be a harmless no-op.
|
||||
if err := client.Close(); err != nil {
|
||||
t.Errorf("Close after a failed Connect should be a no-op, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,11 @@ If your device doesn't expose the port, you can still use the on-device installe
|
||||
|
||||
The storage space on the SoundTouch devices is very limited — stock rootfs typically has only a few MB free (e.g. ~4 MB on the ST20, see issue #268), well below the AfterTouch binary's ~12 MB. To work around this, the installer puts everything on `/mnt/nv/aftertouch` by default (the persistent partition, typically ~30 MB free) and points `/opt/aftertouch` at it via a symlink so the init script and runtime paths stay unchanged. Override the install target with `INSTALL_DIR=/some/path` if you've got room elsewhere.
|
||||
|
||||
The space limitation also means we are currently unsure on how to update the system, because two binaries are already too large. We are currently working on this - both by checking how we can make the binaries smaller, but also on how we can extend the storage space (e.g. by running AfterTouch from a USB drive).
|
||||
Updating is genuinely tight on this partition, since the old binary, the new one, and a rollback backup can't all comfortably fit at once, and binaries only keep growing (the Go toolchain's own defaults alone add hundreds of KB per major version, independent of anything in this project). The installer handles this in a few ways:
|
||||
- The rollback backup is gzip-compressed (`.backup.gz`) rather than a plain copy, cutting its footprint by roughly a third.
|
||||
- Before downloading anything, it checks whether there's actually enough free space for the update, using the new binary's real size (a HEAD request), not a guess.
|
||||
- If there's enough room for the update itself but not enough extra for a backup, it asks for confirmation before proceeding without one — reading from `/dev/tty` since the installer is normally run as `curl | sh`. The default (empty input, or no `/dev/tty` available at all) is always to abort rather than silently skip the backup; set `AFTERTOUCH_FORCE_NO_BACKUP=yes` to skip that prompt for unattended/scripted installs.
|
||||
- If there isn't even enough room for the update itself, it aborts before downloading anything, rather than leaving a partially-overwritten, non-executable binary in place.
|
||||
|
||||
### Logs
|
||||
|
||||
@@ -56,7 +60,71 @@ After the installation check if you can access AfterTouch from your local device
|
||||
|
||||
### If `http://<IP_ADDRESS_OF_SPEAKER>:8000` fails: SSH port forwarding
|
||||
|
||||
Some firmware images only bind the AfterTouch HTTP port to loopback (see issue #196). The workaround is an SSH tunnel — your machine talks to its own local `:8000`, the SSH connection forwards to the speaker's `:8000` on loopback.
|
||||
On some device models AfterTouch's port is reachable from other machines on
|
||||
your LAN out of the box. On others (see issue #196) it isn't, and (unlike
|
||||
the phrasing this README used to have) that's not AfterTouch or its
|
||||
firewall configuration choosing to bind loopback-only. AfterTouch itself
|
||||
binds `0.0.0.0` (all interfaces) correctly, confirmed by inspecting the
|
||||
running device directly, and there's no firewall rule (`iptables`,
|
||||
`nftables`, or otherwise) blocking it either.
|
||||
|
||||
**Current knowledge (2026-08-16), confirmed on real hardware via a
|
||||
decrypted firmware backup plus simultaneous packet captures on both the
|
||||
speaker and a client machine:** some SoundTouch models built around a
|
||||
"combo" WiFi/Bluetooth co-processor (used for AirPlay) route LAN traffic
|
||||
through that co-processor before it reaches the main application
|
||||
processor where AfterTouch actually runs. That co-processor only relays a
|
||||
fixed set of the device's own original service ports (the same ones the
|
||||
stock SoundTouch app and companion services always used), a list that,
|
||||
as far as we can tell, is compiled into the co-processor's own firmware.
|
||||
AfterTouch's ports were never part of that original design, so they never
|
||||
got included. This isn't a bug in AfterTouch, a router/firewall setting,
|
||||
or WiFi client isolation; all three were separately ruled out.
|
||||
|
||||
**The installer works around this automatically.** On an affected speaker
|
||||
it redirects one of the ports the co-processor *does* relay to AfterTouch,
|
||||
so the UI is reachable from the LAN without any tunnel:
|
||||
|
||||
```
|
||||
http://<IP_ADDRESS_OF_SPEAKER>:17008
|
||||
```
|
||||
|
||||
Port `17008` is Bose's software-update listener; that cloud service no
|
||||
longer exists, so taking over its inbound traffic costs nothing. Only
|
||||
traffic from other machines is affected; anything running on the speaker
|
||||
still reaches AfterTouch on `:8000` as before. Change or disable this with
|
||||
`AFTERTOUCH_LAN_PORT` (`auto` / `none` / a port number) in
|
||||
`/opt/aftertouch/aftertouch.conf`, or pass it at install time:
|
||||
|
||||
```bash
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | AFTERTOUCH_LAN_PORT=none sh
|
||||
```
|
||||
|
||||
**`aftertouch.conf` isn't limited to `AFTERTOUCH_LAN_PORT`.** The init
|
||||
script exports every assignment in this file into the daemon's own
|
||||
environment, so any env var `soundtouch-service` reads (see the
|
||||
[configuration table](../../docs/content/docs/guides/SOUNDTOUCH-SERVICE.md#configuration-options))
|
||||
can be set the same way — for example, to change the admin credentials:
|
||||
|
||||
```
|
||||
MGMT_USERNAME=admin
|
||||
MGMT_PASSWORD=change-me
|
||||
```
|
||||
|
||||
Edit `/opt/aftertouch/aftertouch.conf` over SSH, then
|
||||
`/etc/init.d/aftertouch restart` to apply. `DEPLOYMENT_MODE=on-device` is
|
||||
already set by the init script itself — it never needs to be added here.
|
||||
The auto-export behavior described here needs a build including the fix
|
||||
for issue #546; older installs (before `aftertouch.conf` even existed, or
|
||||
between then and that fix) need to reinstall/update first.
|
||||
|
||||
Which models need this, and how to report one that isn't listed yet, is
|
||||
tracked in
|
||||
[MODEL-SUPPORT-MATRIX.md](../../docs/content/docs/reference/MODEL-SUPPORT-MATRIX.md).
|
||||
The SSH tunnel below still works, and remains the better route 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.
|
||||
|
||||
**Open a fresh terminal on your own machine** (Linux/macOS/Windows — NOT another shell inside the speaker's SSH session — see issue #250 for the trap that catches everyone here) and run:
|
||||
|
||||
@@ -94,15 +162,17 @@ Run the installer again with the version you want to install. The script backs u
|
||||
**Install (or upgrade to) a specific version** — three equivalent ways:
|
||||
|
||||
```bash
|
||||
# 1. Environment variable (works when piping into sh)
|
||||
VERSION=0.111.3 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
|
||||
# 1. Environment variable — goes on `sh`, not `curl`: in a pipe, each
|
||||
# command is a separate process, so `VERSION=X curl ... | sh` silently
|
||||
# does NOT set it for `sh` (the one that actually reads $VERSION).
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | VERSION=0.123.0 sh
|
||||
|
||||
# 2. Command-line flag (pass args after `sh -s --`)
|
||||
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
|
||||
|
||||
# 3. Download first, then run with a flag
|
||||
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
|
||||
```
|
||||
|
||||
Running **without** a version override installs the latest release: the script
|
||||
@@ -111,14 +181,39 @@ redirect to discover the newest tag. If that lookup fails (offline, or a `curl`
|
||||
build without `-w` support), it falls back to a pinned version baked into the
|
||||
script.
|
||||
|
||||
> **Tip — rollback:** if the new binary misbehaves, the installer left a `.backup` file alongside it:
|
||||
> **Tip — rollback:** if the new binary misbehaves, the installer left a backup file
|
||||
> alongside it, gzip-compressed as `.backup.gz` (plain `.backup`, uncompressed, if
|
||||
> `gzip` wasn't available on your device):
|
||||
> ```bash
|
||||
> ls /mnt/nv/aftertouch/aftertouch-service*.backup
|
||||
> ls /mnt/nv/aftertouch/aftertouch-service*.backup*
|
||||
> # .backup.gz (compressed):
|
||||
> gunzip -c /mnt/nv/aftertouch/aftertouch-service.<old-version>.backup.gz \
|
||||
> > /mnt/nv/aftertouch/aftertouch-service
|
||||
> chmod +x /mnt/nv/aftertouch/aftertouch-service
|
||||
> # or, for an uncompressed .backup:
|
||||
> cp /mnt/nv/aftertouch/aftertouch-service.<old-version>.backup \
|
||||
> /mnt/nv/aftertouch/aftertouch-service
|
||||
> /etc/init.d/aftertouch restart
|
||||
> ```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
All of these go on `sh`, not `curl` — in a pipe, each command is a separate
|
||||
process, so `VAR=X curl ... | sh` silently does NOT set it for `sh` (the one
|
||||
that actually reads it). Use `curl -sSL .../install.sh | VAR=X sh` instead.
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|------------------------------|-----------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `VERSION` | resolved automatically to the latest GitHub release | Install a specific version instead of latest. See [Updating AfterTouch](#updating-aftertouch) for the three equivalent ways to set this. |
|
||||
| `INSTALL_DIR` | `/mnt/nv/aftertouch` | Where AfterTouch is installed. `/opt/aftertouch` is symlinked here so the init script's hardcoded path keeps working. |
|
||||
| `AFTERTOUCH_FORCE_NO_BACKUP` | unset | Skip the interactive "not enough space for a backup, continue anyway?" prompt and proceed without a rollback backup. For unattended/scripted installs only — interactively, the installer always asks (or aborts if no terminal is available) rather than silently skipping the backup. |
|
||||
| `AFTERTOUCH_LAN_PORT` | `auto` | Written into `aftertouch.conf` (not consumed by the installer itself beyond that). Controls whether/which LAN entry-port gets redirected to AfterTouch. See [Model Support Matrix](../../docs/content/docs/reference/MODEL-SUPPORT-MATRIX.md). |
|
||||
| `UPDATE_TMP_DIR` | `/media/aftertouch` | Scratch directory for the downloaded binary before it replaces the installed one. Should stay on a different filesystem than `INSTALL_DIR` (tmpfs by default) so the download itself doesn't compete with `INSTALL_DIR` for space. |
|
||||
| `GH_REPO` | `gesellix/Bose-SoundTouch` | Install from a fork instead, e.g. for testing an unmerged branch's release. |
|
||||
| `BINARY_URL` | derived from `GH_REPO`/`VERSION` | Override the service binary's download URL entirely, bypassing `GH_REPO`/`VERSION` for this one file. |
|
||||
| `INIT_SCRIPT_URL` | derived from `GH_REPO`/`VERSION` | Override the init script's download URL entirely, bypassing `GH_REPO`/`VERSION` for this one file. |
|
||||
| `FALLBACK_VERSION` | `0.123.0` | Used only if the latest-release lookup fails (offline, rate-limited, or a `curl` build without `-w` support). |
|
||||
|
||||
## Uninstallation
|
||||
|
||||
Before uninstall, you might want to revert the migration, especially the changes to the server URLs (even though having configured an unresponsive local server probably is about as bad as having configured unresponsive Bose servers). To uninstall AfterTouch, run the following command on the speaker.
|
||||
|
||||
@@ -15,6 +15,7 @@ DESC="Bose AfterTouch service"
|
||||
DAEMON="/opt/aftertouch/aftertouch-service"
|
||||
PIDFILE="/var/run/$NAME.pid"
|
||||
DATADIR="/opt/aftertouch/data"
|
||||
CONFFILE="/opt/aftertouch/aftertouch.conf"
|
||||
SCRIPTNAME="/etc/init.d/$NAME"
|
||||
USER="root"
|
||||
LOG_TAG="aftertouch"
|
||||
@@ -24,6 +25,37 @@ LOG_TAG="aftertouch"
|
||||
export PATH="/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin"
|
||||
|
||||
|
||||
# Optional settings written by install.sh (AFTERTOUCH_LAN_PORT, SERVICE_PORT),
|
||||
# or added by hand for anything the daemon reads from its environment
|
||||
# (SERVER_URL, MGMT_USERNAME, MGMT_PASSWORD, DEPLOYMENT_MODE, ...). `set -a`
|
||||
# auto-exports every assignment while the file is sourced, so any such
|
||||
# variable actually reaches the daemon -- it's forked from this same shell's
|
||||
# environment further down via `--startas "/bin/sh" -- -c "... \"$DAEMON\" ..."`.
|
||||
# Sourced before the defaults below so it can override either.
|
||||
if [ -r "$CONFFILE" ]; then
|
||||
set -a
|
||||
# shellcheck source=/dev/null
|
||||
. "$CONFFILE"
|
||||
set +a
|
||||
fi
|
||||
|
||||
# Port the daemon binds locally. Kept in one variable because it appears in
|
||||
# the daemon arguments, the readiness poll and `status` -- three places that
|
||||
# used to hardcode 8000 independently, so changing one silently broke the
|
||||
# other two.
|
||||
SERVICE_PORT="${SERVICE_PORT:-8000}"
|
||||
|
||||
# LAN entry port: a port number, "auto" (default), or "none".
|
||||
LAN_PORT_MODE="${AFTERTOUCH_LAN_PORT:-auto}"
|
||||
|
||||
# This script only ever runs on the speaker itself, so the deployment mode is
|
||||
# not a guess -- default it here (overridable via aftertouch.conf, though that
|
||||
# should never be needed). Exported so soundtouch-service picks it up via
|
||||
# DEPLOYMENT_MODE without needing a --deployment-mode flag threaded through
|
||||
# the daemon invocation below.
|
||||
export DEPLOYMENT_MODE="${DEPLOYMENT_MODE:-on-device}"
|
||||
|
||||
|
||||
# Sanity check executable
|
||||
test -x "$DAEMON" || {
|
||||
echo "ERROR: Cannot execute $DAEMON (check path and permissions)." >&2
|
||||
@@ -31,6 +63,101 @@ test -x "$DAEMON" || {
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LAN entry-port redirect
|
||||
#
|
||||
# On chassis built around a BCO ("SMSC") Wi-Fi/Bluetooth co-processor,
|
||||
# inbound LAN traffic only reaches this Linux SoC for a fixed set of Bose's
|
||||
# own service ports, which appears to be compiled into the co-processor's
|
||||
# firmware. AfterTouch's :8000 is not on that list, so a LAN client's SYN
|
||||
# never arrives here at all -- confirmed on an ST20 (`spotty`), where
|
||||
# `tcpdump -i eth0` on the speaker saw zero packets for :8000 while Bose's
|
||||
# own :8090/:8091/:17000 answered normally from the same client. The usual
|
||||
# suspects were all ruled out: the service does bind 0.0.0.0 correctly, the
|
||||
# speaker's iptables is empty, and SSH over the same path works.
|
||||
#
|
||||
# Workaround: NAT one of the relayed Bose ports to ours. The default, 17008,
|
||||
# is Bose's SoftwareUpdate listener -- its cloud is gone, so taking over its
|
||||
# inbound traffic costs nothing real. Only external traffic is matched
|
||||
# (`! -i lo`), so anything running on the speaker still reaches both the real
|
||||
# service on loopback and AfterTouch on :8000 as before.
|
||||
#
|
||||
# Credit: the STR / SoundTouch Reborn project (github.com/JRpersonal/streborn)
|
||||
# documented and shipped this REDIRECT technique first, using the same entry
|
||||
# port for the same reason.
|
||||
#
|
||||
# Which models need this is tracked in
|
||||
# docs/content/docs/reference/MODEL-SUPPORT-MATRIX.md.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Resolve LAN_PORT_MODE into $LAN_PORT. Returns non-zero when no redirect
|
||||
# should be installed.
|
||||
lan_redirect_port() {
|
||||
case "$LAN_PORT_MODE" in
|
||||
none|off|disabled|0)
|
||||
return 1
|
||||
;;
|
||||
auto|"")
|
||||
# Only auto-enable where direct LAN access is known not to work.
|
||||
# has-bco is Bose's own helper: [ "$(cat /proc/module_type)" = scm ]
|
||||
has-bco >/dev/null 2>&1 || return 1
|
||||
LAN_PORT=17008
|
||||
;;
|
||||
*[!0-9]*)
|
||||
echo "WARNING: ignoring AFTERTOUCH_LAN_PORT='$LAN_PORT_MODE'; expected a port number, 'auto' or 'none'." >&2
|
||||
return 1
|
||||
;;
|
||||
*)
|
||||
LAN_PORT="$LAN_PORT_MODE"
|
||||
;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
|
||||
# Remove every PREROUTING rule pointing at our service port, whatever entry
|
||||
# port it used, so changing AFTERTOUCH_LAN_PORT cannot orphan the old rule.
|
||||
lan_redirect_purge() {
|
||||
iptables -t nat -S PREROUTING 2>/dev/null \
|
||||
| grep -- "--to-ports $SERVICE_PORT" \
|
||||
| sed 's/^-A /-D /' \
|
||||
| while read -r rule; do
|
||||
# shellcheck disable=SC2086
|
||||
iptables -t nat $rule 2>/dev/null || true
|
||||
done
|
||||
}
|
||||
|
||||
lan_redirect_apply() {
|
||||
lan_redirect_port || return 0
|
||||
|
||||
if ! iptables -t nat -L PREROUTING -n >/dev/null 2>&1; then
|
||||
echo "WARNING: this kernel has no iptables nat table; :$LAN_PORT was not" >&2
|
||||
echo " redirected. Reach AfterTouch over an SSH tunnel instead." >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
lan_redirect_purge
|
||||
|
||||
# Safety net for a kernel whose iptables lacks -S (purge would no-op):
|
||||
# without this, every restart would stack another duplicate rule.
|
||||
if iptables -t nat -C PREROUTING ! -i lo -p tcp --dport "$LAN_PORT" \
|
||||
-j REDIRECT --to-ports "$SERVICE_PORT" 2>/dev/null; then
|
||||
echo "LAN access already active on port $LAN_PORT."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if iptables -t nat -I PREROUTING 1 ! -i lo -p tcp --dport "$LAN_PORT" \
|
||||
-j REDIRECT --to-ports "$SERVICE_PORT" 2>/dev/null; then
|
||||
echo "LAN access: port $LAN_PORT now reaches AfterTouch on :$SERVICE_PORT."
|
||||
else
|
||||
echo "WARNING: could not install the :$LAN_PORT -> :$SERVICE_PORT redirect." >&2
|
||||
fi
|
||||
}
|
||||
|
||||
lan_redirect_remove() {
|
||||
lan_redirect_purge
|
||||
}
|
||||
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
echo "Starting $DESC..."
|
||||
@@ -42,37 +169,82 @@ case "$1" in
|
||||
|
||||
mkdir -p "$DATADIR"
|
||||
|
||||
# Pipe stdout + stderr through `logger -t $LOG_TAG` so the
|
||||
# Route stdout + stderr through `logger -t $LOG_TAG` so the
|
||||
# daemon's output lands in busybox syslog (bounded ring buffer,
|
||||
# never grows on disk). Users diagnose with:
|
||||
#
|
||||
# logread | grep aftertouch | tail -20
|
||||
# logread -f | grep aftertouch # live tail
|
||||
#
|
||||
# `exec` on the daemon replaces /bin/sh so --make-pidfile records
|
||||
# the daemon's own PID (not the shell wrapper). The `logger`
|
||||
# process sits on the read end of the pipe and exits cleanly
|
||||
# when the daemon dies and closes its end.
|
||||
# This used to be a `--startas "/bin/sh" -- -c "exec $DAEMON | logger"`
|
||||
# pipeline, on the theory that `exec` replaces /bin/sh so --make-pidfile
|
||||
# records the daemon's own PID. That's wrong for a *piped* command:
|
||||
# POSIX requires each side of a pipe to run in its own forked process,
|
||||
# so the top-level /bin/sh forks two children (one execs into the
|
||||
# daemon, one becomes logger) and stays alive itself, blocked in
|
||||
# wait() -- --make-pidfile recorded *that* wrapper's PID, not the
|
||||
# daemon's. `stop` then killed the wrapper, which doesn't forward
|
||||
# SIGTERM to its children, orphaning the real daemon (reparented to
|
||||
# init) to keep running -- and keep holding :8000 -- forever, silently
|
||||
# surviving every later stop/start/restart.
|
||||
#
|
||||
# A first fix attempt dropped the wrapper shell entirely in favor of
|
||||
# `--exec "$DAEMON"` directly, with a plain shell-level `>FIFO`
|
||||
# redirection on the start-stop-daemon invocation. That broke logging
|
||||
# instead: this busybox's `--background` resets the backgrounded
|
||||
# child's own stdio, ignoring the outer redirection, so the daemon's
|
||||
# output never reached the FIFO -- confirmed on hardware (`logger`
|
||||
# exited immediately with nothing to read, `logread` showed nothing
|
||||
# new).
|
||||
#
|
||||
# This version keeps a wrapper shell -- its *own* FIFO redirection,
|
||||
# set up by its own script logic rather than inherited from outside,
|
||||
# isn't affected by whatever --background did to its stdio -- but has
|
||||
# the wrapper record the daemon's real PID itself instead of trusting
|
||||
# --make-pidfile. $! after a single, non-piped backgrounded command is
|
||||
# portably that command's own PID; --make-pidfile can only ever see
|
||||
# whatever process start-stop-daemon directly forked (the wrapper),
|
||||
# never a PID from inside it.
|
||||
LOGFIFO="/tmp/$NAME.fifo"
|
||||
rm -f "$LOGFIFO"
|
||||
mkfifo "$LOGFIFO"
|
||||
|
||||
# --pidfile (without --make-pidfile, since the wrapper writes it itself
|
||||
# once it knows the daemon's real PID) makes start-stop-daemon's own
|
||||
# "already running?" check keyed on *our* pidfile, not on "/bin/sh"
|
||||
# identity. Without this, --startas "/bin/sh" is itself the match
|
||||
# criterion -- and since the wrapper stays alive for the daemon's whole
|
||||
# lifetime (blocked in its own `wait`), and `stop` only confirms the
|
||||
# *daemon* PID died (not that the wrapper has finished tearing down),
|
||||
# a `restart` firing `start` right after `stop` can catch the previous
|
||||
# wrapper still mid-teardown. start-stop-daemon then silently refuses
|
||||
# ("/bin/sh is already running", swallowed by --quiet) while the
|
||||
# script burns its full 120s timeout waiting for a daemon that was
|
||||
# never launched. Confirmed on hardware: a bare
|
||||
# `start-stop-daemon --startas "/bin/sh" -- -c "echo hi"` was refused
|
||||
# with exactly that message while a prior wrapper was still alive.
|
||||
start-stop-daemon --start \
|
||||
--quiet \
|
||||
--pidfile "$PIDFILE" \
|
||||
--background \
|
||||
--make-pidfile \
|
||||
--chuid "$USER" \
|
||||
--startas "/bin/sh" \
|
||||
-- -c "exec \"$DAEMON\" --data-dir '$DATADIR' --record-interactions=false --discovery-interval=60m 2>&1 | logger -t $LOG_TAG"
|
||||
-- -c "logger -t $LOG_TAG <'$LOGFIFO' & \"$DAEMON\" --data-dir '$DATADIR' --port '$SERVICE_PORT' --record-interactions=false --discovery-interval=60m >'$LOGFIFO' 2>&1 & echo \$! >'$PIDFILE'; wait"
|
||||
|
||||
tries=0
|
||||
max_tries=60
|
||||
while [ $tries -lt $max_tries ]; do
|
||||
if curl -fsS http://localhost:8000 >/dev/null 2>&1; then
|
||||
if curl -fsS "http://localhost:$SERVICE_PORT" >/dev/null 2>&1; then
|
||||
# Only once the service actually answers is it worth pointing LAN
|
||||
# traffic at it.
|
||||
lan_redirect_apply
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
tries=$((tries + 1))
|
||||
done
|
||||
|
||||
echo "ERROR: daemon started but http://localhost:8000 never responded within $((max_tries * 2))s." >&2
|
||||
echo "ERROR: daemon started but http://localhost:$SERVICE_PORT never responded within $((max_tries * 2))s." >&2
|
||||
echo " Inspect the daemon's syslog output:" >&2
|
||||
echo " logread | grep $LOG_TAG | tail -20" >&2
|
||||
exit 1
|
||||
@@ -80,6 +252,9 @@ case "$1" in
|
||||
|
||||
stop)
|
||||
echo "Stopping $DESC..."
|
||||
# Drop the LAN redirect first: leaving it in place while nothing listens
|
||||
# would silently blackhole the entry port.
|
||||
lan_redirect_remove
|
||||
if [ -f "$PIDFILE" ]; then
|
||||
PID=$(cat "$PIDFILE")
|
||||
start-stop-daemon --stop \
|
||||
@@ -120,11 +295,19 @@ case "$1" in
|
||||
# (Gustour's ST30: status said running, curl said
|
||||
# connection-refused). Distinguish the two states here so
|
||||
# status isn't a false-positive.
|
||||
if curl -fsS --max-time 3 http://localhost:8000 >/dev/null 2>&1; then
|
||||
echo "$NAME is running (PID $PID, http://localhost:8000 responding)."
|
||||
if curl -fsS --max-time 3 "http://localhost:$SERVICE_PORT" >/dev/null 2>&1; then
|
||||
echo "$NAME is running (PID $PID, http://localhost:$SERVICE_PORT responding)."
|
||||
if lan_redirect_port; then
|
||||
if iptables -t nat -C PREROUTING ! -i lo -p tcp --dport "$LAN_PORT" \
|
||||
-j REDIRECT --to-ports "$SERVICE_PORT" 2>/dev/null; then
|
||||
echo "LAN access: reachable from other machines on port $LAN_PORT."
|
||||
else
|
||||
echo "LAN access: redirect for port $LAN_PORT is NOT installed." >&2
|
||||
fi
|
||||
fi
|
||||
exit 0
|
||||
else
|
||||
echo "$NAME PID $PID is alive but http://localhost:8000 is not responding." >&2
|
||||
echo "$NAME PID $PID is alive but http://localhost:$SERVICE_PORT is not responding." >&2
|
||||
echo "Recent log:" >&2
|
||||
logread 2>/dev/null | grep "$LOG_TAG" | tail -10 >&2
|
||||
exit 3
|
||||
|
||||
@@ -5,14 +5,16 @@ set -eo pipefail
|
||||
# curl -sSL .../install.sh | sh
|
||||
# resolves and installs the latest release automatically (see below).
|
||||
#
|
||||
# Pin a specific version via environment variable or the --version/-v flag:
|
||||
# VERSION=0.111.3 curl -sSL .../install.sh | sh
|
||||
# curl -sSL .../install.sh | sh -s -- --version 0.111.3
|
||||
# Pin a specific version via environment variable or the --version/-v flag.
|
||||
# The env var goes on `sh`, not `curl`: in a pipe, each command is its own
|
||||
# process, so `VERSION=X curl ... | sh` silently does NOT set it for `sh`.
|
||||
# curl -sSL .../install.sh | VERSION=0.123.0 sh
|
||||
# curl -sSL .../install.sh | sh -s -- --version 0.123.0
|
||||
VERSION=${VERSION:-}
|
||||
|
||||
# Parse optional command-line arguments so the script can be invoked as:
|
||||
# install.sh --version 0.111.3
|
||||
# install.sh -v 0.111.3
|
||||
# install.sh --version 0.123.0
|
||||
# install.sh -v 0.123.0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--version|-v)
|
||||
@@ -29,7 +31,7 @@ GH_REPO=${GH_REPO:-gesellix/Bose-SoundTouch}
|
||||
|
||||
# Used only when the latest-release lookup fails (offline / rate-limited /
|
||||
# a curl without -w support).
|
||||
FALLBACK_VERSION=${FALLBACK_VERSION:-0.111.3}
|
||||
FALLBACK_VERSION=${FALLBACK_VERSION:-0.123.0}
|
||||
|
||||
# Resolve the latest release when no explicit version was provided, by
|
||||
# following the stable redirect https://github.com/<repo>/releases/latest
|
||||
@@ -85,6 +87,105 @@ if [ "$INSTALL_DIR" != "/opt/aftertouch" ]; then
|
||||
ln -sf "$INSTALL_DIR" /opt/aftertouch
|
||||
fi
|
||||
|
||||
# Prune any *.backup/*.old/*.new artefacts left behind by an earlier install
|
||||
# attempt, before doing anything else that needs disk space. /mnt/nv is small
|
||||
# (tens of MB), and if a previous run died between creating its backup and
|
||||
# reaching the GC step below (e.g. "no space left on device" during the
|
||||
# download that follows), that backup would otherwise never get cleaned up --
|
||||
# and low free space is exactly what makes the next attempt likely to die the
|
||||
# same way. Pruning up front makes cleanup idempotent regardless of where a
|
||||
# prior run was interrupted.
|
||||
echo "Disk usage before pre-install GC:"; df -h "$INSTALL_DIR"
|
||||
for f in "$INSTALL_DIR/aftertouch-service".*.backup \
|
||||
"$INSTALL_DIR/aftertouch-service".*.backup.gz \
|
||||
"$INSTALL_DIR/aftertouch-service".*.old \
|
||||
"$INSTALL_DIR/aftertouch-service.new"; do
|
||||
[ -f "$f" ] || continue
|
||||
rm -f "$f"
|
||||
echo "Removed stale artefact: $f"
|
||||
done
|
||||
echo "Disk usage after pre-install GC:"; df -h "$INSTALL_DIR"
|
||||
|
||||
# --- Preflight disk-space check ------------------------------------------
|
||||
# /mnt/nv is small (tens of MB) and binaries keep growing (Go 1.27 alone
|
||||
# added ~640KB to this binary via its own new stdlib defaults, unrelated to
|
||||
# this project's code). A prior attempt on real hardware ran out of space
|
||||
# mid-replace and left a truncated, non-executable binary in place: UBIFS is
|
||||
# a log-structured flash filesystem, so space freed by overwriting the old
|
||||
# binary isn't necessarily reusable by the time the new one needs to land.
|
||||
# Check upfront, with a safety margin, instead of discovering this mid-write.
|
||||
#
|
||||
# The new binary's size comes from a HEAD request rather than a hardcoded
|
||||
# threshold, so this doesn't go stale as binaries grow across releases.
|
||||
NEW_BINARY_BYTES=$(curl -sSLI --fail "$BINARY_URL" 2>/dev/null \
|
||||
| tr -d '\r' \
|
||||
| awk 'tolower($1) == "content-length:" {v=$2} END {print v}') || true
|
||||
|
||||
AVAILABLE_KB=$(df -Pk "$INSTALL_DIR" | awk 'NR==2 {print $4}')
|
||||
|
||||
CURRENT_BINARY_KB=0
|
||||
if [ -f "$INSTALL_DIR/aftertouch-service" ]; then
|
||||
CURRENT_BINARY_KB=$(du -k "$INSTALL_DIR/aftertouch-service" | awk '{print $1}')
|
||||
fi
|
||||
|
||||
# Flat margin, not a percentage: covers UBIFS's own reserved/GC headroom on
|
||||
# this log-structured flash filesystem plus general slack.
|
||||
SAFETY_MARGIN_KB=5120 # 5 MB
|
||||
|
||||
SKIP_BACKUP=no
|
||||
|
||||
if [ -n "$NEW_BINARY_BYTES" ]; then
|
||||
NEW_BINARY_KB=$((NEW_BINARY_BYTES / 1024))
|
||||
# Backups compress to roughly 70% of the original size in practice
|
||||
# (observed: a ~14.8MB binary gzipped to ~10.1MB); used as a conservative
|
||||
# estimate since the real ratio isn't known until compression actually runs.
|
||||
BACKUP_ESTIMATE_KB=$((CURRENT_BINARY_KB * 7 / 10))
|
||||
|
||||
NEEDED_WITH_BACKUP_KB=$((NEW_BINARY_KB + BACKUP_ESTIMATE_KB + SAFETY_MARGIN_KB))
|
||||
NEEDED_NO_BACKUP_KB=$((NEW_BINARY_KB + SAFETY_MARGIN_KB))
|
||||
|
||||
if [ "$AVAILABLE_KB" -ge "$NEEDED_WITH_BACKUP_KB" ]; then
|
||||
: # plenty of room; proceed normally, with a backup
|
||||
elif [ "$AVAILABLE_KB" -ge "$NEEDED_NO_BACKUP_KB" ]; then
|
||||
echo "WARNING: not enough free space on $INSTALL_DIR to keep a rollback" >&2
|
||||
echo "backup this time (${AVAILABLE_KB}KB available; ~${NEEDED_WITH_BACKUP_KB}KB" >&2
|
||||
echo "wanted with a backup, ~${NEEDED_NO_BACKUP_KB}KB without one)." >&2
|
||||
echo "Continuing will replace the current binary with NO way to" >&2
|
||||
echo "automatically undo it if something goes wrong." >&2
|
||||
if [ -n "${AFTERTOUCH_FORCE_NO_BACKUP:-}" ]; then
|
||||
echo "Proceeding without a backup (AFTERTOUCH_FORCE_NO_BACKUP is set)." >&2
|
||||
SKIP_BACKUP=yes
|
||||
elif [ -r /dev/tty ] && [ -w /dev/tty ]; then
|
||||
printf 'Continue without a backup? [y/N] ' > /dev/tty
|
||||
REPLY=""
|
||||
read -r REPLY < /dev/tty || true
|
||||
case "$REPLY" in
|
||||
[Yy]*) SKIP_BACKUP=yes ;;
|
||||
*)
|
||||
echo "Aborting: refusing to proceed without a backup. Free up space" >&2
|
||||
echo "on $INSTALL_DIR and try again, or set AFTERTOUCH_FORCE_NO_BACKUP=yes" >&2
|
||||
echo "to proceed without one non-interactively." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
else
|
||||
echo "No interactive terminal available to confirm; aborting." >&2
|
||||
echo "Set AFTERTOUCH_FORCE_NO_BACKUP=yes to proceed without a backup" >&2
|
||||
echo "non-interactively." >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "ERROR: not enough free space on $INSTALL_DIR to install AfterTouch" >&2
|
||||
echo "$VERSION safely (${AVAILABLE_KB}KB available, ~${NEEDED_NO_BACKUP_KB}KB" >&2
|
||||
echo "needed). Free up space and try again." >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "WARNING: could not determine the new binary's size ahead of time" >&2
|
||||
echo "(HEAD request to $BINARY_URL failed); skipping the preflight" >&2
|
||||
echo "disk-space check." >&2
|
||||
fi
|
||||
|
||||
curl \
|
||||
-sSL \
|
||||
-o "$UPDATE_TMP_DIR/binary" \
|
||||
@@ -94,15 +195,34 @@ curl \
|
||||
# Back up the current binary before overwriting so a one-step rollback
|
||||
# is always available. The version string comes from the binary itself;
|
||||
# if it is absent (very old build or corrupted) we fall back to a timestamp.
|
||||
# Skipped entirely when the preflight check above decided (with the
|
||||
# operator's explicit confirmation, or AFTERTOUCH_FORCE_NO_BACKUP) that
|
||||
# there isn't room for one.
|
||||
BACKUP_FILE=""
|
||||
if [ -f "$INSTALL_DIR/aftertouch-service" ]; then
|
||||
if [ -f "$INSTALL_DIR/aftertouch-service" ] && [ "$SKIP_BACKUP" != "yes" ]; then
|
||||
current_version=$("$INSTALL_DIR/aftertouch-service" --version 2>/dev/null \
|
||||
| awk '{print $NF}') || true
|
||||
if [ -z "$current_version" ] || [ "$current_version" = "dev" ]; then
|
||||
current_version=$(date +%Y%m%d-%H%M%S)
|
||||
fi
|
||||
# Binaries are tens of MB and only growing (see #614 investigation into
|
||||
# Go 1.27's default binary-size increase), while /mnt/nv is small (tens of
|
||||
# MB total). Stream straight into the compressed file rather than cp-then-
|
||||
# gzip: at this point in the script the old binary is still live AND the
|
||||
# newly-downloaded one is already sitting in $UPDATE_TMP_DIR, so an
|
||||
# intermediate uncompressed backup copy would briefly need all three full
|
||||
# copies on disk at once -- exactly the kind of moment that has already
|
||||
# caused "no space left on device" failures here. Best effort: if gzip is
|
||||
# missing, or the stream fails partway (e.g. disk fills mid-compress),
|
||||
# fall back to a plain uncompressed copy exactly as before.
|
||||
BACKUP_FILE="$INSTALL_DIR/aftertouch-service.${current_version}.backup"
|
||||
cp -p "$INSTALL_DIR/aftertouch-service" "$BACKUP_FILE"
|
||||
if command -v gzip >/dev/null 2>&1 \
|
||||
&& gzip -c < "$INSTALL_DIR/aftertouch-service" > "$BACKUP_FILE.gz"; then
|
||||
BACKUP_FILE="$BACKUP_FILE.gz"
|
||||
else
|
||||
rm -f "$BACKUP_FILE.gz"
|
||||
cp -p "$INSTALL_DIR/aftertouch-service" "$BACKUP_FILE"
|
||||
fi
|
||||
echo "Backed up current binary ($current_version) → $BACKUP_FILE"
|
||||
fi
|
||||
|
||||
@@ -110,11 +230,13 @@ mv "$UPDATE_TMP_DIR/binary" "$INSTALL_DIR/aftertouch-service"
|
||||
chmod +x "$INSTALL_DIR/aftertouch-service"
|
||||
|
||||
# Keep only the backup we just created; prune all older *.backup, *.old, and
|
||||
# *.new artefacts left by earlier installs. /mnt/nv is small (tens of MB),
|
||||
# so accumulation quickly causes "no space left on device" during downloads.
|
||||
# *.new artefacts left by earlier installs. This is a second, defensive pass:
|
||||
# it only matters if something wrote a stray artefact between the pre-install
|
||||
# GC above and here (e.g. a concurrent install run).
|
||||
if [ -n "$BACKUP_FILE" ]; then
|
||||
echo "Disk usage before GC:"; df -h "$INSTALL_DIR"
|
||||
echo "Disk usage before post-install GC:"; df -h "$INSTALL_DIR"
|
||||
for f in "$INSTALL_DIR/aftertouch-service".*.backup \
|
||||
"$INSTALL_DIR/aftertouch-service".*.backup.gz \
|
||||
"$INSTALL_DIR/aftertouch-service".*.old \
|
||||
"$INSTALL_DIR/aftertouch-service.new"; do
|
||||
[ -f "$f" ] || continue
|
||||
@@ -122,7 +244,36 @@ if [ -n "$BACKUP_FILE" ]; then
|
||||
rm -f "$f"
|
||||
echo "Removed stale artefact: $f"
|
||||
done
|
||||
echo "Disk usage after GC:"; df -h "$INSTALL_DIR"
|
||||
echo "Disk usage after post-install GC:"; df -h "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
# Settings file sourced by the init script. Written before the service is
|
||||
# (re)started so the very first start already sees it.
|
||||
#
|
||||
# An existing file is left alone on upgrade -- it may carry the operator's own
|
||||
# choices -- unless AFTERTOUCH_LAN_PORT was passed to this script explicitly.
|
||||
CONF_FILE="$INSTALL_DIR/aftertouch.conf"
|
||||
if [ -n "${AFTERTOUCH_LAN_PORT:-}" ] || [ ! -f "$CONF_FILE" ]; then
|
||||
cat > "$CONF_FILE" <<CONFEOF
|
||||
# AfterTouch on-device settings. Sourced by /etc/init.d/aftertouch, which
|
||||
# exports every assignment here into the daemon's own environment -- so any
|
||||
# env var soundtouch-service reads (see docs: guides/SOUNDTOUCH-SERVICE.md,
|
||||
# "Configuration Options") can be set by adding a line below and running
|
||||
# \`/etc/init.d/aftertouch restart\`, e.g.:
|
||||
# MGMT_USERNAME=admin
|
||||
# MGMT_PASSWORD=change-me
|
||||
#
|
||||
# AFTERTOUCH_LAN_PORT: how AfterTouch is reached from other machines.
|
||||
# auto (default) redirect a spare Bose port to AfterTouch, but only on
|
||||
# speakers whose Wi-Fi co-processor refuses to pass :8000 through.
|
||||
# none never redirect; use an SSH tunnel instead.
|
||||
# <port> always redirect this inbound port to AfterTouch.
|
||||
# See docs: reference/MODEL-SUPPORT-MATRIX.md
|
||||
AFTERTOUCH_LAN_PORT=${AFTERTOUCH_LAN_PORT:-auto}
|
||||
CONFEOF
|
||||
echo "Wrote settings to $CONF_FILE (AFTERTOUCH_LAN_PORT=${AFTERTOUCH_LAN_PORT:-auto})"
|
||||
else
|
||||
echo "Keeping existing settings in $CONF_FILE"
|
||||
fi
|
||||
|
||||
echo "Creating init script..."
|
||||
@@ -136,8 +287,18 @@ mv "$UPDATE_TMP_DIR/init-script" /etc/init.d/aftertouch
|
||||
chmod +x /etc/init.d/aftertouch
|
||||
update-rc.d aftertouch defaults
|
||||
|
||||
echo "Installation complete. Running initial startup..."
|
||||
/etc/init.d/aftertouch start
|
||||
echo "Installation complete. (Re)starting the service..."
|
||||
# Use `restart`, not `start`: if AfterTouch is already running (the normal
|
||||
# case for an in-place upgrade or downgrade), `start` calls start-stop-daemon
|
||||
# with a pidfile that still points at a live PID. start-stop-daemon then
|
||||
# refuses to launch a second instance and exits non-zero -- but this script
|
||||
# has no `set -e` here and never checked that exit status, so the old
|
||||
# process kept running untouched while the new binary sat unused on disk.
|
||||
# The post-install curl check below couldn't catch it either, since the old
|
||||
# process kept answering on :8000 throughout. `restart` stops the old
|
||||
# process first (a no-op if nothing was running yet, e.g. on a fresh
|
||||
# install), guaranteeing the newly-installed binary is the one that starts.
|
||||
/etc/init.d/aftertouch restart
|
||||
|
||||
/etc/init.d/aftertouch status
|
||||
|
||||
@@ -150,10 +311,35 @@ echo "Installation complete. Running initial startup..."
|
||||
# daemon's stdout/stderr through `logger -t aftertouch`, so panics
|
||||
# land in busybox syslog and `logread` reads them out.
|
||||
if curl -fsS --max-time 10 http://localhost:8000 >/dev/null 2>&1; then
|
||||
# We are running ON the speaker, so print the address people actually need
|
||||
# rather than a <your-device-ip> placeholder they have to resolve themselves.
|
||||
LAN_IP=$(ip -4 addr show scope global 2>/dev/null \
|
||||
| awk '/inet /{sub(/\/.*/,"",$2); print $2; exit}')
|
||||
[ -n "$LAN_IP" ] || LAN_IP="<your-device-ip>"
|
||||
|
||||
# If the init script installed a LAN entry-port redirect, that port -- not
|
||||
# 8000 -- is the one reachable from other machines.
|
||||
LAN_PORT=$(iptables -t nat -S PREROUTING 2>/dev/null \
|
||||
| grep -- '-j REDIRECT' \
|
||||
| sed -n 's/.*--dport \([0-9][0-9]*\).*--to-ports 8000.*/\1/p' \
|
||||
| head -1)
|
||||
|
||||
echo ""
|
||||
echo "Installation complete. AfterTouch $VERSION is now running on your device."
|
||||
echo "Connect to http://<your-device-ip>:8000 from another machine on the LAN."
|
||||
echo "If the device doesn't expose :8000 directly, port-forward via SSH:"
|
||||
echo " ssh -L 8000:localhost:8000 root@<IP_ADDRESS_OF_SPEAKER>"
|
||||
echo ""
|
||||
if [ -n "$LAN_PORT" ]; then
|
||||
echo " Open http://$LAN_IP:$LAN_PORT from any machine on your network."
|
||||
echo ""
|
||||
echo " (This speaker's Wi-Fi co-processor does not pass port 8000 through to"
|
||||
echo " AfterTouch, so port $LAN_PORT is redirected to it instead. Set"
|
||||
echo " AFTERTOUCH_LAN_PORT in $CONF_FILE to change or disable this.)"
|
||||
else
|
||||
echo " Open http://$LAN_IP:8000 from any machine on your network."
|
||||
fi
|
||||
echo ""
|
||||
echo "If that doesn't load, reach it through an SSH tunnel instead:"
|
||||
echo " ssh -oHostKeyAlgorithms=+ssh-rsa -L 8000:localhost:8000 root@$LAN_IP"
|
||||
echo "then open http://localhost:8000"
|
||||
else
|
||||
echo "WARNING: the init script reports AfterTouch as running, but" >&2
|
||||
echo " http://localhost:8000 isn't responding. The daemon may have" >&2
|
||||
|
||||
@@ -5,6 +5,18 @@
|
||||
set -eu
|
||||
|
||||
/etc/init.d/aftertouch stop || true
|
||||
|
||||
# `stop` normally removes the LAN entry-port redirect. Repeat it directly in
|
||||
# case the init script was already gone or failed, so no rule is left behind
|
||||
# pointing at a service that no longer exists.
|
||||
iptables -t nat -S PREROUTING 2>/dev/null \
|
||||
| grep -- '--to-ports 8000' \
|
||||
| sed 's/^-A /-D /' \
|
||||
| while read -r rule; do
|
||||
# shellcheck disable=SC2086
|
||||
iptables -t nat $rule 2>/dev/null || true
|
||||
done
|
||||
|
||||
rm -f /etc/init.d/aftertouch
|
||||
update-rc.d -f aftertouch remove
|
||||
|
||||
|
||||
@@ -31,8 +31,8 @@ Both install the **latest release** by default (resolved from GitHub's
|
||||
specific release:
|
||||
|
||||
```bash
|
||||
sudo bash install.sh v0.111.3
|
||||
sudo bash install-player.sh v0.111.3
|
||||
sudo bash install.sh v0.123.0
|
||||
sudo bash install-player.sh v0.123.0
|
||||
```
|
||||
|
||||
## Removal
|
||||
|
||||
@@ -10,7 +10,7 @@ set -euo pipefail
|
||||
# Examples (override defaults via env vars):
|
||||
#
|
||||
# sudo \
|
||||
# VERSION=v0.111.3 \
|
||||
# VERSION=v0.123.0 \
|
||||
# HTTP_PORT=8081 \
|
||||
# bash install-player.sh
|
||||
#
|
||||
@@ -21,7 +21,7 @@ set -euo pipefail
|
||||
# bash install-player.sh
|
||||
#
|
||||
# Or with a version argument to perform an update:
|
||||
# sudo bash install-player.sh v0.111.3
|
||||
# sudo bash install-player.sh v0.123.0
|
||||
#
|
||||
# Notes:
|
||||
# - This script downloads a release binary for your CPU (auto-detects armv7/arm64/amd64).
|
||||
@@ -41,7 +41,7 @@ if [[ -n "$VERSION" && ! "$VERSION" =~ ^v ]]; then
|
||||
fi
|
||||
GH_REPO="${GH_REPO:-gesellix/Bose-SoundTouch}"
|
||||
# Used only when the latest-release lookup fails (offline / rate-limited).
|
||||
FALLBACK_VERSION="${FALLBACK_VERSION:-v0.111.3}"
|
||||
FALLBACK_VERSION="${FALLBACK_VERSION:-v0.123.0}"
|
||||
SERVICE_NAME="${SERVICE_NAME:-soundtouch-player}"
|
||||
BIN_PATH="${BIN_PATH:-/usr/local/bin/soundtouch-player}"
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ set -euo pipefail
|
||||
# Examples (override defaults via env vars):
|
||||
#
|
||||
# sudo \
|
||||
# VERSION=v0.111.3 \
|
||||
# VERSION=v0.123.0 \
|
||||
# HOSTNAME_FQDN=soundtouch.local \
|
||||
# HTTP_PORT=80 \
|
||||
# HTTPS_PORT=443 \
|
||||
@@ -18,7 +18,7 @@ set -euo pipefail
|
||||
# bash install.sh
|
||||
#
|
||||
# Or with a version argument to perform an update:
|
||||
# sudo bash install.sh v0.111.3
|
||||
# sudo bash install.sh v0.123.0
|
||||
#
|
||||
# Notes:
|
||||
# - This script downloads a release binary for your CPU (auto-detects armv7/arm64/amd64).
|
||||
@@ -37,7 +37,7 @@ if [[ -n "$VERSION" && ! "$VERSION" =~ ^v ]]; then
|
||||
fi
|
||||
GH_REPO="${GH_REPO:-gesellix/Bose-SoundTouch}"
|
||||
# Used only when the latest-release lookup fails (offline / rate-limited).
|
||||
FALLBACK_VERSION="${FALLBACK_VERSION:-v0.111.3}"
|
||||
FALLBACK_VERSION="${FALLBACK_VERSION:-v0.123.0}"
|
||||
SERVICE_NAME="${SERVICE_NAME:-soundtouch-service}"
|
||||
BIN_PATH="${BIN_PATH:-/usr/local/bin/soundtouch-service}"
|
||||
|
||||
@@ -122,7 +122,7 @@ detect_arch_asset() {
|
||||
download_url_for() {
|
||||
local asset="$1"
|
||||
# Release asset pattern used by you earlier:
|
||||
# soundtouch-service-v0.111.3-linux-armv7
|
||||
# soundtouch-service-v0.123.0-linux-armv7
|
||||
echo "https://github.com/gesellix/Bose-SoundTouch/releases/download/${VERSION}/soundtouch-service-${VERSION}-${asset}"
|
||||
}
|
||||
|
||||
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# Emits a "Quick downloads" markdown section with real, direct download
|
||||
# links for soundtouch-service and soundtouch-cli, one row per platform.
|
||||
# Asset URLs are deterministic (<binary>-<tag>-<os>-<arch>[.exe]), so this
|
||||
# needs no GitHub API call to build them.
|
||||
#
|
||||
# Usage: quick-downloads.sh <tag-name> <owner/repo>
|
||||
# Output goes to stdout, wrapped in <!-- quick-downloads:start/end -->
|
||||
# markers so callers can find-and-replace a previously inserted block.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
TAG_NAME="$1"
|
||||
REPOSITORY="$2"
|
||||
BASE_URL="https://github.com/${REPOSITORY}/releases/download/${TAG_NAME}"
|
||||
|
||||
# suffix|human label, same order as docs/content/docs/downloads/_index.md
|
||||
PLATFORMS=(
|
||||
"linux-arm64|Raspberry Pi (64-bit) / ARM64 Linux"
|
||||
"linux-armv7|Raspberry Pi (32-bit) / ARMv7"
|
||||
"linux-amd64|Linux (64-bit PC)"
|
||||
"darwin-arm64|macOS (Apple Silicon)"
|
||||
"darwin-amd64|macOS (Intel)"
|
||||
"windows-amd64.exe|Windows (64-bit)"
|
||||
"freebsd-amd64|FreeBSD (64-bit)"
|
||||
)
|
||||
|
||||
build_table() {
|
||||
local BINARY_NAME=$1
|
||||
echo "| Platform | Download | Checksum |"
|
||||
echo "|---|---|---|"
|
||||
for ENTRY in "${PLATFORMS[@]}"; do
|
||||
local SUFFIX="${ENTRY%%|*}"
|
||||
local LABEL="${ENTRY##*|}"
|
||||
local FILENAME="${BINARY_NAME}-${TAG_NAME}-${SUFFIX}"
|
||||
echo "| ${LABEL} | [${FILENAME}](${BASE_URL}/${FILENAME}) | [sha256](${BASE_URL}/${FILENAME}.sha256) |"
|
||||
done
|
||||
}
|
||||
|
||||
SERVICE_TABLE="$(build_table soundtouch-service)"
|
||||
CLI_TABLE="$(build_table soundtouch-cli)"
|
||||
|
||||
cat << EOF
|
||||
<!-- quick-downloads:start -->
|
||||
## Quick downloads
|
||||
|
||||
Most people only need one of these two:
|
||||
|
||||
**soundtouch-service** — the 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.
|
||||
|
||||
$SERVICE_TABLE
|
||||
|
||||
**soundtouch-cli** — command-line control of any device: playback, presets, sources, multiroom zones, discovery, and migration. Good for scripting and home automation.
|
||||
|
||||
$CLI_TABLE
|
||||
|
||||
Everything else (soundtouch-player, soundtouch-backup, other platforms, Docker, install scripts): [Downloads page](https://gesellix.github.io/Bose-SoundTouch/docs/downloads/).
|
||||
<!-- quick-downloads:end -->
|
||||
EOF
|
||||
Reference in New Issue
Block a user