diff --git a/cmd/mdns-scanner/main.go b/cmd/mdns-scanner/main.go index 1995429..08a0d80 100644 --- a/cmd/mdns-scanner/main.go +++ b/cmd/mdns-scanner/main.go @@ -14,6 +14,72 @@ import ( "github.com/hashicorp/mdns" ) +func displayResults(services []ServiceInfo) { + if len(services) == 0 { + fmt.Println("No services found.") + fmt.Println() + fmt.Println("This could mean:") + fmt.Println("- No mDNS services on network") + fmt.Println("- Network blocks multicast traffic") + fmt.Println("- Firewall blocks mDNS port 5353") + fmt.Println("- Try different service types or increase timeout") + + return + } + + // Group services by type for better display + serviceGroups := make(map[string][]ServiceInfo) + for _, s := range services { + serviceGroups[s.ServiceType] = append(serviceGroups[s.ServiceType], s) + } + + // Display grouped services + for serviceType, serviceList := range serviceGroups { + fmt.Printf("Service Type: %s\n", serviceType) + fmt.Printf(" Found %d instance(s):\n", len(serviceList)) + + for i, s := range serviceList { + fmt.Printf(" %d. %s\n", i+1, s.Name) + + if s.Host != "" { + fmt.Printf(" Host: %s\n", s.Host) + } + + if s.IPv4 != "" { + fmt.Printf(" IPv4: %s\n", s.IPv4) + } + + if s.IPv6 != "" { + fmt.Printf(" IPv6: %s\n", s.IPv6) + } + + if s.Port > 0 { + fmt.Printf(" Port: %d\n", s.Port) + } + + if len(s.TxtRecords) > 0 { + fmt.Printf(" TXT Records: %v\n", s.TxtRecords) + } + } + + fmt.Println() + } +} + +func showSuggestions(service string) { + if service == "_services._dns-sd._udp" { + fmt.Println("Common services to look for SoundTouch devices:") + fmt.Println("- _soundtouch._tcp.local.") + fmt.Println("- _http._tcp.local.") + fmt.Println("- _upnp._tcp.local.") + fmt.Println("- _device-info._tcp.local.") + fmt.Println() + fmt.Println("Try scanning specific services:") + fmt.Println(" ./mdns-scanner -service _soundtouch._tcp -v") + fmt.Println(" ./mdns-scanner -service _http._tcp -v") + } +} + func main() { verbose := flag.Bool("verbose", false, "Enable verbose logging") timeout := flag.Duration("timeout", 10*time.Second, "Discovery timeout") @@ -107,68 +173,10 @@ done: }) // Display results - if len(services) == 0 { - fmt.Println("No services found.") - fmt.Println() - fmt.Println("This could mean:") - fmt.Println("- No mDNS services on network") - fmt.Println("- Network blocks multicast traffic") - fmt.Println("- Firewall blocks mDNS port 5353") - fmt.Println("- Try different service types or increase timeout") - } else { - // Group services by type for better display - serviceGroups := make(map[string][]ServiceInfo) - - for _, service := range services { - serviceType := service.ServiceType - serviceGroups[serviceType] = append(serviceGroups[serviceType], service) - } - - // Display grouped services - for serviceType, serviceList := range serviceGroups { - fmt.Printf("Service Type: %s\n", serviceType) - fmt.Printf(" Found %d instance(s):\n", len(serviceList)) - - for i, service := range serviceList { - fmt.Printf(" %d. %s\n", i+1, service.Name) - - if service.Host != "" { - fmt.Printf(" Host: %s\n", service.Host) - } - - if service.IPv4 != "" { - fmt.Printf(" IPv4: %s\n", service.IPv4) - } - - if service.IPv6 != "" { - fmt.Printf(" IPv6: %s\n", service.IPv6) - } - - if service.Port > 0 { - fmt.Printf(" Port: %d\n", service.Port) - } - - if len(service.TxtRecords) > 0 { - fmt.Printf(" TXT Records: %v\n", service.TxtRecords) - } - } - - fmt.Println() - } - } + displayResults(services) // Show suggestions for common SoundTouch-related services - if *service == "_services._dns-sd._udp" { - fmt.Println("Common services to look for SoundTouch devices:") - fmt.Println("- _soundtouch._tcp.local.") - fmt.Println("- _http._tcp.local.") - fmt.Println("- _upnp._tcp.local.") - fmt.Println("- _device-info._tcp.local.") - fmt.Println() - fmt.Println("Try scanning specific services:") - fmt.Println(" ./mdns-scanner -service _soundtouch._tcp -v") - fmt.Println(" ./mdns-scanner -service _http._tcp -v") - } + showSuggestions(*service) } type ServiceInfo struct { diff --git a/cmd/soundtouch-cli/cmd_network.go b/cmd/soundtouch-cli/cmd_network.go index d2b4178..cf9ae01 100644 --- a/cmd/soundtouch-cli/cmd_network.go +++ b/cmd/soundtouch-cli/cmd_network.go @@ -3,9 +3,47 @@ package main import ( "fmt" + "github.com/gesellix/bose-soundtouch/pkg/models" "github.com/urfave/cli/v2" ) +func printNetworkInterface(i int, iface *models.NetworkInterface) { + fmt.Printf("\n Interface %d:\n", i+1) + fmt.Printf(" Type: %s\n", iface.GetType()) + + if iface.GetName() != "" { + fmt.Printf(" Name: %s\n", iface.GetName()) + } + + if iface.GetIPAddress() != "" { + fmt.Printf(" IP Address: %s\n", iface.GetIPAddress()) + } + + if iface.GetMacAddress() != "" { + fmt.Printf(" MAC Address: %s\n", iface.GetMacAddress()) + } + + fmt.Printf(" State: %s\n", iface.GetStateDescription()) + + if iface.IsWiFi() { + if iface.GetSSID() != "" { + fmt.Printf(" SSID: %s\n", iface.GetSSID()) + } + + if iface.GetSignal() != "" { + fmt.Printf(" Signal: %s (%d%%)\n", iface.GetSignalDescription(), iface.GetSignalQuality()) + } + + if iface.GetFrequencyKHz() > 0 { + fmt.Printf(" Frequency: %s (%s)\n", iface.FormatFrequency(), iface.GetFrequencyBand()) + } + + if iface.GetMode() != "" { + fmt.Printf(" Mode: %s\n", iface.GetModeDescription()) + } + } +} + // getNetworkInfo retrieves network information from the device func getNetworkInfo(c *cli.Context) error { clientConfig := GetClientConfig(c) @@ -38,41 +76,7 @@ func getNetworkInfo(c *cli.Context) error { fmt.Printf(" Interfaces (%d):\n", len(interfaces)) for i := range interfaces { - iface := &interfaces[i] - fmt.Printf("\n Interface %d:\n", i+1) - fmt.Printf(" Type: %s\n", iface.GetType()) - - if iface.GetName() != "" { - fmt.Printf(" Name: %s\n", iface.GetName()) - } - - if iface.GetIPAddress() != "" { - fmt.Printf(" IP Address: %s\n", iface.GetIPAddress()) - } - - if iface.GetMacAddress() != "" { - fmt.Printf(" MAC Address: %s\n", iface.GetMacAddress()) - } - - fmt.Printf(" State: %s\n", iface.GetStateDescription()) - - if iface.IsWiFi() { - if iface.GetSSID() != "" { - fmt.Printf(" SSID: %s\n", iface.GetSSID()) - } - - if iface.GetSignal() != "" { - fmt.Printf(" Signal: %s (%d%%)\n", iface.GetSignalDescription(), iface.GetSignalQuality()) - } - - if iface.GetFrequencyKHz() > 0 { - fmt.Printf(" Frequency: %s (%s)\n", iface.FormatFrequency(), iface.GetFrequencyBand()) - } - - if iface.GetMode() != "" { - fmt.Printf(" Mode: %s\n", iface.GetModeDescription()) - } - } + printNetworkInterface(i, &interfaces[i]) } // Show active connections summary diff --git a/cmd/soundtouch-cli/cmd_source.go b/cmd/soundtouch-cli/cmd_source.go index b5e7181..bd3c1cd 100644 --- a/cmd/soundtouch-cli/cmd_source.go +++ b/cmd/soundtouch-cli/cmd_source.go @@ -4,9 +4,30 @@ import ( "fmt" "strings" + "github.com/gesellix/bose-soundtouch/pkg/models" "github.com/urfave/cli/v2" ) +func printSource(source models.SourceItem) { + fmt.Printf(" • %s", source.GetDisplayName()) + + if source.SourceAccount != "" && source.SourceAccount != source.Source { + fmt.Printf(" (%s)", source.SourceAccount) + } + + var attributes []string + if source.IsLocalSource() { + attributes = append(attributes, "Local") + attributes = append(attributes, "Available") + } + + if len(attributes) > 0 { + fmt.Printf(" [%s]", strings.Join(attributes, ", ")) + } + + fmt.Println() +} + // listSources handles listing available audio sources func listSources(c *cli.Context) error { clientConfig := GetClientConfig(c) @@ -32,26 +53,7 @@ func listSources(c *cli.Context) error { fmt.Printf(" Ready Sources:\n") for _, source := range availableSources { - fmt.Printf(" • %s", source.GetDisplayName()) - - if source.SourceAccount != "" && source.SourceAccount != source.Source { - fmt.Printf(" (%s)", source.SourceAccount) - } - - var attributes []string - if source.IsLocalSource() { - attributes = append(attributes, "Local") - } - - if source.IsLocalSource() { - attributes = append(attributes, "Available") - } - - if len(attributes) > 0 { - fmt.Printf(" [%s]", strings.Join(attributes, ", ")) - } - - fmt.Println() + printSource(source) } } diff --git a/cmd/websocket-demo/main.go b/cmd/websocket-demo/main.go index c5c7ed4..e1a7bc5 100644 --- a/cmd/websocket-demo/main.go +++ b/cmd/websocket-demo/main.go @@ -43,6 +43,88 @@ func parseHostPort(hostPort string, defaultPort int) (string, int) { 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, + } + + 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\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{} + } + + 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)") @@ -64,69 +146,13 @@ func main() { } // Validate filter if provided - validFilters := map[string]bool{ - "nowPlaying": true, "volume": true, "connection": true, - "preset": true, "zone": true, "bass": true, - } - - var filters map[string]bool - if *eventFilter != "" { - 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\n", f) - os.Exit(1) - } - - filters[f] = true - } - } - - var ( - deviceHost string - devicePort int - ) + filters := parseFilters(*eventFilter) // Discover devices if no host specified or discover flag used - - if *host == "" || *discover { - fmt.Println("Discovering SoundTouch devices...") - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - // Create unified discovery service - cfg := &config.Config{ - DiscoveryTimeout: 10 * time.Second, - CacheEnabled: false, - } - discoveryService := discovery.NewUnifiedDiscoveryService(cfg) - - devices, err := discoveryService.DiscoverDevices(ctx) - if err != nil { - fmt.Printf("Discovery failed: %v\n", err) - return - } - - if len(devices) == 0 { - fmt.Println("No SoundTouch devices found") - return - } - - // Use first discovered device - device := devices[0] - deviceHost = device.Host - devicePort = device.Port - - fmt.Printf("Found %d device(s), connecting to: %s (%s:%d)\n", - len(devices), device.Name, device.Host, device.Port) - } else { - // Parse provided host - deviceHost, devicePort = parseHostPort(*host, *port) - fmt.Printf("Connecting to: %s:%d\n", deviceHost, devicePort) + deviceHost, devicePort, err := discoverDevice(*discover, *host, *port) + if err != nil { + fmt.Printf("Error: %v\n", err) + return } // Create client @@ -156,24 +182,7 @@ func main() { deviceInfo.Name, deviceInfo.Type, macAddress) // Create WebSocket client - 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{} - } - - if !*reconnect { - wsConfig.MaxReconnectAttempts = 1 - } - - wsClient := soundTouchClient.NewWebSocketClient(wsConfig) + wsClient := setupWebSocket(soundTouchClient, *reconnect, *verbose) // Set up event handlers setupEventHandlers(wsClient, filters, *verbose) @@ -181,9 +190,10 @@ func main() { // Connect to WebSocket fmt.Println("Connecting to WebSocket...") - err = wsClient.ConnectWithConfig(wsConfig) + err = wsClient.Connect() if err != nil { fmt.Printf("Failed to connect to WebSocket: %v\n", err) + return } @@ -243,150 +253,175 @@ func main() { 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) { + preset := &event.Preset + fmt.Printf("\nšŸ“» Preset Update [%s]:\n", event.DeviceID) + fmt.Printf(" šŸ“» Preset: %d\n", preset.ID) + + if preset.ContentItem != nil { + fmt.Printf(" šŸŽµ %s\n", preset.ContentItem.ItemName) + fmt.Printf(" šŸ“» Source: %s\n", preset.ContentItem.Source) + } + + if verbose { + fmt.Printf(" šŸ“± Raw preset data: ID=%d\n", preset.ID) + } +} + +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 setupEventHandlers(wsClient *client.WebSocketClient, filters map[string]bool, verbose bool) { // Now Playing events if filters == nil || filters["nowPlaying"] { wsClient.OnNowPlaying(func(event *models.NowPlayingUpdatedEvent) { - fmt.Printf("\nšŸŽµ Now Playing Update [%s]:\n", event.DeviceID) - np := &event.NowPlaying - - if np.IsEmpty() { - fmt.Println(" ā¹ļø Nothing playing") - } else { - 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) - } - } + handleNowPlaying(event, verbose) }) } // Volume events if filters == nil || filters["volume"] { wsClient.OnVolumeUpdated(func(event *models.VolumeUpdatedEvent) { - 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()) - } + handleVolume(event, verbose) }) } // Connection state events if filters == nil || filters["connection"] { wsClient.OnConnectionState(func(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()) - } + handleConnection(event) }) } // Preset events if filters == nil || filters["preset"] { wsClient.OnPresetUpdated(func(event *models.PresetUpdatedEvent) { - preset := &event.Preset - fmt.Printf("\nšŸ“» Preset Update [%s]:\n", event.DeviceID) - fmt.Printf(" šŸ“» Preset: %d\n", preset.ID) - - if preset.ContentItem != nil { - fmt.Printf(" šŸŽµ %s\n", preset.ContentItem.ItemName) - fmt.Printf(" šŸ“» Source: %s\n", preset.ContentItem.Source) - } - - if verbose { - fmt.Printf(" šŸ“± Raw preset data: ID=%d\n", preset.ID) - } + handlePreset(event, verbose) }) } // Zone/Multiroom events if filters == nil || filters["zone"] { wsClient.OnZoneUpdated(func(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)") - } + handleZone(event) }) } // Bass events if filters == nil || filters["bass"] { wsClient.OnBassUpdated(func(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) + handleBass(event) }) } diff --git a/pkg/client/websocket.go b/pkg/client/websocket.go index b5340ed..b7c3656 100644 --- a/pkg/client/websocket.go +++ b/pkg/client/websocket.go @@ -368,6 +368,68 @@ func (ws *WebSocketClient) handleMessage(data []byte) { ws.handleEvent(event) } +func (ws *WebSocketClient) dispatchTypedEvent(handlers *models.WebSocketEventHandlers, eventType models.WebSocketEventType, event *models.WebSocketEvent) bool { + switch eventType { + case models.EventTypeNowPlaying: + if handlers.OnNowPlaying != nil && event.NowPlayingUpdated != nil { + handlers.OnNowPlaying(event.NowPlayingUpdated) + } + + return true + + case models.EventTypeVolumeUpdated: + if handlers.OnVolumeUpdated != nil && event.VolumeUpdated != nil { + handlers.OnVolumeUpdated(event.VolumeUpdated) + } + + return true + + case models.EventTypeConnectionState: + if handlers.OnConnectionState != nil && event.ConnectionStateUpdated != nil { + handlers.OnConnectionState(event.ConnectionStateUpdated) + } + + return true + + case models.EventTypePresetUpdated: + if handlers.OnPresetUpdated != nil && event.PresetUpdated != nil { + handlers.OnPresetUpdated(event.PresetUpdated) + } + + return true + + default: + return ws.dispatchTypedEventContinued(handlers, eventType, event) + } +} + +func (ws *WebSocketClient) dispatchTypedEventContinued(handlers *models.WebSocketEventHandlers, eventType models.WebSocketEventType, event *models.WebSocketEvent) bool { + switch eventType { + case models.EventTypeZoneUpdated: + if handlers.OnZoneUpdated != nil && event.ZoneUpdated != nil { + handlers.OnZoneUpdated(event.ZoneUpdated) + } + + return true + + case models.EventTypeBassUpdated: + if handlers.OnBassUpdated != nil && event.BassUpdated != nil { + handlers.OnBassUpdated(event.BassUpdated) + } + + return true + + case models.EventTypeRecentsUpdated: + return true + + case models.EventTypeLanguageUpdated: + return true + + default: + return false + } +} + // handleEvent dispatches events to appropriate handlers func (ws *WebSocketClient) handleEvent(event *models.WebSocketEvent) { ws.mu.RLock() @@ -378,41 +440,8 @@ func (ws *WebSocketClient) handleEvent(event *models.WebSocketEvent) { hasKnownEvent := false for _, eventType := range eventTypes { - hasKnownEvent = true - - switch eventType { - case models.EventTypeNowPlaying: - if handlers.OnNowPlaying != nil && event.NowPlayingUpdated != nil { - handlers.OnNowPlaying(event.NowPlayingUpdated) - } - - case models.EventTypeVolumeUpdated: - if handlers.OnVolumeUpdated != nil && event.VolumeUpdated != nil { - handlers.OnVolumeUpdated(event.VolumeUpdated) - } - - case models.EventTypeConnectionState: - if handlers.OnConnectionState != nil && event.ConnectionStateUpdated != nil { - handlers.OnConnectionState(event.ConnectionStateUpdated) - } - - case models.EventTypePresetUpdated: - if handlers.OnPresetUpdated != nil && event.PresetUpdated != nil { - handlers.OnPresetUpdated(event.PresetUpdated) - } - - case models.EventTypeZoneUpdated: - if handlers.OnZoneUpdated != nil && event.ZoneUpdated != nil { - handlers.OnZoneUpdated(event.ZoneUpdated) - } - - case models.EventTypeBassUpdated: - if handlers.OnBassUpdated != nil && event.BassUpdated != nil { - handlers.OnBassUpdated(event.BassUpdated) - } - - default: - hasKnownEvent = false + if ws.dispatchTypedEvent(handlers, eventType, event) { + hasKnownEvent = true } } diff --git a/pkg/models/balance_marshal_test.go b/pkg/models/balance_marshal_test.go index a469965..6eaef83 100644 --- a/pkg/models/balance_marshal_test.go +++ b/pkg/models/balance_marshal_test.go @@ -22,7 +22,9 @@ func TestBalanceMarshalXML(t *testing.T) { t.Fatalf("MarshalXML failed: %v", err) } - encoder.Flush() + if err := encoder.Flush(); err != nil { + t.Fatalf("Flush failed: %v", err) + } // Convert to string for easier testing xmlStr := buf.String() @@ -57,10 +59,11 @@ func TestBalanceMarshalXML_PositiveValue(t *testing.T) { t.Fatalf("MarshalXML failed: %v", err) } - encoder.Flush() + if err := encoder.Flush(); err != nil { + t.Fatalf("Flush failed: %v", err) + } xmlStr := buf.String() - expectedElements := []string{ `deviceID="ABCDEF123456"`, `30`, @@ -90,10 +93,11 @@ func TestBalanceMarshalXML_ZeroValue(t *testing.T) { t.Fatalf("MarshalXML failed: %v", err) } - encoder.Flush() + if err := encoder.Flush(); err != nil { + t.Fatalf("Flush failed: %v", err) + } xmlStr := buf.String() - expectedElements := []string{ `deviceID="ZERO0000TEST"`, `0`, @@ -123,10 +127,11 @@ func TestBalanceMarshalXML_ExtremeValues(t *testing.T) { t.Fatalf("MarshalXML failed: %v", err) } - encoder.Flush() + if err := encoder.Flush(); err != nil { + t.Fatalf("Flush failed: %v", err) + } xmlStr := buf.String() - expectedElements := []string{ `deviceID="EXTREME_TEST"`, `-50`, diff --git a/pkg/models/bass_utilities_test.go b/pkg/models/bass_utilities_test.go index 9dbd493..49c3a20 100644 --- a/pkg/models/bass_utilities_test.go +++ b/pkg/models/bass_utilities_test.go @@ -230,9 +230,10 @@ func TestBassMarshalXML(t *testing.T) { t.Fatalf("MarshalXML failed: %v", err) } - encoder.Flush() + if err := encoder.Flush(); err != nil { + t.Fatalf("Flush failed: %v", err) + } - // Convert to string for easier testing xmlStr := buf.String() // Check that XML contains expected elements diff --git a/pkg/models/websocket.go b/pkg/models/websocket.go index 576c1b5..2987742 100644 --- a/pkg/models/websocket.go +++ b/pkg/models/websocket.go @@ -334,83 +334,98 @@ func ParseWebSocketEvent(data []byte) (*WebSocketEvent, error) { return &event, nil } +func (e *WebSocketEvent) getFieldByEventType(eventType WebSocketEventType) interface{} { + var field interface{} + + switch eventType { + case EventTypeNowPlaying: + field = e.NowPlayingUpdated + case EventTypeVolumeUpdated: + field = e.VolumeUpdated + case EventTypeConnectionState: + field = e.ConnectionStateUpdated + case EventTypePresetUpdated: + field = e.PresetUpdated + case EventTypeZoneUpdated: + field = e.ZoneUpdated + case EventTypeBassUpdated: + field = e.BassUpdated + case EventTypeClockTimeUpdated: + field = e.ClockTimeUpdated + case EventTypeClockDisplayUpdated: + field = e.ClockDisplayUpdated + case EventTypeNameUpdated: + field = e.NameUpdated + case EventTypeErrorUpdated: + field = e.ErrorUpdated + case EventTypeRecentsUpdated: + field = e.RecentsUpdated + case EventTypeLanguageUpdated: + field = e.LanguageUpdated + } + + // Use reflection or a type-safe check to ensure we only return non-nil interfaces + // In Go, an interface is nil only if both its type and value are nil. + // If e.NowPlayingUpdated is a nil pointer, field will be a non-nil interface containing a nil pointer. + // We need to return a literal nil if the field is empty to satisfy expectations. + + if field == nil { + return nil + } + + // We know all these fields are pointers. + // We can't easily check for nil pointer without reflection here in a generic way, + // but we can restore the previous logic in a more compact way if needed. + // Actually, the previous logic was: if e.NowPlayingUpdated != nil { return e.NowPlayingUpdated } + // which returns a non-nil interface. + + return field +} + +// isNil checks if an interface is nil or contains a nil pointer. +func isNil(i interface{}) bool { + if i == nil { + return true + } + + switch v := i.(type) { + case *NowPlayingUpdatedEvent: + return v == nil + case *VolumeUpdatedEvent: + return v == nil + case *ConnectionStateUpdatedEvent: + return v == nil + case *PresetUpdatedEvent: + return v == nil + case *ZoneUpdatedEvent: + return v == nil + case *BassUpdatedEvent: + return v == nil + case *ClockTimeUpdatedEvent: + return v == nil + case *ClockDisplayUpdatedEvent: + return v == nil + case *NameUpdatedEvent: + return v == nil + case *ErrorUpdatedEvent: + return v == nil + case *RecentsUpdatedEvent: + return v == nil + case *LanguageUpdatedEvent: + return v == nil + } + + return false +} + // ParseTypedEvent attempts to parse a WebSocket event into a specific typed event func ParseTypedEvent[T any](event *WebSocketEvent, eventType WebSocketEventType) (T, error) { var result T - // Get the event directly from the parsed structure - switch eventType { - case EventTypeNowPlaying: - if event.NowPlayingUpdated != nil { - if typedResult, ok := interface{}(event.NowPlayingUpdated).(T); ok { - return typedResult, nil - } - } - case EventTypeVolumeUpdated: - if event.VolumeUpdated != nil { - if typedResult, ok := interface{}(event.VolumeUpdated).(T); ok { - return typedResult, nil - } - } - case EventTypeConnectionState: - if event.ConnectionStateUpdated != nil { - if typedResult, ok := interface{}(event.ConnectionStateUpdated).(T); ok { - return typedResult, nil - } - } - case EventTypePresetUpdated: - if event.PresetUpdated != nil { - if typedResult, ok := interface{}(event.PresetUpdated).(T); ok { - return typedResult, nil - } - } - case EventTypeZoneUpdated: - if event.ZoneUpdated != nil { - if typedResult, ok := interface{}(event.ZoneUpdated).(T); ok { - return typedResult, nil - } - } - case EventTypeBassUpdated: - if event.BassUpdated != nil { - if typedResult, ok := interface{}(event.BassUpdated).(T); ok { - return typedResult, nil - } - } - case EventTypeClockTimeUpdated: - if event.ClockTimeUpdated != nil { - if typedResult, ok := interface{}(event.ClockTimeUpdated).(T); ok { - return typedResult, nil - } - } - case EventTypeClockDisplayUpdated: - if event.ClockDisplayUpdated != nil { - if typedResult, ok := interface{}(event.ClockDisplayUpdated).(T); ok { - return typedResult, nil - } - } - case EventTypeNameUpdated: - if event.NameUpdated != nil { - if typedResult, ok := interface{}(event.NameUpdated).(T); ok { - return typedResult, nil - } - } - case EventTypeErrorUpdated: - if event.ErrorUpdated != nil { - if typedResult, ok := interface{}(event.ErrorUpdated).(T); ok { - return typedResult, nil - } - } - case EventTypeRecentsUpdated: - if event.RecentsUpdated != nil { - if typedResult, ok := interface{}(event.RecentsUpdated).(T); ok { - return typedResult, nil - } - } - case EventTypeLanguageUpdated: - if event.LanguageUpdated != nil { - if typedResult, ok := interface{}(event.LanguageUpdated).(T); ok { - return typedResult, nil - } + field := event.getFieldByEventType(eventType) + if !isNil(field) { + if typedResult, ok := field.(T); ok { + return typedResult, nil } }