mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
feat(group): add ST-10 stereo-pair support end-to-end
Implements the speaker-side group API surface (path 1 of the two approaches gmuth outlined in issue #252): clients form, rename, and dissolve stereo pairs directly on the device, and the resulting GroupService.xml persists on disk in the same shape the device emits over /getGroup. What landed: - pkg/models/group.go: Status field + IsEmpty() helper, matching the GET /getGroup response shape (id-attr, masterDeviceId, roles, senderIPAddress). - pkg/client/client.go: GetGroup, AddGroup, UpdateGroup, RemoveGroup. The endpoint name is /getGroup (not /group, despite some wiki docs) — confirmed against a real ST-10's /supportedURLs. RemoveGroup uses GET per the wire spec. - cmd/soundtouch-cli/cmd_group.go + main.go: new `group` subcommand with status / create --left --right [--name] / rename / remove, mirroring gmuth's group.sh recipe. WebSocket notifications: - pkg/models/websocket.go: EventTypeGroupUpdated + GroupUpdatedEvent + dispatch helpers. The device fans this out to both LEFT and RIGHT speakers on every group mutation, including empty-group teardowns; the parse test covers both shapes. - pkg/client/websocket.go: OnGroupUpdated registration and dispatch. - cmd/soundtouch-cli/cmd_events.go: `group` filter + handleGroupEvent formatter. WebSocket observability (came up while validating the above against a real device): - New RawMessageHandler type + OnRawMessage hook that fires for every incoming frame before parsing, with the parse error alongside. - New --debug flag on `events subscribe` with modes all / unknown / errors. Raw output goes to stderr so it composes cleanly with shell redirects. The pkg/client refactor in this commit also adopts speaker.HTTPPort (introduced in the previous refactor) — the unexported defaultSoundTouchPort and three hard-coded 8090 literals are gone. 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
c8c38b78e6
commit
cbbbaa9707
+56
-6
@@ -153,11 +153,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/speaker"
|
||||
)
|
||||
|
||||
// defaultSoundTouchPort is the standard port for SoundTouch devices
|
||||
const defaultSoundTouchPort = 8090
|
||||
|
||||
// Client represents a SoundTouch API client
|
||||
type Client struct {
|
||||
baseURL string
|
||||
@@ -204,7 +202,7 @@ func NewClient(config *Config) *Client {
|
||||
// Fallback for invalid URLs
|
||||
port := config.Port
|
||||
if port == 0 {
|
||||
port = 8090
|
||||
port = speaker.HTTPPort
|
||||
}
|
||||
|
||||
return &Client{
|
||||
@@ -223,7 +221,7 @@ func NewClient(config *Config) *Client {
|
||||
// No port in the host string, use the one from config or default
|
||||
port := config.Port
|
||||
if port == 0 {
|
||||
port = 8090
|
||||
port = speaker.HTTPPort
|
||||
}
|
||||
|
||||
u.Host = net.JoinHostPort(u.Host, fmt.Sprintf("%d", port))
|
||||
@@ -231,7 +229,7 @@ func NewClient(config *Config) *Client {
|
||||
// Empty port, use config or default
|
||||
port := config.Port
|
||||
if port == 0 {
|
||||
port = 8090
|
||||
port = speaker.HTTPPort
|
||||
}
|
||||
|
||||
u.Host = net.JoinHostPort(u.Hostname(), fmt.Sprintf("%d", port))
|
||||
@@ -1380,6 +1378,58 @@ func (c *Client) GetZoneMembers() ([]string, error) {
|
||||
return zone.GetAllDeviceIDs(), nil
|
||||
}
|
||||
|
||||
// GetGroup retrieves the current stereo-pair configuration from the device.
|
||||
// An empty <group/> response is reported as a zero-value Group; callers can
|
||||
// distinguish with (*Group).IsEmpty().
|
||||
//
|
||||
// ST-10 is the only product that supports stereo pairs; on other devices
|
||||
// the call is harmless but will always return an empty group. The endpoint
|
||||
// is named /getGroup on the device (mirroring /getZone), even though some
|
||||
// third-party wikis document it as plain /group.
|
||||
func (c *Client) GetGroup() (*models.Group, error) {
|
||||
var g models.Group
|
||||
|
||||
err := c.get("/getGroup", &g)
|
||||
|
||||
return &g, err
|
||||
}
|
||||
|
||||
// AddGroup creates a new stereo pair on the device addressed by this client,
|
||||
// which becomes the master. The supplied group must contain both LEFT and
|
||||
// RIGHT roles; the device assigns the group ID and echoes the full state
|
||||
// in the response.
|
||||
func (c *Client) AddGroup(group *models.Group) (*models.Group, error) {
|
||||
var result models.Group
|
||||
if err := c.postWithResponse("/addGroup", group, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// UpdateGroup renames or otherwise updates an existing stereo pair. The
|
||||
// device requires the full group structure on every update, not just the
|
||||
// changed fields.
|
||||
func (c *Client) UpdateGroup(group *models.Group) (*models.Group, error) {
|
||||
var result models.Group
|
||||
if err := c.postWithResponse("/updateGroup", group, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// RemoveGroup tears down the device's stereo pair. The device returns an
|
||||
// empty <group/> on success — surfaced here as a non-error nil.
|
||||
//
|
||||
// Note: the wiki specifies GET (not DELETE) for this endpoint, so we honour
|
||||
// that despite the state-mutating semantics.
|
||||
func (c *Client) RemoveGroup() error {
|
||||
var g models.Group
|
||||
|
||||
return c.get("/removeGroup", &g)
|
||||
}
|
||||
|
||||
// SetName sets the device name
|
||||
func (c *Client) SetName(name string) error {
|
||||
nameRequest := models.Name{
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestClient_GetGroup_Configured(t *testing.T) {
|
||||
responseXML := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<group id="1234567">
|
||||
<name>Living Room Pair</name>
|
||||
<masterDeviceId>9070658C9D4A</masterDeviceId>
|
||||
<roles>
|
||||
<groupRole>
|
||||
<deviceId>9070658C9D4A</deviceId>
|
||||
<role>LEFT</role>
|
||||
<ipAddress>192.168.1.131</ipAddress>
|
||||
</groupRole>
|
||||
<groupRole>
|
||||
<deviceId>F45EAB3115DA</deviceId>
|
||||
<role>RIGHT</role>
|
||||
<ipAddress>192.168.1.134</ipAddress>
|
||||
</groupRole>
|
||||
</roles>
|
||||
<senderIPAddress>192.168.1.131</senderIPAddress>
|
||||
<status>GROUP_OK</status>
|
||||
</group>`
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/getGroup" {
|
||||
t.Errorf("path = %q, want /getGroup", r.URL.Path)
|
||||
}
|
||||
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("method = %s, want GET", r.Method)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(responseXML))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
g, err := createTestClient(server.URL).GetGroup()
|
||||
if err != nil {
|
||||
t.Fatalf("GetGroup: %v", err)
|
||||
}
|
||||
|
||||
if g.ID != "1234567" {
|
||||
t.Errorf("ID = %q, want 1234567", g.ID)
|
||||
}
|
||||
|
||||
if g.Name != "Living Room Pair" {
|
||||
t.Errorf("Name = %q, want Living Room Pair", g.Name)
|
||||
}
|
||||
|
||||
if g.MasterDeviceID != "9070658C9D4A" {
|
||||
t.Errorf("MasterDeviceID = %q", g.MasterDeviceID)
|
||||
}
|
||||
|
||||
if g.Status != "GROUP_OK" {
|
||||
t.Errorf("Status = %q, want GROUP_OK", g.Status)
|
||||
}
|
||||
|
||||
if len(g.Roles.Roles) != 2 {
|
||||
t.Fatalf("roles = %d, want 2", len(g.Roles.Roles))
|
||||
}
|
||||
|
||||
if g.Roles.Roles[0].Role != "LEFT" || g.Roles.Roles[1].Role != "RIGHT" {
|
||||
t.Errorf("role order LEFT/RIGHT not preserved: %+v", g.Roles.Roles)
|
||||
}
|
||||
|
||||
if g.IsEmpty() {
|
||||
t.Errorf("IsEmpty = true for populated group")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetGroup_Empty(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(`<group />`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
g, err := createTestClient(server.URL).GetGroup()
|
||||
if err != nil {
|
||||
t.Fatalf("GetGroup: %v", err)
|
||||
}
|
||||
|
||||
if !g.IsEmpty() {
|
||||
t.Errorf("IsEmpty = false for <group/>, got %+v", g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_AddGroup(t *testing.T) {
|
||||
var capturedBody string
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/addGroup" {
|
||||
t.Errorf("path = %q, want /addGroup", r.URL.Path)
|
||||
}
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("method = %s, want POST", r.Method)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
capturedBody = string(body)
|
||||
|
||||
// Echo the request back with an assigned ID and GROUP_OK status —
|
||||
// matches real device behaviour.
|
||||
var got models.Group
|
||||
if err := xml.Unmarshal(body, &got); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
|
||||
got.ID = "9999999"
|
||||
got.Status = "GROUP_OK"
|
||||
got.SenderIPAddress = "192.168.1.131"
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
|
||||
enc, _ := xml.Marshal(&got)
|
||||
_, _ = w.Write(enc)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
req := &models.Group{
|
||||
Name: "Living Room",
|
||||
MasterDeviceID: "9070658C9D4A",
|
||||
Roles: models.GroupRoles{
|
||||
Roles: []models.GroupRole{
|
||||
{DeviceID: "9070658C9D4A", Role: "LEFT", IPAddress: "192.168.1.131"},
|
||||
{DeviceID: "F45EAB3115DA", Role: "RIGHT", IPAddress: "192.168.1.134"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := createTestClient(server.URL).AddGroup(req)
|
||||
if err != nil {
|
||||
t.Fatalf("AddGroup: %v", err)
|
||||
}
|
||||
|
||||
if resp.ID != "9999999" {
|
||||
t.Errorf("response ID = %q, want 9999999", resp.ID)
|
||||
}
|
||||
|
||||
if resp.Status != "GROUP_OK" {
|
||||
t.Errorf("response Status = %q, want GROUP_OK", resp.Status)
|
||||
}
|
||||
|
||||
// Wire-shape sanity: the request body must carry both roles and the
|
||||
// master ID (the device validates these on the wire).
|
||||
for _, want := range []string{"<role>LEFT</role>", "<role>RIGHT</role>", "9070658C9D4A"} {
|
||||
if !strings.Contains(capturedBody, want) {
|
||||
t.Errorf("request body missing %q\nbody:\n%s", want, capturedBody)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_UpdateGroup_RenameRoundtrip(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/updateGroup" {
|
||||
t.Errorf("path = %q, want /updateGroup", r.URL.Path)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
|
||||
var got models.Group
|
||||
if err := xml.Unmarshal(body, &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
got.Status = "GROUP_OK"
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
|
||||
enc, _ := xml.Marshal(&got)
|
||||
_, _ = w.Write(enc)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
req := &models.Group{
|
||||
ID: "1234567",
|
||||
Name: "Kitchen Pair",
|
||||
MasterDeviceID: "AAAA",
|
||||
Roles: models.GroupRoles{
|
||||
Roles: []models.GroupRole{
|
||||
{DeviceID: "AAAA", Role: "LEFT"},
|
||||
{DeviceID: "BBBB", Role: "RIGHT"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := createTestClient(server.URL).UpdateGroup(req)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateGroup: %v", err)
|
||||
}
|
||||
|
||||
if resp.Name != "Kitchen Pair" {
|
||||
t.Errorf("Name = %q, want Kitchen Pair", resp.Name)
|
||||
}
|
||||
|
||||
if resp.ID != "1234567" {
|
||||
t.Errorf("ID = %q, want 1234567", resp.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_RemoveGroup(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/removeGroup" {
|
||||
t.Errorf("path = %q, want /removeGroup", r.URL.Path)
|
||||
}
|
||||
|
||||
// The wiki specifies GET (not DELETE) for /removeGroup. We honour
|
||||
// that, surprising as it is for a state-mutating endpoint.
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("method = %s, want GET", r.Method)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(`<group />`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := createTestClient(server.URL).RemoveGroup(); err != nil {
|
||||
t.Fatalf("RemoveGroup: %v", err)
|
||||
}
|
||||
}
|
||||
+55
-6
@@ -140,6 +140,17 @@ func (ws *WebSocketClient) OnZoneUpdated(handler models.TypedEventHandler[*model
|
||||
ws.handlers.OnZoneUpdated = handler
|
||||
}
|
||||
|
||||
// OnGroupUpdated sets a handler for ST-10 stereo-pair update events.
|
||||
// The device fans these out to both LEFT and RIGHT speakers whenever the
|
||||
// pair is created, renamed, or removed, so callers will see one event per
|
||||
// affected device.
|
||||
func (ws *WebSocketClient) OnGroupUpdated(handler models.TypedEventHandler[*models.GroupUpdatedEvent]) {
|
||||
ws.mu.Lock()
|
||||
defer ws.mu.Unlock()
|
||||
|
||||
ws.handlers.OnGroupUpdated = handler
|
||||
}
|
||||
|
||||
// OnBassUpdated sets a handler for bass update events
|
||||
func (ws *WebSocketClient) OnBassUpdated(handler models.TypedEventHandler[*models.BassUpdatedEvent]) {
|
||||
ws.mu.Lock()
|
||||
@@ -156,6 +167,18 @@ func (ws *WebSocketClient) OnUnknownEvent(handler models.EventHandler) {
|
||||
ws.handlers.OnUnknownEvent = handler
|
||||
}
|
||||
|
||||
// OnRawMessage sets a handler that fires for every incoming frame with
|
||||
// the raw bytes and the result of attempting to XML-parse them. The
|
||||
// typed handlers (OnNowPlaying, OnGroupUpdated, ...) still run
|
||||
// afterwards on successful parses, so OnRawMessage is purely additive —
|
||||
// intended for debug/observability tooling.
|
||||
func (ws *WebSocketClient) OnRawMessage(handler models.RawMessageHandler) {
|
||||
ws.mu.Lock()
|
||||
defer ws.mu.Unlock()
|
||||
|
||||
ws.handlers.OnRawMessage = handler
|
||||
}
|
||||
|
||||
// OnSpecialMessage sets a handler for special (non-updates) messages
|
||||
func (ws *WebSocketClient) OnSpecialMessage(handler models.SpecialMessageHandler) {
|
||||
ws.mu.Lock()
|
||||
@@ -379,26 +402,45 @@ func (ws *WebSocketClient) attemptReconnect(config *WebSocketConfig) {
|
||||
|
||||
// handleMessage processes incoming WebSocket messages
|
||||
func (ws *WebSocketClient) handleMessage(data []byte) {
|
||||
// Check if this is a SoundTouchSdkInfo or other non-updates message
|
||||
// Special (non-updates) messages take their own decode path and
|
||||
// surface raw payloads to the OnRawMessage hook from there, so
|
||||
// observers see exactly one notification per frame.
|
||||
if !ws.isUpdatesMessage(data) {
|
||||
ws.handleSpecialMessage(data)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the WebSocket event
|
||||
event, err := models.ParseWebSocketEvent(data)
|
||||
if err != nil {
|
||||
ws.logger.Printf("Failed to parse WebSocket message: %v", err)
|
||||
event, parseErr := models.ParseWebSocketEvent(data)
|
||||
|
||||
ws.fireRawMessage(data, parseErr)
|
||||
|
||||
if parseErr != nil {
|
||||
ws.logger.Printf("Failed to parse WebSocket message: %v", parseErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Process each event type in the message
|
||||
ws.handleEvent(event)
|
||||
}
|
||||
|
||||
// fireRawMessage invokes the OnRawMessage hook if one is registered.
|
||||
// Kept separate so the read path doesn't have to repeat the locking
|
||||
// dance for every frame.
|
||||
func (ws *WebSocketClient) fireRawMessage(data []byte, parseErr error) {
|
||||
ws.mu.RLock()
|
||||
handler := ws.handlers.OnRawMessage
|
||||
ws.mu.RUnlock()
|
||||
|
||||
if handler != nil {
|
||||
handler(data, parseErr)
|
||||
}
|
||||
}
|
||||
|
||||
// handleSpecialMessage processes special (non-updates) WebSocket messages
|
||||
func (ws *WebSocketClient) handleSpecialMessage(data []byte) {
|
||||
specialMessage, err := models.ParseSpecialMessage(data)
|
||||
|
||||
ws.fireRawMessage(data, err)
|
||||
|
||||
if err != nil {
|
||||
ws.logger.Printf("Unknown special message type: %v", err)
|
||||
ws.logger.Printf("Raw message: %s", string(data))
|
||||
@@ -468,6 +510,13 @@ func (ws *WebSocketClient) dispatchTypedEventContinued(handlers *models.WebSocke
|
||||
|
||||
return true
|
||||
|
||||
case models.EventTypeGroupUpdated:
|
||||
if handlers.OnGroupUpdated != nil && event.GroupUpdated != nil {
|
||||
handlers.OnGroupUpdated(event.GroupUpdated)
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
case models.EventTypeBassUpdated:
|
||||
if handlers.OnBassUpdated != nil && event.BassUpdated != nil {
|
||||
handlers.OnBassUpdated(event.BassUpdated)
|
||||
|
||||
Reference in New Issue
Block a user