diff --git a/cmd/soundtouch-cli/cmd_setup.go b/cmd/soundtouch-cli/cmd_setup.go index d543f0e..32c5113 100644 --- a/cmd/soundtouch-cli/cmd_setup.go +++ b/cmd/soundtouch-cli/cmd_setup.go @@ -1353,10 +1353,11 @@ func setupPairCmd() *cli.Command { Flags: []cli.Flag{ &cli.StringFlag{Name: "account", Usage: "7-digit account ID (empty = generate)"}, &cli.StringFlag{Name: "mode", Value: "full", Usage: "full (state machine) or bare (setMargeAccount only — experimental)"}, - &cli.StringFlag{Name: "service-url", Value: "http://aftertouch.local:8000", Usage: "AfterTouch base URL (used by mode=full for defaults)"}, + &cli.StringFlag{Name: "service-url", Value: "http://aftertouch.local:8000", Usage: "AfterTouch base URL (also populates / in setMargeAccount)"}, &cli.StringFlag{Name: "name", Usage: "Speaker name to set during pairing (empty = keep current)"}, &cli.IntFlag{Name: "language", Value: setup.LanguageEnglish, Usage: "sysLanguage code (2 = English)"}, &cli.DurationFlag{Name: "step-timeout", Value: 8 * time.Second}, + &cli.StringFlag{Name: "token", Usage: "userAuthToken value (empty = use built-in placeholder matching the Bose app token shape)"}, }, Action: func(c *cli.Context) error { cfg := GetClientConfig(c) @@ -1401,8 +1402,20 @@ func runPairBare(c *cli.Context, deviceIP, accountID string) error { fmt.Printf("pre /info deviceID=%s margeAccountUUID=%q margeURL=%q\n", info.DeviceID, info.MargeAccountUUID, info.MargeURL) + // Service URL drives the extended payload + // (boseServer/updateServer/accountEmail). When empty, the session + // falls back to the minimal historical shape (accountId + + // userAuthToken only). + serviceURL := c.String("service-url") + + var extras setup.MargePairingExtras + if serviceURL != "" { + extras = setup.MargePairingExtras{BoseServer: serviceURL} + } + session, err := setup.DialSession(deviceIP, info.DeviceID, setup.SessionConfig{ - StepTimeout: c.Duration("step-timeout"), + StepTimeout: c.Duration("step-timeout"), + PairingExtras: extras, }) if err != nil { return fmt.Errorf("dial WS: %w", err) @@ -1413,9 +1426,13 @@ func runPairBare(c *cli.Context, deviceIP, accountID string) error { ctx, cancel := context.WithTimeout(c.Context, c.Duration("step-timeout")+2*time.Second) defer cancel() - fmt.Printf("→ setMargeAccount accountID=%s (no SETUP bracket)\n", accountID) + if serviceURL != "" { + fmt.Printf("→ setMargeAccount accountID=%s (extended: boseServer=%s)\n", accountID, serviceURL) + } else { + fmt.Printf("→ setMargeAccount accountID=%s (minimal payload, no SETUP bracket)\n", accountID) + } - if pairErr := session.SetMargeAccount(ctx, accountID, ""); pairErr != nil { + if pairErr := session.SetMargeAccount(ctx, accountID, c.String("token")); pairErr != nil { PrintError(fmt.Sprintf("setMargeAccount: %v", pairErr)) return pairErr } diff --git a/pkg/service/setup/init_plan.go b/pkg/service/setup/init_plan.go index 2a8df26..7f5dc2d 100644 --- a/pkg/service/setup/init_plan.go +++ b/pkg/service/setup/init_plan.go @@ -199,7 +199,7 @@ func (m *Manager) ExecuteInitPlan(ctx context.Context, plan InitPlan, progress P } // applyInitPlanDefaults validates required fields and fills in defaults -// from Manager.ServerURL / sysLanguage 2 / "Bearer aftertouch". +// 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") @@ -218,7 +218,7 @@ func applyInitPlanDefaults(plan InitPlan, serverURL string) (InitPlan, error) { } if plan.AuthToken == "" { - plan.AuthToken = "Bearer aftertouch" + plan.AuthToken = DefaultMargeAuthToken } return plan, nil diff --git a/pkg/service/setup/init_plan_test.go b/pkg/service/setup/init_plan_test.go index 9566b55..39f7fb7 100644 --- a/pkg/service/setup/init_plan_test.go +++ b/pkg/service/setup/init_plan_test.go @@ -147,7 +147,7 @@ func TestExecuteInitPlan_FactoryReset_GeneratesAccountAndRunsAllSteps(t *testing "Enter", "IdentifyLeave", "SetName(Living Room)", - "SetMargeAccount(1234567,Bearer aftertouch)", + "SetMargeAccount(1234567," + DefaultMargeAuthToken + ")", "Leave", "PushCustomerSupportInfo", } diff --git a/pkg/service/setup/setup_session.go b/pkg/service/setup/setup_session.go index 7289296..5ffb4ad 100644 --- a/pkg/service/setup/setup_session.go +++ b/pkg/service/setup/setup_session.go @@ -21,8 +21,53 @@ const ( // LanguageEnglish is the sysLanguage code for English. ‹2› is the // value the official Bose app sends during English-locale setup. LanguageEnglish = 2 + + // DefaultMargeAuthToken is the placeholder userAuthToken sent in + // when the caller didn't supply one. The + // speaker accepts any non-empty value; a real Bose-issued token + // shape (128-char base64 per docs/reference/DEVICE-PAIRING-FLOW.md + // line 154) is not required — verified during #195 investigation + // where the speaker happily persisted "Bearer AfterTouch" and + // re-derived its post-pair state from the marge endpoints + // regardless of token content. + DefaultMargeAuthToken = "Bearer AfterTouch" + + // DefaultMargePairingEmail is the synthetic accountEmail used when + // PairingExtras requests the extended payload + // but doesn't supply an email. RFC 2606 reserves ".invalid" as a TLD + // guaranteed never to resolve, which is what we want here — the + // speaker writes it into its persistent state but no real address + // receives anything. + DefaultMargePairingEmail = "local@aftertouch.invalid" ) +// MargePairingExtras carries the optional fields that the official Bose +// Android app and Zimbo88's USB-less OpenCloudTouch script include in +// their payloads. AfterTouch historically sent +// only + ; that minimal shape is the +// suspected trigger for the post-pair AUX/preset breakage tracked in +// issues #195 and #269. +// +// Set BoseServer (and optionally UpdateServer/AccountEmail) on the +// SessionConfig to opt into the richer payload. Empty fields are +// omitted from the XML so callers can choose any subset. +// +// Reference: docs/reference/DEVICE-PAIRING-FLOW.md and +// https://github.com/scheilch/opencloudtouch/discussions/201. +type MargePairingExtras struct { + // BoseServer is the marge server URL the speaker should use after + // pairing. Typically equal to AfterTouch's service URL. + BoseServer string + // UpdateServer is the firmware-update server URL. If empty and + // BoseServer is set, SetMargeAccount derives it as + // BoseServer + "/updates/soundtouch". + UpdateServer string + // AccountEmail is the synthetic email persisted alongside the + // account. If empty and BoseServer is set, SetMargeAccount fills + // in DefaultMargePairingEmail. + AccountEmail string +} + // StateMachine is the surface the InitPlan orchestrator drives. The // concrete WebSocket-backed implementation is *Session; tests inject // an in-memory fake via Manager.NewSession. @@ -52,6 +97,11 @@ type SessionConfig struct { WSScheme string // WSPort overrides 8080 when deviceIP does not already carry a port. WSPort int + // PairingExtras opts the session into the richer + // payload (boseServer / updateServer / + // accountEmail) used by the official Bose Android app. Zero value + // retains the historical minimal payload. + PairingExtras MargePairingExtras } // Session is a synchronous request/response WebSocket session driving @@ -60,10 +110,11 @@ type SessionConfig struct { // and stateful) — setup is a short, linear sequence and benefits from a // purpose-built transport. type Session struct { - deviceID string - conn *websocket.Conn - reqID atomic.Int64 - stepTimeout time.Duration + deviceID string + conn *websocket.Conn + reqID atomic.Int64 + stepTimeout time.Duration + pairingExtras MargePairingExtras } // DialSession opens a WebSocket to the speaker at deviceIP and @@ -117,7 +168,12 @@ func DialSession(deviceIP, deviceID string, cfg SessionConfig) (*Session, error) step = defaultSetupStepTimeout } - return &Session{deviceID: deviceID, conn: conn, stepTimeout: step}, nil + return &Session{ + deviceID: deviceID, + conn: conn, + stepTimeout: step, + pairingExtras: cfg.PairingExtras, + }, nil } // Close sends a normal-closure frame and closes the underlying socket. @@ -243,24 +299,55 @@ func (s *Session) SetName(ctx context.Context, name string) error { } // 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. +// authToken defaults to DefaultMargeAuthToken when empty. +// +// If SessionConfig.PairingExtras.BoseServer is set, the payload is +// extended with , , and +// matching the official Bose app's shape (and Zimbo88's OpenCloudTouch +// USB-less script). UpdateServer and AccountEmail derive from +// BoseServer when not explicitly set. func (s *Session) SetMargeAccount(ctx context.Context, accountID, authToken string) error { if accountID == "" { return errors.New("SetMargeAccount: accountID is required") } if authToken == "" { - authToken = "Bearer aftertouch" + authToken = DefaultMargeAuthToken } - body := fmt.Sprintf( - `%s%s`, - xmlBodyEscape(accountID), xmlBodyEscape(authToken), - ) + return s.sendStep(ctx, "setMargeAccount", "POST", buildPairDeviceWithAccountXML(accountID, authToken, s.pairingExtras)) +} - return s.sendStep(ctx, "setMargeAccount", "POST", body) +// buildPairDeviceWithAccountXML serializes the +// body. Extracted so tests can pin the exact shape without driving a +// full WebSocket session. +func buildPairDeviceWithAccountXML(accountID, authToken string, extras MargePairingExtras) string { + var b strings.Builder + b.WriteString(``) + b.WriteString(`` + xmlBodyEscape(accountID) + ``) + b.WriteString(`` + xmlBodyEscape(authToken) + ``) + + if extras.BoseServer != "" { + b.WriteString(`` + xmlBodyEscape(extras.BoseServer) + ``) + + updateServer := extras.UpdateServer + if updateServer == "" { + updateServer = strings.TrimRight(extras.BoseServer, "/") + "/updates/soundtouch" + } + + b.WriteString(`` + xmlBodyEscape(updateServer) + ``) + + email := extras.AccountEmail + if email == "" { + email = DefaultMargePairingEmail + } + + b.WriteString(`` + xmlBodyEscape(email) + ``) + } + + b.WriteString(``) + + return b.String() } // Leave sends SETUP_LEAVE. diff --git a/pkg/service/setup/setup_session_test.go b/pkg/service/setup/setup_session_test.go index 0b6dc4e..a00ceca 100644 --- a/pkg/service/setup/setup_session_test.go +++ b/pkg/service/setup/setup_session_test.go @@ -184,7 +184,7 @@ func TestSession_SendsCanonicalEnvelopes(t *testing.T) { mustContain(t, frames[3], ``) mustContain(t, frames[4], ``) mustContain(t, frames[5], `url="name"`, `Living Room`) - mustContain(t, frames[6], `url="setMargeAccount"`, `1234567`, `Bearer aftertouch`) + mustContain(t, frames[6], `url="setMargeAccount"`, `1234567`, ``+DefaultMargeAuthToken+``) mustContain(t, frames[7], ``) mustContain(t, frames[8], `url="pushCustomerSupportInfoToMarge"`, `method="GET"`) } @@ -301,6 +301,69 @@ func TestSession_XMLAttributeEscape(t *testing.T) { mustContain(t, frames[0], `deviceID="quoted"<id>"`) } +// TestBuildPairDeviceWithAccountXML pins both the minimal-payload +// shape (historical AfterTouch behaviour) and the extended-payload +// shape introduced for #195/#269 investigation. The extended path +// mirrors what the official Bose app and Zimbo88's OpenCloudTouch +// USB-less script send (see docs/reference/DEVICE-PAIRING-FLOW.md +// and https://github.com/scheilch/opencloudtouch/discussions/201). +func TestBuildPairDeviceWithAccountXML(t *testing.T) { + t.Run("minimal payload — no extras", func(t *testing.T) { + got := buildPairDeviceWithAccountXML("1234567", "Bearer tok", MargePairingExtras{}) + + want := `` + + `1234567` + + `Bearer tok` + + `` + if got != want { + t.Errorf("\n got: %s\nwant: %s", got, want) + } + }) + + t.Run("extended payload — BoseServer triggers derived defaults", func(t *testing.T) { + got := buildPairDeviceWithAccountXML( + "1234567", "Bearer tok", + MargePairingExtras{BoseServer: "https://soundtouch.local"}, + ) + + mustContain(t, got, + `https://soundtouch.local`, + `https://soundtouch.local/updates/soundtouch`, + ``+DefaultMargePairingEmail+``, + ) + }) + + t.Run("extended payload — explicit UpdateServer + AccountEmail honoured", func(t *testing.T) { + got := buildPairDeviceWithAccountXML( + "1234567", "Bearer tok", + MargePairingExtras{ + BoseServer: "https://example.test", + UpdateServer: "https://updates.example.test/firmware", + AccountEmail: "user@example.test", + }, + ) + + mustContain(t, got, + `https://example.test`, + `https://updates.example.test/firmware`, + `user@example.test`, + ) + }) + + t.Run("extended payload — BoseServer trailing slash trimmed when deriving UpdateServer", func(t *testing.T) { + got := buildPairDeviceWithAccountXML( + "1234567", "Bearer tok", + MargePairingExtras{BoseServer: "https://soundtouch.local/"}, + ) + + // Derived path uses TrimRight on BoseServer so we don't get + // "soundtouch.local//updates/soundtouch". + mustContain(t, got, + `https://soundtouch.local/updates/soundtouch`, + ) + }) +} + func mustContain(t *testing.T, s string, needles ...string) { t.Helper()