Files
Bose-SoundTouch/pkg/service/setup/init_plan.go
T
Tobias GesellchenandClaude Opus 4.7 e3450ffd00 refactor(setup): split high-complexity functions into per-axis helpers
Brings the five remaining gocyclo > 20 warnings to zero by extracting
cohesive sub-functions; same observable behaviour, smaller surface to
read at each call site. Bonus: the new helpers are individually testable.

- pkg/models/clockdisplay.go: split ClockDisplay.UnmarshalXML attr
  handling into applyClockDisplayOuterAttrs (legacy flat shape) and
  applyClockConfigAttrs (current nested shape).
- pkg/service/setup/ssh_probe_apply.go: split applyProbeToSummary into
  applyProbeCurrentConfig / applyProbeResolvConf /
  applyProbeRemoteServices / applyProbeCACert — one helper per
  MigrationSummary axis the probe populates.
- pkg/service/setup/init_plan.go: split ExecuteInitPlan into
  applyInitPlanDefaults, runURLRewrite, resolveAccountID, and
  verifyPairing. Cleans up several shadowed err variables in the
  process.
- cmd/soundtouch-cli/cmd_setup.go: split renderInspectReport into
  renderInspectIdentityAndPairing / renderInspectNetwork /
  renderInspectSources / renderInspectPresets / renderInspectRuntimeURLs,
  and buildPlanSteps into resetSteps + migrationSteps helpers.

golangci-lint run ./pkg/service/setup/... ./pkg/models/...
./cmd/soundtouch-cli/... now reports zero findings. Tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:43:36 +02:00

323 lines
10 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) {
plan, err := applyInitPlanDefaults(plan, m.ServerURL)
if err != nil {
return plan, err
}
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 rewriteErr := m.runURLRewrite(plan, emit); rewriteErr != nil {
return plan, rewriteErr
}
plan, err = m.resolveAccountID(plan, info, emit)
if err != nil {
return plan, err
}
emit(StepDialWebSocket, "dial websocket", StatusRunning, nil)
if m.NewSession == nil {
nilErr := errors.New("Manager.NewSession is nil — call NewManager or set it explicitly")
emit(StepDialWebSocket, "dial websocket", StatusFailed, nilErr)
return plan, nilErr
}
session, err := m.NewSession(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 stepErr := st.fn(ctx); stepErr != nil {
emit(st.kind, st.name, StatusFailed, stepErr)
return plan, fmt.Errorf("%s: %w", st.name, stepErr)
}
emit(st.kind, st.name, StatusOK, nil)
}
if err := m.verifyPairing(plan, emit); err != nil {
return plan, err
}
return plan, nil
}
// applyInitPlanDefaults validates required fields and fills in defaults
// from Manager.ServerURL / sysLanguage 2 / "Bearer aftertouch".
func applyInitPlanDefaults(plan InitPlan, serverURL string) (InitPlan, error) {
if plan.DeviceIP == "" {
return plan, errors.New("InitPlan.DeviceIP is required")
}
if plan.ServiceURL == "" {
plan.ServiceURL = 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"
}
return plan, nil
}
// runURLRewrite applies the telnet envswitch URL rewrite step unless the
// caller asked to skip it.
func (m *Manager) runURLRewrite(plan InitPlan, emit func(StepKind, string, StepStatus, error)) error {
if plan.SkipURLRewrite {
emit(StepURLRewrite, "telnet URL rewrite", StatusSkipped, nil)
return nil
}
emit(StepURLRewrite, "telnet URL rewrite", StatusRunning, nil)
urls := defaultTelnetURLs(plan.ServiceURL)
if _, rwErr := m.migrateViaTelnet(plan.DeviceIP, plan.ServiceURL, urls); rwErr != nil {
emit(StepURLRewrite, "telnet URL rewrite", StatusFailed, rwErr)
return fmt.Errorf("URL rewrite: %w", rwErr)
}
emit(StepURLRewrite, "telnet URL rewrite", StatusOK, nil)
return nil
}
// resolveAccountID populates plan.AccountID — reusing the device's
// existing margeAccountUUID, generating a fresh non-colliding 7-digit
// ID, or validating a user-supplied value.
func (m *Manager) resolveAccountID(plan InitPlan, info *DeviceInfoXML, emit func(StepKind, string, StepStatus, error)) (InitPlan, error) {
if plan.AccountID != "" {
if !IsValidAccountID(plan.AccountID) {
invalidErr := fmt.Errorf("invalid AccountID %q: must be exactly 7 digits", plan.AccountID)
emit(StepGenerateAccountID, "validate account ID", StatusFailed, invalidErr)
return plan, invalidErr
}
return plan, nil
}
if info.MargeAccountUUID != "" && IsValidAccountID(info.MargeAccountUUID) {
plan.AccountID = info.MargeAccountUUID
emit(StepGenerateAccountID, "reuse existing margeAccountUUID="+plan.AccountID, StatusOK, nil)
return plan, nil
}
emit(StepGenerateAccountID, "generate account ID", StatusRunning, nil)
id, genErr := GenerateAccountID(listKnownAccountIDs(m))
if genErr != nil {
emit(StepGenerateAccountID, "generate account ID", StatusFailed, genErr)
return plan, fmt.Errorf("generate account ID: %w", genErr)
}
plan.AccountID = id
emit(StepGenerateAccountID, "generate account ID="+id, StatusOK, nil)
return plan, nil
}
// verifyPairing re-reads /info after the state machine finished and
// confirms the device's margeAccountUUID matches what we asked for.
func (m *Manager) verifyPairing(plan InitPlan, emit func(StepKind, string, StepStatus, error)) error {
emit(StepVerify, "verify /info margeAccountUUID", StatusRunning, nil)
verify, err := m.GetLiveDeviceInfo(plan.DeviceIP)
if err != nil {
emit(StepVerify, "verify /info", StatusFailed, err)
return fmt.Errorf("verify /info: %w", err)
}
if verify.MargeAccountUUID != plan.AccountID {
mismatchErr := fmt.Errorf("post-init /info shows margeAccountUUID=%q, want %q", verify.MargeAccountUUID, plan.AccountID)
emit(StepVerify, "verify /info", StatusFailed, mismatchErr)
return mismatchErr
}
emit(StepVerify, "verify /info margeAccountUUID="+plan.AccountID, StatusOK, nil)
return 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
}