Compare commits

..
12 Commits
Author SHA1 Message Date
Tobias Gesellchen b3ff98290b feat(soundtouchweb): make #622 auto-resume opt-in via settings.json
Automatically re-triggering content selection without a user action
isn't something every operator wants, and we haven't independently
confirmed the root cause generalises beyond the original report.

Add Settings.AutoResumeOnSourceDisconnect (default false, hand-edit
settings.json to enable, matching the TuneInStreamFormats precedent -
no admin UI control yet). Wired through a WebApp hook so the standalone
soundtouch-player build stays unaffected, and read fresh per drop so
toggling the setting takes effect without a restart.
2026-08-18 21:38:33 +02:00
Tobias Gesellchen 133ba5a616 fix(soundtouchweb): auto-resume playback after an unsolicited SOURCE_DISCONNECTED (#622)
A speaker can drop its own active source mid-playback (errorUpdate 1041
SOURCE_DISCONNECTED -> now_playing INVALID_SOURCE) while the SoundTouch
WebSocket control channel stays healthy throughout. Nothing previously
noticed this: logNowPlayingError only logged the transition, leaving
the speaker silent until someone manually re-selected the source.

Add autoResumeState, tracking the last healthy ContentItem per device
connection. On a fresh transition into an error source (not a repeat
of one already seen), it re-issues that ContentItem via SelectContentItem
after a short backoff - the same call pressing the preset again makes.
No attempt cap: if the resume itself fails, the source stays in error
and nothing fires again until a genuine recovery is observed, which
already bounds retries for a station that's truly gone without capping
a station that legitimately (and repeatedly) recovers on its own.
2026-08-18 21:38:33 +02:00
dependabot[bot] 27bb738751 ci(deps): bump the codeql-action group with 3 updates
Bumps the codeql-action group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.37.6 to 4.37.7
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/5595ccaf912efad79be6eef63a5619ff05969be3...ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd)

Updates `github/codeql-action/analyze` from 4.37.6 to 4.37.7
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/5595ccaf912efad79be6eef63a5619ff05969be3...ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd)

Updates `github/codeql-action/upload-sarif` from 4.37.6 to 4.37.7
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/5595ccaf912efad79be6eef63a5619ff05969be3...ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.37.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.37.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-18 19:55:50 +02:00
Tobias Gesellchen e57708ea11 fix(setup): gate setup pair --mode=full on configuration status (#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 (reported for ST30 Series II/III in #615). Only a full
pass through the WebSocket setup state machine clears it, but running
that unconditionally risks re-running the bracket on speakers that
don't need or support it.

Add Manager.PreflightInitPlan: checks /supportedURLs for
/setMargeAccount, then requires /soundTouchConfigurationStatus to read
exactly SOUNDTOUCH_NOT_CONFIGURED before ExecuteInitPlan runs.
Already-configured devices are a no-op; an unsupported route or an
unrecognised status value aborts instead of guessing.
2026-08-17 21:29:39 +02:00
Tobias GesellchenandClaude Sonnet 5 d873d88b4f fix(admin-ui): clarify CA/TLS and HTTPS test are optional for HTTP plans
The default Suggested Plan (both XML-over-SSH and Telnet) migrates the
speaker over plain HTTP and never touches CA/TLS at all, but the CA/TLS
precondition always showed a red not-installed marker and the HTTPS
Connection Test panel was always rendered, regardless of whether the
current Target URL actually needs HTTPS. Both read as mandatory steps
even when nothing needed doing.

CA/TLS and HTTPS only matter when the Target URL is https:// or the
Customize form's DNS-interception method is chosen (that one always
targets https://*.bose.com).

- caVerdict() now takes whether the Target URL is HTTPS: shows a
  neutral marker with a "not needed" note for HTTP targets, keeps the
  red marker with a sharper "required" note for HTTPS targets.
- The HTTPS Connection Test panel gets a small note under its heading
  ("Optional for your current plan (HTTP)" / "Required ... (HTTPS)"),
  computed from the same check. Stays visible either way so someone can
  still run it if they want.

Frontend-only — showSummary already had the Target URL in scope.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 20:58:51 +02:00
Tobias GesellchenandClaude Sonnet 5 57c0895063 fix(admin-ui): make Migration tab action buttons consistently reachable
Follow-up on #621: the Reboot Speaker button (plus Revert to Defaults,
Enable SSH, Disable SSH) was reachable only after expanding the
collapsed "Customize this migration" section and scrolling past three
fieldsets and the XML/telnet diff panes. Meanwhile every other real
action elsewhere in the admin UI (Save Settings, Apply Suggested Plan,
Start Sync, ...) is visible by default.

- Move Revert to Defaults and Reboot Speaker into an always-visible
  "Speaker controls" row directly under the Migration State card.
- Move Enable/Disable SSH into the Preconditions table, inline with the
  SSH (remote_services) status row, sized like the existing "Trust CA
  Now" button next to the CA/TLS row. script.js now only rewrites the
  inner status span on re-render (matching the CA/TLS pattern) so the
  buttons survive summary refreshes.
- Add shared .btn-primary/.btn-danger CSS classes so button color
  consistently means the same thing everywhere (primary = confirm,
  danger = destructive) instead of ad-hoc inline colors; applied to
  Save Settings, Apply Suggested/Custom Plan, Enable/Disable SSH,
  Revert to Defaults, and Trust CA Now. Removed decorative gray from
  Reboot Speaker and the connection/DNS test buttons.
- Replace the "Cancel" button (which only hid the whole summary panel,
  not any of the actions it sat beside) with a "✕ Hide" control next
  to the "Migration Summary for <device>" heading, alongside a new
  "↻ Reload" shortcut for refreshSummary().
- Remove the now-unneeded force-open-the-details hack in migrate()
  since Reboot no longer lives inside any collapsed container.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 20:58:51 +02:00
Tobias GesellchenandClaude Sonnet 5 fb8eab27c3 docs(readme): remove obsolete Go Report Card badge
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 20:58:38 +02:00
Tobias GesellchenandClaude Sonnet 5 2218a28179 docs: add ST30 III reset note and post-update version-check guidance
Two doc notes from dunha's #621 follow-up: the factory-reset button
combo is confirmed identical on the SoundTouch 30 Series III, and
checking the reported version right after an on-device update can
still show stale info until the speaker (or an open Admin UI tab) is
rebooted, even though the new binary is already running.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 19:57:02 +02:00
Tobias GesellchenandClaude Sonnet 5 9e56c4f3f4 fix(setup,admin-ui,install): three bugs from #621 follow-up feedback
- setup: resync all four boseurls (not just marge/swUpdate) over telnet
  after an SSH-XML migration. `envswitch boseurls set` persists whatever
  is currently in the runtime layer, so leaving stats/bmx untouched froze
  their stale pre-migration values into the persistence layer permanently
  -- surviving reboot and previously requiring a factory reset to clear.
- admin-ui: Migrate tab's Target Domain edits now propagate into the four
  service URL fields (tracked via a dataset.autofilled flag so real manual
  edits still aren't clobbered), closing the gap where changing Target
  Domain to a new value left the four fields pointed at a stale default.
- install.sh: prune stale binary backups before the download too, not
  only after a successful install, so a backup left by a previously
  aborted (out-of-space) run gets cleaned up instead of compounding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 19:57:02 +02:00
dependabot[bot] ba45d997cf deps(deps): bump the golang group with 3 updates
Bumps the golang group with 3 updates: [golang.org/x/mod](https://github.com/golang/mod), [golang.org/x/net](https://github.com/golang/net) and [golang.org/x/tools](https://github.com/golang/tools).


Updates `golang.org/x/mod` from 0.39.0 to 0.40.0
- [Commits](https://github.com/golang/mod/compare/v0.39.0...v0.40.0)

Updates `golang.org/x/net` from 0.57.0 to 0.58.0
- [Commits](https://github.com/golang/net/compare/v0.57.0...v0.58.0)

Updates `golang.org/x/tools` from 0.48.0 to 0.49.0
- [Release notes](https://github.com/golang/tools/releases)
- [Commits](https://github.com/golang/tools/compare/v0.48.0...v0.49.0)

---
updated-dependencies:
- dependency-name: golang.org/x/mod
  dependency-version: 0.40.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/net
  dependency-version: 0.58.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/tools
  dependency-version: 0.49.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 19:44:44 +02:00
Tobias GesellchenandClaude Sonnet 5 eec57cbc10 fix(admin-ui): Migrate tab blocked on-device localhost by default
validateURL() unconditionally rejected the hostnames "localhost" and
"127.0.0.1" for the four Migrate-tab plan URL fields, with no awareness
of deployment mode. Since the Suggested Plan's URLs are derived from the
page's own configured Target URL, a fresh on-device install (whose
server_url is now correctly http://localhost:8000, since #546) loaded
the Migrate tab with "Apply Suggested Plan" and "Pre-flight" disabled
by default, before the user touched anything -- directly contradicting
the on-device docs' "Migrate -> accept the suggested plan -> apply"
instructions.

Found while investigating why a #614 reporter used the non-standard
"localhost.localdomain" as a workaround, and why a #621 reporter got
stuck with "Migration Status: Migrated (URL mismatch)" trying to follow
the (correct) on-device localhost guidance.

Fix: a loopback URL is only flagged when it doesn't match the plan's
own Target URL origin. A field that's exactly what the service itself
is already configured to answer as (the on-device case) is accepted;
a stray "localhost" typed into one field while Target URL is a real LAN
address (the external-host mistake the check exists to catch) is still
flagged, since the origins differ.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 20:41:40 +02:00
Tobias GesellchenandClaude Sonnet 5 3e730c983f docs(troubleshooting): Settings tab alone never updates an already-migrated speaker
Confirmed via code review: the Settings-save handler only updates the
service's own serverURL/settings.json, never contacts a device, and
neither migration method (telnet or XML/SSH) leaves anything behind that
would make a speaker later re-fetch a new address on its own. Both write
once, at migrate time.

Adds a Troubleshooting entry for this, and a cross-reference from the
Migration Guide's Step 2 (Target Domain) pointing at it, plus a step-
number fix (Migrate is Step 5, not Step 4).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 20:23:13 +02:00
29 changed files with 1027 additions and 202 deletions
+2 -2
View File
@@ -40,7 +40,7 @@ jobs:
run: sudo apt-get install -y libpcap-dev
- name: Initialize CodeQL
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/init@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@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
category: "/language:${{ matrix.language }}"
+1 -1
View File
@@ -78,7 +78,7 @@ jobs:
- name: Upload Semgrep SARIF results
if: always()
uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
sarif_file: semgrep.sarif
continue-on-error: true
-1
View File
@@ -2,7 +2,6 @@
<p style="margin-top: -10px; font-style: italic; color: #666;">Bose SoundTouch Toolkit</p>
[![Go Reference](https://pkg.go.dev/badge/github.com/gesellix/bose-soundtouch.svg)](https://pkg.go.dev/github.com/gesellix/bose-soundtouch)
[![Go Report Card](https://goreportcard.com/badge/github.com/gesellix/bose-soundtouch)](https://goreportcard.com/report/github.com/gesellix/bose-soundtouch)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
> Independent project. **Not affiliated with, endorsed by, sponsored
+12 -1
View File
@@ -2081,6 +2081,17 @@ func runPairBare(c *cli.Context, deviceIP, accountID string) error {
func runPairFull(c *cli.Context, deviceIP, accountID string) error {
m := setup.NewManager(c.String("service-url"), nil, nil)
needed, status, err := m.PreflightInitPlan(deviceIP)
if err != nil {
PrintError(fmt.Sprintf("preflight: %v", err))
return err
}
if !needed {
PrintSuccess(fmt.Sprintf("Device already configured (status=%s) — nothing to do.", status))
return nil
}
plan := setup.InitPlan{
DeviceIP: deviceIP,
ServiceURL: c.String("service-url"),
@@ -2094,7 +2105,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)
+11
View File
@@ -1471,6 +1471,17 @@ func newEmbeddedWebApp(server *handlers.Server, serverURL, internalURL string, d
return err
}
// Opt-in (#622): hand-edit settings.json's auto_resume_on_source_disconnect
// to enable. Read fresh per drop so toggling it applies without a restart.
webApp.AutoResumeOnSourceDisconnect = func() bool {
settings, err := ds.GetSettings()
if err != nil {
return false
}
return settings.AutoResumeOnSourceDisconnect
}
// Keep the UI registry live as the service discovers or devices are added.
server.SetDevicesChangedHook(func() {
webApp.SeedExtraDevices()
@@ -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
@@ -1427,6 +1427,15 @@ 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
@@ -107,6 +107,8 @@ 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.
@@ -84,7 +84,7 @@ rm -f /mnt/nv/aftertouch/soundtouch-cli
df -h /mnt/nv # confirm space recovered
```
> **From v0.89.0 onwards the installer prunes stale artefacts automatically**
> **From v0.93.0 onwards the installer prunes stale artefacts automatically**
> during every upgrade — manual cleanup should no longer be necessary on
> fresh installs.
@@ -404,6 +404,13 @@ curl -sSLo install.sh https://raw.githubusercontent.com/gesellix/Bose-SoundTouch
sh install.sh --version 0.123.0
```
The script's own final output already confirms the new version came up and
is answering on `:8000`. If you separately check the version yourself
(`wget -qO- http://localhost:8000/health`, or the Admin UI), **reboot the
speaker first**: an Admin UI tab left open from before the update, or a
browser cache of the previous page load, can otherwise still show the old
version even though the new binary is already running.
**Rollback:** the installer keeps a `.backup` file alongside the binary:
```bash
+27 -1
View File
@@ -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.
@@ -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.
+3 -3
View File
@@ -16,8 +16,8 @@ require (
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
github.com/urfave/cli/v2 v2.27.7
golang.org/x/crypto v0.55.0
golang.org/x/mod v0.39.0
golang.org/x/net v0.57.0
golang.org/x/mod v0.40.0
golang.org/x/net v0.58.0
golang.org/x/term v0.45.0
)
@@ -36,5 +36,5 @@ require (
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/tools v0.48.0 // indirect
golang.org/x/tools v0.49.0 // indirect
)
+6 -6
View File
@@ -69,12 +69,12 @@ golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74=
golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY=
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
@@ -89,8 +89,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
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=
+13
View File
@@ -2651,6 +2651,19 @@ type Settings struct {
// individual format tokens.
TuneInStreamFormats string `json:"tunein_stream_formats,omitempty"`
// AutoResumeOnSourceDisconnect, when true, re-issues a device's last
// playing content item if now_playing drops into an error source right
// after a healthy one, instead of leaving the speaker silent until a
// user manually re-selects it. See #622: some TuneIn streams disconnect
// the speaker's own audio pipeline (errorUpdate 1041
// SOURCE_DISCONNECTED) on their own, mid-playback, with the SoundTouch
// WebSocket control channel staying healthy throughout; the observed
// fix is exactly what pressing the preset again does. Opt-in (default
// false): this automatically re-triggers content selection without a
// user action, which not every operator wants. Hand-edit settings.json
// to enable — no admin UI control yet, matching TuneInStreamFormats.
AutoResumeOnSourceDisconnect bool `json:"auto_resume_on_source_disconnect,omitempty"`
// DefaultLanding selects what the root path "/" serves to a browser:
// "chooser" (or empty) — the neutral landing page that links to the
// player and the admin/setup console;
+46 -44
View File
@@ -673,28 +673,29 @@ func (s *Server) addSystemFiles(tw *tar.Writer) {
// diagSettings is a copy of datastore.Settings with secrets zeroed out so the
// struct can be marshalled into the archive without exposing credentials.
type diagSettings struct {
ServerURL string `json:"server_url"`
HTTPSServerURL string `json:"https_server_url,omitempty"`
HTTPSServerURLOverride string `json:"https_server_url_override,omitempty"`
RedactLogs bool `json:"redact_logs"`
LogBodies bool `json:"log_bodies"`
RecordInteractions bool `json:"record_interactions"`
DiscoveryInterval string `json:"discovery_interval,omitempty"`
DiscoveryEnabled bool `json:"discovery_enabled"`
DNSEnabled bool `json:"dns_enabled"`
DNSUpstream []string `json:"dns_upstream,omitempty"`
DNSBindAddr string `json:"dns_bind_addr,omitempty"`
InternalPaths []string `json:"internal_paths,omitempty"`
Shortcuts map[string]int `json:"shortcuts,omitempty"`
SpotifyClientID string `json:"spotify_client_id,omitempty"`
SpotifyClientSecret string `json:"spotify_client_secret,omitempty"`
SpotifyRedirectURI string `json:"spotify_redirect_uri,omitempty"`
AmazonClientID string `json:"amazon_client_id,omitempty"`
AmazonClientSecret string `json:"amazon_client_secret,omitempty"`
AmazonRedirectURI string `json:"amazon_redirect_uri,omitempty"`
TrustForwardedHeaders bool `json:"trust_forwarded_headers,omitempty"`
TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs,omitempty"`
TuneInStreamFormats string `json:"tunein_stream_formats,omitempty"`
ServerURL string `json:"server_url"`
HTTPSServerURL string `json:"https_server_url,omitempty"`
HTTPSServerURLOverride string `json:"https_server_url_override,omitempty"`
RedactLogs bool `json:"redact_logs"`
LogBodies bool `json:"log_bodies"`
RecordInteractions bool `json:"record_interactions"`
DiscoveryInterval string `json:"discovery_interval,omitempty"`
DiscoveryEnabled bool `json:"discovery_enabled"`
DNSEnabled bool `json:"dns_enabled"`
DNSUpstream []string `json:"dns_upstream,omitempty"`
DNSBindAddr string `json:"dns_bind_addr,omitempty"`
InternalPaths []string `json:"internal_paths,omitempty"`
Shortcuts map[string]int `json:"shortcuts,omitempty"`
SpotifyClientID string `json:"spotify_client_id,omitempty"`
SpotifyClientSecret string `json:"spotify_client_secret,omitempty"`
SpotifyRedirectURI string `json:"spotify_redirect_uri,omitempty"`
AmazonClientID string `json:"amazon_client_id,omitempty"`
AmazonClientSecret string `json:"amazon_client_secret,omitempty"`
AmazonRedirectURI string `json:"amazon_redirect_uri,omitempty"`
TrustForwardedHeaders bool `json:"trust_forwarded_headers,omitempty"`
TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs,omitempty"`
TuneInStreamFormats string `json:"tunein_stream_formats,omitempty"`
AutoResumeOnSourceDisconnect bool `json:"auto_resume_on_source_disconnect,omitempty"`
}
// addSettingsJSON serialises the service settings into the archive as
@@ -773,28 +774,29 @@ func (s *Server) addSettingsJSON(tw *tar.Writer) {
_, effectiveHTTPSURL := s.GetSettings()
ds := diagSettings{
ServerURL: st.ServerURL,
HTTPSServerURL: effectiveHTTPSURL,
HTTPSServerURLOverride: st.HTTPServerURL,
RedactLogs: st.RedactLogs,
LogBodies: st.LogBodies,
RecordInteractions: st.RecordInteractions,
DiscoveryInterval: st.DiscoveryInterval,
DiscoveryEnabled: st.DiscoveryEnabled,
DNSEnabled: st.DNSEnabled,
DNSUpstream: st.DNSUpstream,
DNSBindAddr: st.DNSBindAddr,
InternalPaths: st.InternalPaths,
Shortcuts: st.Shortcuts,
SpotifyClientID: st.SpotifyClientID,
SpotifyClientSecret: redact(st.SpotifyClientSecret),
SpotifyRedirectURI: st.SpotifyRedirectURI,
AmazonClientID: st.AmazonClientID,
AmazonClientSecret: redact(st.AmazonClientSecret),
AmazonRedirectURI: st.AmazonRedirectURI,
TrustForwardedHeaders: st.TrustForwardedHeaders,
TrustedProxyCIDRs: st.TrustedProxyCIDRs,
TuneInStreamFormats: st.TuneInStreamFormats,
ServerURL: st.ServerURL,
HTTPSServerURL: effectiveHTTPSURL,
HTTPSServerURLOverride: st.HTTPServerURL,
RedactLogs: st.RedactLogs,
LogBodies: st.LogBodies,
RecordInteractions: st.RecordInteractions,
DiscoveryInterval: st.DiscoveryInterval,
DiscoveryEnabled: st.DiscoveryEnabled,
DNSEnabled: st.DNSEnabled,
DNSUpstream: st.DNSUpstream,
DNSBindAddr: st.DNSBindAddr,
InternalPaths: st.InternalPaths,
Shortcuts: st.Shortcuts,
SpotifyClientID: st.SpotifyClientID,
SpotifyClientSecret: redact(st.SpotifyClientSecret),
SpotifyRedirectURI: st.SpotifyRedirectURI,
AmazonClientID: st.AmazonClientID,
AmazonClientSecret: redact(st.AmazonClientSecret),
AmazonRedirectURI: st.AmazonRedirectURI,
TrustForwardedHeaders: st.TrustForwardedHeaders,
TrustedProxyCIDRs: st.TrustedProxyCIDRs,
TuneInStreamFormats: st.TuneInStreamFormats,
AutoResumeOnSourceDisconnect: st.AutoResumeOnSourceDisconnect,
}
data, err := json.MarshalIndent(ds, "", " ")
+14
View File
@@ -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;
+79 -91
View File
@@ -533,7 +533,7 @@
</div>
<div style="margin-bottom: 20px">
<button onclick="updateSettings()">Save Settings</button>
<button class="btn-primary" onclick="updateSettings()">Save Settings</button>
<span
id="settings-status"
style="margin-left: 10px; font-size: 0.9em"
@@ -691,9 +691,27 @@
class="summary-box"
style="display: none"
>
<h3>
Migration Summary for
<span id="summary-device-display"></span>
<h3 style="display: flex; align-items: baseline; justify-content: space-between">
<span>
Migration Summary for
<span id="summary-device-display"></span>
</span>
<span style="display: flex; gap: 6px">
<button
type="button"
onclick="refreshSummary()"
title="Reload summary for this device"
aria-label="Reload summary"
style="padding: 2px 8px; font-size: 0.85em; line-height: 1; cursor: pointer; font-weight: normal"
>&#x21bb; 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"
>&#x2715; Hide</button>
</span>
</h3>
<input type="hidden" id="summary-device-id"/>
<p>Migration Status: <span id="migration-status"></span></p>
@@ -739,7 +757,8 @@
<button
id="trust-ca-btn"
type="button"
style="display: none; background-color: #607d8b; color: white; border: none; padding: 2px 8px; font-size: 0.85em"
class="btn-primary"
style="display: none; padding: 2px 8px; font-size: 0.85em"
>Trust CA Now</button>
<a
href="/setup/ca.crt"
@@ -760,7 +779,24 @@
<tbody>
<tr style="border-top: 1px solid #eee">
<td style="padding: 4px 8px; width: 170px; color: #555" title="The remote_services file controls whether SSH is available after reboot">SSH (remote_services)</td>
<td id="state-remote-services-cell" style="padding: 4px 8px"></td>
<td id="state-remote-services-cell" style="padding: 4px 8px">
<span id="state-remote-services-line"></span>
<span style="margin-left: 12px; white-space: nowrap">
<button
id="ensure-remote-btn"
type="button"
class="btn-primary"
style="padding: 2px 8px; font-size: 0.85em"
>Enable SSH (Persist remote_services)</button>
<button
id="remove-remote-btn"
type="button"
class="btn-danger"
title="Removes the remote_services file — SSH will be disabled after the next reboot"
style="margin-left: 6px; padding: 2px 8px; font-size: 0.85em"
>Disable SSH (Remove remote_services)</button>
</span>
</td>
</tr>
<tr style="border-top: 1px solid #eee">
<td style="padding: 4px 8px; color: #555">Account paired</td>
@@ -775,6 +811,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. -->
@@ -808,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
@@ -819,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>
@@ -879,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>
@@ -963,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>
@@ -975,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>
@@ -987,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>
@@ -999,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>
@@ -1067,6 +1111,7 @@
<button
type="button"
id="plan-apply-btn"
class="btn-primary"
onclick="applySuggestedPlan()"
style="font-size: 0.95em"
>Apply Suggested Plan</button>
@@ -1150,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>
@@ -1236,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>
+101 -22
View File
@@ -2096,7 +2096,7 @@ async function showSummary(deviceId) {
if (accountIdEl && summary.account_id) accountIdEl.innerText = summary.account_id;
}
renderMigrationState(summary);
renderMigrationState(summary, targetUrl);
renderPlan(summary);
renderPlanCurrentURLs(summary);
renderPlanPairing(summary, deviceId);
@@ -2150,6 +2150,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";
@@ -2558,18 +2572,15 @@ async function migrate(deviceId, ip, method) {
}),
);
// Make reboot button available and prominent
// Make reboot button available and prominent. It lives in the
// always-visible "Speaker controls" row (see #621 — it used to
// be reachable only after expanding "Customize this migration"),
// so no need to force any collapsed container open here.
const rebootBtn = document.getElementById("reboot-speaker-btn");
rebootBtn.style.display = "inline-block";
rebootBtn.disabled = false;
rebootBtn.style.border = "2px solid #000";
// The Reboot button now lives inside the "Customize this
// migration" <details>; expand it so the post-migration
// reboot affordance is reachable from the Plan flow too.
const customize = rebootBtn.closest("details");
if (customize) customize.open = true;
// Re-show summary but with prominence on reboot
summaryDiv.style.display = "block";
} else {
@@ -2776,6 +2787,26 @@ function onPlanTargetURLChange() {
saved.innerText = "✏️ unsaved change — click \"Save as default\" to persist";
saved.style.color = "#bf6900";
}
// Re-derive the four service URL fields from the new Target URL, same
// as the initial pre-fill on summary render. fillPlanURLInputs still
// only overwrites fields the user hasn't hand-edited (tracked via
// dataset.autofilled), so this doesn't clobber genuinely manual edits.
// Without this, changing Target Domain to e.g. localhost left the four
// fields pointed at a stale default with no warning until the user
// edited them by hand (#621 follow-up).
const soundcork = document.getElementById("plan-soundcork-mode") &&
document.getElementById("plan-soundcork-mode").checked;
fillPlanURLInputs(defaultServiceURLs(v, {soundcorkMode: soundcork}));
}
// onPlanURLFieldEdited marks a Plan-card URL input as manually edited so
// fillPlanURLInputs stops treating it as an auto-fillable default, then
// re-validates. Wired from each of the four fields' oninput instead of
// calling validatePlanURLs() directly.
function onPlanURLFieldEdited(el) {
el.dataset.autofilled = "";
validatePlanURLs();
}
// saveTargetURLAsDefault posts the current plan-target-url value to
@@ -2838,8 +2869,12 @@ function defaultServiceURLs(targetUrl, options = {}) {
// fillPlanURLInputs writes the four URLs into the Plan card inputs.
// force=true overwrites existing values (used by Reset and the
// Soundcork toggle); force=false only fills empties (used on summary
// render so manual edits survive a refresh).
// Soundcork toggle); force=false only fills empties and fields still
// flagged dataset.autofilled=true (used on summary render and on Target
// URL changes, so manual edits survive but a still-default value tracks
// Target URL). Every field this function writes to is (re-)flagged
// autofilled; onPlanURLFieldEdited clears the flag the moment a user
// types into a field directly.
function fillPlanURLInputs(urls, {force = false} = {}) {
const fields = [
["plan-marge-url", urls.marge],
@@ -2850,7 +2885,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();
}
@@ -2882,7 +2920,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: ""};
@@ -2899,7 +2946,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."};
}
@@ -2918,12 +2966,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}`);
}
@@ -3781,7 +3840,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");
@@ -3862,16 +3925,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");
@@ -3993,9 +4059,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) {
+41
View File
@@ -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"
+53
View File
@@ -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
+76
View File
@@ -225,6 +225,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.
+122 -7
View File
@@ -16,13 +16,14 @@ import (
// 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 +63,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://"):]
@@ -360,6 +371,110 @@ func TestEnsureMargeAccountPaired_PropagatesPairingFailure(t *testing.T) {
}
}
func TestReadConfigurationStatus_ReturnsRawStatus(t *testing.T) {
d := newFakeDevice(t)
d.configurationStatus = ConfigurationStatusConfigured
m := &Manager{}
status, err := m.ReadConfigurationStatus(d.addr)
if err != nil {
t.Fatalf("ReadConfigurationStatus: %v", err)
}
if status != ConfigurationStatusConfigured {
t.Errorf("status = %q, want %q", status, ConfigurationStatusConfigured)
}
}
func TestReadConfigurationStatus_ErrorsWhenRouteUnsupported(t *testing.T) {
d := newFakeDevice(t)
d.configurationStatus = ""
m := &Manager{}
if _, err := m.ReadConfigurationStatus(d.addr); err == nil {
t.Fatal("expected an error when the route is unsupported (404)")
}
}
func TestPreflightInitPlan_NotConfiguredNeedsRepair(t *testing.T) {
d := newFakeDevice(t)
d.configurationStatus = ConfigurationStatusNotConfigured
m := &Manager{}
needed, status, err := m.PreflightInitPlan(d.addr)
if err != nil {
t.Fatalf("PreflightInitPlan: %v", err)
}
if !needed {
t.Error("needed should be true for SOUNDTOUCH_NOT_CONFIGURED")
}
if status != ConfigurationStatusNotConfigured {
t.Errorf("status = %q, want %q", status, ConfigurationStatusNotConfigured)
}
}
func TestPreflightInitPlan_AlreadyConfiguredIsNoOp(t *testing.T) {
d := newFakeDevice(t)
d.configurationStatus = ConfigurationStatusConfigured
m := &Manager{}
needed, status, err := m.PreflightInitPlan(d.addr)
if err != nil {
t.Fatalf("PreflightInitPlan: %v", err)
}
if needed {
t.Error("needed should be false for SOUNDTOUCH_CONFIGURED")
}
if status != ConfigurationStatusConfigured {
t.Errorf("status = %q, want %q", status, ConfigurationStatusConfigured)
}
}
func TestPreflightInitPlan_UnsupportedSetMargeAccountFailsClosed(t *testing.T) {
d := newFakeDevice(t)
d.supportsSetMarge = false
d.configurationStatus = ConfigurationStatusNotConfigured
m := &Manager{}
needed, _, err := m.PreflightInitPlan(d.addr)
if err == nil {
t.Fatal("expected an error when /setMargeAccount is not listed in /supportedURLs")
}
if needed {
t.Error("needed should be false when preflight fails")
}
}
func TestPreflightInitPlan_UnrecognisedStatusFailsClosed(t *testing.T) {
d := newFakeDevice(t)
d.configurationStatus = "SOMETHING_UNEXPECTED"
m := &Manager{}
needed, status, err := m.PreflightInitPlan(d.addr)
if err == nil {
t.Fatal("expected an error for an unrecognised status value")
}
if needed {
t.Error("needed should be false when the status is unrecognised")
}
if status != "SOMETHING_UNEXPECTED" {
t.Errorf("status = %q, want the raw unrecognised value returned alongside the error", status)
}
}
func TestIsValidAccountID(t *testing.T) {
cases := []struct {
in string
+21 -7
View File
@@ -1089,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)
}
+27 -11
View File
@@ -2102,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)
}
}
}
+98
View File
@@ -0,0 +1,98 @@
package soundtouchweb
import (
"log"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
)
// autoResumeBackoff is the delay before re-issuing a dropped content item,
// giving a transient upstream hiccup a moment to clear before retrying.
const autoResumeBackoff = 2 * time.Second
// autoResumeState tracks what ConnectDeviceWebSocket needs to decide whether
// a now_playing transition should trigger an auto-resume. Split out from the
// WebSocket goroutine so the decision can be unit tested without a live
// connection.
//
// resumeAttempts only labels log lines — it is never used to cap retries.
// A resume is gated on wasError being false (see observe), which already
// means at most one attempt ever fires per drop: if the attempt fails and
// the source stays in error, every following event has wasError=true and
// nothing fires again until a genuine recovery is observed. A station that
// keeps recovering and re-dropping (the reported #622 pattern — a TuneIn
// stream disconnecting the speaker on a fixed cycle, indefinitely, while
// otherwise healthy) is exactly the case this should keep resuming forever.
type autoResumeState struct {
lastGoodContentItem *models.ContentItem
resumeAttempts int
}
// observe updates the state for a new now_playing event and reports whether
// the caller should fire an auto-resume for item, plus a label for the log
// line. prevSource is the source seen on the previous event.
//
// #622: some TuneIn stations disconnect the speaker's audio pipeline on
// their own (errorUpdate 1041 SOURCE_DISCONNECTED, observed ~5m35s into
// playback on one reporter's setup) even though the SoundTouch WebSocket
// control channel stays healthy throughout. The firmware does not recover
// on its own, so a fresh transition into an error source right after a
// healthy one — the speaker dropping a source it didn't choose to leave, as
// opposed to the user picking a new one — re-issues the last content item,
// exactly what pressing the physical preset button again does.
func (s *autoResumeState) observe(prevSource string, np *models.NowPlaying) (item *models.ContentItem, attempt int, shouldResume bool) {
wasError := isErrorSource(prevSource)
nowError := isErrorSource(np.Source)
if !nowError {
if np.ContentItem != nil {
s.lastGoodContentItem = np.ContentItem
}
return nil, 0, false
}
if wasError || s.lastGoodContentItem == nil {
return nil, 0, false
}
s.resumeAttempts++
return s.lastGoodContentItem, s.resumeAttempts, true
}
// autoResumePlayback re-selects item on conn's device after autoResumeBackoff.
// It runs in its own goroutine (never on the WebSocket read loop) so a slow
// or hanging /select call can't stall processing of further device events.
func autoResumePlayback(conn *webtypes.DeviceConnection, deviceID string, item *models.ContentItem, attempt int) {
autoResumePlaybackAfter(conn, deviceID, item, attempt, autoResumeBackoff)
}
// autoResumePlaybackAfter is autoResumePlayback with an injectable delay so
// tests don't have to wait out the real backoff.
func autoResumePlaybackAfter(conn *webtypes.DeviceConnection, deviceID string, item *models.ContentItem, attempt int, delay time.Duration) {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-timer.C:
case <-conn.Done():
return
}
if conn.Client == nil {
return
}
if err := conn.Client.SelectContentItem(item); err != nil {
log.Printf("[play] device=%q auto-resume attempt %d failed: %v",
sanitizeLog(deviceID), attempt, err)
return
}
log.Printf("[play] device=%q auto-resume attempt %d re-selected source=%q location=%q",
sanitizeLog(deviceID), attempt, sanitizeLog(item.Source), sanitizeLog(item.Location))
}
@@ -0,0 +1,190 @@
package soundtouchweb
import (
"strings"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
)
func tuneInNowPlaying(source string) *models.NowPlaying {
return &models.NowPlaying{
Source: source,
ContentItem: &models.ContentItem{
Source: "TUNEIN",
Type: "stationurl",
Location: "/v1/playback/station/s119025",
ItemName: "Arabella Lovesongs",
},
}
}
func TestAutoResumeState_HealthyRemembersContentItemAndDoesNotResume(t *testing.T) {
s := &autoResumeState{}
item, attempt, shouldResume := s.observe("", tuneInNowPlaying("TUNEIN"))
if shouldResume {
t.Fatalf("shouldResume = true on a healthy source, want false")
}
if item != nil || attempt != 0 {
t.Errorf("item/attempt = %v/%d, want nil/0", item, attempt)
}
if s.lastGoodContentItem == nil {
t.Fatal("lastGoodContentItem was not recorded from a healthy now_playing")
}
}
func TestAutoResumeState_FreshErrorAfterHealthyTriggersResume(t *testing.T) {
s := &autoResumeState{}
// Prime with a healthy TUNEIN event, matching the WS handler calling
// observe once per event with the source seen on the previous call.
s.observe("", tuneInNowPlaying("TUNEIN"))
item, attempt, shouldResume := s.observe("TUNEIN", tuneInNowPlaying("INVALID_SOURCE"))
if !shouldResume {
t.Fatal("shouldResume = false on a fresh error transition, want true")
}
if attempt != 1 {
t.Errorf("attempt = %d, want 1", attempt)
}
if item == nil || item.Location != "/v1/playback/station/s119025" {
t.Errorf("item = %+v, want the last healthy ContentItem", item)
}
}
func TestAutoResumeState_DoesNotResumeWithoutAPriorGoodContentItem(t *testing.T) {
s := &autoResumeState{}
// No healthy event was ever observed, so there's nothing to restore.
_, _, shouldResume := s.observe("", tuneInNowPlaying("INVALID_SOURCE"))
if shouldResume {
t.Fatal("shouldResume = true with no prior good ContentItem, want false")
}
}
func TestAutoResumeState_DoesNotResumeOnRepeatedErrorEvents(t *testing.T) {
s := &autoResumeState{}
s.observe("", tuneInNowPlaying("TUNEIN"))
s.observe("TUNEIN", tuneInNowPlaying("INVALID_SOURCE")) // first resume, attempt 1
// A second consecutive error event (wasError=true this time) must not
// fire another resume — one attempt per drop, not per event.
_, _, shouldResume := s.observe("INVALID_SOURCE", tuneInNowPlaying("INVALID_SOURCE"))
if shouldResume {
t.Fatal("shouldResume = true on a repeated error event, want false")
}
}
func TestAutoResumeState_KeepsResumingIndefinitelyAcrossRepeatedDrops(t *testing.T) {
s := &autoResumeState{}
s.observe("", tuneInNowPlaying("TUNEIN"))
// The reported #622 pattern: the same station drops and (once resumed)
// recovers repeatedly, indefinitely, on a fixed cycle. Each fresh drop
// after a genuine recovery must keep resuming — there is no cap.
const cycles = 20
for i := 1; i <= cycles; i++ {
_, attempt, shouldResume := s.observe("TUNEIN", tuneInNowPlaying("INVALID_SOURCE"))
if !shouldResume {
t.Fatalf("cycle %d: shouldResume = false, want true", i)
}
if attempt != i {
t.Errorf("cycle %d: attempt label = %d, want %d", i, attempt, i)
}
s.observe("INVALID_SOURCE", tuneInNowPlaying("TUNEIN")) // the resume worked
}
}
func TestAutoResumeState_StopsRetryingAfterAFailedResume(t *testing.T) {
s := &autoResumeState{}
s.observe("", tuneInNowPlaying("TUNEIN"))
_, _, shouldResume := s.observe("TUNEIN", tuneInNowPlaying("INVALID_SOURCE"))
if !shouldResume {
t.Fatal("shouldResume = false on the first drop, want true")
}
// The resume attempt itself failed (or the station is genuinely gone):
// the speaker keeps reporting the same error source on further events.
// wasError is now true, so nothing should fire again without a genuine
// recovery in between — this is what keeps a truly dead station from
// being retried forever.
for i := 0; i < 5; i++ {
_, _, shouldResume := s.observe("INVALID_SOURCE", tuneInNowPlaying("INVALID_SOURCE"))
if shouldResume {
t.Fatalf("iteration %d: shouldResume = true on a persisting error, want false", i)
}
}
}
func TestAutoResumePlaybackAfter_ReselectsContentItem(t *testing.T) {
speaker, captured := setupSpeakerMock(t, nil)
defer speaker.Close()
c := client.NewClient(&client.Config{Host: speaker.URL})
conn := webtypes.NewDeviceConnection(c, &models.DeviceInfo{DeviceID: "DEVICEID01"})
item := &models.ContentItem{Source: "TUNEIN", Type: "stationurl", Location: "/v1/playback/station/s119025", ItemName: "Arabella Lovesongs"}
done := make(chan struct{})
go func() {
autoResumePlaybackAfter(conn, "DEVICEID01", item, 1, 0)
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("autoResumePlaybackAfter did not return in time")
}
body, ok := captured["/select"]
if !ok {
t.Fatalf("no /select request captured; requests: %v", captured)
}
if !strings.Contains(body, `source="TUNEIN"`) || !strings.Contains(body, "/v1/playback/station/s119025") {
t.Errorf("/select body = %q, want it to carry the TUNEIN content item", body)
}
}
func TestAutoResumePlaybackAfter_StopsWhenConnectionClosed(t *testing.T) {
speaker, captured := setupSpeakerMock(t, nil)
defer speaker.Close()
c := client.NewClient(&client.Config{Host: speaker.URL})
conn := webtypes.NewDeviceConnection(c, &models.DeviceInfo{DeviceID: "DEVICEID01"})
conn.Close()
item := &models.ContentItem{Source: "TUNEIN", Type: "stationurl", Location: "/v1/playback/station/s119025"}
done := make(chan struct{})
go func() {
autoResumePlaybackAfter(conn, "DEVICEID01", item, 1, time.Hour)
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("autoResumePlaybackAfter did not return promptly after conn.Close()")
}
if _, ok := captured["/select"]; ok {
t.Error("/select was called after the connection was closed, want no request")
}
}
+9
View File
@@ -78,6 +78,15 @@ type WebApp struct {
// removal only prunes the in-memory registry).
RemoveDeviceHook func(deviceID string) error
// AutoResumeOnSourceDisconnect, when set and returning true, makes
// ConnectDeviceWebSocket re-issue a device's last playing content item
// after an unsolicited drop into an error source (#622). Opt-in: the
// embedded build wires it to Settings.AutoResumeOnSourceDisconnect
// (settings.json, default false); standalone soundtouch-player leaves it
// nil, which disables the behaviour. Read once per drop rather than
// cached, so toggling the setting takes effect without a restart.
AutoResumeOnSourceDisconnect func() bool
discoveryStatus atomic.Value // stores *webtypes.DiscoveryStatus
}
+11
View File
@@ -166,6 +166,12 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device
// error source is logged once per transition into it, not on every event.
var prevSource string
// resumeState survives both the speaker's own WebSocket reconnects and
// this loop's outer reconnects (declared once, outside the loop) so an
// auto-resume can fire regardless of which layer last re-established
// the connection.
resumeState := &autoResumeState{}
for {
// Stop if the device was removed from the registry (conn.Close()).
select {
@@ -189,6 +195,11 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device
logNowPlayingError(deviceID, np.Source, np.SourceAccount)
}
if item, attempt, shouldResume := resumeState.observe(prevSource, np); shouldResume &&
app.AutoResumeOnSourceDisconnect != nil && app.AutoResumeOnSourceDisconnect() {
go autoResumePlayback(conn, deviceID, item, attempt)
}
prevSource = np.Source
conn.UpdateStatus(func(s *webtypes.DeviceStatus) {
+23 -4
View File
@@ -87,6 +87,24 @@ 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".*.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"
curl \
-sSL \
-o "$UPDATE_TMP_DIR/binary" \
@@ -112,10 +130,11 @@ 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".*.old \
"$INSTALL_DIR/aftertouch-service.new"; do
@@ -124,7 +143,7 @@ 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