feat(setup): add CLI setup command group for end-to-end speaker provisioning

Add `soundtouch-cli setup` subcommand group covering the full reset →
re-provision → pair lifecycle as a scriptable alternative to the web UI:

  inspect, verify, plan, factory-reset, wait-ap, wifi-push, wait-online,
  ssh-check, install-ca, migrate, reboot, pair (bare | full state machine)

Supporting library code lives in pkg/service/setup: factory_reset.go,
wifi_provision.go, inspect.go, init_plan.go, setup_session.go.

Confirmed against ST10 firmware 27.0.6 that bare setMargeAccount over
WebSocket — no SETUP_START/SETUP_ENTER/SETUP_LEAVE bracket — is
sufficient to pair a factory-reset speaker; the firmware materializes
SystemConfigurationDB.xml and Sources.xml itself and the pairing
survives reboot. Result and field-by-field SystemConfigurationDB
comparison documented in docs/analysis/SETUP-WEBSOCKET-EXPERIMENT.md.
Captures the device's pre-reset DELETE-to-marge plus its LAN peer
notification flow in docs/analysis/FACTORY-RESET-PROTOCOL.md.

Perf: batch GetMigrationSummary's SSH probes into one Run() call via
ssh_probe.go / ssh_probe_apply.go — was ~8 sequential dials at
500-1000 ms each on FW 27 crypto, now one round-trip. Same data shape,
same MigrationSummary fields populated.

Fixes /clockTime and /clockDisplay wire formats — firmware 27 rejects
the legacy flat XML ("Error parsing request"). ClockTimeRequest now
uses utcTime attribute; ClockDisplayRequest emits the nested
<clockConfig> envelope with timezoneInfo/timeFormat/brightnessLevel.

Removes cmd/example-init-speaker (superseded by setup pair).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-13 18:43:36 +02:00
co-authored by Claude Opus 4.7
parent 1ab4295653
commit 29a462da2b
27 changed files with 5297 additions and 158 deletions
+27
View File
@@ -157,6 +157,33 @@ func setClockTimeNow(c *cli.Context) error {
return nil
}
// setClockDisplayTimezone POSTs only the timezoneInfo attribute,
// leaving format/brightness untouched. Useful after a clock now to
// make the speaker's logs and front-panel display tick in local time
// instead of UTC.
func setClockDisplayTimezone(c *cli.Context) error {
clientConfig := GetClientConfig(c)
tz := c.String("tz")
PrintDeviceHeader(fmt.Sprintf("Setting clock timezone to %s", tz), clientConfig.Host, clientConfig.Port)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
return err
}
request := models.NewClockDisplayRequest().SetTimeZone(tz)
if err := client.SetClockDisplay(request); err != nil {
PrintError(fmt.Sprintf("Failed to set timezone: %v", err))
return err
}
PrintSuccess(fmt.Sprintf("Timezone set to %s", tz))
return nil
}
// getClockDisplay retrieves the current clock display settings
func getClockDisplay(c *cli.Context) error {
clientConfig := GetClientConfig(c)
File diff suppressed because it is too large Load Diff
+310
View File
@@ -0,0 +1,310 @@
package main
import (
"bytes"
"io"
"os"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
)
// captureStdout runs fn and returns whatever it wrote to os.Stdout.
// renderSourceTable prints directly via fmt.Print* — this lets us assert
// on its output without restructuring the renderer to take an io.Writer.
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
orig := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
os.Stdout = w
done := make(chan struct{})
buf := &bytes.Buffer{}
go func() {
_, _ = io.Copy(buf, r)
close(done)
}()
fn()
_ = w.Close()
os.Stdout = orig
<-done
return buf.String()
}
func TestRenderSourceTable_AlignsColumnsAndDedupsDisplayName(t *testing.T) {
items := []models.SourceItem{
// displayName != account → kept as "AUX (AUX IN)"
{Source: "AUX", SourceAccount: "AUX", DisplayName: "AUX IN", Status: "READY", IsLocal: true, MultiroomAllowed: true},
// displayName == account → dropped (would otherwise duplicate the next column)
{Source: "AMAZON", SourceAccount: "amzn1.account.AFKTQOUNVZL7ODQCF4STPAAMVMPA", DisplayName: "amzn1.account.AFKTQOUNVZL7ODQCF4STPAAMVMPA", Status: "READY", MultiroomAllowed: true},
// No displayName at all, no account
{Source: "BLUETOOTH", Status: "UNAVAILABLE", IsLocal: true, MultiroomAllowed: true},
// Long source name, no catalog entry → provider#?
{Source: "STORED_MUSIC_MEDIA_RENDERER", SourceAccount: "StoredMusicUserName", DisplayName: "StoredMusicUserName", Status: "UNAVAILABLE", MultiroomAllowed: true},
}
out := captureStdout(t, func() { renderSourceTable(items) })
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
if len(lines) != 4 {
t.Fatalf("got %d output lines, want 4:\n%s", len(lines), out)
}
// (1) AUX keeps "(AUX IN)" because it differs from both source and account.
if !strings.Contains(lines[0], "AUX (AUX IN)") {
t.Errorf("AUX line should keep displayName parenthesis: %q", lines[0])
}
// (2) AMAZON drops "(amzn1…)" because displayName equals sourceAccount.
if strings.Contains(lines[1], "(amzn1.account") {
t.Errorf("AMAZON line should drop displayName when it duplicates account: %q", lines[1])
}
// (3) provider#? for the uncatalogued source.
if !strings.Contains(lines[3], "provider#?") {
t.Errorf("uncatalogued source should be tagged provider#?: %q", lines[3])
}
// (4) Column starts must align across all rows — find the column index
// where "status=" appears in each line; they should all match.
statusCols := make([]int, len(lines))
for i, l := range lines {
statusCols[i] = strings.Index(l, "status=")
if statusCols[i] < 0 {
t.Fatalf("line %d missing status= column: %q", i, l)
}
}
for i := 1; i < len(statusCols); i++ {
if statusCols[i] != statusCols[0] {
t.Errorf("status= column misaligned: line 0 at col %d, line %d at col %d\n%s",
statusCols[0], i, statusCols[i], out)
}
}
// (5) account= column should likewise align across all rows.
accountCols := make([]int, len(lines))
for i, l := range lines {
accountCols[i] = strings.Index(l, "account=")
if accountCols[i] < 0 {
t.Fatalf("line %d missing account= column: %q", i, l)
}
}
for i := 1; i < len(accountCols); i++ {
if accountCols[i] != accountCols[0] {
t.Errorf("account= column misaligned: line 0 at col %d, line %d at col %d\n%s",
accountCols[0], i, accountCols[i], out)
}
}
}
func TestRenderSourceTable_EmptyShowsNonePlaceholder(t *testing.T) {
out := captureStdout(t, func() { renderSourceTable(nil) })
if !strings.Contains(out, "(none)") {
t.Errorf("expected (none) placeholder for empty list, got: %q", out)
}
}
func TestRecommendMigrationMethod_PrefersTelnet(t *testing.T) {
method, reason := recommendMigrationMethod("http://aftertouch.local:8000", &setup.MigrationSummary{
TelnetReachable: true,
SSHSuccess: true,
})
if method != setup.MigrationMethodTelnet {
t.Errorf("method = %q, want telnet (simplest path when telnet works)", method)
}
if !strings.Contains(reason, "Telnet") {
t.Errorf("reason should mention Telnet: %q", reason)
}
}
func TestRecommendMigrationMethod_HTTPSAddsCaveatToTelnet(t *testing.T) {
_, reason := recommendMigrationMethod("https://aftertouch.local:8443", &setup.MigrationSummary{
TelnetReachable: true,
})
if !strings.Contains(reason, "install-ca") {
t.Errorf("HTTPS service URL should flag the CA-install caveat in the reason: %q", reason)
}
}
func TestRecommendMigrationMethod_FallsBackToResolvWhenTelnetDown(t *testing.T) {
method, _ := recommendMigrationMethod("http://aftertouch.local:8000", &setup.MigrationSummary{
TelnetReachable: false,
SSHSuccess: true,
})
if method != setup.MigrationMethodResolvConf {
t.Errorf("method = %q, want resolv (DNS redirect via SSH)", method)
}
}
func TestRecommendMigrationMethod_EmptyWhenNoTransport(t *testing.T) {
method, _ := recommendMigrationMethod("http://aftertouch.local:8000", &setup.MigrationSummary{
TelnetReachable: false,
SSHSuccess: false,
})
if method != "" {
t.Errorf("method = %q, want empty when no transport works", method)
}
}
func TestBuildPlanSteps_NoOpWhenAlreadyMigratedAndPaired(t *testing.T) {
summary := &setup.MigrationSummary{IsMigrated: true, IsPaired: true, TelnetMigrated: true}
inspect := &setup.InspectReport{Info: &setup.DeviceInfoXML{DeviceID: "AABBCCDDEEFF"}}
steps := buildPlanSteps("192.168.1.42", "http://aftertouch.local:8000", "", true, false, inspect, summary)
if len(steps) != 0 {
t.Errorf("expected no steps for fully-set-up device, got %d:\n%v", len(steps), steps)
}
}
func TestBuildPlanSteps_RecommendsPairWhenMigratedButUnpaired(t *testing.T) {
summary := &setup.MigrationSummary{IsMigrated: true, IsPaired: false, TelnetMigrated: true, TelnetReachable: true}
inspect := &setup.InspectReport{Info: &setup.DeviceInfoXML{DeviceID: "AABBCCDDEEFF"}}
steps := buildPlanSteps("192.168.1.42", "http://aftertouch.local:8000", "", true, false, inspect, summary)
if len(steps) != 1 {
t.Fatalf("expected exactly the pair step, got %d:\n%v", len(steps), steps)
}
if !strings.Contains(steps[0].cmd, "setup pair") {
t.Errorf("expected pair command, got %q", steps[0].cmd)
}
}
func TestBuildPlanSteps_MigrateRebootThenPairWhenFresh(t *testing.T) {
summary := &setup.MigrationSummary{TelnetReachable: true, SSHSuccess: false, IsPaired: false}
inspect := &setup.InspectReport{Info: &setup.DeviceInfoXML{DeviceID: "AABBCCDDEEFF"}}
steps := buildPlanSteps("192.168.1.42", "http://aftertouch.local:8000", "", true, false, inspect, summary)
// migrate → reboot → pair. The reboot step exists because envswitch's
// parallel-persistence layer only fully wins on the next boot, and we
// want the new URLs locked in before pairing posts to the speaker.
if len(steps) != 3 {
t.Fatalf("expected migrate+reboot+pair, got %d steps:\n%v", len(steps), steps)
}
if !strings.Contains(steps[0].cmd, "setup migrate") || !strings.Contains(steps[0].cmd, "method=telnet") {
t.Errorf("step 1 should be telnet migrate, got %q", steps[0].cmd)
}
if !strings.Contains(steps[1].cmd, "setup reboot") {
t.Errorf("step 2 should be reboot, got %q", steps[1].cmd)
}
if !strings.Contains(steps[2].cmd, "setup pair") {
t.Errorf("step 3 should be pair, got %q", steps[2].cmd)
}
}
func TestBuildPlanSteps_DNSMethodPrependsCAInstall(t *testing.T) {
// Telnet down, SSH up, CA not yet trusted → plan must install-ca
// before applying the resolv migration.
summary := &setup.MigrationSummary{
TelnetReachable: false,
SSHSuccess: true,
CACertTrusted: false,
IsPaired: false,
}
inspect := &setup.InspectReport{Info: &setup.DeviceInfoXML{DeviceID: "X"}}
steps := buildPlanSteps("192.168.1.42", "http://aftertouch.local:8000", "", false, false, inspect, summary)
if len(steps) < 2 {
t.Fatalf("expected at least install-ca + migrate, got %d steps:\n%v", len(steps), steps)
}
if !strings.Contains(steps[0].cmd, "install-ca") {
t.Errorf("install-ca should come first when DNS method is chosen and CA is not trusted, got %q", steps[0].cmd)
}
if !strings.Contains(steps[1].cmd, "method=resolv") {
t.Errorf("step 2 should be resolv migrate, got %q", steps[1].cmd)
}
}
func TestBuildPlanSteps_ResetModeIncludesManualNetworkSwitches(t *testing.T) {
inspect := &setup.InspectReport{
Info: &setup.DeviceInfoXML{DeviceID: "506583DE4803"},
Network: &models.NetworkInformation{
Interfaces: models.NetworkInterfaces{
Interfaces: []models.NetworkInterface{
{Type: "WIFI_INTERFACE", SSID: "MyHomeNetwork"},
},
},
},
}
summary := &setup.MigrationSummary{IsMigrated: true, IsPaired: true} // doesn't matter in reset mode
steps := buildPlanSteps("192.168.1.42", "http://aftertouch.local:8000", "", true, true, inspect, summary)
// Expected sequence in --reset mode:
// factory-reset, manual AP switch, wait-ap, wifi-push, manual home switch,
// wait-online, migrate, pair (8 steps).
if len(steps) < 7 {
t.Fatalf("expected at least 7 steps in --reset mode, got %d:\n%v", len(steps), steps)
}
manualCount := 0
for _, s := range steps {
if s.manual {
manualCount++
}
}
if manualCount < 2 {
t.Errorf("expected at least 2 manual steps for the Wi-Fi switches, got %d", manualCount)
}
if !strings.Contains(steps[0].cmd, "factory-reset") {
t.Errorf("step 1 must be factory-reset, got %q", steps[0].cmd)
}
// wifi-push step should default to the inspected SSID
foundWiFi := false
for _, s := range steps {
if strings.Contains(s.cmd, "wifi-push") && strings.Contains(s.cmd, "MyHomeNetwork") {
foundWiFi = true
break
}
}
if !foundWiFi {
t.Errorf("expected wifi-push step to default to inspected SSID 'MyHomeNetwork'")
}
// wait-online --match should use the deviceID suffix
foundMatch := false
for _, s := range steps {
if strings.Contains(s.cmd, "wait-online") && strings.Contains(s.cmd, "--match=DE4803") {
foundMatch = true
break
}
}
if !foundMatch {
t.Errorf("expected wait-online step to use --match=DE4803 from deviceID suffix")
}
}
+17
View File
@@ -1312,6 +1312,19 @@ func main() {
},
Before: RequireHost,
},
{
Name: "timezone",
Usage: "Set display timezone (IANA zone, e.g. Europe/Berlin)",
Action: setClockDisplayTimezone,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "tz",
Usage: "IANA timezone identifier (e.g. Europe/Berlin, America/New_York)",
Required: true,
},
},
Before: RequireHost,
},
},
},
},
@@ -2179,6 +2192,10 @@ func main() {
},
}
// Speaker provisioning (factory-reset, Wi-Fi, URL rewrite, pairing).
// Defined in cmd_setup.go to keep the top-level command list readable.
app.Commands = append(app.Commands, setupCommand())
// Sort commands alphabetically (including subcommands and flags recursively)
sortCommands(app.Commands)
+2
View File
@@ -63,6 +63,8 @@
* [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md)
* [Telnet (Port 17000) Migration Method](analysis/TELNET-MIGRATION-METHOD.md)
* [Telnet Command Reference](analysis/TELNET-COMMAND-REFERENCE.md)
* [Setup WebSocket Experiment](analysis/SETUP-WEBSOCKET-EXPERIMENT.md)
* [Factory Reset Protocol](analysis/FACTORY-RESET-PROTOCOL.md)
* [Wiki API Comparison](analysis/WIKI-COMPARISON.md)
* [IoT Config Summary](analysis/IOT-CONFIG-SUMMARY.md)
* [IoT Configuration Analysis](analysis/IOT-CONFIGURATION-ANALYSIS.md)
+136
View File
@@ -0,0 +1,136 @@
# What a SoundTouch speaker does during factory reset
Observed live on ST10 firmware `27.0.6.46330.5043500` (build `epdbuild.trunk.hepdswbld04.2022-08-04`) on 2026-05-12, by running `soundtouch-cli setup factory-reset` and tailing the speaker's `logread` over SSH. The trace is preserved at `_/logs/factory-reset.txt` for reference.
## Sequence
1. **Telnet receives `sys factorydefault`.** The diagnostic shell on port 17000 accepts the command and acknowledges. Some firmwares close the socket as part of the reboot — our CLI's `setup factory-reset` tolerates that as success.
2. **Speaker DELETEs itself from its marge account.** Before wiping anything, the firmware does:
```
[MargeStateAssociated] HandleRemoveDeviceRequest - Removing this device from the user's Marge account
[MargeClient] RemoveDevice calling Marge Server with https://streaming.bose.com/streaming/account/{accountId}/device/{deviceId}
[MargeClient] RemoveDeviceCB - Device removed from the user's Marge account
[MargeStateAssociated] HandleRemoveDeviceRequestSuccessCB, Marge returned: {"ok": true}
```
AfterTouch already handles this — `HandleMargeRemoveDevice` (`pkg/service/handlers/handlers_marge.go:633`) routed via `r.Delete("/device/{device}", …)` in `cmd/soundtouch-service/main.go:955`. The handler calls `marge.RemoveDeviceFromAccount(s.ds, account, device)` and prunes the device from the datastore.
3. **Speaker notifies its LAN peers.** Two HTTP POSTs to each known peer at `:8090/notification`:
```
[NotificationSender] SendNotifyLisas_: URL: >>http://192.168.123.122:8090/notification<<, m_msgdata.size(58)
[SimpleURLFetcher] multipart/form-data text/xml
```
~58 bytes of `multipart/form-data` carrying `text/xml`. "Lisas" is the firmware's internal term for LAN peers (devices on the same account on the same network segment). AfterTouch is **not** on this path — it's pure peer-to-peer over the LAN. Peers presumably refresh their account info as a result.
4. **Local state teardown.** Bluetooth pairings cleared (`BTRemoteDeviceAccess::ClearPrevPairedList`), zone/group state torn down, all source proxies disconnected (`STSAccountProxy::Disconnect Requested` × many).
5. **Persistence cleanup.** Logs, core dumps wiped (`FactoryDefault: Clearing the CoreDump and BoseLogs … rm -rf /mnt/nv/BoseLog/*`). Notably **NOT wiped**: `/mnt/nv/aftertouch.resolv.conf`, `/mnt/nv/rc.local`'s Aftertouch hook, and `/mnt/nv/BoseApp-Persistence/1/SystemConfigurationDB.xml`. The reset only touches log directories and account-specific persistence under the same `/mnt/nv/BoseApp-Persistence/1/` tree.
6. **Reboot into setup mode.** Speaker drops Wi-Fi, comes back as its own AP `Bose SoundTouch XXXX` on 192.0.2.1.
## Implications for migration ordering
The DELETE in step 2 only reaches AfterTouch if the speaker's `margeURL` already points at AfterTouch *at the moment of reset*. A speaker still pointing at `streaming.bose.com` sends it into the void → AfterTouch keeps a stale `account/{id}/device/{id}` entry until someone manually prunes it.
Therefore for a clean datastore lifecycle on an already-Bose-paired speaker:
1. Migrate URLs first (`setup migrate --method=resolv` or `--method=telnet`).
2. Reboot to apply.
3. Factory reset.
4. Re-provision.
`soundtouch-cli setup plan --reset` currently runs factory-reset first (optimal for already-on-AfterTouch speakers); both `setup plan --reset` and `setup factory-reset` print a one-line note explaining the ordering tradeoff so users can pick the right sequence for their starting state.
## Implications for AfterTouch behaviour
- The DELETE handler is already correct; no changes needed.
- AfterTouch is invisible to the LAN-peer notification step — that's just LAN HTTP between speakers.
- If you build a "consolidate account" / "migrate fleet" feature later, the peer-notification channel is the propagation path the firmware uses internally; AfterTouch doesn't need to do anything analogous.
- The persistence layer at `/mnt/nv/` is **factory-reset-resistant**. Our DNS-redirect migration (`setup migrate --method=resolv`) writes there specifically so AfterTouch routing survives a reset. This is intentional — the user can factory-reset a speaker freely without re-running migration.
## Open questions
- Are there other peer endpoints the firmware POSTs to besides `:8090/notification`? Worth checking on a 3-speaker LAN.
- Does the `:8090/notification` payload format match the format used for play-as-notification audio pushes, or is it a distinct message shape? The "size(58)" byte count is too small for an audio URL but big enough for an XML envelope with an event type.
If you want either of these answered, capture two synchronised `logread -f` streams from two LAN speakers while one is being reset.
## Runbook — reset & re-provision an ST10 on AfterTouch
End-to-end command sequence used during the 2026-05-12 bare-pairing experiment, recorded verbatim from the test session. Replace IPs, SSID, password, service URL, and account ID with your own. Two manual Wi-Fi switches happen between `factory-reset` and `wifi-push` (host joins the speaker's AP) and again between `wifi-push` and `wait-online` (host re-joins home Wi-Fi).
```bash
# === 1. Reconnaissance — confirm what state the speaker is in before touching it. ===
# Identity, network, sources, presets.
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup inspect
# Green/red status across every migration axis (SSH, telnet, CA, pairing, …).
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup verify \
--service-url=https://soundtouch.fritz.box
# What `setup plan --reset` would recommend, so you can preview the sequence.
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup plan \
--service-url=https://soundtouch.fritz.box --reset
# === 2. Reset and Wi-Fi re-provisioning. ===
# Tell the speaker to wipe itself. Speaker drops Wi-Fi and reboots into AP mode.
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup factory-reset
# Manual: switch this host to the speaker's setup AP.
# macOS: networksetup -setairportnetwork en0 "Bose SoundTouch XXXX"
# Poll 192.0.2.1:8090/info until the speaker answers (interval=2s, timeout=5m).
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup wait-ap
# Push home Wi-Fi credentials. NOTE the single-quoted password: zsh expands `!`
# inside double quotes as history-expansion and will refuse the command.
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup wifi-push \
--ssid="wifi-name" --pass='a.secure!password'
# Manual: switch host back to home Wi-Fi.
# macOS: networksetup -setairportnetwork en0 "wifi-name" 'a.secure!password'
# mDNS-poll for the speaker on the home network, matched by deviceID suffix
# (which survives the reset since it's the MAC). Returns the new IP.
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup wait-online --match=536A98
# === 3. Clock, migrate, pair. From here on use the new IP wait-online reported. ===
# Set the speaker's wall-clock. `clock set --time=now` fails on FW 27;
# `clock now` is the working subcommand.
go run ./cmd/soundtouch-cli --host 192.168.123.123 clock now
# Reboot to clear any half-initialized resolver / NTP state from the wifi-push flap.
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup reboot
# Apply DNS-redirect migration: routes *.bose.com to AfterTouch and installs its CA.
# Idempotent; safe to re-run.
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup migrate \
--service-url=https://soundtouch.fritz.box --method=resolv
# Reboot again so the envswitch parallel-persistence layer and the resolv hook
# both take effect on the next boot.
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup reboot
# Pair the device with an AfterTouch account — bare experiment variant.
# Drop --mode=bare and add --name=… / --language=… for the full state-machine variant.
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup pair \
--mode=bare --account=1111111 --service-url='https://soundtouch.fritz.box'
# === 4. Verify. ===
# Reboot to verify persistence survives.
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup reboot
# Snapshot the result. margeAccountUUID should still equal --account, and Sources
# should list ~14 entries (TUNEIN, RADIO_BROWSER, LOCAL_INTERNET_RADIO,
# SPOTIFY slots, AIRPLAY, etc.) materialized by the firmware.
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup inspect
```
Total wall-clock for the above on this hardware: roughly 5 minutes including the two manual Wi-Fi switches and three reboots.
+215
View File
@@ -0,0 +1,215 @@
# Experiment: Does bare `setMargeAccount` work outside the SETUP bracket?
## Why we are doing this
Our captured pairing flow (`docs/reference/DEVICE-PAIRING-FLOW.md`) shows the official Bose app always sends `setMargeAccount` *inside* a `SETUP_START``SETUP_ENTER``SETUP_LEAVE` state-machine bracket over WebSocket. The question this experiment answers:
> If we open a WebSocket to a factory-reset speaker and send **only** `setMargeAccount` — no surrounding setupState messages — does the device honor it and write its persistence files (`SystemConfigurationDB.xml`, `Sources.xml`) cleanly?
The answer determines the shape of `PairAccount`:
- **If YES:** `PairAccount` becomes uniform: WebSocket-first, HTTP `/setMargeAccount` second, telnet `envswitch accountid set` third. One function, one ordering, all callers.
- **If NO:** WebSocket pairing is only meaningful inside the full state machine. Factory-reset path uses the state machine; re-pair path keeps today's HTTP→telnet ordering.
## Preconditions
- A SoundTouch speaker that has been **factory-reset** and joined to the test Wi-Fi.
- Speaker reachable on `:8090` (HTTP API) and `:8080` (WebSocket).
- Speaker's runtime marge URL already points at AfterTouch (run the existing telnet URL rewrite first — otherwise the device's downstream POST will land on the dead Bose cloud and we will not be able to distinguish "WS message refused" from "downstream cloud failed").
- A free 7-digit account ID — for example, generated via `setup.GenerateAccountID(nil)`.
## Step 0 — Baseline
```bash
DEVICE=192.168.x.x
curl -s http://$DEVICE:8090/info | xmllint --format -
curl -s http://$DEVICE:8090/sources | xmllint --format -
curl -s http://$DEVICE:8090/presets | xmllint --format -
```
Record:
- `<margeAccountUUID>` — expect empty on a factory-reset device.
- `<margeURL>` — expect the AfterTouch URL (preflight already applied).
- `<sources>` — expect a minimal list.
- `<presets>` — expect `<presets/>`.
## Step 1 — Send bare `setMargeAccount` over WebSocket
Build the CLI once:
```bash
make build
```
Then run the bare path against the speaker:
```bash
DEVICE=192.168.x.x
./build/soundtouch-cli setup pair --host=$DEVICE --account=1234567 --mode=bare
```
What it does:
1. Reads `/info` to discover `deviceID`, logs the pre-state.
2. Opens a WebSocket to `$DEVICE:8080` with the `gabbo` subprotocol.
3. Sends exactly one frame — the `setMargeAccount` envelope — **without** any preceding `SETUP_START`/`SETUP_ENTER`.
4. Reads frames for up to `--step-timeout=8s` (configurable), looking for an ack referencing our `requestID`.
5. Closes the WebSocket, waits 2 s, re-reads `/info`, prints whether `margeAccountUUID` now equals our supplied ID.
The exact frame sent (built by `setup.SetupSession.SetMargeAccount`):
```xml
<msg><header deviceID="DEVICE_ID" url="setMargeAccount" method="POST"><request requestID="1"/></header><body>
<PairDeviceWithAccount>
<accountId>1234567</accountId>
<userAuthToken>Bearer aftertouch</userAuthToken>
</PairDeviceWithAccount>
</body></msg>
```
Outcomes the CLI will surface:
- `Device accepted bare pairing.` (post-`/info` shows our ID) → **bare path works**.
- `setMargeAccount: device rejected setMargeAccount: …` → device returned an `<error>` body → **bare path refused explicitly**.
- `setMargeAccount: await ack for setMargeAccount: …` (timeout or EOF) → **bare path refused silently**.
- `Device did NOT persist the pairing — bare path likely refused silently.` → ack received but persistence didn't follow.
## Step 2 — Record outcome
After step 1 (regardless of which branch happened):
```bash
sleep 2
curl -s http://$DEVICE:8090/info | grep margeAccountUUID
```
| Observed result | Verdict |
|------------------------------------------------------------------------------|-----------------------------|
| `<margeAccountUUID>1234567</margeAccountUUID>` appears | **YES** — Option 1 wins |
| `<margeAccountUUID></margeAccountUUID>` still empty, no error frame received | Refused silently → **NO** |
| Error frame returned (e.g. `<error name="UNSUPPORTED_STATE"/>`) | Refused explicitly → **NO** |
| Device drops the WebSocket connection without replying | Refused → **NO** |
If verdict is YES, also verify the device wrote persistence cleanly. Reboot the device, then:
```bash
ssh root@$DEVICE 'cat /mnt/nv/BoseApp-Persistence/1/SystemConfigurationDB.xml'
ssh root@$DEVICE 'cat /mnt/nv/BoseApp-Persistence/1/Sources.xml'
curl -s http://$DEVICE:8090/info | grep margeAccountUUID
```
The UUID must still be present after reboot, and `SystemConfigurationDB.xml` must contain `<AccountUUID>1234567</AccountUUID>`. If it survives reboot, **YES** is confirmed.
## Step 3 — Control: full state machine
Factory-reset the same speaker again and run the full state machine — the same CLI, `--mode=full`:
```bash
./build/soundtouch-cli setup pair --host=$DEVICE --account=1234567 --mode=full
```
This drives `setup.Manager.ExecuteInitPlan` with `SkipURLRewrite=true`, which runs:
```
SETUP_START
SETUP_IDENTIFY_DEVICE_ENTER
language sysLanguage=2
SETUP_ENTER
SETUP_IDENTIFY_DEVICE_LEAVE
setMargeAccount …
SETUP_LEAVE
pushCustomerSupportInfoToMarge
```
The CLI logs every step with status. Confirm `/info`, persistence, and reboot-survival checks pass. If the bare path failed but the full path succeeds, the SETUP bracket is load-bearing — a follow-up bisect (e.g. `SETUP_START + setMargeAccount + SETUP_LEAVE` only) tells us *which* surrounding messages the firmware actually requires.
## Full reset-and-rebuild loop
Once the bare/full question is decided, the loop for repeated experiments is:
```bash
# 0. Speaker is currently on home Wi-Fi at $DEVICE.
# Capture deviceID-suffix + current SSID first so wait-online and
# wifi-push have the right inputs.
./build/soundtouch-cli setup inspect --host=$DEVICE
./build/soundtouch-cli setup factory-reset --host=$DEVICE
# 1. Manually switch this host to the speaker's AP (Bose SoundTouch XXXX).
# macOS: networksetup -setairportnetwork en0 "Bose SoundTouch XXXX"
./build/soundtouch-cli setup wait-ap
./build/soundtouch-cli setup wifi-push --ssid="$HOME_SSID" --pass="$HOME_PASS"
# 2. Manually switch this host back to home Wi-Fi.
./build/soundtouch-cli setup wait-online --match=DE4803 # deviceID suffix from /info before reset
# (note the new IP from the "Speaker discovered" line)
NEW_IP=192.168.x.y
./build/soundtouch-cli setup migrate --host=$NEW_IP --service-url=http://aftertouch.local:8000 # default --method=telnet
# Optional, if you want the DNS-redirect path instead of (or alongside) telnet envswitch:
# 1. ./build/soundtouch-cli setup ssh-check --host=$NEW_IP # USB-stick procedure if 22 is closed
# 2. ./build/soundtouch-cli setup install-ca --host=$NEW_IP --service-url=http://aftertouch.local:8000
# 3. ./build/soundtouch-cli setup migrate --host=$NEW_IP --service-url=http://aftertouch.local:8000 --method=resolv
./build/soundtouch-cli setup pair --host=$NEW_IP --mode=bare # or --mode=full
```
The two manual lines are user-side Wi-Fi switches that can't be automated portably. The `wait-ap` and `wait-online` subcommands poll for the corresponding network state, so timing them is hands-off.
## Recording the result
Append to this file under `## Results`:
```
- Date: YYYY-MM-DD
- Firmware: 27.x.x
- Model: ST10 / ST20 / ST30 / ST300
- Bare setMargeAccount accepted: yes/no
- Persistence written: yes/no
- Survives reboot: yes/no
- Notes: ...
```
One row per device tested. Once two devices on different firmware confirm the same verdict, we treat it as decided.
## Results
- Date: 2026-05-13
- Firmware: 27.0.6.46330.5043500 (build epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29)
- Model: SoundTouch 10 (deviceID A81B6A536A98)
- Bare setMargeAccount accepted: **yes** — pre-/info margeAccountUUID="" → post-/info margeAccountUUID="1111111"
- Persistence written: **yes** — device materialized 14-entry Sources.xml on its own
- Survives reboot: **yes**`setup inspect` after `setup reboot` shows margeAccountUUID still 1111111
- Notes: After bare pairing, the speaker did the full post-pairing handshake against AfterTouch (POST /streaming/support/power_on, GET /streaming/sourceproviders, GET /streaming/account/{id}/full, group/, provider_settings). No SETUP_START/SETUP_ENTER/SETUP_LEAVE was ever sent. Verdict: bare path is functionally equivalent to the full state machine on this firmware.
### Implication for the codebase
- `pkg/service/setup/setup_session.go` keeps the full state machine for completeness, but
- `pkg/service/setup/init_plan.go`'s default could be simplified to "send setMargeAccount only" once we have one more confirming run on a different model.
- The OCT issue-167 SSH-XML seeding workaround is **not required**.
### Appendix — SystemConfigurationDB.xml comparison
Post-experiment we compared the device-written `/mnt/nv/BoseApp-Persistence/1/SystemConfigurationDB.xml` from the bare-paired speaker against two SSH backups taken from speakers originally paired by the official Bose app (account 3230304, devices `A_Sound_Machine` and `Sound_Machinechen`). The diff is much smaller than expected — only two fields differ, and neither is set by the pairing protocol itself:
| Field | Bare-paired (1111111) | Real-Bose-paired (3230304) | Set by |
|--------------------------|--------------------------------------------|----------------------------|-----------------------------------------------------------------------------------------------------------|
| `DeviceName` | `Bose SoundTouch 536A98` (factory default) | `Sound Machinechen` | `name` WS message — only sent in `--mode=full` |
| `AccountAssociatedEMail` | empty | **empty** | Never populated, even by real Bose |
| `AccountUUID` | `1111111` | `3230304` | `setMargeAccount` — both paths set it |
| `Locale` | empty | **empty** | Never populated, even by real Bose |
| `acctMode` | `global` | `global` | Firmware-default; no protocol path observed to change it |
| `isMultiDeviceAccount` | `false` | `true` | Derived from the cloud's `/streaming/account/{id}/full` response — count of `<devices>` > 1 flips it true |
| `margeAuthServerToken` | empty | **empty** | Never populated, even by real Bose |
| `Password` | (encrypted blob) | (encrypted blob) | Device-local key; expected to differ |
Three of the seven informational fields are empty even after a real-Bose pairing — the firmware simply doesn't populate `AccountAssociatedEMail`, `Locale`, or `margeAuthServerToken` from the pairing flow. So bare pairing isn't missing any field that real pairing fills.
The two genuinely different fields:
- **`DeviceName`** — pure UX. Settable any time post-pair via `name` POST (`soundtouch-cli name set --value=…`) or by sending the `name` WS message during `--mode=full` pairing.
- **`isMultiDeviceAccount`** — not a pairing concern. It's derived from the account's device count on AfterTouch's side; flips to `true` automatically the next time the speaker refreshes account state if a second speaker has been paired to the same account.
So the experiment's YES verdict stands unqualified: bare `setMargeAccount` produces a `SystemConfigurationDB.xml` functionally equivalent to one written by the official pairing flow.
+2 -5
View File
@@ -95,9 +95,7 @@ func TestClient_SetClockTime(t *testing.T) {
{
name: "Successful clock time set",
request: &models.ClockTimeRequest{
UTC: 1609459200,
Value: "2021-01-01 00:00:00",
Zone: "UTC",
UTCTime: 1609459200,
},
statusCode: http.StatusOK,
expectError: false,
@@ -112,8 +110,7 @@ func TestClient_SetClockTime(t *testing.T) {
{
name: "Server error",
request: &models.ClockTimeRequest{
UTC: 1609459200,
Value: "2021-01-01 00:00:00",
UTCTime: 1609459200,
},
statusCode: http.StatusInternalServerError,
expectError: true,
+199 -16
View File
@@ -2,20 +2,142 @@ package models
import (
"encoding/xml"
"errors"
"fmt"
"io"
"strconv"
"strings"
)
// ClockDisplay represents the device's clock display settings
// ClockDisplay represents the device's clock display settings.
//
// Wire format (confirmed against ST10/ST20 firmware 27.0.6 — flat
// attributes on the outer <clockDisplay> are rejected with
// "Error parsing request"):
//
// <clockDisplay deviceID="…">
// <clockConfig timezoneInfo="Europe/Berlin"
// userEnable="true"
// timeFormat="TIME_FORMAT_24HOUR_ID"
// userOffsetMinute="0"
// brightnessLevel="70"
// userUtcTime="0"/>
// </clockDisplay>
//
// The struct keeps its historical flat-field public API so the CLI and
// other callers don't have to be rewritten; custom MarshalXML /
// UnmarshalXML methods bridge to the nested format on the wire.
type ClockDisplay struct {
XMLName xml.Name `xml:"clockDisplay"`
DeviceID string `xml:"deviceID,attr,omitempty"`
Enabled bool `xml:"enabled,attr,omitempty"`
Format string `xml:"format,attr,omitempty"`
Brightness int `xml:"brightness,attr,omitempty"`
AutoDim bool `xml:"autoDim,attr,omitempty"`
TimeZone string `xml:"timeZone,attr,omitempty"`
Value string `xml:",chardata"`
XMLName xml.Name `xml:"-"`
DeviceID string
Enabled bool
Format string // public-facing values: "12", "24", "auto"
Brightness int
AutoDim bool // not on the device's wire format; preserved for API compat
TimeZone string
Value string // kept for API compat — older fixtures stored chardata here
}
// Wire constants for clockConfig/@timeFormat.
const (
wireTimeFormat12Hour = "TIME_FORMAT_12HOUR_ID"
wireTimeFormat24Hour = "TIME_FORMAT_24HOUR_ID"
wireTimeFormatAuto = "TIME_FORMAT_AUTO_ID"
)
func mapToWireFormat(f string) string {
switch strings.ToLower(f) {
case "12":
return wireTimeFormat12Hour
case "24":
return wireTimeFormat24Hour
case "auto":
return wireTimeFormatAuto
default:
return ""
}
}
func mapFromWireFormat(wire string) string {
switch wire {
case wireTimeFormat12Hour:
return "12"
case wireTimeFormat24Hour:
return "24"
case wireTimeFormatAuto:
return "auto"
default:
return ""
}
}
// UnmarshalXML decodes the nested <clockDisplay><clockConfig …/></clockDisplay>
// into ClockDisplay's flat fields. Tolerates the older flat shape too —
// either because it appears in legacy captures or for forward-compat with
// firmwares that may revert.
func (c *ClockDisplay) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
for _, attr := range start.Attr {
switch attr.Name.Local {
case "deviceID":
c.DeviceID = attr.Value
case "enabled":
c.Enabled = attr.Value == "true"
case "format":
c.Format = attr.Value
case "brightness":
c.Brightness, _ = strconv.Atoi(attr.Value)
case "autoDim":
c.AutoDim = attr.Value == "true"
case "timeZone":
c.TimeZone = attr.Value
}
}
for {
tok, err := d.Token()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return err
}
switch t := tok.(type) {
case xml.StartElement:
if t.Name.Local == "clockConfig" {
for _, attr := range t.Attr {
switch attr.Name.Local {
case "timezoneInfo":
c.TimeZone = attr.Value
case "userEnable":
c.Enabled = attr.Value == "true"
case "timeFormat":
if mapped := mapFromWireFormat(attr.Value); mapped != "" {
c.Format = mapped
}
case "brightnessLevel":
c.Brightness, _ = strconv.Atoi(attr.Value)
}
}
}
if err := d.Skip(); err != nil {
return err
}
case xml.CharData:
text := strings.TrimSpace(string(t))
if text != "" {
c.Value = text
}
case xml.EndElement:
return nil
}
}
return nil
}
// ClockFormat represents supported clock display formats
@@ -108,14 +230,16 @@ func (c *ClockDisplay) IsEmpty() bool {
return !c.Enabled && c.Format == "" && c.Brightness == 0 && c.TimeZone == ""
}
// ClockDisplayRequest represents a request to configure clock display settings
// ClockDisplayRequest represents a request to configure clock display
// settings. Fields use the same public names as the response struct;
// MarshalXML produces the nested wire format the device requires.
type ClockDisplayRequest struct {
XMLName xml.Name `xml:"clockDisplay"`
Enabled *bool `xml:"enabled,attr,omitempty"`
Format string `xml:"format,attr,omitempty"`
Brightness *int `xml:"brightness,attr,omitempty"`
AutoDim *bool `xml:"autoDim,attr,omitempty"`
TimeZone string `xml:"timeZone,attr,omitempty"`
XMLName xml.Name `xml:"-"`
Enabled *bool
Format string
Brightness *int
AutoDim *bool
TimeZone string
}
// NewClockDisplayRequest creates a new clock display configuration request
@@ -184,3 +308,62 @@ func (r *ClockDisplayRequest) Validate() error {
func (r *ClockDisplayRequest) HasChanges() bool {
return r.Enabled != nil || r.Format != "" || r.Brightness != nil || r.AutoDim != nil || r.TimeZone != ""
}
// MarshalXML emits the nested <clockDisplay><clockConfig …/></clockDisplay>
// envelope the device accepts. Empty fields are omitted so partial updates
// (e.g. "set only the timezone") don't accidentally clear other settings.
//
// AutoDim has no counterpart in the captured wire format; we still accept
// it in the public API for backward-compat but it is not emitted.
func (r ClockDisplayRequest) MarshalXML(e *xml.Encoder, _ xml.StartElement) error {
display := xml.StartElement{Name: xml.Name{Local: "clockDisplay"}}
if err := e.EncodeToken(display); err != nil {
return err
}
cfg := xml.StartElement{Name: xml.Name{Local: "clockConfig"}}
if r.TimeZone != "" {
cfg.Attr = append(cfg.Attr, xml.Attr{
Name: xml.Name{Local: "timezoneInfo"},
Value: r.TimeZone,
})
}
if r.Enabled != nil {
cfg.Attr = append(cfg.Attr, xml.Attr{
Name: xml.Name{Local: "userEnable"},
Value: strconv.FormatBool(*r.Enabled),
})
}
if r.Format != "" {
if wire := mapToWireFormat(r.Format); wire != "" {
cfg.Attr = append(cfg.Attr, xml.Attr{
Name: xml.Name{Local: "timeFormat"},
Value: wire,
})
}
}
if r.Brightness != nil {
cfg.Attr = append(cfg.Attr, xml.Attr{
Name: xml.Name{Local: "brightnessLevel"},
Value: strconv.Itoa(*r.Brightness),
})
}
if err := e.EncodeToken(cfg); err != nil {
return err
}
if err := e.EncodeToken(xml.EndElement{Name: cfg.Name}); err != nil {
return err
}
if err := e.EncodeToken(xml.EndElement{Name: display.Name}); err != nil {
return err
}
return e.Flush()
}
+51 -2
View File
@@ -641,7 +641,7 @@ func TestClockDisplayRequest_MarshalXML(t *testing.T) {
Enabled: &[]bool{true}[0],
Format: "24",
Brightness: &[]int{75}[0],
AutoDim: &[]bool{false}[0],
AutoDim: &[]bool{false}[0], // not on the wire format — must be silently dropped
TimeZone: "America/New_York",
}
@@ -650,8 +650,57 @@ func TestClockDisplayRequest_MarshalXML(t *testing.T) {
t.Fatalf("Failed to marshal XML: %v", err)
}
expected := `<clockDisplay enabled="true" format="24" brightness="75" autoDim="false" timeZone="America/New_York"></clockDisplay>`
// Must match the device's captured POST shape — firmware 27 rejects
// the legacy flat <clockDisplay enabled="…" format="…" .../> with
// "Error parsing request".
expected := `<clockDisplay><clockConfig timezoneInfo="America/New_York" userEnable="true" timeFormat="TIME_FORMAT_24HOUR_ID" brightnessLevel="75"></clockConfig></clockDisplay>`
if string(data) != expected {
t.Errorf("Expected XML %q, got %q", expected, string(data))
}
}
func TestClockDisplayRequest_MarshalXML_TimezoneOnly(t *testing.T) {
// Partial update: only set the timezone. Unset fields must be
// omitted so we don't clobber the device's other settings.
request := ClockDisplayRequest{TimeZone: "Europe/Berlin"}
data, err := xml.Marshal(request)
if err != nil {
t.Fatalf("Failed to marshal XML: %v", err)
}
expected := `<clockDisplay><clockConfig timezoneInfo="Europe/Berlin"></clockConfig></clockDisplay>`
if string(data) != expected {
t.Errorf("Expected XML %q, got %q", expected, string(data))
}
}
func TestClockDisplay_UnmarshalXML_NestedClockConfig(t *testing.T) {
// The real wire format — what firmware-27 devices emit and accept.
xmlData := `<clockDisplay deviceID="A81B6A536A98"><clockConfig timezoneInfo="Europe/Berlin" userEnable="true" timeFormat="TIME_FORMAT_24HOUR_ID" userOffsetMinute="0" brightnessLevel="70" userUtcTime="0"/></clockDisplay>`
var got ClockDisplay
if err := xml.Unmarshal([]byte(xmlData), &got); err != nil {
t.Fatalf("Failed to unmarshal: %v", err)
}
if got.DeviceID != "A81B6A536A98" {
t.Errorf("DeviceID = %q, want A81B6A536A98", got.DeviceID)
}
if got.TimeZone != "Europe/Berlin" {
t.Errorf("TimeZone = %q, want Europe/Berlin", got.TimeZone)
}
if !got.Enabled {
t.Error("Enabled = false, want true (from userEnable=true)")
}
if got.Format != "24" {
t.Errorf("Format = %q, want 24 (from timeFormat=TIME_FORMAT_24HOUR_ID)", got.Format)
}
if got.Brightness != 70 {
t.Errorf("Brightness = %d, want 70 (from brightnessLevel)", got.Brightness)
}
}
+25 -25
View File
@@ -182,44 +182,44 @@ func (c *ClockTime) SetUTC(utc int64) {
}
}
// ClockTimeRequest represents a request to set the device time
// ClockTimeRequest represents a request to set the device time.
//
// The POST body mirrors the device's GET /clockTime response shape —
// firmware 27 expects `utcTime` as the attribute name, not `utc`, and
// rejects any chardata or zone attribute with "Error parsing request"
// (confirmed against ST10/ST20/ST30 in live testing 2026-05-12).
//
// We deliberately do NOT send TimeFormat / Brightness in the request:
// those belong to /clockDisplay and including them here either gets
// ignored or rejected depending on firmware revision.
type ClockTimeRequest struct {
XMLName xml.Name `xml:"clockTime"`
Zone string `xml:"zone,attr,omitempty"`
UTC int64 `xml:"utc,attr,omitempty"`
Value string `xml:",chardata"`
UTCTime int64 `xml:"utcTime,attr"`
}
// NewClockTimeRequest creates a new clock time request from a time.Time
// NewClockTimeRequest creates a new clock time request from a time.Time.
// The input may be in any zone — we always send Unix-seconds, which the
// device interprets as UTC and renders according to its own clockDisplay
// configuration.
func NewClockTimeRequest(t time.Time) *ClockTimeRequest {
return &ClockTimeRequest{
Zone: t.Location().String(),
UTC: t.Unix(),
Value: t.UTC().Format("2006-01-02 15:04:05"),
}
return &ClockTimeRequest{UTCTime: t.Unix()}
}
// NewClockTimeRequestUTC creates a new clock time request from UTC timestamp
// NewClockTimeRequestUTC creates a new clock time request from a Unix
// timestamp in seconds.
func NewClockTimeRequestUTC(utc int64) *ClockTimeRequest {
t := time.Unix(utc, 0).UTC()
return &ClockTimeRequest{
UTC: utc,
Value: t.Format("2006-01-02 15:04:05"),
}
return &ClockTimeRequest{UTCTime: utc}
}
// Validate checks if the clock time request is valid
// Validate checks if the clock time request is valid.
func (r *ClockTimeRequest) Validate() error {
if r.UTC <= 0 && r.Value == "" {
return fmt.Errorf("either UTC timestamp or time value must be provided")
if r.UTCTime <= 0 {
return fmt.Errorf("UTC timestamp must be provided")
}
if r.UTC > 0 {
// Validate UTC timestamp is reasonable (after year 2000, before year 2100)
if r.UTC < 946684800 || r.UTC > 4102444800 {
return fmt.Errorf("UTC timestamp %d is outside reasonable range", r.UTC)
}
// Plausibility window: after year 2000, before year 2100.
if r.UTCTime < 946684800 || r.UTCTime > 4102444800 {
return fmt.Errorf("UTC timestamp %d is outside reasonable range", r.UTCTime)
}
return nil
+14 -50
View File
@@ -284,16 +284,8 @@ func TestNewClockTimeRequest(t *testing.T) {
request := NewClockTimeRequest(testTime)
if request.UTC != testTime.Unix() {
t.Errorf("Expected UTC %d, got %d", testTime.Unix(), request.UTC)
}
if request.Value != "2021-01-01 12:00:00" {
t.Errorf("Expected Value %q, got %q", "2021-01-01 12:00:00", request.Value)
}
if request.Zone != "UTC" {
t.Errorf("Expected Zone %q, got %q", "UTC", request.Zone)
if request.UTCTime != testTime.Unix() {
t.Errorf("Expected UTCTime %d, got %d", testTime.Unix(), request.UTCTime)
}
}
@@ -302,13 +294,8 @@ func TestNewClockTimeRequestUTC(t *testing.T) {
request := NewClockTimeRequestUTC(utcTimestamp)
if request.UTC != utcTimestamp {
t.Errorf("Expected UTC %d, got %d", utcTimestamp, request.UTC)
}
expectedValue := time.Unix(utcTimestamp, 0).UTC().Format("2006-01-02 15:04:05")
if request.Value != expectedValue {
t.Errorf("Expected Value %q, got %q", expectedValue, request.Value)
if request.UTCTime != utcTimestamp {
t.Errorf("Expected UTCTime %d, got %d", utcTimestamp, request.UTCTime)
}
}
@@ -319,25 +306,8 @@ func TestClockTimeRequest_Validate(t *testing.T) {
wantErr bool
}{
{
name: "Valid UTC request",
request: ClockTimeRequest{
UTC: 1609459200,
},
wantErr: false,
},
{
name: "Valid value request",
request: ClockTimeRequest{
Value: "2021-01-01 12:00:00",
},
wantErr: false,
},
{
name: "Valid request with both",
request: ClockTimeRequest{
UTC: 1609459200,
Value: "2021-01-01 12:00:00",
},
name: "Valid UTC request",
request: ClockTimeRequest{UTCTime: 1609459200},
wantErr: false,
},
{
@@ -346,17 +316,13 @@ func TestClockTimeRequest_Validate(t *testing.T) {
wantErr: true,
},
{
name: "UTC too old",
request: ClockTimeRequest{
UTC: 946684799, // Before year 2000
},
name: "UTC too old",
request: ClockTimeRequest{UTCTime: 946684799}, // Before year 2000
wantErr: true,
},
{
name: "UTC too far in future",
request: ClockTimeRequest{
UTC: 4102444801, // After year 2100
},
name: "UTC too far in future",
request: ClockTimeRequest{UTCTime: 4102444801}, // After year 2100
wantErr: true,
},
}
@@ -377,18 +343,16 @@ func TestClockTimeRequest_Validate(t *testing.T) {
}
func TestClockTimeRequest_MarshalXML(t *testing.T) {
request := ClockTimeRequest{
Zone: "UTC",
UTC: 1609459200,
Value: "2021-01-01 00:00:00",
}
request := ClockTimeRequest{UTCTime: 1609459200}
data, err := xml.Marshal(request)
if err != nil {
t.Fatalf("Failed to marshal XML: %v", err)
}
expected := `<clockTime zone="UTC" utc="1609459200">2021-01-01 00:00:00</clockTime>`
// Must match the device's GET /clockTime response attribute name
// — firmware 27 rejects `utc=` (no Time suffix) with "Error parsing request".
expected := `<clockTime utcTime="1609459200"></clockTime>`
if string(data) != expected {
t.Errorf("Expected XML %q, got %q", expected, string(data))
}
+68
View File
@@ -0,0 +1,68 @@
package setup
import (
"testing"
)
func TestBuildServerHTTPSURL_PortResolution(t *testing.T) {
// HTTPS_PORT must be unset for the env-var path tests to be
// meaningful. t.Setenv("HTTPS_PORT", "") clears it for the duration
// of each subtest.
tests := []struct {
name string
targetURL string
envHTTPSPort string
want string
}{
{
name: "https with explicit port wins over HTTPS_PORT env",
targetURL: "https://soundtouch.fritz.box:443",
envHTTPSPort: "8443",
want: "https://soundtouch.fritz.box:443/health",
},
{
name: "https without explicit port uses 443",
targetURL: "https://soundtouch.fritz.box",
want: "https://soundtouch.fritz.box:443/health",
},
{
name: "http URL falls back to HTTPS_PORT env var",
targetURL: "http://aftertouch.local:8000",
envHTTPSPort: "9443",
want: "https://aftertouch.local:9443/health",
},
{
name: "http URL with no env var defaults to 8443",
targetURL: "http://aftertouch.local:8000",
want: "https://aftertouch.local:8443/health",
},
{
name: "invalid URL returns empty",
targetURL: "::not-a-url",
want: "",
},
{
name: "URL with no hostname returns empty",
targetURL: "http://",
want: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if tc.envHTTPSPort != "" {
t.Setenv("HTTPS_PORT", tc.envHTTPSPort)
} else {
t.Setenv("HTTPS_PORT", "")
}
m := &Manager{}
got := m.buildServerHTTPSURL(tc.targetURL)
if got != tc.want {
t.Errorf("buildServerHTTPSURL(%q) = %q, want %q", tc.targetURL, got, tc.want)
}
})
}
}
+73
View File
@@ -0,0 +1,73 @@
package setup
import (
"errors"
"fmt"
"strings"
)
// FactoryReset issues `sys factorydefault` over the device's port-17000
// diagnostic shell. The device wipes its persistent state (account
// pairing, Wi-Fi credentials, presets, source configuration) and reboots
// into setup mode — broadcasting its own `Bose SoundTouch XXXX` access
// point on 192.0.2.1.
//
// After this call the device is unreachable on the home network until
// the caller pushes new Wi-Fi credentials via PushWiFiCredentials (see
// wifi_provision.go).
func (m *Manager) FactoryReset(deviceIP string) (string, error) {
if m.NewTelnet == nil {
return "", errors.New("FactoryReset: Manager.NewTelnet is nil")
}
var logs strings.Builder
t := m.NewTelnet(deviceIP)
if err := t.Dial(); err != nil {
return logs.String(), fmt.Errorf("telnet dial %s:17000: %w", deviceIP, err)
}
defer func() { _ = t.Close() }()
banner, _ := t.Probe()
if banner != "" {
fmt.Fprintf(&logs, "Telnet banner: %q\n", strings.TrimSpace(banner))
}
resp, err := t.SendCommand("sys factorydefault")
if err != nil {
// A graceful close right after the command is normal — the device
// reboots immediately. We treat "connection closed" responses as
// success rather than failure.
if isExpectedDisconnect(err) {
fmt.Fprintf(&logs, "→ sys factorydefault\n(device disconnected — reset accepted)\n")
return logs.String(), nil
}
return logs.String(), fmt.Errorf("sys factorydefault: %w", err)
}
fmt.Fprintf(&logs, "→ sys factorydefault\n%s\n", strings.TrimRight(resp, "\r\n"))
if isCommandNotFound(resp) {
return logs.String(), fmt.Errorf("device rejected `sys factorydefault` (firmware does not expose this command)")
}
return logs.String(), nil
}
// isExpectedDisconnect reports whether an error from SendCommand is the
// normal "device closed the socket while rebooting" pattern, which we
// see during factory-reset.
func isExpectedDisconnect(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "eof") ||
strings.Contains(msg, "connection reset") ||
strings.Contains(msg, "connection closed") ||
strings.Contains(msg, "broken pipe")
}
+76
View File
@@ -0,0 +1,76 @@
package setup
import (
"errors"
"strings"
"testing"
)
func TestFactoryReset_HappyPath(t *testing.T) {
f := &fakeTelnet{
banner: "BoseDebug>",
responses: map[string]string{"sys factorydefault": "Rebooting...\n"},
}
m := newFakeTelnetManager(f)
logs, err := m.FactoryReset("192.0.2.10")
if err != nil {
t.Fatalf("FactoryReset: %v", err)
}
if len(f.commands) != 1 || f.commands[0] != "sys factorydefault" {
t.Errorf("commands = %v, want [sys factorydefault]", f.commands)
}
if !strings.Contains(logs, "Rebooting") {
t.Errorf("logs missing reboot output: %s", logs)
}
}
func TestFactoryReset_DisconnectIsAcceptedAsSuccess(t *testing.T) {
// Some firmwares drop the socket as soon as the reset starts, before
// they finish writing a response. That's not a failure.
f := &fakeTelnet{
fail: map[string]error{"sys factorydefault": errors.New("read EOF")},
}
m := newFakeTelnetManager(f)
logs, err := m.FactoryReset("192.0.2.10")
if err != nil {
t.Fatalf("disconnect during reset should be treated as success, got: %v", err)
}
if !strings.Contains(logs, "device disconnected") {
t.Errorf("logs should mention the expected disconnect, got: %s", logs)
}
}
func TestFactoryReset_RejectsFirmwareWithoutCommand(t *testing.T) {
// Default fakeTelnet response is "Command not found\n" for unmapped commands.
f := &fakeTelnet{}
m := newFakeTelnetManager(f)
_, err := m.FactoryReset("192.0.2.10")
if err == nil || !strings.Contains(err.Error(), "firmware does not expose") {
t.Errorf("err = %v, want firmware-rejection error", err)
}
}
func TestFactoryReset_NoTelnetClient(t *testing.T) {
m := &Manager{} // NewTelnet nil
_, err := m.FactoryReset("192.0.2.10")
if err == nil || !strings.Contains(err.Error(), "NewTelnet") {
t.Errorf("err = %v, want NewTelnet-nil error", err)
}
}
func TestFactoryReset_DialFailurePropagates(t *testing.T) {
f := &fakeTelnet{dialErr: errors.New("connection refused")}
m := newFakeTelnetManager(f)
_, err := m.FactoryReset("192.0.2.10")
if err == nil || !strings.Contains(err.Error(), "connection refused") {
t.Errorf("err = %v, want dial error", err)
}
}
+274
View File
@@ -0,0 +1,274 @@
package setup
import (
"context"
"errors"
"fmt"
"time"
)
// InitPlan describes everything required to take a factory-reset (or
// freshly-joined) speaker from "on the Wi-Fi" to "fully paired with a
// usable margeAccountUUID, pointing at AfterTouch."
//
// All fields are gathered upfront so the orchestrator can validate the
// plan before touching the device. AccountID may be left empty — the
// orchestrator either reuses the device's existing UUID (if it already
// has one) or generates a fresh 7-digit ID via GenerateAccountID.
type InitPlan struct {
DeviceIP string
ServiceURL string
AccountID string
Language int
DeviceName string
AuthToken string
// SkipURLRewrite skips the telnet envswitch step. The caller asserts
// the device's runtime marge URL already points at AfterTouch (e.g. a
// prior migration run, or a controlled test environment).
SkipURLRewrite bool
// StepTimeout overrides the per-WebSocket-step deadline.
StepTimeout time.Duration
}
// StepKind identifies a step for progress reporting.
type StepKind int
// Step kinds emitted by ExecuteInitPlan. Numbered explicitly so the wire
// format is stable for any future UI/JSON consumer.
const (
StepReadDeviceInfo StepKind = 1
StepURLRewrite StepKind = 2
StepGenerateAccountID StepKind = 3
StepDialWebSocket StepKind = 4
StepSetupStart StepKind = 5
StepIdentifyEnter StepKind = 6
StepLanguage StepKind = 7
StepSetupEnter StepKind = 8
StepIdentifyLeave StepKind = 9
StepName StepKind = 10
StepPairAccount StepKind = 11
StepSetupLeave StepKind = 12
StepPushTelemetry StepKind = 13
StepVerify StepKind = 14
)
// StepStatus is the per-step outcome surfaced via StepEvent.Status.
type StepStatus string
// Step statuses. "skipped" covers both caller-requested skips (e.g.
// SkipURLRewrite) and naturally-empty steps (e.g. SetName with no
// DeviceName change).
const (
StatusRunning StepStatus = "running"
StatusOK StepStatus = "ok"
StatusSkipped StepStatus = "skipped"
StatusFailed StepStatus = "failed"
)
// StepEvent is emitted before and after each step so callers can drive a UI.
type StepEvent struct {
Kind StepKind
Name string
Status StepStatus
Err error
}
// ProgressFunc receives StepEvents as the plan executes. May be nil.
type ProgressFunc func(StepEvent)
// ExecuteInitPlan runs the full speaker-initialization sequence described
// in docs/reference/DEVICE-PAIRING-FLOW.md:
//
// 1. read /info (so we know the device ID and current pairing state)
// 2. rewrite URLs via telnet envswitch (so the device's downstream POST
// after setMargeAccount lands on AfterTouch instead of dead Bose cloud)
// 3. resolve an account ID — reuse an existing margeAccountUUID, otherwise
// generate a fresh non-colliding 7-digit ID
// 4. open the WebSocket setup session
// 5. drive the state machine: SETUP_START → IDENTIFY_ENTER → language →
// SETUP_ENTER → IDENTIFY_LEAVE → name → setMargeAccount → SETUP_LEAVE
// → pushCustomerSupportInfoToMarge
// 6. verify by re-reading /info
//
// The returned InitPlan reflects any defaulting that happened (generated
// account ID, defaulted language, etc.) so callers can persist it.
func (m *Manager) ExecuteInitPlan(ctx context.Context, plan InitPlan, progress ProgressFunc) (InitPlan, error) {
if plan.DeviceIP == "" {
return plan, errors.New("InitPlan.DeviceIP is required")
}
if plan.ServiceURL == "" {
plan.ServiceURL = m.ServerURL
}
if plan.ServiceURL == "" {
return plan, errors.New("InitPlan.ServiceURL is required (and Manager.ServerURL is empty)")
}
if plan.Language == 0 {
plan.Language = LanguageEnglish
}
if plan.AuthToken == "" {
plan.AuthToken = "Bearer aftertouch"
}
emit := func(kind StepKind, name string, status StepStatus, err error) {
if progress != nil {
progress(StepEvent{Kind: kind, Name: name, Status: status, Err: err})
}
}
emit(StepReadDeviceInfo, "read /info", StatusRunning, nil)
info, err := m.GetLiveDeviceInfo(plan.DeviceIP)
if err != nil {
emit(StepReadDeviceInfo, "read /info", StatusFailed, err)
return plan, fmt.Errorf("read /info: %w", err)
}
emit(StepReadDeviceInfo, "read /info", StatusOK, nil)
if plan.SkipURLRewrite {
emit(StepURLRewrite, "telnet URL rewrite", StatusSkipped, nil)
} else {
emit(StepURLRewrite, "telnet URL rewrite", StatusRunning, nil)
urls := defaultTelnetURLs(plan.ServiceURL)
if _, err := m.migrateViaTelnet(plan.DeviceIP, plan.ServiceURL, urls); err != nil {
emit(StepURLRewrite, "telnet URL rewrite", StatusFailed, err)
return plan, fmt.Errorf("URL rewrite: %w", err)
}
emit(StepURLRewrite, "telnet URL rewrite", StatusOK, nil)
}
if plan.AccountID == "" {
if info.MargeAccountUUID != "" && IsValidAccountID(info.MargeAccountUUID) {
plan.AccountID = info.MargeAccountUUID
emit(StepGenerateAccountID, "reuse existing margeAccountUUID="+plan.AccountID, StatusOK, nil)
} else {
emit(StepGenerateAccountID, "generate account ID", StatusRunning, nil)
known := listKnownAccountIDs(m)
id, err := GenerateAccountID(known)
if err != nil {
emit(StepGenerateAccountID, "generate account ID", StatusFailed, err)
return plan, fmt.Errorf("generate account ID: %w", err)
}
plan.AccountID = id
emit(StepGenerateAccountID, "generate account ID="+id, StatusOK, nil)
}
} else if !IsValidAccountID(plan.AccountID) {
err := fmt.Errorf("invalid AccountID %q: must be exactly 7 digits", plan.AccountID)
emit(StepGenerateAccountID, "validate account ID", StatusFailed, err)
return plan, err
}
emit(StepDialWebSocket, "dial websocket", StatusRunning, nil)
if m.NewSetupSession == nil {
err := errors.New("Manager.NewSetupSession is nil — call NewManager or set it explicitly")
emit(StepDialWebSocket, "dial websocket", StatusFailed, err)
return plan, err
}
session, err := m.NewSetupSession(plan.DeviceIP, info.DeviceID, plan.StepTimeout)
if err != nil {
emit(StepDialWebSocket, "dial websocket", StatusFailed, err)
return plan, fmt.Errorf("dial websocket: %w", err)
}
defer func() { _ = session.Close() }()
emit(StepDialWebSocket, "dial websocket", StatusOK, nil)
type stepDef struct {
kind StepKind
name string
skip bool
fn func(context.Context) error
}
steps := []stepDef{
{kind: StepSetupStart, name: "SETUP_START", fn: session.Start},
{kind: StepIdentifyEnter, name: "SETUP_IDENTIFY_DEVICE_ENTER", fn: func(ctx context.Context) error {
// 300_000 ms matches the value captured from the official Bose
// app; the device flashes/beeps for that long while the user
// confirms identity. We pass it explicitly so the wire value
// is decided here rather than inside the session helper.
return session.IdentifyEnter(ctx, 300000)
}},
{kind: StepLanguage, name: fmt.Sprintf("sysLanguage=%d", plan.Language), fn: func(ctx context.Context) error {
return session.SetLanguage(ctx, plan.Language)
}},
{kind: StepSetupEnter, name: "SETUP_ENTER", fn: session.Enter},
{kind: StepIdentifyLeave, name: "SETUP_IDENTIFY_DEVICE_LEAVE", fn: session.IdentifyLeave},
{kind: StepName, name: "name=" + plan.DeviceName, skip: plan.DeviceName == "", fn: func(ctx context.Context) error {
return session.SetName(ctx, plan.DeviceName)
}},
{kind: StepPairAccount, name: "setMargeAccount=" + plan.AccountID, fn: func(ctx context.Context) error {
return session.SetMargeAccount(ctx, plan.AccountID, plan.AuthToken)
}},
{kind: StepSetupLeave, name: "SETUP_LEAVE", fn: session.Leave},
{kind: StepPushTelemetry, name: "pushCustomerSupportInfoToMarge", fn: session.PushCustomerSupportInfo},
}
for _, st := range steps {
if st.skip {
emit(st.kind, st.name+" (no change)", StatusSkipped, nil)
continue
}
emit(st.kind, st.name, StatusRunning, nil)
if err := st.fn(ctx); err != nil {
emit(st.kind, st.name, StatusFailed, err)
return plan, fmt.Errorf("%s: %w", st.name, err)
}
emit(st.kind, st.name, StatusOK, nil)
}
emit(StepVerify, "verify /info margeAccountUUID", StatusRunning, nil)
verify, err := m.GetLiveDeviceInfo(plan.DeviceIP)
if err != nil {
emit(StepVerify, "verify /info", StatusFailed, err)
return plan, fmt.Errorf("verify /info: %w", err)
}
if verify.MargeAccountUUID != plan.AccountID {
err := fmt.Errorf("post-init /info shows margeAccountUUID=%q, want %q", verify.MargeAccountUUID, plan.AccountID)
emit(StepVerify, "verify /info", StatusFailed, err)
return plan, err
}
emit(StepVerify, "verify /info margeAccountUUID="+plan.AccountID, StatusOK, nil)
return plan, nil
}
// listKnownAccountIDs collects account IDs already known to the local
// datastore so GenerateAccountID can avoid collisions. Returns nil when
// no datastore is configured or it errors — uniqueness is best-effort.
func listKnownAccountIDs(m *Manager) []string {
if m.DataStore == nil {
return nil
}
ids, err := m.DataStore.ListAccounts()
if err != nil {
return nil
}
return ids
}
+380
View File
@@ -0,0 +1,380 @@
package setup
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"testing"
"time"
)
// fakeSetupSession is a SetupStateMachine that records the order of
// invocations and lets each test inject per-step errors.
type fakeSetupSession struct {
calls []string
errors map[string]error
closed bool
}
func (f *fakeSetupSession) record(name string) error {
if e, ok := f.errors[name]; ok && e != nil {
return e
}
f.calls = append(f.calls, name)
return nil
}
func (f *fakeSetupSession) Start(_ context.Context) error { return f.record("Start") }
func (f *fakeSetupSession) Enter(_ context.Context) error { return f.record("Enter") }
func (f *fakeSetupSession) Leave(_ context.Context) error { return f.record("Leave") }
func (f *fakeSetupSession) IdentifyLeave(_ context.Context) error {
return f.record("IdentifyLeave")
}
func (f *fakeSetupSession) IdentifyEnter(_ context.Context, timeoutMs int) error {
return f.record(fmt.Sprintf("IdentifyEnter(%d)", timeoutMs))
}
func (f *fakeSetupSession) SetLanguage(_ context.Context, code int) error {
return f.record(fmt.Sprintf("SetLanguage(%d)", code))
}
func (f *fakeSetupSession) SetName(_ context.Context, name string) error {
return f.record("SetName(" + name + ")")
}
func (f *fakeSetupSession) SetMargeAccount(_ context.Context, accountID, token string) error {
return f.record(fmt.Sprintf("SetMargeAccount(%s,%s)", accountID, token))
}
func (f *fakeSetupSession) PushCustomerSupportInfo(_ context.Context) error {
return f.record("PushCustomerSupportInfo")
}
func (f *fakeSetupSession) Close() error {
f.closed = true
return nil
}
// fakeInfoResponder produces an http.Response carrying canned /info XML.
// pairedAccount toggles between "unpaired" and "paired with this UUID."
type fakeInfoResponder struct {
deviceID string
paired string // empty = unpaired
postInitPaired string // /info reading after the plan ran
reads int
}
func (f *fakeInfoResponder) get(_ string) (*http.Response, error) {
f.reads++
acct := f.paired
if f.reads >= 2 && f.postInitPaired != "" {
acct = f.postInitPaired
}
body := fmt.Sprintf(
`<info deviceID="%s"><name>Test</name><margeAccountUUID>%s</margeAccountUUID></info>`,
f.deviceID, acct,
)
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(body)),
Header: http.Header{},
}, nil
}
func newTestManagerWithFakes(t *testing.T, info *fakeInfoResponder, sess *fakeSetupSession) *Manager {
t.Helper()
m := &Manager{
ServerURL: "http://aftertouch.local:8000",
HTTPGet: info.get,
NewSetupSession: func(_, _ string, _ time.Duration) (SetupStateMachine, error) {
return sess, nil
},
}
return m
}
func TestExecuteInitPlan_FactoryReset_GeneratesAccountAndRunsAllSteps(t *testing.T) {
info := &fakeInfoResponder{
deviceID: "AABBCCDDEEFF",
paired: "",
postInitPaired: "", // filled below after we know which ID was generated
}
sess := &fakeSetupSession{}
m := newTestManagerWithFakes(t, info, sess)
// Intercept the generated account ID so we can prime the post-init
// /info read to return it. Easiest way: pre-supply a known AccountID.
plan := InitPlan{
DeviceIP: "192.0.2.10",
AccountID: "1234567",
DeviceName: "Living Room",
SkipURLRewrite: true,
}
info.postInitPaired = "1234567"
var events []StepEvent
got, err := m.ExecuteInitPlan(context.Background(), plan, func(e StepEvent) {
events = append(events, e)
})
if err != nil {
t.Fatalf("ExecuteInitPlan: %v", err)
}
if got.AccountID != "1234567" {
t.Errorf("AccountID = %q, want 1234567", got.AccountID)
}
if got.Language != LanguageEnglish {
t.Errorf("Language = %d, want %d (default English)", got.Language, LanguageEnglish)
}
wantCalls := []string{
"Start",
"IdentifyEnter(300000)",
"SetLanguage(2)",
"Enter",
"IdentifyLeave",
"SetName(Living Room)",
"SetMargeAccount(1234567,Bearer aftertouch)",
"Leave",
"PushCustomerSupportInfo",
}
if got, want := strings.Join(sess.calls, "|"), strings.Join(wantCalls, "|"); got != want {
t.Errorf("call order mismatch\n got: %s\nwant: %s", got, want)
}
if !sess.closed {
t.Error("expected session to be closed")
}
// Verify the URL-rewrite event was emitted as Skipped, not silently dropped.
if !hasEvent(events, StepURLRewrite, StatusSkipped) {
t.Errorf("expected StepURLRewrite Skipped event, got %v", eventSummary(events))
}
// Final verify step must report OK.
if !hasEvent(events, StepVerify, StatusOK) {
t.Errorf("expected StepVerify OK, got %v", eventSummary(events))
}
}
func TestExecuteInitPlan_ReusesExistingAccountUUID(t *testing.T) {
info := &fakeInfoResponder{
deviceID: "AABBCCDDEEFF",
paired: "9876543",
postInitPaired: "9876543",
}
sess := &fakeSetupSession{}
m := newTestManagerWithFakes(t, info, sess)
plan := InitPlan{
DeviceIP: "192.0.2.10",
SkipURLRewrite: true,
}
got, err := m.ExecuteInitPlan(context.Background(), plan, nil)
if err != nil {
t.Fatalf("ExecuteInitPlan: %v", err)
}
if got.AccountID != "9876543" {
t.Errorf("AccountID = %q, want 9876543 (the device's existing UUID)", got.AccountID)
}
}
func TestExecuteInitPlan_GeneratesAccountWhenDeviceUUIDInvalid(t *testing.T) {
// Devices that report a non-7-digit UUID (e.g. a stale local value) must
// not be reused — we treat them as factory-reset for ID purposes.
info := &fakeInfoResponder{
deviceID: "AABBCCDDEEFF",
paired: "not-7-digits",
postInitPaired: "", // we'll learn the generated ID from the result
}
sess := &fakeSetupSession{}
m := newTestManagerWithFakes(t, info, sess)
// Pre-generate so the post-init /info knows what to return.
plan := InitPlan{DeviceIP: "192.0.2.10", SkipURLRewrite: true}
// Track the generated AccountID and feed it back as the post-init /info value.
progress := func(e StepEvent) {
if e.Kind == StepGenerateAccountID && e.Status == StatusOK && strings.Contains(e.Name, "generate account ID=") {
info.postInitPaired = strings.TrimPrefix(e.Name, "generate account ID=")
}
}
got, err := m.ExecuteInitPlan(context.Background(), plan, progress)
if err != nil {
t.Fatalf("ExecuteInitPlan: %v", err)
}
if !IsValidAccountID(got.AccountID) {
t.Errorf("got.AccountID = %q, want a valid 7-digit ID", got.AccountID)
}
if got.AccountID == "not-7-digits" {
t.Error("orchestrator should not reuse an invalid UUID")
}
}
func TestExecuteInitPlan_RejectsInvalidSuppliedAccountID(t *testing.T) {
info := &fakeInfoResponder{deviceID: "X", paired: ""}
sess := &fakeSetupSession{}
m := newTestManagerWithFakes(t, info, sess)
plan := InitPlan{
DeviceIP: "192.0.2.10",
AccountID: "abc",
SkipURLRewrite: true,
}
_, err := m.ExecuteInitPlan(context.Background(), plan, nil)
if err == nil {
t.Fatal("expected error for invalid AccountID")
}
if !strings.Contains(err.Error(), "invalid AccountID") {
t.Errorf("err = %v, want to mention invalid AccountID", err)
}
if len(sess.calls) != 0 {
t.Errorf("expected zero WS calls after rejection, got %v", sess.calls)
}
}
func TestExecuteInitPlan_StopsAtFirstFailedStep(t *testing.T) {
info := &fakeInfoResponder{deviceID: "X", paired: "", postInitPaired: "1234567"}
sess := &fakeSetupSession{
errors: map[string]error{
"Enter": errors.New("device dropped the SETUP_ENTER frame"),
},
}
m := newTestManagerWithFakes(t, info, sess)
plan := InitPlan{
DeviceIP: "192.0.2.10",
AccountID: "1234567",
SkipURLRewrite: true,
}
var events []StepEvent
_, err := m.ExecuteInitPlan(context.Background(), plan, func(e StepEvent) { events = append(events, e) })
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "SETUP_ENTER") {
t.Errorf("err = %v, want to mention SETUP_ENTER", err)
}
// Steps after the failed one must not have been called.
for _, c := range sess.calls {
if c == "Leave" || c == "PushCustomerSupportInfo" {
t.Errorf("unexpected post-failure call %q", c)
}
}
if !hasEvent(events, StepSetupEnter, StatusFailed) {
t.Errorf("expected StepSetupEnter Failed event, got %v", eventSummary(events))
}
}
func TestExecuteInitPlan_EmptyDeviceNameSkipsNameStep(t *testing.T) {
info := &fakeInfoResponder{deviceID: "X", paired: "", postInitPaired: "1234567"}
sess := &fakeSetupSession{}
m := newTestManagerWithFakes(t, info, sess)
plan := InitPlan{
DeviceIP: "192.0.2.10",
AccountID: "1234567",
SkipURLRewrite: true,
// DeviceName intentionally empty
}
if _, err := m.ExecuteInitPlan(context.Background(), plan, nil); err != nil {
t.Fatalf("ExecuteInitPlan: %v", err)
}
for _, c := range sess.calls {
if strings.HasPrefix(c, "SetName(") {
t.Errorf("SetName should be skipped when DeviceName is empty, but was called: %q", c)
}
}
}
func TestExecuteInitPlan_RequiresDeviceIP(t *testing.T) {
m := &Manager{ServerURL: "http://aftertouch.local:8000"}
_, err := m.ExecuteInitPlan(context.Background(), InitPlan{}, nil)
if err == nil || !strings.Contains(err.Error(), "DeviceIP") {
t.Errorf("err = %v, want to mention DeviceIP", err)
}
}
func TestExecuteInitPlan_RequiresServiceURL(t *testing.T) {
m := &Manager{}
_, err := m.ExecuteInitPlan(context.Background(), InitPlan{DeviceIP: "192.0.2.10"}, nil)
if err == nil || !strings.Contains(err.Error(), "ServiceURL") {
t.Errorf("err = %v, want to mention ServiceURL", err)
}
}
func TestExecuteInitPlan_FailsOnPostInitVerifyMismatch(t *testing.T) {
// Device's post-init /info still reports the old account — surface
// that as a verification failure rather than a silent success.
info := &fakeInfoResponder{
deviceID: "X",
paired: "",
postInitPaired: "9999999", // not equal to plan.AccountID
}
sess := &fakeSetupSession{}
m := newTestManagerWithFakes(t, info, sess)
plan := InitPlan{
DeviceIP: "192.0.2.10",
AccountID: "1234567",
SkipURLRewrite: true,
}
_, err := m.ExecuteInitPlan(context.Background(), plan, nil)
if err == nil {
t.Fatal("expected verification error")
}
if !strings.Contains(err.Error(), "margeAccountUUID") {
t.Errorf("err = %v, want to mention margeAccountUUID mismatch", err)
}
}
func hasEvent(events []StepEvent, kind StepKind, status StepStatus) bool {
for _, e := range events {
if e.Kind == kind && e.Status == status {
return true
}
}
return false
}
func eventSummary(events []StepEvent) string {
parts := make([]string, 0, len(events))
for _, e := range events {
parts = append(parts, fmt.Sprintf("%d/%s", e.Kind, e.Status))
}
return strings.Join(parts, ",")
}
+157
View File
@@ -0,0 +1,157 @@
package setup
import (
"encoding/xml"
"errors"
"fmt"
"io"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
// InspectOptions controls how Manager.Inspect probes the speaker.
type InspectOptions struct {
// IncludeTelnet runs `getpdo CurrentSystemConfiguration` over telnet to
// capture the speaker's runtime URL configuration. Slower and not
// always reachable on hardened firmware, hence opt-in.
IncludeTelnet bool
}
// InspectSection is one slice of an InspectReport. Each section has an
// independent error so a partial failure (e.g. /presets refused) does not
// hide the rest of the report.
type InspectSection struct {
Name string
Err error
}
// InspectReport summarises everything we can learn about a speaker
// without writing to it. Used to populate UI before factory-reset / pair
// flows and to record the deviceID-suffix for later wait-online calls.
type InspectReport struct {
DeviceIP string
Info *DeviceInfoXML `json:"info,omitempty"`
InfoErr error `json:"-"`
Network *models.NetworkInformation `json:"network,omitempty"`
NetworkErr error `json:"-"`
Sources *models.Sources `json:"sources,omitempty"`
SourcesErr error `json:"-"`
Presets *PresetList `json:"presets,omitempty"`
PresetsErr error `json:"-"`
RuntimeURLs string `json:"runtime_urls,omitempty"`
RuntimeErr error `json:"-"`
}
// PresetList is a minimal preset summary — just enough to render a
// "preset N: <name>" overview. The full preset model lives in
// pkg/models, but we don't need it here.
type PresetList struct {
XMLName xml.Name `xml:"presets"`
Presets []struct {
ID string `xml:"id,attr"`
ContentItem struct {
Source string `xml:"source,attr"`
SourceAccount string `xml:"sourceAccount,attr"`
Type string `xml:"type,attr"`
ItemName string `xml:"itemName"`
} `xml:"ContentItem"`
} `xml:"preset"`
}
// Inspect gathers a non-destructive snapshot of the speaker at deviceIP.
// Every probe is best-effort: individual section errors are recorded on
// the report rather than aborting the whole call.
func (m *Manager) Inspect(deviceIP string, opts InspectOptions) *InspectReport {
r := &InspectReport{DeviceIP: deviceIP}
r.Info, r.InfoErr = m.GetLiveDeviceInfo(deviceIP)
r.Network, r.NetworkErr = m.fetchNetworkInfo(deviceIP)
r.Sources, r.SourcesErr = m.fetchSources(deviceIP)
r.Presets, r.PresetsErr = m.fetchPresets(deviceIP)
if opts.IncludeTelnet {
r.RuntimeURLs, r.RuntimeErr = m.fetchRuntimeURLs(deviceIP)
}
return r
}
func (m *Manager) fetchXML(deviceIP, path string, out any) error {
url := buildDeviceURL(deviceIP, path)
resp, err := m.HTTPGet(url)
if err != nil {
return fmt.Errorf("GET %s: %w", url, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("GET %s returned %d", url, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read %s: %w", url, err)
}
return xml.Unmarshal(body, out)
}
func (m *Manager) fetchNetworkInfo(deviceIP string) (*models.NetworkInformation, error) {
var n models.NetworkInformation
if err := m.fetchXML(deviceIP, "/networkInfo", &n); err != nil {
return nil, err
}
return &n, nil
}
func (m *Manager) fetchSources(deviceIP string) (*models.Sources, error) {
var s models.Sources
if err := m.fetchXML(deviceIP, "/sources", &s); err != nil {
return nil, err
}
return &s, nil
}
func (m *Manager) fetchPresets(deviceIP string) (*PresetList, error) {
var p PresetList
if err := m.fetchXML(deviceIP, "/presets", &p); err != nil {
return nil, err
}
return &p, nil
}
// fetchRuntimeURLs reads the device's runtime URL configuration via the
// port-17000 diagnostic shell. The response is a multi-line text blob —
// we return it as-is so the caller can decide how to format it.
func (m *Manager) fetchRuntimeURLs(deviceIP string) (string, error) {
if m.NewTelnet == nil {
return "", errors.New("telnet probe disabled: Manager.NewTelnet is nil")
}
t := m.NewTelnet(deviceIP)
if err := t.Dial(); err != nil {
return "", fmt.Errorf("telnet dial %s:17000: %w", deviceIP, err)
}
defer func() { _ = t.Close() }()
_, _ = t.Probe()
resp, err := t.SendCommand("getpdo CurrentSystemConfiguration")
if err != nil {
return "", fmt.Errorf("getpdo: %w", err)
}
if isCommandNotFound(resp) {
return "", errors.New("device rejected `getpdo` (firmware does not expose this command)")
}
return strings.TrimRight(resp, "\r\n"), nil
}
+181
View File
@@ -0,0 +1,181 @@
package setup
import (
"errors"
"io"
"net/http"
"strings"
"testing"
)
// inspectFakes wires canned XML bodies into Manager.HTTPGet keyed by URL
// path. A missing path returns 404; an empty-string body returns the
// supplied err.
type inspectFakes struct {
responses map[string]string
errs map[string]error
}
func (f *inspectFakes) get(url string) (*http.Response, error) {
for path, body := range f.responses {
if strings.HasSuffix(url, path) {
if e := f.errs[path]; e != nil {
return nil, e
}
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(body)),
Header: http.Header{},
}, nil
}
}
return &http.Response{
StatusCode: 404,
Body: io.NopCloser(strings.NewReader("not found")),
Header: http.Header{},
}, nil
}
func TestInspect_HappyPath(t *testing.T) {
f := &inspectFakes{
responses: map[string]string{
"/info": `<info deviceID="506583DE4803">
<name>Bose SoundTouch DE4803</name>
<type>SoundTouch 10</type>
<margeAccountUUID>1234567</margeAccountUUID>
<margeURL>http://aftertouch.local:8000</margeURL>
<components>
<component>
<componentCategory>SCM</componentCategory>
<softwareVersion>27.0.6</softwareVersion>
<serialNumber>F23456789012</serialNumber>
</component>
</components>
</info>`,
"/networkInfo": `<networkInfo wifiProfileCount="1">
<interfaces>
<interface type="WIFI_INTERFACE" name="wlan0" macAddress="aa:bb:cc:dd:ee:ff" ipAddress="192.168.1.42" ssid="MyHomeNetwork" frequencyKHz="2452000" state="NETWORK_WIFI_CONNECTED" signal="GOOD_SIGNAL" mode="STATION"/>
</interfaces>
</networkInfo>`,
"/sources": `<sources><sourceItem source="TUNEIN" status="READY"/><sourceItem source="SPOTIFY" sourceAccount="user@example.com" status="READY"/></sources>`,
"/presets": `<presets><preset id="1"><ContentItem source="TUNEIN" type="stationurl"><itemName>1LIVE</itemName></ContentItem></preset></presets>`,
},
}
m := &Manager{HTTPGet: f.get}
r := m.Inspect("192.168.1.42", InspectOptions{})
if r.InfoErr != nil {
t.Errorf("InfoErr = %v, want nil", r.InfoErr)
}
if r.Info == nil || r.Info.DeviceID != "506583DE4803" {
t.Errorf("Info.DeviceID = %v, want 506583DE4803", r.Info)
}
if r.Info.MargeAccountUUID != "1234567" {
t.Errorf("MargeAccountUUID = %q, want 1234567", r.Info.MargeAccountUUID)
}
if r.Network == nil || len(r.Network.Interfaces.Interfaces) == 0 {
t.Fatalf("Network parse failed: %v / %v", r.Network, r.NetworkErr)
}
wifi := r.Network.Interfaces.Interfaces[0]
if wifi.SSID != "MyHomeNetwork" {
t.Errorf("SSID = %q, want MyHomeNetwork", wifi.SSID)
}
if r.Sources == nil || len(r.Sources.SourceItem) != 2 {
t.Errorf("Sources = %v, want 2 entries", r.Sources)
}
if r.Presets == nil || len(r.Presets.Presets) != 1 {
t.Errorf("Presets = %v, want 1 preset", r.Presets)
}
if r.Presets.Presets[0].ContentItem.ItemName != "1LIVE" {
t.Errorf("preset name = %q, want 1LIVE", r.Presets.Presets[0].ContentItem.ItemName)
}
}
func TestInspect_PartialFailureRecordsPerSectionErrors(t *testing.T) {
// /info ok, /presets returns network error — the rest of the report
// must still populate.
f := &inspectFakes{
responses: map[string]string{
"/info": `<info deviceID="X"><name>n</name></info>`,
"/networkInfo": `<networkInfo><interfaces></interfaces></networkInfo>`,
"/sources": `<sources/>`,
"/presets": "", // body unused — errs map below triggers error
},
errs: map[string]error{
"/presets": errors.New("connection reset"),
},
}
m := &Manager{HTTPGet: f.get}
r := m.Inspect("192.168.1.42", InspectOptions{})
if r.InfoErr != nil {
t.Errorf("InfoErr = %v, want nil", r.InfoErr)
}
if r.PresetsErr == nil {
t.Error("expected PresetsErr to be populated")
}
if r.Sources == nil {
t.Error("Sources should still populate despite PresetsErr")
}
}
func TestInspect_TelnetRuntimeURLs(t *testing.T) {
f := &inspectFakes{
responses: map[string]string{
"/info": `<info deviceID="X"><name>n</name></info>`,
},
}
tn := &fakeTelnet{
responses: map[string]string{
"getpdo CurrentSystemConfiguration": "margeServerUrl: http://aftertouch.local:8000\nstatsServerUrl: http://aftertouch.local:8000\n",
},
}
m := &Manager{
HTTPGet: f.get,
NewTelnet: func(string) TelnetClient { return tn },
}
r := m.Inspect("192.168.1.42", InspectOptions{IncludeTelnet: true})
if r.RuntimeErr != nil {
t.Errorf("RuntimeErr = %v, want nil", r.RuntimeErr)
}
if !strings.Contains(r.RuntimeURLs, "margeServerUrl") {
t.Errorf("RuntimeURLs missing expected content: %q", r.RuntimeURLs)
}
}
func TestInspect_TelnetSkippedWhenOptionDisabled(t *testing.T) {
f := &inspectFakes{
responses: map[string]string{
"/info": `<info deviceID="X"><name>n</name></info>`,
},
}
m := &Manager{HTTPGet: f.get}
r := m.Inspect("192.168.1.42", InspectOptions{IncludeTelnet: false})
if r.RuntimeURLs != "" || r.RuntimeErr != nil {
t.Errorf("telnet runtime fields should be zero when IncludeTelnet=false, got %q / %v",
r.RuntimeURLs, r.RuntimeErr)
}
}
+98 -60
View File
@@ -14,6 +14,7 @@ import (
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
@@ -138,6 +139,11 @@ type Manager struct {
NewSSH func(host string) SSHClient
NewTelnet func(host string) TelnetClient
// NewSetupSession opens the WebSocket setup state-machine session used
// by ExecuteInitPlan. Tests inject an in-memory fake; the production
// default is DialSetupSession.
NewSetupSession func(deviceIP, deviceID string, stepTimeout time.Duration) (SetupStateMachine, error)
// GetDNSRunning is an optional callback to check the actual state of the DNS server.
GetDNSRunning func() (bool, string)
@@ -161,6 +167,9 @@ func NewManager(serverURL string, ds *datastore.DataStore, cm *certmanager.Certi
NewTelnet: func(host string) TelnetClient {
return telnet.NewClient(host)
},
NewSetupSession: func(deviceIP, deviceID string, stepTimeout time.Duration) (SetupStateMachine, error) {
return DialSetupSession(deviceIP, deviceID, SetupSessionConfig{StepTimeout: stepTimeout})
},
HTTPGet: http.Get,
MgmtUsername: "admin",
MgmtPassword: "change_me!",
@@ -292,34 +301,19 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
BmxRegistryUrl: fmt.Sprintf("%s/bmx/registry/v1/services", targetURL),
}
// 2. Check SSH and read current config
currentConfig, err := m.checkCurrentConfig(summary, deviceIP)
if err == nil && currentConfig != "" {
summary.CurrentConfig = currentConfig
fmt.Printf("Current config from %s (length: %d):\n%q\n", deviceIP, len(currentConfig), currentConfig)
// 2. One batched SSH round-trip collects every file/existence probe
// we need. Without this, the legacy per-helper path issued ~8 fresh
// SSH dials in sequence — each pkg/ssh.Run() opens a brand-new
// TCP+SSH handshake on legacy crypto, ~500 ms1 s each.
probe := m.probeSpeakerSSH(deviceIP)
// Parse current config
var currentCfg PrivateCfg
if xml.Unmarshal([]byte(currentConfig), &currentCfg) == nil {
summary.ParsedCurrentConfig = &currentCfg
if proxyURL == "" {
proxyURL = targetURL
}
// Apply options if provided
if options != nil {
m.applyProxyOptions(&plannedCfg, proxyURL, options, &currentCfg)
}
}
}
m.applyProbeToSummary(summary, probe, &plannedCfg, proxyURL, targetURL, options)
// Per-field literal URL overrides win over both the canonical
// derivation and any self/proxied/original mode applied above —
// the user picked a URL, so the planned preview reflects exactly
// what the XML migration will write.
applyURLOverrides(&plannedCfg, options)
// Note: CurrentConfig is set by checkCurrentConfig in all cases (success or failure)
xmlContent, err := xml.MarshalIndent(plannedCfg, "", " ")
if err != nil {
@@ -331,25 +325,12 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
// 2b. Planned network config (hosts entries, resolv.conf preview, resolve error)
m.populatePlannedNetworkConfig(summary, deviceIP, targetURL)
// 3. Check for remote services files
m.checkRemoteServices(summary, deviceIP)
// 4. Check if CA certificate is trusted
m.checkCACertTrusted(summary, deviceIP)
// 4b. Check current /etc/resolv.conf
if summary.SSHSuccess {
client := m.NewSSH(deviceIP)
if resolvConf, err := client.Run("cat /etc/resolv.conf"); err == nil {
summary.CurrentResolvConf = resolvConf
}
}
// 5. Provide HTTPS URL for testing
// 3. Provide HTTPS URL for testing (consumed by the migration UI)
summary.ServerHTTPSURL = m.buildServerHTTPSURL(targetURL)
// 6. Check if migrated
m.checkIsMigrated(summary, deviceIP)
// 4. Check if migrated (telnet axis uses the parallel preflight;
// XML/hosts/resolv axes use the probe data already gathered above).
m.checkIsMigratedFromProbe(summary, probe)
// 7. Mirroring settings
if m.DataStore != nil {
@@ -387,9 +368,13 @@ func (m *Manager) populatePlannedNetworkConfig(summary *MigrationSummary, device
return
}
client := m.NewSSH(deviceIP)
hostIP, resolveErr := m.resolveIP(hostName, client)
// Resolve locally only. The "from-device" lookup that resolveIP can
// do via SSH (`ping -c 1 host`) costs another fresh SSH handshake
// plus the ping's own runtime — easily 25 s on firmware-27 devices
// — and the result feeds only the PlannedResolv/PlannedHosts preview.
// For the actual apply paths (migrateViaHosts/migrateViaResolv) the
// device-side resolution is still used; this is only the preview.
hostIP, resolveErr := m.resolveIP(hostName, nil)
if resolveErr != nil {
summary.ResolveIPError = resolveErr.Error()
}
@@ -423,13 +408,36 @@ func (m *Manager) populatePlannedNetworkConfig(summary *MigrationSummary, device
summary.PlannedHosts = strings.Join(hostsLines, "\n")
}
// buildServerHTTPSURL composes the AfterTouch /health probe URL.
//
// Port resolution order (first non-empty wins):
// 1. The port from targetURL when it is already an https:// URL — the
// caller supplied an HTTPS endpoint, so it owns the port choice.
// 2. The implicit https:// default 443 when targetURL is https:// with
// no explicit port.
// 3. The HTTPS_PORT env var — back-compat for deployments where
// targetURL is http://…:8000 and HTTPS_PORT names the separate TLS
// listener.
// 4. The legacy default 8443.
func (m *Manager) buildServerHTTPSURL(targetURL string) string {
parsedURL, err := url.Parse(targetURL)
if err != nil || parsedURL.Hostname() == "" {
return ""
}
httpsPort := os.Getenv("HTTPS_PORT")
var httpsPort string
if parsedURL.Scheme == "https" {
httpsPort = parsedURL.Port()
if httpsPort == "" {
httpsPort = "443"
}
}
if httpsPort == "" {
httpsPort = os.Getenv("HTTPS_PORT")
}
if httpsPort == "" {
httpsPort = "8443"
}
@@ -757,22 +765,29 @@ func (m *Manager) checkRemoteServices(summary *MigrationSummary, deviceIP string
}
}
// checkCACertTrusted checks if the local CA certificate is already in the device's trust store.
// checkCACertTrusted checks if the local CA certificate is already in
// the device's trust store. The CALabel grep works regardless of whether
// Manager.Crypto is configured — only the secondary "match cert payload"
// fallback needs it. CLI callers without Crypto can therefore still
// detect a previously-trusted CA.
func (m *Manager) checkCACertTrusted(summary *MigrationSummary, deviceIP string) {
if m.Crypto == nil {
return
}
client := m.NewSSH(deviceIP)
bundlePath := "/etc/pki/tls/certs/ca-bundle.crt"
// First, check for the label
// Primary check: our injected label.
output, err := client.Run(fmt.Sprintf("grep -F %q %s", CALabel, bundlePath))
if err == nil && strings.Contains(output, CALabel) {
summary.CACertTrusted = true
return
}
// Secondary check (only when Manager.Crypto is configured): match
// the actual cert payload — covers older injections that lack the
// label.
if m.Crypto == nil {
return
}
caCertPEM, err := os.ReadFile(m.Crypto.GetCACertPath())
if err != nil {
return
@@ -864,6 +879,14 @@ func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options m
}
func (m *Manager) checkDNSPreFlight() error {
// CLI / remote callers construct a Manager without a DataStore — they
// can't introspect AfterTouch's settings from here. Skip the local
// check in that case; the caller is responsible for verifying the
// remote service's DNS state (the CLI hits GET /setup/settings).
if m.DataStore == nil {
return nil
}
// Pre-flight check: DNS server must be enabled and bound to port 53
settings, err := m.DataStore.GetSettings()
if err != nil {
@@ -1156,18 +1179,38 @@ func (m *Manager) EnsureRemoteServices(deviceIP string) (string, error) {
return logs, fmt.Errorf("failed to enable remote services in any of the locations: %v", locations)
}
// TrustCACert injects the local CA certificate into the device's shared trust store.
// TrustCACert injects the local CA certificate into the device's shared
// trust store. The cert is read from disk via Manager.Crypto — used by
// the in-process migration flow where the CLI and the certmanager share
// a filesystem. Remote/CLI callers without Crypto should fetch the cert
// over HTTP and use TrustCACertFromBytes instead.
func (m *Manager) TrustCACert(deviceIP string) (string, error) {
client := m.NewSSH(deviceIP)
rwCmd := "(rw || mount -o remount,rw /)"
var logs string
if m.Crypto == nil {
return "", errors.New("TrustCACert: Manager.Crypto is nil — remote callers should fetch the CA via /setup/ca.crt and call TrustCACertFromBytes (e.g. `soundtouch-cli setup install-ca`)")
}
caCertPEM, err := os.ReadFile(m.Crypto.GetCACertPath())
if err != nil {
return "", fmt.Errorf("failed to read CA certificate: %w", err)
}
return m.TrustCACertFromBytes(deviceIP, caCertPEM)
}
// TrustCACertFromBytes injects the supplied PEM-encoded CA bundle into
// the speaker's shared trust store. Identical to TrustCACert except the
// cert bytes come from the caller — used by the remote CLI which fetches
// /setup/ca.crt over HTTP and never touches Manager.Crypto.
func (m *Manager) TrustCACertFromBytes(deviceIP string, caCertPEM []byte) (string, error) {
if !strings.Contains(string(caCertPEM), "BEGIN CERTIFICATE") {
return "", fmt.Errorf("CA payload does not contain a PEM certificate")
}
client := m.NewSSH(deviceIP)
rwCmd := "(rw || mount -o remount,rw /)"
var logs string
bundlePath := "/etc/pki/tls/certs/ca-bundle.crt"
out, _ := client.Run(rwCmd)
logs += rwCmd + ": " + out + "\n"
@@ -1187,12 +1230,8 @@ func (m *Manager) TrustCACert(deviceIP string) (string, error) {
}
if strings.Contains(bundleContent, CALabel) {
// Label found, let's replace the whole block between labels if we used them,
// or just remove the lines containing the label and re-append.
// For simplicity, let's remove everything between CALabel tags if we had them,
// but since we only had one line before, let's just remove lines containing CALabel
// and the cert data if possible.
// A better way is to rebuild the bundle without our CA.
// Rebuild the bundle without our previously-injected CA so the
// fresh one replaces the old.
lines := strings.Split(bundleContent, "\n")
var newLines []string
@@ -1218,7 +1257,6 @@ func (m *Manager) TrustCACert(deviceIP string) (string, error) {
bundleContent += "\n"
}
// Append with labels
labeledCert := fmt.Sprintf("\n%s\n%s%s\n", CALabel, string(caCertPEM), CALabel)
newBundleContent := bundleContent + labeledCert
+304
View File
@@ -0,0 +1,304 @@
package setup
import (
"context"
"encoding/xml"
"errors"
"fmt"
"net"
"net/url"
"strings"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
)
const (
defaultSetupStepTimeout = 8 * time.Second
setupHandshakeTimeout = 10 * time.Second
// LanguageEnglish is the sysLanguage code for English. 2 is the
// value the official Bose app sends during English-locale setup.
LanguageEnglish = 2
)
// SetupStateMachine is the surface the InitPlan orchestrator drives. The
// concrete WebSocket-backed implementation is *SetupSession; tests inject
// an in-memory fake via Manager.NewSetupSession.
type SetupStateMachine interface {
Start(ctx context.Context) error
IdentifyEnter(ctx context.Context, timeoutMs int) error
SetLanguage(ctx context.Context, code int) error
Enter(ctx context.Context) error
IdentifyLeave(ctx context.Context) error
SetName(ctx context.Context, name string) error
SetMargeAccount(ctx context.Context, accountID, authToken string) error
Leave(ctx context.Context) error
PushCustomerSupportInfo(ctx context.Context) error
Close() error
}
// SetupSessionConfig configures DialSetupSession. Zero values pick safe
// defaults; in production callers normally pass an empty struct.
type SetupSessionConfig struct {
// StepTimeout caps the per-message wait for an ack frame. Default 8 s.
StepTimeout time.Duration
// DialTimeout caps the WebSocket handshake. Default 10 s.
DialTimeout time.Duration
// WSScheme overrides "ws". Tests inject "ws" with httptest's host:port
// already encoded in deviceIP and rely on the dialer to use the URL
// as-is.
WSScheme string
// WSPort overrides 8080 when deviceIP does not already carry a port.
WSPort int
}
// SetupSession is a synchronous request/response WebSocket session driving
// the speaker's setup state machine. It is deliberately separate from
// pkg/client.WebSocketClient (which is event-oriented, auto-reconnecting,
// and stateful) — setup is a short, linear sequence and benefits from a
// purpose-built transport.
type SetupSession struct {
deviceID string
conn *websocket.Conn
reqID atomic.Int64
stepTimeout time.Duration
}
// DialSetupSession opens a WebSocket to the speaker at deviceIP and
// returns a session ready to drive the SETUP state machine. deviceID is
// required because every <msg> envelope embeds it in the header; obtain
// it from /info before calling.
func DialSetupSession(deviceIP, deviceID string, cfg SetupSessionConfig) (*SetupSession, error) {
if deviceID == "" {
return nil, errors.New("DialSetupSession: deviceID is required for message routing")
}
scheme := cfg.WSScheme
if scheme == "" {
scheme = "ws"
}
host := deviceIP
if _, _, err := net.SplitHostPort(deviceIP); err != nil {
port := cfg.WSPort
if port == 0 {
port = 8080
}
host = fmt.Sprintf("%s:%d", deviceIP, port)
}
wsURL := url.URL{Scheme: scheme, Host: host, Path: "/"}
handshake := cfg.DialTimeout
if handshake == 0 {
handshake = setupHandshakeTimeout
}
dialer := websocket.Dialer{
HandshakeTimeout: handshake,
Subprotocols: []string{"gabbo"},
}
conn, resp, err := dialer.Dial(wsURL.String(), nil)
if resp != nil && resp.Body != nil {
defer func() { _ = resp.Body.Close() }()
}
if err != nil {
return nil, fmt.Errorf("websocket dial %s: %w", wsURL.String(), err)
}
step := cfg.StepTimeout
if step == 0 {
step = defaultSetupStepTimeout
}
return &SetupSession{deviceID: deviceID, conn: conn, stepTimeout: step}, nil
}
// Close sends a normal-closure frame and closes the underlying socket.
func (s *SetupSession) Close() error {
if s.conn == nil {
return nil
}
_ = s.conn.WriteControl(
websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""),
time.Now().Add(time.Second),
)
err := s.conn.Close()
s.conn = nil
return err
}
// sendStep wraps body in the canonical <msg><header url="…" method="…">…
// envelope, sends it, and drains incoming frames until one references the
// same requestID, status path, or url attribute — that frame is the ack.
// Pushed <updates> and <SoundTouchSdkInfo> frames are ignored.
func (s *SetupSession) sendStep(ctx context.Context, route, method, body string) (string, error) {
if s.conn == nil {
return "", errors.New("setup session: connection closed")
}
id := s.reqID.Add(1)
envelope := fmt.Sprintf(
`<msg><header deviceID="%s" url="%s" method="%s"><request requestID="%d"/></header><body>%s</body></msg>`,
xmlAttrEscape(s.deviceID), xmlAttrEscape(route), method, id, body,
)
deadline, ok := ctx.Deadline()
if !ok {
deadline = time.Now().Add(s.stepTimeout)
}
_ = s.conn.SetWriteDeadline(deadline)
if err := s.conn.WriteMessage(websocket.TextMessage, []byte(envelope)); err != nil {
return "", fmt.Errorf("send %s: %w", route, err)
}
idNeedle := fmt.Sprintf(`requestID="%d"`, id)
statusNeedle := fmt.Sprintf(`<status>/%s</status>`, route)
urlNeedle := fmt.Sprintf(`url="%s"`, route)
for {
_ = s.conn.SetReadDeadline(deadline)
_, data, err := s.conn.ReadMessage()
if err != nil {
return "", fmt.Errorf("await ack for %s: %w", route, err)
}
text := string(data)
// Pushed event frames during setup (sourcesUpdated etc.) and the
// SDK banner are not acks.
if strings.Contains(text, "<updates ") || strings.Contains(text, "<SoundTouchSdkInfo") {
continue
}
// Device-side errors surface as <error …/> in the body.
if strings.Contains(strings.ToLower(text), "<error") {
return text, fmt.Errorf("device rejected %s: %s", route, strings.TrimSpace(text))
}
if strings.Contains(text, idNeedle) || strings.Contains(text, statusNeedle) || strings.Contains(text, urlNeedle) {
return text, nil
}
}
}
// Start sends SETUP_START.
func (s *SetupSession) Start(ctx context.Context) error {
_, err := s.sendStep(ctx, "setup", "POST", `<setupState state="SETUP_START"/>`)
return err
}
// IdentifyEnter sends SETUP_IDENTIFY_DEVICE_ENTER. timeoutMs defaults to
// the value observed in captures (300 000 ms).
func (s *SetupSession) IdentifyEnter(ctx context.Context, timeoutMs int) error {
if timeoutMs <= 0 {
timeoutMs = 300000
}
body := fmt.Sprintf(`<setupState state="SETUP_IDENTIFY_DEVICE_ENTER" timeout="%d"/>`, timeoutMs)
_, err := s.sendStep(ctx, "setup", "POST", body)
return err
}
// SetLanguage POSTs sysLanguage. Code 2 = English.
func (s *SetupSession) SetLanguage(ctx context.Context, code int) error {
body := fmt.Sprintf(`<sysLanguage>%d</sysLanguage>`, code)
_, err := s.sendStep(ctx, "language", "POST", body)
return err
}
// Enter sends SETUP_ENTER.
func (s *SetupSession) Enter(ctx context.Context) error {
_, err := s.sendStep(ctx, "setup", "POST", `<setupState state="SETUP_ENTER"/>`)
return err
}
// IdentifyLeave sends SETUP_IDENTIFY_DEVICE_LEAVE.
func (s *SetupSession) IdentifyLeave(ctx context.Context) error {
_, err := s.sendStep(ctx, "setup", "POST", `<setupState state="SETUP_IDENTIFY_DEVICE_LEAVE"/>`)
return err
}
// SetName POSTs a device-name change. An empty name is a no-op.
func (s *SetupSession) SetName(ctx context.Context, name string) error {
if name == "" {
return nil
}
body := fmt.Sprintf(`<name>%s</name>`, xmlBodyEscape(name))
_, err := s.sendStep(ctx, "name", "POST", body)
return err
}
// SetMargeAccount sends the canonical PairDeviceWithAccount envelope.
// authToken defaults to "Bearer aftertouch" when empty — our local
// service does not validate it, but a non-empty value matches the
// official app's shape.
func (s *SetupSession) SetMargeAccount(ctx context.Context, accountID, authToken string) error {
if accountID == "" {
return errors.New("SetMargeAccount: accountID is required")
}
if authToken == "" {
authToken = "Bearer aftertouch"
}
body := fmt.Sprintf(
`<PairDeviceWithAccount><accountId>%s</accountId><userAuthToken>%s</userAuthToken></PairDeviceWithAccount>`,
xmlBodyEscape(accountID), xmlBodyEscape(authToken),
)
_, err := s.sendStep(ctx, "setMargeAccount", "POST", body)
return err
}
// Leave sends SETUP_LEAVE.
func (s *SetupSession) Leave(ctx context.Context) error {
_, err := s.sendStep(ctx, "setup", "POST", `<setupState state="SETUP_LEAVE"/>`)
return err
}
// PushCustomerSupportInfo triggers the post-setup telemetry sync. Harmless
// on our local service.
func (s *SetupSession) PushCustomerSupportInfo(ctx context.Context) error {
_, err := s.sendStep(ctx, "pushCustomerSupportInfoToMarge", "GET", "")
return err
}
// xmlAttrEscape escapes the small set of characters that would break an
// XML attribute context. We build envelopes by concatenation because the
// body fragments are already valid XML — running them through encoding/xml
// would re-escape nested tags.
func xmlAttrEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "\"", "&quot;")
s = strings.ReplaceAll(s, "<", "&lt;")
return s
}
// xmlBodyEscape escapes text-node content using the encoding/xml helper.
func xmlBodyEscape(s string) string {
var b strings.Builder
_ = xml.EscapeText(&b, []byte(s))
return b.String()
}
+312
View File
@@ -0,0 +1,312 @@
package setup
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
)
// fakeSpeaker is a minimal WebSocket endpoint that records frames sent by
// SetupSession and responds with canned replies. Each test wires its own
// reply policy by setting reply.
type fakeSpeaker struct {
server *httptest.Server
mu sync.Mutex
frames []string
reply func(frame string) []string
}
func newFakeSpeaker(t *testing.T) *fakeSpeaker {
t.Helper()
f := &fakeSpeaker{}
upgrader := websocket.Upgrader{
Subprotocols: []string{"gabbo"},
CheckOrigin: func(*http.Request) bool { return true },
}
f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
t.Logf("upgrade: %v", err)
return
}
defer func() { _ = conn.Close() }()
for {
_, data, err := conn.ReadMessage()
if err != nil {
return
}
f.mu.Lock()
f.frames = append(f.frames, string(data))
policy := f.reply
f.mu.Unlock()
var replies []string
if policy != nil {
replies = policy(string(data))
} else {
replies = []string{ackFor(string(data))}
}
for _, r := range replies {
if err := conn.WriteMessage(websocket.TextMessage, []byte(r)); err != nil {
return
}
}
}
}))
t.Cleanup(f.server.Close)
return f
}
// ackFor builds a minimal echo reply that carries the same requestID as
// the incoming frame, so the SetupSession's correlation logic accepts it.
func ackFor(frame string) string {
id := extractAttr(frame, `requestID="`, `"`)
return fmt.Sprintf(`<msg><header url="setup"><response requestID="%s"/></header><body><status>ok</status></body></msg>`, id)
}
func extractAttr(s, prefix, suffix string) string {
i := strings.Index(s, prefix)
if i < 0 {
return ""
}
rest := s[i+len(prefix):]
j := strings.Index(rest, suffix)
if j < 0 {
return ""
}
return rest[:j]
}
func (f *fakeSpeaker) recordedFrames() []string {
f.mu.Lock()
defer f.mu.Unlock()
out := make([]string, len(f.frames))
copy(out, f.frames)
return out
}
// dialFakeSession opens a SetupSession against the fake speaker. We turn
// the httptest server URL inside-out (http → ws, keep host:port) so the
// dialer reaches our handler.
func dialFakeSession(t *testing.T, f *fakeSpeaker, deviceID string) *SetupSession {
t.Helper()
u, err := url.Parse(f.server.URL)
if err != nil {
t.Fatalf("parse server URL: %v", err)
}
s, err := DialSetupSession(u.Host, deviceID, SetupSessionConfig{
StepTimeout: 2 * time.Second,
DialTimeout: 2 * time.Second,
WSScheme: "ws",
})
if err != nil {
t.Fatalf("DialSetupSession: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
return s
}
func TestSetupSession_SendsCanonicalEnvelopes(t *testing.T) {
f := newFakeSpeaker(t)
s := dialFakeSession(t, f, "AABBCCDDEEFF")
ctx := context.Background()
if err := s.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
if err := s.IdentifyEnter(ctx, 300000); err != nil {
t.Fatalf("IdentifyEnter: %v", err)
}
if err := s.SetLanguage(ctx, 2); err != nil {
t.Fatalf("SetLanguage: %v", err)
}
if err := s.Enter(ctx); err != nil {
t.Fatalf("Enter: %v", err)
}
if err := s.IdentifyLeave(ctx); err != nil {
t.Fatalf("IdentifyLeave: %v", err)
}
if err := s.SetName(ctx, "Living Room"); err != nil {
t.Fatalf("SetName: %v", err)
}
if err := s.SetMargeAccount(ctx, "1234567", ""); err != nil {
t.Fatalf("SetMargeAccount: %v", err)
}
if err := s.Leave(ctx); err != nil {
t.Fatalf("Leave: %v", err)
}
if err := s.PushCustomerSupportInfo(ctx); err != nil {
t.Fatalf("PushCustomerSupportInfo: %v", err)
}
frames := f.recordedFrames()
if len(frames) != 9 {
t.Fatalf("got %d frames, want 9: %v", len(frames), frames)
}
mustContain(t, frames[0], `deviceID="AABBCCDDEEFF"`, `url="setup"`, `method="POST"`, `<setupState state="SETUP_START"/>`)
mustContain(t, frames[1], `url="setup"`, `<setupState state="SETUP_IDENTIFY_DEVICE_ENTER" timeout="300000"/>`)
mustContain(t, frames[2], `url="language"`, `<sysLanguage>2</sysLanguage>`)
mustContain(t, frames[3], `<setupState state="SETUP_ENTER"/>`)
mustContain(t, frames[4], `<setupState state="SETUP_IDENTIFY_DEVICE_LEAVE"/>`)
mustContain(t, frames[5], `url="name"`, `<name>Living Room</name>`)
mustContain(t, frames[6], `url="setMargeAccount"`, `<accountId>1234567</accountId>`, `<userAuthToken>Bearer aftertouch</userAuthToken>`)
mustContain(t, frames[7], `<setupState state="SETUP_LEAVE"/>`)
mustContain(t, frames[8], `url="pushCustomerSupportInfoToMarge"`, `method="GET"`)
}
func TestSetupSession_RequestIDsAreUniquePerStep(t *testing.T) {
f := newFakeSpeaker(t)
s := dialFakeSession(t, f, "X")
ctx := context.Background()
if err := s.Start(ctx); err != nil {
t.Fatal(err)
}
if err := s.Enter(ctx); err != nil {
t.Fatal(err)
}
frames := f.recordedFrames()
id1 := extractAttr(frames[0], `requestID="`, `"`)
id2 := extractAttr(frames[1], `requestID="`, `"`)
if id1 == "" || id2 == "" {
t.Fatalf("missing requestIDs: %q %q", id1, id2)
}
if id1 == id2 {
t.Errorf("requestIDs must be unique per step, got %s twice", id1)
}
}
func TestSetupSession_IgnoresUpdatesFramesBeforeAck(t *testing.T) {
f := newFakeSpeaker(t)
f.reply = func(frame string) []string {
id := extractAttr(frame, `requestID="`, `"`)
// Push a sourcesUpdated frame first; the session must ignore it
// and keep reading until the actual ack arrives.
return []string{
`<updates deviceID="X"><sourcesUpdated/></updates>`,
`<SoundTouchSdkInfo build="x"/>`,
fmt.Sprintf(`<msg><header url="setup"><response requestID="%s"/></header><body><status>/setup</status></body></msg>`, id),
}
}
s := dialFakeSession(t, f, "X")
if err := s.Start(context.Background()); err != nil {
t.Fatalf("Start should succeed despite pushed update frames, got %v", err)
}
}
func TestSetupSession_SurfacesDeviceErrors(t *testing.T) {
f := newFakeSpeaker(t)
f.reply = func(frame string) []string {
return []string{
`<msg><header url="setMargeAccount"><response/></header><body><error value="1003" name="ACCOUNT_REJECTED">no</error></body></msg>`,
}
}
s := dialFakeSession(t, f, "X")
err := s.SetMargeAccount(context.Background(), "1234567", "")
if err == nil {
t.Fatal("expected error from <error/> body")
}
if !strings.Contains(err.Error(), "device rejected setMargeAccount") {
t.Errorf("err = %v, want to mention device rejection", err)
}
}
func TestSetupSession_RejectsEmptyDeviceID(t *testing.T) {
_, err := DialSetupSession("127.0.0.1:8080", "", SetupSessionConfig{})
if err == nil {
t.Fatal("expected error for empty deviceID")
}
}
func TestSetupSession_RejectsEmptyAccountID(t *testing.T) {
f := newFakeSpeaker(t)
s := dialFakeSession(t, f, "X")
err := s.SetMargeAccount(context.Background(), "", "")
if err == nil {
t.Fatal("expected error for empty accountID")
}
}
func TestSetupSession_EmptyNameIsNoOp(t *testing.T) {
f := newFakeSpeaker(t)
s := dialFakeSession(t, f, "X")
if err := s.SetName(context.Background(), ""); err != nil {
t.Fatalf("SetName(\"\") should be no-op, got %v", err)
}
if len(f.recordedFrames()) != 0 {
t.Errorf("expected no frames for empty name, got %v", f.recordedFrames())
}
}
func TestSetupSession_XMLAttributeEscape(t *testing.T) {
// Device names with special characters must not break the envelope.
f := newFakeSpeaker(t)
s := dialFakeSession(t, f, `quoted"<id>`)
if err := s.Start(context.Background()); err != nil {
t.Fatalf("Start: %v", err)
}
frames := f.recordedFrames()
if len(frames) != 1 {
t.Fatalf("want 1 frame, got %d", len(frames))
}
mustContain(t, frames[0], `deviceID="quoted&quot;&lt;id>"`)
}
func mustContain(t *testing.T, s string, needles ...string) {
t.Helper()
for _, n := range needles {
if !strings.Contains(s, n) {
t.Errorf("frame missing %q in: %s", n, s)
}
}
}
+67
View File
@@ -1,11 +1,13 @@
package setup
import (
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
@@ -18,13 +20,78 @@ type mockSSH struct {
uploadContentFunc func(content []byte, remotePath string) error
}
// probeScriptHeader is the first line of the batched probe script
// emitted by buildSpeakerProbeScript. We use it as a sentinel so that
// per-command test mocks (which only know `cat` / `[ -f ]` / etc.) can
// still satisfy GetMigrationSummary after the SSH probes were batched
// into a single Run() call — the mock synthesizes the framed probe
// response by invoking its existing runFunc for each path the script
// would have probed.
const probeScriptHeader = "echo '@SSH_OK@'"
func (m *mockSSH) Run(command string) (string, error) {
if strings.HasPrefix(command, probeScriptHeader) {
return m.synthesizeProbeResponse(command)
}
if m.runFunc != nil {
return m.runFunc(command)
}
return "", nil
}
// synthesizeProbeResponse parses the batched probe script for the file
// and existence paths it references, calls the test's runFunc to find
// out what each one "contains," and emits the framed response format
// that parseSpeakerProbe expects. Lets existing per-command test mocks
// drive the batched probe without any test-side changes.
//
// If runFunc errors on a simple reachability probe (`ls /`), we treat
// the SSH connection as down and return the same error — matching the
// behaviour tests expect when they wire a runFunc that errors on every
// command.
func (m *mockSSH) synthesizeProbeResponse(script string) (string, error) {
if m.runFunc == nil {
return "@SSH_OK@\n", nil
}
// SSH-reachability probe: if a simple read fails, the connection
// itself is "down" in mock-land and the real batched script would
// also have produced an error from ssh.Dial.
if _, err := m.runFunc("ls /"); err != nil {
return "", err
}
var out strings.Builder
out.WriteString("@SSH_OK@\n")
fileRE := regexp.MustCompile(`\[ -f '([^']+)' \]`)
for _, match := range fileRE.FindAllStringSubmatch(script, -1) {
path := match[1]
content, err := m.runFunc("cat " + path)
if err != nil || content == "" {
continue
}
out.WriteString("@FILE@" + path + "@\n")
out.WriteString(base64.StdEncoding.EncodeToString([]byte(content)))
out.WriteString("\n@END@\n")
}
existsRE := regexp.MustCompile(`\[ -e '([^']+)' \]`)
for _, match := range existsRE.FindAllStringSubmatch(script, -1) {
path := match[1]
if _, err := m.runFunc("[ -e " + path + " ]"); err == nil {
out.WriteString("@EXISTS@" + path + "@\n")
}
}
return out.String(), nil
}
func (m *mockSSH) UploadContent(content []byte, remotePath string) error {
if m.uploadContentFunc != nil {
return m.uploadContentFunc(content, remotePath)
+171
View File
@@ -0,0 +1,171 @@
package setup
import (
"bufio"
"encoding/base64"
"strings"
)
// speakerProbe is the result of a single batched SSH round-trip that
// gathers everything GetMigrationSummary needs in one go. Without it,
// the summary makes ~8 sequential SSH dials; pkg/ssh opens a fresh
// TCP+SSH handshake on every Run(), and SoundTouch firmware accepts
// only legacy crypto so each handshake is ~500 ms1 s. Batching collapses
// that to one handshake.
type speakerProbe struct {
// SSHOK reports whether the batched probe completed successfully.
// false implies SSH is unreachable, auth failed, or the script
// errored — in all cases GetMigrationSummary falls back to its
// non-SSH paths (telnet preflight, HTTPS probe, etc).
SSHOK bool
// Files maps absolute device paths to their decoded contents.
// Missing keys mean the file did not exist or could not be read.
Files map[string]string
// Exists is the set of probe paths that exist on the device (for
// directories or non-text files we only need a yes/no signal).
Exists map[string]bool
// Err carries the underlying SSH error if SSHOK is false.
Err error
}
// Probe paths used by the batched script. Keep this list in lockstep
// with the consumers in GetMigrationSummary.
var (
probeFilePaths = []string{
SoundTouchSdkPrivateCfgPath, // current XML config
SoundTouchSdkPrivateCfgPath + ".original", // backup XML config
"/etc/resolv.conf", // DNS resolver
"/etc/hosts", // hostname overrides
"/etc/pki/tls/certs/ca-bundle.crt", // CA trust store
}
probeExistsPaths = []string{
"/etc/remote_services", // SSH-enablement marker (persistent)
"/mnt/nv/remote_services", // SSH-enablement marker (persistent, NV)
"/tmp/remote_services", // SSH-enablement marker (volatile)
"/mnt/nv/aftertouch.resolv.conf",
}
)
// probeSpeakerSSH runs one shell script over a single SSH connection
// and parses the result into a speakerProbe. The script emits framed
// blocks per file (base64-encoded so newlines/binary don't break the
// parser) and EXISTS lines per probe path.
func (m *Manager) probeSpeakerSSH(deviceIP string) *speakerProbe {
probe := &speakerProbe{
Files: make(map[string]string),
Exists: make(map[string]bool),
}
if m.NewSSH == nil {
return probe
}
script := buildSpeakerProbeScript(probeFilePaths, probeExistsPaths)
client := m.NewSSH(deviceIP)
output, err := client.Run(script)
if err != nil {
probe.Err = err
return probe
}
parseSpeakerProbe(probe, output)
return probe
}
// buildSpeakerProbeScript composes the POSIX-sh script that does all the
// probes in one execution. Kept separate so tests can verify the script
// shape without having to mock an SSH transport.
func buildSpeakerProbeScript(filePaths, existsPaths []string) string {
var b strings.Builder
b.WriteString("echo '@SSH_OK@'\n")
for _, p := range filePaths {
b.WriteString("if [ -f '")
b.WriteString(p)
b.WriteString("' ]; then\n")
b.WriteString(" echo '@FILE@")
b.WriteString(p)
b.WriteString("@'\n")
b.WriteString(" base64 < '")
b.WriteString(p)
b.WriteString("' 2>/dev/null | tr -d '\\n'\n")
b.WriteString(" echo\n")
b.WriteString(" echo '@END@'\n")
b.WriteString("fi\n")
}
for _, p := range existsPaths {
b.WriteString("if [ -e '")
b.WriteString(p)
b.WriteString("' ]; then echo '@EXISTS@")
b.WriteString(p)
b.WriteString("@'; fi\n")
}
return b.String()
}
// parseSpeakerProbe parses the script's stdout into the probe struct.
// The format is line-oriented:
//
// @SSH_OK@ — sentinel: script ran to completion
// @FILE@<path>@ — start-of-file marker
// <base64 contents> — exactly one line of base64 (no newlines)
// @END@ — end-of-file marker
// @EXISTS@<path>@ — path-exists assertion
//
// We tolerate any other lines as stray output and skip them.
func parseSpeakerProbe(probe *speakerProbe, output string) {
scanner := bufio.NewScanner(strings.NewReader(output))
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
var (
inFile bool
currentPath string
b64 strings.Builder
)
for scanner.Scan() {
line := scanner.Text()
switch {
case line == "@SSH_OK@":
probe.SSHOK = true
case strings.HasPrefix(line, "@FILE@") && strings.HasSuffix(line, "@"):
currentPath = strings.TrimSuffix(strings.TrimPrefix(line, "@FILE@"), "@")
inFile = true
b64.Reset()
case line == "@END@":
if inFile && currentPath != "" {
if decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64.String())); err == nil {
probe.Files[currentPath] = string(decoded)
}
}
inFile = false
currentPath = ""
b64.Reset()
case strings.HasPrefix(line, "@EXISTS@") && strings.HasSuffix(line, "@"):
path := strings.TrimSuffix(strings.TrimPrefix(line, "@EXISTS@"), "@")
probe.Exists[path] = true
default:
if inFile {
b64.WriteString(line)
}
}
}
}
+184
View File
@@ -0,0 +1,184 @@
package setup
import (
"encoding/xml"
"fmt"
"net/url"
"os"
"strings"
)
// applyProbeToSummary populates the SSH-derived fields of a
// MigrationSummary directly from a batched speakerProbe. Mirrors what
// the per-helper path (checkCurrentConfig + checkRemoteServices +
// checkCACertTrusted + the inline resolv read) used to do across
// multiple SSH dials.
//
// One subtle difference from the legacy path: the original
// checkCurrentConfig has a fallback that reads the file via base64 when
// `cat` returns empty but the file has size > 0. The batched script
// already does base64 for every file, so that fallback is implicit —
// any readable file appears in probe.Files.
func (m *Manager) applyProbeToSummary(
summary *MigrationSummary,
probe *speakerProbe,
plannedCfg *PrivateCfg,
proxyURL, targetURL string,
options map[string]string,
) {
summary.SSHSuccess = probe.SSHOK
if !probe.SSHOK && probe.Err != nil {
summary.CurrentConfig = fmt.Sprintf("SSH connection failed: %v", probe.Err)
}
// Current SoundTouchSdkPrivateCfg.xml (+ .original backup, if any)
if cfg, ok := probe.Files[SoundTouchSdkPrivateCfgPath]; ok && cfg != "" {
summary.CurrentConfig = cfg
fmt.Printf("Current config from %s (length: %d):\n%q\n", probeDeviceTagFor(summary), len(cfg), cfg)
var currentCfg PrivateCfg
if xml.Unmarshal([]byte(cfg), &currentCfg) == nil {
summary.ParsedCurrentConfig = &currentCfg
if proxyURL == "" {
proxyURL = targetURL
}
if options != nil {
m.applyProxyOptions(plannedCfg, proxyURL, options, &currentCfg)
}
}
}
if orig, ok := probe.Files[SoundTouchSdkPrivateCfgPath+".original"]; ok && orig != "" {
summary.OriginalConfig = orig
}
// /etc/resolv.conf — cached so checkIsMigratedFromProbe doesn't dial again
if resolv, ok := probe.Files["/etc/resolv.conf"]; ok {
summary.CurrentResolvConf = resolv
}
// remote_services markers (SSH enablement state)
for _, loc := range []string{"/etc/remote_services", "/mnt/nv/remote_services", "/tmp/remote_services"} {
if probe.Exists[loc] {
summary.RemoteServicesFound = append(summary.RemoteServicesFound, loc)
summary.RemoteServicesEnabled = true
if loc != "/tmp/remote_services" {
summary.RemoteServicesPersistent = true
}
}
}
// CA trust: check the ca-bundle for our injection label first; fall
// back to matching the cert payload itself when Manager.Crypto is set
// (web-UI/in-process path; the remote CLI has no Crypto so this stays
// false until install-ca is run).
if bundle, ok := probe.Files["/etc/pki/tls/certs/ca-bundle.crt"]; ok && bundle != "" {
switch {
case strings.Contains(bundle, CALabel):
summary.CACertTrusted = true
case m.Crypto != nil:
if caCertPEM, err := os.ReadFile(m.Crypto.GetCACertPath()); err == nil {
for _, line := range strings.Split(string(caCertPEM), "\n") {
if line == "" || strings.Contains(line, "BEGIN CERTIFICATE") || strings.Contains(line, "END CERTIFICATE") {
continue
}
if strings.Contains(bundle, line) {
summary.CACertTrusted = true
}
break
}
}
}
}
}
// probeDeviceTagFor returns a short identifier for the device used in the
// "Current config from …" log line. We keep the legacy log shape so any
// downstream log scraping continues to work.
func probeDeviceTagFor(summary *MigrationSummary) string {
if summary.DeviceID != "" {
return summary.DeviceID
}
return "unknown"
}
// checkIsMigratedFromProbe is the probe-driven equivalent of
// checkIsMigrated. Unlike the legacy path it makes no fresh SSH dials —
// every file it inspects came from the single batched probe.
//
// Behavioural note: the legacy isResolvConfMigrated has a third fallback
// that DNS-resolves the target host via SSH (`getent` on the device) so
// it can match the *resolved* IP against resolv.conf. The probe path
// skips that — it would force a second SSH dial just for the corner
// case where the device's resolv.conf has the resolved IP but neither
// the marker comment nor the target hostname. In practice the hook file
// or marker comment is always present, so this is acceptable.
func (m *Manager) checkIsMigratedFromProbe(summary *MigrationSummary, probe *speakerProbe) {
summary.TelnetMigrated = m.isTelnetMigrated(summary)
if probe.SSHOK {
summary.XMLMigrated = m.isXMLMigrated(summary)
summary.HostsMigrated = isHostsMigratedFromProbe(probe, summary)
summary.ResolvMigrated = m.isResolvConfMigratedFromProbe(probe, summary)
}
summary.IsMigrated = summary.TelnetMigrated ||
summary.XMLMigrated ||
summary.HostsMigrated ||
summary.ResolvMigrated
}
func isHostsMigratedFromProbe(probe *speakerProbe, summary *MigrationSummary) bool {
hostsContent, ok := probe.Files["/etc/hosts"]
if !ok {
return false
}
boseDomains := []string{
"streaming.bose.com",
"updates.bose.com",
"stats.bose.com",
"bmx.bose.com",
}
for _, domain := range boseDomains {
if strings.Contains(hostsContent, domain) && summary.CACertTrusted {
return true
}
}
return false
}
func (m *Manager) isResolvConfMigratedFromProbe(probe *speakerProbe, summary *MigrationSummary) bool {
if probe.Exists["/mnt/nv/aftertouch.resolv.conf"] {
return summary.CACertTrusted
}
if summary.CurrentResolvConf == "" {
return false
}
if strings.Contains(summary.CurrentResolvConf, "# Priority nameserver for Bose service redirection") && summary.CACertTrusted {
return true
}
parsedTarget, err := url.Parse(m.ServerURL)
if err != nil {
return false
}
targetHost := parsedTarget.Hostname()
if strings.Contains(summary.CurrentResolvConf, targetHost) && summary.CACertTrusted {
return true
}
return false
}
+251
View File
@@ -0,0 +1,251 @@
package setup
import (
"context"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
// SpeakerSetupAP is the IP address a SoundTouch speaker assigns itself
// when in setup mode. Verified on ST10 (assigns 192.0.2.2 to the client
// via DHCP).
const SpeakerSetupAP = "192.0.2.1"
// DefaultWiFiSecurity matches what the official setup wizard sends for
// home networks. The device accepts this string for both WPA and WPA2.
const DefaultWiFiSecurity = "wpa_or_wpa2"
// PushWiFiCredentialsParams holds the inputs for PushWiFiCredentials.
type PushWiFiCredentialsParams struct {
// APHost is the speaker's setup-mode address. Defaults to SpeakerSetupAP.
APHost string
// SSID and Password identify the home network to join.
SSID string
Password string
// Security defaults to DefaultWiFiSecurity.
Security string
// HTTPClient lets callers (mostly tests) override the transport. Nil
// uses a 10-second default client.
HTTPClient *http.Client
}
// PushWiFiCredentials POSTs an AddWirelessProfile XML to the speaker's
// setup-mode endpoint, instructing it to drop AP mode and join the named
// network. The caller must already be connected to the speaker's Wi-Fi.
//
// The speaker confirms the request before disconnecting; expect to lose
// the AP link within ~30 seconds.
func PushWiFiCredentials(ctx context.Context, p PushWiFiCredentialsParams) error {
if p.SSID == "" {
return fmt.Errorf("PushWiFiCredentials: SSID is required")
}
host := p.APHost
if host == "" {
host = SpeakerSetupAP
}
security := p.Security
if security == "" {
security = DefaultWiFiSecurity
}
body := fmt.Sprintf(
`<AddWirelessProfile><profile ssid="%s" password="%s" securityType="%s" /></AddWirelessProfile>`,
xmlAttrEscape(p.SSID), xmlAttrEscape(p.Password), xmlAttrEscape(security),
)
hostPort := host
if _, _, err := net.SplitHostPort(host); err != nil {
hostPort = host + ":8090"
}
url := "http://" + hostPort + "/addWirelessProfile"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(body))
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "text/xml")
httpClient := p.HTTPClient
if httpClient == nil {
httpClient = &http.Client{Timeout: 10 * time.Second}
}
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("POST %s: %w", url, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("POST %s returned %d: %s", url, resp.StatusCode, strings.TrimSpace(string(respBody)))
}
return nil
}
// PollConfig governs the retry cadence of WaitForAP and WaitForOnline.
type PollConfig struct {
// Interval between probes. Default 2 s.
Interval time.Duration
// Timeout is the total wall-clock budget. Default 5 min.
Timeout time.Duration
}
func (c PollConfig) interval() time.Duration {
if c.Interval <= 0 {
return 2 * time.Second
}
return c.Interval
}
func (c PollConfig) timeout() time.Duration {
if c.Timeout <= 0 {
return 5 * time.Minute
}
return c.Timeout
}
// WaitForAP blocks until the speaker at apHost answers /info on its
// setup-mode HTTP endpoint, then returns the parsed info. apHost
// defaults to SpeakerSetupAP. The caller is expected to have switched
// the host machine to the speaker's setup-mode Wi-Fi network manually.
//
// HTTPGet is the dependency-injection point so tests can supply a fake
// without spinning a server on 192.0.2.1.
func WaitForAP(ctx context.Context, apHost string, cfg PollConfig, httpGet func(string) (*http.Response, error)) (*DeviceInfoXML, error) {
if apHost == "" {
apHost = SpeakerSetupAP
}
if httpGet == nil {
client := &http.Client{Timeout: 3 * time.Second}
httpGet = client.Get
}
deadline := time.Now().Add(cfg.timeout())
hostPort := apHost
if _, _, err := net.SplitHostPort(apHost); err != nil {
hostPort = apHost + ":8090"
}
infoURL := "http://" + hostPort + "/info"
for {
info, err := tryFetchInfo(httpGet, infoURL)
if err == nil && info != nil {
return info, nil
}
select {
case <-ctx.Done():
return nil, fmt.Errorf("WaitForAP: %w", ctx.Err())
case <-time.After(cfg.interval()):
}
if time.Now().After(deadline) {
return nil, fmt.Errorf("WaitForAP: %s did not respond within %s", infoURL, cfg.timeout())
}
}
}
// tryFetchInfo performs one /info probe; returns nil on any failure so
// the polling loop can decide whether to retry.
func tryFetchInfo(httpGet func(string) (*http.Response, error), infoURL string) (*DeviceInfoXML, error) {
resp, err := httpGet(infoURL)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
m := &Manager{}
var info DeviceInfoXML
if err := m.parseDeviceInfoXML(strings.NewReader(string(body)), &info); err != nil {
return nil, err
}
return &info, nil
}
// MDNSDiscoverer is the discovery-side capability WaitForOnline depends
// on. The real implementation is *discovery.MDNSDiscoveryService; tests
// inject a stub.
type MDNSDiscoverer interface {
DiscoverDevices(ctx context.Context) ([]*models.DiscoveredDevice, error)
}
// WaitForOnline polls mDNS for a SoundTouch speaker matching the given
// substring (typically a device-ID suffix such as "DE4803"). It returns
// the speaker's IP address as soon as it reappears on the home network
// after a Wi-Fi provision.
//
// matcher is matched case-insensitively against DiscoveredDevice.Name,
// SerialNo, and Host. An empty matcher returns the first speaker seen.
func WaitForOnline(ctx context.Context, matcher string, cfg PollConfig, mdns MDNSDiscoverer) (*models.DiscoveredDevice, error) {
if mdns == nil {
mdns = discovery.NewMDNSDiscoveryService(cfg.interval())
}
deadline := time.Now().Add(cfg.timeout())
needle := strings.ToLower(matcher)
for {
devs, _ := mdns.DiscoverDevices(ctx)
for _, d := range devs {
if needle == "" || matchesDevice(d, needle) {
return d, nil
}
}
select {
case <-ctx.Done():
return nil, fmt.Errorf("WaitForOnline: %w", ctx.Err())
case <-time.After(cfg.interval()):
}
if time.Now().After(deadline) {
return nil, fmt.Errorf("WaitForOnline: no speaker matching %q discovered within %s", matcher, cfg.timeout())
}
}
}
func matchesDevice(d *models.DiscoveredDevice, needle string) bool {
if d == nil {
return false
}
for _, candidate := range []string{d.Name, d.SerialNo, d.Host, d.UPnPSerial} {
if candidate != "" && strings.Contains(strings.ToLower(candidate), needle) {
return true
}
}
return false
}
+268
View File
@@ -0,0 +1,268 @@
package setup
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
func TestPushWiFiCredentials_BuildsCanonicalRequest(t *testing.T) {
var (
gotMethod string
gotPath string
gotCT string
gotBody string
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
gotCT = r.Header.Get("Content-Type")
b, _ := io.ReadAll(r.Body)
gotBody = string(b)
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8" ?><AddWirelessProfileResponse />`))
}))
defer srv.Close()
apHost := strings.TrimPrefix(srv.URL, "http://")
err := PushWiFiCredentials(context.Background(), PushWiFiCredentialsParams{
APHost: apHost,
SSID: "MyHomeNetwork",
Password: "s3cret",
// Security and HTTPClient default
})
if err != nil {
t.Fatalf("PushWiFiCredentials: %v", err)
}
if gotMethod != http.MethodPost {
t.Errorf("method = %s, want POST", gotMethod)
}
if gotPath != "/addWirelessProfile" {
t.Errorf("path = %s, want /addWirelessProfile", gotPath)
}
if gotCT != "text/xml" {
t.Errorf("content-type = %s, want text/xml", gotCT)
}
if !strings.Contains(gotBody, `ssid="MyHomeNetwork"`) {
t.Errorf("body missing ssid: %s", gotBody)
}
if !strings.Contains(gotBody, `password="s3cret"`) {
t.Errorf("body missing password: %s", gotBody)
}
if !strings.Contains(gotBody, `securityType="wpa_or_wpa2"`) {
t.Errorf("body should default to wpa_or_wpa2 security, got: %s", gotBody)
}
}
func TestPushWiFiCredentials_EscapesQuotesInCredentials(t *testing.T) {
var gotBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
gotBody = string(b)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
err := PushWiFiCredentials(context.Background(), PushWiFiCredentialsParams{
APHost: strings.TrimPrefix(srv.URL, "http://"),
SSID: `net "with quote`,
Password: `pa<ss>`,
})
if err != nil {
t.Fatalf("PushWiFiCredentials: %v", err)
}
// Quotes must be escaped so they don't break the attribute context.
if strings.Contains(gotBody, `ssid="net "with quote"`) {
t.Errorf("quotes in SSID must be escaped, got: %s", gotBody)
}
if !strings.Contains(gotBody, "&quot;") {
t.Errorf("expected &quot; escape in body: %s", gotBody)
}
}
func TestPushWiFiCredentials_RequiresSSID(t *testing.T) {
err := PushWiFiCredentials(context.Background(), PushWiFiCredentialsParams{Password: "x"})
if err == nil || !strings.Contains(err.Error(), "SSID") {
t.Errorf("err = %v, want SSID-required error", err)
}
}
func TestPushWiFiCredentials_SurfacesHTTPErrors(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte("nope"))
}))
defer srv.Close()
err := PushWiFiCredentials(context.Background(), PushWiFiCredentialsParams{
APHost: strings.TrimPrefix(srv.URL, "http://"),
SSID: "X",
})
if err == nil || !strings.Contains(err.Error(), "403") {
t.Errorf("err = %v, want to surface HTTP 403", err)
}
}
func TestWaitForAP_ReturnsOnceInfoSucceeds(t *testing.T) {
var calls atomic.Int32
httpGet := func(_ string) (*http.Response, error) {
c := calls.Add(1)
if c < 3 {
return nil, errors.New("no route to host")
}
body := `<info deviceID="AABBCCDDEEFF"><name>Bose SoundTouch DE4803</name><margeAccountUUID></margeAccountUUID></info>`
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(body)),
Header: http.Header{},
}, nil
}
cfg := PollConfig{Interval: 5 * time.Millisecond, Timeout: 500 * time.Millisecond}
info, err := WaitForAP(context.Background(), "", cfg, httpGet)
if err != nil {
t.Fatalf("WaitForAP: %v", err)
}
if info.DeviceID != "AABBCCDDEEFF" {
t.Errorf("DeviceID = %q, want AABBCCDDEEFF", info.DeviceID)
}
if calls.Load() < 3 {
t.Errorf("expected at least 3 polls, got %d", calls.Load())
}
}
func TestWaitForAP_TimesOut(t *testing.T) {
httpGet := func(_ string) (*http.Response, error) {
return nil, errors.New("network unreachable")
}
cfg := PollConfig{Interval: 5 * time.Millisecond, Timeout: 30 * time.Millisecond}
_, err := WaitForAP(context.Background(), "", cfg, httpGet)
if err == nil || !strings.Contains(err.Error(), "did not respond") {
t.Errorf("err = %v, want timeout error", err)
}
}
func TestWaitForAP_RespectsContextCancellation(t *testing.T) {
httpGet := func(_ string) (*http.Response, error) {
return nil, errors.New("network unreachable")
}
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(15 * time.Millisecond)
cancel()
}()
cfg := PollConfig{Interval: 5 * time.Millisecond, Timeout: 5 * time.Second}
_, err := WaitForAP(ctx, "", cfg, httpGet)
if err == nil || !strings.Contains(err.Error(), "context canceled") {
t.Errorf("err = %v, want context-cancellation error", err)
}
}
// stubMDNS is a controllable MDNSDiscoverer.
type stubMDNS struct {
results [][]*models.DiscoveredDevice
call atomic.Int32
}
func (s *stubMDNS) DiscoverDevices(_ context.Context) ([]*models.DiscoveredDevice, error) {
i := s.call.Add(1) - 1
if int(i) >= len(s.results) {
return nil, fmt.Errorf("exhausted")
}
return s.results[i], nil
}
func TestWaitForOnline_MatchesSubstringInNameOrSerial(t *testing.T) {
stub := &stubMDNS{
results: [][]*models.DiscoveredDevice{
nil, // first poll: nothing yet
{
{Name: "Other Bose Speaker", SerialNo: "AAAAAAAAAAAA", Host: "192.168.1.50"},
{Name: "Bose SoundTouch DE4803", SerialNo: "506583DE4803", Host: "192.168.1.42"},
},
},
}
cfg := PollConfig{Interval: 5 * time.Millisecond, Timeout: 500 * time.Millisecond}
d, err := WaitForOnline(context.Background(), "DE4803", cfg, stub)
if err != nil {
t.Fatalf("WaitForOnline: %v", err)
}
if d.Host != "192.168.1.42" {
t.Errorf("Host = %q, want 192.168.1.42", d.Host)
}
}
func TestWaitForOnline_EmptyMatcherReturnsFirst(t *testing.T) {
stub := &stubMDNS{
results: [][]*models.DiscoveredDevice{
{
{Name: "Bose SoundTouch DE4803", Host: "192.168.1.42"},
},
},
}
cfg := PollConfig{Interval: 5 * time.Millisecond, Timeout: 500 * time.Millisecond}
d, err := WaitForOnline(context.Background(), "", cfg, stub)
if err != nil {
t.Fatalf("WaitForOnline: %v", err)
}
if d.Host != "192.168.1.42" {
t.Errorf("Host = %q, want 192.168.1.42", d.Host)
}
}
func TestWaitForOnline_TimesOutWhenNoMatch(t *testing.T) {
stub := &stubMDNS{
results: [][]*models.DiscoveredDevice{
{{Name: "Wrong One", SerialNo: "X", Host: "192.168.1.99"}},
{{Name: "Wrong One", SerialNo: "X", Host: "192.168.1.99"}},
{{Name: "Wrong One", SerialNo: "X", Host: "192.168.1.99"}},
},
}
cfg := PollConfig{Interval: 5 * time.Millisecond, Timeout: 25 * time.Millisecond}
_, err := WaitForOnline(context.Background(), "DE4803", cfg, stub)
if err == nil || !strings.Contains(err.Error(), "no speaker matching") {
t.Errorf("err = %v, want no-match timeout error", err)
}
}