mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-20 01:26:14 +00:00
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:
co-authored by
Claude Opus 4.7
parent
77188418a7
commit
7d46ae2280
@@ -118,6 +118,7 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red
|
|||||||
return serverURL
|
return serverURL
|
||||||
})
|
})
|
||||||
health.RegisterOrionPathsCheck(s.healthRegistry, ds)
|
health.RegisterOrionPathsCheck(s.healthRegistry, ds)
|
||||||
|
health.RegisterPresetsCountCheck(s.healthRegistry, ds)
|
||||||
|
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user