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>
Groups (stereo pairs of ST10 speakers) were read-only — the GET endpoint
always returned an empty <group/>. Add POST /account/{account}/group,
POST /account/{account}/group/{groupId}, and DELETE
/account/{account}/group/{groupId} with datastore persistence, matching
the API shape observed in soundcork. The GET endpoint now reads live
group state from the datastore.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>