Compare commits

..
Author SHA1 Message Date
Tobias GesellchenandClaude Opus 5 62da5f23e0 TEMPORARY: trace speaker HTTP and player WS/HTTP traffic
Not for merge. Measures the source-selection readback cost in PR #670
review finding 2.

Server side: an http.RoundTripper wrapper on the speaker client logs every
outgoing request with a sequence number, path, status and duration. One
hook catches get/post/postWithResponse/avtransport alike. Enabled by
AFTERTOUCH_TRACE_SPEAKER=1.

Browser side: wraps fetch and WebSocket to log the player's HTTP calls and
every speaker event frame, with __trace.mark()/__trace.report() to bracket
and summarise one interaction. Enabled by localStorage.aftertouchTrace='1'.

Both are off by default, so a stray build stays silent. Delete
pkg/client/trace_temp.go, static/js/trace_temp.js, and their two call sites
when done.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 20:39:45 +02:00
41 changed files with 321 additions and 2769 deletions
+11 -64
View File
@@ -1510,23 +1510,12 @@ func renderMigrationSummary(deviceIP, serviceURL string, s *setup.MigrationSumma
}
}
var telnetRevertOverrideFlags = []string{"marge-url", "stats-url", "sw-update-url", "bmx-url"}
func validateRevertMethodOptions(method string, overrideFlags []string) error {
if method != "ssh" && method != string(setup.MigrationMethodTelnet) {
return fmt.Errorf("unsupported revert method %q; expected ssh or telnet", method)
}
if method != string(setup.MigrationMethodTelnet) && len(overrideFlags) > 0 {
return fmt.Errorf("--%s requires --method telnet", strings.Join(overrideFlags, ", --"))
}
return nil
}
// setupRevertCmd restores either the SSH/filesystem migration state or only
// the four URL fields written by a telnet migration. The default remains the
// existing SSH path for backwards compatibility.
// 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
@@ -1534,53 +1523,15 @@ func validateRevertMethodOptions(method string, overrideFlags []string) error {
func setupRevertCmd() *cli.Command {
return &cli.Command{
Name: "revert",
Usage: "Undo a migration via SSH backups or restore canonical Bose service URLs over telnet",
Usage: "Undo a migration: restore SoundTouchSdkPrivateCfg.xml/hosts/resolv.conf from backups and remove the AfterTouch CA cert",
Before: RequireHost,
Flags: []cli.Flag{
&cli.StringFlag{Name: "method", Value: "ssh", Usage: "ssh | telnet"},
&cli.StringFlag{Name: "marge-url", Usage: "Override the canonical Bose margeServerUrl (telnet only)"},
&cli.StringFlag{Name: "stats-url", Usage: "Override the canonical Bose statsServerUrl (telnet only)"},
&cli.StringFlag{Name: "sw-update-url", Usage: "Override the canonical Bose swUpdateUrl (telnet only)"},
&cli.StringFlag{Name: "bmx-url", Usage: "Override the canonical Bose bmxRegistryUrl (telnet only)"},
},
Action: func(c *cli.Context) error {
cfg := GetClientConfig(c)
method := c.String("method")
var overrideFlags []string
for _, flag := range telnetRevertOverrideFlags {
if c.IsSet(flag) {
overrideFlags = append(overrideFlags, flag)
}
}
if err := validateRevertMethodOptions(method, overrideFlags); err != nil {
return err
}
m := setup.NewManager("", nil, nil)
fmt.Printf("Reverting migration on %s using method=%s...\n", cfg.Host, method)
var (
logs string
err error
)
switch method {
case "ssh":
logs, err = m.RevertMigration(cfg.Host)
case string(setup.MigrationMethodTelnet):
options := map[string]string{
"marge_url": c.String("marge-url"),
"stats_url": c.String("stats-url"),
"sw_update_url": c.String("sw-update-url"),
"bmx_url": c.String("bmx-url"),
}
logs, err = m.RevertTelnetURLs(cfg.Host, options)
}
fmt.Printf("Reverting migration on %s...\n", cfg.Host)
logs, err := m.RevertMigration(cfg.Host)
if logs != "" {
fmt.Print(logs)
}
@@ -1590,12 +1541,8 @@ func setupRevertCmd() *cli.Command {
return err
}
if method == string(setup.MigrationMethodTelnet) {
PrintSuccess("Canonical Bose URL configuration restored. Reboot the speaker to verify the persisted layer; filesystem, DNS, CA, SSH, and account state were not changed.")
} else {
PrintSuccess("Migration reverted. SSH access and account pairing are untouched by this — " +
"see `setup remote-services --remove` and `account unpair` if you want those cleared too.")
}
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
},
-32
View File
@@ -207,38 +207,6 @@ func TestRecommendMigrationMethod_EmptyWhenNoTransport(t *testing.T) {
}
}
func TestValidateRevertMethodOptions(t *testing.T) {
tests := []struct {
name string
method string
overrides []string
wantError string
}{
{name: "ssh defaults", method: "ssh"},
{name: "telnet defaults", method: "telnet"},
{name: "telnet overrides", method: "telnet", overrides: []string{"marge-url"}},
{name: "ssh rejects overrides", method: "ssh", overrides: []string{"marge-url"}, wantError: "requires --method telnet"},
{name: "unknown method", method: "serial", wantError: "unsupported revert method"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateRevertMethodOptions(tt.method, tt.overrides)
if tt.wantError == "" {
if err != nil {
t.Fatalf("validateRevertMethodOptions: %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tt.wantError) {
t.Fatalf("error = %v, want text %q", err, tt.wantError)
}
})
}
}
func TestBuildPlanSteps_NoOpWhenAlreadyMigratedAndPaired(t *testing.T) {
summary := &setup.MigrationSummary{IsMigrated: true, IsPaired: true, TelnetMigrated: true}
inspect := &setup.InspectReport{Info: &setup.DeviceInfoXML{DeviceID: "AABBCCDDEEFF"}}
@@ -450,14 +450,11 @@ that `setup.PairAccount` already implements.
| ST Portable / FW 27.0.6 | jmosen | #236 | After migration: `POST /marge/streaming/support/power_on` → 502; `<margeAccountUUID/>` empty. Time-bounded HTTP path fails; envswitch fallback succeeds. |
| BST20 Portable (factory reset) | ubittner | scheilch/opencloudtouch#167 | `<margeAccountUUID/>` empty; `/setMargeAccount` not in `/supportedURLs`. HTTP path skipped entirely; only the telnet fallback works. |
### 8.3 Likely to reject the telnet sequence
### 8.3 Likely to fail (but the failure is clean)
Our preflight + abort-on-first-rejection design (`TestMigrateViaTelnet_CommandNotFoundAborts`)
stops at the first rejected command and reports whether earlier runtime writes
or the persistence command may already have applied. A rejection of command #1
leaves the URL state untouched; after any later failure, read back all four URL
fields before retrying or rebooting. The user is also pointed to the XML or DNS
method.
means none of these scenarios leave a device half-configured. The user is
told what failed and pointed to the XML or DNS method.
| Device | Source | Likely cause |
|--------------------------------------------|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
@@ -485,10 +482,8 @@ negative claim: the author writes "I've made some educated guesses and come
up with the following valid commands" and never says they tested
`envswitch`. We do not down-weight `envswitch` availability on the strength
of S5 alone — but if a real-device run ever shows `envswitch` rejected on
an ST 10, the migration aborts on the first unconfirmed response and reports
whether runtime writes were confirmed or persistence is uncertain. Because the
commands are sequential, the user must read back all four fields before retrying
or rebooting.
an ST 10, our preflight catches it, the migration aborts on the first
non-OK response, and the user gets a clear error rather than partial state.
### 8.6 Failure-mode matrix
@@ -497,12 +492,10 @@ What `migrateViaTelnet` does in each failure mode (verified by
| Failure | Outcome | Test |
|------------------------------------------------|---------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------|
| Port 17000 closed / TCP unreachable | `Dial` errors before any command is sent; UI shows the error; nothing persisted | `TestMigrateViaTelnet_DialFailureReturnsError` |
| `sys configuration` rejected | Sequence aborts; earlier or attempted runtime writes may have applied; read back all four fields | `TestMigrateViaTelnet_GenericRuntimeRejectionReportsPartialState` |
| `envswitch boseurls set` rejected | Sequence aborts after four confirmed runtime writes; persistence outcome is uncertain; inspect before rebooting | `TestMigrateViaTelnet_EnvswitchRejectionReportsUncertainPersistence` |
| Verification mismatch (URLs not echoed back) | Loud error after accepted `envswitch`; runtime differs and persistence may already have changed; UI never claims success | `TestMigrateViaTelnet_VerifyMismatchFails` |
| Invalid or command-unsafe URL input | Rejected before a telnet client is created or any device connection is attempted | `TestMigrateViaTelnet_RejectsUnsafeURLsBeforeCreatingClient` |
| Concurrent URL mutations for one speaker | Process-local per-speaker lock keeps command sequences contiguous; different processes remain out of scope | `TestTelnetURLMutationsSameSpeakerAreSerialized` |
| Port 17000 closed / TCP unreachable | `Dial` errors before any command is sent; UI shows the error; nothing persisted | `TestMigrateViaTelnet_DialFailureReturnsError` |
| `sys configuration` rejected (cmd #1) | Sequence aborts; verification not sent; rest of commands not attempted | `TestMigrateViaTelnet_CommandNotFoundAborts` (envswitch variant — generalises) |
| `envswitch boseurls set` rejected | Sequence aborts; runtime-only `sys configuration` state reverts on reboot — no permanent damage | `TestMigrateViaTelnet_CommandNotFoundAborts` |
| Verification mismatch (URLs not echoed back) | Loud "verification failed" error; live state may persist until reboot but UI never claims success | `TestMigrateViaTelnet_VerifyMismatchFails` |
| `/setMargeAccount` 502 / hang | 5s connect + 12s total budget enforced; falls through to telnet `envswitch accountid set` | `TestPairAccount_FallsBackWhenHTTPReturnsServerError` |
| `/setMargeAccount` missing in `/supportedURLs` | HTTP path skipped; goes straight to telnet `envswitch accountid set` | `TestPairAccount_FallsBackWhenSetMargeAccountMissing` |
| Both pairing paths unavailable | Structured error: "use the official Bose app before EOS, or open SSH and use the XML method" | `TestPairAccount_NoTelnetAndHTTPMissingReturnsClearError`, `TestPairAccount_TelnetCommandNotFoundReportsBothPaths` |
@@ -511,7 +504,7 @@ What `migrateViaTelnet` does in each failure mode (verified by
- **Green light** — ST 10, ST 20, ST 300, Wave III, Wave IV on FW 27.0.6 (multi-reporter agreement).
- **Yellow** — ST Portable and BST20 Portable: migration works, pairing needs our fallback (already implemented).
- **Red, aborts with explicit state diagnostics** — SA-5 on FW 9.x, possibly newer ST Portable builds.
- **Red, but fails cleanly** — SA-5 on FW 9.x, possibly newer ST Portable builds.
- **Unverified but expected to work** — ST 30, ST 520, Wave Music System I/II.
The most useful next verification step is touching a real ST 30 and ST 520
+9 -29
View File
@@ -861,9 +861,9 @@ Create and manage a persistent LEFT/RIGHT pair of two SoundTouch 10 speakers.
This is distinct from a temporary multi-room zone. Both speakers must be
online, stereo-capable, standalone, and outside any zone before a lifecycle
operation. Pair creation also requires both speakers to use the same Marge
backend, though they need not share a Marge account. Run lifecycle commands
from the site containing both speakers; site-relative Marge names such as
`unifi` do not identify a remote site when resolved by the CLI host.
account and backend. Run lifecycle commands from the site containing both
speakers; site-relative Marge names such as `unifi` do not identify a remote
site when resolved by the CLI host.
```bash
# Inspect a standalone speaker or either member of a pair
@@ -1399,37 +1399,17 @@ soundtouch-cli --host <device> setup migrate --method telnet \
#### `setup revert`
Undoes a migration. The default `--method ssh` is the CLI equivalent of the
web UI's **Revert to Defaults** button: it restores
`SoundTouchSdkPrivateCfg.xml`, `/etc/hosts`, and `/etc/resolv.conf` from their
`.original` backups, removes the AfterTouch DNS-hook artifacts, and strips
just the AfterTouch-labeled certificate out of the trust bundle.
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
```
For a telnet-only migration, `--method telnet` restores the four canonical
Bose service URLs without requiring SSH or an XML backup:
```bash
soundtouch-cli --host <device> setup revert --method telnet
```
This only changes `margeServerUrl`, `statsServerUrl`, `swUpdateUrl`, and
`bmxRegistryUrl`. It does not restore filesystem, DNS, CA, SSH, or account
state. Reboot the speaker afterwards and verify all four persisted values.
The `--marge-url`, `--stats-url`, `--sw-update-url`, and `--bmx-url` flags can
override the canonical defaults for firmware- or region-specific values.
These flags require `--method telnet`; using them with the default SSH method
is an error. Each value must be an absolute HTTP or HTTPS service URL without
userinfo, query parameters, fragments, whitespace, control characters, or
shell metacharacters.
Telnet writes are sequential rather than transactional. If the command reports
an error, read back and reconcile all four fields before retrying or rebooting;
the error distinguishes a partial runtime update from an uncertain persistence
outcome after `envswitch`.
**Out of scope for this command** (matches the web UI button): SSH /
`remote_services` persistence (use `setup remote-services --remove`) and
account pairing (use `account unpair`) are untouched — revert them
+1 -15
View File
@@ -174,20 +174,6 @@ Once the speaker appears, click **Sync Data**. This connects to the speaker and
Sync pulls the speaker's local state into AfterTouch's datastore, creating an off-device backup of its configuration. If you ran this before May 6, 2026, your account data from Bose's servers was also captured at that time.
Migration is refused until the service has a valid snapshot for that exact
account and device and verifies that its rendered account data preserves every
live preset slot. If the migration page asks for Data Sync, sync the device and
retry instead of bypassing the check.
If the account already contains other devices, migration proceeds but the log
says so. Some speaker firmware has been reported to wipe its presets after a
reboot-triggered resync of a shared account even when `/full` contains the
correct data (see issue #614, where the root cause is still open). One account
holding every speaker in the household is the normal arrangement, so this is a
warning rather than a refusal; if you do hit the preset wipe, moving that
speaker to a dedicated account and running Data Sync for it is the known
workaround.
---
## Step 5: Migrate
@@ -345,7 +331,7 @@ The wizard is still the recommended path for a one-off migration of an existing
If you need to undo a migration:
- **From the web UI**: Use the **Revert to Defaults** action on the device — this restores the `.original` backup files created on the speaker during the XML migration.
- **Telnet-only migrations**: the wizard writes both the runtime configuration layer (`sys configuration …`) and the persistent layer (`envswitch boseurls set …`) so the migration survives reboot. Use **Restore Bose URLs via Telnet** in the web UI or run `soundtouch-cli --host <device> setup revert --method telnet`. This restores only the four canonical Bose URL fields; use the CLI URL override flags if your original firmware- or region-specific values differ. The web action is offered whenever the live telnet configuration contains a non-canonical URL, including a URL for an older AfterTouch backend.
- **Telnet-only migrations**: the wizard writes both the runtime configuration layer (`sys configuration …`) and the persistent layer (`envswitch boseurls set …`) so the migration survives reboot. If you want to revert quickly, the cleanest path is to re-run the wizard with the original Bose URLs in the URL editor.
- **Via SSH**: The original XML config is backed up on the speaker with a `.original` suffix. Restore it manually if the UI is unreachable.
- **Factory reset**: As a last resort, perform a factory reset (see [Device Initial Setup](DEVICE-INITIAL-SETUP.md) for button sequences). This wipes all configuration and returns the speaker to out-of-box state.
+6 -9
View File
@@ -1,21 +1,21 @@
---
title: "Migration & Safety Guide"
---
Starting a migration on real hardware requires a "Safety First" approach. This guide outlines the safety features implemented in the `soundtouch-service` and provides a checklist for a successful migration. The available safeguards depend on the migration method: SSH-backed methods can preserve files, while telnet-only URL migration is sequential and creates no filesystem backup.
Starting a migration on real hardware requires a "Safety First" approach. This guide outlines the safety features implemented in the `soundtouch-service` and provides a checklist for a successful migration.
#### 🛠 Technical Safety Enhancements
The following features are built into the `soundtouch-service` to ensure stability and easy rollbacks:
1. **Off-Device Backups**: Before an SSH-backed migration starts, the service fetches the original `SoundTouchSdkPrivateCfg.xml` and `/etc/hosts` from your speaker and saves them locally in your `data/default/devices/<SERIAL>/` directory. This ensures you have a recovery path even if the speaker's filesystem becomes inaccessible. Telnet-only migration does not create these files.
2. **Pre-flight Write Verification**: SSH-backed migration checks for write access (`rw`) before modifying files. Telnet migration instead checks each command response and reads back all four runtime URL fields; its writes remain sequential rather than atomic.
1. **Off-Device Backups**: Before any migration starts, the service automatically fetches the original `SoundTouchSdkPrivateCfg.xml` and `/etc/hosts` from your speaker and saves them locally in your `data/default/devices/<SERIAL>/` directory. This ensures you have a recovery path even if the speaker's filesystem becomes inaccessible.
2. **Pre-flight Write Verification**: The migration process includes a mandatory check for SSH write access (`rw`) before attempting any modifications. This prevents "half-baked" migrations where a script might fail halfway through due to a read-only filesystem.
3. **Automatic Safety on Sync**: Running a "Sync" in the Web UI or CLI automatically triggers an off-device backup, making it the perfect first step for any new device discovery.
#### 📋 Professional Migration Checklist
Before you proceed with the actual migration, follow these steps:
1. **Enable SSH Access (SSH-backed methods only)**: SSH is not enabled by default. Skip this step for a telnet-only URL migration.
1. **Enable SSH Access (Prerequisite)**: This toolkit requires SSH access to your speakers, which is not enabled by default.
- Create a file named `remote_services` on a FAT-formatted USB drive. The drive may need its bootable flag set — see [SoundCork issue #172](https://github.com/deborahgu/soundcork/issues/172) for details.
- Insert the USB stick into the SoundTouch speaker's **SERVICE** port.
- Reboot the speaker (unplug and replug).
@@ -25,15 +25,14 @@ Before you proceed with the actual migration, follow these steps:
3. **Initial Discovery & Sync**:
- Run `soundtouch-cli discover devices` to ensure connectivity.
- Use the Web UI or CLI to "Sync" the device. This will automatically backup your presets and system configuration files to your local server.
4. **Validate SSH Access (SSH-backed methods only)**: Confirm the device responds to SSH without a password.
4. **Validate SSH Access**: Confirm the device responds to SSH without a password.
- In the Web UI **Migration** tab, select your speaker and verify that the "SSH Connection" status shows ✅ Success.
- This toolkit automatically handles the necessary SSH parameters (ciphers and key exchanges) required by older Bose firmware.
5. **Migration Methods**:
- **XML redirect (default)**: Uploads a config file to the speaker via the Web API. Less invasive — only changes the application-level service URLs. Best for testing or single-device migration.
- **Telnet URL redirect**: Writes the four service URLs through the port-17000 diagnostic shell without SSH. The commands are sequential, so a failed run can leave partial runtime state and must be inspected before retry or reboot.
- **DNS/DHCP redirect**: Configures the speaker to use a custom DNS server that resolves Bose hostnames to the local service. Best for all-device coverage; requires the AfterTouch DNS server running on port 53. The service includes a pre-flight check before applying this method.
The web UI walks you through the available methods. When the target uses HTTPS, its CA certificate must be trusted on the speaker; the web UI handles this as part of the migration flow.
The web UI walks you through both methods. Both require the CA certificate to be trusted on the speaker for HTTPS to work — the web UI handles this as part of the migration flow.
6. **Monitor Logs**: Run the `soundtouch-service` with `DEBUG` or `INFO` logging to see the step-by-step progress of the migration.
#### 🔄 Rollback Strategy
@@ -41,8 +40,6 @@ Before you proceed with the actual migration, follow these steps:
If something goes wrong or you want to return to the original Bose cloud services:
* **Standard Revert**: Use the "Revert Migration" button in the Web UI or the corresponding CLI command. This restores the `.original` files created on the device.
* **Telnet URL Restore**: A telnet-only migration creates no filesystem backup. Use **Restore Bose URLs via Telnet** or `setup revert --method telnet` to restore the four canonical Bose URL fields, then reboot and verify them. This does not restore DNS, CA, SSH, account, or filesystem state; pass explicit URL overrides when the device's original values differ from the canonical defaults. If any command fails, read back and reconcile all four fields before rebooting because earlier runtime writes or the `envswitch` persistence commit may already have taken effect.
* **Concurrent Telnet Operations**: The service keeps URL-changing telnet sequences and telnet reboot operations contiguous per speaker. This process-local serialization prevents two HTTP requests from interleaving commands, but it cannot coordinate a separate CLI process or another service instance and does not make the device's multi-command update transactional.
* **Emergency Recovery**: If the device is unreachable via the UI but SSH still works, you can manually restore the files from your local `data/` directory using `scp` or the backups created on-device (`.original`).
* **Factory Reset**: As a last resort, Bose SoundTouch devices can be factory reset (usually by holding '1' and 'Volume Down' while plugging in). This will wipe all settings and return the device to the stock firmware configuration (the firmware itself remains at the current version, but configurations are reset).
@@ -477,24 +477,6 @@ curl -X POST "http://localhost:8000/setup/migrate/192.0.2.100?method=telnet&targ
curl -X POST "http://localhost:8000/setup/migrate/192.0.2.100?method=resolv&target_url=https://my-server.com:8443"
```
#### `POST /setup/revert/{deviceID}`
Reverts either an SSH-backed migration or the URL fields written by a telnet migration.
- No `method` query parameter, or `method=ssh`, preserves the existing behavior:
restore the on-speaker `.original` files and related SSH-managed state.
- `method=telnet` restores the four canonical Bose service URLs without SSH.
The optional `marge_url`, `stats_url`, `sw_update_url`, and `bmx_url` query
parameters override individual canonical values.
- Supplying those URL parameters with the default SSH method returns `400`
instead of silently ignoring them. Invalid or command-unsafe telnet URLs also
return `400` before a speaker connection is attempted.
The telnet path changes URL configuration only and does not reboot the speaker.
Its commands are sequential, so an error can mean partial runtime state or an
uncertain persistence outcome. Read back and reconcile all four fields before
retrying or rebooting. A successful response confirms the runtime readback;
verify persistence after the subsequent reboot.
#### `POST /setup/telnet-probe/{deviceIP}`
SSH-less reachability check. Temporarily flips the speaker's `swUpdateUrl` via the port-17000 diagnostic shell, triggers `:8090/swUpdateCheck` on the device, and observes whether the resulting outbound lands on this service's `/probe/{token}` handler within 6 s. Always attempts to restore the original `swUpdateUrl` even on failure.
@@ -24,10 +24,6 @@ need a station **ContentItem carrying a `Location`** (see
`stations.ResolveContentItem`, which sets `type="stationurl"`). There is
nothing for the speaker to resume from the source name alone.
All three are confirmed on hardware: `RADIO_BROWSER` and
`LOCAL_INTERNET_RADIO` by the stub described below, `TUNEIN` by its resume
path playing the station as intended.
`STORED_MUSIC` is a third case: one entry per media server, its
`sourceAccount` being a server UDN. Selecting it identifies no track or
container.
@@ -78,9 +74,8 @@ URL's `bmx.BuildOrionLocation`, but it also carries CLI URL playback, and any
future audio-injecting feature would have to remember to stay clear of it.
Opening Play URL does not depend on classifying what is in Recents.
`ALEXA` is advertised `READY` too and is deliberately left alone: it cannot be
tested on the hardware available, and guessing at its behaviour risks breaking
a source that works today. The backstop below covers it instead.
`ALEXA` is advertised `READY` too and is left alone, because whether a bare
select resumes anything for it has not been verified.
### The backstop
@@ -106,10 +101,7 @@ seconds later, surfacing as a transition to an error source
- **Bounded readbacks** at 2s, 5s and 10s are the fallback for a speaker whose
events are not arriving. They stop as soon as a confirmation arrives *and*
the readback reports a live event stream, so a confirmed selection normally
costs one request rather than three. That signal is `webSocketConnected`,
which reports the service's own socket to the speaker; it is opened lazily
on first fetch or control of a device, so the very first click after
loading one can still take all three.
costs one request rather than three.
- Readbacks use `GET /devices/{id}/now-playing`, which refreshes only
`/now_playing`. The full device fetch runs a complete status poll: six
sequential speaker calls plus `/getGroup` on a stereo-capable model, to
+2 -2
View File
@@ -24,10 +24,10 @@ require (
require (
filippo.io/edwards25519 v1.2.0 // indirect
filippo.io/hpke v0.4.0 // indirect
github.com/chromedp/cdproto v0.0.0-20260804232424-e85f50dbfd32 // indirect
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 // indirect
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
+4 -4
View File
@@ -6,8 +6,8 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
github.com/chromedp/cdproto v0.0.0-20260804232424-e85f50dbfd32 h1:6JI+JS7Zef+bMzZQ+OgzTHf79v3GqdvP6rD0FaP9CMk=
github.com/chromedp/cdproto v0.0.0-20260804232424-e85f50dbfd32/go.mod h1:RwFsSODCtFExll+GhHM6R92SARHR3Z3oipaxLHj46C0=
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f h1:0Z1zcSLEmnj2c2CmJYBqewtS6pxhB39bNWUSEUAWjgk=
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f/go.mod h1:RwFsSODCtFExll+GhHM6R92SARHR3Z3oipaxLHj46C0=
github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk=
github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
@@ -19,8 +19,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 h1:UADEEmDKgfXbtnGJZ97beY5XLo9ZechG1nlU4KnRrkE=
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/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=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
+4 -4
View File
@@ -218,9 +218,9 @@ func NewClient(config *Config) *Client {
return &Client{
baseURL: fmt.Sprintf("http://%s:%d", config.Host, port),
httpClient: &http.Client{
httpClient: installSpeakerTrace(&http.Client{
Timeout: config.Timeout,
},
}),
timeout: config.Timeout,
userAgent: config.UserAgent,
}
@@ -248,9 +248,9 @@ func NewClient(config *Config) *Client {
return &Client{
baseURL: u.String(),
httpClient: &http.Client{
httpClient: installSpeakerTrace(&http.Client{
Timeout: config.Timeout,
},
}),
timeout: config.Timeout,
userAgent: config.UserAgent,
}
+69
View File
@@ -0,0 +1,69 @@
package client
// TEMPORARY INSTRUMENTATION -- do not merge.
//
// Added to measure the source-selection readback cost described in PR #670
// review finding 2 (three readbacks per source click, each running a full
// UpdateDeviceStatus against the speaker). Delete this file, and the
// traceTransport wiring in NewClient, once the measurement is done.
//
// Off unless AFTERTOUCH_TRACE_SPEAKER=1, so a stray build stays silent.
import (
"fmt"
"log"
"net/http"
"os"
"strings"
"sync/atomic"
"time"
)
var (
traceSpeakerEnabled = os.Getenv("AFTERTOUCH_TRACE_SPEAKER") == "1"
traceSpeakerSeq atomic.Int64
traceStart = time.Now()
)
// traceTransport logs every outgoing speaker request with a sequence number,
// the elapsed time since process start, and the round-trip duration.
type traceTransport struct{ base http.RoundTripper }
func (t *traceTransport) RoundTrip(req *http.Request) (*http.Response, error) {
base := t.base
if base == nil {
base = http.DefaultTransport
}
seq := traceSpeakerSeq.Add(1)
started := time.Now()
resp, err := base.RoundTrip(req)
elapsed := time.Since(started)
status := "ERR"
if resp != nil {
status = fmt.Sprintf("%d", resp.StatusCode)
}
detail := ""
if err != nil {
detail = " err=" + strings.ReplaceAll(err.Error(), "\n", " ")
}
log.Printf("[SPEAKER-TRACE] #%04d t=%8.3fs %-4s %-28s host=%-22s status=%-3s took=%6.1fms%s",
seq, time.Since(traceStart).Seconds(), req.Method, req.URL.Path,
req.URL.Host, status, float64(elapsed.Microseconds())/1000, detail)
return resp, err
}
// installSpeakerTrace wraps an http.Client's transport when tracing is on.
func installSpeakerTrace(c *http.Client) *http.Client {
if !traceSpeakerEnabled || c == nil {
return c
}
c.Transport = &traceTransport{base: c.Transport}
return c
}
-96
View File
@@ -596,22 +596,6 @@ func (ds *DataStore) GetDeviceInfo(account, device string) (*models.ServiceDevic
return ds.getDeviceInfoNoLock(account, device)
}
// GetExactDeviceInfo retrieves DeviceInfo.xml from the literal account/device
// directory, without applying legacy device-ID mappings.
func (ds *DataStore) GetExactDeviceInfo(account, device string) (*models.ServiceDeviceInfo, error) {
ds.fileMutex.RLock()
defer ds.fileMutex.RUnlock()
path := ds.safeJoin("accounts", account, constants.DevicesDir, device, constants.DeviceInfoFile)
data, err := ds.rootReadFile(path)
if err != nil {
return nil, err
}
return decodeDeviceInfo(data, account)
}
func (ds *DataStore) getDeviceInfoNoLock(account, device string) (*models.ServiceDeviceInfo, error) {
path := ds.AccountDeviceDir(account, device)
deviceInfoPath := filepath.Join(path, constants.DeviceInfoFile)
@@ -621,10 +605,6 @@ func (ds *DataStore) getDeviceInfoNoLock(account, device string) (*models.Servic
return nil, err
}
return decodeDeviceInfo(data, account)
}
func decodeDeviceInfo(data []byte, account string) (*models.ServiceDeviceInfo, error) {
var info struct {
XMLName xml.Name `xml:"info"`
DeviceID string `xml:"deviceID,attr"`
@@ -1002,70 +982,6 @@ func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo
return deviceInfo, nil
}
// PresetSnapshotState describes whether Presets.xml is a usable persisted
// snapshot. GetPresets intentionally treats the non-valid states as empty for
// serving compatibility; migration readiness needs to distinguish them.
type PresetSnapshotState string
// PresetSnapshotValid and related constants describe persisted preset snapshot states.
const (
PresetSnapshotValid PresetSnapshotState = "valid"
PresetSnapshotMissing PresetSnapshotState = "missing"
PresetSnapshotEmpty PresetSnapshotState = "empty"
PresetSnapshotMalformed PresetSnapshotState = "malformed"
)
// PresetSnapshot is a read-only view of the exact account/device Presets.xml.
type PresetSnapshot struct {
State PresetSnapshotState
Presets []models.ServicePreset
NeedsRewrite bool
}
// ReadPresetSnapshot reads the literal account/device Presets.xml without
// rewriting legacy XML or collapsing missing/corrupt files into a valid empty
// snapshot.
func (ds *DataStore) ReadPresetSnapshot(account, device string) (PresetSnapshot, error) {
ds.fileMutex.RLock()
defer ds.fileMutex.RUnlock()
path := ds.safeJoin("accounts", account, constants.DevicesDir, device, constants.PresetsFile)
data, err := ds.rootReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return PresetSnapshot{State: PresetSnapshotMissing}, nil
}
return PresetSnapshot{}, err
}
if len(bytes.TrimSpace(data)) == 0 {
return PresetSnapshot{State: PresetSnapshotEmpty}, nil
}
normalized := bytes.ReplaceAll(data, []byte("<ContentItem"), []byte("<contentItem"))
normalized = bytes.ReplaceAll(normalized, []byte("</ContentItem>"), []byte("</contentItem>"))
var root struct {
XMLName xml.Name `xml:"presets"`
}
if unmarshalErr := xml.Unmarshal(normalized, &root); unmarshalErr != nil {
return PresetSnapshot{State: PresetSnapshotMalformed}, nil
}
presets, _, err := ds.readPresetsNoLock(account, device)
if err != nil {
return PresetSnapshot{}, err
}
return PresetSnapshot{
State: PresetSnapshotValid,
Presets: presets,
NeedsRewrite: !bytes.Equal(normalized, data),
}, nil
}
// GetPresets retrieves all presets for the specified account and device.
func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset, error) {
ds.fileMutex.RLock()
@@ -1087,18 +1003,6 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
return presets, nil
}
// GetPresetsReadOnly retrieves presets without canonicalizing legacy XML on
// disk. It is intended for preflight paths which must not mutate datastore
// state while rendering the response they are about to validate.
func (ds *DataStore) GetPresetsReadOnly(account, device string) ([]models.ServicePreset, error) {
ds.fileMutex.RLock()
defer ds.fileMutex.RUnlock()
presets, _, err := ds.readPresetsNoLock(account, device)
return presets, err
}
// 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
@@ -1,77 +0,0 @@
package datastore
import (
"path/filepath"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
)
func TestReadPresetSnapshotStates(t *testing.T) {
account := "1234567"
device := "DEVICE01"
tests := []struct {
name string
write []byte
want PresetSnapshotState
}{
{name: "missing", want: PresetSnapshotMissing},
{name: "empty", write: []byte(" \n"), want: PresetSnapshotEmpty},
{name: "malformed", write: []byte("<presets>"), want: PresetSnapshotMalformed},
{name: "valid empty", write: []byte("<presets></presets>"), want: PresetSnapshotValid},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ds := NewDataStore(t.TempDir())
if tt.write != nil {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile)
if err := ds.MkdirAllUnderBase(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAllUnderBase: %v", err)
}
if err := ds.WriteFileUnderBase(path, tt.write, 0o644); err != nil {
t.Fatalf("WriteFileUnderBase: %v", err)
}
}
snapshot, err := ds.ReadPresetSnapshot(account, device)
if err != nil {
t.Fatalf("ReadPresetSnapshot: %v", err)
}
if snapshot.State != tt.want {
t.Fatalf("state = %q, want %q", snapshot.State, tt.want)
}
if tt.want == PresetSnapshotValid && len(snapshot.Presets) != 0 {
t.Fatalf("valid empty snapshot returned %d presets", len(snapshot.Presets))
}
})
}
}
func TestReadPresetSnapshotReturnsPersistedPresets(t *testing.T) {
ds := NewDataStore(t.TempDir())
account := "1234567"
device := "DEVICE01"
want := models.ServicePreset{
ServiceContentItem: models.ServiceContentItem{Name: "Radio", Location: "http://radio.example/stream"},
ID: "1",
ButtonNumber: "1",
}
if err := ds.SavePresets(account, device, []models.ServicePreset{want}); err != nil {
t.Fatalf("SavePresets: %v", err)
}
snapshot, err := ds.ReadPresetSnapshot(account, device)
if err != nil {
t.Fatalf("ReadPresetSnapshot: %v", err)
}
if snapshot.State != PresetSnapshotValid {
t.Fatalf("state = %q, want %q", snapshot.State, PresetSnapshotValid)
}
if len(snapshot.Presets) != 1 || snapshot.Presets[0].Name != want.Name || snapshot.Presets[0].Location != want.Location {
t.Fatalf("presets = %+v, want Radio at %s", snapshot.Presets, want.Location)
}
}
@@ -1,70 +0,0 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
"github.com/go-chi/chi/v5"
)
func TestHandleMigrateDeviceMapsMigrationDataNotReadyToConflict(t *testing.T) {
const (
accountID = "1234567"
deviceID = "DEVICE01"
)
speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/info" {
http.NotFound(w, r)
return
}
_, _ = fmt.Fprintf(w, `<info deviceID="%s"><name>Test Speaker</name><margeAccountUUID>%s</margeAccountUUID></info>`, deviceID, accountID)
}))
defer speaker.Close()
ds := datastore.NewDataStore(t.TempDir())
deviceIP := strings.TrimPrefix(speaker.URL, "http://")
if err := ds.SaveDeviceInfo(accountID, deviceID, &models.ServiceDeviceInfo{
DeviceID: deviceID,
AccountID: accountID,
IPAddress: deviceIP,
Name: "Test Speaker",
}); err != nil {
t.Fatalf("SaveDeviceInfo: %v", err)
}
manager := setup.NewManager("http://aftertouch.example:8000", ds, nil)
server := NewServer(ds, manager, manager.ServerURL, false, false, false)
router := chi.NewRouter()
router.Post("/migrate/{deviceId}", server.HandleMigrateDevice)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/migrate/"+deviceID+"?method=telnet", nil)
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusConflict {
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusConflict, recorder.Body.String())
}
var response struct {
OK bool `json:"ok"`
Message string `json:"message"`
}
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response.OK {
t.Fatal("response ok = true, want false")
}
if !strings.Contains(response.Message, "Data Sync") {
t.Fatalf("message = %q, want actionable Data Sync guidance", response.Message)
}
}
+6 -59
View File
@@ -3,8 +3,6 @@ package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"os"
@@ -14,6 +12,8 @@ import (
"strings"
"time"
"fmt"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
@@ -719,19 +719,8 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) {
output, err := s.sm.MigrateSpeaker(deviceIP, targetURL, proxyURL, options, method)
if err != nil {
status := http.StatusInternalServerError
var notReady *setup.MigrationDataNotReadyError
switch {
case errors.As(err, &notReady):
status = http.StatusConflict
case errors.Is(err, setup.ErrInvalidTelnetURL):
status = http.StatusBadRequest
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
w.WriteHeader(http.StatusInternalServerError)
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error(), "output": output}); encodeErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
@@ -749,9 +738,7 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) {
}
}
// HandleRevertMigration reverts the migration for a device. The existing
// no-query path restores SSH/filesystem backups; method=telnet restores only
// the four canonical Bose service URLs and accepts the migration URL overrides.
// HandleRevertMigration reverts the migration for a device.
func (s *Server) HandleRevertMigration(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
@@ -779,50 +766,10 @@ func (s *Server) HandleRevertMigration(w http.ResponseWriter, r *http.Request) {
return
}
method := r.URL.Query().Get("method")
if (method == "" || method == "ssh") && len(presentTelnetURLOverrides(r.URL.Query())) > 0 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{
"ok": false,
"message": "Telnet URL overrides require method=telnet",
}); encodeErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
return
}
var output string
switch method {
case "", "ssh":
output, err = s.sm.RevertMigration(deviceIP)
case string(setup.MigrationMethodTelnet):
output, err = s.sm.RevertTelnetURLs(deviceIP, parseMigrationOptions(r.URL.Query()))
default:
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{
"ok": false,
"message": fmt.Sprintf("Unsupported revert method %q; expected ssh or telnet", method),
}); encodeErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
return
}
output, err := s.sm.RevertMigration(deviceIP)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, setup.ErrInvalidTelnetURL) {
status = http.StatusBadRequest
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
w.WriteHeader(http.StatusInternalServerError)
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error(), "output": output}); encodeErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
-137
View File
@@ -6,7 +6,6 @@ import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
@@ -705,10 +704,6 @@ func TestMigrationAndCA(t *testing.T) {
sm.NewSSH = func(host string) setup.SSHClient {
return &mockSSH{host: host}
}
telnetMock := &mockSetupTelnet{}
sm.NewTelnet = func(string) setup.TelnetClient {
return telnetMock
}
// Mock HTTPGet to avoid real network timeouts
sm.HTTPGet = func(url string) (*http.Response, error) {
@@ -719,12 +714,6 @@ func TestMigrationAndCA(t *testing.T) {
Body: io.NopCloser(strings.NewReader(xml)),
}, nil
}
if strings.HasSuffix(url, "/presets") {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`<presets/>`)),
}, nil
}
return &http.Response{
StatusCode: http.StatusNotFound,
Body: io.NopCloser(strings.NewReader("Not Found")),
@@ -743,7 +732,6 @@ func TestMigrationAndCA(t *testing.T) {
IPAddress: "192.0.2.10",
AccountID: "default",
})
_ = ds.SavePresets("default", "192.0.2.10", nil)
// 1. Test GET /setup/ca.crt
res, err := http.Get(ts.URL + "/setup/ca.crt")
@@ -781,22 +769,6 @@ func TestMigrationAndCA(t *testing.T) {
t.Errorf("Migrate: Expected output field in response")
}
// Unsafe telnet migration input is a client error and never reaches the speaker.
unsafeMigrateCommandCount := len(telnetMock.commands)
unsafeTarget := url.QueryEscape("http://192.0.2.100:8000\r\nsys reboot")
res, err = http.Post(ts.URL+"/setup/migrate/192.0.2.10?method=telnet&target_url="+unsafeTarget, "application/json", nil)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusBadRequest {
t.Errorf("Unsafe telnet migration: expected status 400, got %v", res.Status)
}
if len(telnetMock.commands) != unsafeMigrateCommandCount {
t.Errorf("Unsafe telnet migration sent commands: before=%d after=%d", unsafeMigrateCommandCount, len(telnetMock.commands))
}
// 3. Test POST /setup/trust-ca/{deviceIP}
res, err = http.Post(ts.URL+"/setup/trust-ca/192.0.2.10", "application/json", nil)
if err != nil {
@@ -859,81 +831,6 @@ func TestMigrationAndCA(t *testing.T) {
if _, ok := result["output"]; !ok {
t.Errorf("RemoveRemote: Expected output field in response")
}
// 6. Telnet-only revert uses the dedicated URL restore path.
res, err = http.Post(ts.URL+"/setup/revert/192.0.2.10?method=telnet", "application/json", nil)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Telnet revert: expected status OK, got %v", res.Status)
}
if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
t.Fatalf("Telnet revert: failed to decode response: %v", err)
}
if result["ok"] != true {
t.Errorf("Telnet revert: expected ok=true, got %v", result["ok"])
}
commands := strings.Join(telnetMock.commands, "\n")
for _, want := range []string{
"sys configuration margeServerUrl https://streaming.bose.com",
"sys configuration statsServerUrl https://events.api.bosecm.com",
"sys configuration swUpdateUrl https://worldwide.bose.com/updates/soundtouch",
"sys configuration bmxRegistryUrl https://content.api.bose.io/bmx/registry/v1/services",
"envswitch boseurls set https://streaming.bose.com https://worldwide.bose.com/updates/soundtouch",
"getpdo CurrentSystemConfiguration",
} {
if !strings.Contains(commands, want) {
t.Errorf("Telnet revert commands missing %q:\n%s", want, commands)
}
}
// 7. SSH revert rejects telnet-only URL overrides instead of ignoring them.
commandCount := len(telnetMock.commands)
res, err = http.Post(ts.URL+"/setup/revert/192.0.2.10?method=ssh&marge_url=https%3A%2F%2Foverride.example%2Fmarge", "application/json", nil)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusBadRequest {
t.Errorf("SSH revert with telnet overrides: expected status 400, got %v", res.Status)
}
if len(telnetMock.commands) != commandCount {
t.Errorf("SSH revert with telnet overrides sent telnet commands: before=%d after=%d", commandCount, len(telnetMock.commands))
}
// 8. Unsafe telnet URL input fails before any command is sent.
unsafeURL := url.QueryEscape("https://override.example/marge\r\nsys reboot")
res, err = http.Post(ts.URL+"/setup/revert/192.0.2.10?method=telnet&marge_url="+unsafeURL, "application/json", nil)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusBadRequest {
t.Errorf("Unsafe telnet URL: expected status 400, got %v", res.Status)
}
if len(telnetMock.commands) != commandCount {
t.Errorf("Unsafe telnet URL sent commands: before=%d after=%d", commandCount, len(telnetMock.commands))
}
// 9. Unknown revert methods fail before touching either transport.
res, err = http.Post(ts.URL+"/setup/revert/192.0.2.10?method=invalid", "application/json", nil)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusBadRequest {
t.Errorf("Invalid revert method: expected status 400, got %v", res.Status)
}
if len(telnetMock.commands) != commandCount {
t.Errorf("Invalid revert method sent telnet commands: before=%d after=%d", commandCount, len(telnetMock.commands))
}
}
func TestRemoveDevice(t *testing.T) {
@@ -1035,40 +932,6 @@ type mockSSH struct {
uploaded map[string][]byte
}
type mockSetupTelnet struct {
commands []string
}
func (m *mockSetupTelnet) Dial() error { return nil }
func (m *mockSetupTelnet) Probe() (string, error) { return "->", nil }
func (m *mockSetupTelnet) SendCommand(command string) (string, error) {
m.commands = append(m.commands, command)
if command == "getpdo CurrentSystemConfiguration" {
return `margeServerUrl {
text: "https://streaming.bose.com"
}
statsServerUrl {
text: "https://events.api.bosecm.com"
}
swUpdateUrl {
text: "https://worldwide.bose.com/updates/soundtouch"
}
bmxRegistryUrl {
text: "https://content.api.bose.io/bmx/registry/v1/services"
}`, nil
}
if fields := strings.Fields(command); len(fields) == 5 &&
fields[0] == "envswitch" && fields[1] == "boseurls" && fields[2] == "set" {
return "Setting Bose Server URLs to " + fields[3] + " and " + fields[4] + "\n->OK\n->", nil
}
return "OK", nil
}
func (m *mockSetupTelnet) Close() error { return nil }
func (m *mockSSH) Run(command string) (string, error) {
if strings.Contains(command, "cat /etc/hosts") {
m.runCount++
-19
View File
@@ -2,13 +2,6 @@ package handlers
import "net/url"
var telnetMigrationURLKeys = []string{
"marge_url",
"stats_url",
"sw_update_url",
"bmx_url",
}
// migrationOptionKeys is the allow-list of query parameters carried into
// the migration manager's options map. Two families coexist:
//
@@ -50,15 +43,3 @@ func parseMigrationOptions(query url.Values) map[string]string {
return out
}
func presentTelnetURLOverrides(query url.Values) []string {
var present []string
for _, key := range telnetMigrationURLKeys {
if _, ok := query[key]; ok {
present = append(present, key)
}
}
return present
}
-7
View File
@@ -826,13 +826,6 @@
>
Revert to Defaults
</button>
<button
id="revert-telnet-btn"
class="btn-danger"
style="padding: 10px 20px; display: none"
>
Restore Bose URLs via Telnet
</button>
<button
id="reboot-speaker-btn"
style="padding: 10px 20px"
-52
View File
@@ -2364,11 +2364,6 @@ async function showSummary(deviceId) {
revertBtn.disabled = !summary.ssh_success;
revertBtn.style.display = summary.original_config ? "inline-block" : "none";
const revertTelnetBtn = document.getElementById("revert-telnet-btn");
revertTelnetBtn.onclick = () => revertTelnetURLs(deviceId);
revertTelnetBtn.disabled = !summary.telnet_reachable;
revertTelnetBtn.style.display = summary.telnet_revert_available ? "inline-block" : "none";
const rebootBtn = document.getElementById("reboot-speaker-btn");
rebootBtn.onclick = () => reboot(deviceId, ip);
rebootBtn.disabled = !anyTransport;
@@ -2448,53 +2443,6 @@ async function revert(deviceId, ip) {
}
}
async function revertTelnetURLs(deviceId) {
if (!deviceId) {
alert("Please select a device.");
return;
}
const display = getDeviceDisplayName(deviceId);
if (!confirm(
"Restore the canonical Bose service URLs on " + display + " via Telnet? " +
"This changes only the four URL fields; it does not restore filesystem, DNS, CA, SSH, or account state. " +
"The writes are sequential, so inspect all four fields if an error occurs.",
)) {
return;
}
const revertTelnetBtn = document.getElementById("revert-telnet-btn");
revertTelnetBtn.disabled = true;
const statusDiv = document.getElementById("status");
statusDiv.style.display = "block";
statusDiv.style.backgroundColor = "#ffffcc";
statusDiv.textContent = "Restoring canonical Bose URLs on " + display + " via Telnet...";
try {
const response = await fetch(
"/api/setup/revert/" + encodeURIComponent(deviceId) + "?method=telnet",
{method: "POST"},
);
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = "#ccffcc";
statusDiv.textContent = "Restored canonical Bose URLs on " + display +
". Reboot the speaker, then verify all four persisted URL fields.";
} else {
revertTelnetBtn.disabled = false;
statusDiv.style.backgroundColor = "#ffcccc";
statusDiv.textContent = "Telnet URL restore failed for " + display + ": " +
(result.message || "Unknown error");
}
} catch (error) {
revertTelnetBtn.disabled = false;
statusDiv.style.backgroundColor = "#ffcccc";
statusDiv.textContent = "Error restoring Bose URLs on " + display + ": " + error;
}
}
async function reboot(deviceId, ip) {
if (!deviceId) {
alert("Please select a device.");
+3 -21
View File
@@ -695,10 +695,6 @@ func APIVersionsToXML() ([]byte, error) {
// CreateAccountDevice creates an AccountDevice model for the given account and device.
func CreateAccountDevice(ds *datastore.DataStore, account, deviceID string) (models.AccountDevice, error) {
return createAccountDevice(ds, account, deviceID, ds.GetPresets)
}
func createAccountDevice(ds *datastore.DataStore, account, deviceID string, readPresets func(string, string) ([]models.ServicePreset, error)) (models.AccountDevice, error) {
info, err := ds.GetDeviceInfo(account, deviceID)
if err != nil {
return models.AccountDevice{}, err
@@ -750,7 +746,7 @@ func createAccountDevice(ds *datastore.DataStore, account, deviceID string, read
return models.AccountDevice{}, err
}
presets, _ := readPresets(account, deviceID)
presets, _ := ds.GetPresets(account, deviceID)
recents, _ := ds.GetRecents(account, deviceID)
device.Presets = mapPresetsToFullResponse(presets, sources)
@@ -1265,10 +1261,6 @@ func fillAccountInfo(ds *datastore.DataStore, account string, resp *models.Accou
}
func getAccountDevices(ds *datastore.DataStore, account string, entries []os.DirEntry) ([]models.AccountDevice, string) {
return getAccountDevicesWithPresetReader(ds, account, entries, ds.GetPresets)
}
func getAccountDevicesWithPresetReader(ds *datastore.DataStore, account string, entries []os.DirEntry, readPresets func(string, string) ([]models.ServicePreset, error)) ([]models.AccountDevice, string) {
var (
devices []models.AccountDevice
lastDeviceID string
@@ -1282,7 +1274,7 @@ func getAccountDevicesWithPresetReader(ds *datastore.DataStore, account string,
deviceID := entry.Name()
lastDeviceID = deviceID
dev, err := createAccountDevice(ds, account, deviceID, readPresets)
dev, err := CreateAccountDevice(ds, account, deviceID)
if err != nil {
continue
}
@@ -1477,16 +1469,6 @@ func AccountDevicesToXML(ds *datastore.DataStore, account string) ([]byte, error
// AccountFullToXML generates a complete account XML with devices, presets, and recents.
func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) {
return accountFullToXML(ds, account, ds.GetPresets)
}
// AccountFullToXMLReadOnly generates the same account XML without rewriting
// legacy preset snapshots while traversing account devices.
func AccountFullToXMLReadOnly(ds *datastore.DataStore, account string) ([]byte, error) {
return accountFullToXML(ds, account, ds.GetPresetsReadOnly)
}
func accountFullToXML(ds *datastore.DataStore, account string, readPresets func(string, string) ([]models.ServicePreset, error)) ([]byte, error) {
devicesDir := ds.AccountDevicesDir(account)
resp := models.AccountFullResponse{
@@ -1504,7 +1486,7 @@ func accountFullToXML(ds *datastore.DataStore, account string, readPresets func(
return nil, err
}
devices, lastDeviceID := getAccountDevicesWithPresetReader(ds, account, entries, readPresets)
devices, lastDeviceID := getAccountDevices(ds, account, entries)
resp.Devices = devices
resp.Sources = getAccountSources(ds, account, lastDeviceID)
-13
View File
@@ -104,9 +104,6 @@ func (m *Manager) runTelnetInjection(deviceIP string, forbidQuote, cmds []string
}
}
unlock := m.lockTelnetURLMutation(deviceIP)
defer unlock()
var logs strings.Builder
t := m.NewTelnet(deviceIP)
@@ -157,9 +154,6 @@ func (m *Manager) setBoseURLsViaTelnet(deviceIP, marge, swUpdate string) (string
return "", errors.New("boseurls values must not contain a double quote")
}
unlock := m.lockTelnetURLMutation(deviceIP)
defer unlock()
var logs strings.Builder
t := m.NewTelnet(deviceIP)
@@ -197,17 +191,10 @@ func (m *Manager) setBoseURLsViaTelnet(deviceIP, marge, swUpdate string) (string
// injection), this mirrors telnetURLs.Commands()'s full sequence so the
// envswitch commit captures fresh values for all four fields, not just two.
func (m *Manager) setAllBoseURLsViaTelnet(deviceIP string, urls telnetURLs) (string, error) {
if err := urls.validate(); err != nil {
return "", err
}
if m.NewTelnet == nil {
return "", errors.New("telnet not configured: Manager.NewTelnet is nil")
}
unlock := m.lockTelnetURLMutation(deviceIP)
defer unlock()
var logs strings.Builder
t := m.NewTelnet(deviceIP)
+1 -1
View File
@@ -242,7 +242,7 @@ func (m *Manager) runURLRewrite(plan InitPlan, emit func(StepKind, string, StepS
emit(StepURLRewrite, "telnet URL rewrite", StatusRunning, nil)
urls := defaultTelnetURLs(plan.ServiceURL)
if _, rwErr := m.migrateViaTelnet(plan.DeviceIP, urls); rwErr != nil {
if _, rwErr := m.migrateViaTelnet(plan.DeviceIP, plan.ServiceURL, urls); rwErr != nil {
emit(StepURLRewrite, "telnet URL rewrite", StatusFailed, rwErr)
return fmt.Errorf("URL rewrite: %w", rwErr)
}
-288
View File
@@ -1,288 +0,0 @@
package setup
import (
"encoding/xml"
"fmt"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/marge"
)
// MigrationDataNotReadyError means migration was refused because the service
// cannot prove that its rendered account data preserves the speaker's presets.
type MigrationDataNotReadyError struct {
Reason string
Action string
}
func (e *MigrationDataNotReadyError) Error() string {
action := e.Action
if action == "" {
action = "Run Data Sync for this device and retry migration."
}
return fmt.Sprintf("Migration data is not ready: %s. %s", e.Reason, action)
}
func migrationDataNotReadyf(format string, args ...any) error {
return &MigrationDataNotReadyError{Reason: fmt.Sprintf(format, args...)}
}
// checkMigrationDataReady proves that redirecting the speaker to this service
// will not replace its live presets with missing, stale, or filtered account
// data. Every operation in this check is read-only.
//
// It returns warnings for conditions worth telling the user about but not
// worth refusing over, and an error only for the ones it can actually prove.
func (m *Manager) checkMigrationDataReady(deviceIP string) ([]string, error) {
if m.DataStore == nil {
// CLI callers do not own the service datastore and cannot enforce this
// check. ExecuteInitPlan is a separate onboarding flow which establishes
// account state only after its intentional URL rewrite.
return nil, nil
}
info, err := m.GetLiveDeviceInfo(deviceIP)
if err != nil {
return nil, migrationDataNotReadyf("cannot read live /info: %v", err)
}
deviceID := strings.TrimSpace(info.DeviceID)
accountID := strings.TrimSpace(info.MargeAccountUUID)
if deviceID == "" {
return nil, migrationDataNotReadyf("live /info has no deviceID")
}
if accountID == "" {
// An unpaired speaker, typically factory-reset, has no account data to
// preserve, so there is nothing for this check to compare and nothing
// to lose. Refusing here would also break the documented onboarding
// order: the admin UI migrates first and pairs afterwards (see the
// "Pairing runs after the URL flip" comment in the setup page), and
// MIGRATION-GUIDE.md tells the user to Generate an account ID on a
// factory-reset device. Data Sync cannot unblock it either, since it
// files an account-less device under "default", which never matches an
// empty live account.
return nil, nil
}
if !datastore.IsSafeIdentifier(accountID) || !datastore.IsSafeIdentifier(deviceID) {
return nil, migrationDataNotReadyf("live /info contains an invalid account or device identifier")
}
persistedInfo, err := m.DataStore.GetExactDeviceInfo(accountID, deviceID)
if err != nil {
// Report the cause: a malformed file or an I/O error is not a missing
// sync, and Data Sync is then the wrong remedy to suggest.
return nil, migrationDataNotReadyf("cannot read DeviceInfo.xml under account %q and device %q: %v", accountID, deviceID, err)
}
if persistedInfo.DeviceID != deviceID {
return nil, migrationDataNotReadyf("persisted DeviceInfo.xml identifies device %q instead of %q", persistedInfo.DeviceID, deviceID)
}
snapshot, err := m.DataStore.ReadPresetSnapshot(accountID, deviceID)
if err != nil {
return nil, migrationDataNotReadyf("cannot read the persisted preset snapshot: %v", err)
}
if snapshot.State != datastore.PresetSnapshotValid {
return nil, migrationDataNotReadyf("persisted Presets.xml is %s", snapshot.State)
}
if snapshot.NeedsRewrite {
return nil, migrationDataNotReadyf("persisted Presets.xml uses a legacy format that must be refreshed")
}
livePresets, err := m.fetchLivePresets(deviceIP)
if err != nil {
return nil, migrationDataNotReadyf("cannot read live /presets: %v", err)
}
fullXML, err := marge.AccountFullToXMLReadOnly(m.DataStore, accountID)
if err != nil {
return nil, migrationDataNotReadyf("cannot render account /full: %v", err)
}
fullPresets, accountDeviceCount, err := migrationFullPresets(fullXML, deviceID)
if err != nil {
return nil, migrationDataNotReadyf("rendered account /full is incomplete: %v", err)
}
var warnings []string
// Not a refusal. One account holding every speaker in the household is the
// normal Bose topology, so blocking it would block most setups, and issue
// #614 concluded the shared-account preset wipe is empirical rather than a
// proven mechanism with an open root cause. A stale duplicate entry left
// by a failed /info read or a DHCP lease change would also trip it on a
// genuinely single-speaker setup. Say what was found and let the user
// decide.
if accountDeviceCount != 1 {
warnings = append(warnings, fmt.Sprintf(
"migrating into an account that contains %d devices; some firmware has been reported to wipe presets after a reboot-triggered resync of a shared account (issue #614, root cause open)",
accountDeviceCount))
}
persisted := migrationPresetIdentities(snapshot.Presets)
live := migrationPresetIdentities(livePresets)
if mismatch := compareMigrationPresets("persisted snapshot", persisted, "rendered /full", fullPresets); mismatch != nil {
return nil, mismatch.err()
}
if mismatch := compareMigrationPresets("live /presets", live, "rendered /full", fullPresets); mismatch != nil {
return nil, mismatch.err()
}
return warnings, nil
}
type migrationPresetIdentity struct {
Slot string
Name string
Location string
}
func migrationPresetIdentities(presets []models.ServicePreset) []migrationPresetIdentity {
result := make([]migrationPresetIdentity, 0, len(presets))
for i := range presets {
slot := presets[i].ButtonNumber
if slot == "" {
slot = presets[i].ID
}
// Clearing a slot through the Marge API leaves a zero-value entry in
// the list (RemovePreset assigns models.ServicePreset{}), persisted as
// <preset id="">. The rendered /full drops it, so counting it here
// would report a mismatch for a datastore that is perfectly in sync.
// An empty slot carries no identity to compare either way.
if slot == "" {
continue
}
result = append(result, migrationPresetIdentity{
Slot: slot,
Name: presets[i].Name,
Location: presets[i].Location,
})
}
return result
}
func migrationFullPresets(fullXML []byte, deviceID string) ([]migrationPresetIdentity, int, error) {
var full struct {
Devices []struct {
DeviceID string `xml:"deviceid,attr"`
Presets []struct {
Slot string `xml:"buttonNumber,attr"`
Name string `xml:"name"`
Location string `xml:"location"`
} `xml:"presets>preset"`
} `xml:"devices>device"`
}
if err := xml.Unmarshal(fullXML, &full); err != nil {
return nil, 0, fmt.Errorf("malformed XML: %w", err)
}
for i := range full.Devices {
if full.Devices[i].DeviceID != deviceID {
continue
}
presets := make([]migrationPresetIdentity, 0, len(full.Devices[i].Presets))
for _, preset := range full.Devices[i].Presets {
presets = append(presets, migrationPresetIdentity{
Slot: preset.Slot,
Name: preset.Name,
Location: preset.Location,
})
}
return presets, len(full.Devices), nil
}
return nil, len(full.Devices), fmt.Errorf("target device %q is missing", deviceID)
}
// migrationPresetMismatch describes why two preset views disagree.
type migrationPresetMismatch struct {
Reason string
// DroppedByFull marks the case where the speaker's own view holds a slot
// the rendered /full does not. mapPresetsToFullResponse omits a preset
// whose source is absent from the account's configured sources and cannot
// be synthesised, so this is not a stale snapshot and re-syncing cannot
// fix it: the source itself has to come back.
DroppedByFull bool
}
func (m *migrationPresetMismatch) err() error {
if !m.DroppedByFull {
return migrationDataNotReadyf("%s", m.Reason)
}
return &MigrationDataNotReadyError{
Reason: m.Reason,
Action: "The rendered account omits a preset whose music service source is missing, so Data Sync cannot restore it. " +
"Re-link or repopulate that source for this account, then retry migration.",
}
}
func compareMigrationPresets(leftName string, left []migrationPresetIdentity, rightName string, right []migrationPresetIdentity) *migrationPresetMismatch {
if len(left) != len(right) {
return &migrationPresetMismatch{
Reason: fmt.Sprintf("%s has %d preset(s), but %s has %d", leftName, len(left), rightName, len(right)),
DroppedByFull: len(left) > len(right),
}
}
leftBySlot, problem := indexMigrationPresets(leftName, left)
if problem != "" {
return &migrationPresetMismatch{Reason: problem}
}
rightBySlot, problem := indexMigrationPresets(rightName, right)
if problem != "" {
return &migrationPresetMismatch{Reason: problem}
}
for slot, leftPreset := range leftBySlot {
rightPreset, ok := rightBySlot[slot]
if !ok {
return &migrationPresetMismatch{
Reason: fmt.Sprintf("preset slot %s from %s is missing from %s", slot, leftName, rightName),
DroppedByFull: true,
}
}
if leftPreset.Name != rightPreset.Name || leftPreset.Location != rightPreset.Location {
return &migrationPresetMismatch{
Reason: fmt.Sprintf("preset slot %s differs between %s and %s", slot, leftName, rightName),
}
}
}
return nil
}
func indexMigrationPresets(name string, presets []migrationPresetIdentity) (map[string]migrationPresetIdentity, string) {
bySlot := make(map[string]migrationPresetIdentity, len(presets))
for _, preset := range presets {
if preset.Slot == "" {
return nil, fmt.Sprintf("%s contains a preset without a slot", name)
}
if _, exists := bySlot[preset.Slot]; exists {
return nil, fmt.Sprintf("%s contains duplicate preset slot %s", name, preset.Slot)
}
bySlot[preset.Slot] = preset
}
return bySlot, ""
}
@@ -1,378 +0,0 @@
package setup
import (
"bytes"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
const (
readinessAccount = "1234567"
readinessDevice = "DEVICE01"
)
func newMigrationReadinessFixture(t *testing.T, livePresetsXML string) (*Manager, *datastore.DataStore, string) {
t.Helper()
speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/info":
_, _ = fmt.Fprintf(w, `<info deviceID="%s"><name>Test Speaker</name><margeAccountUUID>%s</margeAccountUUID></info>`, readinessDevice, readinessAccount)
case "/presets":
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(livePresetsXML))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(speaker.Close)
ds := datastore.NewDataStore(t.TempDir())
deviceIP := strings.TrimPrefix(speaker.URL, "http://")
if err := ds.SaveDeviceInfo(readinessAccount, readinessDevice, &models.ServiceDeviceInfo{
DeviceID: readinessDevice,
AccountID: readinessAccount,
IPAddress: deviceIP,
Name: "Test Speaker",
}); err != nil {
t.Fatalf("SaveDeviceInfo: %v", err)
}
return NewManager("http://aftertouch.example:8000", ds, nil), ds, deviceIP
}
func readinessPreset(slot, name, location string) models.ServicePreset {
return models.ServicePreset{
ServiceContentItem: models.ServiceContentItem{
Name: name,
Source: "LOCAL_INTERNET_RADIO",
Type: "stationurl",
ContentItemType: "stationurl",
Location: location,
SourceID: "10003",
IsPresetable: "true",
},
ID: slot,
ButtonNumber: slot,
}
}
func livePresetsXML(presets ...models.ServicePreset) string {
var xml strings.Builder
xml.WriteString(`<presets deviceID="` + readinessDevice + `">`)
for _, preset := range presets {
fmt.Fprintf(&xml, `<preset id="%s"><ContentItem source="%s" type="%s" location="%s" isPresetable="true"><itemName>%s</itemName></ContentItem></preset>`,
preset.ButtonNumber, preset.Source, preset.Type, preset.Location, preset.Name)
}
xml.WriteString(`</presets>`)
return xml.String()
}
func discardWarnings(_ []string, err error) error { return err }
func requireMigrationNotReady(t *testing.T, err error) *MigrationDataNotReadyError {
t.Helper()
if err == nil {
t.Fatal("expected migration data readiness error")
}
var notReady *MigrationDataNotReadyError
if !errors.As(err, &notReady) {
t.Fatalf("error type = %T, want *MigrationDataNotReadyError: %v", err, err)
}
if notReady.Action == "" && !strings.Contains(err.Error(), "Data Sync") {
t.Fatalf("default error is not actionable: %v", err)
}
if notReady.Action != "" && !strings.Contains(err.Error(), notReady.Action) {
t.Fatalf("custom action is missing from error: %v", err)
}
return notReady
}
func TestMigrateSpeakerMissingSnapshotBlocksBeforeTelnet(t *testing.T) {
m, _, deviceIP := newMigrationReadinessFixture(t, `<presets/>`)
telnetCalls := 0
m.NewTelnet = func(string) TelnetClient {
telnetCalls++
return &fakeTelnet{}
}
_, err := m.MigrateSpeaker(deviceIP, "", "", nil, MigrationMethodTelnet)
notReady := requireMigrationNotReady(t, err)
if !strings.Contains(notReady.Reason, "missing") {
t.Fatalf("reason = %q, want missing snapshot", notReady.Reason)
}
if telnetCalls != 0 {
t.Fatalf("telnet factory called %d times, want zero", telnetCalls)
}
}
func TestMigrationDataReadinessBlocksPartialAndFilteredPresets(t *testing.T) {
t.Run("partial live list", func(t *testing.T) {
one := readinessPreset("1", "One", "http://radio.example/one")
two := readinessPreset("2", "Two", "http://radio.example/two")
m, ds, deviceIP := newMigrationReadinessFixture(t, livePresetsXML(one))
if err := ds.SavePresets(readinessAccount, readinessDevice, []models.ServicePreset{one, two}); err != nil {
t.Fatalf("SavePresets: %v", err)
}
requireMigrationNotReady(t, discardWarnings(m.checkMigrationDataReady(deviceIP)))
})
t.Run("preset filtered from full", func(t *testing.T) {
filtered := readinessPreset("1", "Spotify", "spotify:track:missing")
filtered.Source = "SPOTIFY"
filtered.SourceID = "missing-spotify-source"
m, ds, deviceIP := newMigrationReadinessFixture(t, livePresetsXML(filtered))
if err := ds.SavePresets(readinessAccount, readinessDevice, []models.ServicePreset{filtered}); err != nil {
t.Fatalf("SavePresets: %v", err)
}
notReady := requireMigrationNotReady(t, discardWarnings(m.checkMigrationDataReady(deviceIP)))
if !strings.Contains(notReady.Reason, "rendered /full") {
t.Fatalf("reason = %q, want rendered /full mismatch", notReady.Reason)
}
})
t.Run("shared account warns but does not refuse", func(t *testing.T) {
m, ds, deviceIP := newMigrationReadinessFixture(t, `<presets/>`)
if err := ds.SavePresets(readinessAccount, readinessDevice, nil); err != nil {
t.Fatalf("SavePresets: %v", err)
}
if err := ds.SaveDeviceInfo(readinessAccount, "SIBLING01", &models.ServiceDeviceInfo{
DeviceID: "SIBLING01",
AccountID: readinessAccount,
Name: "Sibling Speaker",
}); err != nil {
t.Fatalf("SaveDeviceInfo sibling: %v", err)
}
warnings, err := m.checkMigrationDataReady(deviceIP)
if err != nil {
t.Fatalf("shared account refused migration: %v", err)
}
if len(warnings) != 1 || !strings.Contains(warnings[0], "2 devices") {
t.Fatalf("warnings = %v, want one naming the device count", warnings)
}
})
t.Run("valid empty", func(t *testing.T) {
m, ds, deviceIP := newMigrationReadinessFixture(t, `<presets/>`)
if err := ds.SavePresets(readinessAccount, readinessDevice, nil); err != nil {
t.Fatalf("SavePresets: %v", err)
}
if _, err := m.checkMigrationDataReady(deviceIP); err != nil {
t.Fatalf("checkMigrationDataReady: %v", err)
}
})
t.Run("fully synced", func(t *testing.T) {
preset := readinessPreset("1", "Radio", "http://radio.example/stream")
m, ds, deviceIP := newMigrationReadinessFixture(t, livePresetsXML(preset))
if err := ds.SavePresets(readinessAccount, readinessDevice, []models.ServicePreset{preset}); err != nil {
t.Fatalf("SavePresets: %v", err)
}
if _, err := m.checkMigrationDataReady(deviceIP); err != nil {
t.Fatalf("checkMigrationDataReady: %v", err)
}
})
}
func TestMigrationDataReadinessDoesNotRewritePresetSnapshots(t *testing.T) {
t.Run("ready single device", func(t *testing.T) {
preset := readinessPreset("1", "Target Radio", "http://radio.example/target")
m, ds, deviceIP := newMigrationReadinessFixture(t, livePresetsXML(preset))
if err := ds.SavePresets(readinessAccount, readinessDevice, []models.ServicePreset{preset}); err != nil {
t.Fatalf("SavePresets target: %v", err)
}
targetPath := filepath.Join(ds.AccountDeviceDir(readinessAccount, readinessDevice), constants.PresetsFile)
before, err := os.ReadFile(targetPath)
if err != nil {
t.Fatalf("read target snapshot: %v", err)
}
if _, err = m.checkMigrationDataReady(deviceIP); err != nil {
t.Fatalf("checkMigrationDataReady: %v", err)
}
assertPresetSnapshotUnchanged(t, targetPath, before)
})
t.Run("shared account leaves a sibling snapshot alone", func(t *testing.T) {
m, ds, deviceIP := newMigrationReadinessFixture(t, `<presets/>`)
if err := ds.SavePresets(readinessAccount, readinessDevice, nil); err != nil {
t.Fatalf("SavePresets target: %v", err)
}
const siblingDevice = "SIBLING01"
if err := ds.SaveDeviceInfo(readinessAccount, siblingDevice, &models.ServiceDeviceInfo{
DeviceID: siblingDevice,
AccountID: readinessAccount,
Name: "Sibling Speaker",
}); err != nil {
t.Fatalf("SaveDeviceInfo sibling: %v", err)
}
legacy := []byte(`<presets><preset id="1"><ContentItem source="LOCAL_INTERNET_RADIO" type="stationurl" location="http://radio.example/sibling"><itemName>Sibling Radio</itemName></ContentItem></preset></presets>`)
siblingPath := filepath.Join(ds.AccountDeviceDir(readinessAccount, siblingDevice), constants.PresetsFile)
if err := ds.WriteFileUnderBase(siblingPath, legacy, 0o644); err != nil {
t.Fatalf("write legacy sibling snapshot: %v", err)
}
// Reading the sibling's account to count devices must not canonicalise
// its legacy Presets.xml, which is the point of this case; the shared
// account itself is only a warning.
warnings, err := m.checkMigrationDataReady(deviceIP)
if err != nil {
t.Fatalf("shared account refused migration: %v", err)
}
if len(warnings) != 1 || !strings.Contains(warnings[0], "2 devices") {
t.Fatalf("warnings = %v, want one naming the device count", warnings)
}
assertPresetSnapshotUnchanged(t, siblingPath, legacy)
})
}
func assertPresetSnapshotUnchanged(t *testing.T, path string, want []byte) {
t.Helper()
after, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read preset snapshot: %v", err)
}
if !bytes.Equal(after, want) {
t.Fatalf("readiness preflight rewrote Presets.xml\n got: %s\nwant: %s", after, want)
}
}
// TestMigrationDataReadinessAllowsUnpairedSpeaker: a factory-reset speaker has
// no account data to preserve, and the admin UI migrates before it pairs, so
// refusing here would make onboarding impossible. Data Sync could not unblock
// it either: an account-less device is filed under "default", which never
// matches an empty live account.
func TestMigrationDataReadinessAllowsUnpairedSpeaker(t *testing.T) {
speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/info" {
_, _ = fmt.Fprintf(w, `<info deviceID="%s"><name>Fresh Speaker</name><margeAccountUUID></margeAccountUUID></info>`, readinessDevice)
return
}
http.NotFound(w, r)
}))
defer speaker.Close()
m := NewManager("http://aftertouch.example:8000", datastore.NewDataStore(t.TempDir()), nil)
if _, err := m.checkMigrationDataReady(strings.TrimPrefix(speaker.URL, "http://")); err != nil {
t.Fatalf("unpaired speaker was refused migration: %v", err)
}
}
// TestMigrationDataReadinessIgnoresClearedPresetSlots: clearing a slot through
// the Marge API leaves a zero-value entry that /full drops. Counting it would
// refuse migration for a datastore that is otherwise perfectly in sync.
func TestMigrationDataReadinessIgnoresClearedPresetSlots(t *testing.T) {
kept := readinessPreset("1", "Kept Station", "http://radio.example/kept")
m, ds, deviceIP := newMigrationReadinessFixture(t, livePresetsXML(kept))
if err := ds.SavePresets(readinessAccount, readinessDevice, []models.ServicePreset{
kept,
{}, // slot 2, cleared through RemovePreset
}); err != nil {
t.Fatalf("SavePresets: %v", err)
}
if _, err := m.checkMigrationDataReady(deviceIP); err != nil {
t.Fatalf("a cleared preset slot blocked migration: %v", err)
}
}
// TestMigrationDataReadinessExplainsPresetsDroppedByFull: a preset whose music
// service source is missing from the account is omitted from the rendered
// /full on purpose. Telling the user to run Data Sync sends them in a loop,
// since syncing cannot bring the source back.
func TestMigrationDataReadinessExplainsPresetsDroppedByFull(t *testing.T) {
kept := readinessPreset("1", "Kept Station", "http://radio.example/kept")
dropped := readinessPreset("2", "Spotify Mix", "spotify:playlist:x")
dropped.Source = "SPOTIFY"
dropped.SourceID = "99999"
dropped.SourceAccount = "someone"
m, ds, deviceIP := newMigrationReadinessFixture(t, livePresetsXML(kept, dropped))
if err := ds.SavePresets(readinessAccount, readinessDevice, []models.ServicePreset{kept, dropped}); err != nil {
t.Fatalf("SavePresets: %v", err)
}
_, err := m.checkMigrationDataReady(deviceIP)
notReady := requireMigrationNotReady(t, err)
if strings.Contains(notReady.Action, "Run Data Sync") {
t.Errorf("action = %q, want it not to prescribe a sync that cannot help", notReady.Action)
}
if !strings.Contains(notReady.Action, "source") {
t.Errorf("action = %q, want it to point at the missing source", notReady.Action)
}
}
// TestMigrationSummaryReportsDataReadiness: the pre-flight panel must show a
// refusal before the user commits, rather than showing all-green and failing
// with a 409 at Apply.
func TestMigrationSummaryReportsDataReadiness(t *testing.T) {
t.Run("refusal", func(t *testing.T) {
m, _, deviceIP := newMigrationReadinessFixture(t, `<presets/>`)
// No persisted snapshot, so the check refuses.
summary, err := m.GetMigrationSummary(deviceIP, "http://aftertouch.example:8000", "", nil)
if err != nil {
t.Fatalf("GetMigrationSummary: %v", err)
}
if summary.DataReadyError == "" {
t.Error("summary hid a refusal that Apply would hit")
}
})
t.Run("warning", func(t *testing.T) {
m, ds, deviceIP := newMigrationReadinessFixture(t, `<presets/>`)
if err := ds.SavePresets(readinessAccount, readinessDevice, nil); err != nil {
t.Fatalf("SavePresets: %v", err)
}
if err := ds.SaveDeviceInfo(readinessAccount, "SIBLING01", &models.ServiceDeviceInfo{
DeviceID: "SIBLING01",
AccountID: readinessAccount,
Name: "Sibling Speaker",
}); err != nil {
t.Fatalf("SaveDeviceInfo sibling: %v", err)
}
summary, err := m.GetMigrationSummary(deviceIP, "http://aftertouch.example:8000", "", nil)
if err != nil {
t.Fatalf("GetMigrationSummary: %v", err)
}
if summary.DataReadyError != "" {
t.Errorf("summary reported a refusal for a shared account: %q", summary.DataReadyError)
}
if len(summary.DataReadyWarnings) != 1 {
t.Errorf("warnings = %v, want one about the device count", summary.DataReadyWarnings)
}
})
}
@@ -29,58 +29,6 @@ func TestIsTelnetMigrated_DifferentHostname(t *testing.T) {
}
}
func TestCheckIsMigratedFromProbe_FormerBackendOffersTelnetRevert(t *testing.T) {
m := &Manager{ServerURL: "http://current.example:8000"}
summary := &MigrationSummary{
TelnetReachable: true,
TelnetVerifiedConfig: flatGetpdoResponse(telnetURLs{
Marge: "http://former.example:8000/marge",
Stats: "http://former.example:8000",
SwUpdate: "http://former.example:8000/updates/soundtouch",
BmxRegistry: "http://former.example:8000/bmx/registry/v1/services",
}),
}
m.checkIsMigratedFromProbe(summary, &speakerProbe{})
if summary.TelnetMigrated {
t.Error("TelnetMigrated = true, want false for a former backend")
}
if !summary.TelnetRevertAvailable {
t.Error("TelnetRevertAvailable = false, want rollback for a former backend")
}
}
func TestCheckIsMigratedFromProbe_CanonicalBoseURLsDoNotOfferTelnetRevert(t *testing.T) {
m := &Manager{ServerURL: "http://current.example:8000"}
summary := &MigrationSummary{
TelnetReachable: true,
TelnetVerifiedConfig: flatGetpdoResponse(canonicalBoseTelnetURLs()),
}
m.checkIsMigratedFromProbe(summary, &speakerProbe{})
if summary.TelnetRevertAvailable {
t.Error("TelnetRevertAvailable = true, want false for canonical Bose URLs")
}
}
func TestCheckIsMigratedFromProbe_PartialNonCanonicalConfigOffersTelnetRevert(t *testing.T) {
m := &Manager{ServerURL: "http://current.example:8000"}
summary := &MigrationSummary{
TelnetReachable: true,
TelnetVerifiedConfig: "margeServerUrl=https://streaming.bose.com\n" +
"statsServerUrl=http://legacy.example:8000\n",
}
m.checkIsMigratedFromProbe(summary, &speakerProbe{})
if !summary.TelnetRevertAvailable {
t.Error("TelnetRevertAvailable = false, want rollback for partial non-canonical config")
}
}
func TestIsTelnetMigrated_EmptyVerifiedConfig(t *testing.T) {
m := &Manager{ServerURL: "http://example:8000"}
@@ -77,10 +77,6 @@ func TestGetMigrationSummary_TelnetSucceedsSSHFails(t *testing.T) {
if !strings.Contains(summary.TelnetVerifiedConfig, target) {
t.Errorf("TelnetVerifiedConfig = %q, want it to contain %q", summary.TelnetVerifiedConfig, target)
}
if !summary.TelnetRevertAvailable {
t.Error("TelnetRevertAvailable = false, want rollback for live non-canonical URLs")
}
}
func TestGetMigrationSummary_TelnetFailsSSHFails(t *testing.T) {
+6 -70
View File
@@ -14,7 +14,6 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/gesellix/bose-soundtouch/pkg/client"
@@ -81,12 +80,6 @@ type MigrationSummary struct {
CurrentResolvConf string `json:"current_resolv_conf,omitempty"`
PlannedResolv string `json:"planned_resolv,omitempty"`
IsMigrated bool `json:"is_migrated"`
// Data readiness, so the pre-flight panel can show a refusal before the
// user commits to Apply instead of only surfacing it as a 409 afterwards.
// DataReadyError is the reason migration will be refused; empty means it
// will proceed. DataReadyWarnings are advisory and do not block.
DataReadyError string `json:"data_ready_error,omitempty"`
DataReadyWarnings []string `json:"data_ready_warnings,omitempty"`
// Per-axis migration signals — IsMigrated is the OR of these. The UI
// displays them individually so users can see partial states (e.g.
// URLs flipped via telnet but the on-disk XML hasn't caught up, or
@@ -114,11 +107,10 @@ type MigrationSummary struct {
// Telnet (port 17000) preflight state — populated when the user is about to
// or has just used MigrationMethodTelnet.
TelnetReachable bool `json:"telnet_reachable"`
TelnetBanner string `json:"telnet_banner,omitempty"`
TelnetVerifiedConfig string `json:"telnet_verified_config,omitempty"`
TelnetProbeError string `json:"telnet_probe_error,omitempty"`
TelnetRevertAvailable bool `json:"telnet_revert_available"`
TelnetReachable bool `json:"telnet_reachable"`
TelnetBanner string `json:"telnet_banner,omitempty"`
TelnetVerifiedConfig string `json:"telnet_verified_config,omitempty"`
TelnetProbeError string `json:"telnet_probe_error,omitempty"`
// KnownAccountIDs are accountIDs already present in the local datastore;
// the UI offers them as choices when pairing a fresh device.
@@ -163,11 +155,6 @@ type Manager struct {
NewSSH func(host string) SSHClient
NewTelnet func(host string) TelnetClient
// URL-changing telnet operations are multi-command sequences. Keep each
// speaker's sequence contiguous while allowing different speakers to run
// independently.
telnetURLMutationLocks sync.Map // device IP -> *sync.Mutex
// NewSession opens the WebSocket setup state-machine session used
// by ExecuteInitPlan. Tests inject an in-memory fake; the production
// default is DialSession.
@@ -184,19 +171,6 @@ type Manager struct {
MgmtPassword string
}
func (m *Manager) lockTelnetURLMutation(deviceIP string) func() {
value, _ := m.telnetURLMutationLocks.LoadOrStore(deviceIP, &sync.Mutex{})
mu, ok := value.(*sync.Mutex)
if !ok {
panic("setup: telnet URL mutation lock has unexpected type")
}
mu.Lock()
return mu.Unlock
}
// NewManager creates a new Manager with the given base server URL.
func NewManager(serverURL string, ds *datastore.DataStore, cm *certmanager.CertificateManager) *Manager {
return &Manager{
@@ -329,13 +303,6 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
SSHSuccess: false,
}
// Same read-only check MigrateSpeaker runs, reported rather than enforced.
if warnings, err := m.checkMigrationDataReady(deviceIP); err != nil {
summary.DataReadyError = err.Error()
} else {
summary.DataReadyWarnings = warnings
}
// Run the telnet preflight in parallel with the SSH-based probes below.
// Both transports are queried independently: SSH gives access to
// /etc/hosts, /etc/resolv.conf and the on-device XML config; telnet's
@@ -559,7 +526,6 @@ func (m *Manager) buildServerHTTPSURL(targetURL string) string {
// up in `getpdo CurrentSystemConfiguration`.
func (m *Manager) checkIsMigrated(summary *MigrationSummary, deviceIP string) {
summary.TelnetMigrated = m.isTelnetMigrated(summary)
summary.TelnetRevertAvailable = telnetRevertAvailable(summary.TelnetVerifiedConfig)
if summary.SSHSuccess {
client := m.NewSSH(deviceIP)
@@ -907,18 +873,6 @@ func (m *Manager) firstCACertBodyLine() (string, bool) {
// MigrateSpeaker configures the speaker at the given IP to use this service.
func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options map[string]string, method MigrationMethod) (string, error) {
readinessWarnings, err := m.checkMigrationDataReady(deviceIP)
if err != nil {
return "", err
}
// Surfaced in the migration log the UI shows, alongside the other
// "Warning:" lines, so an advisory reaches the user without blocking them.
var preflightLogs string
for _, warning := range readinessWarnings {
preflightLogs += fmt.Sprintf("Warning: %s\n", warning)
}
if targetURL == "" {
targetURL = m.ServerURL
}
@@ -932,12 +886,10 @@ func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options m
// rooted via remote_services.
if method == MigrationMethodTelnet {
urls := telnetURLsFromOptions(targetURL, options)
telnetLogs, telnetErr := m.migrateViaTelnet(deviceIP, urls)
return preflightLogs + telnetLogs, telnetErr
return m.migrateViaTelnet(deviceIP, targetURL, urls)
}
logs := preflightLogs
var logs string
// 0. Off-device backup for safety
if backupErr := m.BackupConfigOffDevice(deviceIP); backupErr != nil {
@@ -1178,14 +1130,6 @@ func (m *Manager) resyncBoseURLsAfterXML(deviceIP string, urls telnetURLs) strin
rlogs, rerr := m.setAllBoseURLsViaTelnet(deviceIP, urls)
if rerr != nil {
// A rejected URL is not the device being unreachable, and saying so
// would send the user looking at telnet. The XML write has already
// happened with this value, so the URL itself is what needs attention.
if errors.Is(rerr, ErrInvalidTelnetURL) {
return fmt.Sprintf("Note: skipped the telnet boseurls re-sync because a URL was rejected (%v); "+
"the XML configuration was still written, and a device reboot will reconcile the runtime layer.\n", rerr)
}
return fmt.Sprintf("Note: could not re-sync boseurls over telnet (%v); a device reboot will reconcile the runtime layer.\n", rerr)
}
@@ -2310,10 +2254,6 @@ func (m *Manager) rebootViaTelnet(deviceIP string) (string, error) {
return "", errors.New("telnet reboot not configured: Manager.NewTelnet is nil")
}
// Do not let a reboot cut through a multi-command URL mutation.
unlock := m.lockTelnetURLMutation(deviceIP)
defer unlock()
fmt.Printf("Rebooting speaker at %s via telnet\n", deviceIP)
t := m.NewTelnet(deviceIP)
@@ -2928,10 +2868,6 @@ func (m *Manager) fetchLivePresets(deviceIP string) ([]models.ServicePreset, err
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("GET %s returned %d", presetsURL, resp.StatusCode)
}
var ps models.Presets
if decodeErr := xml.NewDecoder(resp.Body).Decode(&ps); decodeErr != nil {
return nil, decodeErr
+1 -17
View File
@@ -12,7 +12,6 @@ import (
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
@@ -2032,30 +2031,15 @@ func TestMigrateSpeaker_ResolvBlocking(t *testing.T) {
// Mock HTTP server for device info
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/info":
if r.URL.Path == "/info" {
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<info deviceID="12345"><name>Test Speaker</name><type>ST10</type><maccAddress>00:11:22:33:44:55</maccAddress><margeAccountUUID>acc-123</margeAccountUUID></info>`))
case "/presets":
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<presets/>`))
}
}))
defer ts.Close()
// Use the test server address as device IP
tsIP := strings.TrimPrefix(ts.URL, "http://")
if err := ds.SaveDeviceInfo("acc-123", "12345", &models.ServiceDeviceInfo{
DeviceID: "12345",
AccountID: "acc-123",
IPAddress: tsIP,
Name: "Test Speaker",
}); err != nil {
t.Fatalf("SaveDeviceInfo: %v", err)
}
if err := ds.SavePresets("acc-123", "12345", nil); err != nil {
t.Fatalf("SavePresets: %v", err)
}
_, err = m.MigrateSpeaker(tsIP, "", "", nil, MigrationMethodResolvConf)
if err == nil || !strings.Contains(err.Error(), "DNS discovery server is not enabled") {
-1
View File
@@ -159,7 +159,6 @@ func probeDeviceTagFor(summary *MigrationSummary) string {
// or marker comment is always present, so this is acceptable.
func (m *Manager) checkIsMigratedFromProbe(summary *MigrationSummary, probe *speakerProbe) {
summary.TelnetMigrated = m.isTelnetMigrated(summary)
summary.TelnetRevertAvailable = telnetRevertAvailable(summary.TelnetVerifiedConfig)
if probe.SSHOK {
summary.XMLMigrated = m.isXMLMigrated(summary)
+17 -355
View File
@@ -3,15 +3,9 @@ package setup
import (
"errors"
"fmt"
"net/url"
"strings"
"unicode"
)
// ErrInvalidTelnetURL identifies URL input rejected before any device
// connection or command is attempted.
var ErrInvalidTelnetURL = errors.New("invalid telnet URL")
// telnetURLs holds the four URLs the migration writes via telnet. Most
// users keep all four pointing at the same service base; per-field
// overrides exist mainly so soundcork users can append /marge to the
@@ -23,20 +17,6 @@ type telnetURLs struct {
BmxRegistry string
}
type telnetURLField struct {
configName string
value string
}
func (u telnetURLs) fields() []telnetURLField {
return []telnetURLField{
{configName: "margeServerUrl", value: u.Marge},
{configName: "statsServerUrl", value: u.Stats},
{configName: "swUpdateUrl", value: u.SwUpdate},
{configName: "bmxRegistryUrl", value: u.BmxRegistry},
}
}
// defaultTelnetURLs returns the canonical URL set derived from the
// soundtouch-service base targetURL.
func defaultTelnetURLs(targetURL string) telnetURLs {
@@ -48,17 +28,6 @@ func defaultTelnetURLs(targetURL string) telnetURLs {
}
}
// canonicalBoseTelnetURLs returns the public Bose endpoints restored by a
// telnet revert.
func canonicalBoseTelnetURLs() telnetURLs {
return telnetURLs{
Marge: "https://streaming.bose.com",
Stats: "https://events.api.bosecm.com",
SwUpdate: "https://worldwide.bose.com/updates/soundtouch",
BmxRegistry: "https://content.api.bose.io/bmx/registry/v1/services",
}
}
// telnetURLsFromOptions resolves the four URLs from targetURL plus
// per-field overrides supplied via the migration options map. Recognised
// keys are marge_url, stats_url, sw_update_url, bmx_url; missing or empty
@@ -70,10 +39,8 @@ func canonicalBoseTelnetURLs() telnetURLs {
// one base URL plus optional path suffixes — and let the service layer
// hold any non-trivial logic.
func telnetURLsFromOptions(targetURL string, options map[string]string) telnetURLs {
return defaultTelnetURLs(targetURL).withOptions(options)
}
u := defaultTelnetURLs(targetURL)
func (u telnetURLs) withOptions(options map[string]string) telnetURLs {
if v := options["marge_url"]; v != "" {
u.Marge = v
}
@@ -93,156 +60,6 @@ func (u telnetURLs) withOptions(options map[string]string) telnetURLs {
return u
}
func (u telnetURLs) validate() error {
for _, field := range u.fields() {
if err := validateTelnetURL(field.configName, field.value); err != nil {
return err
}
}
return nil
}
func validateTelnetURL(field, value string) error {
invalid := func(reason string) error {
return fmt.Errorf("%w for %s: %s", ErrInvalidTelnetURL, field, reason)
}
if value == "" {
return invalid("value is empty")
}
if strings.IndexFunc(value, func(r rune) bool {
return unicode.IsControl(r) || unicode.IsSpace(r)
}) >= 0 {
return invalid("whitespace and control characters are not allowed")
}
// These characters can change command parsing or the persisted shell
// expression used by Bose firmware. Clean service URLs do not need them.
if strings.ContainsAny(value, "\"'`;\\|&$<>(){}") {
return invalid("shell metacharacters are not allowed")
}
parsed, err := url.Parse(value)
if err != nil {
return invalid("value cannot be parsed")
}
if !strings.EqualFold(parsed.Scheme, "http") && !strings.EqualFold(parsed.Scheme, "https") {
return invalid("scheme must be http or https")
}
if parsed.Hostname() == "" {
return invalid("host is required")
}
if parsed.User != nil {
return invalid("user information is not allowed")
}
if parsed.RawQuery != "" || parsed.ForceQuery {
return invalid("query parameters are not allowed")
}
if parsed.Fragment != "" {
return invalid("fragments are not allowed")
}
return nil
}
// knownOriginalTelnetURLs lists, per field, the values actually observed on
// speakers that were never migrated. Firmware variants differ: the stats and
// BMX registry endpoints have each been seen with two different hosts, so a
// single canonical set is not enough to recognise an untouched device.
//
// Only values actually observed in these four fields belong here. Other Bose
// hostnames appear elsewhere in this repo (updates.bose.com and bmx.bose.com
// in the DNS interception and /etc/hosts lists) and in DNS recordings, but
// those record hosts a speaker RESOLVES at runtime, including ones reached
// through redirects and unrelated APIs. That is a different thing from the
// configured value of margeServerUrl and friends.
//
// The asymmetry matters: a wrong entry here makes a changed device look
// original, so the revert quietly disappears for someone who needs it. Being
// incomplete only offers a revert that turns out to be unnecessary.
//
// Compared against the full URL rather than just the host. Any Bose-looking
// host would also accept a speaker pointed at some other Bose endpoint, which
// is not the same thing as being unmigrated.
func knownOriginalTelnetURLs() map[string][]string {
return map[string][]string{
"margeServerUrl": {"https://streaming.bose.com"},
"statsServerUrl": {
"https://stats.bose.com",
"https://events.api.bosecm.com",
},
"swUpdateUrl": {"https://worldwide.bose.com/updates/soundtouch"},
"bmxRegistryUrl": {
"https://content.api.bose.io/bmx/registry/v1/services",
"https://bmxservice.bose.com/bmx/registry/v1/services",
},
}
}
// normalizeTelnetURLForComparison makes URL equality tolerant of the
// differences firmware introduces when echoing a value back, without
// loosening which endpoints count as original.
func normalizeTelnetURLForComparison(value string) string {
return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(value)), "/")
}
// telnetRevertAvailable reports whether the device carries a URL set worth
// offering to restore.
//
// It answers "has this been changed away from a factory configuration", not
// "does it differ from our canonical set". Those are not the same question:
// the canonical set is one of several original variants, so comparing against
// it alone offers a destructive, persisted rewrite on speakers that were never
// migrated, destroying the record of what their URLs actually were. Telnet
// migration takes no backup, so that record is not recoverable.
func telnetRevertAvailable(response string) bool {
current := parseGetpdoConfig(response)
known := knownOriginalTelnetURLs()
for _, field := range canonicalBoseTelnetURLs().fields() {
value, ok := current[field.configName]
if !ok || value == "" {
continue
}
if !matchesKnownOriginalTelnetURL(known[field.configName], value) {
return true
}
}
return false
}
func matchesKnownOriginalTelnetURL(originals []string, value string) bool {
normalized := normalizeTelnetURLForComparison(value)
for _, original := range originals {
if normalizeTelnetURLForComparison(original) == normalized {
return true
}
}
return false
}
// RevertTelnetURLs restores the canonical Bose URL configuration over the
// device's port-17000 shell. Per-field URL options take precedence over the
// defaults.
func (m *Manager) RevertTelnetURLs(deviceIP string, options map[string]string) (string, error) {
urls := canonicalBoseTelnetURLs().withOptions(options)
logs := "Restoring Bose URL configuration only; no factory reset, account change, or reboot will be performed. " +
"Telnet writes are sequential, not transactional; if the operation fails, read back all four URL fields before retrying or rebooting.\n"
migrationLogs, err := m.migrateViaTelnet(deviceIP, urls)
return logs + migrationLogs, err
}
// Commands returns the canonical sequence of telnet commands. Order
// matters: `sys configuration …` writes the runtime layer; the closing
// `envswitch boseurls set …` writes the parallel persistence layer that
@@ -268,21 +85,19 @@ func (u telnetURLs) Commands() []string {
// to the user, who triggers it via the existing reboot button (which now
// accepts a method=telnet|ssh selector).
//
// Commands are sent sequentially and cannot be rolled back atomically. Errors
// therefore report whether runtime writes were confirmed or the persistence
// command may already have taken effect.
func (m *Manager) migrateViaTelnet(deviceIP string, urls telnetURLs) (string, error) {
if err := urls.validate(); err != nil {
return "", err
}
// The sequence aborts on the first non-OK response so we never half-write the
// configuration; the caller can retry safely after fixing the underlying
// issue (closed port, hardened firmware, etc.).
//
// targetURL is kept as a separate verification anchor: most users have
// every URL share that base, so substring-matching it against the
// device's `getpdo` reply is the simplest "did the writes stick?" check
// that still works for the soundcork "/marge on one field" case.
func (m *Manager) migrateViaTelnet(deviceIP, targetURL string, urls telnetURLs) (string, error) {
if m.NewTelnet == nil {
return "", errors.New("telnet migration not configured: Manager.NewTelnet is nil")
}
unlock := m.lockTelnetURLMutation(deviceIP)
defer unlock()
var logs strings.Builder
t := m.NewTelnet(deviceIP)
@@ -297,53 +112,28 @@ func (m *Manager) migrateViaTelnet(deviceIP string, urls telnetURLs) (string, er
fmt.Fprintf(&logs, "Telnet banner: %q\n", strings.TrimSpace(banner))
}
commands := urls.Commands()
runtimeWrites := 0
for i, cmd := range commands {
persistenceCommand := i == len(commands)-1
for _, cmd := range urls.Commands() {
resp, err := t.SendCommand(cmd)
if err != nil {
return logs.String(), fmt.Errorf("telnet command %q failed: %w; %s", cmd, err,
telnetWriteFailureContext(runtimeWrites, persistenceCommand))
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 err := validateTelnetMutationResponse(cmd, resp, persistenceCommand, urls); err != nil {
// Aborting here is deliberate: no further writes are sent, so a
// rejected sequence cannot spread. But the advice this produces is
// "read back and reconcile all four URL fields", which the service
// can simply do, and must, because an unrecognised reply is not
// proof the write failed. A telnet console is a shared stream and
// firmware echoes vary, so the readback is better evidence than
// the reply shape. It is read-only and changes nothing.
// Read back before snapshotting logs: the return values are
// evaluated left to right, so logs.String() inline would capture
// the builder before the read-back appends to it.
readBack := telnetReadBackSummary(t, &logs)
return logs.String(), fmt.Errorf("%w; %s%s", err,
telnetWriteFailureContext(runtimeWrites, persistenceCommand), readBack)
}
if !persistenceCommand {
runtimeWrites++
if isCommandNotFound(resp) {
return logs.String(), fmt.Errorf("device rejected %q (firmware does not expose this command)", cmd)
}
}
verify, err := t.SendCommand("getpdo CurrentSystemConfiguration")
if err != nil {
return logs.String(), fmt.Errorf("verification command failed after envswitch was accepted: %w; "+
"persistence may already have changed, so read back and reconcile all four URL fields before rebooting", err)
return logs.String(), fmt.Errorf("verification command failed: %w", err)
}
fmt.Fprintf(&logs, "→ getpdo CurrentSystemConfiguration (runtime layer only — confirms the writes were accepted, not that they'll survive a reboot)\n%s\n", strings.TrimRight(verify, "\r\n"))
if err := verifyTelnetURLs(verify, urls); err != nil {
return logs.String(), fmt.Errorf("%w; envswitch was accepted, so persistence may already have changed; "+
"read back and reconcile all four URL fields before rebooting", err)
if !strings.Contains(verify, targetURL) {
return logs.String(), fmt.Errorf("verification failed: getpdo response does not contain %q (device may have rejected the new URLs)", targetURL)
}
logs.WriteString("Telnet writes accepted (runtime layer). Reboot the device so the envswitch-persisted layer takes over.\n")
@@ -351,134 +141,6 @@ func (m *Manager) migrateViaTelnet(deviceIP string, urls telnetURLs) (string, er
return logs.String(), nil
}
func validateTelnetMutationResponse(cmd, response string, persistenceCommand bool, urls telnetURLs) error {
if isCommandNotFound(response) {
return fmt.Errorf("device rejected %q (firmware does not expose this command)", cmd)
}
if persistenceCommand {
expected := "Setting Bose Server URLs to " + urls.Marge + " and " + urls.SwUpdate
if hasTelnetPersistenceConfirmation(response, expected) {
return nil
}
} else if hasTelnetOKResponse(response) {
return nil
}
return fmt.Errorf("device did not confirm telnet command %q; response was %q", cmd, strings.TrimSpace(response))
}
func hasTelnetOKResponse(response string) bool {
return hasExactTelnetResponseLine(response, "OK", true)
}
func meaningfulTelnetResponseLines(response string) []string {
var meaningful []string
for _, raw := range strings.Split(response, "\n") {
line := strings.TrimSpace(raw)
for strings.HasPrefix(line, "->") {
line = strings.TrimSpace(strings.TrimPrefix(line, "->"))
}
if line != "" {
meaningful = append(meaningful, line)
}
}
return meaningful
}
func hasExactTelnetResponseLine(response, expected string, foldCase bool) bool {
meaningful := meaningfulTelnetResponseLines(response)
if len(meaningful) != 1 {
return false
}
if foldCase {
return strings.EqualFold(meaningful[0], expected)
}
return meaningful[0] == expected
}
// hasTelnetPersistenceConfirmation accepts exactly the two response shapes
// observed on SoundTouch firmware: a confirmation ending in a prompt marker,
// or the confirmation followed by a prompt-prefixed OK line.
func hasTelnetPersistenceConfirmation(response, expected string) bool {
meaningful := meaningfulTelnetResponseLines(response)
return len(meaningful) == 1 && meaningful[0] == expected+" ->" ||
len(meaningful) == 2 && meaningful[0] == expected && meaningful[1] == "OK"
}
// telnetReadBackSummary reads the live URL configuration so a failure report
// says what the device actually holds now, instead of asking the user to go
// and find out. Returns an empty string when the readback itself fails, since
// there is then nothing trustworthy to add.
func telnetReadBackSummary(t TelnetClient, logs *strings.Builder) string {
verify, err := t.SendCommand("getpdo CurrentSystemConfiguration")
if err != nil {
fmt.Fprintf(logs, "Read-back after the failure also failed: %v\n", err)
return ""
}
fmt.Fprintf(logs, "→ getpdo CurrentSystemConfiguration (read-back after the failure)\n%s\n",
strings.TrimRight(verify, "\r\n"))
current := parseGetpdoConfig(verify)
if len(current) == 0 {
return ""
}
fields := make([]string, 0, len(current))
for _, field := range canonicalBoseTelnetURLs().fields() {
if value, ok := current[field.configName]; ok && value != "" {
fields = append(fields, field.configName+"="+value)
}
}
if len(fields) == 0 {
return ""
}
return "; the device currently reports " + strings.Join(fields, ", ")
}
func telnetWriteFailureContext(runtimeWrites int, persistenceAttempted bool) string {
if persistenceAttempted {
return "all four runtime URL writes were confirmed, but the persistence outcome is uncertain; " +
"read back all four URL fields before retrying or rebooting"
}
if runtimeWrites > 0 {
return fmt.Sprintf("%d runtime URL write(s) were confirmed, so the runtime configuration may be partial; "+
"read back all four URL fields before retrying or rebooting", runtimeWrites)
}
return "no URL write was confirmed, but the attempted command may have reached the speaker; " +
"read back all four URL fields before retrying or rebooting"
}
func verifyTelnetURLs(response string, want telnetURLs) error {
got := parseGetpdoConfig(response)
for _, field := range want.fields() {
value, ok := got[field.configName]
if !ok {
return fmt.Errorf("verification failed: getpdo response is missing %s", field.configName)
}
if value != field.value {
return fmt.Errorf("verification failed: %s is %q, want %q", field.configName, value, field.value)
}
}
return nil
}
// isCommandNotFound returns true if the device's response to a command
// indicates the command is not available on this firmware. Different firmware
// builds use slightly different wording; we accept any of the observed
+21 -391
View File
@@ -47,45 +47,15 @@ func newFakeTelnetManager(f *fakeTelnet) *Manager {
return m
}
func flatGetpdoResponse(urls telnetURLs) string {
return "margeServerUrl=" + urls.Marge + "\n" +
"statsServerUrl=" + urls.Stats + "\n" +
"swUpdateUrl=" + urls.SwUpdate + "\n" +
"bmxRegistryUrl=" + urls.BmxRegistry + "\n"
}
func protobufGetpdoResponse(urls telnetURLs) string {
return `margeServerUrl {
text: "` + urls.Marge + `"
}
statsServerUrl {
text: "` + urls.Stats + `"
}
swUpdateUrl {
text: "` + urls.SwUpdate + `"
}
bmxRegistryUrl {
text: "` + urls.BmxRegistry + `"
}
->OK
`
}
func telnetResponses(urls telnetURLs, verify string) map[string]string {
return map[string]string{
"sys configuration bmxRegistryUrl " + urls.BmxRegistry: "OK\n",
"sys configuration statsServerUrl " + urls.Stats: "OK\n",
"sys configuration margeServerUrl " + urls.Marge: "OK\n",
"sys configuration swUpdateUrl " + urls.SwUpdate: "OK\n",
"envswitch boseurls set " + urls.Marge + " " + urls.SwUpdate: "Setting Bose Server URLs to " + urls.Marge + " and " + urls.SwUpdate + " ->\n",
"getpdo CurrentSystemConfiguration": verify,
}
}
func happyResponses(targetURL string) map[string]string {
urls := defaultTelnetURLs(targetURL)
return telnetResponses(urls, flatGetpdoResponse(urls))
return map[string]string{
"sys configuration bmxRegistryUrl " + targetURL + "/bmx/registry/v1/services": "OK\n",
"sys configuration statsServerUrl " + targetURL: "OK\n",
"sys configuration margeServerUrl " + targetURL: "OK\n",
"sys configuration swUpdateUrl " + targetURL + "/updates/soundtouch": "OK\n",
"envswitch boseurls set " + targetURL + " " + targetURL + "/updates/soundtouch": "OK\n",
"getpdo CurrentSystemConfiguration": "margeServerUrl=" + targetURL + "\nbmxRegistryUrl=" + targetURL + "/bmx/registry/v1/services\n",
}
}
func TestMigrateViaTelnet_HappyPath(t *testing.T) {
@@ -96,7 +66,7 @@ func TestMigrateViaTelnet_HappyPath(t *testing.T) {
}
m := newFakeTelnetManager(f)
logs, err := m.migrateViaTelnet("192.0.2.1", defaultTelnetURLs(target))
logs, err := m.migrateViaTelnet("192.0.2.1", target, defaultTelnetURLs(target))
if err != nil {
t.Fatalf("migrateViaTelnet: %v", err)
}
@@ -129,116 +99,11 @@ func TestMigrateViaTelnet_HappyPath(t *testing.T) {
}
}
func TestMigrateViaTelnet_AcceptsPromptedOKAfterEnvswitchConfirmation(t *testing.T) {
target := "http://example:8000"
urls := defaultTelnetURLs(target)
responses := happyResponses(target)
responses["envswitch boseurls set "+urls.Marge+" "+urls.SwUpdate] =
"Setting Bose Server URLs to " + urls.Marge + " and " + urls.SwUpdate + "\n->OK\n->"
f := &fakeTelnet{responses: responses}
m := newFakeTelnetManager(f)
if _, err := m.migrateViaTelnet("192.0.2.1", urls); err != nil {
t.Fatalf("migrateViaTelnet: %v", err)
}
}
func TestValidateTelnetMutationResponse_PersistenceShapes(t *testing.T) {
target := "http://example:8000"
urls := defaultTelnetURLs(target)
command := "envswitch boseurls set " + urls.Marge + " " + urls.SwUpdate
confirmation := "Setting Bose Server URLs to " + urls.Marge + " and " + urls.SwUpdate
tests := []struct {
name string
response string
accept bool
}{
{name: "legacy prompt suffix", response: confirmation + " ->\n", accept: true},
{name: "prompted ok", response: confirmation + "\n->OK\n->", accept: true},
{name: "bare confirmation", response: confirmation + "\n"},
{name: "bare ok", response: "OK\n"},
{name: "reversed order", response: "OK\n" + confirmation + "\n"},
{name: "duplicate ok", response: confirmation + "\n->OK\n->OK\n->"},
{name: "legacy plus ok", response: confirmation + " ->\n->OK\n->"},
{name: "unexpected line", response: confirmation + "\nwarning: not persisted\n->OK\n->"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateTelnetMutationResponse(command, tt.response, true, urls)
if tt.accept && err != nil {
t.Fatalf("response rejected: %v", err)
}
if !tt.accept && err == nil {
t.Fatal("response accepted")
}
})
}
}
func TestMigrateViaTelnet_RejectsUnsafeURLsBeforeCreatingClient(t *testing.T) {
tests := []struct {
name string
value string
}{
{name: "line break", value: "http://example:8000/marge\r\nsys reboot"},
{name: "space", value: "http://example:8000/not safe"},
{name: "shell metacharacter", value: "http://example:8000/;sys-reboot"},
{name: "unsupported scheme", value: "ftp://example:8000/marge"},
{name: "missing host", value: "http:/marge"},
{name: "userinfo", value: "http://user:secret@example:8000/marge"},
{name: "query", value: "http://example:8000/marge?mode=test"},
{name: "fragment", value: "http://example:8000/marge#section"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
urls := defaultTelnetURLs("http://example:8000")
urls.Marge = tt.value
clientCreated := false
m := &Manager{NewTelnet: func(string) TelnetClient {
clientCreated = true
return &fakeTelnet{}
}}
_, err := m.migrateViaTelnet("192.0.2.1", urls)
if !errors.Is(err, ErrInvalidTelnetURL) {
t.Fatalf("error = %v, want ErrInvalidTelnetURL", err)
}
if clientCreated {
t.Fatal("telnet client was created for rejected URL input")
}
})
}
}
func TestMigrateViaTelnet_AcceptsHTTPAndHTTPSServiceURLs(t *testing.T) {
for _, target := range []string{
"http://unifi:8001",
"HTTPS://example:8443/aftertouch",
"https://[2001:db8::1]:8443/aftertouch",
} {
t.Run(target, func(t *testing.T) {
urls := defaultTelnetURLs(target)
f := &fakeTelnet{responses: telnetResponses(urls, flatGetpdoResponse(urls))}
m := newFakeTelnetManager(f)
if _, err := m.migrateViaTelnet("192.0.2.1", urls); err != nil {
t.Fatalf("migrateViaTelnet: %v", err)
}
})
}
}
func TestMigrateViaTelnet_DialFailureReturnsError(t *testing.T) {
f := &fakeTelnet{dialErr: errors.New("connection refused")}
m := newFakeTelnetManager(f)
_, err := m.migrateViaTelnet("192.0.2.1", defaultTelnetURLs("http://example:8000"))
_, err := m.migrateViaTelnet("192.0.2.1", "http://example:8000", defaultTelnetURLs("http://example:8000"))
if err == nil {
t.Fatal("expected dial error, got nil")
}
@@ -261,7 +126,7 @@ func TestMigrateViaTelnet_CommandNotFoundAborts(t *testing.T) {
f := &fakeTelnet{responses: resp}
m := newFakeTelnetManager(f)
_, err := m.migrateViaTelnet("192.0.2.1", defaultTelnetURLs(target))
_, err := m.migrateViaTelnet("192.0.2.1", target, defaultTelnetURLs(target))
if err == nil {
t.Fatal("expected error when envswitch is rejected, got nil")
}
@@ -270,93 +135,25 @@ func TestMigrateViaTelnet_CommandNotFoundAborts(t *testing.T) {
t.Errorf("err = %v, want to mention the rejected command", err)
}
assertNoTelnetWritesAfterRejection(t, f.commands, "envswitch")
}
func TestMigrateViaTelnet_GenericRuntimeRejectionReportsPartialState(t *testing.T) {
target := "http://example:8000"
resp := happyResponses(target)
resp["sys configuration statsServerUrl "+target] = "NOT OK\n"
f := &fakeTelnet{responses: resp}
m := newFakeTelnetManager(f)
_, err := m.migrateViaTelnet("192.0.2.1", defaultTelnetURLs(target))
if err == nil {
t.Fatal("expected generic rejection error, got nil")
}
if !strings.Contains(err.Error(), "1 runtime URL write(s) were confirmed") {
t.Errorf("err = %v, want partial-runtime classification", err)
}
for _, command := range f.commands {
if strings.Contains(command, "margeServerUrl") {
t.Errorf("migration continued after rejected runtime write: %v", f.commands)
}
}
}
func TestMigrateViaTelnet_EnvswitchRejectionReportsUncertainPersistence(t *testing.T) {
target := "http://example:8000"
urls := defaultTelnetURLs(target)
resp := happyResponses(target)
resp["envswitch boseurls set "+urls.Marge+" "+urls.SwUpdate] =
"Not setting Bose Server URLs to " + urls.Marge + " and " + urls.SwUpdate + " ->\n"
f := &fakeTelnet{responses: resp}
m := newFakeTelnetManager(f)
_, err := m.migrateViaTelnet("192.0.2.1", urls)
if err == nil {
t.Fatal("expected envswitch rejection error, got nil")
}
if !strings.Contains(err.Error(), "persistence outcome is uncertain") {
t.Errorf("err = %v, want uncertain-persistence classification", err)
}
assertNoTelnetWritesAfterRejection(t, f.commands, "envswitch")
}
// assertNoTelnetWritesAfterRejection checks the property that matters once a
// command is rejected: no further command may CHANGE the device. The
// read-only getpdo read-back is expected, since the failure report says what
// the device actually holds rather than telling the user to go and find out.
func assertNoTelnetWritesAfterRejection(t *testing.T, commands []string, rejected string) {
t.Helper()
seenRejected := false
for _, command := range commands {
if strings.Contains(command, rejected) {
seenRejected = true
continue
}
if !seenRejected {
continue
}
if strings.HasPrefix(command, "sys configuration") || strings.HasPrefix(command, "envswitch") {
t.Errorf("a write was sent after the rejected command: %v", commands)
// The verification command must NOT have been sent — the run aborts on
// the first rejection.
for _, c := range f.commands {
if c == "getpdo CurrentSystemConfiguration" {
t.Errorf("verification was sent after a rejected command: %v", f.commands)
}
}
}
func TestMigrateViaTelnet_VerifyMismatchFails(t *testing.T) {
target := "http://example:8000"
urls := defaultTelnetURLs(target)
resp := happyResponses(target)
// The old substring check passed this response because the target still
// appeared in it, despite one field having the wrong exact value.
urls.Stats = target + "/wrong"
resp["getpdo CurrentSystemConfiguration"] = flatGetpdoResponse(urls)
// Device echoes the OLD URLs (envswitch/sys configuration silently dropped).
resp["getpdo CurrentSystemConfiguration"] = "margeServerUrl=https://streaming.bose.com\n"
f := &fakeTelnet{responses: resp}
m := newFakeTelnetManager(f)
_, err := m.migrateViaTelnet("192.0.2.1", defaultTelnetURLs(target))
_, err := m.migrateViaTelnet("192.0.2.1", target, defaultTelnetURLs(target))
if err == nil {
t.Fatal("expected verification mismatch error, got nil")
}
@@ -364,40 +161,6 @@ func TestMigrateViaTelnet_VerifyMismatchFails(t *testing.T) {
if !strings.Contains(err.Error(), "verification failed") {
t.Errorf("err = %v, want to mention verification failure", err)
}
if !strings.Contains(err.Error(), "persistence may already have changed") {
t.Errorf("err = %v, want post-envswitch uncertainty", err)
}
}
func TestMigrateViaTelnet_VerifyProtobufFormatSucceeds(t *testing.T) {
target := "http://example:8000"
urls := defaultTelnetURLs(target)
f := &fakeTelnet{responses: telnetResponses(urls, protobufGetpdoResponse(urls))}
m := newFakeTelnetManager(f)
if _, err := m.migrateViaTelnet("192.0.2.1", urls); err != nil {
t.Fatalf("migrateViaTelnet: %v", err)
}
}
func TestMigrateViaTelnet_VerifyMissingFieldFails(t *testing.T) {
target := "http://example:8000"
urls := defaultTelnetURLs(target)
verify := "margeServerUrl=" + urls.Marge + "\n" +
"statsServerUrl=" + urls.Stats + "\n" +
"swUpdateUrl=" + urls.SwUpdate + "\n"
f := &fakeTelnet{responses: telnetResponses(urls, verify)}
m := newFakeTelnetManager(f)
_, err := m.migrateViaTelnet("192.0.2.1", urls)
if err == nil {
t.Fatal("expected verification error for missing bmxRegistryUrl, got nil")
}
if !strings.Contains(err.Error(), "missing bmxRegistryUrl") {
t.Errorf("err = %v, want missing field name", err)
}
}
func TestMigrateViaTelnet_TransportErrorAborts(t *testing.T) {
@@ -410,7 +173,7 @@ func TestMigrateViaTelnet_TransportErrorAborts(t *testing.T) {
}
m := newFakeTelnetManager(f)
_, err := m.migrateViaTelnet("192.0.2.1", defaultTelnetURLs(target))
_, err := m.migrateViaTelnet("192.0.2.1", target, defaultTelnetURLs(target))
if err == nil {
t.Fatal("expected transport error, got nil")
}
@@ -420,30 +183,10 @@ func TestMigrateViaTelnet_TransportErrorAborts(t *testing.T) {
}
}
func TestMigrateViaTelnet_VerificationTransportFailureReportsUncertainPersistence(t *testing.T) {
target := "http://example:8000"
f := &fakeTelnet{
responses: happyResponses(target),
fail: map[string]error{
"getpdo CurrentSystemConfiguration": errors.New("read: connection reset"),
},
}
m := newFakeTelnetManager(f)
_, err := m.migrateViaTelnet("192.0.2.1", defaultTelnetURLs(target))
if err == nil {
t.Fatal("expected verification transport error, got nil")
}
if !strings.Contains(err.Error(), "persistence may already have changed") {
t.Errorf("err = %v, want post-envswitch uncertainty", err)
}
}
func TestMigrateViaTelnet_MissingNewTelnetIsClearError(t *testing.T) {
m := &Manager{ServerURL: "http://example:8000"} // NewTelnet deliberately nil
_, err := m.migrateViaTelnet("192.0.2.1", defaultTelnetURLs("http://example:8000"))
_, err := m.migrateViaTelnet("192.0.2.1", "http://example:8000", defaultTelnetURLs("http://example:8000"))
if err == nil {
t.Fatal("expected error when NewTelnet is nil")
}
@@ -452,116 +195,3 @@ func TestMigrateViaTelnet_MissingNewTelnetIsClearError(t *testing.T) {
t.Errorf("err = %v, want a configuration error mentioning NewTelnet", err)
}
}
// TestTelnetRevertNotOfferedOnUnmigratedSpeakers: offering the revert here
// would rewrite a pristine speaker's genuine factory URLs and commit them
// through envswitch. Telnet migration takes no backup, so the record of what
// those URLs were is then gone.
func TestTelnetRevertNotOfferedOnUnmigratedSpeakers(t *testing.T) {
getpdo := func(marge, stats, swUpdate, bmx string) string {
return "margeServerUrl {\n text: \"" + marge + "\"\n}\n" +
"statsServerUrl {\n text: \"" + stats + "\"\n}\n" +
"swUpdateUrl {\n text: \"" + swUpdate + "\"\n}\n" +
"bmxRegistryUrl {\n text: \"" + bmx + "\"\n}\n->OK\n"
}
for _, test := range []struct {
name string
response string
want bool
}{
{
// Observed on real hardware, and what canonicalBoseTelnetURLs holds.
name: "canonical original variant",
response: getpdo("https://streaming.bose.com", "https://events.api.bosecm.com",
"https://worldwide.bose.com/updates/soundtouch",
"https://content.api.bose.io/bmx/registry/v1/services"),
},
{
// The variant pkg/service/testing/fakespeaker models. Comparing
// against the canonical set alone offered a revert here.
name: "older original variant",
response: getpdo("https://streaming.bose.com", "https://stats.bose.com",
"https://worldwide.bose.com/updates/soundtouch",
"https://bmxservice.bose.com/bmx/registry/v1/services"),
},
{
name: "original with firmware-normalised casing and trailing slash",
response: getpdo("https://STREAMING.BOSE.COM/", "https://events.api.bosecm.com",
"https://worldwide.bose.com/updates/soundtouch",
"https://content.api.bose.io/bmx/registry/v1/services"),
},
{
name: "migrated to AfterTouch",
response: getpdo("http://aftertouch.example:8000", "http://aftertouch.example:8000",
"http://aftertouch.example:8000/updates/soundtouch",
"http://aftertouch.example:8000/bmx/registry/v1/services"),
want: true,
},
{
// A single changed field is still a changed device.
name: "partially migrated",
response: getpdo("http://aftertouch.example:8000", "https://events.api.bosecm.com",
"https://worldwide.bose.com/updates/soundtouch",
"https://content.api.bose.io/bmx/registry/v1/services"),
want: true,
},
} {
t.Run(test.name, func(t *testing.T) {
if got := telnetRevertAvailable(test.response); got != test.want {
t.Errorf("telnetRevertAvailable() = %v, want %v", got, test.want)
}
})
}
}
// TestMigrateViaTelnet_FailureReportsWhatTheDeviceHolds: the failure advice is
// "read back and reconcile all four URL fields", which the service can do
// itself. An unrecognised reply is not proof the write failed, so the readback
// is better evidence than the reply shape.
func TestMigrateViaTelnet_FailureReportsWhatTheDeviceHolds(t *testing.T) {
target := "http://example:8000"
urls := defaultTelnetURLs(target)
resp := happyResponses(target)
resp["envswitch boseurls set "+urls.Marge+" "+urls.SwUpdate] = "something unfamiliar\n"
f := &fakeTelnet{responses: resp}
m := newFakeTelnetManager(f)
logs, err := m.migrateViaTelnet("192.0.2.1", urls)
if err == nil {
t.Fatal("expected an error for an unrecognised envswitch response")
}
if !strings.Contains(err.Error(), "the device currently reports") {
t.Errorf("err = %v, want it to carry the live URL state", err)
}
if !strings.Contains(err.Error(), "margeServerUrl="+target) {
t.Errorf("err = %v, want the actual margeServerUrl value", err)
}
if !strings.Contains(logs, "read-back after the failure") {
t.Errorf("logs did not record the read-back:\n%s", logs)
}
}
// TestResyncBoseURLsAfterXML_DistinguishesRejectedURL: applyURLOverrides lets
// the XML path accept URLs the telnet validator refuses, so the XML write
// succeeds while the re-sync is skipped. Reporting that as "could not re-sync
// over telnet" points the user at the wrong thing.
func TestResyncBoseURLsAfterXML_DistinguishesRejectedURL(t *testing.T) {
m := newFakeTelnetManager(&fakeTelnet{responses: map[string]string{}})
rejected := m.resyncBoseURLsAfterXML("192.0.2.1", telnetURLs{
Marge: "http://example:8000?probe=1",
Stats: "http://example:8000",
SwUpdate: "http://example:8000/updates/soundtouch",
BmxRegistry: "http://example:8000/bmx/registry/v1/services",
})
if !strings.Contains(rejected, "a URL was rejected") {
t.Errorf("note = %q, want it to name the rejected URL as the cause", rejected)
}
if strings.Contains(rejected, "could not re-sync boseurls over telnet") {
t.Errorf("note = %q, want it not to blame telnet availability", rejected)
}
}
@@ -1,127 +0,0 @@
package setup
import (
"reflect"
"sync"
"testing"
"time"
)
type serializedTelnetFactory struct {
mu sync.Mutex
nextID int
urlsByID map[int]telnetURLs
commandsByConnection []int
firstCommandStarted chan struct{}
releaseFirstCommand chan struct{}
secondDialed chan struct{}
}
type serializedTelnetClient struct {
factory *serializedTelnetFactory
id int
commandCount int
}
func (f *serializedTelnetFactory) newClient(string) TelnetClient {
f.mu.Lock()
defer f.mu.Unlock()
f.nextID++
return &serializedTelnetClient{factory: f, id: f.nextID}
}
func (c *serializedTelnetClient) Dial() error {
if c.id == 2 {
close(c.factory.secondDialed)
}
return nil
}
func (c *serializedTelnetClient) Probe() (string, error) { return "", nil }
func (c *serializedTelnetClient) Close() error { return nil }
func (c *serializedTelnetClient) SendCommand(command string) (string, error) {
c.commandCount++
c.factory.mu.Lock()
c.factory.commandsByConnection = append(c.factory.commandsByConnection, c.id)
c.factory.mu.Unlock()
if c.id == 1 && c.commandCount == 1 {
close(c.factory.firstCommandStarted)
<-c.factory.releaseFirstCommand
}
urls := c.factory.urlsByID[c.id]
if command == "getpdo CurrentSystemConfiguration" {
return flatGetpdoResponse(urls), nil
}
if command == "envswitch boseurls set "+urls.Marge+" "+urls.SwUpdate {
return "Setting Bose Server URLs to " + urls.Marge + " and " + urls.SwUpdate + " ->\n", nil
}
return "OK\n", nil
}
func TestTelnetURLMutationsSameSpeakerAreSerialized(t *testing.T) {
firstURLs := canonicalBoseTelnetURLs()
secondURLs := defaultTelnetURLs("http://next.example:8000")
factory := &serializedTelnetFactory{
urlsByID: map[int]telnetURLs{
1: firstURLs,
2: secondURLs,
},
firstCommandStarted: make(chan struct{}),
releaseFirstCommand: make(chan struct{}),
secondDialed: make(chan struct{}),
}
m := &Manager{NewTelnet: factory.newClient}
firstDone := make(chan error, 1)
secondDone := make(chan error, 1)
go func() {
_, err := m.RevertTelnetURLs("192.0.2.1", nil)
firstDone <- err
}()
<-factory.firstCommandStarted
secondStarted := make(chan struct{})
go func() {
close(secondStarted)
_, err := m.setAllBoseURLsViaTelnet("192.0.2.1", secondURLs)
secondDone <- err
}()
<-secondStarted
select {
case <-factory.secondDialed:
close(factory.releaseFirstCommand)
t.Fatal("second mutation dialed before the first sequence completed")
case <-time.After(100 * time.Millisecond):
}
close(factory.releaseFirstCommand)
if err := <-firstDone; err != nil {
t.Fatalf("first mutation: %v", err)
}
if err := <-secondDone; err != nil {
t.Fatalf("second mutation: %v", err)
}
factory.mu.Lock()
got := append([]int(nil), factory.commandsByConnection...)
factory.mu.Unlock()
want := []int{1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2}
if !reflect.DeepEqual(got, want) {
t.Fatalf("connection command order = %v, want contiguous blocks %v", got, want)
}
}
+13 -53
View File
@@ -69,57 +69,6 @@ func TestTelnetURLsFromOptions_PerFieldOverrides(t *testing.T) {
}
}
func TestRevertTelnetURLs_DefaultsAndCommandOrder(t *testing.T) {
urls := telnetURLs{
Marge: "https://streaming.bose.com",
Stats: "https://events.api.bosecm.com",
SwUpdate: "https://worldwide.bose.com/updates/soundtouch",
BmxRegistry: "https://content.api.bose.io/bmx/registry/v1/services",
}
f := &fakeTelnet{responses: telnetResponses(urls, flatGetpdoResponse(urls))}
m := newFakeTelnetManager(f)
logs, err := m.RevertTelnetURLs("192.0.2.1", nil)
if err != nil {
t.Fatalf("RevertTelnetURLs: %v", err)
}
wantCommands := append(urls.Commands(), "getpdo CurrentSystemConfiguration")
if !reflect.DeepEqual(f.commands, wantCommands) {
t.Errorf("commands =\n%v\nwant\n%v", f.commands, wantCommands)
}
if !strings.Contains(logs, "URL configuration only") {
t.Errorf("logs do not limit the operation to URL configuration:\n%s", logs)
}
}
func TestRevertTelnetURLs_OverridesTakePrecedence(t *testing.T) {
options := map[string]string{
"marge_url": "https://override.example/marge",
"stats_url": "https://override.example/stats",
"sw_update_url": "https://override.example/update",
"bmx_url": "https://override.example/bmx",
}
want := telnetURLs{
Marge: options["marge_url"],
Stats: options["stats_url"],
SwUpdate: options["sw_update_url"],
BmxRegistry: options["bmx_url"],
}
f := &fakeTelnet{responses: telnetResponses(want, flatGetpdoResponse(want))}
m := newFakeTelnetManager(f)
if _, err := m.RevertTelnetURLs("192.0.2.1", options); err != nil {
t.Fatalf("RevertTelnetURLs: %v", err)
}
wantCommands := append(want.Commands(), "getpdo CurrentSystemConfiguration")
if !reflect.DeepEqual(f.commands, wantCommands) {
t.Errorf("commands =\n%v\nwant overrides\n%v", f.commands, wantCommands)
}
}
// TestTelnetURLs_Commands_EnvswitchTracksMargeAndSwUpdate is the load-bearing
// test for the soundcork case: if the user added /marge to Marge, the
// envswitch arg1 must follow the same suffix verbatim, otherwise the
@@ -156,6 +105,7 @@ func TestTelnetURLs_Commands_EnvswitchTracksMargeAndSwUpdate(t *testing.T) {
}
func TestMigrateViaTelnet_SoundcorkMargeSuffixPropagatesToEnvswitch(t *testing.T) {
target := "http://example:8000"
urls := telnetURLs{
Marge: "http://example:8000/marge",
Stats: "http://example:8000",
@@ -163,10 +113,20 @@ func TestMigrateViaTelnet_SoundcorkMargeSuffixPropagatesToEnvswitch(t *testing.T
BmxRegistry: "http://example:8000/bmx/registry/v1/services",
}
f := &fakeTelnet{responses: telnetResponses(urls, flatGetpdoResponse(urls))}
// Build a happy-path responder that matches the *new* command set.
resp := map[string]string{
"sys configuration bmxRegistryUrl " + urls.BmxRegistry: "OK\n",
"sys configuration statsServerUrl " + urls.Stats: "OK\n",
"sys configuration margeServerUrl " + urls.Marge: "OK\n",
"sys configuration swUpdateUrl " + urls.SwUpdate: "OK\n",
"envswitch boseurls set " + urls.Marge + " " + urls.SwUpdate: "OK\n",
"getpdo CurrentSystemConfiguration": "margeServerUrl=" + urls.Marge + "\n",
}
f := &fakeTelnet{responses: resp}
m := newFakeTelnetManager(f)
if _, err := m.migrateViaTelnet("192.0.2.1", urls); err != nil {
if _, err := m.migrateViaTelnet("192.0.2.1", target, urls); err != nil {
t.Fatalf("migrateViaTelnet: %v", err)
}
@@ -13,12 +13,9 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"io/fs"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
@@ -170,164 +167,6 @@ func TestPlayerRendersNatively(t *testing.T) {
// script types), which routes every browser -- including this ordinary
// headless Chrome -- through the library's own polyfill resolution instead
// of native import map support.
// outageProxy is a TCP proxy in front of the test server that can be taken
// down and brought back at the same address.
//
// Simulating an outage needs both halves: refusing new connections AND
// severing the established ones. A server that merely stops accepting leaves
// an open WebSocket running, and Chrome's offline emulation does not close it
// either, so neither reproduces a service that went away.
type outageProxy struct {
listener net.Listener
backend string
mu sync.Mutex
up bool
conns []net.Conn
}
func newOutageProxy(t *testing.T, backend string) *outageProxy {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
p := &outageProxy{listener: listener, backend: backend, up: true}
t.Cleanup(func() {
_ = listener.Close()
p.setUp(false)
})
go p.serve()
return p
}
func (p *outageProxy) url() string { return "http://" + p.listener.Addr().String() }
func (p *outageProxy) serve() {
for {
client, err := p.listener.Accept()
if err != nil {
return
}
p.mu.Lock()
serving := p.up
p.mu.Unlock()
if !serving {
_ = client.Close()
continue
}
upstream, err := net.Dial("tcp", p.backend)
if err != nil {
_ = client.Close()
continue
}
p.mu.Lock()
p.conns = append(p.conns, client, upstream)
p.mu.Unlock()
go func() { _, _ = io.Copy(upstream, client) }()
go func() { _, _ = io.Copy(client, upstream) }()
}
}
// setUp brings the proxy down or back. Going down also drops every connection
// already established, which is what makes an open WebSocket notice.
func (p *outageProxy) setUp(up bool) {
p.mu.Lock()
p.up = up
conns := p.conns
p.conns = nil
p.mu.Unlock()
if up {
return
}
for _, c := range conns {
_ = c.Close()
}
}
// TestPlayerSurvivesAServiceOutage: the player used to reload itself five
// seconds after the socket closed, which cannot work while the service is
// down, since the document is served by that same service. The tab landed on
// the browser's error page and everything the page held was lost.
//
// It must now stay up, say so, and recover on its own. The epoch on each
// status is what makes that safe: a restarted service publishes revisions
// from 0 again, and without the epoch the browser would reject them forever.
func TestPlayerSurvivesAServiceOutage(t *testing.T) {
app := NewWebApp()
r := chi.NewRouter()
app.Mount(r, nil)
server := httptest.NewServer(r)
t.Cleanup(server.Close)
backendURL, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("parse server URL: %v", err)
}
proxy := newOutageProxy(t, backendURL.Host)
ctx := newHeadlessChromeContext(t)
if err := chromedp.Run(ctx,
chromedp.Navigate(proxy.url()+"/app"),
chromedp.WaitVisible(`#app`, chromedp.ByQuery),
// The socket must be up before taking it away.
chromedp.Poll(`document.querySelector('.connection-banner') === null`, nil),
); err != nil {
t.Fatalf("connect before the outage: %v", err)
}
proxy.setUp(false)
var bannerAfterOutage, stillLoaded string
if err := chromedp.Run(ctx,
chromedp.Poll(`document.querySelector('.connection-banner') !== null`, nil),
chromedp.Text(`.connection-banner`, &bannerAfterOutage, chromedp.ByQuery),
// The page is still the player, not the browser's error page.
chromedp.Evaluate(`document.querySelector('#app') !== null ? 'loaded' : 'gone'`, &stillLoaded),
); err != nil {
t.Fatalf("detect the outage: %v", err)
}
if !strings.Contains(bannerAfterOutage, "Reconnecting") {
t.Errorf("banner during outage = %q, want it to say it is reconnecting", bannerAfterOutage)
}
if stillLoaded != "loaded" {
t.Error("player did not survive the outage")
}
proxy.setUp(true)
var navigations int
if err := chromedp.Run(ctx,
// Recovers on its own, with no interaction.
chromedp.Poll(`document.querySelector('.connection-banner') === null`, nil),
chromedp.Evaluate(`performance.getEntriesByType('navigation').length`, &navigations),
); err != nil {
t.Fatalf("recover after the outage: %v", err)
}
// Still the document that weathered the outage, not a reloaded one.
if navigations != 1 {
t.Errorf("navigation entries = %d, want 1: the player reloaded instead of reconnecting", navigations)
}
}
func TestPlayerRendersUnderForcedShimMode(t *testing.T) {
app := NewWebApp()
r := chi.NewRouter()
@@ -999,30 +999,6 @@ img { display: block; max-width: 100%; }
}
/* ── Toast ───────────────────────────────────────────────────────────────── */
/* ── Connection banner ───────────────────────────────────────────────────── */
.connection-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
padding: .5rem 1rem;
text-align: center;
font-size: .8125rem;
z-index: 300;
box-shadow: 0 2px 8px rgba(0,0,0,.2);
/* --stale is not redefined for dark mode, so pairing it with a fixed
near-black keeps the contrast readable in both themes. */
background: var(--stale);
color: #1a1a1a;
}
/* Opening the socket is not a problem worth an alarm colour. */
.connection-banner.connecting {
background: var(--surface);
color: var(--text);
border-bottom: 1px solid var(--border);
}
.toast {
position: fixed;
bottom: 1.5rem;
+10 -55
View File
@@ -18,13 +18,13 @@ import { Announcements } from './components/Announcements.js';
import { api } from './api.js';
import { isSoundTouch10StereoPair } from './stereoPresentation.mjs';
import { removeDeviceAndRefresh } from './deviceRemoval.js';
// TEMPORARY INSTRUMENTATION -- do not merge. See trace_temp.js.
import { installTrace } from './trace_temp.js';
installTrace();
const html = htm.bind(h);
// Reconnect backoff for the status socket, doubling from base to max.
const RECONNECT_BASE_MS = 1000;
const RECONNECT_MAX_MS = 15000;
function statusRevision(status) {
const revision = status?.revision;
return Number.isSafeInteger(revision) && revision >= 0 ? revision : null;
@@ -176,9 +176,6 @@ function App() {
const [toast, setToast] = useState(null);
const [version, setVersion] = useState(null);
const [isDiscovering, setIsDiscovering] = useState(false);
// 'connecting' until the first frame arrives, so a page opened while the
// service is down does not claim the connection was lost.
const [connection, setConnection] = useState('connecting');
const getPageTitle = () => {
if (page === 'devices') return 'Devices';
@@ -215,12 +212,10 @@ function App() {
.catch(err => console.error('Failed to fetch version:', err));
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
let socket = null;
const ws = new WebSocket(`${protocol}//${location.host}/api/control/ws`);
let reconnectTimer;
let backoff = RECONNECT_BASE_MS;
let closed = false;
const handleMessage = (event) => {
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'devices') {
setDevices(previous => mergeDevicesSnapshot(previous, msg.data));
@@ -241,45 +236,13 @@ function App() {
}
};
// Reconnect in place rather than reloading. Reloading a page whose
// own document is served by the service cannot work while the service
// is down: it replaces a working UI with the browser's error page and
// loses everything the page held. Reconnecting keeps the page usable
// and recovers on its own when the service returns.
//
// This is safe because each status carries the epoch of the
// connection that produced it. A restarted service publishes
// revisions from 0 again, which the browser would otherwise reject
// forever; a newer epoch is accepted regardless of its revision, so a
// reconnected socket resynchronises without a reload.
function connect() {
if (closed) return;
const ws = new WebSocket(`${protocol}//${location.host}/api/control/ws`);
socket = ws;
ws.onopen = () => {
backoff = RECONNECT_BASE_MS;
setConnection('online');
};
ws.onmessage = handleMessage;
ws.onclose = () => {
if (closed || socket !== ws) return;
setConnection('offline');
reconnectTimer = setTimeout(connect, backoff);
backoff = Math.min(backoff * 2, RECONNECT_MAX_MS);
};
}
connect();
ws.onclose = () => {
reconnectTimer = setTimeout(() => location.reload(), 5000);
};
return () => {
closed = true;
clearTimeout(reconnectTimer);
socket?.close();
ws.close();
};
}, []);
@@ -445,14 +408,6 @@ function App() {
</footer>
` : null}
${connection !== 'online' ? html`
<div class="connection-banner ${connection}" role="status" aria-live="polite" key="connection">
${connection === 'connecting'
? 'Connecting to AfterTouch…'
: 'Lost contact with AfterTouch. Reconnecting…'}
</div>
` : null}
${toast ? html`<div class="toast" role="status" aria-live="polite"
aria-atomic="true" key="toast">${toast}</div>` : null}
</div>
@@ -24,15 +24,11 @@ const SOURCE_READBACK_DELAYS_MS = [2000, 5000, 10000];
// playing. The speaker then reports that stub indefinitely, so the player
// shows a source the speaker is not actually playing.
//
// Verified against real hardware: RADIO_BROWSER and LOCAL_INTERNET_RADIO both
// produce the byte-identical stub, and resuming TUNEIN from its Recents plays
// the station as intended.
//
// ALEXA is advertised READY as well but is deliberately NOT listed. It cannot
// be tested on the hardware available, so listing it would mean guessing at
// its behaviour, and guessing wrong would break a source that works today.
// The stub check in isStubNowPlaying covers it instead: if a bare select does
// strand it, that is reported as a failure rather than confirmed.
// Verified against real hardware for RADIO_BROWSER and LOCAL_INTERNET_RADIO:
// both produce the byte-identical stub. TUNEIN is listed because
// ResolveContentItem treats it identically to RADIO_BROWSER (both need a
// Location). ALEXA is also advertised READY but is NOT listed: whether a bare
// select resumes anything for it is unverified, so it keeps today's behaviour.
//
// `page` is the browser to open for the source: the page in this app that
// produces content for it. `resume` says whether clicking may first replay
@@ -0,0 +1,119 @@
// TEMPORARY INSTRUMENTATION -- do not merge.
//
// Counts the player's HTTP calls and the speaker events arriving over the
// WebSocket, to measure the source-selection readback cost in PR #670 review
// finding 2. Delete this file and its import in app.js once done.
//
// Off unless localStorage.aftertouchTrace === '1', so it costs nothing until
// switched on. In the browser console:
//
// localStorage.aftertouchTrace = '1'; location.reload();
// __trace.mark('click AUX'); // before each thing you want to bracket
// __trace.report(); // grouped counts since the last mark
// __trace.reset();
// delete localStorage.aftertouchTrace; location.reload();
let enabled = false;
try {
enabled = localStorage.getItem('aftertouchTrace') === '1';
} catch (_) {
enabled = false;
}
const started = performance.now();
const entries = [];
let markLabel = 'start';
let markAt = started;
function since() {
return ((performance.now() - started) / 1000).toFixed(3);
}
function record(kind, detail, extra = {}) {
const entry = { kind, detail, at: performance.now(), mark: markLabel, ...extra };
entries.push(entry);
const offset = ((entry.at - markAt) / 1000).toFixed(3);
console.log(`[trace] t=${since()}s +${offset}s after "${markLabel}" ${kind} ${detail}`,
Object.keys(extra).length ? extra : '');
}
export function installTrace() {
if (!enabled) return;
const nativeFetch = globalThis.fetch.bind(globalThis);
globalThis.fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input?.url ?? String(input);
const method = init?.method ?? (typeof input === 'object' ? input?.method : null) ?? 'GET';
const at = performance.now();
try {
const response = await nativeFetch(input, init);
record('HTTP', `${method} ${url}`, {
status: response.status,
ms: Math.round(performance.now() - at),
});
return response;
} catch (error) {
record('HTTP', `${method} ${url}`, { status: 'ERR', ms: Math.round(performance.now() - at) });
throw error;
}
};
const NativeWebSocket = globalThis.WebSocket;
globalThis.WebSocket = function TracedWebSocket(url, protocols) {
const socket = protocols === undefined
? new NativeWebSocket(url) : new NativeWebSocket(url, protocols);
record('WS', `open ${url}`);
socket.addEventListener('message', event => {
let type = 'unparsed';
let deviceId = '';
let extra = {};
try {
const msg = JSON.parse(event.data);
type = msg.type ?? 'untyped';
deviceId = msg.deviceId ?? '';
// The two fields this investigation cares about.
const status = msg.data?.status ?? msg.data?.[deviceId]?.status;
if (status) {
extra = {
source: status.nowPlaying?.Source,
revision: status.revision,
nowPlayingRevision: status.nowPlayingRevision,
epoch: status.epoch,
};
}
} catch (_) { /* keep the frame counted even if it is not JSON */ }
record('WS', `${type}${deviceId ? ' ' + deviceId : ''}`, extra);
});
socket.addEventListener('close', () => record('WS', `close ${url}`));
return socket;
};
globalThis.WebSocket.prototype = NativeWebSocket.prototype;
globalThis.__trace = {
mark(label) {
markLabel = label;
markAt = performance.now();
console.log(`[trace] ---- mark: ${label} (t=${since()}s) ----`);
},
report() {
const scoped = entries.filter(e => e.mark === markLabel);
const byDetail = new Map();
for (const e of scoped) {
const key = `${e.kind} ${e.detail}`;
byDetail.set(key, (byDetail.get(key) ?? 0) + 1);
}
console.log(`[trace] since "${markLabel}": ${scoped.length} events`);
console.table([...byDetail.entries()]
.sort((a, b) => b[1] - a[1])
.map(([what, count]) => ({ count, what })));
return scoped;
},
entries: () => entries,
reset() {
entries.length = 0;
markAt = performance.now();
},
};
console.log('[trace] AfterTouch player tracing on. __trace.mark(label) / __trace.report()');
}
-5
View File
@@ -14,7 +14,6 @@ import (
"net"
"os"
"strconv"
"strings"
"time"
)
@@ -124,10 +123,6 @@ func (c *Client) Probe() (string, error) {
// device's textual conventions vary by firmware: some commands return "OK",
// others echo state, others return nothing).
func (c *Client) SendCommand(cmd string) (string, error) {
if strings.ContainsAny(cmd, "\r\n") {
return "", errors.New("telnet: command must not contain line breaks")
}
if c.conn == nil {
return "", errors.New("telnet: not connected")
}
-22
View File
@@ -284,28 +284,6 @@ func TestSendCommand_CommandNotFound(t *testing.T) {
}
}
func TestSendCommand_RejectsLineBreaksWithoutWriting(t *testing.T) {
s := newScriptedServer(t, "", map[string]string{
"getpdo CurrentSystemConfiguration": "OK\n",
})
defer s.close()
c := newClientFor(t, s)
if err := c.Dial(); err != nil {
t.Fatalf("Dial: %v", err)
}
defer func() { _ = c.Close() }()
if _, err := c.SendCommand("getpdo CurrentSystemConfiguration\r\nsys reboot"); err == nil ||
!strings.Contains(err.Error(), "line breaks") {
t.Fatalf("SendCommand error = %v, want line-break rejection", err)
}
if _, err := c.SendCommand("getpdo CurrentSystemConfiguration"); err != nil {
t.Fatalf("safe command after rejection: %v", err)
}
}
func TestSendCommand_DeadlineFiresWhenDeviceHangs(t *testing.T) {
s := newScriptedServer(t, "", map[string]string{
"first": "OK\n",