mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 16:46:17 +00:00
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>
376 lines
10 KiB
Go
376 lines
10 KiB
Go
package setup
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
// fakeSpeaker is a minimal WebSocket endpoint that records frames sent by
|
|
// Session and responds with canned replies. Each test wires its own
|
|
// reply policy by setting reply.
|
|
type fakeSpeaker struct {
|
|
server *httptest.Server
|
|
mu sync.Mutex
|
|
frames []string
|
|
reply func(frame string) []string
|
|
}
|
|
|
|
func newFakeSpeaker(t *testing.T) *fakeSpeaker {
|
|
t.Helper()
|
|
|
|
f := &fakeSpeaker{}
|
|
upgrader := websocket.Upgrader{
|
|
Subprotocols: []string{"gabbo"},
|
|
CheckOrigin: func(*http.Request) bool { return true },
|
|
}
|
|
|
|
f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
conn, err := upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
t.Logf("upgrade: %v", err)
|
|
return
|
|
}
|
|
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
for {
|
|
_, data, err := conn.ReadMessage()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
f.mu.Lock()
|
|
f.frames = append(f.frames, string(data))
|
|
policy := f.reply
|
|
f.mu.Unlock()
|
|
|
|
var replies []string
|
|
if policy != nil {
|
|
replies = policy(string(data))
|
|
} else {
|
|
replies = []string{ackFor(string(data))}
|
|
}
|
|
|
|
for _, r := range replies {
|
|
if err := conn.WriteMessage(websocket.TextMessage, []byte(r)); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}))
|
|
|
|
t.Cleanup(f.server.Close)
|
|
|
|
return f
|
|
}
|
|
|
|
// ackFor builds a minimal echo reply that carries the same requestID as
|
|
// the incoming frame, so the Session's correlation logic accepts it.
|
|
func ackFor(frame string) string {
|
|
id := extractAttr(frame, `requestID="`, `"`)
|
|
return fmt.Sprintf(`<msg><header url="setup"><response requestID="%s"/></header><body><status>ok</status></body></msg>`, id)
|
|
}
|
|
|
|
func extractAttr(s, prefix, suffix string) string {
|
|
i := strings.Index(s, prefix)
|
|
if i < 0 {
|
|
return ""
|
|
}
|
|
|
|
rest := s[i+len(prefix):]
|
|
|
|
j := strings.Index(rest, suffix)
|
|
if j < 0 {
|
|
return ""
|
|
}
|
|
|
|
return rest[:j]
|
|
}
|
|
|
|
func (f *fakeSpeaker) recordedFrames() []string {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
|
|
out := make([]string, len(f.frames))
|
|
copy(out, f.frames)
|
|
|
|
return out
|
|
}
|
|
|
|
// dialFakeSession opens a Session against the fake speaker. We turn
|
|
// the httptest server URL inside-out (http → ws, keep host:port) so the
|
|
// dialer reaches our handler.
|
|
func dialFakeSession(t *testing.T, f *fakeSpeaker, deviceID string) *Session {
|
|
t.Helper()
|
|
|
|
u, err := url.Parse(f.server.URL)
|
|
if err != nil {
|
|
t.Fatalf("parse server URL: %v", err)
|
|
}
|
|
|
|
s, err := DialSession(u.Host, deviceID, SessionConfig{
|
|
StepTimeout: 2 * time.Second,
|
|
DialTimeout: 2 * time.Second,
|
|
WSScheme: "ws",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("DialSession: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() { _ = s.Close() })
|
|
|
|
return s
|
|
}
|
|
|
|
func TestSession_SendsCanonicalEnvelopes(t *testing.T) {
|
|
f := newFakeSpeaker(t)
|
|
s := dialFakeSession(t, f, "AABBCCDDEEFF")
|
|
|
|
ctx := context.Background()
|
|
|
|
if err := s.Start(ctx); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
|
|
if err := s.IdentifyEnter(ctx, 300000); err != nil {
|
|
t.Fatalf("IdentifyEnter: %v", err)
|
|
}
|
|
|
|
if err := s.SetLanguage(ctx, 2); err != nil {
|
|
t.Fatalf("SetLanguage: %v", err)
|
|
}
|
|
|
|
if err := s.Enter(ctx); err != nil {
|
|
t.Fatalf("Enter: %v", err)
|
|
}
|
|
|
|
if err := s.IdentifyLeave(ctx); err != nil {
|
|
t.Fatalf("IdentifyLeave: %v", err)
|
|
}
|
|
|
|
if err := s.SetName(ctx, "Living Room"); err != nil {
|
|
t.Fatalf("SetName: %v", err)
|
|
}
|
|
|
|
if err := s.SetMargeAccount(ctx, "1234567", ""); err != nil {
|
|
t.Fatalf("SetMargeAccount: %v", err)
|
|
}
|
|
|
|
if err := s.Leave(ctx); err != nil {
|
|
t.Fatalf("Leave: %v", err)
|
|
}
|
|
|
|
if err := s.PushCustomerSupportInfo(ctx); err != nil {
|
|
t.Fatalf("PushCustomerSupportInfo: %v", err)
|
|
}
|
|
|
|
frames := f.recordedFrames()
|
|
if len(frames) != 9 {
|
|
t.Fatalf("got %d frames, want 9: %v", len(frames), frames)
|
|
}
|
|
|
|
mustContain(t, frames[0], `deviceID="AABBCCDDEEFF"`, `url="setup"`, `method="POST"`, `<setupState state="SETUP_START"/>`)
|
|
mustContain(t, frames[1], `url="setup"`, `<setupState state="SETUP_IDENTIFY_DEVICE_ENTER" timeout="300000"/>`)
|
|
mustContain(t, frames[2], `url="language"`, `<sysLanguage>2</sysLanguage>`)
|
|
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>`+DefaultMargeAuthToken+`</userAuthToken>`)
|
|
mustContain(t, frames[7], `<setupState state="SETUP_LEAVE"/>`)
|
|
mustContain(t, frames[8], `url="pushCustomerSupportInfoToMarge"`, `method="GET"`)
|
|
}
|
|
|
|
func TestSession_RequestIDsAreUniquePerStep(t *testing.T) {
|
|
f := newFakeSpeaker(t)
|
|
s := dialFakeSession(t, f, "X")
|
|
ctx := context.Background()
|
|
|
|
if err := s.Start(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := s.Enter(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
frames := f.recordedFrames()
|
|
id1 := extractAttr(frames[0], `requestID="`, `"`)
|
|
id2 := extractAttr(frames[1], `requestID="`, `"`)
|
|
|
|
if id1 == "" || id2 == "" {
|
|
t.Fatalf("missing requestIDs: %q %q", id1, id2)
|
|
}
|
|
|
|
if id1 == id2 {
|
|
t.Errorf("requestIDs must be unique per step, got %s twice", id1)
|
|
}
|
|
}
|
|
|
|
func TestSession_IgnoresUpdatesFramesBeforeAck(t *testing.T) {
|
|
f := newFakeSpeaker(t)
|
|
f.reply = func(frame string) []string {
|
|
id := extractAttr(frame, `requestID="`, `"`)
|
|
// Push a sourcesUpdated frame first; the session must ignore it
|
|
// and keep reading until the actual ack arrives.
|
|
return []string{
|
|
`<updates deviceID="X"><sourcesUpdated/></updates>`,
|
|
`<SoundTouchSdkInfo build="x"/>`,
|
|
fmt.Sprintf(`<msg><header url="setup"><response requestID="%s"/></header><body><status>/setup</status></body></msg>`, id),
|
|
}
|
|
}
|
|
|
|
s := dialFakeSession(t, f, "X")
|
|
if err := s.Start(context.Background()); err != nil {
|
|
t.Fatalf("Start should succeed despite pushed update frames, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSession_SurfacesDeviceErrors(t *testing.T) {
|
|
f := newFakeSpeaker(t)
|
|
f.reply = func(frame string) []string {
|
|
return []string{
|
|
`<msg><header url="setMargeAccount"><response/></header><body><error value="1003" name="ACCOUNT_REJECTED">no</error></body></msg>`,
|
|
}
|
|
}
|
|
|
|
s := dialFakeSession(t, f, "X")
|
|
|
|
err := s.SetMargeAccount(context.Background(), "1234567", "")
|
|
if err == nil {
|
|
t.Fatal("expected error from <error/> body")
|
|
}
|
|
|
|
if !strings.Contains(err.Error(), "device rejected setMargeAccount") {
|
|
t.Errorf("err = %v, want to mention device rejection", err)
|
|
}
|
|
}
|
|
|
|
func TestSession_RejectsEmptyDeviceID(t *testing.T) {
|
|
_, err := DialSession("127.0.0.1:8080", "", SessionConfig{})
|
|
if err == nil {
|
|
t.Fatal("expected error for empty deviceID")
|
|
}
|
|
}
|
|
|
|
func TestSession_RejectsEmptyAccountID(t *testing.T) {
|
|
f := newFakeSpeaker(t)
|
|
s := dialFakeSession(t, f, "X")
|
|
|
|
err := s.SetMargeAccount(context.Background(), "", "")
|
|
if err == nil {
|
|
t.Fatal("expected error for empty accountID")
|
|
}
|
|
}
|
|
|
|
func TestSession_EmptyNameIsNoOp(t *testing.T) {
|
|
f := newFakeSpeaker(t)
|
|
s := dialFakeSession(t, f, "X")
|
|
|
|
if err := s.SetName(context.Background(), ""); err != nil {
|
|
t.Fatalf("SetName(\"\") should be no-op, got %v", err)
|
|
}
|
|
|
|
if len(f.recordedFrames()) != 0 {
|
|
t.Errorf("expected no frames for empty name, got %v", f.recordedFrames())
|
|
}
|
|
}
|
|
|
|
func TestSession_XMLAttributeEscape(t *testing.T) {
|
|
// Device names with special characters must not break the envelope.
|
|
f := newFakeSpeaker(t)
|
|
s := dialFakeSession(t, f, `quoted"<id>`)
|
|
|
|
if err := s.Start(context.Background()); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
|
|
frames := f.recordedFrames()
|
|
if len(frames) != 1 {
|
|
t.Fatalf("want 1 frame, got %d", len(frames))
|
|
}
|
|
|
|
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()
|
|
|
|
for _, n := range needles {
|
|
if !strings.Contains(s, n) {
|
|
t.Errorf("frame missing %q in: %s", n, s)
|
|
}
|
|
}
|
|
}
|