Add comprehensive mDNS/Bonjour discovery with unified service and diagnostic tools

Features Added:
• mDNS/Bonjour discovery using hashicorp/mdns library
• Unified discovery service combining UPnP + mDNS + configuration
• Parallel discovery execution for optimal performance
• Comprehensive logging for both UPnP and mDNS discovery
• Network diagnostic tools for troubleshooting

New Discovery Methods:
• Configuration-based (fastest, most reliable)
• UPnP/SSDP discovery (widely supported, enhanced logging)
• mDNS/Bonjour discovery (Apple ecosystem friendly)

New Programs & Tools:
• cmd/example-mdns - Standalone mDNS discovery testing
• cmd/example-upnp - Isolated UPnP/SSDP discovery testing
• cmd/mdns-scanner - Network diagnostic tool for mDNS services

Enhanced Build System:
• make dev-mdns / dev-mdns-verbose (mDNS testing)
• make dev-upnp / dev-upnp-verbose (UPnP testing)
• make dev-scan-all (scan all network services)
• make dev-scan-soundtouch (scan for SoundTouch services)

Documentation:
• docs/DISCOVERY.md - Comprehensive discovery guide
• Updated README.md with new features and commands
• Full API documentation and troubleshooting guide

Technical Improvements:
• Detailed request/response logging for UPnP M-SEARCH
• Step-by-step mDNS service discovery tracking
• IP address resolution with IPv4/IPv6 handling
• Service name parsing and device info extraction
• Robust error handling and network diagnostics

Backward Compatibility:
• No breaking changes to existing APIs
• All existing tests pass
• CLI interface unchanged but enhanced
• Legacy UPnP-only service still available
This commit is contained in:
Tobias Gesellchen
2026-01-09 08:49:33 +01:00
parent ea2b6502b2
commit f4c71eaa53
18 changed files with 1812 additions and 28 deletions
+5
View File
@@ -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
+97 -3
View File
@@ -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"
+28 -7
View File
@@ -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 |
+97
View File
@@ -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)
}
+115
View File
@@ -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")
}
}
+217
View File
@@ -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
}
+2 -2
View File
@@ -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 {
+268
View File
@@ -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
+11
View File
@@ -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
)
+80
View File
@@ -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=
+6
View File
@@ -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
+10
View File
@@ -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",
+21
View File
@@ -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
)
+175
View File
@@ -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
}
+108
View File
@@ -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
}
+229
View File
@@ -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
}
+282
View File
@@ -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
}
+61 -16
View File
@@ -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