Files
Bose-SoundTouch/pkg/discovery/mdns.go
T
Tobias GesellchenandClaude Sonnet 4.6 1cd4226f5b feat: tighter discovery filter + UX cleanups (#269, #345, #355, #359)
Four small, independent improvements bundled into one cut:

1. Restrict device discovery to SoundTouch-family services (#269/#359).
   - mDNS now queries all three SoundTouch service-type variants in
     parallel (_soundtouch._tcp, _bose-soundtouch._tcp, _soundtouchstick._tcp)
     and deduplicates results by host:port. mDNS has no native wildcard
     for service types, so we fan out one query per variant.
   - UPnP/SSDP M-SEARCH receives a manufacturer/modelName check after
     fetching the device description: devices whose manufacturer doesn't
     contain "bose" AND whose model doesn't contain "soundtouch" are
     rejected. Closes the loop on NorbertBauer's diagnostic bundle that
     showed a Dreambox dm920 and Onkyo HT-R695 living under the default
     account because they answered our generic MediaRenderer:1 probe.

2. New health check: default-account-contains-non-Bose-devices (#269).
   Walks devices keyed under data/accounts/default/devices/, flags any
   whose ProductCode/Name doesn't look SoundTouch, and offers an Evict
   QuickFix. Bose devices still in default (legitimate pre-pair) are
   intentionally ignored — that's the consistency check's domain.

3. Clipboard fallback for Copy buttons (#355). The two health-tab Copy
   buttons used navigator.clipboard.writeText, which requires a secure
   context. Over plain HTTP at a LAN IP the browser blocks it silently
   and the button shows "Copy failed". New copyTextToClipboard helper
   tries the modern API first, falls back to document.execCommand("copy")
   via an off-screen textarea.

4. Web UI static-asset cache-busting (#345). dekiesel needed Ctrl+F5 to
   see the v0.89 Download button after upgrade. The root HTML now
   carries a ?v=<hash> query string on /web/js/script.js and
   /web/css/style.css references. Hash is sha256 over the embedded asset
   bodies, truncated to 12 hex chars — stable per binary, changes when
   the assets change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 21:15:34 +02:00

338 lines
9.0 KiB
Go

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()
log.Printf("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
}
log.Printf("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) {
log.Printf("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] {
log.Printf("mDNS: Skipping duplicate device %s (already seen via another service-type query)", key)
continue
}
seen[key] = true
log.Printf("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) {
log.Printf("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 {
log.Printf("mDNS: Query '%s' (IPv4) completed successfully", service)
return
}
log.Printf("mDNS: Query '%s' (IPv4) failed: %v — falling back to dual-stack", 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", service, err)
} else {
log.Printf("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 {
log.Printf("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"
log.Printf("mDNS: Using IPv4 address: %s", host)
case entry.AddrV6 != nil:
host = entry.AddrV6.String()
ipSource = "IPv6"
log.Printf("mDNS: Using IPv6 address: %s", host)
default:
// Try to resolve from hostname
log.Printf("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", entry.Host, err)
return nil
}
// Prefer IPv4
for _, ip := range ips {
if ip.To4() != nil {
host = ip.String()
ipSource = "resolved IPv4"
log.Printf("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)"
}
log.Printf("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,
}
log.Printf("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", m.ifaceName, err)
return nil
}
if !interfaceHasIPv4(iface) {
log.Printf("mDNS: Configured interface %q has no usable IPv4 address", m.ifaceName)
return nil
}
log.Printf("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
}
log.Printf("mDNS: Using IPv4 interface: %s", iface.Name)
return &iface
}
log.Printf("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
}