mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 00:26:29 +00:00
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:
co-authored by
Claude Sonnet 4.6
parent
dbe123226c
commit
1cd4226f5b
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user