diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index f161004..ffcd14f 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -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 } diff --git a/pkg/service/health/checks_presets_count.go b/pkg/service/health/checks_presets_count.go new file mode 100644 index 0000000..feb48ff --- /dev/null +++ b/pkg/service/health/checks_presets_count.go @@ -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 (capitalised) inside . +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 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 +} diff --git a/pkg/service/health/checks_presets_count_test.go b/pkg/service/health/checks_presets_count_test.go new file mode 100644 index 0000000..5c34efd --- /dev/null +++ b/pkg/service/health/checks_presets_count_test.go @@ -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(`` + "\n\n") + + for i := 1; i <= count; i++ { + b.WriteString(` + + Slot ` + itoa(i) + ` + + ` + "\n") + } + + b.WriteString("\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(`` + "\n") + b.WriteString(`` + "\n") + + for i := 1; i <= count; i++ { + b.WriteString(`` + "\n") + } + + b.WriteString(``) + + 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) + } +}