mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 09:06:14 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user