mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
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:
co-authored by
Claude Opus 4.7
parent
bb11b9d48c
commit
56efb4fcdb
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user