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:
Tobias Gesellchen
2026-05-11 23:18:08 +02:00
co-authored by Claude Opus 4.7
parent c8c38b78e6
commit cbbbaa9707
9 changed files with 875 additions and 14 deletions
+113 -1
View File
@@ -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)
+218
View File
@@ -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()
}
}
+63 -1
View File
@@ -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 \"<left> + <right>\")",
},
},
},
{
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"},