mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 17:16:16 +00:00
The ST10's /presets response after a factory reset emits self-closing <preset/> entries with no ContentItem child. cmd/soundtouch-cli's getPresets() handled the missing ContentItem in GetDisplayName() but then dereferenced preset.ContentItem.Source on the next line, panicking with "invalid memory address or nil pointer dereference" the moment the loop reached the first empty entry. A second placeholder shape was observed on healthy devices that were never reset: <preset id="0"><ContentItem source="INVALID_SOURCE" isPresetable="true"/></preset>. ContentItem is non-nil here, so the previous "ContentItem != nil" guard at other call sites still let these placeholders through into listings and into the AfterTouch datastore. Fix shape: pkg/models/presets.go - extend Preset.IsEmpty() to recognise both shapes (ContentItem == nil, OR Source == "" / "INVALID_SOURCE"). HasPresets, GetEmptyPresetSlots and GetUsedPresetSlots become honest about which slots actually carry playable content. cmd/soundtouch-cli/cmd_info.go (the crash site) - filter the slice via IsEmpty before the print loop, and switch the still-printed fields to the existing nil-safe Get* helpers. pkg/service/setup/setup.go - upgrade syncPresets's "ContentItem == nil" continue-guard to IsEmpty so Shape B placeholders don't get persisted in the AfterTouch datastore and then surface as junk rows in the admin web UI. cmd/soundtouch-cli/cmd_events.go, cmd/websocket-demo/main.go - same nil-guard upgrade. These already nil-checked so were crash-safe; the change is for consistency and to stop printing "Preset 0: (INVALID_SOURCE)" demo lines. examples/preset-management/main.go - had the same latent crash as cmd_info.go; same fix shape. Regression tests in pkg/models/presets_test.go cover both shapes using the exact XML observed in the wild: the reporter's three <preset/> placeholders plus the three INVALID_SOURCE entries from a live device. The reporter XML test walks every preset through the same accessor path the CLI used and asserts no panic. The soundtouch-web Go code does not deref preset.ContentItem.X anywhere - presets flow through as JSON - so no separate crash trap exists there. The web frontend will pick up the cleaner data once syncPresets stops persisting placeholders. Closes #308 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
579 lines
14 KiB
Go
579 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)
|
|
|
|
// IsEmpty catches both <preset/> and INVALID_SOURCE
|
|
// placeholders; using the nil-safe helpers below means the
|
|
// inner Printf never dereferences a nil ContentItem.
|
|
if !preset.IsEmpty() {
|
|
fmt.Printf(" %s (%s)", preset.GetDisplayName(), preset.GetSource())
|
|
}
|
|
|
|
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
|
|
}
|