mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
140 lines
4.3 KiB
Go
140 lines
4.3 KiB
Go
package health
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
|
)
|
|
|
|
// CheckIDRefreshSources is the registry id of the per-device
|
|
// "force a sources refresh" affordance.
|
|
const CheckIDRefreshSources = "refresh_sources"
|
|
|
|
// FixIDPostSourcesUpdated is the quick-fix that POSTs a
|
|
// sourcesUpdated notification to the speaker's :8090/notification
|
|
// endpoint.
|
|
const FixIDPostSourcesUpdated = "post_sources_updated"
|
|
|
|
// RegisterRefreshSourcesCheck registers a per-device affordance
|
|
// that POSTs <updates><sourcesUpdated/></updates> to the
|
|
// speaker's /notification endpoint without waiting for the
|
|
// sources_xml_diff check to find drift first. Useful after any
|
|
// service-side Sources.xml change (manual edit, fixed via the
|
|
// sources_xml_present quick fix, etc.) to push the new state
|
|
// onto the speaker without a reboot.
|
|
func RegisterRefreshSourcesCheck(r *Registry, ds *datastore.DataStore) {
|
|
r.Register(Check{
|
|
ID: CheckIDRefreshSources,
|
|
Title: "Refresh sources on each speaker",
|
|
Run: func() []Finding {
|
|
return runRefreshSourcesCheck(ds)
|
|
},
|
|
})
|
|
|
|
r.RegisterFix(CheckIDRefreshSources, FixIDPostSourcesUpdated, func(target Target) (string, error) {
|
|
return postSourcesUpdated(ds, target)
|
|
})
|
|
}
|
|
|
|
func runRefreshSourcesCheck(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(),
|
|
}}
|
|
}
|
|
|
|
out := make([]Finding, 0, len(devices))
|
|
|
|
for i := range devices {
|
|
dev := &devices[i]
|
|
if dev.IPAddress == "" || dev.DeviceID == "" {
|
|
continue
|
|
}
|
|
|
|
out = append(out, Finding{
|
|
Severity: SeverityInfo,
|
|
Target: Target{Account: dev.AccountID, Device: dev.DeviceID},
|
|
Message: fmt.Sprintf("Force a sources refresh on %s.", displayName(dev.Name, dev.DeviceID)),
|
|
Details: "POSTs <updates><sourcesUpdated/></updates> to the speaker so it re-fetches /streaming/account/<id>/full. Cheaper than power-cycling when the service-side Sources.xml has been updated.",
|
|
QuickFixes: []QuickFix{{
|
|
ID: FixIDPostSourcesUpdated,
|
|
Label: "Refresh sources",
|
|
}},
|
|
ManualCommands: []ManualCommand{{
|
|
Label: "Or trigger from the LAN:",
|
|
Command: sourcesUpdatedCurlCommand(dev.IPAddress, dev.DeviceID),
|
|
Hint: "Run on a host that can reach the speaker on port 8090. A reboot is sometimes still required for new source *types* (vs. updated metadata for existing types) to take effect.",
|
|
}},
|
|
})
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func postSourcesUpdated(ds *datastore.DataStore, target Target) (string, error) {
|
|
if target.Device == "" {
|
|
return "", fmt.Errorf("device is required")
|
|
}
|
|
|
|
dev, err := ds.GetDeviceInfo(target.Account, target.Device)
|
|
if err != nil || dev == nil {
|
|
return "", fmt.Errorf("device %s not found in datastore", target.Device)
|
|
}
|
|
|
|
if dev.IPAddress == "" {
|
|
return "", fmt.Errorf("device %s has no IP address recorded", target.Device)
|
|
}
|
|
|
|
body := sourcesUpdatedXML(target.Device)
|
|
notifyURL := fmt.Sprintf("http://%s:8090/notification", dev.IPAddress)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, notifyURL, bytes.NewReader([]byte(body)))
|
|
if err != nil {
|
|
return "", fmt.Errorf("build request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/xml")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("post to speaker: %w", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode >= 300 {
|
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
|
|
return "", fmt.Errorf("speaker returned %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
|
|
}
|
|
|
|
return fmt.Sprintf("Sent sourcesUpdated to %s.", displayName(dev.Name, target.Device)), nil
|
|
}
|
|
|
|
func sourcesUpdatedXML(deviceID string) string {
|
|
return fmt.Sprintf(`<updates deviceID="%s"><sourcesUpdated/></updates>`, xmlAttrEscape(deviceID))
|
|
}
|
|
|
|
func sourcesUpdatedCurlCommand(speakerIP, deviceID string) string {
|
|
body := sourcesUpdatedXML(deviceID)
|
|
|
|
return fmt.Sprintf(
|
|
"curl -sS -X POST 'http://%s:8090/notification' -H 'Content-Type: application/xml' -d '%s'",
|
|
speakerIP, strings.ReplaceAll(body, "'", `'\''`),
|
|
)
|
|
}
|