Files
Bose-SoundTouch/cmd/websocket-demo/main.go
T
Tobias GesellchenandClaude Opus 4.7 c668c732df fix(#308): handle placeholder presets without panicking
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>
2026-05-17 11:36:55 +02:00

585 lines
16 KiB
Go

// Package main provides a demonstration of WebSocket event handling for Bose SoundTouch devices.
package main
import (
"context"
"flag"
"fmt"
"net"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/config"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
// parseHostPort splits a host:port string into separate host and port components
// If no port is specified, returns the original host and the provided default port
func parseHostPort(hostPort string, defaultPort int) (string, int) {
// Check if host contains a port (has a colon)
if strings.Contains(hostPort, ":") {
host, portStr, err := net.SplitHostPort(hostPort)
if err != nil {
// If parsing fails, return original host and default port
return hostPort, defaultPort
}
port, err := strconv.Atoi(portStr)
if err != nil || port < 1 || port > 65535 {
// If port parsing fails or is invalid, return host and default port
return host, defaultPort
}
return host, port
}
// No port specified, return original host and default port
return hostPort, defaultPort
}
func parseFilters(eventFilter string) map[string]bool {
validFilters := map[string]bool{
"nowPlaying": true, "volume": true, "connection": true,
"preset": true, "zone": 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] {
fmt.Printf("Invalid filter '%s'. Valid filters: nowPlaying, volume, connection, preset, zone, bass, sdkInfo, userActivity\n", f)
os.Exit(1)
}
filters[f] = true
}
return filters
}
func discoverDevice(discoverFlag bool, hostPort string, defaultPort int) (string, int, error) {
if hostPort != "" && !discoverFlag {
deviceHost, devicePort := parseHostPort(hostPort, defaultPort)
fmt.Printf("Connecting to: %s:%d\n", deviceHost, devicePort)
return deviceHost, devicePort, nil
}
fmt.Println("Discovering SoundTouch devices...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cfg := &config.Config{
DiscoveryTimeout: 10 * time.Second,
CacheEnabled: false,
}
discoveryService := discovery.NewUnifiedDiscoveryService(cfg)
devices, err := discoveryService.DiscoverDevices(ctx)
if err != nil || len(devices) == 0 {
if err != nil {
return "", 0, fmt.Errorf("discovery failed: %w", err)
}
return "", 0, fmt.Errorf("no SoundTouch devices found")
}
device := devices[0]
fmt.Printf("Found %d device(s), connecting to: %s (%s:%d)\n",
len(devices), device.Name, device.Host, device.Port)
return device.Host, device.Port, nil
}
func setupWebSocket(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 {
// Use a silent logger when not verbose
wsConfig.Logger = &SilentLogger{}
}
if !reconnect {
wsConfig.MaxReconnectAttempts = 1
}
return soundTouchClient.NewWebSocketClient(wsConfig)
}
func main() {
var (
host = flag.String("host", "", "SoundTouch device host/IP address (can include port like host:8090)")
port = flag.Int("port", 8090, "SoundTouch device port")
discover = flag.Bool("discover", false, "Discover SoundTouch devices and connect to first found")
duration = flag.Duration("duration", 0, "How long to listen for events (0 = infinite)")
reconnect = flag.Bool("reconnect", true, "Enable automatic reconnection")
verbose = flag.Bool("verbose", false, "Enable verbose logging")
eventFilter = flag.String("filter", "", "Filter events by type (nowPlaying,volume,connection,preset,zone,bass)")
help = flag.Bool("help", false, "Show help")
)
flag.Parse()
if *help {
printHelp()
return
}
// Validate filter if provided
filters := parseFilters(*eventFilter)
// Discover devices if no host specified or discover flag used
deviceHost, devicePort, err := discoverDevice(*discover, *host, *port)
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
// Create client
clientConfig := &client.Config{
Host: deviceHost,
Port: devicePort,
Timeout: 10 * time.Second,
}
soundTouchClient := client.NewClient(clientConfig)
// Test basic connectivity
fmt.Println("Testing device connectivity...")
deviceInfo, err := soundTouchClient.GetDeviceInfo()
if err != nil {
fmt.Printf("Failed to connect to device: %v\n", err)
return
}
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 := setupWebSocket(soundTouchClient, *reconnect, *verbose)
// Set up event handlers
setupEventHandlers(wsClient, filters, *verbose)
// Set up special message handler
wsClient.OnSpecialMessage(func(message *models.SpecialMessage) {
handleSpecialMessage(message, filters, *verbose)
})
// Connect to WebSocket
fmt.Println("Connecting to WebSocket...")
err = wsClient.Connect()
if err != nil {
fmt.Printf("Failed to connect to WebSocket: %v\n", err)
return
}
fmt.Println("Connected! Listening for events...")
if len(filters) > 0 {
fmt.Printf("Filtering events: %v\n", 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("\nDuration 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("\nReceived 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 {
fmt.Printf("Error during disconnect: %v\n", err)
}
fmt.Println("Disconnected successfully")
}
func handleNowPlaying(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 handleVolume(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 handleConnection(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 handlePreset(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 handleZone(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 handleBass(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
}
}
}
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"))
}
default:
fmt.Printf("\n❓ Unknown Special Message: %s\n", message.String())
if verbose {
fmt.Printf(" 📱 Raw data: %s\n", string(message.RawData))
}
}
}
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) {
handleNowPlaying(event, verbose)
})
}
// Volume events
if filters == nil || filters["volume"] {
wsClient.OnVolumeUpdated(func(event *models.VolumeUpdatedEvent) {
handleVolume(event, verbose)
})
}
// Connection state events
if filters == nil || filters["connection"] {
wsClient.OnConnectionState(func(event *models.ConnectionStateUpdatedEvent) {
handleConnection(event)
})
}
// Preset events
if filters == nil || filters["preset"] {
wsClient.OnPresetUpdated(func(event *models.PresetUpdatedEvent) {
handlePreset(event, verbose)
})
}
// Zone/Multiroom events
if filters == nil || filters["zone"] {
wsClient.OnZoneUpdated(func(event *models.ZoneUpdatedEvent) {
handleZone(event)
})
}
// Bass events
if filters == nil || filters["bass"] {
wsClient.OnBassUpdated(func(event *models.BassUpdatedEvent) {
handleBass(event)
})
}
// Unknown events (always enabled for debugging)
wsClient.OnUnknownEvent(func(event *models.WebSocketEvent) {
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))
}
})
}
func getFilterKeys(filters map[string]bool) []string {
var keys []string
for k := range filters {
keys = append(keys, k)
}
return keys
}
func printHelp() {
fmt.Println("SoundTouch WebSocket Event Monitor")
fmt.Println("==================================")
fmt.Println()
fmt.Println("This tool connects to a Bose SoundTouch device via WebSocket to monitor real-time events.")
fmt.Println()
fmt.Println("Usage:")
fmt.Printf(" %s [options]\n", os.Args[0])
fmt.Println()
fmt.Println("Options:")
fmt.Println(" -host string")
fmt.Println(" SoundTouch device host/IP address (can include port like host:8090)")
fmt.Println(" -port int")
fmt.Println(" SoundTouch device port (default: 8090)")
fmt.Println(" -timeout duration")
fmt.Println(" Request timeout (default: 10s)")
fmt.Println(" -discover")
fmt.Println(" Discover SoundTouch devices and connect to first found")
fmt.Println(" -duration duration")
fmt.Println(" How long to listen for events (0 = infinite)")
fmt.Println(" -reconnect")
fmt.Println(" Enable automatic reconnection (default: true)")
fmt.Println(" -verbose")
fmt.Println(" Enable verbose logging")
fmt.Println(" -filter string")
fmt.Println(" Filter events by type (comma-separated):")
fmt.Println(" nowPlaying, volume, connection, preset, zone, bass, sdkInfo, userActivity")
fmt.Println(" -help")
fmt.Println(" Show this help message")
fmt.Println()
fmt.Println("Examples:")
fmt.Println(" # Auto-discover and monitor all events")
fmt.Printf(" %s -discover\n", os.Args[0])
fmt.Println()
fmt.Println(" # Connect to specific device and monitor volume events only")
fmt.Printf(" %s -host 192.168.1.10 -filter volume\n", os.Args[0])
fmt.Println()
fmt.Println(" # Monitor for 5 minutes with verbose output")
fmt.Printf(" %s -host 192.168.1.10 -duration 5m -verbose\n", os.Args[0])
fmt.Println()
fmt.Println(" # Monitor now playing and volume events")
fmt.Printf(" %s -host 192.168.1.10 -filter nowPlaying,volume\n", os.Args[0])
fmt.Println()
fmt.Println("Event Types:")
fmt.Println(" 🎵 nowPlaying - Track changes, playback status")
fmt.Println(" 🔊 volume - Volume and mute changes")
fmt.Println(" 🌐 connection - Network connectivity status")
fmt.Println(" 📻 preset - Preset configuration changes")
fmt.Println(" 🏠 zone - Multiroom zone changes")
fmt.Println(" 🎚️ bass - Bass level changes")
fmt.Println(" 📡 sdkInfo - SDK version information")
fmt.Println(" 👤 userActivity - User interaction notifications")
fmt.Println()
fmt.Println("The tool will automatically reconnect if the connection is lost.")
fmt.Println("Press Ctrl+C to stop monitoring.")
}
// VerboseLogger provides detailed WebSocket logging
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...))
}
// SilentLogger provides no-op WebSocket logging
type SilentLogger struct{}
func (s *SilentLogger) Printf(_ string, _ ...interface{}) {
// Do nothing - silent logging
}