Files
Bose-SoundTouch/pkg/service/setup/init_plan.go
T
Tobias GesellchenandClaude Opus 4.7 74007c7cb2 feat(setup): align <PairDeviceWithAccount> with the official Bose app shape
The Stockholm app (stockholm/setup/js/workflow_add_devices.js:23,77)
and Zimbo88's OpenCloudTouch USB-less script
(https://github.com/scheilch/opencloudtouch/discussions/201) both send
<boseServer>, <updateServer>, and <accountEmail> alongside the
<accountId>/<userAuthToken> pair. AfterTouch's setMargeAccount
historically sent only the latter two.

Adds:

  - MargePairingExtras struct on SessionConfig, opt-in via
    BoseServer (UpdateServer + AccountEmail default-derived when
    empty).
  - DefaultMargeAuthToken constant ("Bearer AfterTouch") and
    DefaultMargePairingEmail constant ("local@aftertouch.invalid",
    RFC 2606 reserved .invalid TLD).
  - buildPairDeviceWithAccountXML helper extracted so tests can
    pin both the minimal-payload and extended-payload shapes
    without driving a full WebSocket session.
  - --token flag on `soundtouch-cli setup pair` so we can override
    the placeholder for token-shape experiments.
  - runPairBare threads --service-url through to PairingExtras so
    `--mode=bare --service-url=...` ships the extended payload too;
    runPairFull already used it via applyInitPlanDefaults.

The speaker accepts any non-empty Bearer string (verified during
#195 investigation: "Bearer AfterTouch" passes and the speaker
re-derives its post-pair state from the marge endpoints regardless
of token content). The Stockholm-app payload shape is purely
documentation alignment; it did NOT fix the post-pair AUX/preset
breakage that turned out to be the cloud /full source list (see the
preceding marge commit). Keeping the wiring so the switches are
ready when we want to experiment further.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:14:18 +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 / DefaultMargeAuthToken.
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 = DefaultMargeAuthToken
}
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
}