mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
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:
co-authored by
Claude Opus 4.7
parent
332c7b87d0
commit
74007c7cb2
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -147,7 +147,7 @@ func TestExecuteInitPlan_FactoryReset_GeneratesAccountAndRunsAllSteps(t *testing
|
||||
"Enter",
|
||||
"IdentifyLeave",
|
||||
"SetName(Living Room)",
|
||||
"SetMargeAccount(1234567,Bearer aftertouch)",
|
||||
"SetMargeAccount(1234567," + DefaultMargeAuthToken + ")",
|
||||
"Leave",
|
||||
"PushCustomerSupportInfo",
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"<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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user