Files
Bose-SoundTouch/cmd/websocket-demo/main.go
T
Tobias GesellchenandClaude Sonnet 4.6 0e9445af47 fix(security): sec7 — log-injection sweep, sanitizeErr helper
~30 remaining go/log-injection alerts share a common pattern: other
positional args in a log call are wrapped in sanitizeLog() but the
trailing 'err' value (via "%v") is not. CodeQL traces taint through
error chains back to the log.Printf call site itself.

Add sanitizeErr(err error) string to every affected package's
logutil.go (strips newlines from err.Error(), returns "<nil>" when
nil). Three packages had no logutil.go yet; new files added for
cmd/soundtouch-cli, cmd/websocket-demo, and examples.

Call-site changes (replace "%v, err" with "%s, sanitizeErr(err)" and
wrap any other unsanitised args in sanitizeLog):

pkg/client:
  - websocket.go:42   DefaultLogger.Printf now pre-formats and sanitises
                       the entire message (all variadic args sanitised)
  - websocket.go:445  err → sanitizeErr(err)

pkg/service/handlers:
  - handlers_account_mgmt.go:44   err
  - handlers_bmx_tunein.go:324,336 err (stationID already sanitised)
  - handlers_marge.go:288,510      err (deviceID/account already done)
  - handlers_mgmt.go:409,436,720  err
  - handlers_setup.go:1345        session + err
  - server.go:500                  bind
  - server.go:504,863,944,1029,   err (deviceIP/accountID already done)
    1164,1174

pkg/service/marge:
  - marge.go:1469,1923  saveErr / err

pkg/service/setup:
  - setup.go:1417,2316,2462  fmt.Printf — deviceIP / hostsContent / ip

pkg/service/stockholm:
  - proxy.go:117  effectiveTarget.String() + err

pkg/service/zeroconf:
  - zeroconf.go:312  err

pkg/service/proxy:
  - recorder.go:403  err (task.path already sanitised)

pkg/service/datastore:
  - datastore.go:940  werr (device already sanitised)

pkg/discovery:
  - dns.go:72   strings.Join(derived)
  - dns.go:503  d.upstreamDNS (fmt.Sprint of []string)

cmd/soundtouch-cli:
  - cmd_events.go:571  VerboseLogger.Printf — pre-format + sanitise
  - common.go:335      PrintError message

cmd/websocket-demo:
  - main.go:576   VerboseLogger.Printf — pre-format + sanitise

examples:
  - recording-filename-demo.go:79  err

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 11:28:26 +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.0.2.10 -filter volume\n", os.Args[0])
fmt.Println()
fmt.Println(" # Monitor for 5 minutes with verbose output")
fmt.Printf(" %s -host 192.0.2.10 -duration 5m -verbose\n", os.Args[0])
fmt.Println()
fmt.Println(" # Monitor now playing and volume events")
fmt.Printf(" %s -host 192.0.2.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, sanitizeLog(fmt.Sprintf(format, args...)))
}
// SilentLogger provides no-op WebSocket logging
type SilentLogger struct{}
func (s *SilentLogger) Printf(_ string, _ ...interface{}) {
// Do nothing - silent logging
}