mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
feat(setup): add CLI setup command group for end-to-end speaker provisioning
Add `soundtouch-cli setup` subcommand group covering the full reset →
re-provision → pair lifecycle as a scriptable alternative to the web UI:
inspect, verify, plan, factory-reset, wait-ap, wifi-push, wait-online,
ssh-check, install-ca, migrate, reboot, pair (bare | full state machine)
Supporting library code lives in pkg/service/setup: factory_reset.go,
wifi_provision.go, inspect.go, init_plan.go, setup_session.go.
Confirmed against ST10 firmware 27.0.6 that bare setMargeAccount over
WebSocket — no SETUP_START/SETUP_ENTER/SETUP_LEAVE bracket — is
sufficient to pair a factory-reset speaker; the firmware materializes
SystemConfigurationDB.xml and Sources.xml itself and the pairing
survives reboot. Result and field-by-field SystemConfigurationDB
comparison documented in docs/analysis/SETUP-WEBSOCKET-EXPERIMENT.md.
Captures the device's pre-reset DELETE-to-marge plus its LAN peer
notification flow in docs/analysis/FACTORY-RESET-PROTOCOL.md.
Perf: batch GetMigrationSummary's SSH probes into one Run() call via
ssh_probe.go / ssh_probe_apply.go — was ~8 sequential dials at
500-1000 ms each on FW 27 crypto, now one round-trip. Same data shape,
same MigrationSummary fields populated.
Fixes /clockTime and /clockDisplay wire formats — firmware 27 rejects
the legacy flat XML ("Error parsing request"). ClockTimeRequest now
uses utcTime attribute; ClockDisplayRequest emits the nested
<clockConfig> envelope with timezoneInfo/timeFormat/brightnessLevel.
Removes cmd/example-init-speaker (superseded by setup pair).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
1ab4295653
commit
29a462da2b
@@ -157,6 +157,33 @@ func setClockTimeNow(c *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// setClockDisplayTimezone POSTs only the timezoneInfo attribute,
|
||||
// leaving format/brightness untouched. Useful after a clock now to
|
||||
// make the speaker's logs and front-panel display tick in local time
|
||||
// instead of UTC.
|
||||
func setClockDisplayTimezone(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
tz := c.String("tz")
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Setting clock timezone to %s", tz), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
request := models.NewClockDisplayRequest().SetTimeZone(tz)
|
||||
if err := client.SetClockDisplay(request); err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to set timezone: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess(fmt.Sprintf("Timezone set to %s", tz))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getClockDisplay retrieves the current clock display settings
|
||||
func getClockDisplay(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,215 @@
|
||||
# Experiment: Does bare `setMargeAccount` work outside the SETUP bracket?
|
||||
|
||||
## Why we are doing this
|
||||
|
||||
Our captured pairing flow (`docs/reference/DEVICE-PAIRING-FLOW.md`) shows the official Bose app always sends `setMargeAccount` *inside* a `SETUP_START` → `SETUP_ENTER` → `SETUP_LEAVE` state-machine bracket over WebSocket. The question this experiment answers:
|
||||
|
||||
> If we open a WebSocket to a factory-reset speaker and send **only** `setMargeAccount` — no surrounding setupState messages — does the device honor it and write its persistence files (`SystemConfigurationDB.xml`, `Sources.xml`) cleanly?
|
||||
|
||||
The answer determines the shape of `PairAccount`:
|
||||
|
||||
- **If YES:** `PairAccount` becomes uniform: WebSocket-first, HTTP `/setMargeAccount` second, telnet `envswitch accountid set` third. One function, one ordering, all callers.
|
||||
- **If NO:** WebSocket pairing is only meaningful inside the full state machine. Factory-reset path uses the state machine; re-pair path keeps today's HTTP→telnet ordering.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- A SoundTouch speaker that has been **factory-reset** and joined to the test Wi-Fi.
|
||||
- Speaker reachable on `:8090` (HTTP API) and `:8080` (WebSocket).
|
||||
- Speaker's runtime marge URL already points at AfterTouch (run the existing telnet URL rewrite first — otherwise the device's downstream POST will land on the dead Bose cloud and we will not be able to distinguish "WS message refused" from "downstream cloud failed").
|
||||
- A free 7-digit account ID — for example, generated via `setup.GenerateAccountID(nil)`.
|
||||
|
||||
## Step 0 — Baseline
|
||||
|
||||
```bash
|
||||
DEVICE=192.168.x.x
|
||||
curl -s http://$DEVICE:8090/info | xmllint --format -
|
||||
curl -s http://$DEVICE:8090/sources | xmllint --format -
|
||||
curl -s http://$DEVICE:8090/presets | xmllint --format -
|
||||
```
|
||||
|
||||
Record:
|
||||
|
||||
- `<margeAccountUUID>` — expect empty on a factory-reset device.
|
||||
- `<margeURL>` — expect the AfterTouch URL (preflight already applied).
|
||||
- `<sources>` — expect a minimal list.
|
||||
- `<presets>` — expect `<presets/>`.
|
||||
|
||||
## Step 1 — Send bare `setMargeAccount` over WebSocket
|
||||
|
||||
Build the CLI once:
|
||||
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
Then run the bare path against the speaker:
|
||||
|
||||
```bash
|
||||
DEVICE=192.168.x.x
|
||||
./build/soundtouch-cli setup pair --host=$DEVICE --account=1234567 --mode=bare
|
||||
```
|
||||
|
||||
What it does:
|
||||
|
||||
1. Reads `/info` to discover `deviceID`, logs the pre-state.
|
||||
2. Opens a WebSocket to `$DEVICE:8080` with the `gabbo` subprotocol.
|
||||
3. Sends exactly one frame — the `setMargeAccount` envelope — **without** any preceding `SETUP_START`/`SETUP_ENTER`.
|
||||
4. Reads frames for up to `--step-timeout=8s` (configurable), looking for an ack referencing our `requestID`.
|
||||
5. Closes the WebSocket, waits 2 s, re-reads `/info`, prints whether `margeAccountUUID` now equals our supplied ID.
|
||||
|
||||
The exact frame sent (built by `setup.SetupSession.SetMargeAccount`):
|
||||
|
||||
```xml
|
||||
<msg><header deviceID="DEVICE_ID" url="setMargeAccount" method="POST"><request requestID="1"/></header><body>
|
||||
<PairDeviceWithAccount>
|
||||
<accountId>1234567</accountId>
|
||||
<userAuthToken>Bearer aftertouch</userAuthToken>
|
||||
</PairDeviceWithAccount>
|
||||
</body></msg>
|
||||
```
|
||||
|
||||
Outcomes the CLI will surface:
|
||||
|
||||
- `Device accepted bare pairing.` (post-`/info` shows our ID) → **bare path works**.
|
||||
- `setMargeAccount: device rejected setMargeAccount: …` → device returned an `<error>` body → **bare path refused explicitly**.
|
||||
- `setMargeAccount: await ack for setMargeAccount: …` (timeout or EOF) → **bare path refused silently**.
|
||||
- `Device did NOT persist the pairing — bare path likely refused silently.` → ack received but persistence didn't follow.
|
||||
|
||||
## Step 2 — Record outcome
|
||||
|
||||
After step 1 (regardless of which branch happened):
|
||||
|
||||
```bash
|
||||
sleep 2
|
||||
curl -s http://$DEVICE:8090/info | grep margeAccountUUID
|
||||
```
|
||||
|
||||
| Observed result | Verdict |
|
||||
|------------------------------------------------------------------------------|-----------------------------|
|
||||
| `<margeAccountUUID>1234567</margeAccountUUID>` appears | **YES** — Option 1 wins |
|
||||
| `<margeAccountUUID></margeAccountUUID>` still empty, no error frame received | Refused silently → **NO** |
|
||||
| Error frame returned (e.g. `<error name="UNSUPPORTED_STATE"/>`) | Refused explicitly → **NO** |
|
||||
| Device drops the WebSocket connection without replying | Refused → **NO** |
|
||||
|
||||
If verdict is YES, also verify the device wrote persistence cleanly. Reboot the device, then:
|
||||
|
||||
```bash
|
||||
ssh root@$DEVICE 'cat /mnt/nv/BoseApp-Persistence/1/SystemConfigurationDB.xml'
|
||||
ssh root@$DEVICE 'cat /mnt/nv/BoseApp-Persistence/1/Sources.xml'
|
||||
curl -s http://$DEVICE:8090/info | grep margeAccountUUID
|
||||
```
|
||||
|
||||
The UUID must still be present after reboot, and `SystemConfigurationDB.xml` must contain `<AccountUUID>1234567</AccountUUID>`. If it survives reboot, **YES** is confirmed.
|
||||
|
||||
## Step 3 — Control: full state machine
|
||||
|
||||
Factory-reset the same speaker again and run the full state machine — the same CLI, `--mode=full`:
|
||||
|
||||
```bash
|
||||
./build/soundtouch-cli setup pair --host=$DEVICE --account=1234567 --mode=full
|
||||
```
|
||||
|
||||
This drives `setup.Manager.ExecuteInitPlan` with `SkipURLRewrite=true`, which runs:
|
||||
|
||||
```
|
||||
SETUP_START
|
||||
SETUP_IDENTIFY_DEVICE_ENTER
|
||||
language sysLanguage=2
|
||||
SETUP_ENTER
|
||||
SETUP_IDENTIFY_DEVICE_LEAVE
|
||||
setMargeAccount …
|
||||
SETUP_LEAVE
|
||||
pushCustomerSupportInfoToMarge
|
||||
```
|
||||
|
||||
The CLI logs every step with status. Confirm `/info`, persistence, and reboot-survival checks pass. If the bare path failed but the full path succeeds, the SETUP bracket is load-bearing — a follow-up bisect (e.g. `SETUP_START + setMargeAccount + SETUP_LEAVE` only) tells us *which* surrounding messages the firmware actually requires.
|
||||
|
||||
## Full reset-and-rebuild loop
|
||||
|
||||
Once the bare/full question is decided, the loop for repeated experiments is:
|
||||
|
||||
```bash
|
||||
# 0. Speaker is currently on home Wi-Fi at $DEVICE.
|
||||
# Capture deviceID-suffix + current SSID first so wait-online and
|
||||
# wifi-push have the right inputs.
|
||||
./build/soundtouch-cli setup inspect --host=$DEVICE
|
||||
./build/soundtouch-cli setup factory-reset --host=$DEVICE
|
||||
|
||||
# 1. Manually switch this host to the speaker's AP (Bose SoundTouch XXXX).
|
||||
# macOS: networksetup -setairportnetwork en0 "Bose SoundTouch XXXX"
|
||||
|
||||
./build/soundtouch-cli setup wait-ap
|
||||
./build/soundtouch-cli setup wifi-push --ssid="$HOME_SSID" --pass="$HOME_PASS"
|
||||
|
||||
# 2. Manually switch this host back to home Wi-Fi.
|
||||
|
||||
./build/soundtouch-cli setup wait-online --match=DE4803 # deviceID suffix from /info before reset
|
||||
# (note the new IP from the "Speaker discovered" line)
|
||||
|
||||
NEW_IP=192.168.x.y
|
||||
./build/soundtouch-cli setup migrate --host=$NEW_IP --service-url=http://aftertouch.local:8000 # default --method=telnet
|
||||
|
||||
# Optional, if you want the DNS-redirect path instead of (or alongside) telnet envswitch:
|
||||
# 1. ./build/soundtouch-cli setup ssh-check --host=$NEW_IP # USB-stick procedure if 22 is closed
|
||||
# 2. ./build/soundtouch-cli setup install-ca --host=$NEW_IP --service-url=http://aftertouch.local:8000
|
||||
# 3. ./build/soundtouch-cli setup migrate --host=$NEW_IP --service-url=http://aftertouch.local:8000 --method=resolv
|
||||
./build/soundtouch-cli setup pair --host=$NEW_IP --mode=bare # or --mode=full
|
||||
```
|
||||
|
||||
The two manual lines are user-side Wi-Fi switches that can't be automated portably. The `wait-ap` and `wait-online` subcommands poll for the corresponding network state, so timing them is hands-off.
|
||||
|
||||
## Recording the result
|
||||
|
||||
Append to this file under `## Results`:
|
||||
|
||||
```
|
||||
- Date: YYYY-MM-DD
|
||||
- Firmware: 27.x.x
|
||||
- Model: ST10 / ST20 / ST30 / ST300
|
||||
- Bare setMargeAccount accepted: yes/no
|
||||
- Persistence written: yes/no
|
||||
- Survives reboot: yes/no
|
||||
- Notes: ...
|
||||
```
|
||||
|
||||
One row per device tested. Once two devices on different firmware confirm the same verdict, we treat it as decided.
|
||||
|
||||
## Results
|
||||
|
||||
- Date: 2026-05-13
|
||||
- Firmware: 27.0.6.46330.5043500 (build epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29)
|
||||
- Model: SoundTouch 10 (deviceID A81B6A536A98)
|
||||
- Bare setMargeAccount accepted: **yes** — pre-/info margeAccountUUID="" → post-/info margeAccountUUID="1111111"
|
||||
- Persistence written: **yes** — device materialized 14-entry Sources.xml on its own
|
||||
- Survives reboot: **yes** — `setup inspect` after `setup reboot` shows margeAccountUUID still 1111111
|
||||
- Notes: After bare pairing, the speaker did the full post-pairing handshake against AfterTouch (POST /streaming/support/power_on, GET /streaming/sourceproviders, GET /streaming/account/{id}/full, group/, provider_settings). No SETUP_START/SETUP_ENTER/SETUP_LEAVE was ever sent. Verdict: bare path is functionally equivalent to the full state machine on this firmware.
|
||||
|
||||
### Implication for the codebase
|
||||
|
||||
- `pkg/service/setup/setup_session.go` keeps the full state machine for completeness, but
|
||||
- `pkg/service/setup/init_plan.go`'s default could be simplified to "send setMargeAccount only" once we have one more confirming run on a different model.
|
||||
- The OCT issue-167 SSH-XML seeding workaround is **not required**.
|
||||
|
||||
### Appendix — SystemConfigurationDB.xml comparison
|
||||
|
||||
Post-experiment we compared the device-written `/mnt/nv/BoseApp-Persistence/1/SystemConfigurationDB.xml` from the bare-paired speaker against two SSH backups taken from speakers originally paired by the official Bose app (account 3230304, devices `A_Sound_Machine` and `Sound_Machinechen`). The diff is much smaller than expected — only two fields differ, and neither is set by the pairing protocol itself:
|
||||
|
||||
| Field | Bare-paired (1111111) | Real-Bose-paired (3230304) | Set by |
|
||||
|--------------------------|--------------------------------------------|----------------------------|-----------------------------------------------------------------------------------------------------------|
|
||||
| `DeviceName` | `Bose SoundTouch 536A98` (factory default) | `Sound Machinechen` | `name` WS message — only sent in `--mode=full` |
|
||||
| `AccountAssociatedEMail` | empty | **empty** | Never populated, even by real Bose |
|
||||
| `AccountUUID` | `1111111` | `3230304` | `setMargeAccount` — both paths set it |
|
||||
| `Locale` | empty | **empty** | Never populated, even by real Bose |
|
||||
| `acctMode` | `global` | `global` | Firmware-default; no protocol path observed to change it |
|
||||
| `isMultiDeviceAccount` | `false` | `true` | Derived from the cloud's `/streaming/account/{id}/full` response — count of `<devices>` > 1 flips it true |
|
||||
| `margeAuthServerToken` | empty | **empty** | Never populated, even by real Bose |
|
||||
| `Password` | (encrypted blob) | (encrypted blob) | Device-local key; expected to differ |
|
||||
|
||||
Three of the seven informational fields are empty even after a real-Bose pairing — the firmware simply doesn't populate `AccountAssociatedEMail`, `Locale`, or `margeAuthServerToken` from the pairing flow. So bare pairing isn't missing any field that real pairing fills.
|
||||
|
||||
The two genuinely different fields:
|
||||
|
||||
- **`DeviceName`** — pure UX. Settable any time post-pair via `name` POST (`soundtouch-cli name set --value=…`) or by sending the `name` WS message during `--mode=full` pairing.
|
||||
- **`isMultiDeviceAccount`** — not a pairing concern. It's derived from the account's device count on AfterTouch's side; flips to `true` automatically the next time the speaker refreshes account state if a second speaker has been paired to the same account.
|
||||
|
||||
So the experiment's YES verdict stands unqualified: bare `setMargeAccount` produces a `SystemConfigurationDB.xml` functionally equivalent to one written by the official pairing flow.
|
||||
@@ -95,9 +95,7 @@ func TestClient_SetClockTime(t *testing.T) {
|
||||
{
|
||||
name: "Successful clock time set",
|
||||
request: &models.ClockTimeRequest{
|
||||
UTC: 1609459200,
|
||||
Value: "2021-01-01 00:00:00",
|
||||
Zone: "UTC",
|
||||
UTCTime: 1609459200,
|
||||
},
|
||||
statusCode: http.StatusOK,
|
||||
expectError: false,
|
||||
@@ -112,8 +110,7 @@ func TestClient_SetClockTime(t *testing.T) {
|
||||
{
|
||||
name: "Server error",
|
||||
request: &models.ClockTimeRequest{
|
||||
UTC: 1609459200,
|
||||
Value: "2021-01-01 00:00:00",
|
||||
UTCTime: 1609459200,
|
||||
},
|
||||
statusCode: http.StatusInternalServerError,
|
||||
expectError: true,
|
||||
|
||||
+199
-16
@@ -2,20 +2,142 @@ package models
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ClockDisplay represents the device's clock display settings
|
||||
// ClockDisplay represents the device's clock display settings.
|
||||
//
|
||||
// Wire format (confirmed against ST10/ST20 firmware 27.0.6 — flat
|
||||
// attributes on the outer <clockDisplay> are rejected with
|
||||
// "Error parsing request"):
|
||||
//
|
||||
// <clockDisplay deviceID="…">
|
||||
// <clockConfig timezoneInfo="Europe/Berlin"
|
||||
// userEnable="true"
|
||||
// timeFormat="TIME_FORMAT_24HOUR_ID"
|
||||
// userOffsetMinute="0"
|
||||
// brightnessLevel="70"
|
||||
// userUtcTime="0"/>
|
||||
// </clockDisplay>
|
||||
//
|
||||
// The struct keeps its historical flat-field public API so the CLI and
|
||||
// other callers don't have to be rewritten; custom MarshalXML /
|
||||
// UnmarshalXML methods bridge to the nested format on the wire.
|
||||
type ClockDisplay struct {
|
||||
XMLName xml.Name `xml:"clockDisplay"`
|
||||
DeviceID string `xml:"deviceID,attr,omitempty"`
|
||||
Enabled bool `xml:"enabled,attr,omitempty"`
|
||||
Format string `xml:"format,attr,omitempty"`
|
||||
Brightness int `xml:"brightness,attr,omitempty"`
|
||||
AutoDim bool `xml:"autoDim,attr,omitempty"`
|
||||
TimeZone string `xml:"timeZone,attr,omitempty"`
|
||||
Value string `xml:",chardata"`
|
||||
XMLName xml.Name `xml:"-"`
|
||||
DeviceID string
|
||||
Enabled bool
|
||||
Format string // public-facing values: "12", "24", "auto"
|
||||
Brightness int
|
||||
AutoDim bool // not on the device's wire format; preserved for API compat
|
||||
TimeZone string
|
||||
Value string // kept for API compat — older fixtures stored chardata here
|
||||
}
|
||||
|
||||
// Wire constants for clockConfig/@timeFormat.
|
||||
const (
|
||||
wireTimeFormat12Hour = "TIME_FORMAT_12HOUR_ID"
|
||||
wireTimeFormat24Hour = "TIME_FORMAT_24HOUR_ID"
|
||||
wireTimeFormatAuto = "TIME_FORMAT_AUTO_ID"
|
||||
)
|
||||
|
||||
func mapToWireFormat(f string) string {
|
||||
switch strings.ToLower(f) {
|
||||
case "12":
|
||||
return wireTimeFormat12Hour
|
||||
case "24":
|
||||
return wireTimeFormat24Hour
|
||||
case "auto":
|
||||
return wireTimeFormatAuto
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func mapFromWireFormat(wire string) string {
|
||||
switch wire {
|
||||
case wireTimeFormat12Hour:
|
||||
return "12"
|
||||
case wireTimeFormat24Hour:
|
||||
return "24"
|
||||
case wireTimeFormatAuto:
|
||||
return "auto"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// UnmarshalXML decodes the nested <clockDisplay><clockConfig …/></clockDisplay>
|
||||
// into ClockDisplay's flat fields. Tolerates the older flat shape too —
|
||||
// either because it appears in legacy captures or for forward-compat with
|
||||
// firmwares that may revert.
|
||||
func (c *ClockDisplay) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
for _, attr := range start.Attr {
|
||||
switch attr.Name.Local {
|
||||
case "deviceID":
|
||||
c.DeviceID = attr.Value
|
||||
case "enabled":
|
||||
c.Enabled = attr.Value == "true"
|
||||
case "format":
|
||||
c.Format = attr.Value
|
||||
case "brightness":
|
||||
c.Brightness, _ = strconv.Atoi(attr.Value)
|
||||
case "autoDim":
|
||||
c.AutoDim = attr.Value == "true"
|
||||
case "timeZone":
|
||||
c.TimeZone = attr.Value
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
tok, err := d.Token()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
if t.Name.Local == "clockConfig" {
|
||||
for _, attr := range t.Attr {
|
||||
switch attr.Name.Local {
|
||||
case "timezoneInfo":
|
||||
c.TimeZone = attr.Value
|
||||
case "userEnable":
|
||||
c.Enabled = attr.Value == "true"
|
||||
case "timeFormat":
|
||||
if mapped := mapFromWireFormat(attr.Value); mapped != "" {
|
||||
c.Format = mapped
|
||||
}
|
||||
case "brightnessLevel":
|
||||
c.Brightness, _ = strconv.Atoi(attr.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := d.Skip(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
case xml.CharData:
|
||||
text := strings.TrimSpace(string(t))
|
||||
if text != "" {
|
||||
c.Value = text
|
||||
}
|
||||
|
||||
case xml.EndElement:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClockFormat represents supported clock display formats
|
||||
@@ -108,14 +230,16 @@ func (c *ClockDisplay) IsEmpty() bool {
|
||||
return !c.Enabled && c.Format == "" && c.Brightness == 0 && c.TimeZone == ""
|
||||
}
|
||||
|
||||
// ClockDisplayRequest represents a request to configure clock display settings
|
||||
// ClockDisplayRequest represents a request to configure clock display
|
||||
// settings. Fields use the same public names as the response struct;
|
||||
// MarshalXML produces the nested wire format the device requires.
|
||||
type ClockDisplayRequest struct {
|
||||
XMLName xml.Name `xml:"clockDisplay"`
|
||||
Enabled *bool `xml:"enabled,attr,omitempty"`
|
||||
Format string `xml:"format,attr,omitempty"`
|
||||
Brightness *int `xml:"brightness,attr,omitempty"`
|
||||
AutoDim *bool `xml:"autoDim,attr,omitempty"`
|
||||
TimeZone string `xml:"timeZone,attr,omitempty"`
|
||||
XMLName xml.Name `xml:"-"`
|
||||
Enabled *bool
|
||||
Format string
|
||||
Brightness *int
|
||||
AutoDim *bool
|
||||
TimeZone string
|
||||
}
|
||||
|
||||
// NewClockDisplayRequest creates a new clock display configuration request
|
||||
@@ -184,3 +308,62 @@ func (r *ClockDisplayRequest) Validate() error {
|
||||
func (r *ClockDisplayRequest) HasChanges() bool {
|
||||
return r.Enabled != nil || r.Format != "" || r.Brightness != nil || r.AutoDim != nil || r.TimeZone != ""
|
||||
}
|
||||
|
||||
// MarshalXML emits the nested <clockDisplay><clockConfig …/></clockDisplay>
|
||||
// envelope the device accepts. Empty fields are omitted so partial updates
|
||||
// (e.g. "set only the timezone") don't accidentally clear other settings.
|
||||
//
|
||||
// AutoDim has no counterpart in the captured wire format; we still accept
|
||||
// it in the public API for backward-compat but it is not emitted.
|
||||
func (r ClockDisplayRequest) MarshalXML(e *xml.Encoder, _ xml.StartElement) error {
|
||||
display := xml.StartElement{Name: xml.Name{Local: "clockDisplay"}}
|
||||
if err := e.EncodeToken(display); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg := xml.StartElement{Name: xml.Name{Local: "clockConfig"}}
|
||||
|
||||
if r.TimeZone != "" {
|
||||
cfg.Attr = append(cfg.Attr, xml.Attr{
|
||||
Name: xml.Name{Local: "timezoneInfo"},
|
||||
Value: r.TimeZone,
|
||||
})
|
||||
}
|
||||
|
||||
if r.Enabled != nil {
|
||||
cfg.Attr = append(cfg.Attr, xml.Attr{
|
||||
Name: xml.Name{Local: "userEnable"},
|
||||
Value: strconv.FormatBool(*r.Enabled),
|
||||
})
|
||||
}
|
||||
|
||||
if r.Format != "" {
|
||||
if wire := mapToWireFormat(r.Format); wire != "" {
|
||||
cfg.Attr = append(cfg.Attr, xml.Attr{
|
||||
Name: xml.Name{Local: "timeFormat"},
|
||||
Value: wire,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if r.Brightness != nil {
|
||||
cfg.Attr = append(cfg.Attr, xml.Attr{
|
||||
Name: xml.Name{Local: "brightnessLevel"},
|
||||
Value: strconv.Itoa(*r.Brightness),
|
||||
})
|
||||
}
|
||||
|
||||
if err := e.EncodeToken(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := e.EncodeToken(xml.EndElement{Name: cfg.Name}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := e.EncodeToken(xml.EndElement{Name: display.Name}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.Flush()
|
||||
}
|
||||
|
||||
@@ -641,7 +641,7 @@ func TestClockDisplayRequest_MarshalXML(t *testing.T) {
|
||||
Enabled: &[]bool{true}[0],
|
||||
Format: "24",
|
||||
Brightness: &[]int{75}[0],
|
||||
AutoDim: &[]bool{false}[0],
|
||||
AutoDim: &[]bool{false}[0], // not on the wire format — must be silently dropped
|
||||
TimeZone: "America/New_York",
|
||||
}
|
||||
|
||||
@@ -650,8 +650,57 @@ func TestClockDisplayRequest_MarshalXML(t *testing.T) {
|
||||
t.Fatalf("Failed to marshal XML: %v", err)
|
||||
}
|
||||
|
||||
expected := `<clockDisplay enabled="true" format="24" brightness="75" autoDim="false" timeZone="America/New_York"></clockDisplay>`
|
||||
// Must match the device's captured POST shape — firmware 27 rejects
|
||||
// the legacy flat <clockDisplay enabled="…" format="…" .../> with
|
||||
// "Error parsing request".
|
||||
expected := `<clockDisplay><clockConfig timezoneInfo="America/New_York" userEnable="true" timeFormat="TIME_FORMAT_24HOUR_ID" brightnessLevel="75"></clockConfig></clockDisplay>`
|
||||
if string(data) != expected {
|
||||
t.Errorf("Expected XML %q, got %q", expected, string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClockDisplayRequest_MarshalXML_TimezoneOnly(t *testing.T) {
|
||||
// Partial update: only set the timezone. Unset fields must be
|
||||
// omitted so we don't clobber the device's other settings.
|
||||
request := ClockDisplayRequest{TimeZone: "Europe/Berlin"}
|
||||
|
||||
data, err := xml.Marshal(request)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal XML: %v", err)
|
||||
}
|
||||
|
||||
expected := `<clockDisplay><clockConfig timezoneInfo="Europe/Berlin"></clockConfig></clockDisplay>`
|
||||
if string(data) != expected {
|
||||
t.Errorf("Expected XML %q, got %q", expected, string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClockDisplay_UnmarshalXML_NestedClockConfig(t *testing.T) {
|
||||
// The real wire format — what firmware-27 devices emit and accept.
|
||||
xmlData := `<clockDisplay deviceID="A81B6A536A98"><clockConfig timezoneInfo="Europe/Berlin" userEnable="true" timeFormat="TIME_FORMAT_24HOUR_ID" userOffsetMinute="0" brightnessLevel="70" userUtcTime="0"/></clockDisplay>`
|
||||
|
||||
var got ClockDisplay
|
||||
if err := xml.Unmarshal([]byte(xmlData), &got); err != nil {
|
||||
t.Fatalf("Failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if got.DeviceID != "A81B6A536A98" {
|
||||
t.Errorf("DeviceID = %q, want A81B6A536A98", got.DeviceID)
|
||||
}
|
||||
|
||||
if got.TimeZone != "Europe/Berlin" {
|
||||
t.Errorf("TimeZone = %q, want Europe/Berlin", got.TimeZone)
|
||||
}
|
||||
|
||||
if !got.Enabled {
|
||||
t.Error("Enabled = false, want true (from userEnable=true)")
|
||||
}
|
||||
|
||||
if got.Format != "24" {
|
||||
t.Errorf("Format = %q, want 24 (from timeFormat=TIME_FORMAT_24HOUR_ID)", got.Format)
|
||||
}
|
||||
|
||||
if got.Brightness != 70 {
|
||||
t.Errorf("Brightness = %d, want 70 (from brightnessLevel)", got.Brightness)
|
||||
}
|
||||
}
|
||||
|
||||
+25
-25
@@ -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
|
||||
|
||||
@@ -284,16 +284,8 @@ func TestNewClockTimeRequest(t *testing.T) {
|
||||
|
||||
request := NewClockTimeRequest(testTime)
|
||||
|
||||
if request.UTC != testTime.Unix() {
|
||||
t.Errorf("Expected UTC %d, got %d", testTime.Unix(), request.UTC)
|
||||
}
|
||||
|
||||
if request.Value != "2021-01-01 12:00:00" {
|
||||
t.Errorf("Expected Value %q, got %q", "2021-01-01 12:00:00", request.Value)
|
||||
}
|
||||
|
||||
if request.Zone != "UTC" {
|
||||
t.Errorf("Expected Zone %q, got %q", "UTC", request.Zone)
|
||||
if request.UTCTime != testTime.Unix() {
|
||||
t.Errorf("Expected UTCTime %d, got %d", testTime.Unix(), request.UTCTime)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,13 +294,8 @@ func TestNewClockTimeRequestUTC(t *testing.T) {
|
||||
|
||||
request := NewClockTimeRequestUTC(utcTimestamp)
|
||||
|
||||
if request.UTC != utcTimestamp {
|
||||
t.Errorf("Expected UTC %d, got %d", utcTimestamp, request.UTC)
|
||||
}
|
||||
|
||||
expectedValue := time.Unix(utcTimestamp, 0).UTC().Format("2006-01-02 15:04:05")
|
||||
if request.Value != expectedValue {
|
||||
t.Errorf("Expected Value %q, got %q", expectedValue, request.Value)
|
||||
if request.UTCTime != utcTimestamp {
|
||||
t.Errorf("Expected UTCTime %d, got %d", utcTimestamp, request.UTCTime)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,25 +306,8 @@ func TestClockTimeRequest_Validate(t *testing.T) {
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Valid UTC request",
|
||||
request: ClockTimeRequest{
|
||||
UTC: 1609459200,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Valid value request",
|
||||
request: ClockTimeRequest{
|
||||
Value: "2021-01-01 12:00:00",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Valid request with both",
|
||||
request: ClockTimeRequest{
|
||||
UTC: 1609459200,
|
||||
Value: "2021-01-01 12:00:00",
|
||||
},
|
||||
name: "Valid UTC request",
|
||||
request: ClockTimeRequest{UTCTime: 1609459200},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
@@ -346,17 +316,13 @@ func TestClockTimeRequest_Validate(t *testing.T) {
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "UTC too old",
|
||||
request: ClockTimeRequest{
|
||||
UTC: 946684799, // Before year 2000
|
||||
},
|
||||
name: "UTC too old",
|
||||
request: ClockTimeRequest{UTCTime: 946684799}, // Before year 2000
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "UTC too far in future",
|
||||
request: ClockTimeRequest{
|
||||
UTC: 4102444801, // After year 2100
|
||||
},
|
||||
name: "UTC too far in future",
|
||||
request: ClockTimeRequest{UTCTime: 4102444801}, // After year 2100
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
@@ -377,18 +343,16 @@ func TestClockTimeRequest_Validate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestClockTimeRequest_MarshalXML(t *testing.T) {
|
||||
request := ClockTimeRequest{
|
||||
Zone: "UTC",
|
||||
UTC: 1609459200,
|
||||
Value: "2021-01-01 00:00:00",
|
||||
}
|
||||
request := ClockTimeRequest{UTCTime: 1609459200}
|
||||
|
||||
data, err := xml.Marshal(request)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal XML: %v", err)
|
||||
}
|
||||
|
||||
expected := `<clockTime zone="UTC" utc="1609459200">2021-01-01 00:00:00</clockTime>`
|
||||
// Must match the device's GET /clockTime response attribute name
|
||||
// — firmware 27 rejects `utc=` (no Time suffix) with "Error parsing request".
|
||||
expected := `<clockTime utcTime="1609459200"></clockTime>`
|
||||
if string(data) != expected {
|
||||
t.Errorf("Expected XML %q, got %q", expected, string(data))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeSetupSession is a SetupStateMachine that records the order of
|
||||
// invocations and lets each test inject per-step errors.
|
||||
type fakeSetupSession struct {
|
||||
calls []string
|
||||
errors map[string]error
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (f *fakeSetupSession) record(name string) error {
|
||||
if e, ok := f.errors[name]; ok && e != nil {
|
||||
return e
|
||||
}
|
||||
|
||||
f.calls = append(f.calls, name)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSetupSession) Start(_ context.Context) error { return f.record("Start") }
|
||||
func (f *fakeSetupSession) Enter(_ context.Context) error { return f.record("Enter") }
|
||||
func (f *fakeSetupSession) Leave(_ context.Context) error { return f.record("Leave") }
|
||||
func (f *fakeSetupSession) IdentifyLeave(_ context.Context) error {
|
||||
return f.record("IdentifyLeave")
|
||||
}
|
||||
|
||||
func (f *fakeSetupSession) IdentifyEnter(_ context.Context, timeoutMs int) error {
|
||||
return f.record(fmt.Sprintf("IdentifyEnter(%d)", timeoutMs))
|
||||
}
|
||||
|
||||
func (f *fakeSetupSession) SetLanguage(_ context.Context, code int) error {
|
||||
return f.record(fmt.Sprintf("SetLanguage(%d)", code))
|
||||
}
|
||||
|
||||
func (f *fakeSetupSession) SetName(_ context.Context, name string) error {
|
||||
return f.record("SetName(" + name + ")")
|
||||
}
|
||||
|
||||
func (f *fakeSetupSession) SetMargeAccount(_ context.Context, accountID, token string) error {
|
||||
return f.record(fmt.Sprintf("SetMargeAccount(%s,%s)", accountID, token))
|
||||
}
|
||||
|
||||
func (f *fakeSetupSession) PushCustomerSupportInfo(_ context.Context) error {
|
||||
return f.record("PushCustomerSupportInfo")
|
||||
}
|
||||
|
||||
func (f *fakeSetupSession) Close() error {
|
||||
f.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// fakeInfoResponder produces an http.Response carrying canned /info XML.
|
||||
// pairedAccount toggles between "unpaired" and "paired with this UUID."
|
||||
type fakeInfoResponder struct {
|
||||
deviceID string
|
||||
paired string // empty = unpaired
|
||||
postInitPaired string // /info reading after the plan ran
|
||||
reads int
|
||||
}
|
||||
|
||||
func (f *fakeInfoResponder) get(_ string) (*http.Response, error) {
|
||||
f.reads++
|
||||
|
||||
acct := f.paired
|
||||
if f.reads >= 2 && f.postInitPaired != "" {
|
||||
acct = f.postInitPaired
|
||||
}
|
||||
|
||||
body := fmt.Sprintf(
|
||||
`<info deviceID="%s"><name>Test</name><margeAccountUUID>%s</margeAccountUUID></info>`,
|
||||
f.deviceID, acct,
|
||||
)
|
||||
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: http.Header{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newTestManagerWithFakes(t *testing.T, info *fakeInfoResponder, sess *fakeSetupSession) *Manager {
|
||||
t.Helper()
|
||||
|
||||
m := &Manager{
|
||||
ServerURL: "http://aftertouch.local:8000",
|
||||
HTTPGet: info.get,
|
||||
NewSetupSession: func(_, _ string, _ time.Duration) (SetupStateMachine, error) {
|
||||
return sess, nil
|
||||
},
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func TestExecuteInitPlan_FactoryReset_GeneratesAccountAndRunsAllSteps(t *testing.T) {
|
||||
info := &fakeInfoResponder{
|
||||
deviceID: "AABBCCDDEEFF",
|
||||
paired: "",
|
||||
postInitPaired: "", // filled below after we know which ID was generated
|
||||
}
|
||||
sess := &fakeSetupSession{}
|
||||
m := newTestManagerWithFakes(t, info, sess)
|
||||
|
||||
// Intercept the generated account ID so we can prime the post-init
|
||||
// /info read to return it. Easiest way: pre-supply a known AccountID.
|
||||
plan := InitPlan{
|
||||
DeviceIP: "192.0.2.10",
|
||||
AccountID: "1234567",
|
||||
DeviceName: "Living Room",
|
||||
SkipURLRewrite: true,
|
||||
}
|
||||
info.postInitPaired = "1234567"
|
||||
|
||||
var events []StepEvent
|
||||
|
||||
got, err := m.ExecuteInitPlan(context.Background(), plan, func(e StepEvent) {
|
||||
events = append(events, e)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteInitPlan: %v", err)
|
||||
}
|
||||
|
||||
if got.AccountID != "1234567" {
|
||||
t.Errorf("AccountID = %q, want 1234567", got.AccountID)
|
||||
}
|
||||
|
||||
if got.Language != LanguageEnglish {
|
||||
t.Errorf("Language = %d, want %d (default English)", got.Language, LanguageEnglish)
|
||||
}
|
||||
|
||||
wantCalls := []string{
|
||||
"Start",
|
||||
"IdentifyEnter(300000)",
|
||||
"SetLanguage(2)",
|
||||
"Enter",
|
||||
"IdentifyLeave",
|
||||
"SetName(Living Room)",
|
||||
"SetMargeAccount(1234567,Bearer aftertouch)",
|
||||
"Leave",
|
||||
"PushCustomerSupportInfo",
|
||||
}
|
||||
if got, want := strings.Join(sess.calls, "|"), strings.Join(wantCalls, "|"); got != want {
|
||||
t.Errorf("call order mismatch\n got: %s\nwant: %s", got, want)
|
||||
}
|
||||
|
||||
if !sess.closed {
|
||||
t.Error("expected session to be closed")
|
||||
}
|
||||
|
||||
// Verify the URL-rewrite event was emitted as Skipped, not silently dropped.
|
||||
if !hasEvent(events, StepURLRewrite, StatusSkipped) {
|
||||
t.Errorf("expected StepURLRewrite Skipped event, got %v", eventSummary(events))
|
||||
}
|
||||
|
||||
// Final verify step must report OK.
|
||||
if !hasEvent(events, StepVerify, StatusOK) {
|
||||
t.Errorf("expected StepVerify OK, got %v", eventSummary(events))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteInitPlan_ReusesExistingAccountUUID(t *testing.T) {
|
||||
info := &fakeInfoResponder{
|
||||
deviceID: "AABBCCDDEEFF",
|
||||
paired: "9876543",
|
||||
postInitPaired: "9876543",
|
||||
}
|
||||
sess := &fakeSetupSession{}
|
||||
m := newTestManagerWithFakes(t, info, sess)
|
||||
|
||||
plan := InitPlan{
|
||||
DeviceIP: "192.0.2.10",
|
||||
SkipURLRewrite: true,
|
||||
}
|
||||
|
||||
got, err := m.ExecuteInitPlan(context.Background(), plan, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteInitPlan: %v", err)
|
||||
}
|
||||
|
||||
if got.AccountID != "9876543" {
|
||||
t.Errorf("AccountID = %q, want 9876543 (the device's existing UUID)", got.AccountID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteInitPlan_GeneratesAccountWhenDeviceUUIDInvalid(t *testing.T) {
|
||||
// Devices that report a non-7-digit UUID (e.g. a stale local value) must
|
||||
// not be reused — we treat them as factory-reset for ID purposes.
|
||||
info := &fakeInfoResponder{
|
||||
deviceID: "AABBCCDDEEFF",
|
||||
paired: "not-7-digits",
|
||||
postInitPaired: "", // we'll learn the generated ID from the result
|
||||
}
|
||||
sess := &fakeSetupSession{}
|
||||
m := newTestManagerWithFakes(t, info, sess)
|
||||
|
||||
// Pre-generate so the post-init /info knows what to return.
|
||||
plan := InitPlan{DeviceIP: "192.0.2.10", SkipURLRewrite: true}
|
||||
|
||||
// Track the generated AccountID and feed it back as the post-init /info value.
|
||||
progress := func(e StepEvent) {
|
||||
if e.Kind == StepGenerateAccountID && e.Status == StatusOK && strings.Contains(e.Name, "generate account ID=") {
|
||||
info.postInitPaired = strings.TrimPrefix(e.Name, "generate account ID=")
|
||||
}
|
||||
}
|
||||
|
||||
got, err := m.ExecuteInitPlan(context.Background(), plan, progress)
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteInitPlan: %v", err)
|
||||
}
|
||||
|
||||
if !IsValidAccountID(got.AccountID) {
|
||||
t.Errorf("got.AccountID = %q, want a valid 7-digit ID", got.AccountID)
|
||||
}
|
||||
|
||||
if got.AccountID == "not-7-digits" {
|
||||
t.Error("orchestrator should not reuse an invalid UUID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteInitPlan_RejectsInvalidSuppliedAccountID(t *testing.T) {
|
||||
info := &fakeInfoResponder{deviceID: "X", paired: ""}
|
||||
sess := &fakeSetupSession{}
|
||||
m := newTestManagerWithFakes(t, info, sess)
|
||||
|
||||
plan := InitPlan{
|
||||
DeviceIP: "192.0.2.10",
|
||||
AccountID: "abc",
|
||||
SkipURLRewrite: true,
|
||||
}
|
||||
|
||||
_, err := m.ExecuteInitPlan(context.Background(), plan, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid AccountID")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "invalid AccountID") {
|
||||
t.Errorf("err = %v, want to mention invalid AccountID", err)
|
||||
}
|
||||
|
||||
if len(sess.calls) != 0 {
|
||||
t.Errorf("expected zero WS calls after rejection, got %v", sess.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteInitPlan_StopsAtFirstFailedStep(t *testing.T) {
|
||||
info := &fakeInfoResponder{deviceID: "X", paired: "", postInitPaired: "1234567"}
|
||||
sess := &fakeSetupSession{
|
||||
errors: map[string]error{
|
||||
"Enter": errors.New("device dropped the SETUP_ENTER frame"),
|
||||
},
|
||||
}
|
||||
m := newTestManagerWithFakes(t, info, sess)
|
||||
|
||||
plan := InitPlan{
|
||||
DeviceIP: "192.0.2.10",
|
||||
AccountID: "1234567",
|
||||
SkipURLRewrite: true,
|
||||
}
|
||||
|
||||
var events []StepEvent
|
||||
|
||||
_, err := m.ExecuteInitPlan(context.Background(), plan, func(e StepEvent) { events = append(events, e) })
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "SETUP_ENTER") {
|
||||
t.Errorf("err = %v, want to mention SETUP_ENTER", err)
|
||||
}
|
||||
|
||||
// Steps after the failed one must not have been called.
|
||||
for _, c := range sess.calls {
|
||||
if c == "Leave" || c == "PushCustomerSupportInfo" {
|
||||
t.Errorf("unexpected post-failure call %q", c)
|
||||
}
|
||||
}
|
||||
|
||||
if !hasEvent(events, StepSetupEnter, StatusFailed) {
|
||||
t.Errorf("expected StepSetupEnter Failed event, got %v", eventSummary(events))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteInitPlan_EmptyDeviceNameSkipsNameStep(t *testing.T) {
|
||||
info := &fakeInfoResponder{deviceID: "X", paired: "", postInitPaired: "1234567"}
|
||||
sess := &fakeSetupSession{}
|
||||
m := newTestManagerWithFakes(t, info, sess)
|
||||
|
||||
plan := InitPlan{
|
||||
DeviceIP: "192.0.2.10",
|
||||
AccountID: "1234567",
|
||||
SkipURLRewrite: true,
|
||||
// DeviceName intentionally empty
|
||||
}
|
||||
|
||||
if _, err := m.ExecuteInitPlan(context.Background(), plan, nil); err != nil {
|
||||
t.Fatalf("ExecuteInitPlan: %v", err)
|
||||
}
|
||||
|
||||
for _, c := range sess.calls {
|
||||
if strings.HasPrefix(c, "SetName(") {
|
||||
t.Errorf("SetName should be skipped when DeviceName is empty, but was called: %q", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteInitPlan_RequiresDeviceIP(t *testing.T) {
|
||||
m := &Manager{ServerURL: "http://aftertouch.local:8000"}
|
||||
|
||||
_, err := m.ExecuteInitPlan(context.Background(), InitPlan{}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "DeviceIP") {
|
||||
t.Errorf("err = %v, want to mention DeviceIP", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteInitPlan_RequiresServiceURL(t *testing.T) {
|
||||
m := &Manager{}
|
||||
|
||||
_, err := m.ExecuteInitPlan(context.Background(), InitPlan{DeviceIP: "192.0.2.10"}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "ServiceURL") {
|
||||
t.Errorf("err = %v, want to mention ServiceURL", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteInitPlan_FailsOnPostInitVerifyMismatch(t *testing.T) {
|
||||
// Device's post-init /info still reports the old account — surface
|
||||
// that as a verification failure rather than a silent success.
|
||||
info := &fakeInfoResponder{
|
||||
deviceID: "X",
|
||||
paired: "",
|
||||
postInitPaired: "9999999", // not equal to plan.AccountID
|
||||
}
|
||||
sess := &fakeSetupSession{}
|
||||
m := newTestManagerWithFakes(t, info, sess)
|
||||
|
||||
plan := InitPlan{
|
||||
DeviceIP: "192.0.2.10",
|
||||
AccountID: "1234567",
|
||||
SkipURLRewrite: true,
|
||||
}
|
||||
|
||||
_, err := m.ExecuteInitPlan(context.Background(), plan, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected verification error")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "margeAccountUUID") {
|
||||
t.Errorf("err = %v, want to mention margeAccountUUID mismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func hasEvent(events []StepEvent, kind StepKind, status StepStatus) bool {
|
||||
for _, e := range events {
|
||||
if e.Kind == kind && e.Status == status {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func eventSummary(events []StepEvent) string {
|
||||
parts := make([]string, 0, len(events))
|
||||
for _, e := range events {
|
||||
parts = append(parts, fmt.Sprintf("%d/%s", e.Kind, e.Status))
|
||||
}
|
||||
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
// InspectOptions controls how Manager.Inspect probes the speaker.
|
||||
type InspectOptions struct {
|
||||
// IncludeTelnet runs `getpdo CurrentSystemConfiguration` over telnet to
|
||||
// capture the speaker's runtime URL configuration. Slower and not
|
||||
// always reachable on hardened firmware, hence opt-in.
|
||||
IncludeTelnet bool
|
||||
}
|
||||
|
||||
// InspectSection is one slice of an InspectReport. Each section has an
|
||||
// independent error so a partial failure (e.g. /presets refused) does not
|
||||
// hide the rest of the report.
|
||||
type InspectSection struct {
|
||||
Name string
|
||||
Err error
|
||||
}
|
||||
|
||||
// InspectReport summarises everything we can learn about a speaker
|
||||
// without writing to it. Used to populate UI before factory-reset / pair
|
||||
// flows and to record the deviceID-suffix for later wait-online calls.
|
||||
type InspectReport struct {
|
||||
DeviceIP string
|
||||
|
||||
Info *DeviceInfoXML `json:"info,omitempty"`
|
||||
InfoErr error `json:"-"`
|
||||
Network *models.NetworkInformation `json:"network,omitempty"`
|
||||
NetworkErr error `json:"-"`
|
||||
Sources *models.Sources `json:"sources,omitempty"`
|
||||
SourcesErr error `json:"-"`
|
||||
Presets *PresetList `json:"presets,omitempty"`
|
||||
PresetsErr error `json:"-"`
|
||||
RuntimeURLs string `json:"runtime_urls,omitempty"`
|
||||
RuntimeErr error `json:"-"`
|
||||
}
|
||||
|
||||
// PresetList is a minimal preset summary — just enough to render a
|
||||
// "preset N: <name>" overview. The full preset model lives in
|
||||
// pkg/models, but we don't need it here.
|
||||
type PresetList struct {
|
||||
XMLName xml.Name `xml:"presets"`
|
||||
Presets []struct {
|
||||
ID string `xml:"id,attr"`
|
||||
ContentItem struct {
|
||||
Source string `xml:"source,attr"`
|
||||
SourceAccount string `xml:"sourceAccount,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
ItemName string `xml:"itemName"`
|
||||
} `xml:"ContentItem"`
|
||||
} `xml:"preset"`
|
||||
}
|
||||
|
||||
// Inspect gathers a non-destructive snapshot of the speaker at deviceIP.
|
||||
// Every probe is best-effort: individual section errors are recorded on
|
||||
// the report rather than aborting the whole call.
|
||||
func (m *Manager) Inspect(deviceIP string, opts InspectOptions) *InspectReport {
|
||||
r := &InspectReport{DeviceIP: deviceIP}
|
||||
|
||||
r.Info, r.InfoErr = m.GetLiveDeviceInfo(deviceIP)
|
||||
r.Network, r.NetworkErr = m.fetchNetworkInfo(deviceIP)
|
||||
r.Sources, r.SourcesErr = m.fetchSources(deviceIP)
|
||||
r.Presets, r.PresetsErr = m.fetchPresets(deviceIP)
|
||||
|
||||
if opts.IncludeTelnet {
|
||||
r.RuntimeURLs, r.RuntimeErr = m.fetchRuntimeURLs(deviceIP)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (m *Manager) fetchXML(deviceIP, path string, out any) error {
|
||||
url := buildDeviceURL(deviceIP, path)
|
||||
|
||||
resp, err := m.HTTPGet(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GET %s: %w", url, err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("GET %s returned %d", url, resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", url, err)
|
||||
}
|
||||
|
||||
return xml.Unmarshal(body, out)
|
||||
}
|
||||
|
||||
func (m *Manager) fetchNetworkInfo(deviceIP string) (*models.NetworkInformation, error) {
|
||||
var n models.NetworkInformation
|
||||
if err := m.fetchXML(deviceIP, "/networkInfo", &n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &n, nil
|
||||
}
|
||||
|
||||
func (m *Manager) fetchSources(deviceIP string) (*models.Sources, error) {
|
||||
var s models.Sources
|
||||
if err := m.fetchXML(deviceIP, "/sources", &s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (m *Manager) fetchPresets(deviceIP string) (*PresetList, error) {
|
||||
var p PresetList
|
||||
if err := m.fetchXML(deviceIP, "/presets", &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// fetchRuntimeURLs reads the device's runtime URL configuration via the
|
||||
// port-17000 diagnostic shell. The response is a multi-line text blob —
|
||||
// we return it as-is so the caller can decide how to format it.
|
||||
func (m *Manager) fetchRuntimeURLs(deviceIP string) (string, error) {
|
||||
if m.NewTelnet == nil {
|
||||
return "", errors.New("telnet probe disabled: Manager.NewTelnet is nil")
|
||||
}
|
||||
|
||||
t := m.NewTelnet(deviceIP)
|
||||
if err := t.Dial(); err != nil {
|
||||
return "", fmt.Errorf("telnet dial %s:17000: %w", deviceIP, err)
|
||||
}
|
||||
|
||||
defer func() { _ = t.Close() }()
|
||||
|
||||
_, _ = t.Probe()
|
||||
|
||||
resp, err := t.SendCommand("getpdo CurrentSystemConfiguration")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("getpdo: %w", err)
|
||||
}
|
||||
|
||||
if isCommandNotFound(resp) {
|
||||
return "", errors.New("device rejected `getpdo` (firmware does not expose this command)")
|
||||
}
|
||||
|
||||
return strings.TrimRight(resp, "\r\n"), nil
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// inspectFakes wires canned XML bodies into Manager.HTTPGet keyed by URL
|
||||
// path. A missing path returns 404; an empty-string body returns the
|
||||
// supplied err.
|
||||
type inspectFakes struct {
|
||||
responses map[string]string
|
||||
errs map[string]error
|
||||
}
|
||||
|
||||
func (f *inspectFakes) get(url string) (*http.Response, error) {
|
||||
for path, body := range f.responses {
|
||||
if strings.HasSuffix(url, path) {
|
||||
if e := f.errs[path]; e != nil {
|
||||
return nil, e
|
||||
}
|
||||
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: http.Header{},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return &http.Response{
|
||||
StatusCode: 404,
|
||||
Body: io.NopCloser(strings.NewReader("not found")),
|
||||
Header: http.Header{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestInspect_HappyPath(t *testing.T) {
|
||||
f := &inspectFakes{
|
||||
responses: map[string]string{
|
||||
"/info": `<info deviceID="506583DE4803">
|
||||
<name>Bose SoundTouch DE4803</name>
|
||||
<type>SoundTouch 10</type>
|
||||
<margeAccountUUID>1234567</margeAccountUUID>
|
||||
<margeURL>http://aftertouch.local:8000</margeURL>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<softwareVersion>27.0.6</softwareVersion>
|
||||
<serialNumber>F23456789012</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
</info>`,
|
||||
"/networkInfo": `<networkInfo wifiProfileCount="1">
|
||||
<interfaces>
|
||||
<interface type="WIFI_INTERFACE" name="wlan0" macAddress="aa:bb:cc:dd:ee:ff" ipAddress="192.168.1.42" ssid="MyHomeNetwork" frequencyKHz="2452000" state="NETWORK_WIFI_CONNECTED" signal="GOOD_SIGNAL" mode="STATION"/>
|
||||
</interfaces>
|
||||
</networkInfo>`,
|
||||
"/sources": `<sources><sourceItem source="TUNEIN" status="READY"/><sourceItem source="SPOTIFY" sourceAccount="user@example.com" status="READY"/></sources>`,
|
||||
"/presets": `<presets><preset id="1"><ContentItem source="TUNEIN" type="stationurl"><itemName>1LIVE</itemName></ContentItem></preset></presets>`,
|
||||
},
|
||||
}
|
||||
|
||||
m := &Manager{HTTPGet: f.get}
|
||||
|
||||
r := m.Inspect("192.168.1.42", InspectOptions{})
|
||||
|
||||
if r.InfoErr != nil {
|
||||
t.Errorf("InfoErr = %v, want nil", r.InfoErr)
|
||||
}
|
||||
|
||||
if r.Info == nil || r.Info.DeviceID != "506583DE4803" {
|
||||
t.Errorf("Info.DeviceID = %v, want 506583DE4803", r.Info)
|
||||
}
|
||||
|
||||
if r.Info.MargeAccountUUID != "1234567" {
|
||||
t.Errorf("MargeAccountUUID = %q, want 1234567", r.Info.MargeAccountUUID)
|
||||
}
|
||||
|
||||
if r.Network == nil || len(r.Network.Interfaces.Interfaces) == 0 {
|
||||
t.Fatalf("Network parse failed: %v / %v", r.Network, r.NetworkErr)
|
||||
}
|
||||
|
||||
wifi := r.Network.Interfaces.Interfaces[0]
|
||||
if wifi.SSID != "MyHomeNetwork" {
|
||||
t.Errorf("SSID = %q, want MyHomeNetwork", wifi.SSID)
|
||||
}
|
||||
|
||||
if r.Sources == nil || len(r.Sources.SourceItem) != 2 {
|
||||
t.Errorf("Sources = %v, want 2 entries", r.Sources)
|
||||
}
|
||||
|
||||
if r.Presets == nil || len(r.Presets.Presets) != 1 {
|
||||
t.Errorf("Presets = %v, want 1 preset", r.Presets)
|
||||
}
|
||||
|
||||
if r.Presets.Presets[0].ContentItem.ItemName != "1LIVE" {
|
||||
t.Errorf("preset name = %q, want 1LIVE", r.Presets.Presets[0].ContentItem.ItemName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspect_PartialFailureRecordsPerSectionErrors(t *testing.T) {
|
||||
// /info ok, /presets returns network error — the rest of the report
|
||||
// must still populate.
|
||||
f := &inspectFakes{
|
||||
responses: map[string]string{
|
||||
"/info": `<info deviceID="X"><name>n</name></info>`,
|
||||
"/networkInfo": `<networkInfo><interfaces></interfaces></networkInfo>`,
|
||||
"/sources": `<sources/>`,
|
||||
"/presets": "", // body unused — errs map below triggers error
|
||||
},
|
||||
errs: map[string]error{
|
||||
"/presets": errors.New("connection reset"),
|
||||
},
|
||||
}
|
||||
|
||||
m := &Manager{HTTPGet: f.get}
|
||||
|
||||
r := m.Inspect("192.168.1.42", InspectOptions{})
|
||||
|
||||
if r.InfoErr != nil {
|
||||
t.Errorf("InfoErr = %v, want nil", r.InfoErr)
|
||||
}
|
||||
|
||||
if r.PresetsErr == nil {
|
||||
t.Error("expected PresetsErr to be populated")
|
||||
}
|
||||
|
||||
if r.Sources == nil {
|
||||
t.Error("Sources should still populate despite PresetsErr")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspect_TelnetRuntimeURLs(t *testing.T) {
|
||||
f := &inspectFakes{
|
||||
responses: map[string]string{
|
||||
"/info": `<info deviceID="X"><name>n</name></info>`,
|
||||
},
|
||||
}
|
||||
|
||||
tn := &fakeTelnet{
|
||||
responses: map[string]string{
|
||||
"getpdo CurrentSystemConfiguration": "margeServerUrl: http://aftertouch.local:8000\nstatsServerUrl: http://aftertouch.local:8000\n",
|
||||
},
|
||||
}
|
||||
|
||||
m := &Manager{
|
||||
HTTPGet: f.get,
|
||||
NewTelnet: func(string) TelnetClient { return tn },
|
||||
}
|
||||
|
||||
r := m.Inspect("192.168.1.42", InspectOptions{IncludeTelnet: true})
|
||||
|
||||
if r.RuntimeErr != nil {
|
||||
t.Errorf("RuntimeErr = %v, want nil", r.RuntimeErr)
|
||||
}
|
||||
|
||||
if !strings.Contains(r.RuntimeURLs, "margeServerUrl") {
|
||||
t.Errorf("RuntimeURLs missing expected content: %q", r.RuntimeURLs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspect_TelnetSkippedWhenOptionDisabled(t *testing.T) {
|
||||
f := &inspectFakes{
|
||||
responses: map[string]string{
|
||||
"/info": `<info deviceID="X"><name>n</name></info>`,
|
||||
},
|
||||
}
|
||||
|
||||
m := &Manager{HTTPGet: f.get}
|
||||
|
||||
r := m.Inspect("192.168.1.42", InspectOptions{IncludeTelnet: false})
|
||||
|
||||
if r.RuntimeURLs != "" || r.RuntimeErr != nil {
|
||||
t.Errorf("telnet runtime fields should be zero when IncludeTelnet=false, got %q / %v",
|
||||
r.RuntimeURLs, r.RuntimeErr)
|
||||
}
|
||||
}
|
||||
+98
-60
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultSetupStepTimeout = 8 * time.Second
|
||||
setupHandshakeTimeout = 10 * time.Second
|
||||
|
||||
// LanguageEnglish is the sysLanguage code for English. ‹2› is the
|
||||
// value the official Bose app sends during English-locale setup.
|
||||
LanguageEnglish = 2
|
||||
)
|
||||
|
||||
// SetupStateMachine is the surface the InitPlan orchestrator drives. The
|
||||
// concrete WebSocket-backed implementation is *SetupSession; tests inject
|
||||
// an in-memory fake via Manager.NewSetupSession.
|
||||
type SetupStateMachine interface {
|
||||
Start(ctx context.Context) error
|
||||
IdentifyEnter(ctx context.Context, timeoutMs int) error
|
||||
SetLanguage(ctx context.Context, code int) error
|
||||
Enter(ctx context.Context) error
|
||||
IdentifyLeave(ctx context.Context) error
|
||||
SetName(ctx context.Context, name string) error
|
||||
SetMargeAccount(ctx context.Context, accountID, authToken string) error
|
||||
Leave(ctx context.Context) error
|
||||
PushCustomerSupportInfo(ctx context.Context) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
// SetupSessionConfig configures DialSetupSession. Zero values pick safe
|
||||
// defaults; in production callers normally pass an empty struct.
|
||||
type SetupSessionConfig struct {
|
||||
// StepTimeout caps the per-message wait for an ack frame. Default 8 s.
|
||||
StepTimeout time.Duration
|
||||
// DialTimeout caps the WebSocket handshake. Default 10 s.
|
||||
DialTimeout time.Duration
|
||||
// WSScheme overrides "ws". Tests inject "ws" with httptest's host:port
|
||||
// already encoded in deviceIP and rely on the dialer to use the URL
|
||||
// as-is.
|
||||
WSScheme string
|
||||
// WSPort overrides 8080 when deviceIP does not already carry a port.
|
||||
WSPort int
|
||||
}
|
||||
|
||||
// SetupSession is a synchronous request/response WebSocket session driving
|
||||
// the speaker's setup state machine. It is deliberately separate from
|
||||
// pkg/client.WebSocketClient (which is event-oriented, auto-reconnecting,
|
||||
// and stateful) — setup is a short, linear sequence and benefits from a
|
||||
// purpose-built transport.
|
||||
type SetupSession struct {
|
||||
deviceID string
|
||||
conn *websocket.Conn
|
||||
reqID atomic.Int64
|
||||
stepTimeout time.Duration
|
||||
}
|
||||
|
||||
// DialSetupSession opens a WebSocket to the speaker at deviceIP and
|
||||
// returns a session ready to drive the SETUP state machine. deviceID is
|
||||
// required because every <msg> envelope embeds it in the header; obtain
|
||||
// it from /info before calling.
|
||||
func DialSetupSession(deviceIP, deviceID string, cfg SetupSessionConfig) (*SetupSession, error) {
|
||||
if deviceID == "" {
|
||||
return nil, errors.New("DialSetupSession: deviceID is required for message routing")
|
||||
}
|
||||
|
||||
scheme := cfg.WSScheme
|
||||
if scheme == "" {
|
||||
scheme = "ws"
|
||||
}
|
||||
|
||||
host := deviceIP
|
||||
|
||||
if _, _, err := net.SplitHostPort(deviceIP); err != nil {
|
||||
port := cfg.WSPort
|
||||
if port == 0 {
|
||||
port = 8080
|
||||
}
|
||||
|
||||
host = fmt.Sprintf("%s:%d", deviceIP, port)
|
||||
}
|
||||
|
||||
wsURL := url.URL{Scheme: scheme, Host: host, Path: "/"}
|
||||
|
||||
handshake := cfg.DialTimeout
|
||||
if handshake == 0 {
|
||||
handshake = setupHandshakeTimeout
|
||||
}
|
||||
|
||||
dialer := websocket.Dialer{
|
||||
HandshakeTimeout: handshake,
|
||||
Subprotocols: []string{"gabbo"},
|
||||
}
|
||||
|
||||
conn, resp, err := dialer.Dial(wsURL.String(), nil)
|
||||
if resp != nil && resp.Body != nil {
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("websocket dial %s: %w", wsURL.String(), err)
|
||||
}
|
||||
|
||||
step := cfg.StepTimeout
|
||||
if step == 0 {
|
||||
step = defaultSetupStepTimeout
|
||||
}
|
||||
|
||||
return &SetupSession{deviceID: deviceID, conn: conn, stepTimeout: step}, nil
|
||||
}
|
||||
|
||||
// Close sends a normal-closure frame and closes the underlying socket.
|
||||
func (s *SetupSession) Close() error {
|
||||
if s.conn == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
_ = s.conn.WriteControl(
|
||||
websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""),
|
||||
time.Now().Add(time.Second),
|
||||
)
|
||||
|
||||
err := s.conn.Close()
|
||||
s.conn = nil
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// sendStep wraps body in the canonical <msg><header url="…" method="…">…
|
||||
// envelope, sends it, and drains incoming frames until one references the
|
||||
// same requestID, status path, or url attribute — that frame is the ack.
|
||||
// Pushed <updates> and <SoundTouchSdkInfo> frames are ignored.
|
||||
func (s *SetupSession) sendStep(ctx context.Context, route, method, body string) (string, error) {
|
||||
if s.conn == nil {
|
||||
return "", errors.New("setup session: connection closed")
|
||||
}
|
||||
|
||||
id := s.reqID.Add(1)
|
||||
|
||||
envelope := fmt.Sprintf(
|
||||
`<msg><header deviceID="%s" url="%s" method="%s"><request requestID="%d"/></header><body>%s</body></msg>`,
|
||||
xmlAttrEscape(s.deviceID), xmlAttrEscape(route), method, id, body,
|
||||
)
|
||||
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
deadline = time.Now().Add(s.stepTimeout)
|
||||
}
|
||||
|
||||
_ = s.conn.SetWriteDeadline(deadline)
|
||||
|
||||
if err := s.conn.WriteMessage(websocket.TextMessage, []byte(envelope)); err != nil {
|
||||
return "", fmt.Errorf("send %s: %w", route, err)
|
||||
}
|
||||
|
||||
idNeedle := fmt.Sprintf(`requestID="%d"`, id)
|
||||
statusNeedle := fmt.Sprintf(`<status>/%s</status>`, route)
|
||||
urlNeedle := fmt.Sprintf(`url="%s"`, route)
|
||||
|
||||
for {
|
||||
_ = s.conn.SetReadDeadline(deadline)
|
||||
|
||||
_, data, err := s.conn.ReadMessage()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("await ack for %s: %w", route, err)
|
||||
}
|
||||
|
||||
text := string(data)
|
||||
|
||||
// Pushed event frames during setup (sourcesUpdated etc.) and the
|
||||
// SDK banner are not acks.
|
||||
if strings.Contains(text, "<updates ") || strings.Contains(text, "<SoundTouchSdkInfo") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Device-side errors surface as <error …/> in the body.
|
||||
if strings.Contains(strings.ToLower(text), "<error") {
|
||||
return text, fmt.Errorf("device rejected %s: %s", route, strings.TrimSpace(text))
|
||||
}
|
||||
|
||||
if strings.Contains(text, idNeedle) || strings.Contains(text, statusNeedle) || strings.Contains(text, urlNeedle) {
|
||||
return text, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start sends SETUP_START.
|
||||
func (s *SetupSession) Start(ctx context.Context) error {
|
||||
_, err := s.sendStep(ctx, "setup", "POST", `<setupState state="SETUP_START"/>`)
|
||||
return err
|
||||
}
|
||||
|
||||
// IdentifyEnter sends SETUP_IDENTIFY_DEVICE_ENTER. timeoutMs defaults to
|
||||
// the value observed in captures (300 000 ms).
|
||||
func (s *SetupSession) IdentifyEnter(ctx context.Context, timeoutMs int) error {
|
||||
if timeoutMs <= 0 {
|
||||
timeoutMs = 300000
|
||||
}
|
||||
|
||||
body := fmt.Sprintf(`<setupState state="SETUP_IDENTIFY_DEVICE_ENTER" timeout="%d"/>`, timeoutMs)
|
||||
_, err := s.sendStep(ctx, "setup", "POST", body)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// SetLanguage POSTs sysLanguage. Code 2 = English.
|
||||
func (s *SetupSession) SetLanguage(ctx context.Context, code int) error {
|
||||
body := fmt.Sprintf(`<sysLanguage>%d</sysLanguage>`, code)
|
||||
_, err := s.sendStep(ctx, "language", "POST", body)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Enter sends SETUP_ENTER.
|
||||
func (s *SetupSession) Enter(ctx context.Context) error {
|
||||
_, err := s.sendStep(ctx, "setup", "POST", `<setupState state="SETUP_ENTER"/>`)
|
||||
return err
|
||||
}
|
||||
|
||||
// IdentifyLeave sends SETUP_IDENTIFY_DEVICE_LEAVE.
|
||||
func (s *SetupSession) IdentifyLeave(ctx context.Context) error {
|
||||
_, err := s.sendStep(ctx, "setup", "POST", `<setupState state="SETUP_IDENTIFY_DEVICE_LEAVE"/>`)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetName POSTs a device-name change. An empty name is a no-op.
|
||||
func (s *SetupSession) SetName(ctx context.Context, name string) error {
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
body := fmt.Sprintf(`<name>%s</name>`, xmlBodyEscape(name))
|
||||
_, err := s.sendStep(ctx, "name", "POST", body)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// SetMargeAccount sends the canonical PairDeviceWithAccount envelope.
|
||||
// authToken defaults to "Bearer aftertouch" when empty — our local
|
||||
// service does not validate it, but a non-empty value matches the
|
||||
// official app's shape.
|
||||
func (s *SetupSession) SetMargeAccount(ctx context.Context, accountID, authToken string) error {
|
||||
if accountID == "" {
|
||||
return errors.New("SetMargeAccount: accountID is required")
|
||||
}
|
||||
|
||||
if authToken == "" {
|
||||
authToken = "Bearer aftertouch"
|
||||
}
|
||||
|
||||
body := fmt.Sprintf(
|
||||
`<PairDeviceWithAccount><accountId>%s</accountId><userAuthToken>%s</userAuthToken></PairDeviceWithAccount>`,
|
||||
xmlBodyEscape(accountID), xmlBodyEscape(authToken),
|
||||
)
|
||||
_, err := s.sendStep(ctx, "setMargeAccount", "POST", body)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Leave sends SETUP_LEAVE.
|
||||
func (s *SetupSession) Leave(ctx context.Context) error {
|
||||
_, err := s.sendStep(ctx, "setup", "POST", `<setupState state="SETUP_LEAVE"/>`)
|
||||
return err
|
||||
}
|
||||
|
||||
// PushCustomerSupportInfo triggers the post-setup telemetry sync. Harmless
|
||||
// on our local service.
|
||||
func (s *SetupSession) PushCustomerSupportInfo(ctx context.Context) error {
|
||||
_, err := s.sendStep(ctx, "pushCustomerSupportInfoToMarge", "GET", "")
|
||||
return err
|
||||
}
|
||||
|
||||
// xmlAttrEscape escapes the small set of characters that would break an
|
||||
// XML attribute context. We build envelopes by concatenation because the
|
||||
// body fragments are already valid XML — running them through encoding/xml
|
||||
// would re-escape nested tags.
|
||||
func xmlAttrEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// fakeSpeaker is a minimal WebSocket endpoint that records frames sent by
|
||||
// SetupSession and responds with canned replies. Each test wires its own
|
||||
// reply policy by setting reply.
|
||||
type fakeSpeaker struct {
|
||||
server *httptest.Server
|
||||
mu sync.Mutex
|
||||
frames []string
|
||||
reply func(frame string) []string
|
||||
}
|
||||
|
||||
func newFakeSpeaker(t *testing.T) *fakeSpeaker {
|
||||
t.Helper()
|
||||
|
||||
f := &fakeSpeaker{}
|
||||
upgrader := websocket.Upgrader{
|
||||
Subprotocols: []string{"gabbo"},
|
||||
CheckOrigin: func(*http.Request) bool { return true },
|
||||
}
|
||||
|
||||
f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
t.Logf("upgrade: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
for {
|
||||
_, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
f.mu.Lock()
|
||||
f.frames = append(f.frames, string(data))
|
||||
policy := f.reply
|
||||
f.mu.Unlock()
|
||||
|
||||
var replies []string
|
||||
if policy != nil {
|
||||
replies = policy(string(data))
|
||||
} else {
|
||||
replies = []string{ackFor(string(data))}
|
||||
}
|
||||
|
||||
for _, r := range replies {
|
||||
if err := conn.WriteMessage(websocket.TextMessage, []byte(r)); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
t.Cleanup(f.server.Close)
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
// ackFor builds a minimal echo reply that carries the same requestID as
|
||||
// the incoming frame, so the SetupSession's correlation logic accepts it.
|
||||
func ackFor(frame string) string {
|
||||
id := extractAttr(frame, `requestID="`, `"`)
|
||||
return fmt.Sprintf(`<msg><header url="setup"><response requestID="%s"/></header><body><status>ok</status></body></msg>`, id)
|
||||
}
|
||||
|
||||
func extractAttr(s, prefix, suffix string) string {
|
||||
i := strings.Index(s, prefix)
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
rest := s[i+len(prefix):]
|
||||
|
||||
j := strings.Index(rest, suffix)
|
||||
if j < 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return rest[:j]
|
||||
}
|
||||
|
||||
func (f *fakeSpeaker) recordedFrames() []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
out := make([]string, len(f.frames))
|
||||
copy(out, f.frames)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// dialFakeSession opens a SetupSession against the fake speaker. We turn
|
||||
// the httptest server URL inside-out (http → ws, keep host:port) so the
|
||||
// dialer reaches our handler.
|
||||
func dialFakeSession(t *testing.T, f *fakeSpeaker, deviceID string) *SetupSession {
|
||||
t.Helper()
|
||||
|
||||
u, err := url.Parse(f.server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse server URL: %v", err)
|
||||
}
|
||||
|
||||
s, err := DialSetupSession(u.Host, deviceID, SetupSessionConfig{
|
||||
StepTimeout: 2 * time.Second,
|
||||
DialTimeout: 2 * time.Second,
|
||||
WSScheme: "ws",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DialSetupSession: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func TestSetupSession_SendsCanonicalEnvelopes(t *testing.T) {
|
||||
f := newFakeSpeaker(t)
|
||||
s := dialFakeSession(t, f, "AABBCCDDEEFF")
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
if err := s.Start(ctx); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
|
||||
if err := s.IdentifyEnter(ctx, 300000); err != nil {
|
||||
t.Fatalf("IdentifyEnter: %v", err)
|
||||
}
|
||||
|
||||
if err := s.SetLanguage(ctx, 2); err != nil {
|
||||
t.Fatalf("SetLanguage: %v", err)
|
||||
}
|
||||
|
||||
if err := s.Enter(ctx); err != nil {
|
||||
t.Fatalf("Enter: %v", err)
|
||||
}
|
||||
|
||||
if err := s.IdentifyLeave(ctx); err != nil {
|
||||
t.Fatalf("IdentifyLeave: %v", err)
|
||||
}
|
||||
|
||||
if err := s.SetName(ctx, "Living Room"); err != nil {
|
||||
t.Fatalf("SetName: %v", err)
|
||||
}
|
||||
|
||||
if err := s.SetMargeAccount(ctx, "1234567", ""); err != nil {
|
||||
t.Fatalf("SetMargeAccount: %v", err)
|
||||
}
|
||||
|
||||
if err := s.Leave(ctx); err != nil {
|
||||
t.Fatalf("Leave: %v", err)
|
||||
}
|
||||
|
||||
if err := s.PushCustomerSupportInfo(ctx); err != nil {
|
||||
t.Fatalf("PushCustomerSupportInfo: %v", err)
|
||||
}
|
||||
|
||||
frames := f.recordedFrames()
|
||||
if len(frames) != 9 {
|
||||
t.Fatalf("got %d frames, want 9: %v", len(frames), frames)
|
||||
}
|
||||
|
||||
mustContain(t, frames[0], `deviceID="AABBCCDDEEFF"`, `url="setup"`, `method="POST"`, `<setupState state="SETUP_START"/>`)
|
||||
mustContain(t, frames[1], `url="setup"`, `<setupState state="SETUP_IDENTIFY_DEVICE_ENTER" timeout="300000"/>`)
|
||||
mustContain(t, frames[2], `url="language"`, `<sysLanguage>2</sysLanguage>`)
|
||||
mustContain(t, frames[3], `<setupState state="SETUP_ENTER"/>`)
|
||||
mustContain(t, frames[4], `<setupState state="SETUP_IDENTIFY_DEVICE_LEAVE"/>`)
|
||||
mustContain(t, frames[5], `url="name"`, `<name>Living Room</name>`)
|
||||
mustContain(t, frames[6], `url="setMargeAccount"`, `<accountId>1234567</accountId>`, `<userAuthToken>Bearer aftertouch</userAuthToken>`)
|
||||
mustContain(t, frames[7], `<setupState state="SETUP_LEAVE"/>`)
|
||||
mustContain(t, frames[8], `url="pushCustomerSupportInfoToMarge"`, `method="GET"`)
|
||||
}
|
||||
|
||||
func TestSetupSession_RequestIDsAreUniquePerStep(t *testing.T) {
|
||||
f := newFakeSpeaker(t)
|
||||
s := dialFakeSession(t, f, "X")
|
||||
ctx := context.Background()
|
||||
|
||||
if err := s.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := s.Enter(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
frames := f.recordedFrames()
|
||||
id1 := extractAttr(frames[0], `requestID="`, `"`)
|
||||
id2 := extractAttr(frames[1], `requestID="`, `"`)
|
||||
|
||||
if id1 == "" || id2 == "" {
|
||||
t.Fatalf("missing requestIDs: %q %q", id1, id2)
|
||||
}
|
||||
|
||||
if id1 == id2 {
|
||||
t.Errorf("requestIDs must be unique per step, got %s twice", id1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupSession_IgnoresUpdatesFramesBeforeAck(t *testing.T) {
|
||||
f := newFakeSpeaker(t)
|
||||
f.reply = func(frame string) []string {
|
||||
id := extractAttr(frame, `requestID="`, `"`)
|
||||
// Push a sourcesUpdated frame first; the session must ignore it
|
||||
// and keep reading until the actual ack arrives.
|
||||
return []string{
|
||||
`<updates deviceID="X"><sourcesUpdated/></updates>`,
|
||||
`<SoundTouchSdkInfo build="x"/>`,
|
||||
fmt.Sprintf(`<msg><header url="setup"><response requestID="%s"/></header><body><status>/setup</status></body></msg>`, id),
|
||||
}
|
||||
}
|
||||
|
||||
s := dialFakeSession(t, f, "X")
|
||||
if err := s.Start(context.Background()); err != nil {
|
||||
t.Fatalf("Start should succeed despite pushed update frames, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupSession_SurfacesDeviceErrors(t *testing.T) {
|
||||
f := newFakeSpeaker(t)
|
||||
f.reply = func(frame string) []string {
|
||||
return []string{
|
||||
`<msg><header url="setMargeAccount"><response/></header><body><error value="1003" name="ACCOUNT_REJECTED">no</error></body></msg>`,
|
||||
}
|
||||
}
|
||||
|
||||
s := dialFakeSession(t, f, "X")
|
||||
|
||||
err := s.SetMargeAccount(context.Background(), "1234567", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error from <error/> body")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "device rejected setMargeAccount") {
|
||||
t.Errorf("err = %v, want to mention device rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupSession_RejectsEmptyDeviceID(t *testing.T) {
|
||||
_, err := DialSetupSession("127.0.0.1:8080", "", SetupSessionConfig{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty deviceID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupSession_RejectsEmptyAccountID(t *testing.T) {
|
||||
f := newFakeSpeaker(t)
|
||||
s := dialFakeSession(t, f, "X")
|
||||
|
||||
err := s.SetMargeAccount(context.Background(), "", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty accountID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupSession_EmptyNameIsNoOp(t *testing.T) {
|
||||
f := newFakeSpeaker(t)
|
||||
s := dialFakeSession(t, f, "X")
|
||||
|
||||
if err := s.SetName(context.Background(), ""); err != nil {
|
||||
t.Fatalf("SetName(\"\") should be no-op, got %v", err)
|
||||
}
|
||||
|
||||
if len(f.recordedFrames()) != 0 {
|
||||
t.Errorf("expected no frames for empty name, got %v", f.recordedFrames())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupSession_XMLAttributeEscape(t *testing.T) {
|
||||
// Device names with special characters must not break the envelope.
|
||||
f := newFakeSpeaker(t)
|
||||
s := dialFakeSession(t, f, `quoted"<id>`)
|
||||
|
||||
if err := s.Start(context.Background()); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
|
||||
frames := f.recordedFrames()
|
||||
if len(frames) != 1 {
|
||||
t.Fatalf("want 1 frame, got %d", len(frames))
|
||||
}
|
||||
|
||||
mustContain(t, frames[0], `deviceID="quoted"<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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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@<path>@ — start-of-file marker
|
||||
// <base64 contents> — exactly one line of base64 (no newlines)
|
||||
// @END@ — end-of-file marker
|
||||
// @EXISTS@<path>@ — path-exists assertion
|
||||
//
|
||||
// We tolerate any other lines as stray output and skip them.
|
||||
func parseSpeakerProbe(probe *speakerProbe, output string) {
|
||||
scanner := bufio.NewScanner(strings.NewReader(output))
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
|
||||
var (
|
||||
inFile bool
|
||||
currentPath string
|
||||
b64 strings.Builder
|
||||
)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
switch {
|
||||
case line == "@SSH_OK@":
|
||||
probe.SSHOK = true
|
||||
|
||||
case strings.HasPrefix(line, "@FILE@") && strings.HasSuffix(line, "@"):
|
||||
currentPath = strings.TrimSuffix(strings.TrimPrefix(line, "@FILE@"), "@")
|
||||
inFile = true
|
||||
|
||||
b64.Reset()
|
||||
|
||||
case line == "@END@":
|
||||
if inFile && currentPath != "" {
|
||||
if decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64.String())); err == nil {
|
||||
probe.Files[currentPath] = string(decoded)
|
||||
}
|
||||
}
|
||||
|
||||
inFile = false
|
||||
currentPath = ""
|
||||
|
||||
b64.Reset()
|
||||
|
||||
case strings.HasPrefix(line, "@EXISTS@") && strings.HasSuffix(line, "@"):
|
||||
path := strings.TrimSuffix(strings.TrimPrefix(line, "@EXISTS@"), "@")
|
||||
probe.Exists[path] = true
|
||||
|
||||
default:
|
||||
if inFile {
|
||||
b64.WriteString(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
// SpeakerSetupAP is the IP address a SoundTouch speaker assigns itself
|
||||
// when in setup mode. Verified on ST10 (assigns 192.0.2.2 to the client
|
||||
// via DHCP).
|
||||
const SpeakerSetupAP = "192.0.2.1"
|
||||
|
||||
// DefaultWiFiSecurity matches what the official setup wizard sends for
|
||||
// home networks. The device accepts this string for both WPA and WPA2.
|
||||
const DefaultWiFiSecurity = "wpa_or_wpa2"
|
||||
|
||||
// PushWiFiCredentialsParams holds the inputs for PushWiFiCredentials.
|
||||
type PushWiFiCredentialsParams struct {
|
||||
// APHost is the speaker's setup-mode address. Defaults to SpeakerSetupAP.
|
||||
APHost string
|
||||
// SSID and Password identify the home network to join.
|
||||
SSID string
|
||||
Password string
|
||||
// Security defaults to DefaultWiFiSecurity.
|
||||
Security string
|
||||
// HTTPClient lets callers (mostly tests) override the transport. Nil
|
||||
// uses a 10-second default client.
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// PushWiFiCredentials POSTs an AddWirelessProfile XML to the speaker's
|
||||
// setup-mode endpoint, instructing it to drop AP mode and join the named
|
||||
// network. The caller must already be connected to the speaker's Wi-Fi.
|
||||
//
|
||||
// The speaker confirms the request before disconnecting; expect to lose
|
||||
// the AP link within ~30 seconds.
|
||||
func PushWiFiCredentials(ctx context.Context, p PushWiFiCredentialsParams) error {
|
||||
if p.SSID == "" {
|
||||
return fmt.Errorf("PushWiFiCredentials: SSID is required")
|
||||
}
|
||||
|
||||
host := p.APHost
|
||||
if host == "" {
|
||||
host = SpeakerSetupAP
|
||||
}
|
||||
|
||||
security := p.Security
|
||||
if security == "" {
|
||||
security = DefaultWiFiSecurity
|
||||
}
|
||||
|
||||
body := fmt.Sprintf(
|
||||
`<AddWirelessProfile><profile ssid="%s" password="%s" securityType="%s" /></AddWirelessProfile>`,
|
||||
xmlAttrEscape(p.SSID), xmlAttrEscape(p.Password), xmlAttrEscape(security),
|
||||
)
|
||||
|
||||
hostPort := host
|
||||
if _, _, err := net.SplitHostPort(host); err != nil {
|
||||
hostPort = host + ":8090"
|
||||
}
|
||||
|
||||
url := "http://" + hostPort + "/addWirelessProfile"
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "text/xml")
|
||||
|
||||
httpClient := p.HTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("POST %s: %w", url, err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("POST %s returned %d: %s", url, resp.StatusCode, strings.TrimSpace(string(respBody)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PollConfig governs the retry cadence of WaitForAP and WaitForOnline.
|
||||
type PollConfig struct {
|
||||
// Interval between probes. Default 2 s.
|
||||
Interval time.Duration
|
||||
// Timeout is the total wall-clock budget. Default 5 min.
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
func (c PollConfig) interval() time.Duration {
|
||||
if c.Interval <= 0 {
|
||||
return 2 * time.Second
|
||||
}
|
||||
|
||||
return c.Interval
|
||||
}
|
||||
|
||||
func (c PollConfig) timeout() time.Duration {
|
||||
if c.Timeout <= 0 {
|
||||
return 5 * time.Minute
|
||||
}
|
||||
|
||||
return c.Timeout
|
||||
}
|
||||
|
||||
// WaitForAP blocks until the speaker at apHost answers /info on its
|
||||
// setup-mode HTTP endpoint, then returns the parsed info. apHost
|
||||
// defaults to SpeakerSetupAP. The caller is expected to have switched
|
||||
// the host machine to the speaker's setup-mode Wi-Fi network manually.
|
||||
//
|
||||
// HTTPGet is the dependency-injection point so tests can supply a fake
|
||||
// without spinning a server on 192.0.2.1.
|
||||
func WaitForAP(ctx context.Context, apHost string, cfg PollConfig, httpGet func(string) (*http.Response, error)) (*DeviceInfoXML, error) {
|
||||
if apHost == "" {
|
||||
apHost = SpeakerSetupAP
|
||||
}
|
||||
|
||||
if httpGet == nil {
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
httpGet = client.Get
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(cfg.timeout())
|
||||
|
||||
hostPort := apHost
|
||||
if _, _, err := net.SplitHostPort(apHost); err != nil {
|
||||
hostPort = apHost + ":8090"
|
||||
}
|
||||
|
||||
infoURL := "http://" + hostPort + "/info"
|
||||
|
||||
for {
|
||||
info, err := tryFetchInfo(httpGet, infoURL)
|
||||
if err == nil && info != nil {
|
||||
return info, nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("WaitForAP: %w", ctx.Err())
|
||||
case <-time.After(cfg.interval()):
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
return nil, fmt.Errorf("WaitForAP: %s did not respond within %s", infoURL, cfg.timeout())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tryFetchInfo performs one /info probe; returns nil on any failure so
|
||||
// the polling loop can decide whether to retry.
|
||||
func tryFetchInfo(httpGet func(string) (*http.Response, error), infoURL string) (*DeviceInfoXML, error) {
|
||||
resp, err := httpGet(infoURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
var info DeviceInfoXML
|
||||
if err := m.parseDeviceInfoXML(strings.NewReader(string(body)), &info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// MDNSDiscoverer is the discovery-side capability WaitForOnline depends
|
||||
// on. The real implementation is *discovery.MDNSDiscoveryService; tests
|
||||
// inject a stub.
|
||||
type MDNSDiscoverer interface {
|
||||
DiscoverDevices(ctx context.Context) ([]*models.DiscoveredDevice, error)
|
||||
}
|
||||
|
||||
// WaitForOnline polls mDNS for a SoundTouch speaker matching the given
|
||||
// substring (typically a device-ID suffix such as "DE4803"). It returns
|
||||
// the speaker's IP address as soon as it reappears on the home network
|
||||
// after a Wi-Fi provision.
|
||||
//
|
||||
// matcher is matched case-insensitively against DiscoveredDevice.Name,
|
||||
// SerialNo, and Host. An empty matcher returns the first speaker seen.
|
||||
func WaitForOnline(ctx context.Context, matcher string, cfg PollConfig, mdns MDNSDiscoverer) (*models.DiscoveredDevice, error) {
|
||||
if mdns == nil {
|
||||
mdns = discovery.NewMDNSDiscoveryService(cfg.interval())
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(cfg.timeout())
|
||||
needle := strings.ToLower(matcher)
|
||||
|
||||
for {
|
||||
devs, _ := mdns.DiscoverDevices(ctx)
|
||||
|
||||
for _, d := range devs {
|
||||
if needle == "" || matchesDevice(d, needle) {
|
||||
return d, nil
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("WaitForOnline: %w", ctx.Err())
|
||||
case <-time.After(cfg.interval()):
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
return nil, fmt.Errorf("WaitForOnline: no speaker matching %q discovered within %s", matcher, cfg.timeout())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func matchesDevice(d *models.DiscoveredDevice, needle string) bool {
|
||||
if d == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, candidate := range []string{d.Name, d.SerialNo, d.Host, d.UPnPSerial} {
|
||||
if candidate != "" && strings.Contains(strings.ToLower(candidate), needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestPushWiFiCredentials_BuildsCanonicalRequest(t *testing.T) {
|
||||
var (
|
||||
gotMethod string
|
||||
gotPath string
|
||||
gotCT string
|
||||
gotBody string
|
||||
)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotMethod = r.Method
|
||||
gotPath = r.URL.Path
|
||||
gotCT = r.Header.Get("Content-Type")
|
||||
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
gotBody = string(b)
|
||||
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8" ?><AddWirelessProfileResponse />`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
apHost := strings.TrimPrefix(srv.URL, "http://")
|
||||
|
||||
err := PushWiFiCredentials(context.Background(), PushWiFiCredentialsParams{
|
||||
APHost: apHost,
|
||||
SSID: "MyHomeNetwork",
|
||||
Password: "s3cret",
|
||||
// Security and HTTPClient default
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PushWiFiCredentials: %v", err)
|
||||
}
|
||||
|
||||
if gotMethod != http.MethodPost {
|
||||
t.Errorf("method = %s, want POST", gotMethod)
|
||||
}
|
||||
|
||||
if gotPath != "/addWirelessProfile" {
|
||||
t.Errorf("path = %s, want /addWirelessProfile", gotPath)
|
||||
}
|
||||
|
||||
if gotCT != "text/xml" {
|
||||
t.Errorf("content-type = %s, want text/xml", gotCT)
|
||||
}
|
||||
|
||||
if !strings.Contains(gotBody, `ssid="MyHomeNetwork"`) {
|
||||
t.Errorf("body missing ssid: %s", gotBody)
|
||||
}
|
||||
|
||||
if !strings.Contains(gotBody, `password="s3cret"`) {
|
||||
t.Errorf("body missing password: %s", gotBody)
|
||||
}
|
||||
|
||||
if !strings.Contains(gotBody, `securityType="wpa_or_wpa2"`) {
|
||||
t.Errorf("body should default to wpa_or_wpa2 security, got: %s", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushWiFiCredentials_EscapesQuotesInCredentials(t *testing.T) {
|
||||
var gotBody string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
gotBody = string(b)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := PushWiFiCredentials(context.Background(), PushWiFiCredentialsParams{
|
||||
APHost: strings.TrimPrefix(srv.URL, "http://"),
|
||||
SSID: `net "with quote`,
|
||||
Password: `pa<ss>`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PushWiFiCredentials: %v", err)
|
||||
}
|
||||
|
||||
// Quotes must be escaped so they don't break the attribute context.
|
||||
if strings.Contains(gotBody, `ssid="net "with quote"`) {
|
||||
t.Errorf("quotes in SSID must be escaped, got: %s", gotBody)
|
||||
}
|
||||
|
||||
if !strings.Contains(gotBody, """) {
|
||||
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 := `<info deviceID="AABBCCDDEEFF"><name>Bose SoundTouch DE4803</name><margeAccountUUID></margeAccountUUID></info>`
|
||||
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: http.Header{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
cfg := PollConfig{Interval: 5 * time.Millisecond, Timeout: 500 * time.Millisecond}
|
||||
|
||||
info, err := WaitForAP(context.Background(), "", cfg, httpGet)
|
||||
if err != nil {
|
||||
t.Fatalf("WaitForAP: %v", err)
|
||||
}
|
||||
|
||||
if info.DeviceID != "AABBCCDDEEFF" {
|
||||
t.Errorf("DeviceID = %q, want AABBCCDDEEFF", info.DeviceID)
|
||||
}
|
||||
|
||||
if calls.Load() < 3 {
|
||||
t.Errorf("expected at least 3 polls, got %d", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForAP_TimesOut(t *testing.T) {
|
||||
httpGet := func(_ string) (*http.Response, error) {
|
||||
return nil, errors.New("network unreachable")
|
||||
}
|
||||
|
||||
cfg := PollConfig{Interval: 5 * time.Millisecond, Timeout: 30 * time.Millisecond}
|
||||
|
||||
_, err := WaitForAP(context.Background(), "", cfg, httpGet)
|
||||
if err == nil || !strings.Contains(err.Error(), "did not respond") {
|
||||
t.Errorf("err = %v, want timeout error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForAP_RespectsContextCancellation(t *testing.T) {
|
||||
httpGet := func(_ string) (*http.Response, error) {
|
||||
return nil, errors.New("network unreachable")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
go func() {
|
||||
time.Sleep(15 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
cfg := PollConfig{Interval: 5 * time.Millisecond, Timeout: 5 * time.Second}
|
||||
|
||||
_, err := WaitForAP(ctx, "", cfg, httpGet)
|
||||
if err == nil || !strings.Contains(err.Error(), "context canceled") {
|
||||
t.Errorf("err = %v, want context-cancellation error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// stubMDNS is a controllable MDNSDiscoverer.
|
||||
type stubMDNS struct {
|
||||
results [][]*models.DiscoveredDevice
|
||||
call atomic.Int32
|
||||
}
|
||||
|
||||
func (s *stubMDNS) DiscoverDevices(_ context.Context) ([]*models.DiscoveredDevice, error) {
|
||||
i := s.call.Add(1) - 1
|
||||
if int(i) >= len(s.results) {
|
||||
return nil, fmt.Errorf("exhausted")
|
||||
}
|
||||
|
||||
return s.results[i], nil
|
||||
}
|
||||
|
||||
func TestWaitForOnline_MatchesSubstringInNameOrSerial(t *testing.T) {
|
||||
stub := &stubMDNS{
|
||||
results: [][]*models.DiscoveredDevice{
|
||||
nil, // first poll: nothing yet
|
||||
{
|
||||
{Name: "Other Bose Speaker", SerialNo: "AAAAAAAAAAAA", Host: "192.168.1.50"},
|
||||
{Name: "Bose SoundTouch DE4803", SerialNo: "506583DE4803", Host: "192.168.1.42"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg := PollConfig{Interval: 5 * time.Millisecond, Timeout: 500 * time.Millisecond}
|
||||
|
||||
d, err := WaitForOnline(context.Background(), "DE4803", cfg, stub)
|
||||
if err != nil {
|
||||
t.Fatalf("WaitForOnline: %v", err)
|
||||
}
|
||||
|
||||
if d.Host != "192.168.1.42" {
|
||||
t.Errorf("Host = %q, want 192.168.1.42", d.Host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForOnline_EmptyMatcherReturnsFirst(t *testing.T) {
|
||||
stub := &stubMDNS{
|
||||
results: [][]*models.DiscoveredDevice{
|
||||
{
|
||||
{Name: "Bose SoundTouch DE4803", Host: "192.168.1.42"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg := PollConfig{Interval: 5 * time.Millisecond, Timeout: 500 * time.Millisecond}
|
||||
|
||||
d, err := WaitForOnline(context.Background(), "", cfg, stub)
|
||||
if err != nil {
|
||||
t.Fatalf("WaitForOnline: %v", err)
|
||||
}
|
||||
|
||||
if d.Host != "192.168.1.42" {
|
||||
t.Errorf("Host = %q, want 192.168.1.42", d.Host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForOnline_TimesOutWhenNoMatch(t *testing.T) {
|
||||
stub := &stubMDNS{
|
||||
results: [][]*models.DiscoveredDevice{
|
||||
{{Name: "Wrong One", SerialNo: "X", Host: "192.168.1.99"}},
|
||||
{{Name: "Wrong One", SerialNo: "X", Host: "192.168.1.99"}},
|
||||
{{Name: "Wrong One", SerialNo: "X", Host: "192.168.1.99"}},
|
||||
},
|
||||
}
|
||||
|
||||
cfg := PollConfig{Interval: 5 * time.Millisecond, Timeout: 25 * time.Millisecond}
|
||||
|
||||
_, err := WaitForOnline(context.Background(), "DE4803", cfg, stub)
|
||||
if err == nil || !strings.Contains(err.Error(), "no speaker matching") {
|
||||
t.Errorf("err = %v, want no-match timeout error", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user