diff --git a/pkg/discovery/constants.go b/pkg/discovery/constants.go index c6c22ab..5472e25 100644 --- a/pkg/discovery/constants.go +++ b/pkg/discovery/constants.go @@ -1,7 +1,10 @@ // Package discovery provides device discovery functionality for Bose SoundTouch devices using mDNS and UPnP protocols. package discovery -import "time" +import ( + "strings" + "time" +) const ( // SSDP multicast address and port @@ -10,7 +13,9 @@ const ( // 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) + // mDNS service type for SoundTouch devices (matches Bose's actual service name). + // Retained as the canonical / primary service type for log lines and tests; + // the full set of accepted variants lives in soundTouchServiceTypes below. soundTouchServiceType = "_soundtouch._tcp" soundTouchDomain = "local." @@ -20,3 +25,34 @@ const ( // Default cache TTL defaultCacheTTL = 30 * time.Second ) + +// soundTouchServiceTypes lists every mDNS service-type variant we consider +// part of the SoundTouch family. mDNS doesn't support wildcard service-type +// queries at the protocol level, so the discovery code issues one parallel +// query per entry below and merges the results. Add new variants here as +// they're observed in the wild — Bose has historically advertised at +// least three: +// +// - _soundtouch._tcp : classic SoundTouch speakers (ST10/20/30, …) +// - _bose-soundtouch._tcp : seen on some newer firmware variants +// - _soundtouchstick._tcp : SoundTouch Wireless Adapter / dongle +var soundTouchServiceTypes = []string{ + "_soundtouch._tcp", + "_bose-soundtouch._tcp", + "_soundtouchstick._tcp", +} + +// isSoundTouchServiceName reports whether the mDNS service entry name +// belongs to any registered SoundTouch service type. Case-insensitive +// substring match — Bose's mDNS entries embed the service type after a +// dot (e.g. "Speaker._soundtouch._tcp.local."). +func isSoundTouchServiceName(name string) bool { + lower := strings.ToLower(name) + for _, t := range soundTouchServiceTypes { + if strings.Contains(lower, strings.ToLower(t)) { + return true + } + } + + return false +} diff --git a/pkg/discovery/mdns.go b/pkg/discovery/mdns.go index 6104a5c..9438f67 100644 --- a/pkg/discovery/mdns.go +++ b/pkg/discovery/mdns.go @@ -6,6 +6,7 @@ import ( "log" "net" "strings" + "sync" "time" "github.com/gesellix/bose-soundtouch/pkg/models" @@ -49,49 +50,37 @@ func (m *MDNSDiscoveryService) DiscoverDevices(ctx context.Context) ([]*models.D timeoutCtx, cancel := context.WithTimeout(ctx, m.timeout) defer cancel() - // Start mDNS query in a goroutine + // Fan out one query per SoundTouch service-type variant; mDNS has no + // wildcard service-type query, so we issue them in parallel and merge + // into a single entries channel. close(entries) only once all queries + // are done (or the timeout fires). go func() { defer close(entries) - log.Printf("mDNS: Starting discovery for service '%s.%s' with timeout %v", - soundTouchServiceType, soundTouchDomain, m.timeout) + log.Printf("mDNS: Starting discovery for %d service-type variant(s) with timeout %v", + len(soundTouchServiceTypes), m.timeout) - // IPv4-only query to fix "no route to host" errors on IPv6 - // This addresses the issue where hashicorp/mdns fails with: - // "write udp6 [::]:port->[ff02::fb]:5353: sendto: no route to host" - // The trailing dot in service names is handled correctly by separating - // service and domain parameters as expected by the library. - err := mdns.Query(&mdns.QueryParam{ - Service: "_soundtouch._tcp", - Domain: "local.", - Timeout: m.timeout, - Entries: entries, - DisableIPv6: true, // Force IPv4 only to avoid routing issues - Interface: m.getIPv4Interface(), // Use specific interface if available - }) - if err != nil { - log.Printf("mDNS IPv4 query failed: %v", err) + var wg sync.WaitGroup - // Fallback to standard query (both IPv4 and IPv6) - log.Printf("mDNS: Falling back to standard query...") + for _, service := range soundTouchServiceTypes { + wg.Add(1) - 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") - } - } else { - log.Printf("mDNS IPv4 query completed successfully") + go func(service string) { + defer wg.Done() + + m.queryService(service, entries) + }(service) } + + wg.Wait() + log.Printf("mDNS: All %d service-type queries finished", len(soundTouchServiceTypes)) }() - // Collect discovered devices + // Collect discovered devices, deduplicating by host:port since a single + // speaker may answer multiple service types (older firmware advertises + // both `_soundtouch._tcp` and `_bose-soundtouch._tcp` simultaneously). + seen := make(map[string]bool) + for { select { case <-timeoutCtx.Done(): @@ -107,23 +96,68 @@ func (m *MDNSDiscoveryService) DiscoverDevices(ctx context.Context) ([]*models.D 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) - // Only process SoundTouch devices - if !strings.Contains(entry.Name, "_soundtouch._tcp") { + // Only process SoundTouch-family services. + if !isSoundTouchServiceName(entry.Name) { log.Printf("mDNS: Skipping non-SoundTouch service: %s", entry.Name) continue } 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 { + if device == nil { log.Printf("mDNS: Failed to convert service entry to device (no valid IP address)") + continue } + + key := fmt.Sprintf("%s:%d", device.Host, device.Port) + if seen[key] { + log.Printf("mDNS: Skipping duplicate device %s (already seen via another service-type query)", key) + continue + } + + seen[key] = true + + log.Printf("mDNS: Successfully converted to device: %s at %s:%d", device.Name, device.Host, device.Port) + devices = append(devices, device) } } } +// queryService issues a single mDNS Query for the given service type +// against the IPv4 interface first, with a graceful fallback to the +// library's default (IPv4+IPv6) behaviour if the IPv4-only path fails. +// All results stream into the shared entries channel; the caller is +// responsible for fan-in deduplication. +func (m *MDNSDiscoveryService) queryService(service string, entries chan<- *mdns.ServiceEntry) { + log.Printf("mDNS: Query '%s.%s' starting", service, soundTouchDomain) + + err := mdns.Query(&mdns.QueryParam{ + Service: service, + Domain: "local.", + Timeout: m.timeout, + Entries: entries, + DisableIPv6: true, + Interface: m.getIPv4Interface(), + }) + if err == nil { + log.Printf("mDNS: Query '%s' (IPv4) completed successfully", service) + return + } + + log.Printf("mDNS: Query '%s' (IPv4) failed: %v — falling back to dual-stack", service, err) + + err = mdns.Query(&mdns.QueryParam{ + Service: service, + Domain: "local.", + Timeout: m.timeout, + Entries: entries, + }) + if err != nil { + log.Printf("mDNS: Query '%s' (dual-stack) failed: %v", service, err) + } else { + log.Printf("mDNS: Query '%s' (dual-stack) completed successfully", service) + } +} + // serviceEntryToDevice converts an mdns ServiceEntry to a DiscoveredDevice func (m *MDNSDiscoveryService) serviceEntryToDevice(entry *mdns.ServiceEntry) *models.DiscoveredDevice { if entry == nil { diff --git a/pkg/discovery/upnp.go b/pkg/discovery/upnp.go index 578b8b7..e74c3af 100644 --- a/pkg/discovery/upnp.go +++ b/pkg/discovery/upnp.go @@ -442,18 +442,50 @@ func (d *Service) parseResponse(response string) (*models.DiscoveredDevice, erro 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 + // 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 { - 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 + log.Printf("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)", device.ModelID) + return nil, fmt.Errorf("non-Bose UPnP device: %s", device.ModelID) } else { - log.Printf("UPnP: Successfully enriched device info for %s", device.Name) + log.Printf("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) { log.Printf("UPnP: Parsing location URL: %s", location) @@ -515,6 +547,7 @@ func (d *Service) EnrichDeviceInfo(device *models.DiscoveredDevice, location str 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"` @@ -529,6 +562,10 @@ func (d *Service) EnrichDeviceInfo(device *models.DiscoveredDevice, location str device.Name = upnpRoot.Device.FriendlyName } + if upnpRoot.Device.Manufacturer != "" { + device.Manufacturer = upnpRoot.Device.Manufacturer + } + if upnpRoot.Device.ModelName != "" { device.ModelID = upnpRoot.Device.ModelName } @@ -537,8 +574,8 @@ func (d *Service) EnrichDeviceInfo(device *models.DiscoveredDevice, location str device.UPnPSerial = upnpRoot.Device.SerialNumber } - log.Printf("UPnP: Enriched device info: Name='%s', Model='%s', UPnPSerial='%s'", - device.Name, device.ModelID, device.UPnPSerial) + log.Printf("UPnP: Enriched device info: Name='%s', Manufacturer='%s', Model='%s', UPnPSerial='%s'", + device.Name, device.Manufacturer, device.ModelID, device.UPnPSerial) return nil } diff --git a/pkg/discovery/upnp_bose_filter_test.go b/pkg/discovery/upnp_bose_filter_test.go new file mode 100644 index 0000000..90688c9 --- /dev/null +++ b/pkg/discovery/upnp_bose_filter_test.go @@ -0,0 +1,88 @@ +package discovery + +import ( + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/models" +) + +func TestIsBoseUPnPDevice(t *testing.T) { + cases := []struct { + name string + dev *models.DiscoveredDevice + want bool + }{ + { + name: "Bose manufacturer wins", + dev: &models.DiscoveredDevice{Manufacturer: "Bose Corporation", ModelID: "Generic"}, + want: true, + }, + { + name: "SoundTouch model wins even without manufacturer", + dev: &models.DiscoveredDevice{Manufacturer: "", ModelID: "SoundTouch 30 sm2"}, + want: true, + }, + { + name: "Case-insensitive manufacturer", + dev: &models.DiscoveredDevice{Manufacturer: "BOSE CORP"}, + want: true, + }, + { + name: "LG TV rejected", + dev: &models.DiscoveredDevice{Manufacturer: "LG Electronics", ModelID: "OLED55G2"}, + want: false, + }, + { + name: "Onkyo AVR rejected", + dev: &models.DiscoveredDevice{Manufacturer: "Onkyo Corporation", ModelID: "HT-R695"}, + want: false, + }, + { + name: "Dreambox rejected", + dev: &models.DiscoveredDevice{Manufacturer: "Dream Multimedia", ModelID: "dm920"}, + want: false, + }, + { + name: "Empty fields rejected", + dev: &models.DiscoveredDevice{}, + want: false, + }, + { + name: "Nil rejected", + dev: nil, + want: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isBoseUPnPDevice(tc.dev); got != tc.want { + t.Errorf("got %v, want %v (dev=%+v)", got, tc.want, tc.dev) + } + }) + } +} + +func TestIsSoundTouchServiceName(t *testing.T) { + cases := []struct { + name string + want bool + }{ + {"Bose-Wohnzimmer._soundtouch._tcp.local.", true}, + {"SoundTouch-Stick._soundtouchstick._tcp.local.", true}, + {"NewSpeaker._bose-soundtouch._tcp.local.", true}, + {"PrinterA._ipp._tcp.local.", false}, + {"TV._smarttv._tcp.local.", false}, + {"", false}, + // Case-insensitive: firmware might emit mixed-case + {"Speaker._SoundTouch._tcp.local.", true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isSoundTouchServiceName(tc.name); got != tc.want { + t.Errorf("isSoundTouchServiceName(%q) = %v, want %v", tc.name, got, tc.want) + } + }) + } +} diff --git a/pkg/models/device.go b/pkg/models/device.go index ec14b5b..bd55a0f 100644 --- a/pkg/models/device.go +++ b/pkg/models/device.go @@ -111,6 +111,7 @@ type DiscoveredDevice struct { UPnPLocation string `json:"upnp_location,omitempty"` // UPnP device description XML URL UPnPUSN string `json:"upnp_usn,omitempty"` // UPnP Unique Service Name UPnPSerial string `json:"upnp_serial,omitempty"` // Serial number from UPnP (MAC address) + Manufacturer string `json:"manufacturer,omitempty"` // Manufacturer from UPnP device description (used to reject non-Bose devices) 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 diff --git a/pkg/service/handlers/handlers_media.go b/pkg/service/handlers/handlers_media.go index d749e47..834925e 100644 --- a/pkg/service/handlers/handlers_media.go +++ b/pkg/service/handlers/handlers_media.go @@ -1,7 +1,9 @@ package handlers import ( + "crypto/sha256" "embed" + "encoding/hex" "encoding/json" "io/fs" "net/http" @@ -14,6 +16,58 @@ var indexHTML []byte //go:embed web/css/* web/js/* web/img/favicon-braille* web/img/favicon* var webFS embed.FS +// indexHTMLVersioned is the HTML the root handler serves: identical to +// indexHTML except the script.js and style.css references carry a +// ?v= query string so the browser cache invalidates whenever +// the asset content changes. Computed once at package init and reused +// per-request. webAssetHash is the truncated SHA-256 over the asset +// bodies; it's exposed for /setup/settings consumers that want to +// build versioned URLs against /web/* from their own DOM constructors. +var ( + indexHTMLVersioned []byte + webAssetHash string +) + +func init() { + webAssetHash = computeWebAssetHash() + indexHTMLVersioned = applyAssetVersionToHTML(indexHTML, webAssetHash) +} + +// computeWebAssetHash hashes the embedded script.js and style.css +// bodies into a short stable identifier. SHA-256 truncated to 12 +// hex chars is more than enough to detect content changes across +// release builds without bloating the URL. +func computeWebAssetHash() string { + h := sha256.New() + + for _, path := range []string{"web/js/script.js", "web/css/style.css"} { + data, err := webFS.ReadFile(path) + if err != nil { + continue + } + + _, _ = h.Write(data) + } + + return hex.EncodeToString(h.Sum(nil))[:12] +} + +// applyAssetVersionToHTML rewrites the script and stylesheet src/href +// attributes in the embedded HTML to carry a ?v= query string. +// Operates on the byte slice once at startup; HandleRoot then serves +// the cached output verbatim per request. +func applyAssetVersionToHTML(html []byte, hash string) []byte { + if hash == "" { + return html + } + + out := string(html) + out = strings.Replace(out, `href="/web/css/style.css"`, `href="/web/css/style.css?v=`+hash+`"`, 1) + out = strings.Replace(out, `src="/web/js/script.js"`, `src="/web/js/script.js?v=`+hash+`"`, 1) + + return []byte(out) +} + //go:embed static/media/* var mediaFS embed.FS @@ -60,7 +114,7 @@ func (s *Server) HandleRoot(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "text/html") - _, _ = w.Write(indexHTML) + _, _ = w.Write(indexHTMLVersioned) } // HandleWeb returns a handler for serving web resources. diff --git a/pkg/service/handlers/handlers_media_test.go b/pkg/service/handlers/handlers_media_test.go index e0fa352..1352704 100644 --- a/pkg/service/handlers/handlers_media_test.go +++ b/pkg/service/handlers/handlers_media_test.go @@ -185,3 +185,57 @@ func TestStaticWeb(t *testing.T) { t.Errorf("Web Root Favicon: Expected status NotFound, got %v", res.Status) } } + +func TestComputeWebAssetHash_StableAndShort(t *testing.T) { + got := computeWebAssetHash() + if len(got) != 12 { + t.Errorf("expected 12-char hash, got %d (%q)", len(got), got) + } + + if got != computeWebAssetHash() { + t.Errorf("hash should be stable across calls — same embedded FS") + } + + for _, c := range got { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + t.Errorf("hash must be lowercase hex, got %q", got) + break + } + } +} + +func TestApplyAssetVersionToHTML_InjectsQueryString(t *testing.T) { + const html = `` + + `` + + out := string(applyAssetVersionToHTML([]byte(html), "abc123")) + + if !strings.Contains(out, `/web/css/style.css?v=abc123`) { + t.Errorf("expected style.css to carry ?v=abc123, got: %s", out) + } + + if !strings.Contains(out, `/web/js/script.js?v=abc123`) { + t.Errorf("expected script.js to carry ?v=abc123, got: %s", out) + } +} + +func TestApplyAssetVersionToHTML_EmptyHashPassthrough(t *testing.T) { + const html = `` + + out := applyAssetVersionToHTML([]byte(html), "") + if string(out) != html { + t.Errorf("expected unchanged HTML for empty hash, got: %s", out) + } +} + +func TestIndexHTMLVersioned_CarriesHash(t *testing.T) { + body := string(indexHTMLVersioned) + + if !strings.Contains(body, "/web/js/script.js?v=") { + t.Errorf("indexHTMLVersioned must carry ?v= on script.js reference") + } + + if !strings.Contains(body, "/web/css/style.css?v=") { + t.Errorf("indexHTMLVersioned must carry ?v= on style.css reference") + } +} diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index 635c0b1..ea3fc2b 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -133,6 +133,7 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red health.RegisterPresetsCountCheck(s.healthRegistry, ds) health.RegisterPresetsConsistencyCheck(s.healthRegistry, ds) health.RegisterRefreshSourcesCheck(s.healthRegistry, ds) + health.RegisterDefaultAccountNonBoseDevicesCheck(s.healthRegistry, ds) // Health QuickFix executor for the empty-margeAccountUUID // finding from RegisterSpeakerInfoReachable. Lives here (not in diff --git a/pkg/service/handlers/web/js/script.js b/pkg/service/handlers/web/js/script.js index cf8afb1..d934f83 100644 --- a/pkg/service/handlers/web/js/script.js +++ b/pkg/service/handlers/web/js/script.js @@ -7,6 +7,51 @@ // to the user — they'd be misleading without context. const FAST_ERROR_MS = 150; +// copyTextToClipboard attempts navigator.clipboard.writeText first (modern +// async API, requires a secure context — HTTPS or localhost). On insecure +// contexts (plain HTTP at a LAN IP), the Clipboard API is unavailable, so +// we fall back to the legacy document.execCommand("copy") path using a +// throwaway off-screen textarea. Returns true on success, false on +// failure. Both paths preserve the page's current focus. +async function copyTextToClipboard(text) { + if (navigator.clipboard && window.isSecureContext) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch (e) { + // Fall through to the legacy path — some browsers still reject + // even when isSecureContext claims true (e.g. iframes without + // the clipboard-write permission). + } + } + + const ta = document.createElement("textarea"); + ta.value = text; + ta.setAttribute("readonly", ""); + ta.style.position = "absolute"; + ta.style.left = "-9999px"; + ta.style.top = "0"; + document.body.appendChild(ta); + + const previousActive = document.activeElement; + ta.select(); + + let ok = false; + try { + ok = document.execCommand("copy"); + } catch (e) { + ok = false; + } + + document.body.removeChild(ta); + + if (previousActive && typeof previousActive.focus === "function") { + previousActive.focus(); + } + + return ok; +} + async function probeBrowser443(lanHost, listenerPort, statusEl, serverLocalhostOK, serverLanOK) { const line = document.createElement("div"); line.style.fontSize = "0.85em"; @@ -4221,13 +4266,10 @@ function renderManualCommand(cmd) { copyBtn.textContent = "Copy"; copyBtn.style.alignSelf = "flex-start"; copyBtn.onclick = async () => { - try { - await navigator.clipboard.writeText(cmd.command); - const orig = copyBtn.textContent; - copyBtn.textContent = "Copied"; - setTimeout(() => { copyBtn.textContent = orig; }, 1200); - } catch (e) { - copyBtn.textContent = "Copy failed"; + const ok = await copyTextToClipboard(cmd.command); + copyBtn.textContent = ok ? "Copied" : "Copy failed"; + if (ok) { + setTimeout(() => { copyBtn.textContent = "Copy"; }, 1200); } }; row.appendChild(copyBtn); @@ -4653,13 +4695,10 @@ function unreachableBlock(probe) { const btn = document.createElement("button"); btn.textContent = "Copy"; btn.onclick = async () => { - try { - await navigator.clipboard.writeText(probe.curl_command); - const orig = btn.textContent; - btn.textContent = "Copied"; - setTimeout(() => { btn.textContent = orig; }, 1200); - } catch (e) { - btn.textContent = "Copy failed"; + const ok = await copyTextToClipboard(probe.curl_command); + btn.textContent = ok ? "Copied" : "Copy failed"; + if (ok) { + setTimeout(() => { btn.textContent = "Copy"; }, 1200); } }; row.appendChild(btn); diff --git a/pkg/service/health/checks_default_non_bose.go b/pkg/service/health/checks_default_non_bose.go new file mode 100644 index 0000000..9668adc --- /dev/null +++ b/pkg/service/health/checks_default_non_bose.go @@ -0,0 +1,132 @@ +package health + +import ( + "fmt" + "strings" + + "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" +) + +// CheckIDDefaultAccountNonBoseDevices is the registry id of the +// non-Bose-default-account-devices check. +const CheckIDDefaultAccountNonBoseDevices = "default_account_non_bose_devices" + +// FixIDEvictDefaultNonBoseDevice removes a non-Bose UPnP device from +// the "default" account directory. Implemented by the existing +// DataStore.RemoveDevice — this constant ties it to the finding it +// remediates. +const FixIDEvictDefaultNonBoseDevice = "evict_default_non_bose_device" + +// RegisterDefaultAccountNonBoseDevicesCheck registers the +// non-Bose-default-account-devices health check. Walks the entries +// under data/accounts/default/devices/ and flags any whose +// DeviceInfo.xml model/type doesn't look like a Bose SoundTouch +// product. These are leftover discovery hits from the LAN's broader +// UPnP MediaRenderer population — LG TVs, Onkyo / Yamaha receivers, +// Dreambox tuners — that responded to our generic +// `urn:schemas-upnp-org:device:MediaRenderer:1` M-SEARCH. +// +// Each flagged entry comes with an "Evict" QuickFix that removes the +// device's data directory; the live discovery filter (see +// `pkg/discovery/upnp.go isBoseUPnPDevice`) prevents the entry from +// being re-created on the next scan. +// +// Bose devices that still live under "default" (e.g. a fresh speaker +// before pairing completes) are intentionally ignored here — that's +// the consistency check's domain. +func RegisterDefaultAccountNonBoseDevicesCheck(r *Registry, ds *datastore.DataStore) { + r.Register(Check{ + ID: CheckIDDefaultAccountNonBoseDevices, + Title: "Default-account devices are SoundTouch speakers", + Run: func() []Finding { + return runDefaultAccountNonBoseDevicesCheck(ds) + }, + }) + + r.RegisterFix( + CheckIDDefaultAccountNonBoseDevices, + FixIDEvictDefaultNonBoseDevice, + func(target Target) (string, error) { + if target.Device == "" { + return "", fmt.Errorf("device is required") + } + + if err := ds.RemoveDevice("default", target.Device); err != nil { + return "", fmt.Errorf("remove default/%s: %w", target.Device, err) + } + + return fmt.Sprintf("Evicted %s from the default account. If it returns on the next scan, AfterTouch's discovery filter needs an update — please file a bug.", target.Device), nil + }, + ) +} + +func runDefaultAccountNonBoseDevicesCheck(ds *datastore.DataStore) []Finding { + devices, err := ds.ListAllDevices() + if err != nil { + return []Finding{{ + Severity: SeverityError, + Message: "Could not enumerate devices: " + err.Error(), + }} + } + + var findings []Finding + + for i := range devices { + dev := &devices[i] + if dev.AccountID != "default" { + continue + } + + if looksLikeSoundTouch(dev) { + continue + } + + findings = append(findings, Finding{ + Severity: SeverityWarning, + Target: Target{Account: "default", Device: dev.DeviceID}, + Message: fmt.Sprintf( + "Non-Bose device %q (type=%q) is stored under the default account.", + labelForDevice(dev), dev.ProductCode, + ), + Details: "Likely a UPnP MediaRenderer (TV / AV receiver / set-top box) that answered AfterTouch's generic discovery probe. " + + "Evict it via the QuickFix; the discovery filter introduced alongside this check (#269/#359) prevents it from being re-created.", + QuickFixes: []QuickFix{{ + ID: FixIDEvictDefaultNonBoseDevice, + Label: "Evict from default account", + Confirm: fmt.Sprintf("This will delete data/accounts/default/devices/%s/ and all its contents. The device entry was created by AfterTouch's discovery; no real speaker state is affected.", dev.DeviceID), + }}, + }) + } + + return findings +} + +// looksLikeSoundTouch returns true when the device's ProductCode / +// Name suggests it's a Bose SoundTouch product. The signal we have on +// disk is the `` element from /info, which Bose devices populate +// with strings like "SoundTouch 10 sm2" or just "SoundTouch"; non-Bose +// devices populate it with their own model name ("HT-R695", "dm920", +// "OLED55G2", …). Case-insensitive substring match — the on-disk file +// preserves whatever the device emitted, so we don't normalise. +func looksLikeSoundTouch(dev *models.ServiceDeviceInfo) bool { + if dev == nil { + return false + } + + hay := strings.ToLower(dev.ProductCode + " " + dev.Name) + + return strings.Contains(hay, "soundtouch") || strings.Contains(hay, "wave music system") +} + +func labelForDevice(dev *models.ServiceDeviceInfo) string { + if dev.Name != "" { + return dev.Name + } + + if dev.DeviceID != "" { + return dev.DeviceID + } + + return "(unnamed)" +} diff --git a/pkg/service/health/checks_default_non_bose_test.go b/pkg/service/health/checks_default_non_bose_test.go new file mode 100644 index 0000000..ff72eb4 --- /dev/null +++ b/pkg/service/health/checks_default_non_bose_test.go @@ -0,0 +1,98 @@ +package health + +import ( + "os" + "path/filepath" + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" +) + +func TestLooksLikeSoundTouch(t *testing.T) { + cases := []struct { + name string + dev *models.ServiceDeviceInfo + want bool + }{ + {name: "SoundTouch type", dev: &models.ServiceDeviceInfo{ProductCode: "SoundTouch", Name: "Bose_Bad"}, want: true}, + {name: "SoundTouch 10 sm2 type", dev: &models.ServiceDeviceInfo{ProductCode: "SoundTouch 10 sm2"}, want: true}, + {name: "Wave Music System III", dev: &models.ServiceDeviceInfo{ProductCode: "Wave Music System III"}, want: true}, + {name: "Onkyo HT-R695", dev: &models.ServiceDeviceInfo{ProductCode: "HT-R695", Name: "Onkyo HT-R695 E9A20F"}, want: false}, + {name: "Dreambox dm920", dev: &models.ServiceDeviceInfo{ProductCode: "dm920", Name: "dm920"}, want: false}, + {name: "LG OLED", dev: &models.ServiceDeviceInfo{ProductCode: "OLED55G2", Name: "[LG] webOS TV"}, want: false}, + {name: "Empty", dev: &models.ServiceDeviceInfo{}, want: false}, + {name: "Nil", dev: nil, want: false}, + {name: "Name only", dev: &models.ServiceDeviceInfo{ProductCode: "", Name: "My SoundTouch 30"}, want: true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := looksLikeSoundTouch(tc.dev); got != tc.want { + t.Errorf("got %v, want %v (dev=%+v)", got, tc.want, tc.dev) + } + }) + } +} + +// TestDefaultAccountNonBoseCheck_FlagsNonBoseAndIgnoresBose drives the +// check end-to-end against a temporary datastore seeded with the same +// shape we saw in NorbertBauer's #269 diagnostic bundle: a Dreambox +// and an Onkyo under default, plus an unpaired Bose SoundTouch that +// must NOT trigger the warning. +func TestDefaultAccountNonBoseCheck_FlagsNonBoseAndIgnoresBose(t *testing.T) { + tmp := t.TempDir() + ds := datastore.NewDataStore(tmp) + + t.Cleanup(func() { _ = ds.Close() }) + + mustWriteDeviceInfo(t, tmp, "default", "192.168.1.10", + `dm920dm920SSDP/UPnP`) + mustWriteDeviceInfo(t, tmp, "default", "192.168.1.12", + `Onkyo HT-R695 E9A20FHT-R695SSDP/UPnP`) + mustWriteDeviceInfo(t, tmp, "default", "AABBCCDDEEFF", + `Bose Living RoomSoundTouch 30 sm2SSDP/UPnP`) + + got := runDefaultAccountNonBoseDevicesCheck(ds) + + if len(got) != 2 { + t.Fatalf("expected 2 findings (Dreambox + Onkyo), got %d: %+v", len(got), got) + } + + flaggedIDs := map[string]bool{} + for _, f := range got { + flaggedIDs[f.Target.Device] = true + + if f.Severity != SeverityWarning { + t.Errorf("expected SeverityWarning, got %v on %+v", f.Severity, f) + } + + if len(f.QuickFixes) != 1 || f.QuickFixes[0].ID != FixIDEvictDefaultNonBoseDevice { + t.Errorf("expected one Evict QuickFix, got %+v", f.QuickFixes) + } + } + + if !flaggedIDs["192.168.1.10"] || !flaggedIDs["192.168.1.12"] { + t.Errorf("expected both Dreambox + Onkyo flagged, got: %v", flaggedIDs) + } + + if flaggedIDs["AABBCCDDEEFF"] { + t.Errorf("unpaired Bose SoundTouch must not be flagged; got: %v", flaggedIDs) + } +} + +// mustWriteDeviceInfo writes a DeviceInfo.xml under +// /accounts//devices//DeviceInfo.xml. +// Fails the test on any IO error. +func mustWriteDeviceInfo(t *testing.T, baseDir, account, device, body string) { + t.Helper() + + dir := filepath.Join(baseDir, "accounts", account, "devices", device) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(dir, "DeviceInfo.xml"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +}