feat(health): compare speaker /presets count with service Presets.xml

Probes http://<ip>:8090/presets for each device and counts the
returned <preset id=…> entries against the service-side
Presets.xml count. Three outcomes:

  - Match: no finding.
  - Speaker has 0 while service has entries: WARNING — the
    post-migration / post-reset preset-loss pattern from
    discussion #295 and #235.
  - Counts differ otherwise: INFO with both numbers in the
    message, so the operator can decide whether to sync.

Reachability / parse failures degrade to info-level findings
with a copyable curl command, matching the dual-mode pattern
the rest of the slice uses.

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 77188418a7
commit 7d46ae2280
3 changed files with 356 additions and 0 deletions
+1
View File
@@ -118,6 +118,7 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red
return serverURL
})
health.RegisterOrionPathsCheck(s.healthRegistry, ds)
health.RegisterPresetsCountCheck(s.healthRegistry, ds)
return s
}
+161
View File
@@ -0,0 +1,161 @@
package health
import (
"context"
"encoding/xml"
"fmt"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
// CheckIDPresetsCount is the registry id of the speaker-vs-service
// preset count check.
const CheckIDPresetsCount = "speaker_presets_count"
// speakerPresetsXML mirrors just enough of the speaker's :8090/presets
// XML to count slots. The schema is the same as on the service side
// but with <ContentItem> (capitalised) inside <preset>.
type speakerPresetsXML struct {
XMLName xml.Name `xml:"presets"`
Presets []struct {
ID string `xml:"id,attr"`
} `xml:"preset"`
}
// RegisterPresetsCountCheck registers a check that fetches each
// device's :8090/presets and compares the count against the
// service's Presets.xml. Useful as a one-step "is the speaker
// seeing the same presets the service thinks it has?" sanity
// check — the question that triggers issue #253, #269, #308,
// among others.
func RegisterPresetsCountCheck(r *Registry, ds *datastore.DataStore) {
r.Register(Check{
ID: CheckIDPresetsCount,
Title: "Speaker preset count matches service Presets.xml",
Run: func() []Finding {
return runPresetsCountCheck(ds)
},
})
}
func runPresetsCountCheck(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, comparePresetsForDevice(ds, dev.AccountID, dev.DeviceID, dev.IPAddress)...)
}
return findings
}
func comparePresetsForDevice(ds *datastore.DataStore, account, deviceID, ipAddress string) []Finding {
probeURL := fmt.Sprintf("http://%s:8090/presets", ipAddress)
return comparePresetsForDeviceWithURL(ds, account, deviceID, probeURL)
}
// comparePresetsForDeviceWithURL is the same but takes the URL
// directly; used by tests bound to an httptest.Server.
func comparePresetsForDeviceWithURL(ds *datastore.DataStore, account, deviceID, probeURL string) []Finding {
target := Target{Account: account, Device: deviceID}
servicePresets, err := ds.GetPresets(account, deviceID)
if err != nil {
return []Finding{{
Severity: SeverityWarning,
Target: target,
Message: "Could not read service-side Presets.xml.",
Details: err.Error(),
}}
}
serviceCount := len(servicePresets)
res := ProbeGet(context.Background(), probeURL, 2*time.Second)
if !res.Reachable {
return []Finding{{
Severity: SeverityInfo,
Target: target,
Message: fmt.Sprintf("Couldn't fetch /presets from the speaker; can't compare. Service Presets.xml has %d entries.", serviceCount),
ManualCommands: []ManualCommand{{
Label: "Fetch /presets from your network:",
Command: res.CurlCommand,
Hint: "Compare the count and slot IDs against what AfterTouch has for this device.",
}},
}}
}
if res.Status != 200 {
return []Finding{{
Severity: SeverityInfo,
Target: target,
Message: fmt.Sprintf("Speaker /presets returned HTTP %d.", res.Status),
}}
}
var parsed speakerPresetsXML
if err := xml.Unmarshal(res.Body, &parsed); err != nil {
return []Finding{{
Severity: SeverityWarning,
Target: target,
Message: "Speaker /presets reply isn't valid XML.",
Details: err.Error(),
}}
}
speakerCount := countNonEmpty(parsed)
if speakerCount == serviceCount {
return nil
}
severity := SeverityInfo
if speakerCount == 0 && serviceCount > 0 {
// Speaker shows nothing while the service has presets —
// this is the post-reset preset-loss class from
// discussion #295 and #235.
severity = SeverityWarning
}
return []Finding{{
Severity: severity,
Target: target,
Message: fmt.Sprintf(
"Speaker shows %d preset slot(s); service Presets.xml has %d.",
speakerCount, serviceCount,
),
Details: "If the speaker shows fewer than the service, a power-cycle or a sourcesUpdated notification usually re-syncs. If it shows more, the service may have stale entries or the speaker is still holding pre-migration state.",
}}
}
// countNonEmpty returns the number of <preset> entries with a
// non-empty id. Empty slots in the speaker's response (e.g. the
// six fixed buttons with no programmed preset) are not counted.
func countNonEmpty(parsed speakerPresetsXML) int {
n := 0
for i := range parsed.Presets {
if parsed.Presets[i].ID != "" {
n++
}
}
return n
}
@@ -0,0 +1,194 @@
package health
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func newPresetsCountDS(t *testing.T, account, device string) *datastore.DataStore {
t.Helper()
tempDir, err := os.MkdirTemp("", "presets-count-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 writeServicePresets(t *testing.T, ds *datastore.DataStore, account, device string, count int) {
t.Helper()
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` + "\n<presets>\n")
for i := 1; i <= count; i++ {
b.WriteString(` <preset id="`)
b.WriteString(itoa(i))
b.WriteString(`" createdOn="2026-05-01" updatedOn="2026-05-01">
<contentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s` + itoa(i) + `">
<itemName>Slot ` + itoa(i) + `</itemName>
</contentItem>
</preset>` + "\n")
}
b.WriteString("</presets>\n")
path := filepath.Join(ds.AccountDeviceDir(account, device), "Presets.xml")
if err := os.WriteFile(path, []byte(b.String()), 0644); err != nil {
t.Fatalf("write Presets.xml: %v", err)
}
}
func itoa(i int) string {
if i == 0 {
return "0"
}
neg := i < 0
if neg {
i = -i
}
var buf [20]byte
pos := len(buf)
for i > 0 {
pos--
buf[pos] = byte('0' + i%10)
i /= 10
}
if neg {
pos--
buf[pos] = '-'
}
return string(buf[pos:])
}
func stubSpeakerPresetsServer(t *testing.T, count int) string {
t.Helper()
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` + "\n")
b.WriteString(`<presets>` + "\n")
for i := 1; i <= count; i++ {
b.WriteString(`<preset id="` + itoa(i) + `"><ContentItem source="TUNEIN"/></preset>` + "\n")
}
b.WriteString(`</presets>`)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/presets" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(b.String()))
}))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
return "http://" + u.Host + "/presets"
}
func TestPresetsCount_MatchingProducesNoFinding(t *testing.T) {
account, device := "1000001", "DEVICEID01"
ds := newPresetsCountDS(t, account, device)
writeServicePresets(t, ds, account, device, 3)
probeURL := stubSpeakerPresetsServer(t, 3)
got := comparePresetsForDeviceWithURL(ds, account, device, probeURL)
if len(got) != 0 {
t.Errorf("expected no findings when counts match, got %+v", got)
}
}
func TestPresetsCount_SpeakerEmptyWhileServiceHas(t *testing.T) {
account, device := "1000001", "DEVICEID01"
ds := newPresetsCountDS(t, account, device)
writeServicePresets(t, ds, account, device, 3)
probeURL := stubSpeakerPresetsServer(t, 0)
got := comparePresetsForDeviceWithURL(ds, account, device, probeURL)
if len(got) != 1 || got[0].Severity != SeverityWarning {
t.Fatalf("expected one warning, got %+v", got)
}
if !strings.Contains(got[0].Message, "0 preset") || !strings.Contains(got[0].Message, "3") {
t.Errorf("expected counts in message, got %q", got[0].Message)
}
}
func TestPresetsCount_SpeakerHasMore(t *testing.T) {
account, device := "1000001", "DEVICEID01"
ds := newPresetsCountDS(t, account, device)
writeServicePresets(t, ds, account, device, 1)
probeURL := stubSpeakerPresetsServer(t, 3)
got := comparePresetsForDeviceWithURL(ds, account, device, probeURL)
if len(got) != 1 || got[0].Severity != SeverityInfo {
t.Fatalf("expected one info finding, got %+v", got)
}
}
func TestPresetsCount_UnreachableSpeaker(t *testing.T) {
account, device := "1000001", "DEVICEID01"
ds := newPresetsCountDS(t, account, device)
writeServicePresets(t, ds, account, device, 2)
got := comparePresetsForDeviceWithURL(ds, account, device, "http://127.0.0.1:1/presets")
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 manual command on unreachable case, got %+v", got[0].ManualCommands)
}
}
func TestPresetsCount_MalformedXML(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("nope"))
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
probeURL := "http://" + u.Host + "/presets"
account, device := "1000001", "DEVICEID01"
ds := newPresetsCountDS(t, account, device)
writeServicePresets(t, ds, account, device, 1)
got := comparePresetsForDeviceWithURL(ds, account, device, probeURL)
if len(got) != 1 || got[0].Severity != SeverityWarning {
t.Fatalf("expected warning for malformed XML, got %+v", got)
}
}