mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +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
|
||||
})
|
||||
health.RegisterOrionPathsCheck(s.healthRegistry, ds)
|
||||
health.RegisterPresetsCountCheck(s.healthRegistry, ds)
|
||||
|
||||
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