Files
Bose-SoundTouch/cmd/soundtouch-cli/cmd_events.go
T
Tobias GesellchenandClaude Opus 4.7 cbbbaa9707 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>
2026-05-11 23:18:08 +02:00

577 lines
14 KiB
Go

// Package main provides the soundtouch-cli events command for WebSocket event monitoring.
package main
import (
"context"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/urfave/cli/v2"
)
// eventSubscribe handles the events subscribe command
func eventSubscribe(c *cli.Context) error {
clientConfig := GetClientConfig(c)
// Parse filters
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")
reconnect := !c.Bool("no-reconnect")
PrintDeviceHeader("Starting WebSocket event monitoring", clientConfig.Host, clientConfig.Port)
// Create SoundTouch client
soundTouchClient, err := CreateSoundTouchClient(clientConfig)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
return err
}
// Test basic connectivity
fmt.Println("Testing device connectivity...")
deviceInfo, err := soundTouchClient.GetDeviceInfo()
if err != nil {
PrintError(fmt.Sprintf("Failed to connect to device: %v", err))
return err
}
macAddress := ""
if len(deviceInfo.NetworkInfo) > 0 {
macAddress = deviceInfo.NetworkInfo[0].MacAddress
}
fmt.Printf("✅ Connected to: %s (Type: %s, MAC: %s)\n",
deviceInfo.Name, deviceInfo.Type, macAddress)
// Create WebSocket client
wsClient := setupWebSocketClient(soundTouchClient, reconnect, verbose)
// Set up event handlers
setupEventHandlers(wsClient, filters, verbose)
if debugMode != debugOff {
installDebugHook(wsClient, debugMode)
}
// Connect to WebSocket
fmt.Println("🔌 Connecting to WebSocket...")
err = wsClient.Connect()
if err != nil {
PrintError(fmt.Sprintf("Failed to connect to WebSocket: %v", err))
return err
}
fmt.Println("✅ Connected! Listening for events...")
if len(filters) > 0 {
fmt.Printf("📋 Filtering events: %s\n", strings.Join(getFilterKeys(filters), ", "))
}
if duration > 0 {
fmt.Printf("⏰ Will listen for %v\n", duration)
} else {
fmt.Println("⏸️ Press Ctrl+C to stop")
}
// Set up graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Handle duration limit
if duration > 0 {
go func() {
select {
case <-time.After(duration):
fmt.Println("\n⏰ Duration limit reached, shutting down...")
cancel()
case <-ctx.Done():
return
}
}()
}
// Handle interrupt signals
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
select {
case sig := <-sigChan:
fmt.Printf("\n🛑 Received signal %v, shutting down...\n", sig)
cancel()
case <-ctx.Done():
return
}
}()
// Wait for shutdown
<-ctx.Done()
// Disconnect WebSocket
fmt.Println("🔌 Disconnecting...")
if err := wsClient.Disconnect(); err != nil {
PrintError(fmt.Sprintf("Error during disconnect: %v", err))
}
fmt.Println("✅ Disconnected successfully")
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, "group": true, "bass": true,
"sdkInfo": true, "userActivity": true,
}
if eventFilter == "" {
return nil
}
filters := make(map[string]bool)
filterList := strings.Split(eventFilter, ",")
for _, f := range filterList {
f = strings.TrimSpace(f)
if !validFilters[f] {
PrintError(fmt.Sprintf("Invalid filter '%s'. Valid filters: %s",
f, strings.Join(getFilterKeys(validFilters), ", ")))
os.Exit(1)
}
filters[f] = true
}
return filters
}
// setupWebSocketClient creates and configures the WebSocket client
func setupWebSocketClient(soundTouchClient *client.Client, reconnect, verbose bool) *client.WebSocketClient {
wsConfig := &client.WebSocketConfig{
ReconnectInterval: 5 * time.Second,
MaxReconnectAttempts: 0, // Unlimited if reconnect enabled
PingInterval: 30 * time.Second,
PongTimeout: 10 * time.Second,
ReadBufferSize: 2048,
WriteBufferSize: 2048,
}
if verbose {
wsConfig.Logger = &VerboseLogger{}
} else {
wsConfig.Logger = &SilentLogger{}
}
if !reconnect {
wsConfig.MaxReconnectAttempts = 1
}
return soundTouchClient.NewWebSocketClient(wsConfig)
}
// setupEventHandlers configures all event handlers
func setupEventHandlers(wsClient *client.WebSocketClient, filters map[string]bool, verbose bool) {
// Now Playing events
if filters == nil || filters["nowPlaying"] {
wsClient.OnNowPlaying(func(event *models.NowPlayingUpdatedEvent) {
handleNowPlayingEvent(event, verbose)
})
}
// Volume events
if filters == nil || filters["volume"] {
wsClient.OnVolumeUpdated(func(event *models.VolumeUpdatedEvent) {
handleVolumeEvent(event, verbose)
})
}
// Connection state events
if filters == nil || filters["connection"] {
wsClient.OnConnectionState(func(event *models.ConnectionStateUpdatedEvent) {
handleConnectionEvent(event)
})
}
// Preset events
if filters == nil || filters["preset"] {
wsClient.OnPresetUpdated(func(event *models.PresetUpdatedEvent) {
handlePresetEvent(event, verbose)
})
}
// Zone/Multiroom events
if filters == nil || filters["zone"] {
wsClient.OnZoneUpdated(func(event *models.ZoneUpdatedEvent) {
handleZoneEvent(event)
})
}
// 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) {
handleBassEvent(event)
})
}
// Special message handler
wsClient.OnSpecialMessage(func(message *models.SpecialMessage) {
handleSpecialMessage(message, filters, verbose)
})
// Unknown events (always enabled for debugging)
wsClient.OnUnknownEvent(func(event *models.WebSocketEvent) {
handleUnknownEvent(event, verbose)
})
}
// Event handlers
func handleNowPlayingEvent(event *models.NowPlayingUpdatedEvent, verbose bool) {
fmt.Printf("\n🎵 Now Playing Update [%s]:\n", event.DeviceID)
np := &event.NowPlaying
if np.IsEmpty() {
fmt.Println(" ⏹️ Nothing playing")
return
}
fmt.Printf(" 🎵 %s\n", np.GetDisplayTitle())
if artist := np.GetDisplayArtist(); artist != "" {
fmt.Printf(" 👤 %s\n", artist)
}
if np.Album != "" {
fmt.Printf(" 💿 %s\n", np.Album)
}
fmt.Printf(" 📻 Source: %s\n", np.Source)
fmt.Printf(" ▶️ Status: %s\n", np.PlayStatus.String())
if np.HasTimeInfo() {
fmt.Printf(" ⏱️ Duration: %s\n", np.FormatDuration())
}
if np.ShuffleSetting != "" {
fmt.Printf(" 🔀 Shuffle: %s\n", np.ShuffleSetting.String())
}
if np.RepeatSetting != "" {
fmt.Printf(" 🔁 Repeat: %s\n", np.RepeatSetting.String())
}
if verbose {
fmt.Printf(" 📱 Raw Source: %s, Account: %s\n", np.Source, np.SourceAccount)
if np.Art != nil && np.Art.URL != "" {
fmt.Printf(" 🖼️ Artwork: %s\n", np.Art.URL)
}
}
}
func handleVolumeEvent(event *models.VolumeUpdatedEvent, verbose bool) {
vol := &event.Volume
fmt.Printf("\n🔊 Volume Update [%s]:\n", event.DeviceID)
if vol.IsMuted() {
fmt.Println(" 🔇 Muted")
} else {
fmt.Printf(" 🔊 Level: %d\n", vol.ActualVolume)
if vol.TargetVolume != vol.ActualVolume {
fmt.Printf(" 🎯 Target: %d\n", vol.TargetVolume)
}
fmt.Printf(" 📊 %s\n", models.GetVolumeLevelName(vol.ActualVolume))
}
if verbose {
fmt.Printf(" 📱 Sync: %v\n", vol.IsVolumeSync())
}
}
func handleConnectionEvent(event *models.ConnectionStateUpdatedEvent) {
cs := &event.ConnectionState
fmt.Printf("\n🌐 Connection Update [%s]:\n", event.DeviceID)
if cs.IsConnected() {
fmt.Println(" ✅ Connected")
} else {
fmt.Printf(" ❌ State: %s\n", cs.State)
}
if cs.Signal != "" {
fmt.Printf(" 📶 Signal: %s\n", cs.GetSignalStrength())
}
}
func handlePresetEvent(event *models.PresetUpdatedEvent, verbose bool) {
presets := &event.Presets
deviceHeader := "\n📻 Presets Update"
if event.DeviceID != "" {
deviceHeader += fmt.Sprintf(" [%s]", event.DeviceID)
}
fmt.Printf("%s:\n", deviceHeader)
fmt.Printf(" 📻 Total presets: %d\n", len(presets.Preset))
for _, preset := range presets.Preset {
fmt.Printf(" 📻 Preset %d:", preset.ID)
if preset.ContentItem != nil {
fmt.Printf(" %s", preset.ContentItem.ItemName)
fmt.Printf(" (%s)", preset.ContentItem.Source)
}
fmt.Println()
}
if verbose {
fmt.Printf(" 📱 Raw presets data: %d total presets\n", len(presets.Preset))
}
}
func handleZoneEvent(event *models.ZoneUpdatedEvent) {
zone := &event.Zone
fmt.Printf("\n🏠 Zone Update [%s]:\n", event.DeviceID)
fmt.Printf(" 👑 Master: %s\n", zone.Master)
if len(zone.Members) > 0 {
fmt.Printf(" 👥 Members (%d):\n", len(zone.Members))
for i, member := range zone.Members {
fmt.Printf(" %d. %s (%s)\n", i+1, member.DeviceID, member.IP)
}
} else {
fmt.Println(" 👤 Single device (no zone)")
}
}
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)
fmt.Printf(" 🎚️ Level: %d\n", bass.ActualBass)
if bass.TargetBass != bass.ActualBass {
fmt.Printf(" 🎯 Target: %d\n", bass.TargetBass)
}
levelDesc := "Neutral"
if bass.ActualBass > 0 {
levelDesc = "Boosted"
} else if bass.ActualBass < 0 {
levelDesc = "Reduced"
}
fmt.Printf(" 📊 %s\n", levelDesc)
}
func handleSpecialMessage(message *models.SpecialMessage, filters map[string]bool, verbose bool) {
// Check if we should filter this message type
if filters != nil {
switch message.Type {
case models.MessageTypeSdkInfo:
if !filters["sdkInfo"] {
return
}
case models.MessageTypeUserActivity:
if !filters["userActivity"] {
return
}
case models.MessageTypeUserInactivity:
if !filters["userInactivity"] {
return
}
}
}
switch message.Type {
case models.MessageTypeSdkInfo:
if sdkInfo := message.GetSdkInfo(); sdkInfo != nil {
fmt.Printf("\n📡 SDK Info:\n")
fmt.Printf(" 📋 Server Version: %s\n", sdkInfo.ServerVersion)
fmt.Printf(" 🔧 Server Build: %s\n", sdkInfo.ServerBuild)
}
case models.MessageTypeUserActivity:
fmt.Printf("\n👤 User Activity [%s]\n", message.DeviceID)
if verbose {
fmt.Printf(" ⏰ Timestamp: %s\n", message.Timestamp.Format("15:04:05"))
}
case models.MessageTypeUserInactivity:
fmt.Printf("\n💤 User Inactivity [%s]\n", message.DeviceID)
if verbose {
fmt.Printf(" ⏰ Timestamp: %s\n", message.Timestamp.Format("15:04:05"))
}
default:
fmt.Printf("\n❓ Unknown Special Message: %s\n", message.String())
if verbose {
fmt.Printf(" 📱 Raw data: %s\n", string(message.RawData))
}
}
}
func handleUnknownEvent(event *models.WebSocketEvent, verbose bool) {
fmt.Printf("\n❓ Unknown Event [%s]:\n", event.DeviceID)
types := event.GetEventTypes()
for _, eventType := range types {
fmt.Printf(" 📝 Type: %s\n", eventType)
}
if verbose {
events := event.GetEvents()
fmt.Printf(" 📱 Event count: %d\n", len(events))
fmt.Printf(" ⏰ Timestamp: %s\n", event.Timestamp.Format(time.RFC3339))
}
}
// getFilterKeys extracts keys from filter map
func getFilterKeys(filters map[string]bool) []string {
var keys []string
for k := range filters {
keys = append(keys, k)
}
return keys
}
// Logger implementations
type VerboseLogger struct{}
func (v *VerboseLogger) Printf(format string, args ...interface{}) {
timestamp := time.Now().Format("15:04:05")
fmt.Printf("[%s] [WebSocket] %s\n", timestamp, fmt.Sprintf(format, args...))
}
type SilentLogger struct{}
func (s *SilentLogger) Printf(_ string, _ ...interface{}) {
// Do nothing - silent logging
}