Fix SSDP discovery and enhance device discovery consistency

Major improvements to device discovery system:

🔧 **SSDP Discovery Fixed**:
- Fixed networking issue where SSDP used connected UDP socket instead of UDP listener
- SSDP now properly receives unicast responses from multicast requests
- UPnP discovery now works reliably and finds all MediaRenderer devices

 **Enhanced DiscoveredDevice Model**:
- Added consistent URL fields (APIBaseURL, InfoURL) for all discovery methods
- Added protocol-specific fields (UPnPLocation, UPnPUSN, MDNSHostname, etc.)
- Added DiscoveryMethod tracking to show how devices were found
- Added device merging support for same device found via multiple protocols

🚀 **Unified Discovery Improvements**:
- Fixed device merging logic to properly combine protocol-specific data
- Discovery methods now correctly show combinations like 'Configuration+SSDP/UPnP+mDNS/Bonjour'
- Removed duplicate configuration device loading in individual services
- All three discovery methods (SSDP, mDNS, Configuration) work together seamlessly

🛠 **Updated Tools & Examples**:
- Updated soundtouch-cli to display new consistent field structure
- Enhanced all example programs with better device information display
- Added new unified discovery example demonstrating all three methods
- Fixed context timeout issues in example programs

📋 **Comprehensive Testing**:
- All tests updated and passing
- Real-world validation with actual Bose SoundTouch devices
- Confirmed discovery methods properly merge device data

Every discovered device now has consistent http://host:port/info URLs regardless
of discovery method, while preserving valuable protocol-specific metadata.
This commit is contained in:
Tobias Gesellchen
2026-01-10 23:01:22 +01:00
parent ad43cdaf88
commit c5a3911104
15 changed files with 1242 additions and 157 deletions
+6 -1
View File
@@ -94,7 +94,12 @@ func main() {
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(" Info URL: %s\n", device.InfoURL)
if device.MDNSHostname != "" {
fmt.Printf(" mDNS Hostname: %s\n", device.MDNSHostname)
}
fmt.Printf(" Last seen: %s\n", device.LastSeen.Format("2006-01-02 15:04:05"))
fmt.Println()
}
+262
View File
@@ -0,0 +1,262 @@
// Package main provides an example of discovering SoundTouch devices using all three mechanisms.
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"time"
"github.com/gesellix/bose-soundtouch/pkg/config"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
)
func main() {
verbose := flag.Bool("verbose", false, "Enable verbose logging")
timeout := flag.Duration("timeout", 5*time.Second, "Discovery timeout")
showConfig := flag.Bool("show-config", false, "Show configuration details")
flag.Parse()
// Configure logging
if *verbose {
log.SetOutput(os.Stdout)
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
} else {
log.SetOutput(os.Stderr)
}
fmt.Println("SoundTouch Unified Discovery Example")
fmt.Println("===================================")
fmt.Printf("Timeout: %v, Verbose: %v\n", *timeout, *verbose)
fmt.Println()
// Load configuration
cfg, err := config.LoadFromEnv()
if err != nil {
fmt.Printf("Failed to load configuration: %v\n", err)
os.Exit(1)
}
// Override timeout from command line
cfg.DiscoveryTimeout = *timeout
if *showConfig {
printConfiguration(cfg)
}
fmt.Println("Testing individual discovery mechanisms:")
fmt.Println("--------------------------------------")
testSSDP(cfg, *timeout, *verbose)
testMDNS(cfg, *timeout, *verbose)
testConfig(cfg, *verbose)
testUnified(cfg, *timeout, *verbose)
}
func printConfiguration(cfg *config.Config) {
fmt.Println("Configuration:")
fmt.Printf(" UPnP Enabled: %v\n", cfg.UPnPEnabled)
fmt.Printf(" mDNS Enabled: %v\n", cfg.MDNSEnabled)
fmt.Printf(" Cache Enabled: %v\n", cfg.CacheEnabled)
fmt.Printf(" Discovery Timeout: %v\n", cfg.DiscoveryTimeout)
fmt.Printf(" Preferred Devices: %d\n", len(cfg.PreferredDevices))
for i, device := range cfg.PreferredDevices {
fmt.Printf(" %d. %s at %s:%d\n", i+1, device.Name, device.Host, device.Port)
}
fmt.Println()
}
func testSSDP(cfg *config.Config, timeout time.Duration, verbose bool) {
// Test SSDP discovery
fmt.Println("1. SSDP/UPnP Discovery:")
if cfg.UPnPEnabled {
// Create fresh context for SSDP test
ssdpCtx, ssdpCancel := context.WithTimeout(context.Background(), timeout+2*time.Second)
defer ssdpCancel()
ssdpService := discovery.NewServiceWithConfig(cfg)
start := time.Now()
ssdpDevices, ssdpErr := ssdpService.DiscoverDevices(ssdpCtx)
duration := time.Since(start)
if ssdpErr != nil {
fmt.Printf(" Error: %v\n", ssdpErr)
} else {
fmt.Printf(" Found %d devices in %v\n", len(ssdpDevices), duration)
for _, device := range ssdpDevices {
fmt.Printf(" - %s (%s:%d)\n", device.Name, device.Host, device.Port)
if verbose {
fmt.Printf(" Info URL: %s\n", device.InfoURL)
if device.UPnPLocation != "" {
fmt.Printf(" UPnP Location: %s\n", device.UPnPLocation)
}
}
}
}
} else {
fmt.Println(" Disabled in configuration")
}
fmt.Println()
}
func testMDNS(cfg *config.Config, timeout time.Duration, verbose bool) {
// Test mDNS discovery
fmt.Println("2. mDNS/Bonjour Discovery:")
if cfg.MDNSEnabled {
// Create fresh context for mDNS test
mdnsCtx, mdnsCancel := context.WithTimeout(context.Background(), timeout+2*time.Second)
defer mdnsCancel()
mdnsService := discovery.NewMDNSDiscoveryService(timeout)
start := time.Now()
mdnsDevices, mdnsErr := mdnsService.DiscoverDevices(mdnsCtx)
duration := time.Since(start)
if mdnsErr != nil {
fmt.Printf(" Error: %v\n", mdnsErr)
} else {
fmt.Printf(" Found %d devices in %v\n", len(mdnsDevices), duration)
for _, device := range mdnsDevices {
fmt.Printf(" - %s (%s:%d)\n", device.Name, device.Host, device.Port)
if verbose {
fmt.Printf(" Info URL: %s\n", device.InfoURL)
if device.MDNSHostname != "" {
fmt.Printf(" mDNS Hostname: %s\n", device.MDNSHostname)
}
}
}
}
} else {
fmt.Println(" Disabled in configuration")
}
fmt.Println()
}
func testConfig(cfg *config.Config, verbose bool) {
// Test configuration-based devices
fmt.Println("3. Configuration-based Devices:")
configDevices := cfg.GetPreferredDevicesAsDiscovered()
if len(configDevices) > 0 {
fmt.Printf(" Found %d configured devices\n", len(configDevices))
for _, device := range configDevices {
fmt.Printf(" - %s (%s:%d)\n", device.Name, device.Host, device.Port)
if verbose {
fmt.Printf(" Info URL: %s\n", device.InfoURL)
}
}
} else {
fmt.Println(" No devices configured in .env file")
}
fmt.Println()
}
func testUnified(cfg *config.Config, timeout time.Duration, verbose bool) {
// Test unified discovery
fmt.Println("4. Unified Discovery (combines all methods):")
// Create fresh context for unified test
unifiedCtx, unifiedCancel := context.WithTimeout(context.Background(), timeout+2*time.Second)
defer unifiedCancel()
unifiedService := discovery.NewUnifiedDiscoveryService(cfg)
start := time.Now()
allDevices, err := unifiedService.DiscoverDevices(unifiedCtx)
duration := time.Since(start)
if err != nil {
fmt.Printf(" Error: %v\n", err)
return
}
fmt.Printf(" Found %d total devices in %v\n", len(allDevices), duration)
fmt.Println()
if len(allDevices) == 0 {
fmt.Println("No SoundTouch devices found via any discovery method")
fmt.Println()
fmt.Println("This could mean:")
fmt.Println("- No SoundTouch devices on network")
fmt.Println("- All discovery methods are disabled")
fmt.Println("- Network blocks multicast traffic")
fmt.Println("- Devices are not advertising services")
return
}
fmt.Println("Unified Device List:")
fmt.Println("-------------------")
for i, device := range allDevices {
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 Base URL: %s\n", device.APIBaseURL)
fmt.Printf(" Info URL: %s\n", device.InfoURL)
fmt.Printf(" Discovery Method: %s\n", device.DiscoveryMethod)
fmt.Printf(" Last seen: %s\n", device.LastSeen.Format("2006-01-02 15:04:05"))
if verbose {
if device.ModelID != "" {
fmt.Printf(" Model ID: %s\n", device.ModelID)
}
if device.SerialNo != "" {
fmt.Printf(" Serial No: %s\n", device.SerialNo)
}
// Show protocol-specific details
if device.UPnPLocation != "" {
fmt.Printf(" UPnP Location: %s\n", device.UPnPLocation)
if device.UPnPUSN != "" {
fmt.Printf(" UPnP USN: %s\n", device.UPnPUSN)
}
}
if device.MDNSHostname != "" {
fmt.Printf(" mDNS Hostname: %s\n", device.MDNSHostname)
if device.MDNSService != "" {
fmt.Printf(" mDNS Service: %s\n", device.MDNSService)
}
}
if device.ConfigName != "" {
fmt.Printf(" Config Name: %s\n", device.ConfigName)
}
}
fmt.Println()
}
fmt.Printf("✓ Unified discovery completed successfully!\n")
fmt.Printf("✓ Found %d unique device(s) in %v\n", len(allDevices), duration)
if verbose {
fmt.Println()
fmt.Println("Technical Details:")
fmt.Printf("- SSDP multicast address: 239.255.255.250:1900\n")
fmt.Printf("- mDNS service type: _soundtouch._tcp.local\n")
fmt.Printf("- Discovery timeout: %v\n", timeout)
fmt.Printf("- Configuration file: .env (if present)\n")
}
}
+6 -1
View File
@@ -103,7 +103,12 @@ func main() {
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(" Info URL: %s\n", device.InfoURL)
if device.UPnPLocation != "" {
fmt.Printf(" UPnP Location: %s\n", device.UPnPLocation)
}
fmt.Printf(" Last seen: %s\n", device.LastSeen.Format("2006-01-02 15:04:05"))
fmt.Println()
}
+77 -35
View File
@@ -7,14 +7,12 @@ import (
"github.com/gesellix/bose-soundtouch/pkg/config"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/urfave/cli/v2"
)
// discoverDevices handles device discovery command
func discoverDevices(c *cli.Context) error {
httpTimeout := c.Duration("timeout")
showAll := c.Bool("all")
fmt.Printf("Discovering SoundTouch devices...\n")
// Load configuration
@@ -23,27 +21,11 @@ func discoverDevices(c *cli.Context) error {
cfg = config.DefaultConfig()
}
// Only override discovery timeout if user explicitly provided --timeout flag
// This respects DISCOVERY_TIMEOUT from .env file when no flag is provided
if c.IsSet("timeout") {
cfg.HTTPTimeout = httpTimeout
// Set discovery timeout to be 2x HTTP timeout (min 5s, max 30s)
discoveryTimeout := httpTimeout * 2
if discoveryTimeout < 5*time.Second {
discoveryTimeout = 5 * time.Second
}
// Update config with CLI flags
updateConfigFromCLI(c, cfg)
if discoveryTimeout > 30*time.Second {
discoveryTimeout = 30 * time.Second
}
cfg.DiscoveryTimeout = discoveryTimeout
}
if showAll {
fmt.Printf("HTTP Timeout: %v\n", cfg.HTTPTimeout)
fmt.Printf("Discovery Timeout: %v\n", cfg.DiscoveryTimeout)
fmt.Printf("Mode: Detailed information\n")
if c.Bool("all") {
printDiscoveryContext(cfg)
}
fmt.Println()
@@ -61,18 +43,51 @@ func discoverDevices(c *cli.Context) error {
}
if len(devices) == 0 {
fmt.Println("No SoundTouch devices found on the network.")
fmt.Println()
fmt.Println("This could mean:")
fmt.Println("- No SoundTouch devices are powered on")
fmt.Println("- Devices are on a different network segment")
fmt.Println("- Network blocks multicast traffic")
fmt.Println("- Firewall is blocking discovery ports")
printNoDevicesMessage()
return nil
}
// Display results
printDiscoveryResults(devices, c.Bool("all"))
return nil
}
func updateConfigFromCLI(c *cli.Context, cfg *config.Config) {
if c.IsSet("timeout") {
httpTimeout := c.Duration("timeout")
cfg.HTTPTimeout = httpTimeout
// Set discovery timeout to be 2x HTTP timeout (min 5s, max 30s)
discoveryTimeout := httpTimeout * 2
if discoveryTimeout < 5*time.Second {
discoveryTimeout = 5 * time.Second
}
if discoveryTimeout > 30*time.Second {
discoveryTimeout = 30 * time.Second
}
cfg.DiscoveryTimeout = discoveryTimeout
}
}
func printDiscoveryContext(cfg *config.Config) {
fmt.Printf("HTTP Timeout: %v\n", cfg.HTTPTimeout)
fmt.Printf("Discovery Timeout: %v\n", cfg.DiscoveryTimeout)
fmt.Printf("Mode: Detailed information\n")
}
func printNoDevicesMessage() {
fmt.Println("No SoundTouch devices found on the network.")
fmt.Println()
fmt.Println("This could mean:")
fmt.Println("- No SoundTouch devices are powered on")
fmt.Println("- Devices are on a different network segment")
fmt.Println("- Network blocks multicast traffic")
fmt.Println("- Firewall is blocking discovery ports")
}
func printDiscoveryResults(devices []*models.DiscoveredDevice, showAll bool) {
fmt.Printf("Found %d SoundTouch device(s):\n\n", len(devices))
for i, device := range devices {
@@ -84,11 +99,40 @@ func discoverDevices(c *cli.Context) error {
fmt.Printf(" Serial: %s\n", device.SerialNo)
}
if device.Location != "" {
fmt.Printf(" Location: %s\n", device.Location)
if device.APIBaseURL != "" {
fmt.Printf(" API Base URL: %s\n", device.APIBaseURL)
}
if device.InfoURL != "" {
fmt.Printf(" Info URL: %s\n", device.InfoURL)
}
if device.DiscoveryMethod != "" {
fmt.Printf(" Discovery Method: %s\n", device.DiscoveryMethod)
}
if showAll {
// Show protocol-specific details in verbose mode
if device.UPnPLocation != "" {
fmt.Printf(" UPnP Location: %s\n", device.UPnPLocation)
}
if device.UPnPUSN != "" {
fmt.Printf(" UPnP USN: %s\n", device.UPnPUSN)
}
if device.MDNSHostname != "" {
fmt.Printf(" mDNS Hostname: %s\n", device.MDNSHostname)
}
if device.MDNSService != "" {
fmt.Printf(" mDNS Service: %s\n", device.MDNSService)
}
if device.ConfigName != "" {
fmt.Printf(" Config Name: %s\n", device.ConfigName)
}
fmt.Printf(" Last Seen: %s\n", device.LastSeen.Format("2006-01-02 15:04:05"))
}
@@ -101,6 +145,4 @@ func discoverDevices(c *cli.Context) error {
fmt.Println()
fmt.Printf("Use any of these hosts with other commands:\n")
fmt.Printf("Example: soundtouch-cli info --host %s\n", devices[0].Host)
return nil
}
+566
View File
@@ -0,0 +1,566 @@
# Manual Network Discovery on macOS
This document provides comprehensive guidance for manually discovering network services and devices using built-in macOS tools and command-line utilities. This is particularly useful for troubleshooting network discovery issues or understanding what services are available on your local network.
## Overview
Network service discovery typically relies on two main protocols:
- **mDNS (Multicast DNS)** - Used by Apple devices, printers, and many local services
- **SSDP (Simple Service Discovery Protocol)** - Used by UPnP devices, media servers, and smart home devices
## mDNS (Multicast DNS) Discovery
**Multicast Address:** `224.0.0.251:5353`
mDNS is the underlying protocol for Bonjour/Zeroconf services. It allows devices to advertise services on the local network using `.local` domain names.
### Built-in Tools (Recommended)
macOS includes `dns-sd`, a powerful command-line tool for service discovery:
```bash
# Browse for all available service types
dns-sd -B _services._dns-sd._udp local.
# Browse for specific service types
dns-sd -B _http._tcp local. # Web servers
dns-sd -B _airplay._tcp local. # AirPlay devices
dns-sd -B _ipp._tcp local. # Internet Printing Protocol
dns-sd -B _soundtouch._tcp local. # Bose SoundTouch devices
dns-sd -B _ssh._tcp local. # SSH servers
dns-sd -B _afpovertcp._tcp local. # AFP file sharing
# Resolve a specific service to get IP address and port
dns-sd -L "ServiceName" _http._tcp local.
# Register a test service (useful for testing)
dns-sd -R "TestService" _http._tcp local 8080
# Query for a specific record type
dns-sd -Q hostname.local A # Get IPv4 address
dns-sd -Q hostname.local AAAA # Get IPv6 address
```
### Using dig Command
The `dig` command can also query mDNS directly:
```bash
# Query for a specific hostname
dig @224.0.0.251 -p 5353 hostname.local
# Query for all service types
dig @224.0.0.251 -p 5353 _services._dns-sd._udp.local PTR
# Query for specific service instances
dig @224.0.0.251 -p 5353 _http._tcp.local PTR
# Get detailed information with additional records
dig @224.0.0.251 -p 5353 _soundtouch._tcp.local PTR +additional
```
### Advanced mDNS Monitoring
```bash
# Monitor all mDNS traffic (requires sudo)
sudo tcpdump -i any -n -s 0 'port 5353'
# Monitor specific service announcements
sudo tcpdump -i any -n -s 0 -A 'port 5353 and host 224.0.0.251'
# Monitor with human-readable timestamps
sudo tcpdump -i any -n -s 0 -t -A 'port 5353'
```
### With Homebrew (Optional)
For additional tools, you can install Avahi:
```bash
brew install avahi
# Browse all services
avahi-browse -a
# Browse with verbose details
avahi-browse -a -v -t
# Browse only for a limited time
avahi-browse -a -t --timeout=10
# Resolve a specific service
avahi-resolve -n hostname.local
# Publish a test service
avahi-publish -s "Test Service" _http._tcp 8080
```
## SSDP (Simple Service Discovery Protocol)
**Multicast Address:** `239.255.255.250:1900`
SSDP is used by UPnP devices to advertise and discover services. It uses HTTP-like messages over UDP multicast.
### Active Discovery (M-SEARCH)
This method sends out discovery requests and waits for responses:
**Terminal 1 - Capture responses:**
```bash
# Monitor all SSDP traffic
sudo tcpdump -i any -n -A 'udp port 1900'
# Monitor with better formatting
sudo tcpdump -i any -n -s 0 -A 'udp port 1900' | grep -E '(M-SEARCH|HTTP|NOTIFY|ST:|USN:|LOCATION:)'
```
**Terminal 2 - Send discovery requests:**
```bash
# Basic discovery for all devices
echo -e "M-SEARCH * HTTP/1.1\r\nHost:239.255.255.250:1900\r\nST:ssdp:all\r\nMan:\"ssdp:discover\"\r\nMX:3\r\n\r\n" | nc -u 239.255.255.250 1900
# Search for specific device types
echo -e "M-SEARCH * HTTP/1.1\r\nHost:239.255.255.250:1900\r\nST:urn:schemas-upnp-org:device:MediaRenderer:1\r\nMan:\"ssdp:discover\"\r\nMX:3\r\n\r\n" | nc -u 239.255.255.250 1900
# Search for root devices only
echo -e "M-SEARCH * HTTP/1.1\r\nHost:239.255.255.250:1900\r\nST:upnp:rootdevice\r\nMan:\"ssdp:discover\"\r\nMX:3\r\n\r\n" | nc -u 239.255.255.250 1900
# Search with longer timeout for slow devices
echo -e "M-SEARCH * HTTP/1.1\r\nHost:239.255.255.250:1900\r\nST:ssdp:all\r\nMan:\"ssdp:discover\"\r\nMX:10\r\n\r\n" | nc -u 239.255.255.250 1900
```
### Passive Listening (NOTIFY messages)
Devices periodically send NOTIFY messages to announce their presence:
```bash
# Simple listening (may miss some messages)
nc -ul 1900
# More reliable listening with proper multicast join
# First, install socat if not available
brew install socat
# Listen to multicast SSDP traffic
socat - UDP4-RECVFROM:1900,ip-add-membership=239.255.255.250:0.0.0.0,fork
# Alternative: bind to specific interface
socat - UDP4-RECVFROM:1900,ip-add-membership=239.255.255.250:en0,fork
```
### Python Script for SSDP Discovery
For more reliable and detailed discovery, use this Python script:
```python
#!/usr/bin/env python3
"""
SSDP Discovery Script
Sends M-SEARCH requests and collects responses from UPnP devices.
"""
import socket
import time
import re
from urllib.parse import urlparse
# M-SEARCH message for discovering all SSDP devices
MSEARCH_MSG = \
'M-SEARCH * HTTP/1.1\r\n' \
'HOST:239.255.255.250:1900\r\n' \
'ST:ssdp:all\r\n' \
'MX:3\r\n' \
'MAN:"ssdp:discover"\r\n' \
'\r\n'
def discover_devices(timeout=5, retries=2):
"""Discover UPnP devices using SSDP."""
devices = {}
for attempt in range(retries):
print(f"\n--- Discovery attempt {attempt + 1} ---")
# Create UDP socket
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
sock.settimeout(timeout)
try:
# Send M-SEARCH request
sock.sendto(MSEARCH_MSG.encode(), ('239.255.255.250', 1900))
# Collect responses
start_time = time.time()
while time.time() - start_time < timeout:
try:
data, addr = sock.recvfrom(8192)
response = data.decode('utf-8', errors='ignore')
# Parse the response
device_info = parse_ssdp_response(response, addr)
if device_info:
# Use USN as unique identifier
usn = device_info.get('USN', f"{addr[0]}:unknown")
devices[usn] = device_info
except socket.timeout:
continue
except Exception as e:
print(f"Error receiving data: {e}")
continue
except Exception as e:
print(f"Discovery attempt {attempt + 1} failed: {e}")
finally:
sock.close()
return devices
def parse_ssdp_response(response, addr):
"""Parse SSDP response and extract device information."""
lines = response.split('\r\n')
# Check if it's a valid HTTP response
if not lines[0].startswith('HTTP/1.1 200 OK'):
return None
device_info = {
'IP': addr[0],
'Port': addr[1],
'Raw': response
}
# Parse headers
for line in lines[1:]:
if ':' in line:
key, value = line.split(':', 1)
device_info[key.strip().upper()] = value.strip()
return device_info
def print_device_summary(devices):
"""Print a summary of discovered devices."""
if not devices:
print("\nNo devices discovered.")
return
print(f"\n--- Discovered {len(devices)} devices ---")
for usn, device in devices.items():
print(f"\nDevice: {device.get('SERVER', 'Unknown')}")
print(f" IP: {device['IP']}")
print(f" USN: {device.get('USN', 'N/A')}")
print(f" ST: {device.get('ST', 'N/A')}")
location = device.get('LOCATION')
if location:
parsed = urlparse(location)
print(f" Location: {location}")
print(f" Host: {parsed.hostname}:{parsed.port}")
def print_detailed_info(devices):
"""Print detailed information for all devices."""
for i, (usn, device) in enumerate(devices.items(), 1):
print(f"\n{'='*60}")
print(f"Device {i}: {device['IP']}")
print(f"{'='*60}")
print(device['Raw'])
if __name__ == "__main__":
print("SSDP Device Discovery")
print("Searching for UPnP devices on the network...")
# Discover devices
devices = discover_devices(timeout=5, retries=2)
# Print results
print_device_summary(devices)
# Ask if user wants detailed info
if devices:
response = input("\nShow detailed device information? (y/N): ")
if response.lower() == 'y':
print_detailed_info(devices)
```
Save this script and run it:
```bash
# Save the script
cat > ssdp_discovery.py << 'EOF'
# [paste the Python script above]
EOF
# Make it executable
chmod +x ssdp_discovery.py
# Run the discovery
python3 ssdp_discovery.py
```
### SSDP Message Types
Understanding SSDP message types helps interpret the traffic:
**M-SEARCH Request:**
```
M-SEARCH * HTTP/1.1
HOST:239.255.255.250:1900
ST:ssdp:all
MAN:"ssdp:discover"
MX:3
```
**NOTIFY Advertisement:**
```
NOTIFY * HTTP/1.1
HOST:239.255.255.250:1900
CACHE-CONTROL:max-age=1800
LOCATION:http://192.168.1.100:8090/device_description.xml
NT:upnp:rootdevice
NTS:ssdp:alive
USN:uuid:12345678-1234-1234-1234-123456789012::upnp:rootdevice
```
**HTTP Response:**
```
HTTP/1.1 200 OK
CACHE-CONTROL:max-age=1800
DATE:Wed, 18 Dec 2024 10:30:00 GMT
EXT:
LOCATION:http://192.168.1.100:8090/device_description.xml
SERVER:Linux/3.0 UPnP/1.0 Device/1.0
ST:upnp:rootdevice
USN:uuid:12345678-1234-1234-1234-123456789012::upnp:rootdevice
```
## Network Interface Discovery
### Find Your Network Interfaces
```bash
# List all network interfaces
ifconfig
# Show only active interfaces with IP addresses
ifconfig | grep -A 1 "inet "
# Show routing table to find default interface
netstat -rn | grep default
# Use route command (alternative)
route get default
```
### Find Your Network Segment
```bash
# Get your IP and netmask
ifconfig en0 | grep inet
# Show ARP table (devices that have communicated recently)
arp -a
# Scan local network segment (requires nmap)
brew install nmap
nmap -sn 192.168.1.0/24 # Adjust network range as needed
# Quick ping sweep (built-in)
for i in {1..254}; do ping -c 1 -t 1 192.168.1.$i >/dev/null 2>&1 && echo "192.168.1.$i is up"; done
```
## Troubleshooting Discovery Issues
### Common Problems and Solutions
**1. No responses to mDNS queries:**
```bash
# Check if mDNS daemon is running
sudo launchctl list | grep mDNSResponder
# Restart mDNS if needed (rarely required)
sudo launchctl kickstart -k system/com.apple.mDNSResponder
# Test basic mDNS functionality
dns-sd -B _services._dns-sd._udp local.
```
**2. No responses to SSDP queries:**
```bash
# Check if firewall is blocking multicast
sudo pfctl -sr | grep 1900
# Test multicast connectivity
ping 239.255.255.250
# Check interface supports multicast
ifconfig en0 | grep MULTICAST
```
**3. Network interface issues:**
```bash
# Check which interface is being used
route get 239.255.255.250
# Force specific interface for testing
ping -I en0 239.255.255.250
sudo tcpdump -i en0 'port 5353 or port 1900'
```
**4. Firewall blocking discovery:**
```bash
# Check macOS firewall status
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate
# Temporarily disable firewall for testing (BE CAREFUL)
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate off
# Re-enable firewall after testing
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate on
```
### Debugging Tools
**Monitor all discovery traffic:**
```bash
# Watch both mDNS and SSDP traffic
sudo tcpdump -i any -n -s 0 'port 5353 or port 1900'
# Save traffic to file for analysis
sudo tcpdump -i any -n -s 0 -w discovery.pcap 'port 5353 or port 1900'
# Analyze with specific filters
sudo tcpdump -i any -n -A 'port 5353' | grep -i soundtouch
```
**Network connectivity tests:**
```bash
# Test multicast group membership
netstat -g
# Test UDP connectivity
nc -u 192.168.1.100 8090 # Replace with actual device IP
# Test HTTP connectivity to discovered devices
curl -i http://192.168.1.100:8090/info # SoundTouch info endpoint
```
## Protocol Comparison
| Protocol | Port | Multicast Address | Use Case | Discovery Method |
|----------|------|------------------|----------|------------------|
| **mDNS** | 5353 | 224.0.0.251 | Apple devices, printers, local services | Query `.local` names, browse service types |
| **SSDP** | 1900 | 239.255.255.250 | UPnP devices, media servers, smart home | M-SEARCH requests, NOTIFY advertisements |
## Advanced Techniques
### Continuous Monitoring
Create a script to continuously monitor for new devices:
```bash
#!/bin/bash
# continuous_discovery.sh
echo "Starting continuous network discovery monitoring..."
echo "Press Ctrl+C to stop"
# Function to handle cleanup
cleanup() {
echo -e "\nStopping monitoring..."
kill $TCPDUMP_PID 2>/dev/null
kill $MDNS_PID 2>/dev/null
exit 0
}
trap cleanup INT TERM
# Start background monitoring
sudo tcpdump -i any -n -l 'port 5353 or port 1900' &
TCPDUMP_PID=$!
# Periodic active discovery
while true; do
echo -e "\n--- $(date) - Active Discovery Sweep ---"
# mDNS discovery
timeout 5 dns-sd -B _services._dns-sd._udp local. &
MDNS_PID=$!
# SSDP discovery
echo -e "M-SEARCH * HTTP/1.1\r\nHost:239.255.255.250:1900\r\nST:ssdp:all\r\nMan:\"ssdp:discover\"\r\nMX:3\r\n\r\n" | nc -u 239.255.255.250 1900
# Wait before next sweep
sleep 30
done
```
### Device-Specific Queries
For SoundTouch devices specifically:
```bash
# Look for SoundTouch-specific services
dns-sd -B _soundtouch._tcp local.
# Query for SoundTouch device descriptions
dns-sd -L "Bose SoundTouch" _soundtouch._tcp local.
# SSDP query for media renderers (SoundTouch devices often respond)
echo -e "M-SEARCH * HTTP/1.1\r\nHost:239.255.255.250:1900\r\nST:urn:schemas-upnp-org:device:MediaRenderer:1\r\nMan:\"ssdp:discover\"\r\nMX:5\r\n\r\n" | nc -u 239.255.255.250 1900
```
### Creating Test Services
For testing your discovery setup:
```bash
# Register a test mDNS service
dns-sd -R "TestDevice" _http._tcp local 8080 &
TEST_PID=$!
# Test that it can be discovered
dns-sd -B _http._tcp local.
# Clean up
kill $TEST_PID
```
## Security Considerations
- **Network exposure**: Discovery protocols broadcast device information
- **No authentication**: Discovery traffic is typically unauthenticated
- **Information disclosure**: Device details may be visible to entire network
- **Firewall configuration**: Consider allowing only necessary multicast traffic
## Quick Reference
### Essential Commands
```bash
# Quick mDNS service browse
dns-sd -B _services._dns-sd._udp local.
# Quick SSDP discovery
echo -e "M-SEARCH * HTTP/1.1\r\nHost:239.255.255.250:1900\r\nST:ssdp:all\r\nMan:\"ssdp:discover\"\r\nMX:3\r\n\r\n" | nc -u 239.255.255.250 1900
# Monitor all discovery traffic
sudo tcpdump -i any -n 'port 5353 or port 1900'
# Test specific device connectivity
curl -i http://device-ip:8090/info
```
### Common Service Types
| Service Type | Protocol | Description |
|-------------|----------|-------------|
| `_http._tcp` | mDNS | Web servers |
| `_airplay._tcp` | mDNS | AirPlay devices |
| `_soundtouch._tcp` | mDNS | Bose SoundTouch |
| `_ipp._tcp` | mDNS | Printers |
| `_ssh._tcp` | mDNS | SSH servers |
| `upnp:rootdevice` | SSDP | UPnP root devices |
| `urn:schemas-upnp-org:device:MediaRenderer:1` | SSDP | Media players |
This guide provides comprehensive tools for manually discovering and troubleshooting network services on macOS. Use these techniques to understand what devices and services are available on your network, debug discovery issues, and verify that your applications are correctly implementing discovery protocols.
+8 -5
View File
@@ -112,11 +112,14 @@ func (c *Config) GetPreferredDevicesAsDiscovered() []*models.DiscoveredDevice {
for _, device := range c.PreferredDevices {
discovered := &models.DiscoveredDevice{
Name: device.Name,
Host: device.Host,
Port: device.Port,
Location: fmt.Sprintf("http://%s:%d/info", device.Host, device.Port),
LastSeen: time.Now(),
Name: device.Name,
Host: device.Host,
Port: device.Port,
LastSeen: time.Now(),
DiscoveryMethod: "Configuration",
APIBaseURL: fmt.Sprintf("http://%s:%d/", device.Host, device.Port),
InfoURL: fmt.Sprintf("http://%s:%d/info", device.Host, device.Port),
ConfigName: device.Name,
}
devices = append(devices, discovered)
}
+3 -3
View File
@@ -348,9 +348,9 @@ func TestGetPreferredDevicesAsDiscovered(t *testing.T) {
t.Errorf("Expected port 8090, got %d", devices[0].Port)
}
expectedLocation := "http://192.168.1.100:8090/info"
if devices[0].Location != expectedLocation {
t.Errorf("Expected location '%s', got '%s'", expectedLocation, devices[0].Location)
expectedInfoURL := "http://192.168.1.100:8090/info"
if devices[0].InfoURL != expectedInfoURL {
t.Errorf("Expected info URL '%s', got '%s'", expectedInfoURL, devices[0].InfoURL)
}
}
+1 -1
View File
@@ -54,7 +54,7 @@ func ExampleService_DiscoverDevices() {
fmt.Printf("Device: %s\n", device.Name)
fmt.Printf(" Address: %s:%d\n", device.Host, device.Port)
fmt.Printf(" Serial: %s\n", device.SerialNo)
fmt.Printf(" Location: %s\n", device.Location)
fmt.Printf(" Info URL: %s\n", device.InfoURL)
fmt.Printf(" Host: %s:%d\n", device.Host, device.Port)
fmt.Println()
}
+9 -5
View File
@@ -202,11 +202,15 @@ func (m *MDNSDiscoveryService) serviceEntryToDevice(entry *mdns.ServiceEntry) *m
name = strings.ReplaceAll(name, `\\`, `\`)
device := &models.DiscoveredDevice{
Host: host,
Port: port,
Name: name,
Location: fmt.Sprintf("http://%s:%d/info", host, port),
LastSeen: time.Now(),
Host: host,
Port: port,
Name: name,
LastSeen: time.Now(),
DiscoveryMethod: "mDNS/Bonjour",
APIBaseURL: fmt.Sprintf("http://%s:%d/", host, port),
InfoURL: fmt.Sprintf("http://%s:%d/info", host, port),
MDNSHostname: entry.Host,
MDNSService: entry.Name,
}
log.Printf("mDNS: Created device '%s' at %s:%d (IP source: %s)", name, host, port, ipSource)
+2 -2
View File
@@ -51,8 +51,8 @@ func TestMDNSDiscoverDevices(t *testing.T) {
t.Error("Device name should not be empty")
}
if device.Location == "" {
t.Error("Device location should not be empty")
if device.InfoURL == "" {
t.Error("Device info URL should not be empty")
}
}
}
+111 -14
View File
@@ -110,6 +110,7 @@ package discovery
import (
"context"
"fmt"
"strings"
"sync"
"time"
@@ -179,7 +180,8 @@ func (u *UnifiedDiscoveryService) DiscoverDevices(ctx context.Context) ([]*model
go func() {
defer wg.Done()
devices, err := u.ssdpService.DiscoverDevices(ctx)
// Use PerformDiscovery directly to avoid double-adding configured devices
devices, err := u.ssdpService.PerformDiscovery(ctx)
if err == nil {
ssdpChan <- devices
} else {
@@ -319,26 +321,121 @@ func (u *UnifiedDiscoveryService) getConfiguredDevices() []*models.DiscoveredDev
return u.config.GetPreferredDevicesAsDiscovered()
}
// mergeDevices merges two device lists, avoiding duplicates based on host
// mergeDevices merges two device lists, combining protocol-specific data when same device found via multiple methods
func (u *UnifiedDiscoveryService) mergeDevices(existing, newDevices []*models.DiscoveredDevice) []*models.DiscoveredDevice {
hostSet := make(map[string]bool)
result := make([]*models.DiscoveredDevice, 0, len(existing)+len(newDevices))
deviceMap := make(map[string]*models.DiscoveredDevice)
// Add existing devices
// Add existing devices to map
for _, device := range existing {
if !hostSet[device.Host] {
result = append(result, device)
hostSet[device.Host] = true
deviceMap[device.Host] = device
}
// Merge new devices, combining protocol-specific data for duplicates
for _, newDevice := range newDevices {
if existingDevice, exists := deviceMap[newDevice.Host]; exists {
// Same device found via different protocol - merge the data
mergedDevice := u.mergeDeviceData(existingDevice, newDevice)
deviceMap[newDevice.Host] = mergedDevice
} else {
// New device
deviceMap[newDevice.Host] = newDevice
}
}
// Add new devices if not already present
for _, device := range newDevices {
if !hostSet[device.Host] {
result = append(result, device)
hostSet[device.Host] = true
}
// Convert map back to slice
result := make([]*models.DiscoveredDevice, 0, len(deviceMap))
for _, device := range deviceMap {
result = append(result, device)
}
return result
}
// mergeDeviceData combines data from two DiscoveredDevice instances representing the same physical device
func (u *UnifiedDiscoveryService) mergeDeviceData(existing, newDevice *models.DiscoveredDevice) *models.DiscoveredDevice {
// Start with the existing device as base
merged := *existing
// Update last seen to the most recent
if newDevice.LastSeen.After(existing.LastSeen) {
merged.LastSeen = newDevice.LastSeen
}
// Prefer more descriptive names
merged.Name = u.pickBestName(existing, newDevice)
// Combine discovery methods
if !strings.Contains(merged.DiscoveryMethod, newDevice.DiscoveryMethod) {
merged.DiscoveryMethod = merged.DiscoveryMethod + "+" + newDevice.DiscoveryMethod
}
// Merge protocol-specific data
u.mergeProtocolData(&merged, newDevice)
// Merge metadata if it exists
u.mergeMetadata(&merged, newDevice)
// Keep model info if available
u.mergeModelInfo(&merged, newDevice)
return &merged
}
func (u *UnifiedDiscoveryService) pickBestName(existing, newDevice *models.DiscoveredDevice) string {
// mDNS usually has better names than SSDP
switch {
case newDevice.DiscoveryMethod == "mDNS/Bonjour" && existing.DiscoveryMethod == "SSDP/UPnP":
return newDevice.Name
case existing.DiscoveryMethod == "Configuration":
// Keep user-configured name
return existing.Name
case newDevice.DiscoveryMethod == "Configuration":
return newDevice.Name
default:
return existing.Name
}
}
func (u *UnifiedDiscoveryService) mergeProtocolData(merged, newDevice *models.DiscoveredDevice) {
if newDevice.UPnPLocation != "" {
merged.UPnPLocation = newDevice.UPnPLocation
}
if newDevice.UPnPUSN != "" {
merged.UPnPUSN = newDevice.UPnPUSN
}
if newDevice.MDNSHostname != "" {
merged.MDNSHostname = newDevice.MDNSHostname
}
if newDevice.MDNSService != "" {
merged.MDNSService = newDevice.MDNSService
}
if newDevice.ConfigName != "" {
merged.ConfigName = newDevice.ConfigName
}
}
func (u *UnifiedDiscoveryService) mergeMetadata(merged, newDevice *models.DiscoveredDevice) {
if merged.Metadata == nil {
merged.Metadata = make(map[string]string)
}
if newDevice.Metadata != nil {
for k, v := range newDevice.Metadata {
merged.Metadata[k] = v
}
}
}
func (u *UnifiedDiscoveryService) mergeModelInfo(merged, newDevice *models.DiscoveredDevice) {
if newDevice.ModelID != "" && merged.ModelID == "" {
merged.ModelID = newDevice.ModelID
}
if newDevice.SerialNo != "" && merged.SerialNo == "" {
merged.SerialNo = newDevice.SerialNo
}
}
+2 -2
View File
@@ -111,8 +111,8 @@ func TestUnifiedDiscoverDevices(t *testing.T) {
t.Error("Device name should not be empty")
}
if device.Location == "" {
t.Error("Device location should not be empty")
if device.InfoURL == "" {
t.Error("Device info URL should not be empty")
}
}
}
+126 -74
View File
@@ -79,7 +79,7 @@ func (d *Service) DiscoverDevices(ctx context.Context) ([]*models.DiscoveredDevi
// Perform UPnP discovery if enabled
if d.config.UPnPEnabled {
upnpDevices, err := d.performDiscovery(ctx)
upnpDevices, err := d.PerformDiscovery(ctx)
if err != nil {
log.Printf("UPnP: Discovery failed: %v", err)
// Don't fail completely if UPnP fails, just log and continue with configured devices
@@ -137,86 +137,35 @@ func (d *Service) ClearCache() {
d.cache = make(map[string]*models.DiscoveredDevice)
}
// performDiscovery performs the actual UPnP SSDP discovery
func (d *Service) performDiscovery(ctx context.Context) ([]*models.DiscoveredDevice, error) {
// PerformDiscovery performs the actual UPnP SSDP discovery
func (d *Service) 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)
listener, err := d.setupUDPListener()
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)
return nil, err
}
defer func() {
_ = conn.Close()
_ = listener.Close()
}()
log.Printf("UPnP: Successfully connected to SSDP multicast address %s", ssdpAddr)
// Send M-SEARCH request
msearchRequest := d.buildMSearchRequest()
log.Printf("UPnP: Sending M-SEARCH request:\n%s", strings.TrimSpace(msearchRequest))
bytesWritten, err := conn.Write([]byte(msearchRequest))
multicastAddr, err := net.ResolveUDPAddr("udp4", ssdpAddr)
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: Failed to resolve multicast address %s: %v", ssdpAddr, err)
return nil, fmt.Errorf("failed to resolve multicast address: %w", err)
}
log.Printf("UPnP: Successfully sent M-SEARCH request (%d bytes)", bytesWritten)
if err = d.sendMSearch(listener, multicastAddr); err != nil {
return nil, err
}
// 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 {
var netErr net.Error
if errors.As(err, &netErr) && 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)
}
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)
}
}
responseCount, err := d.listenForResponses(ctx, listener, devices)
if err != nil {
return nil, err
}
// Convert map to slice
@@ -228,12 +177,111 @@ func (d *Service) performDiscovery(ctx context.Context) ([]*models.DiscoveredDev
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)
log.Printf("UPnP: Device #%d: %s at %s:%d (UPnP Location: %s)", i+1, device.Name, device.Host, device.Port, device.UPnPLocation)
}
return result, nil
}
func (d *Service) setupUDPListener() (*net.UDPConn, error) {
listenAddr, err := net.ResolveUDPAddr("udp4", ":0")
if err != nil {
log.Printf("UPnP: Failed to resolve listen address: %v", err)
return nil, fmt.Errorf("failed to resolve listen address: %w", err)
}
listener, err := net.ListenUDP("udp4", listenAddr)
if err != nil {
log.Printf("UPnP: Failed to create UDP listener: %v", err)
return nil, fmt.Errorf("failed to create UDP listener: %w", err)
}
addr := listener.LocalAddr()
localAddr, ok := addr.(*net.UDPAddr)
if !ok {
_ = listener.Close()
log.Printf("UPnP: Failed to cast local address to UDPAddr: %v", addr)
return nil, fmt.Errorf("failed to cast local address to UDPAddr: %v", addr)
}
log.Printf("UPnP: Created UDP listener on %s", localAddr.String())
return listener, nil
}
func (d *Service) sendMSearch(listener *net.UDPConn, multicastAddr *net.UDPAddr) error {
msearchRequest := d.buildMSearchRequest()
log.Printf("UPnP: Sending M-SEARCH request to %s:\n%s", ssdpAddr, strings.TrimSpace(msearchRequest))
bytesWritten, err := listener.WriteToUDP([]byte(msearchRequest), multicastAddr)
if err != nil {
log.Printf("UPnP: Failed to send M-SEARCH request: %v", err)
return fmt.Errorf("failed to send M-SEARCH: %w", err)
}
log.Printf("UPnP: Successfully sent M-SEARCH request (%d bytes)", bytesWritten)
return nil
}
func (d *Service) listenForResponses(ctx context.Context, listener *net.UDPConn, devices map[string]*models.DiscoveredDevice) (int, error) {
responseCount := 0
// Set read deadline
deadline := time.Now().Add(d.timeout)
if err := listener.SetReadDeadline(deadline); err != nil {
log.Printf("UPnP: Failed to set read deadline: %v", err)
return 0, 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 responseCount, ctx.Err()
default:
n, remoteAddr, err := listener.ReadFromUDP(buffer)
if err != nil {
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
log.Printf("UPnP: Read timeout reached after %v, stopping discovery", d.timeout)
return responseCount, nil
}
log.Printf("UPnP: Error reading response: %v", err)
return responseCount, fmt.Errorf("failed to read response: %w", err)
}
responseCount++
responseText := string(buffer[:n])
log.Printf("UPnP: Received response #%d (%d bytes) from %s:\n%s", responseCount, n, remoteAddr.String(), strings.TrimSpace(responseText))
device, err := d.parseResponse(responseText)
if err != nil {
log.Printf("UPnP: Failed to parse response #%d from %s: %v", responseCount, remoteAddr.String(), 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 from %s did not contain a valid SoundTouch device", responseCount, remoteAddr.String())
}
}
}
return responseCount, nil
}
// buildMSearchRequest builds the M-SEARCH request for SoundTouch devices
func (d *Service) buildMSearchRequest() string {
return fmt.Sprintf(
@@ -317,7 +365,7 @@ func (d *Service) parseResponse(response string) (*models.DiscoveredDevice, erro
log.Printf("UPnP: Found Location header: %s", location)
// Extract device information from location URL
device, err := d.parseLocationURL(location)
device, err := d.parseLocationURL(location, headers["usn"])
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)
@@ -338,7 +386,7 @@ func (d *Service) parseResponse(response string) (*models.DiscoveredDevice, erro
}
// parseLocationURL extracts basic device info from the location URL
func (d *Service) parseLocationURL(location string) (*models.DiscoveredDevice, error) {
func (d *Service) parseLocationURL(location, usn string) (*models.DiscoveredDevice, error) {
log.Printf("UPnP: Parsing location URL: %s", location)
// Parse the URL to extract host and port
@@ -355,11 +403,15 @@ func (d *Service) parseLocationURL(location string) (*models.DiscoveredDevice, e
log.Printf("UPnP: Extracted host='%s', using default port=%d", host, port)
device := &models.DiscoveredDevice{
Host: host,
Port: port,
Location: location,
LastSeen: time.Now(),
Name: fmt.Sprintf("SoundTouch-%s", host), // Default name
Host: host,
Port: port,
LastSeen: time.Now(),
Name: fmt.Sprintf("SoundTouch-%s", host), // Default name
DiscoveryMethod: "SSDP/UPnP",
APIBaseURL: fmt.Sprintf("http://%s:%d/", host, port),
InfoURL: fmt.Sprintf("http://%s:%d/info", host, port),
UPnPLocation: location,
UPnPUSN: usn,
}
return device, nil
+6 -6
View File
@@ -63,7 +63,7 @@ func TestParseLocationURL_Valid(t *testing.T) {
service := NewService(1 * time.Second)
location := "http://192.168.1.100:8090/device.xml"
device, err := service.parseLocationURL(location)
device, err := service.parseLocationURL(location, "")
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
@@ -76,8 +76,8 @@ func TestParseLocationURL_Valid(t *testing.T) {
t.Errorf("Expected port 8090, got %d", device.Port)
}
if device.Location != location {
t.Errorf("Expected location '%s', got '%s'", location, device.Location)
if device.UPnPLocation != location {
t.Errorf("Expected UPnP location '%s', got '%s'", location, device.UPnPLocation)
}
if device.Name == "" {
@@ -101,7 +101,7 @@ func TestParseLocationURL_Invalid(t *testing.T) {
}
for _, url := range invalidURLs {
_, err := service.parseLocationURL(url)
_, err := service.parseLocationURL(url, "")
if err == nil {
t.Errorf("Expected error for invalid URL '%s', got nil", url)
}
@@ -135,8 +135,8 @@ USN: uuid:12345678-1234-5678-9012-123456789012::urn:schemas-upnp-org:device:Medi
t.Errorf("Expected host '192.168.1.100', got '%s'", device.Host)
}
if device.Location != "http://192.168.1.100:8090/device.xml" {
t.Errorf("Expected location 'http://192.168.1.100:8090/device.xml', got '%s'", device.Location)
if device.UPnPLocation != "http://192.168.1.100:8090/device.xml" {
t.Errorf("Expected UPnP location 'http://192.168.1.100:8090/device.xml', got '%s'", device.UPnPLocation)
}
}
+57 -8
View File
@@ -53,13 +53,62 @@ func (e *APIError) Error() string {
return e.Message
}
// DiscoveredDevice represents a device found through UPnP discovery
// DiscoveredDevice represents a device found through network discovery
type DiscoveredDevice struct {
Name string `json:"name"`
Host string `json:"host"`
Port int `json:"port"`
ModelID string `json:"model_id"`
SerialNo string `json:"serial_no"`
Location string `json:"location"`
LastSeen time.Time `json:"last_seen"`
Name string `json:"name"`
Host string `json:"host"`
Port int `json:"port"`
ModelID string `json:"model_id"`
SerialNo string `json:"serial_no"`
LastSeen time.Time `json:"last_seen"`
DiscoveryMethod string `json:"discovery_method"`
// Standard URLs
APIBaseURL string `json:"api_base_url"` // http://host:port/
InfoURL string `json:"info_url"` // http://host:port/info
// Protocol-specific details
UPnPLocation string `json:"upnp_location,omitempty"` // UPnP device description XML URL
UPnPUSN string `json:"upnp_usn,omitempty"` // UPnP Unique Service Name
MDNSHostname string `json:"mdns_hostname,omitempty"` // mDNS hostname (e.g., "device.local.")
MDNSService string `json:"mdns_service,omitempty"` // mDNS service name
ConfigName string `json:"config_name,omitempty"` // Original name from config
// Additional metadata
Metadata map[string]string `json:"metadata,omitempty"`
}
// GetStandardURLs returns the standard API URLs for this device
func (d *DiscoveredDevice) GetStandardURLs() map[string]string {
return map[string]string{
"base": d.APIBaseURL,
"info": d.InfoURL,
}
}
// GetProtocolSpecificData returns protocol-specific information
func (d *DiscoveredDevice) GetProtocolSpecificData() map[string]interface{} {
data := make(map[string]interface{})
if d.UPnPLocation != "" {
data["upnp"] = map[string]string{
"location": d.UPnPLocation,
"usn": d.UPnPUSN,
}
}
if d.MDNSHostname != "" {
data["mdns"] = map[string]string{
"hostname": d.MDNSHostname,
"service": d.MDNSService,
}
}
if d.ConfigName != "" {
data["config"] = map[string]string{
"original_name": d.ConfigName,
}
}
return data
}