diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go
index 89b0242..6be3a38 100644
--- a/pkg/service/handlers/server.go
+++ b/pkg/service/handlers/server.go
@@ -106,6 +106,7 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red
health.RegisterSourcesXMLPresent(s.healthRegistry, ds)
health.RegisterSpeakerInfoReachable(s.healthRegistry, ds)
+ health.RegisterSourcesXMLDiff(s.healthRegistry, ds)
return s
}
diff --git a/pkg/service/health/checks_sources_diff.go b/pkg/service/health/checks_sources_diff.go
new file mode 100644
index 0000000..e0a491f
--- /dev/null
+++ b/pkg/service/health/checks_sources_diff.go
@@ -0,0 +1,211 @@
+package health
+
+import (
+ "context"
+ "encoding/xml"
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/gesellix/bose-soundtouch/pkg/service/datastore"
+)
+
+// CheckIDSourcesXMLDiff is the registry id of the speaker-vs-service
+// sources comparison check.
+const CheckIDSourcesXMLDiff = "sources_xml_diff"
+
+// speakerSourcesXML mirrors the speaker's /sources response.
+// Schema example:
+//
+//
+//
+// AUX IN
+//
+//
+// Note this is a *different* schema from the service-side
+// Sources.xml, which is why we compare the set of source-key types
+// rather than diffing the XML byte-for-byte.
+type speakerSourcesXML struct {
+ XMLName xml.Name `xml:"sources"`
+ Items []struct {
+ Source string `xml:"source,attr"`
+ Status string `xml:"status,attr"`
+ } `xml:"sourceItem"`
+}
+
+// RegisterSourcesXMLDiff registers the sources_xml_diff check
+// against r. For each known device it fetches the speaker's
+// /sources, compares the source-type set against the service's
+// Sources.xml, and emits findings for asymmetries.
+func RegisterSourcesXMLDiff(r *Registry, ds *datastore.DataStore) {
+ r.Register(Check{
+ ID: CheckIDSourcesXMLDiff,
+ Title: "Speaker /sources matches service Sources.xml",
+ Run: func() []Finding {
+ return runSourcesXMLDiff(ds)
+ },
+ })
+}
+
+func runSourcesXMLDiff(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(),
+ }}
+ }
+
+ var findings []Finding
+
+ for i := range devices {
+ dev := &devices[i]
+ if dev.IPAddress == "" || dev.AccountID == "" || dev.DeviceID == "" {
+ continue
+ }
+
+ findings = append(findings, diffSourcesForDevice(ds, dev.AccountID, dev.DeviceID, dev.IPAddress)...)
+ }
+
+ return findings
+}
+
+func diffSourcesForDevice(ds *datastore.DataStore, account, deviceID, ipAddress string) []Finding {
+ probeURL := fmt.Sprintf("http://%s:8090/sources", ipAddress)
+ return diffSourcesForDeviceWithURL(ds, account, deviceID, ipAddress, probeURL)
+}
+
+// diffSourcesForDeviceWithURL is the same as diffSourcesForDevice
+// but takes the speaker URL explicitly. Used by tests that point
+// at an httptest.Server bound to a random port.
+func diffSourcesForDeviceWithURL(ds *datastore.DataStore, account, deviceID, ipAddress, probeURL string) []Finding {
+ target := Target{Account: account, Device: deviceID}
+
+ // Service side: read configured sources (if Sources.xml is
+ // missing, leave the set empty — the sources_xml_present check
+ // already flags that case).
+ serviceSet := map[string]bool{}
+
+ if ds.HasConfiguredSources(account, deviceID) {
+ sources, err := ds.GetConfiguredSources(account, deviceID)
+ if err == nil {
+ for i := range sources {
+ if t := sources[i].SourceKey.Type; t != "" {
+ serviceSet[t] = true
+ }
+ }
+ }
+ }
+
+ res := ProbeGet(context.Background(), probeURL, 2*time.Second)
+ if !res.Reachable {
+ // Don't double-warn — the speaker_info_reachable check
+ // will already have flagged unreachable speakers. Surface
+ // only the manual command for this specific endpoint.
+ return []Finding{{
+ Severity: SeverityInfo,
+ Target: target,
+ Message: "Couldn't fetch /sources from the speaker; can't compare.",
+ ManualCommands: []ManualCommand{{
+ Label: "Compare manually:",
+ Command: res.CurlCommand,
+ Hint: "Paste the result here in a future revision, or just diff the source list against the service's Sources.xml by eye.",
+ }},
+ }}
+ }
+
+ if res.Status != 200 {
+ return []Finding{{
+ Severity: SeverityInfo,
+ Target: target,
+ Message: fmt.Sprintf("Speaker /sources returned HTTP %d.", res.Status),
+ }}
+ }
+
+ speakerSet, parseErr := parseSpeakerSources(res.Body)
+ if parseErr != nil {
+ return []Finding{{
+ Severity: SeverityWarning,
+ Target: target,
+ Message: "Speaker /sources reply isn't valid XML.",
+ Details: parseErr.Error(),
+ }}
+ }
+
+ missingOnSpeaker := setDifference(serviceSet, speakerSet)
+ missingOnService := setDifference(speakerSet, serviceSet)
+
+ var findings []Finding
+
+ if len(missingOnSpeaker) > 0 {
+ notifyCmd := fmt.Sprintf(
+ "curl -sS -X POST 'http://%s:8090/notification' -H 'Content-Type: application/xml' -d ''",
+ ipAddress, deviceID,
+ )
+
+ findings = append(findings, Finding{
+ Severity: SeverityWarning,
+ Target: target,
+ Message: fmt.Sprintf(
+ "Speaker is missing %d source type(s) the service advertises: %s.",
+ len(missingOnSpeaker), strings.Join(missingOnSpeaker, ", "),
+ ),
+ Details: "After a power-cycle the speaker fetches /full from the service and re-registers its source list. Forcing a sourcesUpdated notification triggers the same refresh without rebooting.",
+ ManualCommands: []ManualCommand{{
+ Label: "Trigger a sources refresh on the speaker:",
+ Command: notifyCmd,
+ Hint: "Run on a host that can reach the speaker on port 8090. A power-cycle is sometimes also required for the new source types to fully register (see docs/reference/radio-browser.md:21).",
+ }},
+ })
+ }
+
+ if len(missingOnService) > 0 {
+ findings = append(findings, Finding{
+ Severity: SeverityInfo,
+ Target: target,
+ Message: fmt.Sprintf(
+ "Speaker advertises %d source type(s) the service doesn't know about: %s.",
+ len(missingOnService), strings.Join(missingOnService, ", "),
+ ),
+ Details: "Usually harmless — the speaker can keep AUX or other local sources without the service knowing. But if a managed source is in this list, check the service Sources.xml.",
+ })
+ }
+
+ return findings
+}
+
+func parseSpeakerSources(body []byte) (map[string]bool, error) {
+ var parsed speakerSourcesXML
+ if err := xml.Unmarshal(body, &parsed); err != nil {
+ return nil, err
+ }
+
+ out := make(map[string]bool, len(parsed.Items))
+ for i := range parsed.Items {
+ if s := parsed.Items[i].Source; s != "" {
+ out[s] = true
+ }
+ }
+
+ return out, nil
+}
+
+// setDifference returns the keys in a that are not in b, sorted.
+func setDifference(a, b map[string]bool) []string {
+ out := make([]string, 0)
+
+ for k := range a {
+ if !b[k] {
+ out = append(out, k)
+ }
+ }
+
+ sort.Strings(out)
+
+ return out
+}
diff --git a/pkg/service/health/checks_sources_diff_test.go b/pkg/service/health/checks_sources_diff_test.go
new file mode 100644
index 0000000..669127b
--- /dev/null
+++ b/pkg/service/health/checks_sources_diff_test.go
@@ -0,0 +1,207 @@
+package health
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/gesellix/bose-soundtouch/pkg/models"
+ "github.com/gesellix/bose-soundtouch/pkg/service/datastore"
+)
+
+func newSourcesDiffDS(t *testing.T, account, device string) *datastore.DataStore {
+ t.Helper()
+
+ tempDir, err := os.MkdirTemp("", "sources-diff-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,
+ }); err != nil {
+ t.Fatalf("SaveDeviceInfo: %v", err)
+ }
+
+ return ds
+}
+
+func setServiceSources(t *testing.T, ds *datastore.DataStore, account, device string, types ...string) {
+ t.Helper()
+
+ sources := make([]models.ConfiguredSource, 0, len(types))
+ for i, ty := range types {
+ var src models.ConfiguredSource
+ src.ID = "1000" + string(rune('0'+i))
+ src.Type = "Audio"
+ src.SourceKey.Type = ty
+ sources = append(sources, src)
+ }
+
+ if err := ds.SaveConfiguredSources(account, device, sources); err != nil {
+ t.Fatalf("SaveConfiguredSources: %v", err)
+ }
+}
+
+func stubSpeakerSourcesServer(t *testing.T, sources ...string) string {
+ t.Helper()
+
+ var body strings.Builder
+ body.WriteString(`` + "\n")
+ body.WriteString(`` + "\n")
+ for _, s := range sources {
+ body.WriteString(` ` + "\n")
+ }
+ body.WriteString(``)
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/sources" {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/xml")
+ _, _ = w.Write([]byte(body.String()))
+ }))
+ t.Cleanup(srv.Close)
+
+ u, _ := url.Parse(srv.URL)
+
+ return "http://" + u.Host + "/sources"
+}
+
+func TestSourcesDiff_FlagsMissingOnSpeaker(t *testing.T) {
+ account, device := "1000001", "DEVICEID01"
+
+ ds := newSourcesDiffDS(t, account, device)
+ setServiceSources(t, ds, account, device, "TUNEIN", "RADIO_BROWSER", "AUX")
+
+ speakerURL := stubSpeakerSourcesServer(t, "AUX") // missing TUNEIN, RADIO_BROWSER
+
+ got := diffSourcesForDeviceWithURL(ds, account, device, "192.0.2.10", speakerURL)
+ if len(got) == 0 {
+ t.Fatalf("expected at least one finding")
+ }
+
+ var foundMissing bool
+ for _, f := range got {
+ if strings.Contains(f.Message, "missing") && f.Severity == SeverityWarning {
+ foundMissing = true
+ if !strings.Contains(f.Message, "TUNEIN") || !strings.Contains(f.Message, "RADIO_BROWSER") {
+ t.Errorf("expected TUNEIN + RADIO_BROWSER in message, got %q", f.Message)
+ }
+
+ if len(f.ManualCommands) != 1 || !strings.Contains(f.ManualCommands[0].Command, "/notification") {
+ t.Errorf("expected notify command, got %+v", f.ManualCommands)
+ }
+
+ if !strings.Contains(f.ManualCommands[0].Command, "192.0.2.10") {
+ t.Errorf("notify command should target the device IP, got %q", f.ManualCommands[0].Command)
+ }
+ }
+ }
+
+ if !foundMissing {
+ t.Errorf("expected a 'missing on speaker' warning, got %+v", got)
+ }
+}
+
+func TestSourcesDiff_FlagsMissingOnService(t *testing.T) {
+ account, device := "1000001", "DEVICEID01"
+
+ ds := newSourcesDiffDS(t, account, device)
+ setServiceSources(t, ds, account, device, "AUX")
+
+ speakerURL := stubSpeakerSourcesServer(t, "AUX", "BLUETOOTH")
+
+ got := diffSourcesForDeviceWithURL(ds, account, device, "192.0.2.10", speakerURL)
+
+ var foundExtra bool
+ for _, f := range got {
+ if strings.Contains(f.Message, "doesn't know about") && f.Severity == SeverityInfo {
+ foundExtra = true
+ if !strings.Contains(f.Message, "BLUETOOTH") {
+ t.Errorf("expected BLUETOOTH in message, got %q", f.Message)
+ }
+ }
+ }
+
+ if !foundExtra {
+ t.Errorf("expected an info finding for sources missing on service, got %+v", got)
+ }
+}
+
+func TestSourcesDiff_NoFindingsWhenMatched(t *testing.T) {
+ account, device := "1000001", "DEVICEID01"
+
+ ds := newSourcesDiffDS(t, account, device)
+ setServiceSources(t, ds, account, device, "TUNEIN", "AUX")
+
+ speakerURL := stubSpeakerSourcesServer(t, "TUNEIN", "AUX")
+
+ got := diffSourcesForDeviceWithURL(ds, account, device, "192.0.2.10", speakerURL)
+ if len(got) != 0 {
+ t.Errorf("expected no findings, got %+v", got)
+ }
+}
+
+func TestSourcesDiff_UnreachableSpeakerEmitsManualCommand(t *testing.T) {
+ account, device := "1000001", "DEVICEID01"
+
+ ds := newSourcesDiffDS(t, account, device)
+ setServiceSources(t, ds, account, device, "TUNEIN")
+
+ // Refused port; probe fails.
+ got := diffSourcesForDeviceWithURL(ds, account, device, "127.0.0.1", "http://127.0.0.1:1/sources")
+ if len(got) != 1 || got[0].Severity != SeverityInfo {
+ t.Fatalf("expected one info finding for unreachable speaker, got %+v", got)
+ }
+
+ if len(got[0].ManualCommands) != 1 {
+ t.Errorf("expected a manual command, got %+v", got[0].ManualCommands)
+ }
+}
+
+func TestSourcesDiff_MalformedXMLWarns(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = w.Write([]byte("not xml"))
+ }))
+ defer srv.Close()
+
+ u, _ := url.Parse(srv.URL)
+ probeURL := "http://" + u.Host + "/sources"
+
+ account, device := "1000001", "DEVICEID01"
+
+ ds := newSourcesDiffDS(t, account, device)
+ setServiceSources(t, ds, account, device, "TUNEIN")
+
+ got := diffSourcesForDeviceWithURL(ds, account, device, "192.0.2.10", probeURL)
+ if len(got) != 1 || got[0].Severity != SeverityWarning {
+ t.Fatalf("expected one warning for malformed XML, got %+v", got)
+ }
+}
+
+func TestSourcesDiff_EmptyServiceSetDoesNotDoubleWarn(t *testing.T) {
+ account, device := "1000001", "DEVICEID01"
+
+ ds := newSourcesDiffDS(t, account, device)
+ // Don't write any Sources.xml — sources_xml_present already flags this.
+
+ speakerURL := stubSpeakerSourcesServer(t, "AUX", "TUNEIN")
+
+ got := diffSourcesForDeviceWithURL(ds, account, device, "192.0.2.10", speakerURL)
+
+ for _, f := range got {
+ if f.Severity == SeverityWarning && strings.Contains(f.Message, "missing") {
+ t.Errorf("should not emit a 'missing on speaker' warning when service set is empty, got %+v", f)
+ }
+ }
+}