mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 09:06:14 +00:00
The check title asserted "Speaker /sources matches service Sources.xml", but the row renders as a warning when they differ, so "matches" plus a warning read as a contradiction (reported in #493). Reword to "should match" so the title states the expectation; the per-finding messages and severities already convey whether it holds and what the differences are. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
212 lines
6.5 KiB
Go
212 lines
6.5 KiB
Go
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:
|
|
//
|
|
// <sources deviceID="DEVICEID01">
|
|
// <sourceItem source="TUNEIN" status="READY" ... />
|
|
// <sourceItem source="AUX" sourceAccount="AUX" ... />AUX IN</sourceItem>
|
|
// </sources>
|
|
//
|
|
// 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 should match 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 '<updates deviceID=\"%s\"><sourcesUpdated/></updates>'",
|
|
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. If radio source types still won't activate after an in-place migration (a reboot and this notification don't help), see the troubleshooting guide; the confirmed remedy is a factory reset + re-migrate: https://gesellix.github.io/Bose-SoundTouch/docs/guides/TROUBLESHOOTING/#radio-sources-after-migration",
|
|
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
|
|
}
|