feat: tighter discovery filter + UX cleanups (#269, #345, #355, #359)

Four small, independent improvements bundled into one cut:

1. Restrict device discovery to SoundTouch-family services (#269/#359).
   - mDNS now queries all three SoundTouch service-type variants in
     parallel (_soundtouch._tcp, _bose-soundtouch._tcp, _soundtouchstick._tcp)
     and deduplicates results by host:port. mDNS has no native wildcard
     for service types, so we fan out one query per variant.
   - UPnP/SSDP M-SEARCH receives a manufacturer/modelName check after
     fetching the device description: devices whose manufacturer doesn't
     contain "bose" AND whose model doesn't contain "soundtouch" are
     rejected. Closes the loop on NorbertBauer's diagnostic bundle that
     showed a Dreambox dm920 and Onkyo HT-R695 living under the default
     account because they answered our generic MediaRenderer:1 probe.

2. New health check: default-account-contains-non-Bose-devices (#269).
   Walks devices keyed under data/accounts/default/devices/, flags any
   whose ProductCode/Name doesn't look SoundTouch, and offers an Evict
   QuickFix. Bose devices still in default (legitimate pre-pair) are
   intentionally ignored — that's the consistency check's domain.

3. Clipboard fallback for Copy buttons (#355). The two health-tab Copy
   buttons used navigator.clipboard.writeText, which requires a secure
   context. Over plain HTTP at a LAN IP the browser blocks it silently
   and the button shows "Copy failed". New copyTextToClipboard helper
   tries the modern API first, falls back to document.execCommand("copy")
   via an off-screen textarea.

4. Web UI static-asset cache-busting (#345). dekiesel needed Ctrl+F5 to
   see the v0.89 Download button after upgrade. The root HTML now
   carries a ?v=<hash> query string on /web/js/script.js and
   /web/css/style.css references. Hash is sha256 over the embedded asset
   bodies, truncated to 12 hex chars — stable per binary, changes when
   the assets change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-22 21:15:34 +02:00
co-authored by Claude Sonnet 4.6
parent dbe123226c
commit 1cd4226f5b
11 changed files with 638 additions and 64 deletions
+38 -2
View File
@@ -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
}
+74 -40
View File
@@ -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 {
+44 -7
View File
@@ -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
}
+88
View File
@@ -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)
}
})
}
}
+1
View File
@@ -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
+55 -1
View File
@@ -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=<hash> 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=<hash> 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.
@@ -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 = `<link rel="stylesheet" href="/web/css/style.css"/>` +
`<script src="/web/js/script.js"></script>`
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 = `<script src="/web/js/script.js"></script>`
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")
}
}
+1
View File
@@ -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
+53 -14
View File
@@ -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);
@@ -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 `<type>` 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)"
}
@@ -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",
`<?xml version="1.0" encoding="UTF-8"?><info deviceID="192.168.1.10"><name>dm920</name><type>dm920</type><discoveryMethod>SSDP/UPnP</discoveryMethod></info>`)
mustWriteDeviceInfo(t, tmp, "default", "192.168.1.12",
`<?xml version="1.0" encoding="UTF-8"?><info deviceID="192.168.1.12"><name>Onkyo HT-R695 E9A20F</name><type>HT-R695</type><discoveryMethod>SSDP/UPnP</discoveryMethod></info>`)
mustWriteDeviceInfo(t, tmp, "default", "AABBCCDDEEFF",
`<?xml version="1.0" encoding="UTF-8"?><info deviceID="AABBCCDDEEFF"><name>Bose Living Room</name><type>SoundTouch 30 sm2</type><discoveryMethod>SSDP/UPnP</discoveryMethod></info>`)
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
// <baseDir>/accounts/<account>/devices/<device>/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)
}
}