diff --git a/README.md b/README.md index 1336d7c..58d0d1f 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ go run ./cmd/websocket-demo -host 192.168.1.10 -filter volume,nowPlaying go run ./cmd/websocket-demo -host 192.168.1.10 -duration 5m -verbose # Available event types for filtering: -# nowPlaying, volume, connection, preset, zone, bass +# nowPlaying, volume, connection, preset, zone, bass, sdkInfo, userActivity ``` **Supported WebSocket Events:** @@ -155,6 +155,8 @@ go run ./cmd/websocket-demo -host 192.168.1.10 -duration 5m -verbose - šŸ“» **Preset**: Preset configuration updates - šŸ  **Zone**: Multiroom zone membership changes - šŸŽšļø **Bass**: Bass equalizer level adjustments +- šŸ“” **SDK Info**: Server version and build information (sent on connection) +- šŸ‘¤ **User Activity**: User interaction notifications See [docs/websocket-events.md](docs/websocket-events.md) for complete WebSocket documentation. diff --git a/cmd/websocket-demo/main.go b/cmd/websocket-demo/main.go index e1a7bc5..a54bb27 100644 --- a/cmd/websocket-demo/main.go +++ b/cmd/websocket-demo/main.go @@ -47,6 +47,7 @@ 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 == "" { @@ -59,7 +60,7 @@ func parseFilters(eventFilter string) map[string]bool { for _, f := range filterList { f = strings.TrimSpace(f) if !validFilters[f] { - fmt.Printf("Invalid filter '%s'. Valid filters: nowPlaying, volume, connection, preset, zone, bass\n", f) + fmt.Printf("Invalid filter '%s'. Valid filters: nowPlaying, volume, connection, preset, zone, bass, sdkInfo, userActivity\n", f) os.Exit(1) } @@ -116,6 +117,9 @@ func setupWebSocket(soundTouchClient *client.Client, reconnect, verbose bool) *c if verbose { wsConfig.Logger = &VerboseLogger{} + } else { + // Use a silent logger when not verbose + wsConfig.Logger = &SilentLogger{} } if !reconnect { @@ -187,6 +191,11 @@ func main() { // 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...") @@ -382,6 +391,43 @@ func handleBass(event *models.BassUpdatedEvent) { 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"] { @@ -477,7 +523,7 @@ func printHelp() { 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") + fmt.Println(" nowPlaying, volume, connection, preset, zone, bass, sdkInfo, userActivity") fmt.Println(" -help") fmt.Println(" Show this help message") fmt.Println() @@ -501,6 +547,8 @@ func printHelp() { 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.") @@ -513,3 +561,10 @@ 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 +} diff --git a/pkg/client/websocket.go b/pkg/client/websocket.go index b7c3656..160c00d 100644 --- a/pkg/client/websocket.go +++ b/pkg/client/websocket.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "net/url" + "strings" "sync" "time" @@ -153,6 +154,14 @@ func (ws *WebSocketClient) OnUnknownEvent(handler models.EventHandler) { ws.handlers.OnUnknownEvent = handler } +// OnSpecialMessage sets a handler for special (non-updates) messages +func (ws *WebSocketClient) OnSpecialMessage(handler models.SpecialMessageHandler) { + ws.mu.Lock() + defer ws.mu.Unlock() + + ws.handlers.OnSpecialMessage = handler +} + // Connect establishes a WebSocket connection to the SoundTouch device func (ws *WebSocketClient) Connect() error { return ws.connectWithConfig(DefaultWebSocketConfig()) @@ -172,19 +181,26 @@ func (ws *WebSocketClient) connectWithConfig(config *WebSocketConfig) error { } // Build WebSocket URL + // Parse the base URL to extract just the hostname + baseURL, err := url.Parse(ws.client.BaseURL()) + if err != nil { + return fmt.Errorf("failed to parse base URL: %w", err) + } + wsURL := url.URL{ Scheme: "ws", - Host: fmt.Sprintf("%s:%d", ws.client.Host(), 8080), // SoundTouch WebSocket port is typically 8080 + Host: fmt.Sprintf("%s:8080", baseURL.Hostname()), // SoundTouch WebSocket port is typically 8080 Path: "/", } ws.logger.Printf("Connecting to %s", wsURL.String()) - // Create dialer with custom buffer sizes + // Create dialer with custom buffer sizes and "gabbo" protocol dialer := websocket.Dialer{ HandshakeTimeout: 10 * time.Second, ReadBufferSize: config.ReadBufferSize, WriteBufferSize: config.WriteBufferSize, + Subprotocols: []string{"gabbo"}, // Required by SoundTouch API } // Establish connection @@ -357,6 +373,12 @@ func (ws *WebSocketClient) attemptReconnect(config *WebSocketConfig) { // handleMessage processes incoming WebSocket messages func (ws *WebSocketClient) handleMessage(data []byte) { + // Check if this is a SoundTouchSdkInfo or other non-updates message + if !ws.isUpdatesMessage(data) { + ws.handleSpecialMessage(data) + return + } + // Parse the WebSocket event event, err := models.ParseWebSocketEvent(data) if err != nil { @@ -368,6 +390,34 @@ func (ws *WebSocketClient) handleMessage(data []byte) { ws.handleEvent(event) } +// handleSpecialMessage processes special (non-updates) WebSocket messages +func (ws *WebSocketClient) handleSpecialMessage(data []byte) { + specialMessage, err := models.ParseSpecialMessage(data) + if err != nil { + ws.logger.Printf("Unknown special message type: %v", err) + ws.logger.Printf("Raw message: %s", string(data)) + + return + } + + // Call handler if set + ws.mu.RLock() + handler := ws.handlers.OnSpecialMessage + ws.mu.RUnlock() + + if handler != nil { + handler(specialMessage) + } +} + +// isUpdatesMessage checks if the message contains an element +func (ws *WebSocketClient) isUpdatesMessage(data []byte) bool { + // Simple check for element - this avoids full XML parsing + // for messages we want to ignore like + dataStr := string(data) + return strings.Contains(dataStr, " +type SpecialMessageType string + +// Constants for special message types +const ( + MessageTypeSdkInfo SpecialMessageType = "sdkInfo" + MessageTypeUserActivity SpecialMessageType = "userActivity" +) + +// SoundTouchSdkInfo represents the SDK info message sent on connection +type SoundTouchSdkInfo struct { + XMLName xml.Name `xml:"SoundTouchSdkInfo"` + ServerVersion string `xml:"serverVersion,attr"` + ServerBuild string `xml:"serverBuild,attr"` +} + +// UserActivityUpdate represents user activity notifications +type UserActivityUpdate struct { + XMLName xml.Name `xml:"userActivityUpdate"` + DeviceID string `xml:"deviceID,attr"` +} + +// SpecialMessage represents non-updates WebSocket messages +type SpecialMessage struct { + Type SpecialMessageType + DeviceID string + Data interface{} + RawData []byte + Timestamp time.Time +} + +// SpecialMessageHandler defines the signature for special message handlers +type SpecialMessageHandler func(message *SpecialMessage) + // EventHandler represents a function that handles WebSocket events type EventHandler func(event *WebSocketEvent) // TypedEventHandler represents a function that handles specific event types type TypedEventHandler[T any] func(event T) -// WebSocketEventHandlers holds typed event handlers for different event types +// WebSocketEventHandlers contains handlers for different types of WebSocket events type WebSocketEventHandlers struct { OnNowPlaying TypedEventHandler[*NowPlayingUpdatedEvent] OnVolumeUpdated TypedEventHandler[*VolumeUpdatedEvent] @@ -319,6 +354,7 @@ type WebSocketEventHandlers struct { OnRecentsUpdated TypedEventHandler[*RecentsUpdatedEvent] OnLanguageUpdated TypedEventHandler[*LanguageUpdatedEvent] OnUnknownEvent EventHandler + OnSpecialMessage SpecialMessageHandler } // ParseWebSocketEvent attempts to parse a WebSocket message into a specific event type @@ -521,16 +557,88 @@ func (e *WebSocketEvent) GetEventTypes() []WebSocketEventType { // String returns a human-readable string representation of the WebSocket event func (e *WebSocketEvent) String() string { - events := e.GetEvents() eventTypes := e.GetEventTypes() - - if len(events) == 0 { + if len(eventTypes) == 0 { return fmt.Sprintf("WebSocket Event [Device: %s] - No events", e.DeviceID) } - if len(events) == 1 { + if len(eventTypes) == 1 { return fmt.Sprintf("WebSocket Event [Device: %s] - %s", e.DeviceID, eventTypes[0].String()) } - return fmt.Sprintf("WebSocket Event [Device: %s] - %d events", e.DeviceID, len(events)) + return fmt.Sprintf("WebSocket Event [Device: %s] - %d events", e.DeviceID, len(eventTypes)) +} + +// ParseSpecialMessage parses non-updates WebSocket messages +func ParseSpecialMessage(data []byte) (*SpecialMessage, error) { + dataStr := string(data) + + // Check for SoundTouchSdkInfo + if strings.Contains(dataStr, "