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
}