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>
This commit is contained in:
Tobias Gesellchen
2026-05-16 12:14:18 +02:00
co-authored by Claude Opus 4.7
parent 332c7b87d0
commit 74007c7cb2
5 changed files with 189 additions and 22 deletions
+21 -4
View File
@@ -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 <boseServer>/<updateServer> 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 <PairDeviceWithAccount> 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
}
+2 -2
View File
@@ -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
+1 -1
View File
@@ -147,7 +147,7 @@ func TestExecuteInitPlan_FactoryReset_GeneratesAccountAndRunsAllSteps(t *testing
"Enter",
"IdentifyLeave",
"SetName(Living Room)",
"SetMargeAccount(1234567,Bearer aftertouch)",
"SetMargeAccount(1234567," + DefaultMargeAuthToken + ")",
"Leave",
"PushCustomerSupportInfo",
}
+101 -14
View File
@@ -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
// <PairDeviceWithAccount> 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 <PairDeviceWithAccount> 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 <PairDeviceWithAccount> payloads. AfterTouch historically sent
// only <accountId> + <userAuthToken>; 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
// <PairDeviceWithAccount> 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 <boseServer>, <updateServer>, and <accountEmail>
// 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(
`<PairDeviceWithAccount><accountId>%s</accountId><userAuthToken>%s</userAuthToken></PairDeviceWithAccount>`,
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 <PairDeviceWithAccount>
// 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(`<PairDeviceWithAccount>`)
b.WriteString(`<accountId>` + xmlBodyEscape(accountID) + `</accountId>`)
b.WriteString(`<userAuthToken>` + xmlBodyEscape(authToken) + `</userAuthToken>`)
if extras.BoseServer != "" {
b.WriteString(`<boseServer>` + xmlBodyEscape(extras.BoseServer) + `</boseServer>`)
updateServer := extras.UpdateServer
if updateServer == "" {
updateServer = strings.TrimRight(extras.BoseServer, "/") + "/updates/soundtouch"
}
b.WriteString(`<updateServer>` + xmlBodyEscape(updateServer) + `</updateServer>`)
email := extras.AccountEmail
if email == "" {
email = DefaultMargePairingEmail
}
b.WriteString(`<accountEmail>` + xmlBodyEscape(email) + `</accountEmail>`)
}
b.WriteString(`</PairDeviceWithAccount>`)
return b.String()
}
// Leave sends SETUP_LEAVE.
+64 -1
View File
@@ -184,7 +184,7 @@ func TestSession_SendsCanonicalEnvelopes(t *testing.T) {
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[6], `url="setMargeAccount"`, `<accountId>1234567</accountId>`, `<userAuthToken>`+DefaultMargeAuthToken+`</userAuthToken>`)
mustContain(t, frames[7], `<setupState state="SETUP_LEAVE"/>`)
mustContain(t, frames[8], `url="pushCustomerSupportInfoToMarge"`, `method="GET"`)
}
@@ -301,6 +301,69 @@ func TestSession_XMLAttributeEscape(t *testing.T) {
mustContain(t, frames[0], `deviceID="quoted&quot;&lt;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 := `<PairDeviceWithAccount>` +
`<accountId>1234567</accountId>` +
`<userAuthToken>Bearer tok</userAuthToken>` +
`</PairDeviceWithAccount>`
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,
`<boseServer>https://soundtouch.local</boseServer>`,
`<updateServer>https://soundtouch.local/updates/soundtouch</updateServer>`,
`<accountEmail>`+DefaultMargePairingEmail+`</accountEmail>`,
)
})
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,
`<boseServer>https://example.test</boseServer>`,
`<updateServer>https://updates.example.test/firmware</updateServer>`,
`<accountEmail>user@example.test</accountEmail>`,
)
})
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,
`<updateServer>https://soundtouch.local/updates/soundtouch</updateServer>`,
)
})
}
func mustContain(t *testing.T, s string, needles ...string) {
t.Helper()