package discovery import ( "context" "fmt" "log" "net" "strings" "sync" "time" "github.com/gesellix/bose-soundtouch/pkg/models" "github.com/hashicorp/mdns" ) // MDNSDiscoveryService handles mDNS/Bonjour discovery of SoundTouch devices type MDNSDiscoveryService struct { timeout time.Duration ifaceName string } // NewMDNSDiscoveryService creates a new mDNS discovery service func NewMDNSDiscoveryService(timeout time.Duration) *MDNSDiscoveryService { return NewMDNSDiscoveryServiceWithInterface(timeout, "") } // NewMDNSDiscoveryServiceWithInterface creates a new mDNS discovery service // pinned to the given network interface (e.g. "eth0"). An empty ifaceName // falls back to the historical auto-pick behaviour. func NewMDNSDiscoveryServiceWithInterface(timeout time.Duration, ifaceName string) *MDNSDiscoveryService { if timeout == 0 { timeout = defaultTimeout } return &MDNSDiscoveryService{ timeout: timeout, ifaceName: ifaceName, } } // DiscoverDevices discovers SoundTouch devices using mDNS func (m *MDNSDiscoveryService) DiscoverDevices(ctx context.Context) ([]*models.DiscoveredDevice, error) { // Initialize devices slice to ensure it's never nil devices := make([]*models.DiscoveredDevice, 0) // Create a channel to collect service entries entries := make(chan *mdns.ServiceEntry, 100) // Create a timeout context timeoutCtx, cancel := context.WithTimeout(ctx, m.timeout) defer cancel() // Fan out one query per SoundTouch service-type variant; mDNS has no // wildcard service-type query, so we issue them in parallel and merge // into a single entries channel. close(entries) only once all queries // are done (or the timeout fires). go func() { defer close(entries) log.Printf("mDNS: Starting discovery for %d service-type variant(s) with timeout %v", len(soundTouchServiceTypes), m.timeout) var wg sync.WaitGroup for _, service := range soundTouchServiceTypes { wg.Add(1) go func(service string) { defer wg.Done() m.queryService(service, entries) }(service) } wg.Wait() logVerbose("mDNS: All %d service-type queries finished", len(soundTouchServiceTypes)) }() // Collect discovered devices, deduplicating by host:port since a single // speaker may answer multiple service types (older firmware advertises // both `_soundtouch._tcp` and `_bose-soundtouch._tcp` simultaneously). seen := make(map[string]bool) for { select { case <-timeoutCtx.Done(): // Timeout reached, return what we have return devices, nil case entry, ok := <-entries: if !ok { // Channel closed, return collected devices log.Printf("mDNS discovery finished. Found %d devices total.", len(devices)) return devices, nil } logVerbose("mDNS: Received service entry: Name='%s', Host='%s', Port=%d, AddrV4=%v, AddrV6=%v", entry.Name, entry.Host, entry.Port, entry.AddrV4, entry.AddrV6) // Only process SoundTouch-family services. if !isSoundTouchServiceName(entry.Name) { logVerbose("mDNS: Skipping non-SoundTouch service: %s", entry.Name) continue } device := m.serviceEntryToDevice(entry) if device == nil { log.Printf("mDNS: Failed to convert service entry to device (no valid IP address)") continue } key := fmt.Sprintf("%s:%d", device.Host, device.Port) if seen[key] { logVerbose("mDNS: Skipping duplicate device %s (already seen via another service-type query)", key) continue } seen[key] = true logVerbose("mDNS: Successfully converted to device: %s at %s:%d", device.Name, device.Host, device.Port) devices = append(devices, device) } } } // queryService issues a single mDNS Query for the given service type // against the IPv4 interface first, with a graceful fallback to the // library's default (IPv4+IPv6) behaviour if the IPv4-only path fails. // All results stream into the shared entries channel; the caller is // responsible for fan-in deduplication. func (m *MDNSDiscoveryService) queryService(service string, entries chan<- *mdns.ServiceEntry) { logVerbose("mDNS: Query '%s.%s' starting", service, soundTouchDomain) err := mdns.Query(&mdns.QueryParam{ Service: service, Domain: "local.", Timeout: m.timeout, Entries: entries, DisableIPv6: true, Interface: m.getIPv4Interface(), }) if err == nil { logVerbose("mDNS: Query '%s' (IPv4) completed successfully", service) return } log.Printf("mDNS: Query '%s' (IPv4) failed: %v — falling back to dual-stack", sanitizeLog(service), err) err = mdns.Query(&mdns.QueryParam{ Service: service, Domain: "local.", Timeout: m.timeout, Entries: entries, }) if err != nil { log.Printf("mDNS: Query '%s' (dual-stack) failed: %v", sanitizeLog(service), err) } else { logVerbose("mDNS: Query '%s' (dual-stack) completed successfully", service) } } // serviceEntryToDevice converts an mdns ServiceEntry to a DiscoveredDevice func (m *MDNSDiscoveryService) serviceEntryToDevice(entry *mdns.ServiceEntry) *models.DiscoveredDevice { if entry == nil { logVerbose("mDNS: Received nil service entry") return nil } // Get the IP address - prefer IPv4 var ( host string ipSource string ) switch { case entry.AddrV4 != nil: host = entry.AddrV4.String() ipSource = "IPv4" logVerbose("mDNS: Using IPv4 address: %s", host) case entry.AddrV6 != nil: host = entry.AddrV6.String() ipSource = "IPv6" logVerbose("mDNS: Using IPv6 address: %s", host) default: // Try to resolve from hostname logVerbose("mDNS: No direct IP address, trying to resolve hostname: %s", entry.Host) ips, err := net.LookupIP(entry.Host) if err != nil || len(ips) == 0 { log.Printf("mDNS: Failed to resolve hostname '%s': %v", sanitizeLog(entry.Host), err) return nil } // Prefer IPv4 for _, ip := range ips { if ip.To4() != nil { host = ip.String() ipSource = "resolved IPv4" logVerbose("mDNS: Resolved to IPv4 address: %s", host) break } } // If no IPv4 found, use first available if host == "" { host = ips[0].String() if ips[0].To4() != nil { ipSource = "resolved IPv4 (fallback)" } else { ipSource = "resolved IPv6 (fallback)" } logVerbose("mDNS: Using fallback address (%s): %s", ipSource, host) } } if host == "" { log.Printf("mDNS: No usable IP address found for entry") return nil } port := entry.Port // Default to port 8090 if port is 0 or invalid if port == 0 { port = 8090 } // Extract device name from instance name or use a default name := entry.Name if name == "" { name = fmt.Sprintf("SoundTouch-%s", host) } // Clean up the name by removing the service type suffix if strings.HasSuffix(name, "."+soundTouchServiceType+"."+soundTouchDomain) { name = strings.TrimSuffix(name, "."+soundTouchServiceType+"."+soundTouchDomain) } // Unescape any escaped characters in the name (common in mDNS) name = strings.ReplaceAll(name, `\ `, " ") name = strings.ReplaceAll(name, `\.`, ".") name = strings.ReplaceAll(name, `\\`, `\`) device := &models.DiscoveredDevice{ Host: host, Port: port, Name: name, LastSeen: time.Now(), DiscoveryMethod: "mDNS/Bonjour", APIBaseURL: fmt.Sprintf("http://%s:%d/", host, port), InfoURL: fmt.Sprintf("http://%s:%d/info", host, port), MDNSHostname: entry.Host, MDNSService: entry.Name, } logVerbose("mDNS: Created device '%s' at %s:%d (IP source: %s)", name, host, port, ipSource) return device } // getIPv4Interface returns the network interface to use for mDNS queries. // If an explicit name was configured, it is resolved and validated; otherwise // the first suitable, up, non-loopback IPv4 interface is returned. func (m *MDNSDiscoveryService) getIPv4Interface() *net.Interface { if m.ifaceName != "" { iface, err := net.InterfaceByName(m.ifaceName) if err != nil { log.Printf("mDNS: Configured interface %q not found: %v", sanitizeLog(m.ifaceName), err) return nil } if !interfaceHasIPv4(iface) { log.Printf("mDNS: Configured interface %q has no usable IPv4 address", sanitizeLog(m.ifaceName)) return nil } logVerbose("mDNS: Using configured IPv4 interface: %s", iface.Name) return iface } interfaces, err := net.Interfaces() if err != nil { log.Printf("mDNS: Failed to get network interfaces: %v", err) return nil } for _, iface := range interfaces { // Skip loopback, down interfaces, and point-to-point interfaces if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagPointToPoint != 0 { continue } if !interfaceHasIPv4(&iface) { continue } logVerbose("mDNS: Using IPv4 interface: %s", iface.Name) return &iface } logVerbose("mDNS: No suitable IPv4 interface found") return nil } // interfaceHasIPv4 reports whether iface has at least one non-loopback IPv4 // address assigned and is administratively up. func interfaceHasIPv4(iface *net.Interface) bool { if iface.Flags&net.FlagUp == 0 { return false } addrs, err := iface.Addrs() if err != nil { return false } for _, addr := range addrs { ipNet, ok := addr.(*net.IPNet) if !ok { continue } if ipNet.IP.To4() != nil && !ipNet.IP.IsLoopback() { return true } } return false }