Files
Bose-SoundTouch/pkg/service/setup/init_plan.go
T
Tobias GesellchenandClaude Opus 4.7 29a462da2b 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>
2026-05-13 18:43:36 +02:00

275 lines
8.9 KiB
Go

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
}