Fix WebSocket connection issues and add special message parsing

- Fix WebSocket URL construction by properly extracting hostname from base URL
- Add 'gabbo' protocol requirement as specified in SoundTouch API docs
- Add parsing for SoundTouchSdkInfo and UserActivityUpdate messages
- Add proper filtering support for special message types (sdkInfo, userActivity)
- Fix nil pointer dereference by ensuring WebSocket client always has a logger
- Add SilentLogger for non-verbose mode to prevent crashes
- Update README and help text to include new special message types
- Clean up logging to only show unknown message types, not known special messages

Fixes the original WebSocket connection error:
'parse "ws://http:%2F%2F192.168.178.28:8090:8080/": invalid URL escape "%2F"'
This commit is contained in:
Tobias Gesellchen
2026-01-10 23:49:54 +01:00
parent c5a3911104
commit e5673103e0
4 changed files with 226 additions and 11 deletions
+3 -1
View File
@@ -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.
+57 -2
View File
@@ -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
}
+52 -2
View File
@@ -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 <updates> element
func (ws *WebSocketClient) isUpdatesMessage(data []byte) bool {
// Simple check for <updates> element - this avoids full XML parsing
// for messages we want to ignore like <SoundTouchSdkInfo>
dataStr := string(data)
return strings.Contains(dataStr, "<updates") && strings.Contains(dataStr, "deviceID=")
}
func (ws *WebSocketClient) dispatchTypedEvent(handlers *models.WebSocketEventHandlers, eventType models.WebSocketEventType, event *models.WebSocketEvent) bool {
switch eventType {
case models.EventTypeNowPlaying:
+114 -6
View File
@@ -3,6 +3,7 @@ package models
import (
"encoding/xml"
"fmt"
"strings"
"time"
)
@@ -298,13 +299,47 @@ type Language struct {
Value string `xml:",chardata"`
}
// SpecialMessageType represents message types that are not part of <updates>
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, "<SoundTouchSdkInfo") {
var sdkInfo SoundTouchSdkInfo
if err := xml.Unmarshal(data, &sdkInfo); err != nil {
return nil, fmt.Errorf("failed to parse SoundTouchSdkInfo: %w", err)
}
return &SpecialMessage{
Type: MessageTypeSdkInfo,
Data: &sdkInfo,
RawData: data,
Timestamp: time.Now(),
}, nil
}
// Check for userActivityUpdate
if strings.Contains(dataStr, "<userActivityUpdate") {
var userActivity UserActivityUpdate
if err := xml.Unmarshal(data, &userActivity); err != nil {
return nil, fmt.Errorf("failed to parse userActivityUpdate: %w", err)
}
return &SpecialMessage{
Type: MessageTypeUserActivity,
DeviceID: userActivity.DeviceID,
Data: &userActivity,
RawData: data,
Timestamp: time.Now(),
}, nil
}
return nil, fmt.Errorf("unknown special message type: %s", dataStr)
}
// GetSdkInfo returns the parsed SdkInfo data if the message is of that type
func (sm *SpecialMessage) GetSdkInfo() *SoundTouchSdkInfo {
if sm.Type == MessageTypeSdkInfo {
if sdkInfo, ok := sm.Data.(*SoundTouchSdkInfo); ok {
return sdkInfo
}
}
return nil
}
// GetUserActivity returns the parsed UserActivity data if the message is of that type
func (sm *SpecialMessage) GetUserActivity() *UserActivityUpdate {
if sm.Type == MessageTypeUserActivity {
if userActivity, ok := sm.Data.(*UserActivityUpdate); ok {
return userActivity
}
}
return nil
}
// String returns a string representation of the special message
func (sm *SpecialMessage) String() string {
switch sm.Type {
case MessageTypeSdkInfo:
if sdkInfo := sm.GetSdkInfo(); sdkInfo != nil {
return fmt.Sprintf("SoundTouch SDK Info - Version: %s, Build: %s", sdkInfo.ServerVersion, sdkInfo.ServerBuild)
}
case MessageTypeUserActivity:
return fmt.Sprintf("User Activity [Device: %s]", sm.DeviceID)
}
return fmt.Sprintf("Unknown Special Message - Type: %s", sm.Type)
}