diff --git a/.gitignore b/.gitignore index f1b5ff0..80827fe 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,11 @@ dist/ *.so *.dylib +# CLI binaries (should be in build/ directory) +soundtouch-cli +example-mdns +example-upnp + # Environment configuration .env .env.local diff --git a/Makefile b/Makefile index f235644..ecd4e51 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,12 @@ GOFMT=gofmt # Build parameters BINARY_NAME=soundtouch-cli BINARY_PATH=./cmd/$(BINARY_NAME) +EXAMPLE_MDNS_NAME=example-mdns +EXAMPLE_MDNS_PATH=./cmd/$(EXAMPLE_MDNS_NAME) +EXAMPLE_UPNP_NAME=example-upnp +EXAMPLE_UPNP_PATH=./cmd/$(EXAMPLE_UPNP_NAME) +SCANNER_NAME=mdns-scanner +SCANNER_PATH=./cmd/$(SCANNER_NAME) BUILD_DIR=./build # Version info @@ -24,14 +30,23 @@ LDFLAGS=-X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME) -X main.Commi all: check build -build: build-cli +build: build-cli build-examples build-cli: @echo "Building $(BINARY_NAME)..." @mkdir -p $(BUILD_DIR) $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME) $(BINARY_PATH) -build-all: build-linux build-darwin build-windows +build-examples: + @echo "Building $(EXAMPLE_MDNS_NAME)..." + @mkdir -p $(BUILD_DIR) + $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME) $(EXAMPLE_MDNS_PATH) + @echo "Building $(EXAMPLE_UPNP_NAME)..." + $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME) $(EXAMPLE_UPNP_PATH) + @echo "Building $(SCANNER_NAME)..." + $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(SCANNER_NAME) $(SCANNER_PATH) + +build-all: build-linux build-darwin build-windows build-examples-all build-linux: @echo "Building for Linux..." @@ -49,6 +64,22 @@ build-windows: @mkdir -p $(BUILD_DIR) GOOS=windows GOARCH=amd64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe $(BINARY_PATH) +build-examples-all: + @echo "Building examples for all platforms..." + @mkdir -p $(BUILD_DIR) + GOOS=linux GOARCH=amd64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-linux-amd64 $(EXAMPLE_MDNS_PATH) + GOOS=darwin GOARCH=amd64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-darwin-amd64 $(EXAMPLE_MDNS_PATH) + GOOS=darwin GOARCH=arm64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-darwin-arm64 $(EXAMPLE_MDNS_PATH) + GOOS=windows GOARCH=amd64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-windows-amd64.exe $(EXAMPLE_MDNS_PATH) + GOOS=linux GOARCH=amd64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-linux-amd64 $(EXAMPLE_UPNP_PATH) + GOOS=darwin GOARCH=amd64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-darwin-amd64 $(EXAMPLE_UPNP_PATH) + GOOS=darwin GOARCH=arm64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-darwin-arm64 $(EXAMPLE_UPNP_PATH) + GOOS=windows GOARCH=amd64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-windows-amd64.exe $(EXAMPLE_UPNP_PATH) + GOOS=linux GOARCH=amd64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(SCANNER_NAME)-linux-amd64 $(SCANNER_PATH) + GOOS=darwin GOARCH=amd64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(SCANNER_NAME)-darwin-amd64 $(SCANNER_PATH) + GOOS=darwin GOARCH=arm64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(SCANNER_NAME)-darwin-arm64 $(SCANNER_PATH) + GOOS=windows GOARCH=amd64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(SCANNER_NAME)-windows-amd64.exe $(SCANNER_PATH) + test: @echo "Running tests..." $(GOTEST) -v ./... @@ -94,6 +125,50 @@ dev-info: build-cli fi $(BUILD_DIR)/$(BINARY_NAME) -host $(HOST) -info +dev-mdns: build-examples + @echo "Running mDNS discovery example..." + $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME) + +dev-mdns-verbose: build-examples + @echo "Running mDNS discovery example with verbose logging..." + $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME) -v + +dev-mdns-timeout: build-examples + @echo "Running mDNS discovery example with custom timeout..." + @if [ -z "$(TIMEOUT)" ]; then \ + echo "Usage: make dev-mdns-timeout TIMEOUT=10s"; \ + exit 1; \ + fi + $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME) -timeout $(TIMEOUT) -v + +dev-upnp: build-examples + @echo "Running UPnP/SSDP discovery example..." + $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME) + +dev-upnp-verbose: build-examples + @echo "Running UPnP/SSDP discovery example with verbose logging..." + $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME) -v + +dev-upnp-timeout: build-examples + @echo "Running UPnP/SSDP discovery example with custom timeout..." + @if [ -z "$(TIMEOUT)" ]; then \ + echo "Usage: make dev-upnp-timeout TIMEOUT=10s"; \ + exit 1; \ + fi + $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME) -timeout $(TIMEOUT) -v + +dev-scan-all: build-examples + @echo "Scanning all mDNS services on network..." + $(BUILD_DIR)/$(SCANNER_NAME) -v + +dev-scan-soundtouch: build-examples + @echo "Scanning for SoundTouch mDNS services..." + $(BUILD_DIR)/$(SCANNER_NAME) -service _soundtouch._tcp -v + +dev-scan-http: build-examples + @echo "Scanning for HTTP mDNS services..." + $(BUILD_DIR)/$(SCANNER_NAME) -service _http._tcp -v + install: build-cli @echo "Installing $(BINARY_NAME) to $(GOPATH)/bin..." cp $(BUILD_DIR)/$(BINARY_NAME) $(GOPATH)/bin/ @@ -124,7 +199,9 @@ docker-dev: docker-build help: @echo "Available targets:" - @echo " build - Build the CLI tool" + @echo " build - Build the CLI tool and examples" + @echo " build-cli - Build only the CLI tool" + @echo " build-examples - Build only the example programs" @echo " build-all - Build for all platforms" @echo " test - Run tests" @echo " test-coverage - Run tests with coverage report" @@ -136,6 +213,15 @@ help: @echo " dev - Build and show CLI help" @echo " dev-discover - Build and run device discovery" @echo " dev-info - Build and get device info (HOST=ip required)" + @echo " dev-mdns - Build and run mDNS discovery example" + @echo " dev-mdns-verbose - Build and run mDNS example with detailed logging" + @echo " dev-mdns-timeout - Build and run mDNS example with custom timeout (TIMEOUT=10s)" + @echo " dev-upnp - Build and run UPnP/SSDP discovery example" + @echo " dev-upnp-verbose - Build and run UPnP example with detailed logging" + @echo " dev-upnp-timeout - Build and run UPnP example with custom timeout (TIMEOUT=10s)" + @echo " dev-scan-all - Scan all mDNS services on network" + @echo " dev-scan-soundtouch - Scan specifically for SoundTouch mDNS services" + @echo " dev-scan-http - Scan for HTTP mDNS services" @echo " install - Install binary to GOPATH/bin" @echo " clean - Clean build artifacts" @echo " release - Create release binaries" @@ -146,5 +232,13 @@ help: @echo "Examples:" @echo " make dev-discover" @echo " make dev-info HOST=192.168.1.100" + @echo " make dev-mdns" + @echo " make dev-mdns-verbose" + @echo " make dev-mdns-timeout TIMEOUT=10s" + @echo " make dev-upnp" + @echo " make dev-upnp-verbose" + @echo " make dev-upnp-timeout TIMEOUT=10s" + @echo " make dev-scan-all" + @echo " make dev-scan-soundtouch" @echo " make test" @echo " make build-all" diff --git a/README.md b/README.md index 601d56e..b2484b4 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,12 @@ A modern Go library and CLI tool for interacting with Bose SoundTouch devices vi - **Media Controls**: Play, pause, stop, track navigation via `/key` endpoint - **Volume Management**: Get/set volume, incremental control via `/volume` endpoint - **Host:Port Parsing**: Enhanced CLI with automatic host:port parsing -- **UPnP Discovery**: Automatic device discovery on local network +- **UPnP/SSDP Discovery**: Automatic device discovery using Universal Plug and Play +- **mDNS/Bonjour Discovery**: Multicast DNS device discovery support - **Cross-Platform**: Works on Windows, macOS, Linux, and WASM - **CLI Tool**: Command-line interface for testing and control operations - **Flexible Configuration**: Support for .env files and environment variables -- **Hybrid Discovery**: Combines UPnP discovery with configured device lists +- **Unified Discovery**: Combines UPnP, mDNS, and configured device lists - **Safety Features**: Volume warnings, increment limits, error validation ### 🔄 Planned @@ -60,6 +61,7 @@ Example `.env` configuration: # Discovery Settings DISCOVERY_TIMEOUT=5s UPNP_ENABLED=true +MDNS_ENABLED=true # Preferred Devices (alternative to UPnP) # Format: name@host:port;name@host:port;... @@ -73,12 +75,23 @@ USER_AGENT="Bose-SoundTouch-Go-Client/1.0" ### CLI Usage #### Device Discovery + +The library supports multiple discovery methods automatically: +- **Configuration**: Manually specified devices in `.env` file (fastest, most reliable) +- **UPnP/SSDP**: Universal Plug and Play discovery (widely supported) +- **mDNS/Bonjour**: Multicast DNS discovery (Apple ecosystem friendly) + +See [docs/DISCOVERY.md](docs/DISCOVERY.md) for detailed information. + ```bash -# Discover SoundTouch devices (combines UPnP + configured devices) +# Discover SoundTouch devices (combines UPnP, mDNS + configured devices) soundtouch-cli -discover # Discover and show detailed info for all devices soundtouch-cli -discover-all + +# Discover with custom timeout +soundtouch-cli -discover -timeout 10s ``` #### Device Information @@ -256,8 +269,9 @@ func main() { fmt.Printf("Device: %s (%s)\n", deviceInfo.Name, deviceInfo.Type) - // Option 2: Discover devices automatically - discoveryService := discovery.NewDiscoveryService(5 * time.Second) + // Option 2: Discover devices automatically (unified: UPnP + mDNS + config) + cfg, _ := config.LoadFromEnv() + discoveryService := discovery.NewUnifiedDiscoveryService(cfg) ctx := context.Background() devices, err := discoveryService.DiscoverDevices(ctx) @@ -345,7 +359,7 @@ func main() { │ └── soundtouch-cli/ # CLI application ├── pkg/ │ ├── client/ # HTTP client with XML support -│ ├── discovery/ # UPnP SSDP device discovery +│ ├── discovery/ # Device discovery (UPnP/SSDP + mDNS/Bonjour) │ └── models/ # XML data models ├── docs/ # Documentation └── build/ # Build artifacts @@ -383,6 +397,12 @@ go test -v ./pkg/discovery # Test with real devices make dev-info HOST=192.168.1.100 + +# Test device discovery +make dev-discover + +# Test mDNS discovery example +make dev-mdns ``` ### Development Commands @@ -429,7 +449,8 @@ The application supports configuration through `.env` files and environment vari | Variable | Default | Description | |----------|---------|-------------| | `DISCOVERY_TIMEOUT` | `5s` | Timeout for device discovery | -| `UPNP_ENABLED` | `true` | Enable/disable UPnP discovery | +| `UPNP_ENABLED` | `true` | Enable/disable UPnP/SSDP discovery | +| `MDNS_ENABLED` | `true` | Enable/disable mDNS/Bonjour discovery | | `PREFERRED_DEVICES` | (empty) | Semicolon-separated list of devices | | `HTTP_TIMEOUT` | `10s` | HTTP client timeout | | `CACHE_ENABLED` | `true` | Enable device caching | diff --git a/cmd/example-mdns/main.go b/cmd/example-mdns/main.go new file mode 100644 index 0000000..1ac3c8f --- /dev/null +++ b/cmd/example-mdns/main.go @@ -0,0 +1,97 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "time" + + "github.com/user_account/bose-soundtouch/pkg/discovery" +) + +func main() { + verbose := flag.Bool("v", false, "Enable verbose logging") + timeout := flag.Duration("timeout", 5*time.Second, "Discovery timeout") + flag.Parse() + + // Configure logging + if *verbose { + log.SetOutput(os.Stdout) + log.SetFlags(log.LstdFlags | log.Lmicroseconds) + } else { + log.SetOutput(os.Stderr) + } + + fmt.Println("SoundTouch mDNS Discovery Example") + fmt.Println("================================") + + fmt.Printf("Timeout: %v, Verbose: %v\n", *timeout, *verbose) + fmt.Println() + + // Create mDNS discovery service + mdnsService := discovery.NewMDNSDiscoveryService(*timeout) + + // Create context with timeout (add buffer time) + ctx, cancel := context.WithTimeout(context.Background(), *timeout+2*time.Second) + defer cancel() + + fmt.Println("Searching for SoundTouch devices via mDNS...") + fmt.Println("This will search for devices advertising _soundtouch._tcp.local. service") + if *verbose { + fmt.Println("Verbose logging enabled - watch for technical details...") + } + fmt.Println() + + start := time.Now() + + // Discover devices + devices, err := mdnsService.DiscoverDevices(ctx) + duration := time.Since(start) + + fmt.Printf("Discovery completed in %v\n", duration) + fmt.Println() + + if err != nil { + fmt.Printf("mDNS discovery completed with error: %v\n", err) + fmt.Println("Note: This might be normal if no devices support mDNS") + } + + // Display results + if len(devices) == 0 { + fmt.Println("No SoundTouch devices found via mDNS") + fmt.Println() + fmt.Println("Technical Status:") + if *verbose { + fmt.Println("✓ mDNS query was sent (check logs above for details)") + fmt.Println("✓ No network errors during discovery process") + fmt.Println("✗ No devices responded with _soundtouch._tcp service") + } else { + fmt.Println("Run with -v flag for detailed technical information") + } + fmt.Println() + fmt.Println("This could mean:") + fmt.Println("- No SoundTouch devices on network") + fmt.Println("- Devices don't support Bonjour/mDNS") + fmt.Println("- Network blocks multicast traffic (common in corporate networks)") + fmt.Println("- Devices use different service name than '_soundtouch._tcp.local.'") + return + } + + fmt.Printf("Found %d SoundTouch device(s):\n", len(devices)) + fmt.Println() + + for i, device := range devices { + fmt.Printf("%d. %s\n", i+1, device.Name) + fmt.Printf(" Host: %s\n", device.Host) + fmt.Printf(" Port: %d\n", device.Port) + fmt.Printf(" API URL: http://%s:%d/\n", device.Host, device.Port) + fmt.Printf(" Location: %s\n", device.Location) + fmt.Printf(" Last seen: %s\n", device.LastSeen.Format("2006-01-02 15:04:05")) + fmt.Println() + } + + fmt.Println("✓ mDNS discovery completed successfully!") + fmt.Printf("✓ Found %d device(s) in %v\n", len(devices), duration) +} diff --git a/cmd/example-upnp/main.go b/cmd/example-upnp/main.go new file mode 100644 index 0000000..48f44aa --- /dev/null +++ b/cmd/example-upnp/main.go @@ -0,0 +1,115 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "time" + + "github.com/user_account/bose-soundtouch/pkg/config" + "github.com/user_account/bose-soundtouch/pkg/discovery" +) + +func main() { + verbose := flag.Bool("v", false, "Enable verbose logging") + timeout := flag.Duration("timeout", 5*time.Second, "Discovery timeout") + flag.Parse() + + // Configure logging + if *verbose { + log.SetOutput(os.Stdout) + log.SetFlags(log.LstdFlags | log.Lmicroseconds) + } else { + log.SetOutput(os.Stderr) + } + + fmt.Println("SoundTouch UPnP/SSDP Discovery Example") + fmt.Println("=====================================") + + fmt.Printf("Timeout: %v, Verbose: %v\n", *timeout, *verbose) + fmt.Println() + + // Create context with timeout (add buffer time) + ctx, cancel := context.WithTimeout(context.Background(), *timeout+2*time.Second) + defer cancel() + + fmt.Println("Searching for SoundTouch devices via UPnP/SSDP...") + fmt.Println("This will send M-SEARCH requests to multicast address 239.255.255.250:1900") + if *verbose { + fmt.Println("Verbose logging enabled - watch for technical details...") + } + fmt.Println() + + start := time.Now() + + // Create a config that disables configured devices to test only UPnP + cfg := &config.Config{ + DiscoveryTimeout: *timeout, + UPnPEnabled: true, + MDNSEnabled: false, + PreferredDevices: []config.DeviceConfig{}, // Empty to test only UPnP + CacheEnabled: false, + } + + // Use the configured discovery service to isolate UPnP + configuredService := discovery.NewDiscoveryServiceWithConfig(cfg) + devices, err := configuredService.DiscoverDevices(ctx) + duration := time.Since(start) + + fmt.Printf("Discovery completed in %v\n", duration) + fmt.Println() + + if err != nil { + fmt.Printf("UPnP discovery completed with error: %v\n", err) + fmt.Println("Note: This might indicate network issues or no UPnP devices") + } + + // Display results + if len(devices) == 0 { + fmt.Println("No SoundTouch devices found via UPnP/SSDP") + fmt.Println() + fmt.Println("Technical Status:") + if *verbose { + fmt.Println("✓ SSDP M-SEARCH request was sent (check logs above for details)") + fmt.Println("✓ UDP multicast connection established") + fmt.Println("✗ No devices responded with MediaRenderer service type") + } else { + fmt.Println("Run with -v flag for detailed technical information") + } + fmt.Println() + fmt.Println("This could mean:") + fmt.Println("- No SoundTouch devices on network") + fmt.Println("- Devices don't support UPnP/SSDP") + fmt.Println("- Network blocks multicast traffic (common in corporate networks)") + fmt.Println("- Firewall blocks UDP port 1900") + fmt.Println("- Devices are not advertising MediaRenderer service") + return + } + + fmt.Printf("Found %d SoundTouch device(s):\n", len(devices)) + fmt.Println() + + for i, device := range devices { + fmt.Printf("%d. %s\n", i+1, device.Name) + fmt.Printf(" Host: %s\n", device.Host) + fmt.Printf(" Port: %d\n", device.Port) + fmt.Printf(" API URL: http://%s:%d/\n", device.Host, device.Port) + fmt.Printf(" Location: %s\n", device.Location) + fmt.Printf(" Last seen: %s\n", device.LastSeen.Format("2006-01-02 15:04:05")) + fmt.Println() + } + + fmt.Println("✓ UPnP/SSDP discovery completed successfully!") + fmt.Printf("✓ Found %d device(s) in %v\n", len(devices), duration) + + if *verbose { + fmt.Println() + fmt.Println("Technical Details:") + fmt.Printf("- Multicast address used: 239.255.255.250:1900\n") + fmt.Printf("- Service type searched: urn:schemas-upnp-org:device:MediaRenderer:1\n") + fmt.Printf("- Discovery timeout: %v\n", *timeout) + fmt.Printf("- Protocol: UDP SSDP (Simple Service Discovery Protocol)\n") + } +} diff --git a/cmd/mdns-scanner/main.go b/cmd/mdns-scanner/main.go new file mode 100644 index 0000000..20f0655 --- /dev/null +++ b/cmd/mdns-scanner/main.go @@ -0,0 +1,217 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "sort" + "strings" + "time" + + "github.com/hashicorp/mdns" +) + +func main() { + verbose := flag.Bool("v", false, "Enable verbose logging") + timeout := flag.Duration("timeout", 10*time.Second, "Discovery timeout") + service := flag.String("service", "_services._dns-sd._udp", "Service type to scan for (use _services._dns-sd._udp to find all)") + flag.Parse() + + // Configure logging + if *verbose { + log.SetOutput(os.Stdout) + log.SetFlags(log.LstdFlags | log.Lmicroseconds) + } else { + log.SetOutput(os.Stderr) + } + + fmt.Println("mDNS Service Scanner") + fmt.Println("===================") + fmt.Printf("Service: %s\n", *service) + fmt.Printf("Timeout: %v\n", *timeout) + fmt.Printf("Verbose: %v\n", *verbose) + fmt.Println() + + // Create a channel to collect service entries + entries := make(chan *mdns.ServiceEntry, 1000) + var services []ServiceInfo + + // Create context with timeout + ctx, cancel := context.WithTimeout(context.Background(), *timeout) + defer cancel() + + // Start mDNS query in a goroutine + go func() { + defer close(entries) + + if *verbose { + log.Printf("mDNS: Starting scan for service '%s' with timeout %v", *service, *timeout) + } + + // Query for services + err := mdns.Query(&mdns.QueryParam{ + Service: *service, + Domain: "local.", + Timeout: *timeout, + Entries: entries, + }) + + if err != nil { + if *verbose { + log.Printf("mDNS query completed with error: %v", err) + } + } else { + if *verbose { + log.Printf("mDNS query completed successfully") + } + } + }() + + // Collect discovered services + start := time.Now() + for { + select { + case <-ctx.Done(): + // Timeout reached + goto done + case entry, ok := <-entries: + if !ok { + // Channel closed + goto done + } + + if entry != nil { + service := parseServiceEntry(entry, *verbose) + if service != nil { + services = append(services, *service) + } + } + } + } + +done: + duration := time.Since(start) + fmt.Printf("Scan completed in %v\n", duration) + fmt.Printf("Found %d services:\n", len(services)) + fmt.Println() + + // Sort services by name for better display + sort.Slice(services, func(i, j int) bool { + return services[i].Name < services[j].Name + }) + + // 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() + } + } + + // 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") + } +} + +type ServiceInfo struct { + Name string + ServiceType string + Host string + IPv4 string + IPv6 string + Port int + TxtRecords []string +} + +func parseServiceEntry(entry *mdns.ServiceEntry, verbose bool) *ServiceInfo { + if entry == nil { + return nil + } + + if verbose { + 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) + } + + service := &ServiceInfo{ + Name: entry.Name, + Host: entry.Host, + Port: entry.Port, + } + + // Extract service type from name (e.g. "MyDevice._http._tcp.local." -> "_http._tcp") + if entry.Name != "" { + parts := strings.Split(entry.Name, ".") + if len(parts) >= 3 { + // Look for service type pattern: _service._protocol + for i := 0; i < len(parts)-2; i++ { + if strings.HasPrefix(parts[i], "_") && strings.HasPrefix(parts[i+1], "_") { + service.ServiceType = parts[i] + "." + parts[i+1] + break + } + } + } + } + + // Get IP addresses + if entry.AddrV4 != nil { + service.IPv4 = entry.AddrV4.String() + } + if entry.AddrV6 != nil { + service.IPv6 = entry.AddrV6.String() + } + + // Parse TXT records if available + if len(entry.InfoFields) > 0 { + service.TxtRecords = entry.InfoFields + } + + return service +} diff --git a/cmd/soundtouch-cli/main.go b/cmd/soundtouch-cli/main.go index c1fb390..0845d55 100644 --- a/cmd/soundtouch-cli/main.go +++ b/cmd/soundtouch-cli/main.go @@ -256,7 +256,7 @@ func handleDiscovery(showInfo bool, timeout time.Duration) error { cfg.DiscoveryTimeout = timeout } - discoveryService := discovery.NewDiscoveryServiceWithConfig(cfg) + discoveryService := discovery.NewUnifiedDiscoveryService(cfg) ctx, cancel := context.WithTimeout(context.Background(), cfg.DiscoveryTimeout+5*time.Second) defer cancel() @@ -286,7 +286,7 @@ func handleDiscovery(showInfo bool, timeout time.Duration) error { } } } else { - fmt.Printf(" Source: UPnP Discovery\n") + fmt.Printf(" Source: Network Discovery (UPnP/mDNS)\n") } if showInfo { diff --git a/docs/DISCOVERY.md b/docs/DISCOVERY.md new file mode 100644 index 0000000..a07746d --- /dev/null +++ b/docs/DISCOVERY.md @@ -0,0 +1,268 @@ +# SoundTouch Device Discovery + +This document describes the various methods available for discovering Bose SoundTouch devices on your network. + +## Overview + +The SoundTouch library supports multiple discovery methods to find devices on your network: + +1. **Configuration-based discovery** - Manually configured devices in `.env` file +2. **UPnP/SSDP discovery** - Automatic discovery using Universal Plug and Play protocol +3. **mDNS/Bonjour discovery** - Automatic discovery using multicast DNS + +## Discovery Methods + +### 1. Configuration-based Discovery + +You can manually configure known devices in your `.env` file: + +```bash +PREFERRED_DEVICES=Living Room:192.168.1.100:8090,Kitchen:192.168.1.101:8090 +``` + +This method is: +- ✅ Most reliable +- ✅ Fastest (no network scanning) +- ✅ Works in all network configurations +- ❌ Requires manual setup + +### 2. UPnP/SSDP Discovery + +Uses the Universal Plug and Play protocol to discover devices automatically. This is enabled by default. + +Configuration: +```bash +UPNP_ENABLED=true # Default: true +``` + +This method is: +- ✅ Widely supported by SoundTouch devices +- ✅ Standard protocol +- ❌ May be blocked by some firewalls/networks +- ❌ Requires multicast support + +### 3. mDNS/Bonjour Discovery + +Uses multicast DNS to discover devices advertising the `_soundtouch._tcp` service. + +Configuration: +```bash +MDNS_ENABLED=true # Default: true +``` + +This method is: +- ✅ Works well in home networks +- ✅ Apple/Bonjour compatible +- ❌ Depends on device advertising the service +- ❌ May not work in corporate networks +- ❌ Requires multicast support + +## Configuration Options + +### Environment Variables + +```bash +# Discovery timeouts +DISCOVERY_TIMEOUT=5s # How long to wait for discovery + +# Protocol enablement +UPNP_ENABLED=true # Enable UPnP/SSDP discovery +MDNS_ENABLED=true # Enable mDNS/Bonjour discovery + +# Caching +CACHE_ENABLED=true # Enable discovery result caching +CACHE_TTL=30s # How long to cache results + +# Manual device configuration +PREFERRED_DEVICES=Name:IP:Port,Name2:IP2:Port2 +``` + +### .env File Example + +```bash +# Discovery settings +DISCOVERY_TIMEOUT=10s +UPNP_ENABLED=true +MDNS_ENABLED=true + +# Cache settings +CACHE_ENABLED=true +CACHE_TTL=60s + +# Known devices (fastest method) +PREFERRED_DEVICES=Living Room:192.168.1.100:8090,Kitchen:192.168.1.101:8090,Bedroom:192.168.1.102 +``` + +## Usage Examples + +### CLI Discovery + +```bash +# Discover all devices using all methods +./soundtouch-cli -discover + +# Discover with custom timeout +./soundtouch-cli -discover -timeout 10s + +# Show detailed device information +./soundtouch-cli -discover-all +``` + +### Programmatic Usage + +```go +package main + +import ( + "context" + "fmt" + "time" + + "github.com/user_account/bose-soundtouch/pkg/config" + "github.com/user_account/bose-soundtouch/pkg/discovery" +) + +func main() { + // Load configuration + cfg, err := config.LoadFromEnv() + if err != nil { + cfg = config.DefaultConfig() + } + + // Create unified discovery service + discoveryService := discovery.NewUnifiedDiscoveryService(cfg) + + // Discover devices + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + devices, err := discoveryService.DiscoverDevices(ctx) + if err != nil { + fmt.Printf("Discovery failed: %v\n", err) + return + } + + // Print results + for _, device := range devices { + fmt.Printf("Found: %s at %s:%d\n", device.Name, device.Host, device.Port) + } +} +``` + +### mDNS-only Discovery + +```go +package main + +import ( + "context" + "time" + + "github.com/user_account/bose-soundtouch/pkg/discovery" +) + +func main() { + // Create mDNS-only discovery service + mdnsService := discovery.NewMDNSDiscoveryService(5 * time.Second) + + ctx := context.Background() + devices, err := mdnsService.DiscoverDevices(ctx) + + // Handle results... +} +``` + +## Troubleshooting + +### No devices found + +1. **Check network connectivity**: Ensure devices are on the same network +2. **Verify multicast support**: Some corporate networks block multicast traffic +3. **Try configuration-based discovery**: Add devices manually to `.env` file +4. **Increase timeout**: Some networks may be slow +5. **Check firewall settings**: Ensure UDP multicast is allowed + +### Slow discovery + +1. **Enable caching**: Set `CACHE_ENABLED=true` +2. **Use manual configuration**: Fastest method for known devices +3. **Disable unused protocols**: Turn off UPnP or mDNS if not needed +4. **Reduce timeout**: If you know devices respond quickly + +### mDNS specific issues + +mDNS discovery may fail if: +- Devices don't advertise `_soundtouch._tcp` service +- Network blocks multicast DNS (port 5353) +- IPv6 is misconfigured (common error: "no route to host") + +### UPnP specific issues + +UPnP discovery may fail if: +- Network blocks SSDP multicast (239.255.255.250:1900) +- Devices don't respond to M-SEARCH requests +- Corporate firewalls block UPnP traffic + +## Discovery Flow + +The unified discovery service uses this flow: + +1. **Check cache** (if enabled and not expired) +2. **Load configured devices** from environment/config +3. **Start parallel discovery**: + - UPnP/SSDP discovery (if enabled) + - mDNS discovery (if enabled) +4. **Merge results** (removing duplicates by IP) +5. **Update cache** for future requests +6. **Return combined device list** + +## Device Information + +Each discovered device includes: + +- `Name`: Device name (from config or auto-detected) +- `Host`: IP address +- `Port`: Port number (usually 8090) +- `Location`: Full device URL +- `LastSeen`: When the device was discovered + +## Performance Considerations + +- **Caching**: Enabled by default, reduces repeated network scanning +- **Parallel discovery**: UPnP and mDNS run simultaneously +- **Timeouts**: Balance between speed and completeness +- **Configuration priority**: Manual config devices are added first + +## Security Notes + +- Discovery traffic is sent over multicast (inherently insecure) +- No authentication is performed during discovery +- Device communication after discovery may require authentication +- Consider network segmentation for IoT devices + +## API Reference + +### Core Types + +```go +type DiscoveredDevice struct { + Name string + Host string + Port int + Location string + LastSeen time.Time +} +``` + +### Services + +- `UnifiedDiscoveryService`: Uses all available discovery methods +- `DiscoveryService`: UPnP/SSDP only (legacy) +- `MDNSDiscoveryService`: mDNS/Bonjour only + +### Methods + +- `DiscoverDevices(ctx)`: Discover all devices +- `DiscoverDevice(ctx, host)`: Find specific device +- `GetCachedDevices()`: Get cached results +- `ClearCache()`: Clear discovery cache \ No newline at end of file diff --git a/go.mod b/go.mod index 332e94f..8ee484c 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,14 @@ module github.com/user_account/bose-soundtouch go 1.25.5 + +require github.com/hashicorp/mdns v1.0.6 + +require ( + github.com/miekg/dns v1.1.55 // indirect + golang.org/x/mod v0.17.0 // indirect + golang.org/x/net v0.34.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..68ca43d --- /dev/null +++ b/go.sum @@ -0,0 +1,80 @@ +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hashicorp/mdns v1.0.6 h1:SV8UcjnQ/+C7KeJ/QeVD/mdN2EmzYfcGfufcuzxfCLQ= +github.com/hashicorp/mdns v1.0.6/go.mod h1:X4+yWh+upFECLOki1doUPaKpgNQII9gy4bUdCYKNhmM= +github.com/miekg/dns v1.1.55 h1:GoQ4hpsj0nFLYe+bWiCToyrBEJXkQfOOIvFGFy0lEgo= +github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/pkg/config/config.go b/pkg/config/config.go index 653a288..1d52480 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -17,6 +17,7 @@ type Config struct { // Discovery settings DiscoveryTimeout time.Duration `env:"DISCOVERY_TIMEOUT" default:"5s"` UPnPEnabled bool `env:"UPNP_ENABLED" default:"true"` + MDNSEnabled bool `env:"MDNS_ENABLED" default:"true"` // Preferred devices from .env file PreferredDevices []DeviceConfig `env:"PREFERRED_DEVICES"` @@ -42,6 +43,7 @@ func DefaultConfig() *Config { return &Config{ DiscoveryTimeout: 5 * time.Second, UPnPEnabled: true, + MDNSEnabled: true, PreferredDevices: []DeviceConfig{}, HTTPTimeout: 10 * time.Second, UserAgent: "Bose-SoundTouch-Go-Client/1.0", @@ -70,6 +72,10 @@ func LoadFromEnv() (*Config, error) { config.UPnPEnabled = upnp == "true" || upnp == "1" } + if mdns := os.Getenv("MDNS_ENABLED"); mdns != "" { + config.MDNSEnabled = mdns == "true" || mdns == "1" + } + if timeout := os.Getenv("HTTP_TIMEOUT"); timeout != "" { if d, err := time.ParseDuration(timeout); err == nil { config.HTTPTimeout = d diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 92c7290..43339b7 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -17,6 +17,10 @@ func TestDefaultConfig(t *testing.T) { t.Error("Expected UPnP to be enabled by default") } + if !config.MDNSEnabled { + t.Error("Expected mDNS to be enabled by default") + } + if config.HTTPTimeout != 10*time.Second { t.Errorf("Expected HTTP timeout 10s, got %v", config.HTTPTimeout) } @@ -63,6 +67,7 @@ func TestLoadFromEnv_WithEnvVars(t *testing.T) { // Set test environment variables os.Setenv("DISCOVERY_TIMEOUT", "15s") os.Setenv("UPNP_ENABLED", "false") + os.Setenv("MDNS_ENABLED", "false") os.Setenv("HTTP_TIMEOUT", "20s") os.Setenv("USER_AGENT", "Test-Client/1.0") os.Setenv("CACHE_ENABLED", "false") @@ -83,6 +88,10 @@ func TestLoadFromEnv_WithEnvVars(t *testing.T) { t.Error("Expected UPnP to be disabled") } + if config.MDNSEnabled { + t.Error("Expected mDNS to be disabled") + } + if config.HTTPTimeout != 20*time.Second { t.Errorf("Expected HTTP timeout 20s, got %v", config.HTTPTimeout) } @@ -409,6 +418,7 @@ func clearTestEnvVars() { envVars := []string{ "DISCOVERY_TIMEOUT", "UPNP_ENABLED", + "MDNS_ENABLED", "HTTP_TIMEOUT", "USER_AGENT", "CACHE_ENABLED", diff --git a/pkg/discovery/constants.go b/pkg/discovery/constants.go new file mode 100644 index 0000000..f15c313 --- /dev/null +++ b/pkg/discovery/constants.go @@ -0,0 +1,21 @@ +package discovery + +import "time" + +const ( + // SSDP multicast address and port + ssdpAddr = "239.255.255.250:1900" + + // SoundTouch device URN for UPnP discovery + soundTouchURN = "urn:schemas-upnp-org:device:MediaRenderer:1" + + // mDNS service type for SoundTouch devices (matches Bose's actual service name) + soundTouchServiceType = "_soundtouch._tcp" + soundTouchDomain = "local." + + // Default discovery timeout + defaultTimeout = 5 * time.Second + + // Default cache TTL + defaultCacheTTL = 30 * time.Second +) diff --git a/pkg/discovery/mdns.go b/pkg/discovery/mdns.go new file mode 100644 index 0000000..7a12454 --- /dev/null +++ b/pkg/discovery/mdns.go @@ -0,0 +1,175 @@ +package discovery + +import ( + "context" + "fmt" + "log" + "net" + "strings" + "time" + + "github.com/user_account/bose-soundtouch/pkg/models" + "github.com/hashicorp/mdns" +) + +// MDNSDiscoveryService handles mDNS/Bonjour discovery of SoundTouch devices +type MDNSDiscoveryService struct { + timeout time.Duration +} + +// NewMDNSDiscoveryService creates a new mDNS discovery service +func NewMDNSDiscoveryService(timeout time.Duration) *MDNSDiscoveryService { + if timeout == 0 { + timeout = defaultTimeout + } + + return &MDNSDiscoveryService{ + timeout: timeout, + } +} + +// 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() + + // Start mDNS query in a goroutine + go func() { + defer close(entries) + + log.Printf("mDNS: Starting discovery for service '%s.%s' with timeout %v", + soundTouchServiceType, soundTouchDomain, m.timeout) + + // Query for SoundTouch devices + // Note: hashicorp/mdns expects service and domain separately + err := mdns.Query(&mdns.QueryParam{ + Service: "_soundtouch._tcp", + Domain: "local.", + Timeout: m.timeout, + Entries: entries, + }) + + if err != nil { + log.Printf("mDNS query completed with error: %v", err) + } else { + log.Printf("mDNS query completed successfully") + } + }() + + // Collect discovered devices + 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) + + device := m.serviceEntryToDevice(entry) + if device != nil { + log.Printf("mDNS: Successfully converted to device: %s at %s:%d", device.Name, device.Host, device.Port) + devices = append(devices, device) + } else { + log.Printf("mDNS: Failed to convert service entry to device (no valid IP address)") + } + } + } +} + +// 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 + var ipSource string + + if entry.AddrV4 != nil { + host = entry.AddrV4.String() + ipSource = "IPv4" + log.Printf("mDNS: Using IPv4 address: %s", host) + } else if entry.AddrV6 != nil { + host = entry.AddrV6.String() + ipSource = "IPv6" + log.Printf("mDNS: Using IPv6 address: %s", host) + } else { + // 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) + } + + device := &models.DiscoveredDevice{ + Host: host, + Port: port, + Name: name, + Location: fmt.Sprintf("http://%s:%d/info", host, port), + LastSeen: time.Now(), + } + + log.Printf("mDNS: Created device '%s' at %s:%d (IP source: %s)", name, host, port, ipSource) + return device +} diff --git a/pkg/discovery/mdns_test.go b/pkg/discovery/mdns_test.go new file mode 100644 index 0000000..d7e46ab --- /dev/null +++ b/pkg/discovery/mdns_test.go @@ -0,0 +1,108 @@ +package discovery + +import ( + "context" + "testing" + "time" +) + +func TestNewMDNSDiscoveryService(t *testing.T) { + // Test with custom timeout + service := NewMDNSDiscoveryService(10 * time.Second) + if service.timeout != 10*time.Second { + t.Errorf("Expected timeout 10s, got %v", service.timeout) + } + + // Test with zero timeout (should use default) + service = NewMDNSDiscoveryService(0) + if service.timeout != defaultTimeout { + t.Errorf("Expected default timeout %v, got %v", defaultTimeout, service.timeout) + } +} + +func TestMDNSDiscoverDevices(t *testing.T) { + service := NewMDNSDiscoveryService(2 * time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + // Note: This test will attempt actual mDNS discovery + // In a real network environment, this might find actual SoundTouch devices + // In a test environment without devices, it should return an empty slice + devices, _ := service.DiscoverDevices(ctx) + + // devices slice should be initialized (but might be empty) + // We don't fail on errors as they may be due to network conditions in test environment + if devices == nil { + t.Error("Expected devices slice to be initialized, got nil") + } + + // If devices are found, verify they have the required fields + for _, device := range devices { + if device.Host == "" { + t.Error("Device host should not be empty") + } + if device.Port == 0 { + t.Error("Device port should not be zero") + } + if device.Name == "" { + t.Error("Device name should not be empty") + } + if device.Location == "" { + t.Error("Device location should not be empty") + } + } +} + +func TestMDNSServiceEntryToDevice(t *testing.T) { + service := NewMDNSDiscoveryService(5 * time.Second) + + // Test with nil entry + device := service.serviceEntryToDevice(nil) + if device != nil { + t.Error("Expected nil device for nil entry") + } + + // Note: Testing with actual zeroconf.ServiceEntry would require + // creating mock objects or using a testing framework that can + // create proper ServiceEntry instances. For now, we test the nil case. +} + +func TestMDNSDiscoveryTimeout(t *testing.T) { + service := NewMDNSDiscoveryService(100 * time.Millisecond) + + start := time.Now() + ctx := context.Background() + + _, err := service.DiscoverDevices(ctx) + duration := time.Since(start) + + // The discovery should not take significantly longer than the timeout + // Allow some buffer for processing time + maxExpected := 200 * time.Millisecond + if duration > maxExpected { + t.Errorf("Discovery took too long: %v, expected less than %v", duration, maxExpected) + } + + // We don't check for error here because mDNS discovery might succeed quickly + // or fail due to network conditions, both are acceptable in tests + _ = err +} + +func TestMDNSDiscoveryWithCancelledContext(t *testing.T) { + service := NewMDNSDiscoveryService(5 * time.Second) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + devices, err := service.DiscoverDevices(ctx) + + // Should handle cancelled context gracefully + // devices should never be nil, even if cancelled + if devices == nil { + t.Error("Expected devices slice to be initialized, got nil") + } + + // Error might or might not occur depending on timing and network conditions + // This is acceptable for testing - we just ensure no panic and proper slice initialization + _ = err +} diff --git a/pkg/discovery/unified.go b/pkg/discovery/unified.go new file mode 100644 index 0000000..59254fe --- /dev/null +++ b/pkg/discovery/unified.go @@ -0,0 +1,229 @@ +package discovery + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/user_account/bose-soundtouch/pkg/config" + "github.com/user_account/bose-soundtouch/pkg/models" +) + +// UnifiedDiscoveryService combines SSDP and mDNS discovery methods +type UnifiedDiscoveryService struct { + ssdpService *DiscoveryService + mdnsService *MDNSDiscoveryService + config *config.Config + cache map[string]*models.DiscoveredDevice + cacheTTL time.Duration + mutex sync.RWMutex +} + +// NewUnifiedDiscoveryService creates a new unified discovery service +func NewUnifiedDiscoveryService(cfg *config.Config) *UnifiedDiscoveryService { + timeout := cfg.DiscoveryTimeout + if timeout == 0 { + timeout = defaultTimeout + } + + cacheTTL := cfg.CacheTTL + if cacheTTL == 0 { + cacheTTL = defaultCacheTTL + } + + return &UnifiedDiscoveryService{ + ssdpService: NewDiscoveryServiceWithConfig(cfg), + mdnsService: NewMDNSDiscoveryService(timeout), + config: cfg, + cache: make(map[string]*models.DiscoveredDevice), + cacheTTL: cacheTTL, + mutex: sync.RWMutex{}, + } +} + +// DiscoverDevices discovers SoundTouch devices using both SSDP and mDNS +func (u *UnifiedDiscoveryService) DiscoverDevices(ctx context.Context) ([]*models.DiscoveredDevice, error) { + // Check cache first + u.cleanupCache() + cached := u.getCachedDevices() + if len(cached) > 0 && u.config.CacheEnabled { + return cached, nil + } + + // Initialize devices slice to ensure it's never nil + allDevices := make([]*models.DiscoveredDevice, 0) + + // Add configured devices first + configuredDevices := u.getConfiguredDevices() + allDevices = append(allDevices, configuredDevices...) + + // Use channels to collect results from both discovery methods + ssdpChan := make(chan []*models.DiscoveredDevice, 1) + mdnsChan := make(chan []*models.DiscoveredDevice, 1) + + var wg sync.WaitGroup + + // Start SSDP discovery if enabled + if u.config.UPnPEnabled { + wg.Add(1) + go func() { + defer wg.Done() + devices, err := u.ssdpService.DiscoverDevices(ctx) + if err == nil { + ssdpChan <- devices + } else { + ssdpChan <- nil + } + }() + } else { + ssdpChan <- nil + } + + // Start mDNS discovery if enabled + if u.config.MDNSEnabled { + wg.Add(1) + go func() { + defer wg.Done() + devices, err := u.mdnsService.DiscoverDevices(ctx) + if err == nil { + mdnsChan <- devices + } else { + mdnsChan <- nil + } + }() + } else { + mdnsChan <- nil + } + + // Wait for both discovery methods to complete + wg.Wait() + + // Collect results from both methods + if ssdpDevices := <-ssdpChan; ssdpDevices != nil { + allDevices = u.mergeDevices(allDevices, ssdpDevices) + } + + if mdnsDevices := <-mdnsChan; mdnsDevices != nil { + allDevices = u.mergeDevices(allDevices, mdnsDevices) + } + + // Update cache + u.updateCache(allDevices) + + // Ensure we always return a non-nil slice + if allDevices == nil { + allDevices = make([]*models.DiscoveredDevice, 0) + } + + return allDevices, nil +} + +// DiscoverDevice discovers a specific SoundTouch device by host +func (u *UnifiedDiscoveryService) DiscoverDevice(ctx context.Context, host string) (*models.DiscoveredDevice, error) { + // Check cache first + u.mutex.RLock() + if device, exists := u.cache[host]; exists && time.Since(device.LastSeen) < u.cacheTTL { + u.mutex.RUnlock() + return device, nil + } + u.mutex.RUnlock() + + // Try to discover all devices and find the specific one + devices, err := u.DiscoverDevices(ctx) + if err != nil { + return nil, err + } + + for _, device := range devices { + if device.Host == host { + return device, nil + } + } + + return nil, fmt.Errorf("device with host %s not found", host) +} + +// GetCachedDevices returns all cached devices that haven't expired +func (u *UnifiedDiscoveryService) GetCachedDevices() []*models.DiscoveredDevice { + u.cleanupCache() + return u.getCachedDevices() +} + +// ClearCache clears the device cache +func (u *UnifiedDiscoveryService) ClearCache() { + u.mutex.Lock() + defer u.mutex.Unlock() + u.cache = make(map[string]*models.DiscoveredDevice) +} + +// SetMDNSEnabled enables or disables mDNS discovery +func (u *UnifiedDiscoveryService) SetMDNSEnabled(enabled bool) { + u.config.MDNSEnabled = enabled +} + +// updateCache updates the device cache with discovered devices +func (u *UnifiedDiscoveryService) updateCache(devices []*models.DiscoveredDevice) { + u.mutex.Lock() + defer u.mutex.Unlock() + + for _, device := range devices { + u.cache[device.Host] = device + } +} + +// getCachedDevices returns all valid cached devices (internal method) +func (u *UnifiedDiscoveryService) getCachedDevices() []*models.DiscoveredDevice { + u.mutex.RLock() + defer u.mutex.RUnlock() + + devices := make([]*models.DiscoveredDevice, 0, len(u.cache)) + for _, device := range u.cache { + if time.Since(device.LastSeen) < u.cacheTTL { + devices = append(devices, device) + } + } + + return devices +} + +// cleanupCache removes expired devices from cache +func (u *UnifiedDiscoveryService) cleanupCache() { + u.mutex.Lock() + defer u.mutex.Unlock() + + for host, device := range u.cache { + if time.Since(device.LastSeen) >= u.cacheTTL { + delete(u.cache, host) + } + } +} + +// getConfiguredDevices returns devices from configuration +func (u *UnifiedDiscoveryService) getConfiguredDevices() []*models.DiscoveredDevice { + return u.config.GetPreferredDevicesAsDiscovered() +} + +// mergeDevices merges two device lists, avoiding duplicates based on host +func (u *UnifiedDiscoveryService) mergeDevices(existing, new []*models.DiscoveredDevice) []*models.DiscoveredDevice { + hostSet := make(map[string]bool) + result := make([]*models.DiscoveredDevice, 0, len(existing)+len(new)) + + // Add existing devices + for _, device := range existing { + if !hostSet[device.Host] { + result = append(result, device) + hostSet[device.Host] = true + } + } + + // Add new devices if not already present + for _, device := range new { + if !hostSet[device.Host] { + result = append(result, device) + hostSet[device.Host] = true + } + } + + return result +} diff --git a/pkg/discovery/unified_test.go b/pkg/discovery/unified_test.go new file mode 100644 index 0000000..fa47d6d --- /dev/null +++ b/pkg/discovery/unified_test.go @@ -0,0 +1,282 @@ +package discovery + +import ( + "context" + "testing" + "time" + + "github.com/user_account/bose-soundtouch/pkg/config" +) + +func TestNewUnifiedDiscoveryService(t *testing.T) { + cfg := config.DefaultConfig() + service := NewUnifiedDiscoveryService(cfg) + + if service == nil { + t.Error("Expected service to be created, got nil") + } + + if service.config != cfg { + t.Error("Expected config to be set correctly") + } + + if service.ssdpService == nil { + t.Error("Expected SSDP service to be initialized") + } + + if service.mdnsService == nil { + t.Error("Expected mDNS service to be initialized") + } + + if service.cache == nil { + t.Error("Expected cache to be initialized") + } +} + +func TestUnifiedDiscoveryWithDefaultConfig(t *testing.T) { + cfg := config.DefaultConfig() + service := NewUnifiedDiscoveryService(cfg) + + if service == nil { + t.Error("Expected service to be created, got nil") + } + + // Both UPnP and mDNS should be enabled by default + if !cfg.UPnPEnabled { + t.Error("Expected UPnP to be enabled by default") + } + + if !cfg.MDNSEnabled { + t.Error("Expected mDNS to be enabled by default") + } +} + +func TestUnifiedDiscoveryWithCustomConfig(t *testing.T) { + cfg := &config.Config{ + DiscoveryTimeout: 10 * time.Second, + UPnPEnabled: false, + MDNSEnabled: true, + CacheEnabled: false, + CacheTTL: 60 * time.Second, + HTTPTimeout: 15 * time.Second, + } + + service := NewUnifiedDiscoveryService(cfg) + + if service.config.DiscoveryTimeout != 10*time.Second { + t.Errorf("Expected timeout 10s, got %v", service.config.DiscoveryTimeout) + } + + if service.config.UPnPEnabled { + t.Error("Expected UPnP to be disabled") + } + + if !service.config.MDNSEnabled { + t.Error("Expected mDNS to be enabled") + } +} + +func TestUnifiedDiscoverDevices(t *testing.T) { + cfg := config.DefaultConfig() + cfg.DiscoveryTimeout = 2 * time.Second + cfg.CacheEnabled = false // Disable cache for testing + service := NewUnifiedDiscoveryService(cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + devices, err := service.DiscoverDevices(ctx) + + // We don't expect an error, even if no devices are found + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // devices slice should be initialized (but might be empty) + if devices == nil { + t.Error("Expected devices slice to be initialized, got nil") + } + + // If devices are found, verify they have the required fields + for _, device := range devices { + if device.Host == "" { + t.Error("Device host should not be empty") + } + if device.Port == 0 { + t.Error("Device port should not be zero") + } + if device.Name == "" { + t.Error("Device name should not be empty") + } + if device.Location == "" { + t.Error("Device location should not be empty") + } + } +} + +func TestUnifiedDiscoveryOnlyMDNS(t *testing.T) { + cfg := config.DefaultConfig() + cfg.DiscoveryTimeout = 1 * time.Second + cfg.UPnPEnabled = false // Disable UPnP + cfg.MDNSEnabled = true // Enable only mDNS + cfg.CacheEnabled = false + service := NewUnifiedDiscoveryService(cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + devices, err := service.DiscoverDevices(ctx) + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // devices should never be nil, even if no devices are found + if devices == nil { + t.Error("Expected devices slice to be initialized, got nil") + } +} + +func TestUnifiedDiscoveryOnlySSDP(t *testing.T) { + cfg := config.DefaultConfig() + cfg.DiscoveryTimeout = 1 * time.Second + cfg.UPnPEnabled = true // Enable only UPnP + cfg.MDNSEnabled = false // Disable mDNS + cfg.CacheEnabled = false + service := NewUnifiedDiscoveryService(cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + devices, err := service.DiscoverDevices(ctx) + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if devices == nil { + t.Error("Expected devices slice to be initialized, got nil") + } +} + +func TestUnifiedDiscoveryCache(t *testing.T) { + cfg := config.DefaultConfig() + cfg.DiscoveryTimeout = 100 * time.Millisecond + cfg.CacheEnabled = true + cfg.CacheTTL = 1 * time.Second + service := NewUnifiedDiscoveryService(cfg) + + ctx := context.Background() + + // First discovery + start := time.Now() + devices1, err := service.DiscoverDevices(ctx) + firstDuration := time.Since(start) + + if err != nil { + t.Errorf("Expected no error on first discovery, got %v", err) + } + + // Second discovery (should use cache) + start = time.Now() + devices2, err := service.DiscoverDevices(ctx) + secondDuration := time.Since(start) + + if err != nil { + t.Errorf("Expected no error on second discovery, got %v", err) + } + + // Second call should be much faster (cached) + if secondDuration > firstDuration/2 { + t.Logf("First discovery: %v, Second discovery: %v", firstDuration, secondDuration) + // Note: We don't fail here because in a test environment without devices, + // both calls might be very fast anyway + } + + // Results should be the same + if len(devices1) != len(devices2) { + t.Errorf("Expected same number of devices from cache, got %d vs %d", len(devices1), len(devices2)) + } +} + +func TestUnifiedDiscoveryClearCache(t *testing.T) { + cfg := config.DefaultConfig() + cfg.CacheEnabled = true + service := NewUnifiedDiscoveryService(cfg) + + // Clear cache should not panic + service.ClearCache() + + // Check that cache is empty + cached := service.GetCachedDevices() + if len(cached) != 0 { + t.Errorf("Expected empty cache after clear, got %d devices", len(cached)) + } +} + +func TestUnifiedSetMDNSEnabled(t *testing.T) { + cfg := config.DefaultConfig() + service := NewUnifiedDiscoveryService(cfg) + + // Initially enabled + if !service.config.MDNSEnabled { + t.Error("Expected mDNS to be enabled initially") + } + + // Disable mDNS + service.SetMDNSEnabled(false) + if service.config.MDNSEnabled { + t.Error("Expected mDNS to be disabled after SetMDNSEnabled(false)") + } + + // Enable mDNS + service.SetMDNSEnabled(true) + if !service.config.MDNSEnabled { + t.Error("Expected mDNS to be enabled after SetMDNSEnabled(true)") + } +} + +func TestUnifiedDiscoveryWithCancelledContext(t *testing.T) { + cfg := config.DefaultConfig() + cfg.DiscoveryTimeout = 5 * time.Second + service := NewUnifiedDiscoveryService(cfg) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + devices, err := service.DiscoverDevices(ctx) + + // Should handle cancelled context gracefully + // devices should never be nil, even if cancelled + if devices == nil { + t.Error("Expected devices slice to be initialized, got nil") + } + + // Error might or might not occur depending on timing, network conditions, and cancellation + // This is acceptable for testing - we just ensure no panic and proper slice initialization + _ = err +} + +func TestUnifiedDiscoveryTimeout(t *testing.T) { + cfg := config.DefaultConfig() + cfg.DiscoveryTimeout = 200 * time.Millisecond + cfg.CacheEnabled = false + service := NewUnifiedDiscoveryService(cfg) + + start := time.Now() + ctx := context.Background() + + _, err := service.DiscoverDevices(ctx) + duration := time.Since(start) + + // The discovery should not take significantly longer than the timeout + // Allow some buffer for processing time and parallel execution + maxExpected := 1 * time.Second // More generous for parallel execution + if duration > maxExpected { + t.Errorf("Discovery took too long: %v, expected less than %v", duration, maxExpected) + } + + // We don't check for error here because discovery might succeed quickly + // or fail due to network conditions, both are acceptable in tests + _ = err +} diff --git a/pkg/discovery/upnp.go b/pkg/discovery/upnp.go index aa7599b..c770a67 100644 --- a/pkg/discovery/upnp.go +++ b/pkg/discovery/upnp.go @@ -3,6 +3,7 @@ package discovery import ( "context" "fmt" + "log" "net" "net/http" "regexp" @@ -14,20 +15,6 @@ import ( "github.com/user_account/bose-soundtouch/pkg/models" ) -const ( - // SSDP multicast address and port - ssdpAddr = "239.255.255.250:1900" - - // SoundTouch device URN - soundTouchURN = "urn:schemas-upnp-org:device:MediaRenderer:1" - - // Default discovery timeout - defaultTimeout = 5 * time.Second - - // Default cache TTL - defaultCacheTTL = 30 * time.Second -) - // DiscoveryService handles UPnP SSDP discovery of SoundTouch devices type DiscoveryService struct { timeout time.Duration @@ -146,50 +133,74 @@ func (d *DiscoveryService) ClearCache() { // performDiscovery performs the actual UPnP SSDP discovery func (d *DiscoveryService) performDiscovery(ctx context.Context) ([]*models.DiscoveredDevice, error) { + log.Printf("UPnP: Starting SSDP discovery for '%s' with timeout %v", soundTouchURN, d.timeout) + // Create UDP connection for multicast conn, err := net.Dial("udp", ssdpAddr) if err != nil { + log.Printf("UPnP: Failed to create UDP connection to %s: %v", ssdpAddr, err) return nil, fmt.Errorf("failed to create UDP connection: %w", err) } defer conn.Close() + log.Printf("UPnP: Successfully connected to SSDP multicast address %s", ssdpAddr) + // Send M-SEARCH request msearchRequest := d.buildMSearchRequest() - if _, err := conn.Write([]byte(msearchRequest)); err != nil { + log.Printf("UPnP: Sending M-SEARCH request:\n%s", strings.TrimSpace(msearchRequest)) + + bytesWritten, err := conn.Write([]byte(msearchRequest)) + if err != nil { + log.Printf("UPnP: Failed to send M-SEARCH request: %v", err) return nil, fmt.Errorf("failed to send M-SEARCH: %w", err) } + log.Printf("UPnP: Successfully sent M-SEARCH request (%d bytes)", bytesWritten) // Listen for responses devices := make(map[string]*models.DiscoveredDevice) + responseCount := 0 // Set read deadline deadline := time.Now().Add(d.timeout) if err := conn.SetReadDeadline(deadline); err != nil { + log.Printf("UPnP: Failed to set read deadline: %v", err) return nil, fmt.Errorf("failed to set read deadline: %w", err) } + log.Printf("UPnP: Set read deadline to %v, now listening for responses...", deadline.Format("15:04:05.000")) buffer := make([]byte, 4096) for time.Now().Before(deadline) { select { case <-ctx.Done(): + log.Printf("UPnP: Discovery cancelled by context") return nil, ctx.Err() default: n, err := conn.Read(buffer) if err != nil { if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + log.Printf("UPnP: Read timeout reached after %v, stopping discovery", d.timeout) break // Timeout reached, stop reading } + log.Printf("UPnP: Error reading response: %v", err) return nil, fmt.Errorf("failed to read response: %w", err) } - device, err := d.parseResponse(string(buffer[:n])) + responseCount++ + responseText := string(buffer[:n]) + log.Printf("UPnP: Received response #%d (%d bytes):\n%s", responseCount, n, strings.TrimSpace(responseText)) + + device, err := d.parseResponse(responseText) if err != nil { + log.Printf("UPnP: Failed to parse response #%d: %v", responseCount, err) continue // Skip invalid responses } if device != nil { + log.Printf("UPnP: Successfully parsed device from response #%d: %s at %s:%d", responseCount, device.Name, device.Host, device.Port) devices[device.Host] = device + } else { + log.Printf("UPnP: Response #%d did not contain a valid SoundTouch device", responseCount) } } } @@ -200,6 +211,11 @@ func (d *DiscoveryService) performDiscovery(ctx context.Context) ([]*models.Disc result = append(result, device) } + log.Printf("UPnP: Discovery completed. Processed %d responses, found %d unique devices", responseCount, len(result)) + for i, device := range result { + log.Printf("UPnP: Device #%d: %s at %s:%d (Location: %s)", i+1, device.Name, device.Host, device.Port, device.Location) + } + return result, nil } @@ -220,6 +236,8 @@ func (d *DiscoveryService) buildMSearchRequest() string { // parseResponse parses UPnP SSDP response and extracts device information func (d *DiscoveryService) parseResponse(response string) (*models.DiscoveredDevice, error) { + log.Printf("UPnP: Parsing response (%d chars): %.100s...", len(response), strings.ReplaceAll(response, "\r\n", "\\r\\n")) + // Try both \r\n and \n line endings var lines []string if strings.Contains(response, "\r\n") { @@ -230,8 +248,10 @@ func (d *DiscoveryService) parseResponse(response string) (*models.DiscoveredDev // Check if it's a valid HTTP response if len(lines) < 1 || !strings.HasPrefix(lines[0], "HTTP/1.1 200") { + log.Printf("UPnP: Invalid HTTP response, first line: '%s'", lines[0]) return nil, fmt.Errorf("invalid HTTP response") } + log.Printf("UPnP: Valid HTTP response detected") headers := make(map[string]string) for _, line := range lines[1:] { @@ -248,32 +268,48 @@ func (d *DiscoveryService) parseResponse(response string) (*models.DiscoveredDev } } + log.Printf("UPnP: Parsed %d headers from response", len(headers)) + for key, value := range headers { + log.Printf("UPnP: Header: %s = %s", key, value) + } + // Check if it's a SoundTouch device st, exists := headers["st"] if !exists { + log.Printf("UPnP: No ST header found in response") return nil, fmt.Errorf("no ST header found") } + log.Printf("UPnP: Found ST header: %s", st) // Accept both MediaRenderer and any device type for now - we'll validate it's a SoundTouch later if !strings.Contains(strings.ToLower(st), "mediarenderer") && !strings.Contains(strings.ToLower(st), "upnp:rootdevice") { + log.Printf("UPnP: Device type '%s' is not a MediaRenderer, skipping", st) return nil, fmt.Errorf("not a MediaRenderer device") } + log.Printf("UPnP: Device type '%s' is acceptable", st) location, exists := headers["location"] if !exists { + log.Printf("UPnP: No Location header found in response") return nil, fmt.Errorf("no location header found") } + log.Printf("UPnP: Found Location header: %s", location) // Extract device information from location URL device, err := d.parseLocationURL(location) if err != nil { + log.Printf("UPnP: Failed to parse location URL '%s': %v", location, err) return nil, fmt.Errorf("failed to parse location URL: %w", err) } + log.Printf("UPnP: Successfully parsed device from location: %s at %s:%d", device.Name, device.Host, device.Port) // Try to get more device info from the location URL if err := d.enrichDeviceInfo(device, location); err != nil { + log.Printf("UPnP: Could not enrich device info from location '%s': %v", location, err) // Don't fail if we can't get additional info // The basic info from URL parsing should be sufficient + } else { + log.Printf("UPnP: Successfully enriched device info for %s", device.Name) } return device, nil @@ -281,16 +317,20 @@ func (d *DiscoveryService) parseResponse(response string) (*models.DiscoveredDev // parseLocationURL extracts basic device info from the location URL func (d *DiscoveryService) parseLocationURL(location string) (*models.DiscoveredDevice, error) { + log.Printf("UPnP: Parsing location URL: %s", location) + // Parse the URL to extract host and port re := regexp.MustCompile(`http://([^:]+):(\d+)`) matches := re.FindStringSubmatch(location) if len(matches) < 2 { + log.Printf("UPnP: Location URL '%s' does not match expected format http://host:port", location) return nil, fmt.Errorf("invalid location URL format") } host := matches[1] port := 8090 // Default SoundTouch port + log.Printf("UPnP: Extracted host='%s', using default port=%d", host, port) device := &models.DiscoveredDevice{ Host: host, @@ -305,16 +345,21 @@ func (d *DiscoveryService) parseLocationURL(location string) (*models.Discovered // enrichDeviceInfo tries to get additional device information from the device description func (d *DiscoveryService) enrichDeviceInfo(device *models.DiscoveredDevice, location string) error { + log.Printf("UPnP: Attempting to enrich device info by fetching %s", location) + client := &http.Client{ Timeout: 5 * time.Second, } resp, err := client.Get(location) if err != nil { + log.Printf("UPnP: Failed to fetch device description from %s: %v", location, err) return err } defer resp.Body.Close() + log.Printf("UPnP: Successfully fetched device description from %s (Status: %s)", location, resp.Status) + // For now, we'll keep it simple and not parse the full UPnP device description // This can be enhanced later to extract more detailed device information return nil