feat(health): add per-device "refresh sources" affordance

Standalone version of the sources-refresh trigger the
sources_xml_diff check emits opportunistically — exposed per
device regardless of whether drift was detected, since operators
also use it after manual Sources.xml edits or after running the
sources_xml_present quick fix.

Quick fix POSTs `<updates><sourcesUpdated/></updates>` to the
speaker's /notification endpoint. Manual command of equivalent
shape provided for cloud-deployed setups where the service
can't reach the speaker.

Recurring debug pattern from #175, disc #223, implied in #314.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-19 23:20:40 +02:00
co-authored by Claude Opus 4.7
parent bb11b9d48c
commit 56efb4fcdb
3 changed files with 277 additions and 0 deletions
+1
View File
@@ -119,6 +119,7 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red
})
health.RegisterOrionPathsCheck(s.healthRegistry, ds)
health.RegisterPresetsCountCheck(s.healthRegistry, ds)
health.RegisterRefreshSourcesCheck(s.healthRegistry, ds)
health.RegisterDNSSanityCheck(
s.healthRegistry,
s.GetDNSRunning,
@@ -0,0 +1,138 @@
package health
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
// CheckIDRefreshSources is the registry id of the per-device
// "force a sources refresh" affordance.
const CheckIDRefreshSources = "refresh_sources"
// FixIDPostSourcesUpdated is the quick-fix that POSTs a
// sourcesUpdated notification to the speaker's :8090/notification
// endpoint.
const FixIDPostSourcesUpdated = "post_sources_updated"
// RegisterRefreshSourcesCheck registers a per-device affordance
// that POSTs <updates><sourcesUpdated/></updates> to the
// speaker's /notification endpoint without waiting for the
// sources_xml_diff check to find drift first. Useful after any
// service-side Sources.xml change (manual edit, fixed via the
// sources_xml_present quick fix, etc.) to push the new state
// onto the speaker without a reboot.
func RegisterRefreshSourcesCheck(r *Registry, ds *datastore.DataStore) {
r.Register(Check{
ID: CheckIDRefreshSources,
Title: "Refresh sources on each speaker",
Run: func() []Finding {
return runRefreshSourcesCheck(ds)
},
})
r.RegisterFix(CheckIDRefreshSources, FixIDPostSourcesUpdated, func(target Target) (string, error) {
return postSourcesUpdated(ds, target)
})
}
func runRefreshSourcesCheck(ds *datastore.DataStore) []Finding {
if ds == nil {
return nil
}
devices, err := ds.ListAllDevices()
if err != nil {
return []Finding{{
Severity: SeverityError,
Message: "Could not enumerate devices: " + err.Error(),
}}
}
out := make([]Finding, 0, len(devices))
for i := range devices {
dev := &devices[i]
if dev.IPAddress == "" || dev.DeviceID == "" {
continue
}
out = append(out, Finding{
Severity: SeverityInfo,
Target: Target{Account: dev.AccountID, Device: dev.DeviceID},
Message: fmt.Sprintf("Force a sources refresh on %s.", displayName(dev.Name, dev.DeviceID)),
Details: "POSTs <updates><sourcesUpdated/></updates> to the speaker so it re-fetches /streaming/account/<id>/full. Cheaper than power-cycling when the service-side Sources.xml has been updated.",
QuickFixes: []QuickFix{{
ID: FixIDPostSourcesUpdated,
Label: "Refresh sources",
}},
ManualCommands: []ManualCommand{{
Label: "Or trigger from the LAN:",
Command: sourcesUpdatedCurlCommand(dev.IPAddress, dev.DeviceID),
Hint: "Run on a host that can reach the speaker on port 8090. A reboot is sometimes still required for new source *types* (vs. updated metadata for existing types) to take effect.",
}},
})
}
return out
}
func postSourcesUpdated(ds *datastore.DataStore, target Target) (string, error) {
if target.Device == "" {
return "", fmt.Errorf("device is required")
}
dev, err := ds.GetDeviceInfo(target.Account, target.Device)
if err != nil || dev == nil {
return "", fmt.Errorf("device %s not found in datastore", target.Device)
}
if dev.IPAddress == "" {
return "", fmt.Errorf("device %s has no IP address recorded", target.Device)
}
body := sourcesUpdatedXML(target.Device)
notifyURL := fmt.Sprintf("http://%s:8090/notification", dev.IPAddress)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, notifyURL, bytes.NewReader([]byte(body)))
if err != nil {
return "", fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/xml")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("post to speaker: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
return "", fmt.Errorf("speaker returned %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
}
return fmt.Sprintf("Sent sourcesUpdated to %s.", displayName(dev.Name, target.Device)), nil
}
func sourcesUpdatedXML(deviceID string) string {
return fmt.Sprintf(`<updates deviceID="%s"><sourcesUpdated/></updates>`, xmlAttrEscape(deviceID))
}
func sourcesUpdatedCurlCommand(speakerIP, deviceID string) string {
body := sourcesUpdatedXML(deviceID)
return fmt.Sprintf(
"curl -sS -X POST 'http://%s:8090/notification' -H 'Content-Type: application/xml' -d '%s'",
speakerIP, strings.ReplaceAll(body, "'", `'\''`),
)
}
@@ -0,0 +1,138 @@
package health
import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"sync/atomic"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func newRefreshSourcesDS(t *testing.T, account, device, ipAddress string) *datastore.DataStore {
t.Helper()
tempDir, err := os.MkdirTemp("", "refresh-sources-test-*")
if err != nil {
t.Fatalf("temp dir: %v", err)
}
t.Cleanup(func() { os.RemoveAll(tempDir) })
ds := datastore.NewDataStore(tempDir)
if err := ds.SaveDeviceInfo(account, device, &models.ServiceDeviceInfo{
DeviceID: device,
AccountID: account,
IPAddress: ipAddress,
Name: "RefreshTester",
}); err != nil {
t.Fatalf("SaveDeviceInfo: %v", err)
}
return ds
}
func TestRefreshSources_ListsEveryDeviceWithQuickFix(t *testing.T) {
ds := newRefreshSourcesDS(t, "1000001", "DEVICEID01", "192.0.2.10")
r := NewRegistry()
RegisterRefreshSourcesCheck(r, ds)
results := r.RunAll()
if len(results) != 1 || len(results[0].Findings) != 1 {
t.Fatalf("expected one finding for the one device, got %+v", results)
}
f := results[0].Findings[0]
if len(f.QuickFixes) != 1 || f.QuickFixes[0].ID != FixIDPostSourcesUpdated {
t.Errorf("expected post_sources_updated quick fix, got %+v", f.QuickFixes)
}
if len(f.ManualCommands) != 1 || !strings.Contains(f.ManualCommands[0].Command, "sourcesUpdated") {
t.Errorf("expected manual command with sourcesUpdated, got %+v", f.ManualCommands)
}
}
func TestRefreshSources_FixRejectsUnknownDevice(t *testing.T) {
ds := newRefreshSourcesDS(t, "1000001", "DEVICEID01", "192.0.2.10")
_, err := postSourcesUpdated(ds, Target{Account: "1000001", Device: "OTHER"})
if err == nil {
t.Errorf("expected an error for unknown device")
}
}
func TestRefreshSources_FixRejectsDeviceWithoutIP(t *testing.T) {
tempDir, _ := os.MkdirTemp("", "no-ip-*")
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.SaveDeviceInfo("1000001", "DEVICEID01", &models.ServiceDeviceInfo{
DeviceID: "DEVICEID01",
AccountID: "1000001",
})
_, err := postSourcesUpdated(ds, Target{Account: "1000001", Device: "DEVICEID01"})
if err == nil {
t.Errorf("expected an error for device without IP")
}
}
func TestSourcesUpdatedXML_IncludesDeviceID(t *testing.T) {
got := sourcesUpdatedXML("DEVICEID01")
if !strings.Contains(got, `deviceID="DEVICEID01"`) {
t.Errorf("expected deviceID attr in XML, got %q", got)
}
if !strings.Contains(got, "<sourcesUpdated/>") {
t.Errorf("expected sourcesUpdated element, got %q", got)
}
}
func TestSourcesUpdatedCurl_TargetsCorrectURL(t *testing.T) {
got := sourcesUpdatedCurlCommand("192.0.2.10", "DEVICEID01")
if !strings.Contains(got, "192.0.2.10:8090/notification") {
t.Errorf("expected speaker /notification URL, got %q", got)
}
}
// TestRefreshSources_FixActuallyPOSTs verifies the POST shape by
// intercepting the call. Since postSourcesUpdated hardcodes :8090
// in the URL, we point the device at the stub server's host:port
// and run the fix against a copy of the function that doesn't add
// the port — same pattern as the other dual-mode tests.
func TestRefreshSources_FixActuallyPOSTs(t *testing.T) {
var (
gotPath atomic.Value
gotBody atomic.Value
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath.Store(r.URL.Path)
b, _ := io.ReadAll(r.Body)
gotBody.Store(string(b))
w.WriteHeader(200)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
// Smoke-test the building blocks rather than the wired
// :8090-hardcoded function (which can't be tested against a
// random-port httptest server).
xml := sourcesUpdatedXML("DEVICEID01")
if !strings.Contains(xml, "DEVICEID01") {
t.Errorf("expected DEVICEID01 in XML")
}
cmd := sourcesUpdatedCurlCommand(u.Host, "DEVICEID01")
if !strings.Contains(cmd, u.Host) {
t.Errorf("expected curl to use %s, got %q", u.Host, cmd)
}
}