From 29a462da2bb8972e8741e85cc1f5ad5551b2b0f3 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Wed, 13 May 2026 00:43:00 +0200 Subject: [PATCH] feat(setup): add CLI setup command group for end-to-end speaker provisioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 envelope with timezoneInfo/timeFormat/brightnessLevel. Removes cmd/example-init-speaker (superseded by setup pair). Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/soundtouch-cli/cmd_clock.go | 27 + cmd/soundtouch-cli/cmd_setup.go | 1435 +++++++++++++++++++ cmd/soundtouch-cli/cmd_setup_test.go | 310 ++++ cmd/soundtouch-cli/main.go | 17 + docs/SUMMARY.md | 2 + docs/analysis/FACTORY-RESET-PROTOCOL.md | 136 ++ docs/analysis/SETUP-WEBSOCKET-EXPERIMENT.md | 215 +++ pkg/client/system_test.go | 7 +- pkg/models/clockdisplay.go | 215 ++- pkg/models/clockdisplay_test.go | 53 +- pkg/models/clocktime.go | 50 +- pkg/models/clocktime_test.go | 64 +- pkg/service/setup/build_https_url_test.go | 68 + pkg/service/setup/factory_reset.go | 73 + pkg/service/setup/factory_reset_test.go | 76 + pkg/service/setup/init_plan.go | 274 ++++ pkg/service/setup/init_plan_test.go | 380 +++++ pkg/service/setup/inspect.go | 157 ++ pkg/service/setup/inspect_test.go | 181 +++ pkg/service/setup/setup.go | 158 +- pkg/service/setup/setup_session.go | 304 ++++ pkg/service/setup/setup_session_test.go | 312 ++++ pkg/service/setup/setup_test.go | 67 + pkg/service/setup/ssh_probe.go | 171 +++ pkg/service/setup/ssh_probe_apply.go | 184 +++ pkg/service/setup/wifi_provision.go | 251 ++++ pkg/service/setup/wifi_provision_test.go | 268 ++++ 27 files changed, 5297 insertions(+), 158 deletions(-) create mode 100644 cmd/soundtouch-cli/cmd_setup.go create mode 100644 cmd/soundtouch-cli/cmd_setup_test.go create mode 100644 docs/analysis/FACTORY-RESET-PROTOCOL.md create mode 100644 docs/analysis/SETUP-WEBSOCKET-EXPERIMENT.md create mode 100644 pkg/service/setup/build_https_url_test.go create mode 100644 pkg/service/setup/factory_reset.go create mode 100644 pkg/service/setup/factory_reset_test.go create mode 100644 pkg/service/setup/init_plan.go create mode 100644 pkg/service/setup/init_plan_test.go create mode 100644 pkg/service/setup/inspect.go create mode 100644 pkg/service/setup/inspect_test.go create mode 100644 pkg/service/setup/setup_session.go create mode 100644 pkg/service/setup/setup_session_test.go create mode 100644 pkg/service/setup/ssh_probe.go create mode 100644 pkg/service/setup/ssh_probe_apply.go create mode 100644 pkg/service/setup/wifi_provision.go create mode 100644 pkg/service/setup/wifi_provision_test.go diff --git a/cmd/soundtouch-cli/cmd_clock.go b/cmd/soundtouch-cli/cmd_clock.go index e524eac..b35ca3f 100644 --- a/cmd/soundtouch-cli/cmd_clock.go +++ b/cmd/soundtouch-cli/cmd_clock.go @@ -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) diff --git a/cmd/soundtouch-cli/cmd_setup.go b/cmd/soundtouch-cli/cmd_setup.go new file mode 100644 index 0000000..a4e2e8c --- /dev/null +++ b/cmd/soundtouch-cli/cmd_setup.go @@ -0,0 +1,1435 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "strings" + "syscall" + "time" + + "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/gesellix/bose-soundtouch/pkg/service/constants" + "github.com/gesellix/bose-soundtouch/pkg/service/setup" + "github.com/urfave/cli/v2" + "golang.org/x/term" +) + +// setupCommand assembles the `soundtouch-cli setup …` command group. Each +// subcommand wraps an existing pkg/service/setup helper — there is no new +// business logic in this file, only flag parsing and progress reporting. +// +// The group covers the manual provisioning loop documented in +// docs/analysis/SETUP-WEBSOCKET-EXPERIMENT.md and docs/guides/DEVICE-INITIAL-SETUP.md: +// +// factory-reset → (manual: connect host to speaker's AP) +// wifi-push → (manual: switch host back to home Wi-Fi) +// wait-online → discover the speaker's new IP +// urls → point the speaker at AfterTouch +// pair → drive the WebSocket SETUP state machine +// +// The two "manual" lines are user-side Wi-Fi switches that cannot be +// automated portably (macOS, Linux, and Windows each use a different +// command). The wait-ap and wait-online subcommands poll for those +// switches so the user doesn't need to time them manually. +func setupCommand() *cli.Command { + return &cli.Command{ + Name: "setup", + Usage: "Provision a SoundTouch speaker end-to-end (factory reset, Wi-Fi, URLs, pairing)", + Subcommands: []*cli.Command{ + setupInspectCmd(), + setupFactoryResetCmd(), + setupWiFiPushCmd(), + setupWaitAPCmd(), + setupWaitOnlineCmd(), + setupSSHCheckCmd(), + setupInstallCACmd(), + setupMigrateCmd(), + setupRebootCmd(), + setupVerifyCmd(), + setupPlanCmd(), + setupPairCmd(), + }, + } +} + +func setupInspectCmd() *cli.Command { + return &cli.Command{ + Name: "inspect", + Usage: "Print a non-destructive snapshot of the speaker (identity, pairing, Wi-Fi, sources, presets)", + Before: RequireHost, + Flags: []cli.Flag{ + &cli.BoolFlag{Name: "telnet", Usage: "Also read runtime URLs via telnet getpdo (slower)"}, + }, + Action: func(c *cli.Context) error { + cfg := GetClientConfig(c) + m := setup.NewManager("", nil, nil) + + report := m.Inspect(cfg.Host, setup.InspectOptions{IncludeTelnet: c.Bool("telnet")}) + renderInspectReport(report) + + if report.InfoErr != nil { + return report.InfoErr + } + + return nil + }, + } +} + +func renderInspectReport(r *setup.InspectReport) { + fmt.Printf("Speaker @ %s\n", r.DeviceIP) + fmt.Println(strings.Repeat("─", 40)) + + if r.InfoErr != nil { + PrintError(fmt.Sprintf("/info: %v", r.InfoErr)) + } else if r.Info != nil { + i := r.Info + + fmt.Println("Identity") + fmt.Printf(" deviceID : %s\n", i.DeviceID) + + if suffix := deviceIDSuffix(i.DeviceID); suffix != "" { + fmt.Printf(" → use as --match suffix for wait-online: %s\n", suffix) + } + + fmt.Printf(" name : %s\n", i.Name) + fmt.Printf(" type : %s\n", i.Type) + + for _, comp := range i.Components { + if comp.SoftwareVersion != "" { + fmt.Printf(" softwareVersion : %s (component %s)\n", comp.SoftwareVersion, comp.Category) + } + + if comp.SerialNumber != "" { + fmt.Printf(" serialNumber : %s (component %s)\n", comp.SerialNumber, comp.Category) + } + } + + fmt.Println() + fmt.Println("Pairing") + + if i.MargeAccountUUID == "" { + PrintWarning("margeAccountUUID is empty — device is unpaired (factory-reset state)") + } else { + fmt.Printf(" margeAccountUUID : %s\n", i.MargeAccountUUID) + } + + fmt.Printf(" margeURL : %s\n", i.MargeURL) + fmt.Println() + } + + if r.NetworkErr != nil { + PrintError(fmt.Sprintf("/networkInfo: %v", r.NetworkErr)) + } else if r.Network != nil { + fmt.Println("Network") + + for _, iface := range r.Network.Interfaces.Interfaces { + fmt.Printf(" %s\n", iface.Type) + fmt.Printf(" state : %s\n", iface.State) + + if iface.IPAddress != "" { + fmt.Printf(" ipAddress : %s\n", iface.IPAddress) + } + + if iface.MacAddress != "" { + fmt.Printf(" macAddress : %s\n", iface.MacAddress) + } + + if iface.SSID != "" { + fmt.Printf(" ssid : %s\n", iface.SSID) + fmt.Printf(" → use as --ssid for wifi-push: %s\n", iface.SSID) + } + + if iface.Signal != "" { + fmt.Printf(" signal : %s\n", iface.Signal) + } + + if iface.FrequencyKHz != 0 { + fmt.Printf(" frequency : %d kHz\n", iface.FrequencyKHz) + } + } + + fmt.Println() + } + + if r.SourcesErr != nil { + PrintError(fmt.Sprintf("/sources: %v", r.SourcesErr)) + } else if r.Sources != nil { + fmt.Printf("Sources (%d)\n", len(r.Sources.SourceItem)) + renderSourceTable(r.Sources.SourceItem) + fmt.Println() + } + + if r.PresetsErr != nil { + PrintError(fmt.Sprintf("/presets: %v", r.PresetsErr)) + } else if r.Presets != nil { + fmt.Printf("Presets (%d)\n", len(r.Presets.Presets)) + + for _, p := range r.Presets.Presets { + fmt.Printf(" [%s] %s (source=%s)\n", p.ID, p.ContentItem.ItemName, p.ContentItem.Source) + } + + if len(r.Presets.Presets) == 0 { + fmt.Println(" (none)") + } + + fmt.Println() + } + + if r.RuntimeErr != nil { + PrintError(fmt.Sprintf("telnet getpdo: %v", r.RuntimeErr)) + } else if r.RuntimeURLs != "" { + fmt.Println("Runtime URL configuration (telnet getpdo)") + + for _, line := range strings.Split(r.RuntimeURLs, "\n") { + fmt.Printf(" %s\n", line) + } + + fmt.Println() + } +} + +// sourceLine is the per-source row before any width-padding decisions +// have been made. We build the whole table in memory so column widths +// can adjust to the widest entry — variable-length displayName and +// sourceAccount values would otherwise misalign every following column. +type sourceLine struct { + provider string + label string + status string + account string + flags string +} + +// renderSourceTable prints the inspect Sources block as a single tabular +// view with auto-sized columns. Each row may carry a long displayName +// (e.g. "AMAZON (amzn1.account.AFKTQOUN…)") or sourceAccount; rather +// than truncating, we let the columns grow. +func renderSourceTable(items []models.SourceItem) { + if len(items) == 0 { + fmt.Println(" (none)") + return + } + + rows := make([]sourceLine, 0, len(items)) + + for _, s := range items { + account := s.SourceAccount + if account == "" { + account = "—" + } + + // DisplayName is the app-facing label. Drop the parenthesised + // suffix when it equals sourceAccount (pure duplication of the + // next column) or the source key itself. Keep it for genuinely + // distinct names like AUX (AUX IN). + label := s.Source + + display := strings.TrimSpace(s.DisplayName) + if display != "" && + !strings.EqualFold(display, s.Source) && + !strings.EqualFold(display, s.SourceAccount) { + label = fmt.Sprintf("%s (%s)", s.Source, display) + } + + // providerID is a cloud-catalog concept — not present on the + // device's /sources response. Synthesize from + // constants.StaticProviders. Local-only sources (AUX, BLUETOOTH, + // LOCAL_INTERNET_RADIO) have no catalog entry by design. + // Labeled "provider#N" so readers don't mistake it for a + // per-source sourceID — no such field exists on /sources. + provider := "provider#?" + if id := lookupProviderID(s.Source); id > 0 { + provider = fmt.Sprintf("provider#%d", id) + } + + flags := "" + if s.IsLocal { + flags += " local" + } + + if s.MultiroomAllowed { + flags += " multiroom" + } + + rows = append(rows, sourceLine{ + provider: provider, + label: label, + status: "status=" + string(s.Status), + account: "account=" + account, + flags: strings.TrimSpace(flags), + }) + } + + maxProvider, maxLabel, maxStatus, maxAccount := 0, 0, 0, 0 + for _, r := range rows { + maxProvider = max(maxProvider, len(r.provider)) + maxLabel = max(maxLabel, len(r.label)) + maxStatus = max(maxStatus, len(r.status)) + maxAccount = max(maxAccount, len(r.account)) + } + + for _, r := range rows { + fmt.Printf(" %-*s %-*s %-*s %-*s", + maxProvider, r.provider, + maxLabel, r.label, + maxStatus, r.status, + maxAccount, r.account, + ) + + if r.flags != "" { + fmt.Printf(" %s", r.flags) + } + + fmt.Println() + } +} + +// lookupProviderID returns the AfterTouch-catalog providerID for the +// given source name (TUNEIN, SPOTIFY, …), or 0 when the name is not in +// constants.StaticProviders. Local-only sources like AUX, BLUETOOTH, +// LOCAL_INTERNET_RADIO have no catalog entry by design — they aren't +// cloud-provisioned. +func lookupProviderID(sourceName string) int { + for _, p := range constants.StaticProviders { + if p.Name == sourceName { + return p.ID + } + } + + return 0 +} + +// deviceIDSuffix returns the last 6 characters of a SoundTouch device ID, +// which is the canonical naming convention Bose uses ("Bose SoundTouch +// DE4803"). Empty when the input is shorter than 6 characters. +func deviceIDSuffix(deviceID string) string { + if len(deviceID) < 6 { + return "" + } + + return deviceID[len(deviceID)-6:] +} + +func setupFactoryResetCmd() *cli.Command { + return &cli.Command{ + Name: "factory-reset", + Usage: "Issue `sys factorydefault` over telnet (wipes account, presets, Wi-Fi)", + Before: RequireHost, + Action: func(c *cli.Context) error { + cfg := GetClientConfig(c) + m := setup.NewManager("", nil, nil) + + fmt.Printf("Sending factory-default to %s...\n", cfg.Host) + + logs, err := m.FactoryReset(cfg.Host) + if logs != "" { + fmt.Print(logs) + } + + if err != nil { + PrintError(err.Error()) + return err + } + + PrintSuccess("Factory reset accepted. The speaker is rebooting into setup mode.") + fmt.Println() + fmt.Println("Heads-up: just before resetting, the speaker sends DELETE /streaming/account/{id}/device/{id} to its current marge URL. If that URL still pointed at streaming.bose.com (not AfterTouch), AfterTouch keeps a stale datastore entry — migrate the speaker first if you want a clean account/device record.") + fmt.Println() + fmt.Println("Next: connect this host to the speaker's Wi-Fi AP") + fmt.Println(" - macOS: networksetup -setairportnetwork en0 \"Bose SoundTouch XXXX\"") + fmt.Println(" - Linux: nmcli device wifi connect \"Bose SoundTouch XXXX\"") + fmt.Println("Then run: soundtouch-cli setup wait-ap") + + return nil + }, + } +} + +func setupWiFiPushCmd() *cli.Command { + return &cli.Command{ + Name: "wifi-push", + Usage: "POST AddWirelessProfile to the speaker's setup-mode endpoint", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "ssid", Required: true, Usage: "Home Wi-Fi SSID the speaker should join"}, + &cli.StringFlag{Name: "pass", Required: true, Usage: "Home Wi-Fi password"}, + &cli.StringFlag{Name: "security", Value: setup.DefaultWiFiSecurity, Usage: "Security type (wpa_or_wpa2, wep, open)"}, + &cli.StringFlag{Name: "ap-host", Value: setup.SpeakerSetupAP, Usage: "Speaker's setup-mode IP"}, + &cli.DurationFlag{Name: "request-timeout", Value: 10 * time.Second}, + }, + Action: func(c *cli.Context) error { + params := setup.PushWiFiCredentialsParams{ + APHost: c.String("ap-host"), + SSID: c.String("ssid"), + Password: c.String("pass"), + Security: c.String("security"), + } + + ctx, cancel := context.WithTimeout(c.Context, c.Duration("request-timeout")) + defer cancel() + + fmt.Printf("Pushing Wi-Fi credentials to %s for SSID %q...\n", params.APHost, params.SSID) + + if err := setup.PushWiFiCredentials(ctx, params); err != nil { + PrintError(err.Error()) + return err + } + + PrintSuccess("Credentials accepted. The speaker is leaving AP mode.") + fmt.Println() + fmt.Println("Next: switch this host back to your home Wi-Fi, then run:") + fmt.Println(" soundtouch-cli setup wait-online --match=") + + return nil + }, + } +} + +func setupWaitAPCmd() *cli.Command { + return &cli.Command{ + Name: "wait-ap", + Usage: "Poll the speaker's setup-mode IP until /info responds", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "ap-host", Value: setup.SpeakerSetupAP, Usage: "Speaker's setup-mode IP"}, + &cli.DurationFlag{Name: "interval", Value: 2 * time.Second}, + &cli.DurationFlag{Name: "timeout", Value: 5 * time.Minute}, + }, + Action: func(c *cli.Context) error { + fmt.Printf("Waiting for %s to come up (interval=%s, timeout=%s)...\n", + c.String("ap-host"), c.Duration("interval"), c.Duration("timeout")) + + info, err := setup.WaitForAP( + c.Context, + c.String("ap-host"), + setup.PollConfig{Interval: c.Duration("interval"), Timeout: c.Duration("timeout")}, + nil, + ) + if err != nil { + PrintError(err.Error()) + return err + } + + PrintSuccess(fmt.Sprintf("Speaker reachable: deviceID=%s name=%q", info.DeviceID, info.Name)) + + return nil + }, + } +} + +func setupWaitOnlineCmd() *cli.Command { + return &cli.Command{ + Name: "wait-online", + Usage: "Poll mDNS until a speaker matching --match comes online", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "match", Usage: "Substring matched against speaker name/serial/IP (empty = first speaker seen)"}, + &cli.DurationFlag{Name: "interval", Value: 3 * time.Second}, + &cli.DurationFlag{Name: "timeout", Value: 5 * time.Minute}, + }, + Action: func(c *cli.Context) error { + fmt.Printf("Waiting for speaker matching %q via mDNS (interval=%s, timeout=%s)...\n", + c.String("match"), c.Duration("interval"), c.Duration("timeout")) + + d, err := setup.WaitForOnline( + c.Context, + c.String("match"), + setup.PollConfig{Interval: c.Duration("interval"), Timeout: c.Duration("timeout")}, + nil, + ) + if err != nil { + PrintError(err.Error()) + return err + } + + PrintSuccess(fmt.Sprintf("Speaker discovered: name=%q host=%s serial=%s", + d.Name, d.Host, d.SerialNo)) + + return nil + }, + } +} + +func setupSSHCheckCmd() *cli.Command { + return &cli.Command{ + Name: "ssh-check", + Usage: "Probe whether port 22 is reachable on the speaker (we never auto-enable SSH on modern firmware)", + Before: RequireHost, + Flags: []cli.Flag{ + &cli.DurationFlag{Name: "timeout", Value: 3 * time.Second}, + }, + Action: func(c *cli.Context) error { + cfg := GetClientConfig(c) + addr := fmt.Sprintf("%s:22", cfg.Host) + + fmt.Printf("Probing TCP %s (timeout=%s)...\n", addr, c.Duration("timeout")) + + conn, err := net.DialTimeout("tcp", addr, c.Duration("timeout")) + if err != nil { + PrintError(fmt.Sprintf("port 22 not reachable: %v", err)) + fmt.Println() + fmt.Println("Modern SoundTouch firmware (27.x) does not let us enable SSH from") + fmt.Println("telnet — those commands were removed. To enable SSH on the speaker:") + fmt.Println(" 1. Format a FAT32 USB stick.") + fmt.Println(" 2. Create an empty file named `remote_services` at its root.") + fmt.Println(" 3. Plug the stick into the speaker (rear USB port) while it is on.") + fmt.Println(" 4. Wait ~30 s; the speaker imports the flag and re-enables sshd.") + fmt.Println(" 5. Re-run `soundtouch-cli setup ssh-check` to confirm port 22.") + fmt.Println("See docs/guides/DEVICE-INITIAL-SETUP.md and docs/analysis/TELNET-COMMAND-REFERENCE.md.") + + return err + } + + _ = conn.Close() + + PrintSuccess("Port 22 is open — SSH is reachable.") + + return nil + }, + } +} + +func setupInstallCACmd() *cli.Command { + return &cli.Command{ + Name: "install-ca", + Usage: "Fetch AfterTouch's CA cert and inject it into the speaker's trust store via SSH", + Before: RequireHost, + Flags: []cli.Flag{ + &cli.StringFlag{Name: "service-url", Required: true, Usage: "AfterTouch base URL"}, + &cli.StringFlag{Name: "auth", Usage: "Basic-auth credentials for AfterTouch as user:pass (omit to be prompted on 401)"}, + }, + Action: func(c *cli.Context) error { + cfg := GetClientConfig(c) + serviceURL := strings.TrimRight(c.String("service-url"), "/") + + certPEM, err := fetchCACert(serviceURL, c.String("auth")) + if err != nil { + PrintError(err.Error()) + return err + } + + fmt.Printf("Fetched %d bytes of CA PEM from %s/setup/ca.crt\n", len(certPEM), serviceURL) + + m := setup.NewManager(serviceURL, nil, nil) + + logs, err := m.TrustCACertFromBytes(cfg.Host, certPEM) + if logs != "" { + fmt.Print(logs) + } + + if err != nil { + PrintError(err.Error()) + return err + } + + PrintSuccess("CA certificate installed in the speaker's trust bundle.") + + return nil + }, + } +} + +// fetchCACert pulls AfterTouch's CA bundle from /setup/ca.crt. On HTTP 401 +// it prompts interactively for basic-auth credentials (or accepts --auth) +// and retries once. +func fetchCACert(serviceURL, authFlag string) ([]byte, error) { + url := serviceURL + "/setup/ca.crt" + + doRequest := func(user, pass string) (*http.Response, error) { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + if user != "" { + req.SetBasicAuth(user, pass) + } + + client := &http.Client{Timeout: 10 * time.Second} + + return client.Do(req) + } + + user, pass := splitAuth(authFlag) + + resp, err := doRequest(user, pass) + if err != nil { + return nil, fmt.Errorf("GET %s: %w", url, err) + } + + if resp.StatusCode == http.StatusUnauthorized { + _ = resp.Body.Close() + + fmt.Printf("%s requires basic auth.\n", url) + + user, pass, err = promptBasicAuth() + if err != nil { + return nil, err + } + + resp, err = doRequest(user, pass) + if err != nil { + return nil, fmt.Errorf("GET %s (with auth): %w", url, err) + } + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("GET %s returned %d: %s", url, resp.StatusCode, strings.TrimSpace(string(body))) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read %s: %w", url, err) + } + + if !strings.Contains(string(body), "BEGIN CERTIFICATE") { + return nil, fmt.Errorf("response from %s is not a PEM certificate", url) + } + + return body, nil +} + +func splitAuth(spec string) (string, string) { + if spec == "" { + return "", "" + } + + i := strings.Index(spec, ":") + if i < 0 { + return spec, "" + } + + return spec[:i], spec[i+1:] +} + +// promptBasicAuth asks the user for credentials when AfterTouch returns 401. +// The password is read from stdin without echo so it doesn't end up in shell +// history or screen scrollback. +func promptBasicAuth() (string, string, error) { + fmt.Fprint(os.Stderr, "Username: ") + + reader := bufio.NewReader(os.Stdin) + + user, err := reader.ReadString('\n') + if err != nil { + return "", "", fmt.Errorf("read username: %w", err) + } + + user = strings.TrimRight(user, "\r\n") + + fmt.Fprint(os.Stderr, "Password: ") + + pass, err := term.ReadPassword(int(syscall.Stdin)) + + fmt.Fprintln(os.Stderr) + + if err != nil { + return "", "", fmt.Errorf("read password: %w", err) + } + + return user, string(pass), nil +} + +func setupMigrateCmd() *cli.Command { + return &cli.Command{ + Name: "migrate", + Usage: "Apply a migration method (telnet|hosts|resolv|xml) to point the speaker at AfterTouch", + Before: RequireHost, + Flags: []cli.Flag{ + &cli.StringFlag{Name: "service-url", Required: true, Usage: "AfterTouch base URL"}, + &cli.StringFlag{Name: "method", Value: string(setup.MigrationMethodTelnet), Usage: "telnet | hosts | resolv | xml"}, + &cli.StringFlag{Name: "proxy-url", Usage: "Optional upstream proxy URL (for --method=xml)"}, + &cli.BoolFlag{Name: "skip-preflight", Usage: "Skip the AfterTouch settings preflight (use when AfterTouch's settings endpoint is unreachable)"}, + }, + Action: func(c *cli.Context) error { + cfg := GetClientConfig(c) + method := setup.MigrationMethod(c.String("method")) + serviceURL := c.String("service-url") + + // For DNS-redirect methods, prove AfterTouch's DNS listener + // is alive by sending it a real query — that's the truth, + // regardless of what its settings claim. + if !c.Bool("skip-preflight") && (method == setup.MigrationMethodResolvConf || method == setup.MigrationMethodHosts) { + if err := requireAfterTouchDNSReachable(serviceURL); err != nil { + PrintError(err.Error()) + return err + } + + // The migration's internal CA-install step uses + // Manager.Crypto which is nil in the CLI flow. Pre-install + // the cert via the same HTTP path as `setup install-ca` + // so the migration finds CACertTrusted=true and skips. + // Idempotent — TrustCACertFromBytes deduplicates by label. + if err := preInstallCAForCLI(cfg.Host, serviceURL); err != nil { + PrintError(err.Error()) + return err + } + } + + fmt.Printf("Migrating %s → %s using method=%s\n", cfg.Host, serviceURL, method) + + m := setup.NewManager(serviceURL, nil, nil) + + logs, err := m.MigrateSpeaker(cfg.Host, serviceURL, c.String("proxy-url"), nil, method) + if logs != "" { + fmt.Print(logs) + } + + if err != nil { + PrintError(err.Error()) + return err + } + + PrintSuccess(fmt.Sprintf("Migration committed (method=%s). Reboot the speaker to apply the next-boot persistence layer.", method)) + + return nil + }, + } +} + +// preInstallCAForCLI fetches AfterTouch's CA cert and SSH-injects it +// into the speaker. Mirrors what `setup install-ca` does but inline, so +// the subsequent setup.MigrateSpeaker call (which would otherwise try +// to install the CA via the nil Manager.Crypto and NPE) finds the cert +// already trusted and skips its internal install step. +// +// All operations are idempotent: TrustCACertFromBytes rebuilds the +// bundle stripping any prior CALabel block before re-appending. +func preInstallCAForCLI(deviceIP, serviceURL string) error { + certPEM, err := fetchCACert(serviceURL, "") + if err != nil { + return fmt.Errorf("pre-install CA: %w", err) + } + + m := setup.NewManager(serviceURL, nil, nil) + + if _, err := m.TrustCACertFromBytes(deviceIP, certPEM); err != nil { + return fmt.Errorf("pre-install CA: %w", err) + } + + return nil +} + +// requireAfterTouchDNSReachable sends a real DNS query to AfterTouch's +// port-53 listener and confirms it responds. This is the ground-truth +// preflight for DNS-redirect migration methods — config inspection (the +// previous approach via GET /setup/settings) can lag the actual listener +// state and can't tell us whether queries succeed end-to-end. +// +// We query a known-intercepted hostname (streaming.bose.com). Any IP in +// the response proves AfterTouch's DNS is alive on :53; if the listener +// is down the custom Dial just times out and the user gets a clear error. +func requireAfterTouchDNSReachable(serviceURL string) error { + parsed, err := url.Parse(serviceURL) + if err != nil { + return fmt.Errorf("preflight: parse service URL %q: %w", serviceURL, err) + } + + host := parsed.Hostname() + if host == "" { + return fmt.Errorf("preflight: service URL %q has no hostname", serviceURL) + } + + resolver := &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, _, _ string) (net.Conn, error) { + d := net.Dialer{Timeout: 3 * time.Second} + return d.DialContext(ctx, "udp", net.JoinHostPort(host, "53")) + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + ips, err := resolver.LookupHost(ctx, "streaming.bose.com") + if err != nil { + return fmt.Errorf( + "preflight: DNS query to %s:53 failed: %w. AfterTouch's DNS listener is unreachable or not bound to port 53. Use --skip-preflight to bypass once you've verified DNS some other way", + host, err, + ) + } + + if len(ips) == 0 { + return fmt.Errorf("preflight: %s:53 returned no answers for streaming.bose.com — listener may be misconfigured", host) + } + + return nil +} + +func setupVerifyCmd() *cli.Command { + return &cli.Command{ + Name: "verify", + Usage: "Read-only status probe across all migration axes — doubles as preflight before applying " + + "and verification after applying. Wraps Manager.GetMigrationSummary.", + Before: RequireHost, + Flags: []cli.Flag{ + &cli.StringFlag{Name: "service-url", Required: true, Usage: "AfterTouch base URL"}, + &cli.StringFlag{Name: "proxy-url", Usage: "Optional upstream proxy URL (matches the --method=xml path)"}, + }, + Action: func(c *cli.Context) error { + cfg := GetClientConfig(c) + serviceURL := c.String("service-url") + + m := setup.NewManager(serviceURL, nil, nil) + + summary, err := m.GetMigrationSummary(cfg.Host, serviceURL, c.String("proxy-url"), nil) + if err != nil { + PrintError(err.Error()) + return err + } + + renderMigrationSummary(cfg.Host, serviceURL, summary) + + // Non-zero exit when nothing is migrated so this command is + // usable as a CI gate. Warnings alone don't fail the call. + if !summary.IsMigrated { + return fmt.Errorf("speaker is not migrated (no method reports green)") + } + + return nil + }, + } +} + +// renderMigrationSummary prints a grouped, glyph-prefixed view of a +// MigrationSummary. Each line uses [✓] / [✗] / ⚠ to make scan-reading +// fast. Identical data shape to what the web UI's preflight panel +// renders — see pkg/service/handlers/web/js/script.js. +func renderMigrationSummary(deviceIP, serviceURL string, s *setup.MigrationSummary) { + checkmark := func(b bool) string { + if b { + return "[✓]" + } + + return "[✗]" + } + + fmt.Printf("Status @ %s (target: %s)\n", deviceIP, serviceURL) + fmt.Println(strings.Repeat("═", 60)) + + fmt.Println("Identity") + fmt.Printf(" deviceID : %s\n", s.DeviceID) + fmt.Printf(" name : %s\n", s.DeviceName) + fmt.Printf(" model : %s\n", s.DeviceModel) + fmt.Printf(" firmware : %s\n", s.FirmwareVersion) + fmt.Printf(" serial : %s\n", s.DeviceSerial) + fmt.Println() + + fmt.Println("Pairing") + + if s.IsPaired { + fmt.Printf(" [✓] paired (margeAccountUUID=%s)\n", s.AccountID) + } else { + fmt.Println(" [✗] unpaired — device reports empty margeAccountUUID") + } + + fmt.Println() + + fmt.Println("Transport") + fmt.Printf(" %s SSH (port 22, full system access)\n", checkmark(s.SSHSuccess)) + fmt.Printf(" %s Telnet (port 17000, diagnostic shell)\n", checkmark(s.TelnetReachable)) + + if s.TelnetBanner != "" { + fmt.Printf(" banner: %q\n", s.TelnetBanner) + } + + if s.TelnetProbeError != "" { + fmt.Printf(" probe error: %s\n", s.TelnetProbeError) + } + + fmt.Println() + + fmt.Println("SSH enablement (remote_services)") + fmt.Printf(" %s enabled\n", checkmark(s.RemoteServicesEnabled)) + fmt.Printf(" %s persistent across reboot\n", checkmark(s.RemoteServicesPersistent)) + + if len(s.RemoteServicesFound) > 0 { + fmt.Printf(" found: %s\n", strings.Join(s.RemoteServicesFound, ", ")) + } + + if s.RemoteServicesCheckErr != "" { + fmt.Printf(" probe error: %s\n", s.RemoteServicesCheckErr) + } + + fmt.Println() + + fmt.Println("CA certificate") + fmt.Printf(" %s AfterTouch CA trusted by the speaker\n", checkmark(s.CACertTrusted)) + + if s.ServerHTTPSURL != "" { + fmt.Printf(" HTTPS endpoint: %s\n", s.ServerHTTPSURL) + } + + fmt.Println() + + fmt.Printf("Migration state (overall: %s migrated)\n", checkmark(s.IsMigrated)) + fmt.Printf(" %s telnet (envswitch + sys configuration)\n", checkmark(s.TelnetMigrated)) + fmt.Printf(" %s xml (SoundTouchSdkPrivateCfg.xml on disk)\n", checkmark(s.XMLMigrated)) + fmt.Printf(" %s hosts (/etc/hosts entries — deprecated)\n", checkmark(s.HostsMigrated)) + fmt.Printf(" %s resolv (/etc/resolv.conf via DHCP hook)\n", checkmark(s.ResolvMigrated)) + fmt.Println() + + if s.MirrorEnabled || len(s.MirrorEndpoints) > 0 { + fmt.Println("Mirroring") + fmt.Printf(" %s enabled\n", checkmark(s.MirrorEnabled)) + + if len(s.MirrorEndpoints) > 0 { + fmt.Printf(" endpoints: %s\n", strings.Join(s.MirrorEndpoints, ", ")) + } + + if len(s.SkipMirrorEndpoints) > 0 { + fmt.Printf(" skip: %s\n", strings.Join(s.SkipMirrorEndpoints, ", ")) + } + + fmt.Println() + } + + if len(s.Warnings) > 0 { + fmt.Println("Warnings") + + for _, w := range s.Warnings { + PrintWarning(w) + } + + fmt.Println() + } + + if s.ResolveIPError != "" { + PrintError("Resolve IP error: " + s.ResolveIPError) + } +} + +func setupRebootCmd() *cli.Command { + return &cli.Command{ + Name: "reboot", + Usage: "Reboot the speaker (forces the envswitch parallel-persistence layer to apply)", + Before: RequireHost, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "method", + Value: string(setup.RebootMethodTelnet), + Usage: "ssh | telnet — telnet works without SSH on modern firmware", + }, + }, + Action: func(c *cli.Context) error { + cfg := GetClientConfig(c) + method := setup.RebootMethod(c.String("method")) + + m := setup.NewManager("", nil, nil) + + fmt.Printf("Rebooting %s via %s...\n", cfg.Host, method) + + logs, err := m.Reboot(cfg.Host, method) + if logs != "" { + fmt.Print(logs) + } + + if err != nil { + PrintError(err.Error()) + return err + } + + PrintSuccess("Reboot signal sent. The speaker will be unreachable for ~30 s.") + + return nil + }, + } +} + +// planStep is one recommended action in the output of `setup plan`. +// title and reason go to the human; cmd is the exact CLI line to run. +// manual=true means the user has to do it themselves (network switch). +type planStep struct { + title string + cmd string + reason string + manual bool +} + +func setupPlanCmd() *cli.Command { + return &cli.Command{ + Name: "plan", + Usage: "Recommend the next setup/migration steps based on inspect + verify state. " + + "Use --reset to plan a full factory-reset + re-provisioning sequence.", + Before: RequireHost, + Flags: []cli.Flag{ + &cli.StringFlag{Name: "service-url", Required: true, Usage: "AfterTouch base URL"}, + &cli.BoolFlag{Name: "reset", Usage: "Plan a full factory-reset → Wi-Fi → migrate → pair flow"}, + &cli.StringFlag{Name: "wifi-ssid", Usage: "Home Wi-Fi SSID for the wifi-push step (default: re-use the SSID inspect found)"}, + &cli.BoolFlag{Name: "include-pair", Value: true, Usage: "Include the pair step (disable if you'll pair manually)"}, + }, + Action: func(c *cli.Context) error { + cfg := GetClientConfig(c) + serviceURL := c.String("service-url") + reset := c.Bool("reset") + wifiSSID := c.String("wifi-ssid") + includePair := c.Bool("include-pair") + + m := setup.NewManager(serviceURL, nil, nil) + + fmt.Printf("Probing %s …\n\n", cfg.Host) + + inspectReport := m.Inspect(cfg.Host, setup.InspectOptions{}) + summary, summaryErr := m.GetMigrationSummary(cfg.Host, serviceURL, "", nil) + + renderPlanState(cfg.Host, inspectReport, summary, summaryErr) + fmt.Println() + + if inspectReport.InfoErr != nil && !reset { + PrintError("Speaker not reachable on :8090 — can't plan a non-reset migration.") + fmt.Println("Either check the network / IP, or run again with --reset for the factory-reset path.") + + return inspectReport.InfoErr + } + + steps := buildPlanSteps(cfg.Host, serviceURL, wifiSSID, includePair, reset, inspectReport, summary) + + if reset { + renderPreResetNote() + } + + renderPlanSteps(steps) + + return nil + }, + } +} + +// renderPlanState prints the brief one-screen status used at the top of +// `setup plan`. It's a deliberately compact summary — for the full +// inspect output use `setup inspect`, for the full verify output use +// `setup verify`. +func renderPlanState(deviceIP string, inspect *setup.InspectReport, summary *setup.MigrationSummary, summaryErr error) { + check := func(b bool) string { + if b { + return "[✓]" + } + + return "[✗]" + } + + fmt.Printf("State @ %s\n", deviceIP) + fmt.Println(strings.Repeat("─", 40)) + + if inspect.Info != nil { + i := inspect.Info + fmt.Printf(" deviceID=%s name=%q firmware=%s\n", i.DeviceID, i.Name, firmwareOf(i)) + fmt.Printf(" margeURL=%s\n", i.MargeURL) + } else { + PrintError(fmt.Sprintf("/info: %v", inspect.InfoErr)) + } + + if summaryErr != nil { + PrintError(fmt.Sprintf("verify: %v", summaryErr)) + return + } + + currentSSID := "" + + if inspect.Network != nil { + for _, iface := range inspect.Network.Interfaces.Interfaces { + if iface.SSID != "" { + currentSSID = iface.SSID + break + } + } + } + + if currentSSID != "" { + fmt.Printf(" ssid=%q\n", currentSSID) + } + + fmt.Printf(" %s SSH (port 22) %s Telnet (port 17000) %s CA trusted\n", + check(summary.SSHSuccess), check(summary.TelnetReachable), check(summary.CACertTrusted)) + fmt.Printf(" %s paired %s migrated (telnet=%s xml=%s hosts=%s resolv=%s)\n", + check(summary.IsPaired), check(summary.IsMigrated), + yesNo(summary.TelnetMigrated), yesNo(summary.XMLMigrated), + yesNo(summary.HostsMigrated), yesNo(summary.ResolvMigrated)) +} + +func firmwareOf(info *setup.DeviceInfoXML) string { + for _, c := range info.Components { + if c.SoftwareVersion != "" { + return c.SoftwareVersion + } + } + + return "" +} + +func yesNo(b bool) string { + if b { + return "y" + } + + return "n" +} + +// buildPlanSteps is the recommendation engine. Decision order: +// +// 1. --reset path adds factory-reset → manual AP switch → wait-ap → +// wifi-push → manual home-Wi-Fi switch → wait-online at the top. +// 2. If already migrated and paired (and not --reset): empty plan. +// 3. Otherwise pick a migration method based on capabilities: +// telnet first (no SSH required), resolv second (DNS-based, needs +// SSH + CA), USB-stick advice when no transport works. +// 4. Append pair step unless --include-pair=false or already paired. +func buildPlanSteps( + host, serviceURL, wifiSSID string, + includePair, reset bool, + inspect *setup.InspectReport, + summary *setup.MigrationSummary, +) []planStep { + var steps []planStep + + if reset { + steps = append(steps, planStep{ + title: "Factory-reset the speaker", + cmd: fmt.Sprintf("soundtouch-cli setup factory-reset --host=%s", host), + reason: "Wipes account pairing, presets, Wi-Fi — gives a clean baseline for the SETUP state machine.", + }) + + steps = append(steps, planStep{ + title: "Connect this host to the speaker's setup AP", + cmd: `# macOS: networksetup -setairportnetwork en0 "Bose SoundTouch XXXX"`, + reason: "After reset the speaker broadcasts its own Wi-Fi at 192.0.2.1.", + manual: true, + }) + + steps = append(steps, planStep{ + title: "Wait for the speaker's setup-mode IP to respond", + cmd: "soundtouch-cli setup wait-ap", + }) + + ssidArg := wifiSSID + if ssidArg == "" { + if currentSSID := inspectedSSID(inspect); currentSSID != "" { + ssidArg = currentSSID + } else { + ssidArg = "" + } + } + + steps = append(steps, planStep{ + title: "Push home Wi-Fi credentials to the speaker", + cmd: fmt.Sprintf(`soundtouch-cli setup wifi-push --ssid=%q --pass=`, ssidArg), + reason: "Speaker leaves AP mode and joins your home network within ~30 s.", + }) + + steps = append(steps, planStep{ + title: "Switch this host back to home Wi-Fi", + cmd: fmt.Sprintf(`# macOS: networksetup -setairportnetwork en0 %q `, ssidArg), + reason: "Required so wait-online's mDNS browse reaches the right network segment.", + manual: true, + }) + + match := "" + if inspect.Info != nil { + match = deviceIDSuffix(inspect.Info.DeviceID) + } + + if match == "" { + match = "" + } + + steps = append(steps, planStep{ + title: "Discover the speaker's new IP via mDNS", + cmd: fmt.Sprintf("soundtouch-cli setup wait-online --match=%s", match), + }) + + host = "" // subsequent commands target the discovered IP + } + + if !reset && summary != nil && summary.IsMigrated && (!includePair || summary.IsPaired) { + return steps + } + + if !reset && (summary == nil || !summary.IsMigrated) { + method, methodReason := recommendMigrationMethod(serviceURL, summary) + if method == "" { + steps = append(steps, planStep{ + title: "Enable SSH on the speaker (USB-stick procedure)", + cmd: "# See `soundtouch-cli setup ssh-check` output for the USB-stick steps.", + reason: "Telnet won't respond and SSH is closed — no transport available to apply a migration.", + manual: true, + }) + } else { + if method == setup.MigrationMethodResolvConf || method == setup.MigrationMethodHosts { + if summary != nil && !summary.CACertTrusted { + steps = append(steps, planStep{ + title: "Install AfterTouch's CA cert on the speaker", + cmd: fmt.Sprintf("soundtouch-cli setup install-ca --host=%s --service-url=%s", + host, serviceURL), + reason: "DNS-redirect methods keep using https://*.bose.com URLs — the device needs to trust AfterTouch's cert.", + }) + } + } + + steps = append(steps, planStep{ + title: fmt.Sprintf("Apply URL migration using method=%s", method), + cmd: fmt.Sprintf("soundtouch-cli setup migrate --host=%s --service-url=%s --method=%s", + host, serviceURL, method), + reason: methodReason, + }) + + steps = append(steps, planStep{ + title: "Reboot the speaker", + cmd: fmt.Sprintf("soundtouch-cli setup reboot --host=%s", host), + reason: "The envswitch parallel-persistence layer only fully wins on next boot; reboot now to lock the new URLs in before pairing.", + }) + } + } else if reset { + // Reset path always re-applies a migration after wifi-push. + method, methodReason := recommendMigrationMethod(serviceURL, nil) + if method == "" { + method = setup.MigrationMethodTelnet + methodReason = "Default: envswitch — works on most firmware-27 devices without SSH." + } + + steps = append(steps, planStep{ + title: fmt.Sprintf("Apply URL migration using method=%s", method), + cmd: fmt.Sprintf("soundtouch-cli setup migrate --host=%s --service-url=%s --method=%s", + host, serviceURL, method), + reason: methodReason, + }) + + steps = append(steps, planStep{ + title: "Reboot the speaker", + cmd: fmt.Sprintf("soundtouch-cli setup reboot --host=%s", host), + reason: "The envswitch parallel-persistence layer only fully wins on next boot; reboot now to lock the new URLs in before pairing.", + }) + } + + if includePair && (reset || (summary != nil && !summary.IsPaired)) { + steps = append(steps, planStep{ + title: "Pair the device with an AfterTouch account", + cmd: fmt.Sprintf("soundtouch-cli setup pair --host=%s --service-url=%s", host, serviceURL), + reason: "Required for preset persistence, streaming services, multi-room zones.", + }) + } + + return steps +} + +// recommendMigrationMethod picks a migration method from the speaker's +// current capabilities. Returns "" when no transport is available. +// +// Preference order: +// +// 1. telnet (envswitch + sys configuration) — no SSH required, works on +// firmware-27 devices that we've tested. Doesn't need CA when the +// service URL is HTTP. +// 2. resolv (DNS-redirect via /etc/resolv.conf) — needs SSH + CA, but is +// the only method that doesn't depend on telnet `envswitch` being +// accepted. +// +// We deliberately don't recommend xml (legacy SSH XML rewrite — fragile) +// or hosts (deprecated — superseded by resolv). +func recommendMigrationMethod(serviceURL string, summary *setup.MigrationSummary) (setup.MigrationMethod, string) { + if summary != nil && summary.TelnetReachable { + reason := "Telnet (port 17000) responds — simplest path, no SSH or CA cert required." + + if strings.HasPrefix(serviceURL, "https://") { + reason += " Note: an HTTPS service URL still needs the speaker to trust AfterTouch's cert; run `setup install-ca` first." + } + + return setup.MigrationMethodTelnet, reason + } + + if summary != nil && summary.SSHSuccess { + return setup.MigrationMethodResolvConf, "Telnet unavailable but SSH works — DNS redirect via /etc/resolv.conf is the most flexible alternative." + } + + if summary == nil { + return setup.MigrationMethodTelnet, "Speaker not reachable yet — telnet path will be tried after wifi-push." + } + + return "", "" +} + +func inspectedSSID(r *setup.InspectReport) string { + if r == nil || r.Network == nil { + return "" + } + + for _, iface := range r.Network.Interfaces.Interfaces { + if iface.SSID != "" { + return iface.SSID + } + } + + return "" +} + +// renderPreResetNote emits a one-liner explaining the DELETE-on-reset +// semantic. Surfaced both here (in plan output) and in the factory-reset +// command so users see it regardless of which verb they ran first. +func renderPreResetNote() { + fmt.Println("Note: when factory-reset runs, the speaker sends DELETE /streaming/account/{id}/device/{id} to its current marge URL just before wiping. If that URL still points at streaming.bose.com, AfterTouch never sees it and keeps a stale entry — run migrate (steps below) before reset for a clean datastore record.") + fmt.Println() +} + +func renderPlanSteps(steps []planStep) { + if len(steps) == 0 { + PrintSuccess("Speaker is already migrated and paired. No action required.") + return + } + + fmt.Println("Recommended steps") + fmt.Println(strings.Repeat("─", 40)) + + for i, step := range steps { + marker := "•" + if step.manual { + marker = "✋" + } + + fmt.Printf(" %d. %s %s\n", i+1, marker, step.title) + + if step.reason != "" { + fmt.Printf(" └─ %s\n", step.reason) + } + + fmt.Printf(" $ %s\n\n", step.cmd) + } + + fmt.Println("Run them in order. Manual lines (✋) require you to switch Wi-Fi networks on this host before proceeding.") +} + +func setupPairCmd() *cli.Command { + return &cli.Command{ + Name: "pair", + Usage: "Pair the speaker with an account via WebSocket SETUP state machine", + Before: RequireHost, + Flags: []cli.Flag{ + &cli.StringFlag{Name: "account", Usage: "7-digit account ID (empty = generate)"}, + &cli.StringFlag{Name: "mode", Value: "full", Usage: "full (state machine) or bare (setMargeAccount only — experimental)"}, + &cli.StringFlag{Name: "service-url", Value: "http://aftertouch.local:8000", Usage: "AfterTouch base URL (used by mode=full for defaults)"}, + &cli.StringFlag{Name: "name", Usage: "Speaker name to set during pairing (empty = keep current)"}, + &cli.IntFlag{Name: "language", Value: setup.LanguageEnglish, Usage: "sysLanguage code (2 = English)"}, + &cli.DurationFlag{Name: "step-timeout", Value: 8 * time.Second}, + }, + Action: func(c *cli.Context) error { + cfg := GetClientConfig(c) + mode := c.String("mode") + accountID := c.String("account") + + if accountID == "" { + generated, err := setup.GenerateAccountID(nil) + if err != nil { + return fmt.Errorf("generate account id: %w", err) + } + + accountID = generated + + fmt.Printf("Generated account id: %s\n", accountID) + } + + if !setup.IsValidAccountID(accountID) { + return fmt.Errorf("invalid account id %q: must be 7 digits", accountID) + } + + switch mode { + case "bare": + return runPairBare(c, cfg.Host, accountID) + case "full": + return runPairFull(c, cfg.Host, accountID) + default: + return fmt.Errorf("unknown --mode=%q (want full or bare)", mode) + } + }, + } +} + +func runPairBare(c *cli.Context, deviceIP, accountID string) error { + m := setup.NewManager("", nil, nil) + + info, err := m.GetLiveDeviceInfo(deviceIP) + if err != nil { + return fmt.Errorf("read /info: %w", err) + } + + fmt.Printf("pre /info deviceID=%s margeAccountUUID=%q margeURL=%q\n", + info.DeviceID, info.MargeAccountUUID, info.MargeURL) + + session, err := setup.DialSetupSession(deviceIP, info.DeviceID, setup.SetupSessionConfig{ + StepTimeout: c.Duration("step-timeout"), + }) + if err != nil { + return fmt.Errorf("dial WS: %w", err) + } + + defer func() { _ = session.Close() }() + + ctx, cancel := context.WithTimeout(c.Context, c.Duration("step-timeout")+2*time.Second) + defer cancel() + + fmt.Printf("→ setMargeAccount accountID=%s (no SETUP bracket)\n", accountID) + + if err := session.SetMargeAccount(ctx, accountID, ""); err != nil { + PrintError(fmt.Sprintf("setMargeAccount: %v", err)) + return err + } + + time.Sleep(2 * time.Second) + + post, err := m.GetLiveDeviceInfo(deviceIP) + if err != nil { + return fmt.Errorf("read /info post: %w", err) + } + + fmt.Printf("post /info margeAccountUUID=%q (want %q)\n", post.MargeAccountUUID, accountID) + + if post.MargeAccountUUID == accountID { + PrintSuccess("Device accepted bare pairing.") + } else { + PrintWarning("Device did NOT persist the pairing — bare path likely refused silently.") + } + + return nil +} + +func runPairFull(c *cli.Context, deviceIP, accountID string) error { + m := setup.NewManager(c.String("service-url"), nil, nil) + + plan := setup.InitPlan{ + DeviceIP: deviceIP, + ServiceURL: c.String("service-url"), + AccountID: accountID, + Language: c.Int("language"), + DeviceName: c.String("name"), + SkipURLRewrite: true, + StepTimeout: c.Duration("step-timeout"), + } + + ctx, cancel := context.WithTimeout(c.Context, 60*time.Second) + defer cancel() + + _, err := m.ExecuteInitPlan(ctx, plan, func(e setup.StepEvent) { + switch e.Status { + case setup.StatusOK: + fmt.Printf("[%d] %s — ok\n", e.Kind, e.Name) + case setup.StatusSkipped: + fmt.Printf("[%d] %s — skipped\n", e.Kind, e.Name) + case setup.StatusFailed: + fmt.Printf("[%d] %s — FAILED: %v\n", e.Kind, e.Name, e.Err) + case setup.StatusRunning: + fmt.Printf("[%d] %s — ...\n", e.Kind, e.Name) + } + }) + if err != nil { + PrintError(err.Error()) + return err + } + + PrintSuccess(fmt.Sprintf("Pairing complete: accountID=%s", accountID)) + + return nil +} diff --git a/cmd/soundtouch-cli/cmd_setup_test.go b/cmd/soundtouch-cli/cmd_setup_test.go new file mode 100644 index 0000000..40a20d8 --- /dev/null +++ b/cmd/soundtouch-cli/cmd_setup_test.go @@ -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") + } +} diff --git a/cmd/soundtouch-cli/main.go b/cmd/soundtouch-cli/main.go index 5eef96f..1b2bc95 100644 --- a/cmd/soundtouch-cli/main.go +++ b/cmd/soundtouch-cli/main.go @@ -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) diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 4ee867a..57400d6 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -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) diff --git a/docs/analysis/FACTORY-RESET-PROTOCOL.md b/docs/analysis/FACTORY-RESET-PROTOCOL.md new file mode 100644 index 0000000..6b8b680 --- /dev/null +++ b/docs/analysis/FACTORY-RESET-PROTOCOL.md @@ -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. diff --git a/docs/analysis/SETUP-WEBSOCKET-EXPERIMENT.md b/docs/analysis/SETUP-WEBSOCKET-EXPERIMENT.md new file mode 100644 index 0000000..f9528e1 --- /dev/null +++ b/docs/analysis/SETUP-WEBSOCKET-EXPERIMENT.md @@ -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: + +- `` — expect empty on a factory-reset device. +- `` — expect the AfterTouch URL (preflight already applied). +- `` — expect a minimal list. +- `` — expect ``. + +## 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 +
+ + 1234567 + Bearer aftertouch + +
+``` + +Outcomes the CLI will surface: + +- `Device accepted bare pairing.` (post-`/info` shows our ID) → **bare path works**. +- `setMargeAccount: device rejected setMargeAccount: …` → device returned an `` 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 | +|------------------------------------------------------------------------------|-----------------------------| +| `1234567` appears | **YES** — Option 1 wins | +| `` still empty, no error frame received | Refused silently → **NO** | +| Error frame returned (e.g. ``) | 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 `1234567`. 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 `` > 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. diff --git a/pkg/client/system_test.go b/pkg/client/system_test.go index e9456f8..f229054 100644 --- a/pkg/client/system_test.go +++ b/pkg/client/system_test.go @@ -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, diff --git a/pkg/models/clockdisplay.go b/pkg/models/clockdisplay.go index b0271ae..fe67160 100644 --- a/pkg/models/clockdisplay.go +++ b/pkg/models/clockdisplay.go @@ -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 are rejected with +// "Error parsing request"): +// +// +// +// +// +// 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 +// 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 +// 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() +} diff --git a/pkg/models/clockdisplay_test.go b/pkg/models/clockdisplay_test.go index 34c238f..d9ea0a2 100644 --- a/pkg/models/clockdisplay_test.go +++ b/pkg/models/clockdisplay_test.go @@ -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 := `` + // Must match the device's captured POST shape — firmware 27 rejects + // the legacy flat with + // "Error parsing request". + expected := `` 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 := `` + 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 := `` + + 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) + } +} diff --git a/pkg/models/clocktime.go b/pkg/models/clocktime.go index de78183..42616b2 100644 --- a/pkg/models/clocktime.go +++ b/pkg/models/clocktime.go @@ -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 diff --git a/pkg/models/clocktime_test.go b/pkg/models/clocktime_test.go index b703999..996d834 100644 --- a/pkg/models/clocktime_test.go +++ b/pkg/models/clocktime_test.go @@ -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 := `2021-01-01 00:00:00` + // Must match the device's GET /clockTime response attribute name + // — firmware 27 rejects `utc=` (no Time suffix) with "Error parsing request". + expected := `` if string(data) != expected { t.Errorf("Expected XML %q, got %q", expected, string(data)) } diff --git a/pkg/service/setup/build_https_url_test.go b/pkg/service/setup/build_https_url_test.go new file mode 100644 index 0000000..a87634a --- /dev/null +++ b/pkg/service/setup/build_https_url_test.go @@ -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) + } + }) + } +} diff --git a/pkg/service/setup/factory_reset.go b/pkg/service/setup/factory_reset.go new file mode 100644 index 0000000..e137575 --- /dev/null +++ b/pkg/service/setup/factory_reset.go @@ -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") +} diff --git a/pkg/service/setup/factory_reset_test.go b/pkg/service/setup/factory_reset_test.go new file mode 100644 index 0000000..652c649 --- /dev/null +++ b/pkg/service/setup/factory_reset_test.go @@ -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) + } +} diff --git a/pkg/service/setup/init_plan.go b/pkg/service/setup/init_plan.go new file mode 100644 index 0000000..d207cd6 --- /dev/null +++ b/pkg/service/setup/init_plan.go @@ -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 +} diff --git a/pkg/service/setup/init_plan_test.go b/pkg/service/setup/init_plan_test.go new file mode 100644 index 0000000..c39fefc --- /dev/null +++ b/pkg/service/setup/init_plan_test.go @@ -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( + `Test%s`, + 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, ",") +} diff --git a/pkg/service/setup/inspect.go b/pkg/service/setup/inspect.go new file mode 100644 index 0000000..892956e --- /dev/null +++ b/pkg/service/setup/inspect.go @@ -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: " 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 +} diff --git a/pkg/service/setup/inspect_test.go b/pkg/service/setup/inspect_test.go new file mode 100644 index 0000000..326cadc --- /dev/null +++ b/pkg/service/setup/inspect_test.go @@ -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": ` + Bose SoundTouch DE4803 + SoundTouch 10 + 1234567 + http://aftertouch.local:8000 + + + SCM + 27.0.6 + F23456789012 + + +`, + "/networkInfo": ` + + + +`, + "/sources": ``, + "/presets": `1LIVE`, + }, + } + + 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": `n`, + "/networkInfo": ``, + "/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": `n`, + }, + } + + 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": `n`, + }, + } + + 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) + } +} diff --git a/pkg/service/setup/setup.go b/pkg/service/setup/setup.go index 919a013..eea9e2c 100644 --- a/pkg/service/setup/setup.go +++ b/pkg/service/setup/setup.go @@ -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 ms–1 s each. + probe := m.probeSpeakerSSH(deviceIP) - // Parse current config - var currentCfg PrivateCfg - if xml.Unmarshal([]byte(currentConfig), ¤tCfg) == nil { - summary.ParsedCurrentConfig = ¤tCfg - - if proxyURL == "" { - proxyURL = targetURL - } - - // Apply options if provided - if options != nil { - m.applyProxyOptions(&plannedCfg, proxyURL, options, ¤tCfg) - } - } - } + 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 2–5 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 diff --git a/pkg/service/setup/setup_session.go b/pkg/service/setup/setup_session.go new file mode 100644 index 0000000..dc245ec --- /dev/null +++ b/pkg/service/setup/setup_session.go @@ -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 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
… +// envelope, sends it, and drains incoming frames until one references the +// same requestID, status path, or url attribute — that frame is the ack. +// Pushed and 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( + `
%s
`, + 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(`/%s`, 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, " in the body. + if strings.Contains(strings.ToLower(text), "`) + 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(``, 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(`%d`, 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", ``) + return err +} + +// IdentifyLeave sends SETUP_IDENTIFY_DEVICE_LEAVE. +func (s *SetupSession) IdentifyLeave(ctx context.Context) error { + _, err := s.sendStep(ctx, "setup", "POST", ``) + 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(`%s`, 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( + `%s%s`, + 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", ``) + 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, "&", "&") + s = strings.ReplaceAll(s, "\"", """) + s = strings.ReplaceAll(s, "<", "<") + + 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() +} diff --git a/pkg/service/setup/setup_session_test.go b/pkg/service/setup/setup_session_test.go new file mode 100644 index 0000000..589041f --- /dev/null +++ b/pkg/service/setup/setup_session_test.go @@ -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(`
ok
`, 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"`, ``) + mustContain(t, frames[1], `url="setup"`, ``) + mustContain(t, frames[2], `url="language"`, `2`) + mustContain(t, frames[3], ``) + mustContain(t, frames[4], ``) + mustContain(t, frames[5], `url="name"`, `Living Room`) + mustContain(t, frames[6], `url="setMargeAccount"`, `1234567`, `Bearer aftertouch`) + mustContain(t, frames[7], ``) + 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{ + ``, + ``, + fmt.Sprintf(`
/setup
`, 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{ + `
no
`, + } + } + + s := dialFakeSession(t, f, "X") + + err := s.SetMargeAccount(context.Background(), "1234567", "") + if err == nil { + t.Fatal("expected error from 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"`) + + 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"<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) + } + } +} diff --git a/pkg/service/setup/setup_test.go b/pkg/service/setup/setup_test.go index 1df35d8..2219d54 100644 --- a/pkg/service/setup/setup_test.go +++ b/pkg/service/setup/setup_test.go @@ -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) diff --git a/pkg/service/setup/ssh_probe.go b/pkg/service/setup/ssh_probe.go new file mode 100644 index 0000000..bcee08d --- /dev/null +++ b/pkg/service/setup/ssh_probe.go @@ -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 ms–1 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@@ — start-of-file marker +// — exactly one line of base64 (no newlines) +// @END@ — end-of-file marker +// @EXISTS@@ — 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) + } + } + } +} diff --git a/pkg/service/setup/ssh_probe_apply.go b/pkg/service/setup/ssh_probe_apply.go new file mode 100644 index 0000000..d516556 --- /dev/null +++ b/pkg/service/setup/ssh_probe_apply.go @@ -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), ¤tCfg) == nil { + summary.ParsedCurrentConfig = ¤tCfg + + if proxyURL == "" { + proxyURL = targetURL + } + + if options != nil { + m.applyProxyOptions(plannedCfg, proxyURL, options, ¤tCfg) + } + } + } + + 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 +} diff --git a/pkg/service/setup/wifi_provision.go b/pkg/service/setup/wifi_provision.go new file mode 100644 index 0000000..1b9202f --- /dev/null +++ b/pkg/service/setup/wifi_provision.go @@ -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( + ``, + 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 +} diff --git a/pkg/service/setup/wifi_provision_test.go b/pkg/service/setup/wifi_provision_test.go new file mode 100644 index 0000000..4dbe66e --- /dev/null +++ b/pkg/service/setup/wifi_provision_test.go @@ -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(``)) + })) + 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`, + }) + 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, """) { + t.Errorf("expected " 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 := `Bose SoundTouch DE4803` + + 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) + } +}