diff --git a/cmd/soundtouch-cli/cmd_events.go b/cmd/soundtouch-cli/cmd_events.go index f1eeba4..86df8a1 100644 --- a/cmd/soundtouch-cli/cmd_events.go +++ b/cmd/soundtouch-cli/cmd_events.go @@ -23,6 +23,12 @@ func eventSubscribe(c *cli.Context) error { filterStr := c.String("filter") filters := parseEventFilters(filterStr) + debugMode, err := parseDebugMode(c.String("debug")) + if err != nil { + PrintError(err.Error()) + return err + } + // Parse duration duration := c.Duration("duration") verbose := c.Bool("verbose") @@ -60,6 +66,10 @@ func eventSubscribe(c *cli.Context) error { // Set up event handlers setupEventHandlers(wsClient, filters, verbose) + if debugMode != debugOff { + installDebugHook(wsClient, debugMode) + } + // Connect to WebSocket fmt.Println("šŸ”Œ Connecting to WebSocket...") @@ -127,11 +137,78 @@ func eventSubscribe(c *cli.Context) error { return nil } +// debugMode controls when the WebSocket subscribe loop prints raw frames +// to stderr. "off" disables debug output entirely (the production default +// when --debug is unset). +type debugMode int + +const ( + debugOff debugMode = iota + debugAll + debugUnknown + debugErrors +) + +func parseDebugMode(s string) (debugMode, error) { + switch strings.TrimSpace(s) { + case "": + return debugOff, nil + case "all": + return debugAll, nil + case "unknown": + return debugUnknown, nil + case "errors": + return debugErrors, nil + default: + return debugOff, fmt.Errorf("invalid --debug value %q (want one of: all, unknown, errors)", s) + } +} + +// installDebugHook wires an OnRawMessage handler that prints the raw +// frame to stderr based on the chosen mode. Stays out of stdout so +// debug output can be filtered/grep'd independently of normal events. +func installDebugHook(ws *client.WebSocketClient, mode debugMode) { + ws.OnRawMessage(func(data []byte, parseErr error) { + switch mode { + case debugAll: + printRawFrame(data, parseErr, "all") + case debugErrors: + if parseErr != nil { + printRawFrame(data, parseErr, "errors") + } + case debugUnknown: + // "Unknown" = parsed successfully but no known event types + // matched. Parse errors also qualify, since they're frames + // the client couldn't interpret either. + if parseErr != nil { + printRawFrame(data, parseErr, "unknown:parse-error") + return + } + + ev, err := models.ParseWebSocketEvent(data) + if err != nil || len(ev.GetEventTypes()) == 0 { + printRawFrame(data, err, "unknown") + } + case debugOff: + // nothing + } + }) +} + +func printRawFrame(data []byte, parseErr error, tag string) { + prefix := "[ws-debug:" + tag + "]" + if parseErr != nil { + fmt.Fprintf(os.Stderr, "%s parse-error: %v\n", prefix, parseErr) + } + + fmt.Fprintf(os.Stderr, "%s %s\n", prefix, string(data)) +} + // parseEventFilters validates and parses the filter string func parseEventFilters(eventFilter string) map[string]bool { validFilters := map[string]bool{ "nowPlaying": true, "volume": true, "connection": true, - "preset": true, "zone": true, "bass": true, + "preset": true, "zone": true, "group": true, "bass": true, "sdkInfo": true, "userActivity": true, } @@ -217,6 +294,13 @@ func setupEventHandlers(wsClient *client.WebSocketClient, filters map[string]boo }) } + // Stereo-pair (group) events — ST-10 only + if filters == nil || filters["group"] { + wsClient.OnGroupUpdated(func(event *models.GroupUpdatedEvent) { + handleGroupEvent(event) + }) + } + // Bass events if filters == nil || filters["bass"] { wsClient.OnBassUpdated(func(event *models.BassUpdatedEvent) { @@ -358,6 +442,34 @@ func handleZoneEvent(event *models.ZoneUpdatedEvent) { } } +func handleGroupEvent(event *models.GroupUpdatedEvent) { + group := &event.Group + fmt.Printf("\nšŸŽ§ Stereo-Pair Update [%s]:\n", event.DeviceID) + + if group.IsEmpty() { + fmt.Println(" ā›“ļøā€šŸ’„ Pair dissolved (no group configured)") + return + } + + fmt.Printf(" šŸ†” ID: %s\n", group.ID) + fmt.Printf(" šŸ“› Name: %s\n", group.Name) + fmt.Printf(" šŸ‘‘ Master: %s\n", group.MasterDeviceID) + + if group.Status != "" { + fmt.Printf(" āœ… Status: %s\n", group.Status) + } + + for _, r := range group.Roles.Roles { + fmt.Printf(" %-5s %s", r.Role, r.DeviceID) + + if r.IPAddress != "" { + fmt.Printf(" (IP: %s)", r.IPAddress) + } + + fmt.Println() + } +} + func handleBassEvent(event *models.BassUpdatedEvent) { bass := &event.Bass fmt.Printf("\nšŸŽµ Bass Update [%s]:\n", event.DeviceID) diff --git a/cmd/soundtouch-cli/cmd_group.go b/cmd/soundtouch-cli/cmd_group.go new file mode 100644 index 0000000..82ae61a --- /dev/null +++ b/cmd/soundtouch-cli/cmd_group.go @@ -0,0 +1,218 @@ +package main + +import ( + "fmt" + "net" + + "github.com/gesellix/bose-soundtouch/pkg/client" + "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/gesellix/bose-soundtouch/pkg/speaker" + "github.com/urfave/cli/v2" +) + +// getGroupStatus retrieves and prints the device's current stereo-pair state. +func getGroupStatus(c *cli.Context) error { + clientConfig := GetClientConfig(c) + PrintDeviceHeader("Getting group information", clientConfig.Host, clientConfig.Port) + + client, err := CreateSoundTouchClient(clientConfig) + if err != nil { + PrintError(fmt.Sprintf("Failed to create client: %v", err)) + return err + } + + group, err := client.GetGroup() + if err != nil { + PrintError(fmt.Sprintf("Failed to get group: %v", err)) + return err + } + + if group.IsEmpty() { + fmt.Println("Device is not in a stereo pair") + return nil + } + + printGroup(group) + + return nil +} + +// createGroup forms a stereo pair on the LEFT speaker, which becomes the master. +func createGroup(c *cli.Context) error { + leftIP := c.String("left") + rightIP := c.String("right") + name := c.String("name") + + if net.ParseIP(leftIP) == nil { + PrintError(fmt.Sprintf("Invalid left IP address: %s", leftIP)) + return fmt.Errorf("invalid left IP: %s", leftIP) + } + + if net.ParseIP(rightIP) == nil { + PrintError(fmt.Sprintf("Invalid right IP address: %s", rightIP)) + return fmt.Errorf("invalid right IP: %s", rightIP) + } + + PrintDeviceHeader(fmt.Sprintf("Creating stereo pair: LEFT=%s RIGHT=%s", leftIP, rightIP), leftIP, speaker.HTTPPort) + + leftInfo, err := fetchDeviceInfo(c, leftIP) + if err != nil { + PrintError(fmt.Sprintf("Failed to read LEFT device info: %v", err)) + return err + } + + rightInfo, err := fetchDeviceInfo(c, rightIP) + if err != nil { + PrintError(fmt.Sprintf("Failed to read RIGHT device info: %v", err)) + return err + } + + if name == "" { + name = fmt.Sprintf("%s + %s", leftInfo.Name, rightInfo.Name) + } + + req := &models.Group{ + Name: name, + MasterDeviceID: leftInfo.DeviceID, + Roles: models.GroupRoles{ + Roles: []models.GroupRole{ + {DeviceID: leftInfo.DeviceID, Role: "LEFT", IPAddress: leftIP}, + {DeviceID: rightInfo.DeviceID, Role: "RIGHT", IPAddress: rightIP}, + }, + }, + } + + leftClient, err := clientForHost(c, leftIP) + if err != nil { + PrintError(fmt.Sprintf("Failed to create client for LEFT: %v", err)) + return err + } + + result, err := leftClient.AddGroup(req) + if err != nil { + PrintError(fmt.Sprintf("Failed to create group: %v", err)) + return err + } + + PrintSuccess(fmt.Sprintf("Stereo pair created (id=%s)", result.ID)) + printGroup(result) + + return nil +} + +// renameGroup updates the name of the existing stereo pair. The device +// requires the full structure on every update, so we fetch the current +// state first. +func renameGroup(c *cli.Context) error { + clientConfig := GetClientConfig(c) + newName := c.String("name") + + if newName == "" { + PrintError("--name is required") + return fmt.Errorf("name is required") + } + + PrintDeviceHeader(fmt.Sprintf("Renaming stereo pair to %q", newName), clientConfig.Host, clientConfig.Port) + + stClient, err := CreateSoundTouchClient(clientConfig) + if err != nil { + PrintError(fmt.Sprintf("Failed to create client: %v", err)) + return err + } + + current, err := stClient.GetGroup() + if err != nil { + PrintError(fmt.Sprintf("Failed to read current group: %v", err)) + return err + } + + if current.IsEmpty() { + PrintError("Device is not in a stereo pair — nothing to rename") + return fmt.Errorf("no group configured") + } + + // Status is read-only on the device side; don't echo it back. + current.Status = "" + current.Name = newName + + result, err := stClient.UpdateGroup(current) + if err != nil { + PrintError(fmt.Sprintf("Failed to rename group: %v", err)) + return err + } + + PrintSuccess(fmt.Sprintf("Stereo pair renamed to %q", result.Name)) + printGroup(result) + + return nil +} + +// removeGroup tears down the device's stereo pair. +func removeGroup(c *cli.Context) error { + clientConfig := GetClientConfig(c) + PrintDeviceHeader("Removing stereo pair", clientConfig.Host, clientConfig.Port) + + stClient, err := CreateSoundTouchClient(clientConfig) + if err != nil { + PrintError(fmt.Sprintf("Failed to create client: %v", err)) + return err + } + + if err := stClient.RemoveGroup(); err != nil { + PrintError(fmt.Sprintf("Failed to remove group: %v", err)) + return err + } + + PrintSuccess("Stereo pair removed") + + return nil +} + +// fetchDeviceInfo builds a one-off client for the given IP and reads /info. +// Reused for both halves of a `create` invocation so the caller doesn't have +// to babysit two host/port pairs. +func fetchDeviceInfo(c *cli.Context, host string) (*models.DeviceInfo, error) { + stClient, err := clientForHost(c, host) + if err != nil { + return nil, err + } + + return stClient.GetDeviceInfo() +} + +// clientForHost mirrors CreateSoundTouchClient but overrides the host so we +// can talk to a speaker other than the one named in --host. +func clientForHost(c *cli.Context, host string) (*client.Client, error) { + cfg, err := loadConfig(c.Duration("timeout")) + if err != nil { + return nil, fmt.Errorf("failed to load config: %w", err) + } + + return client.NewClient(&client.Config{ + Host: host, + Port: speaker.HTTPPort, + Timeout: cfg.HTTPTimeout, + UserAgent: cfg.UserAgent, + }), nil +} + +func printGroup(g *models.Group) { + fmt.Println("Stereo Pair Configuration:") + fmt.Printf(" ID: %s\n", g.ID) + fmt.Printf(" Name: %s\n", g.Name) + fmt.Printf(" Master: %s\n", g.MasterDeviceID) + + if g.Status != "" { + fmt.Printf(" Status: %s\n", g.Status) + } + + for _, r := range g.Roles.Roles { + fmt.Printf(" %-5s %s", r.Role, r.DeviceID) + + if r.IPAddress != "" { + fmt.Printf(" (IP: %s)", r.IPAddress) + } + + fmt.Println() + } +} diff --git a/cmd/soundtouch-cli/main.go b/cmd/soundtouch-cli/main.go index 5429e90..5eef96f 100644 --- a/cmd/soundtouch-cli/main.go +++ b/cmd/soundtouch-cli/main.go @@ -1478,6 +1478,64 @@ func main() { }, }, }, + // Stereo-pair (group) commands — ST-10 only + { + Name: "group", + Aliases: []string{"g"}, + Usage: "ST-10 stereo-pair management (left/right channel pairing)", + Subcommands: []*cli.Command{ + { + Name: "status", + Usage: "Show the device's current stereo-pair configuration", + Action: getGroupStatus, + Before: RequireHost, + }, + { + Name: "create", + Usage: "Form a stereo pair (LEFT speaker becomes master)", + Action: createGroup, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "left", + Aliases: []string{"l"}, + Usage: "IP address of the LEFT speaker (will be master)", + Required: true, + }, + &cli.StringFlag{ + Name: "right", + Aliases: []string{"r"}, + Usage: "IP address of the RIGHT speaker", + Required: true, + }, + &cli.StringFlag{ + Name: "name", + Aliases: []string{"n"}, + Usage: "Pair name (defaults to \" + \")", + }, + }, + }, + { + Name: "rename", + Usage: "Rename the existing stereo pair on the device", + Action: renameGroup, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "name", + Aliases: []string{"n"}, + Usage: "New pair name", + Required: true, + }, + }, + Before: RequireHost, + }, + { + Name: "remove", + Usage: "Dissolve the device's stereo pair", + Action: removeGroup, + Before: RequireHost, + }, + }, + }, // Advanced Audio commands { Name: "audio", @@ -2093,7 +2151,7 @@ func main() { &cli.StringFlag{ Name: "filter", Aliases: []string{"f"}, - Usage: "Filter events by type (comma-separated): nowPlaying,volume,connection,preset,zone,bass,sdkInfo,userActivity", + Usage: "Filter events by type (comma-separated): nowPlaying,volume,connection,preset,zone,group,bass,sdkInfo,userActivity", }, &cli.DurationFlag{ Name: "duration", @@ -2105,6 +2163,10 @@ func main() { Name: "no-reconnect", Usage: "Disable automatic reconnection on connection loss", }, + &cli.StringFlag{ + Name: "debug", + Usage: "Print raw WebSocket frames to stderr — one of: all, unknown, errors", + }, &cli.BoolFlag{ Name: "verbose", Aliases: []string{"v"}, diff --git a/pkg/client/client.go b/pkg/client/client.go index ae81590..89f4d34 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -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 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 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{ diff --git a/pkg/client/group_test.go b/pkg/client/group_test.go new file mode 100644 index 0000000..021b9ea --- /dev/null +++ b/pkg/client/group_test.go @@ -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 := ` + + Living Room Pair + 9070658C9D4A + + + 9070658C9D4A + LEFT + 192.168.1.131 + + + F45EAB3115DA + RIGHT + 192.168.1.134 + + + 192.168.1.131 + GROUP_OK +` + + 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(``)) + })) + 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 , 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{"LEFT", "RIGHT", "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(``)) + })) + defer server.Close() + + if err := createTestClient(server.URL).RemoveGroup(); err != nil { + t.Fatalf("RemoveGroup: %v", err) + } +} diff --git a/pkg/client/websocket.go b/pkg/client/websocket.go index e9fe53d..8562c2b 100644 --- a/pkg/client/websocket.go +++ b/pkg/client/websocket.go @@ -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) diff --git a/pkg/models/group.go b/pkg/models/group.go index a4aa763..eab4c6b 100644 --- a/pkg/models/group.go +++ b/pkg/models/group.go @@ -10,6 +10,15 @@ type Group struct { MasterDeviceID string `xml:"masterDeviceId"` Roles GroupRoles `xml:"roles"` SenderIPAddress string `xml:"senderIPAddress,omitempty"` + // Status is populated by the device on GET /group (e.g. "GROUP_OK") + // and omitted from requests we send back. + Status string `xml:"status,omitempty"` +} + +// IsEmpty reports whether the device returned an empty element, +// which is the speaker's way of saying "no stereo pair configured". +func (g *Group) IsEmpty() bool { + return g.ID == "" && g.MasterDeviceID == "" && len(g.Roles.Roles) == 0 } // GroupRoles contains the role assignments for devices in a group. diff --git a/pkg/models/websocket.go b/pkg/models/websocket.go index e3e0ad4..d4378a9 100644 --- a/pkg/models/websocket.go +++ b/pkg/models/websocket.go @@ -21,6 +21,10 @@ const ( EventTypePresetUpdated WebSocketEventType = "presetsUpdated" // EventTypeZoneUpdated indicates a zone configuration change EventTypeZoneUpdated WebSocketEventType = "zoneUpdated" + // EventTypeGroupUpdated is emitted to both ROLE devices when an ST-10 + // stereo pair is created, renamed, or removed via /addGroup, + // /updateGroup, or /removeGroup. + EventTypeGroupUpdated WebSocketEventType = "groupUpdated" // EventTypeBassUpdated indicates a bass level change EventTypeBassUpdated WebSocketEventType = "bassUpdated" // EventTypeClockTimeUpdated indicates a clock time change @@ -56,6 +60,8 @@ func (e WebSocketEventType) String() string { return "Preset Updated" case EventTypeZoneUpdated: return "Zone Updated" + case EventTypeGroupUpdated: + return "Stereo Pair Updated" case EventTypeBassUpdated: return "Bass Updated" case EventTypeClockTimeUpdated: @@ -88,6 +94,7 @@ type WebSocketEvent struct { ConnectionStateUpdated *ConnectionStateUpdatedEvent `xml:"connectionStateUpdated,omitempty"` PresetUpdated *PresetUpdatedEvent `xml:"presetsUpdated,omitempty"` ZoneUpdated *ZoneUpdatedEvent `xml:"zoneUpdated,omitempty"` + GroupUpdated *GroupUpdatedEvent `xml:"groupUpdated,omitempty"` BassUpdated *BassUpdatedEvent `xml:"bassUpdated,omitempty"` ClockTimeUpdated *ClockTimeUpdatedEvent `xml:"clockTimeUpdated,omitempty"` ClockDisplayUpdated *ClockDisplayUpdatedEvent `xml:"clockDisplayUpdated,omitempty"` @@ -122,6 +129,10 @@ func (e *WebSocketEvent) GetEvents() []interface{} { events = append(events, e.ZoneUpdated) } + if e.GroupUpdated != nil { + events = append(events, e.GroupUpdated) + } + if e.BassUpdated != nil { events = append(events, e.BassUpdated) } @@ -215,6 +226,16 @@ type ZoneUpdatedEvent struct { Zone Zone `xml:"zone"` } +// GroupUpdatedEvent represents an ST-10 stereo-pair update notification. +// The device fans this event out to both LEFT and RIGHT speakers whenever +// the pair is created, renamed, or removed. Group will be the zero value +// for a teardown notification — see (*Group).IsEmpty. +type GroupUpdatedEvent struct { + XMLName xml.Name `xml:"groupUpdated"` + DeviceID string `xml:"deviceID,attr"` + Group Group `xml:"group"` +} + // Zone represents multiroom zone information type Zone struct { XMLName xml.Name `xml:"zone"` @@ -373,6 +394,7 @@ type WebSocketEventHandlers struct { OnConnectionState TypedEventHandler[*ConnectionStateUpdatedEvent] OnPresetUpdated TypedEventHandler[*PresetUpdatedEvent] OnZoneUpdated TypedEventHandler[*ZoneUpdatedEvent] + OnGroupUpdated TypedEventHandler[*GroupUpdatedEvent] OnBassUpdated TypedEventHandler[*BassUpdatedEvent] OnClockTimeUpdated TypedEventHandler[*ClockTimeUpdatedEvent] OnClockDisplayUpdated TypedEventHandler[*ClockDisplayUpdatedEvent] @@ -382,8 +404,19 @@ type WebSocketEventHandlers struct { OnLanguageUpdated TypedEventHandler[*LanguageUpdatedEvent] OnUnknownEvent EventHandler OnSpecialMessage SpecialMessageHandler + // OnRawMessage fires for every received frame before any parsing + // happens. Use it for debug/observability tooling that wants to see + // exactly what the device sent on the wire — the typed handlers + // above still run afterwards, independently. parseErr is the result + // of the XML parse: nil for messages that decoded cleanly, non-nil + // for malformed payloads. The slice is owned by the caller; copy + // before retaining. + OnRawMessage RawMessageHandler } +// RawMessageHandler defines the signature for raw-frame handlers. +type RawMessageHandler func(data []byte, parseErr error) + // ParseWebSocketEvent attempts to parse a WebSocket message into a specific event type func ParseWebSocketEvent(data []byte) (*WebSocketEvent, error) { var event WebSocketEvent @@ -411,6 +444,8 @@ func (e *WebSocketEvent) getFieldByEventType(eventType WebSocketEventType) inter field = e.PresetUpdated case EventTypeZoneUpdated: field = e.ZoneUpdated + case EventTypeGroupUpdated: + field = e.GroupUpdated case EventTypeBassUpdated: field = e.BassUpdated case EventTypeClockTimeUpdated: @@ -462,6 +497,8 @@ func isNil(i interface{}) bool { return v == nil case *ZoneUpdatedEvent: return v == nil + case *GroupUpdatedEvent: + return v == nil case *BassUpdatedEvent: return v == nil case *ClockTimeUpdatedEvent: @@ -508,6 +545,8 @@ func (e *WebSocketEvent) HasEventType(eventType WebSocketEventType) bool { return e.PresetUpdated != nil case EventTypeZoneUpdated: return e.ZoneUpdated != nil + case EventTypeGroupUpdated: + return e.GroupUpdated != nil case EventTypeBassUpdated: return e.BassUpdated != nil case EventTypeClockTimeUpdated: @@ -551,6 +590,10 @@ func (e *WebSocketEvent) GetEventTypes() []WebSocketEventType { types = append(types, EventTypeZoneUpdated) } + if e.GroupUpdated != nil { + types = append(types, EventTypeGroupUpdated) + } + if e.BassUpdated != nil { types = append(types, EventTypeBassUpdated) } diff --git a/pkg/models/websocket_test.go b/pkg/models/websocket_test.go index efc6870..5678366 100644 --- a/pkg/models/websocket_test.go +++ b/pkg/models/websocket_test.go @@ -16,6 +16,7 @@ func TestWebSocketEventType_String(t *testing.T) { {"ConnectionState", EventTypeConnectionState, "Connection State Updated"}, {"PresetUpdated", EventTypePresetUpdated, "Preset Updated"}, {"ZoneUpdated", EventTypeZoneUpdated, "Zone Updated"}, + {"GroupUpdated", EventTypeGroupUpdated, "Stereo Pair Updated"}, {"BassUpdated", EventTypeBassUpdated, "Bass Updated"}, {"ClockTimeUpdated", EventTypeClockTimeUpdated, "Clock Time Updated"}, {"ClockDisplayUpdated", EventTypeClockDisplayUpdated, "Clock Display Updated"}, @@ -187,6 +188,89 @@ func TestParseWebSocketEvent(t *testing.T) { t.Error("Expected error for invalid XML, got nil") } }) + + t.Run("ValidGroupUpdatedEvent", func(t *testing.T) { + // The device fans this out to both ROLE devices when a stereo + // pair is created via POST /addGroup. + xmlData := ` + + + + Living Room Pair + 9070658C9D4A + + + 9070658C9D4A + LEFT + 192.168.1.131 + + + F45EAB3115DA + RIGHT + 192.168.1.134 + + + GROUP_OK + + +` + + event, err := ParseWebSocketEvent([]byte(xmlData)) + if err != nil { + t.Fatalf("ParseWebSocketEvent: %v", err) + } + + if !event.HasEventType(EventTypeGroupUpdated) { + t.Fatal("HasEventType(EventTypeGroupUpdated) = false, want true") + } + + if event.GroupUpdated == nil { + t.Fatal("GroupUpdated is nil") + } + + g := event.GroupUpdated.Group + + if g.ID != "1234567" { + t.Errorf("group ID = %q, want 1234567", g.ID) + } + + if g.MasterDeviceID != "9070658C9D4A" { + t.Errorf("MasterDeviceID = %q", g.MasterDeviceID) + } + + if len(g.Roles.Roles) != 2 || g.Roles.Roles[0].Role != "LEFT" || g.Roles.Roles[1].Role != "RIGHT" { + t.Errorf("roles not parsed as LEFT/RIGHT: %+v", g.Roles.Roles) + } + + if g.Status != "GROUP_OK" { + t.Errorf("status = %q, want GROUP_OK", g.Status) + } + }) + + t.Run("GroupUpdatedTeardown", func(t *testing.T) { + // On /removeGroup, the device emits a groupUpdated with an empty + // body. Parsing must surface that as IsEmpty=true so the + // UI can render "pair dissolved" cleanly. + xmlData := ` + + + + +` + + event, err := ParseWebSocketEvent([]byte(xmlData)) + if err != nil { + t.Fatalf("ParseWebSocketEvent: %v", err) + } + + if event.GroupUpdated == nil { + t.Fatal("GroupUpdated is nil") + } + + if !event.GroupUpdated.Group.IsEmpty() { + t.Errorf("Group.IsEmpty() = false on teardown; got %+v", event.GroupUpdated.Group) + } + }) } func TestWebSocketEvent_HasEventType(t *testing.T) {