mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
Fixes CodeQL go/log-injection alerts in the final batch of packages. New logutil.go helpers: pkg/client, pkg/testutils/amazon, pkg/testutils/spotify, cmd/soundtouch-service, cmd/soundtouch-web, cmd/dummy-speaker, cmd/mdns-scanner. pkg/discovery/logger.go: added sanitizeLog and a nil-safe remoteAddrString helper to the existing file (alongside logVerbose). Call sites wrapped across 11 files — device IDs, source types, hostnames, IPs, interface names, URLs, service names, HTTP method/form values, WebSocket URLs and payloads, TLS SNI names, remote addresses. No behaviour change. golangci-lint and make check pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
648 lines
19 KiB
Go
648 lines
19 KiB
Go
package discovery
|
|
|
|
import (
|
|
"context"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gesellix/bose-soundtouch/pkg/config"
|
|
"github.com/gesellix/bose-soundtouch/pkg/models"
|
|
"golang.org/x/net/ipv4"
|
|
)
|
|
|
|
// Service handles UPnP SSDP discovery of SoundTouch devices
|
|
type Service struct {
|
|
timeout time.Duration
|
|
cache map[string]*models.DiscoveredDevice
|
|
cacheTTL time.Duration
|
|
mutex sync.RWMutex
|
|
config *config.Config
|
|
httpClient *http.Client
|
|
ifaceName string
|
|
}
|
|
|
|
// NewService creates a new UPnP discovery service
|
|
func NewService(timeout time.Duration) *Service {
|
|
if timeout == 0 {
|
|
timeout = defaultTimeout
|
|
}
|
|
|
|
return &Service{
|
|
timeout: timeout,
|
|
cache: make(map[string]*models.DiscoveredDevice),
|
|
cacheTTL: defaultCacheTTL,
|
|
mutex: sync.RWMutex{},
|
|
config: config.DefaultConfig(),
|
|
httpClient: &http.Client{Timeout: 5 * time.Second},
|
|
}
|
|
}
|
|
|
|
// NewServiceWithConfig creates a new discovery service with configuration
|
|
func NewServiceWithConfig(cfg *config.Config) *Service {
|
|
timeout := cfg.DiscoveryTimeout
|
|
if timeout == 0 {
|
|
timeout = defaultTimeout
|
|
}
|
|
|
|
cacheTTL := cfg.CacheTTL
|
|
if cacheTTL == 0 {
|
|
cacheTTL = defaultCacheTTL
|
|
}
|
|
|
|
return &Service{
|
|
timeout: timeout,
|
|
cache: make(map[string]*models.DiscoveredDevice),
|
|
cacheTTL: cacheTTL,
|
|
mutex: sync.RWMutex{},
|
|
config: cfg,
|
|
httpClient: &http.Client{Timeout: 5 * time.Second},
|
|
ifaceName: cfg.DiscoveryInterface,
|
|
}
|
|
}
|
|
|
|
// DiscoverDevices discovers all SoundTouch devices on the network
|
|
func (d *Service) DiscoverDevices(ctx context.Context) ([]*models.DiscoveredDevice, error) {
|
|
// Check cache first
|
|
d.cleanupCache()
|
|
|
|
cached := d.getCachedDevices()
|
|
if len(cached) > 0 {
|
|
return cached, nil
|
|
}
|
|
|
|
var allDevices []*models.DiscoveredDevice
|
|
|
|
// Add configured devices first
|
|
configuredDevices := d.getConfiguredDevices()
|
|
allDevices = append(allDevices, configuredDevices...)
|
|
|
|
// Perform UPnP discovery if enabled
|
|
if d.config.UPnPEnabled {
|
|
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
|
|
// We'll just use configured devices
|
|
} else {
|
|
// Merge UPnP devices, avoiding duplicates
|
|
allDevices = d.mergeDevices(allDevices, upnpDevices)
|
|
}
|
|
}
|
|
|
|
// Update cache
|
|
d.updateCache(allDevices)
|
|
|
|
return allDevices, nil
|
|
}
|
|
|
|
// DiscoverDevice discovers a specific SoundTouch device by host
|
|
func (d *Service) DiscoverDevice(ctx context.Context, host string) (*models.DiscoveredDevice, error) {
|
|
// Check cache first
|
|
d.mutex.RLock()
|
|
|
|
if device, exists := d.cache[host]; exists && time.Since(device.LastSeen) < d.cacheTTL {
|
|
d.mutex.RUnlock()
|
|
return device, nil
|
|
}
|
|
|
|
d.mutex.RUnlock()
|
|
|
|
// Try to discover all devices and find the specific one
|
|
devices, err := d.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 (d *Service) GetCachedDevices() []*models.DiscoveredDevice {
|
|
d.cleanupCache()
|
|
return d.getCachedDevices()
|
|
}
|
|
|
|
// ClearCache clears the device cache
|
|
func (d *Service) ClearCache() {
|
|
d.mutex.Lock()
|
|
defer d.mutex.Unlock()
|
|
|
|
d.cache = make(map[string]*models.DiscoveredDevice)
|
|
}
|
|
|
|
// 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)
|
|
|
|
listener, err := d.setupUDPListener()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
defer func() {
|
|
_ = listener.Close()
|
|
}()
|
|
|
|
multicastAddr, err := net.ResolveUDPAddr("udp4", ssdpAddr)
|
|
if err != nil {
|
|
log.Printf("UPnP: Failed to resolve multicast address %s: %v", ssdpAddr, err)
|
|
return nil, fmt.Errorf("failed to resolve multicast address: %w", err)
|
|
}
|
|
|
|
if err = d.sendMSearch(listener, multicastAddr); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Listen for responses
|
|
devices := make(map[string]*models.DiscoveredDevice)
|
|
|
|
responseCount, err := d.listenForResponses(ctx, listener, devices)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Convert map to slice
|
|
result := make([]*models.DiscoveredDevice, 0, len(devices))
|
|
for _, device := range devices {
|
|
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 (UPnP Location: %s)", i+1, sanitizeLog(device.Name), sanitizeLog(device.Host), device.Port, sanitizeLog(device.UPnPLocation))
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (d *Service) setupUDPListener() (*net.UDPConn, error) {
|
|
listenIP, iface, err := d.resolveListenInterface()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
listenAddr := &net.UDPAddr{IP: listenIP, Port: 0}
|
|
|
|
listener, err := net.ListenUDP("udp4", listenAddr)
|
|
if err != nil {
|
|
log.Printf("UPnP: Failed to create UDP listener on %s: %v", listenAddr, 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)
|
|
}
|
|
|
|
// Pin the outgoing multicast packets to the configured interface so the
|
|
// M-SEARCH leaves through the right NIC on multi-homed hosts.
|
|
if iface != nil {
|
|
if err := ipv4.NewPacketConn(listener).SetMulticastInterface(iface); err != nil {
|
|
log.Printf("UPnP: Failed to set multicast interface to %q: %v", sanitizeLog(iface.Name), err)
|
|
// Continue regardless — the kernel will fall back to its own routing decision.
|
|
}
|
|
}
|
|
|
|
logVerbose("UPnP: Created UDP listener on %s", localAddr.String())
|
|
|
|
return listener, nil
|
|
}
|
|
|
|
// resolveListenInterface returns the source IP to bind the UDP listener to and
|
|
// the interface to use for outgoing multicast. When no interface is configured,
|
|
// the IP is nil (wildcard) and the iface is nil, preserving the historical
|
|
// behaviour where the kernel picks a route.
|
|
func (d *Service) resolveListenInterface() (net.IP, *net.Interface, error) {
|
|
if d.ifaceName == "" {
|
|
return nil, nil, nil
|
|
}
|
|
|
|
iface, err := net.InterfaceByName(d.ifaceName)
|
|
if err != nil {
|
|
log.Printf("UPnP: Configured interface %q not found: %v", sanitizeLog(d.ifaceName), err)
|
|
return nil, nil, fmt.Errorf("configured interface %q not found: %w", d.ifaceName, err)
|
|
}
|
|
|
|
addrs, err := iface.Addrs()
|
|
if err != nil {
|
|
log.Printf("UPnP: Failed to read addresses for interface %q: %v", sanitizeLog(d.ifaceName), err)
|
|
return nil, nil, fmt.Errorf("read addresses for interface %q: %w", d.ifaceName, err)
|
|
}
|
|
|
|
for _, addr := range addrs {
|
|
ipNet, ok := addr.(*net.IPNet)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
ipv4Addr := ipNet.IP.To4()
|
|
if ipv4Addr == nil || ipNet.IP.IsLoopback() {
|
|
continue
|
|
}
|
|
|
|
log.Printf("UPnP: Binding UDP listener to interface %q (%s)", sanitizeLog(iface.Name), sanitizeLog(ipv4Addr.String()))
|
|
|
|
return ipv4Addr, iface, nil
|
|
}
|
|
|
|
log.Printf("UPnP: Configured interface %q has no usable IPv4 address", sanitizeLog(d.ifaceName))
|
|
|
|
return nil, nil, fmt.Errorf("interface %q has no usable IPv4 address", d.ifaceName)
|
|
}
|
|
|
|
func (d *Service) sendMSearch(listener *net.UDPConn, multicastAddr *net.UDPAddr) error {
|
|
msearchRequest := d.buildMSearchRequest()
|
|
logVerbose("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)
|
|
}
|
|
|
|
logVerbose("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)
|
|
}
|
|
|
|
logVerbose("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():
|
|
logVerbose("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() {
|
|
logVerbose("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])
|
|
logVerbose("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, sanitizeLog(remoteAddr.String()), err)
|
|
continue // Skip invalid responses
|
|
}
|
|
|
|
if device != nil {
|
|
logVerbose("UPnP: Successfully parsed device from response #%d: %s at %s:%d", responseCount, device.Name, device.Host, device.Port)
|
|
devices[device.Host] = device
|
|
} else {
|
|
logVerbose("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 {
|
|
mx := int(d.timeout.Seconds())
|
|
if mx < 1 {
|
|
mx = 1
|
|
}
|
|
|
|
if mx > 5 {
|
|
mx = 5
|
|
}
|
|
|
|
return fmt.Sprintf(
|
|
"M-SEARCH * HTTP/1.1\r\n"+
|
|
"HOST: %s\r\n"+
|
|
"MAN: \"ssdp:discover\"\r\n"+
|
|
"ST: %s\r\n"+
|
|
"MX: %d\r\n"+
|
|
"\r\n",
|
|
ssdpAddr,
|
|
soundTouchURN,
|
|
mx,
|
|
)
|
|
}
|
|
|
|
// parseResponse parses UPnP SSDP response and extracts device information
|
|
func (d *Service) parseResponse(response string) (*models.DiscoveredDevice, error) {
|
|
logVerbose("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") {
|
|
lines = strings.Split(response, "\r\n")
|
|
} else {
|
|
lines = strings.Split(response, "\n")
|
|
}
|
|
|
|
// 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'", sanitizeLog(lines[0]))
|
|
return nil, fmt.Errorf("invalid HTTP response")
|
|
}
|
|
|
|
logVerbose("UPnP: Valid HTTP response detected")
|
|
|
|
headers := make(map[string]string)
|
|
|
|
for _, line := range lines[1:] {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
break
|
|
}
|
|
|
|
parts := strings.SplitN(line, ":", 2)
|
|
if len(parts) == 2 {
|
|
key := strings.TrimSpace(strings.ToLower(parts[0]))
|
|
value := strings.TrimSpace(parts[1])
|
|
headers[key] = value
|
|
}
|
|
}
|
|
|
|
logVerbose("UPnP: Parsed %d headers from response", len(headers))
|
|
|
|
for key, value := range headers {
|
|
logVerbose("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")
|
|
}
|
|
|
|
logVerbose("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", sanitizeLog(st))
|
|
return nil, fmt.Errorf("not a MediaRenderer device")
|
|
}
|
|
|
|
logVerbose("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")
|
|
}
|
|
|
|
logVerbose("UPnP: Found Location header: %s", location)
|
|
|
|
// Extract device information from location URL
|
|
device, err := d.parseLocationURL(location, headers["usn"])
|
|
if err != nil {
|
|
log.Printf("UPnP: Failed to parse location URL '%s': %v", sanitizeLog(location), err)
|
|
return nil, fmt.Errorf("failed to parse location URL: %w", err)
|
|
}
|
|
|
|
logVerbose("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. Crucially this
|
|
// also lets us reject non-Bose UPnP MediaRenderers (LG TVs, Onkyo /
|
|
// Yamaha receivers, Dreambox tuners, etc.) that responded to our
|
|
// generic `ST: …MediaRenderer:1` M-SEARCH. See issues #269 / #359.
|
|
if err := d.EnrichDeviceInfo(device, location); err != nil {
|
|
logVerbose("UPnP: Could not enrich device info from location '%s': %v — accepting tentatively (will be re-verified by /info probe)", location, err)
|
|
} else if !isBoseUPnPDevice(device) {
|
|
log.Printf("UPnP: Rejecting non-Bose device: model=%q (manufacturer not Bose / model not SoundTouch)", sanitizeLog(device.ModelID))
|
|
return nil, fmt.Errorf("non-Bose UPnP device: %s", device.ModelID)
|
|
} else {
|
|
logVerbose("UPnP: Successfully enriched device info for %s (model=%q)", device.Name, device.ModelID)
|
|
}
|
|
|
|
return device, nil
|
|
}
|
|
|
|
// isBoseUPnPDevice classifies an enriched UPnP device as Bose vs. not.
|
|
// Returns true when either the manufacturer string contains "bose" or
|
|
// the model name carries a SoundTouch-family marker. Case-insensitive.
|
|
//
|
|
// This is the discrimination point that keeps non-Bose UPnP
|
|
// MediaRenderers (LG TVs, Onkyo receivers, Dreambox tuners) from
|
|
// landing in the `default` account on the service side — they all
|
|
// reply to our generic MediaRenderer:1 M-SEARCH because that URN is
|
|
// not Bose-specific.
|
|
func isBoseUPnPDevice(device *models.DiscoveredDevice) bool {
|
|
if device == nil {
|
|
return false
|
|
}
|
|
|
|
manuf := strings.ToLower(device.Manufacturer)
|
|
model := strings.ToLower(device.ModelID)
|
|
|
|
if strings.Contains(manuf, "bose") {
|
|
return true
|
|
}
|
|
|
|
if strings.Contains(model, "soundtouch") {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// parseLocationURL extracts basic device info from the location URL
|
|
func (d *Service) parseLocationURL(location, usn string) (*models.DiscoveredDevice, error) {
|
|
logVerbose("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", sanitizeLog(location))
|
|
return nil, fmt.Errorf("invalid location URL format")
|
|
}
|
|
|
|
host := matches[1]
|
|
port := 8090 // Default SoundTouch port
|
|
logVerbose("UPnP: Extracted host='%s', using default port=%d", host, port)
|
|
|
|
device := &models.DiscoveredDevice{
|
|
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
|
|
}
|
|
|
|
// EnrichDeviceInfo tries to get additional device information from the device description
|
|
func (d *Service) EnrichDeviceInfo(device *models.DiscoveredDevice, location string) error {
|
|
logVerbose("UPnP: Attempting to enrich device info by fetching %s", location)
|
|
|
|
resp, err := d.httpClient.Get(location)
|
|
if err != nil {
|
|
log.Printf("UPnP: Failed to fetch device description from %s: %v", sanitizeLog(location), err)
|
|
return err
|
|
}
|
|
|
|
defer func() {
|
|
_ = resp.Body.Close()
|
|
}()
|
|
|
|
logVerbose("UPnP: Successfully fetched device description from %s (Status: %s)", location, resp.Status)
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
|
}
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read body: %w", err)
|
|
}
|
|
|
|
var upnpRoot struct {
|
|
XMLName xml.Name `xml:"root"`
|
|
Device struct {
|
|
FriendlyName string `xml:"friendlyName"`
|
|
Manufacturer string `xml:"manufacturer"`
|
|
ModelName string `xml:"modelName"`
|
|
SerialNumber string `xml:"serialNumber"`
|
|
} `xml:"device"`
|
|
}
|
|
|
|
if err := xml.Unmarshal(data, &upnpRoot); err != nil {
|
|
log.Printf("UPnP: Failed to parse device description from %s: %v", sanitizeLog(location), err)
|
|
return err
|
|
}
|
|
|
|
if upnpRoot.Device.FriendlyName != "" {
|
|
device.Name = upnpRoot.Device.FriendlyName
|
|
}
|
|
|
|
if upnpRoot.Device.Manufacturer != "" {
|
|
device.Manufacturer = upnpRoot.Device.Manufacturer
|
|
}
|
|
|
|
if upnpRoot.Device.ModelName != "" {
|
|
device.ModelID = upnpRoot.Device.ModelName
|
|
}
|
|
|
|
if upnpRoot.Device.SerialNumber != "" {
|
|
device.UPnPSerial = upnpRoot.Device.SerialNumber
|
|
}
|
|
|
|
logVerbose("UPnP: Enriched device info: Name='%s', Manufacturer='%s', Model='%s', UPnPSerial='%s'",
|
|
device.Name, device.Manufacturer, device.ModelID, device.UPnPSerial)
|
|
|
|
return nil
|
|
}
|
|
|
|
// updateCache updates the device cache with discovered devices
|
|
func (d *Service) updateCache(devices []*models.DiscoveredDevice) {
|
|
d.mutex.Lock()
|
|
defer d.mutex.Unlock()
|
|
|
|
for _, device := range devices {
|
|
d.cache[device.Host] = device
|
|
}
|
|
}
|
|
|
|
// getCachedDevices returns all valid cached devices (internal method)
|
|
func (d *Service) getCachedDevices() []*models.DiscoveredDevice {
|
|
d.mutex.RLock()
|
|
defer d.mutex.RUnlock()
|
|
|
|
devices := make([]*models.DiscoveredDevice, 0, len(d.cache))
|
|
for _, device := range d.cache {
|
|
if time.Since(device.LastSeen) < d.cacheTTL {
|
|
devices = append(devices, device)
|
|
}
|
|
}
|
|
|
|
return devices
|
|
}
|
|
|
|
// cleanupCache removes expired devices from cache
|
|
func (d *Service) cleanupCache() {
|
|
d.mutex.Lock()
|
|
defer d.mutex.Unlock()
|
|
|
|
for host, device := range d.cache {
|
|
if time.Since(device.LastSeen) >= d.cacheTTL {
|
|
delete(d.cache, host)
|
|
}
|
|
}
|
|
}
|
|
|
|
// getConfiguredDevices returns devices from configuration
|
|
func (d *Service) getConfiguredDevices() []*models.DiscoveredDevice {
|
|
return d.config.GetPreferredDevicesAsDiscovered()
|
|
}
|
|
|
|
// mergeDevices merges two device lists, avoiding duplicates based on host
|
|
func (d *Service) mergeDevices(existing, newDevices []*models.DiscoveredDevice) []*models.DiscoveredDevice {
|
|
hostSet := make(map[string]bool)
|
|
result := make([]*models.DiscoveredDevice, 0, len(existing)+len(newDevices))
|
|
|
|
// 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 newDevices {
|
|
if !hostSet[device.Host] {
|
|
result = append(result, device)
|
|
hostSet[device.Host] = true
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|