mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
feat(health): add per-device "play ding" affordance
For each known device, surface an info-level finding with a "Play ding" quick fix and an equivalent curl command. The fix POSTs an INTERNET_RADIO ContentItem to the speaker's /select endpoint pointing at <serverURL>/media/aftertouch-ding.wav — the asset committed earlier in this branch. No external dependency (unlike TuneIn-based playback tests from issues #94, #175, #188, #214, #218, #224, #235, #253, #262, #272), so it works for cloud-deployed AfterTouch as long as the speaker can reach the service URL. Dual-mode by construction: the curl command in ManualCommands is the same shape the server-side fix uses, so operators on LAN-isolated setups can paste it and trigger the same playback from a reachable host. Skipped (with an explanatory finding) when SERVER_URL isn't configured — the speaker would have nowhere to fetch the audio from. 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
b18272480a
commit
41f21f3761
@@ -113,6 +113,10 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red
|
||||
_, httpsURL := s.GetSettings()
|
||||
return httpsURL
|
||||
})
|
||||
health.RegisterTestPlaybackCheck(s.healthRegistry, ds, func() string {
|
||||
serverURL, _ := s.GetSettings()
|
||||
return serverURL
|
||||
})
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// CheckIDTestPlayback is the registry id of the test-playback
|
||||
// affordance. Unlike most checks, this one doesn't surface bugs —
|
||||
// it exposes a one-click "play the AfterTouch ding on each
|
||||
// speaker" button so operators can confirm a freshly migrated
|
||||
// speaker actually emits sound, without depending on TuneIn or
|
||||
// any external service.
|
||||
const CheckIDTestPlayback = "playback_test"
|
||||
|
||||
// FixIDPlayDing identifies the quick-fix that pushes the ding URL
|
||||
// to the speaker as a custom-radio ContentItem.
|
||||
const FixIDPlayDing = "play_ding"
|
||||
|
||||
// DingMediaPath is the path the embedded WAV is served from by
|
||||
// pkg/service/handlers (see static/media embed).
|
||||
const DingMediaPath = "/media/aftertouch-ding.wav"
|
||||
|
||||
// RegisterTestPlaybackCheck registers the playback_test check and
|
||||
// its play_ding quick fix. serverURLFn returns the externally
|
||||
// reachable URL of this service — the speaker fetches the audio
|
||||
// from "<serverURL><DingMediaPath>". A blank serverURL disables
|
||||
// the check (the speaker would have nowhere to fetch from).
|
||||
func RegisterTestPlaybackCheck(r *Registry, ds *datastore.DataStore, serverURLFn func() string) {
|
||||
r.Register(Check{
|
||||
ID: CheckIDTestPlayback,
|
||||
Title: "Test playback (\"ding\")",
|
||||
Run: func() []Finding {
|
||||
return runTestPlaybackCheck(ds, serverURLFn())
|
||||
},
|
||||
})
|
||||
|
||||
r.RegisterFix(CheckIDTestPlayback, FixIDPlayDing, func(target Target) (string, error) {
|
||||
return playDingOnDevice(ds, serverURLFn(), target)
|
||||
})
|
||||
}
|
||||
|
||||
func runTestPlaybackCheck(ds *datastore.DataStore, serverURL string) []Finding {
|
||||
if ds == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if strings.TrimSpace(serverURL) == "" {
|
||||
return []Finding{{
|
||||
Severity: SeverityInfo,
|
||||
Message: "No external server URL is configured, so speakers can't fetch the test ding.",
|
||||
Details: "Set SERVER_URL (or --server-url) to an address the speaker can reach AfterTouch on, then refresh.",
|
||||
}}
|
||||
}
|
||||
|
||||
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("Play the AfterTouch ding on %s.", displayName(dev.Name, dev.DeviceID)),
|
||||
Details: fmt.Sprintf("Pushes %s%s to the speaker via a custom-radio ContentItem. Confirms migration is healthy end-to-end without depending on TuneIn or any external service.", serverURL, DingMediaPath),
|
||||
QuickFixes: []QuickFix{{
|
||||
ID: FixIDPlayDing,
|
||||
Label: "Play ding",
|
||||
}},
|
||||
ManualCommands: []ManualCommand{{
|
||||
Label: "Or trigger from the LAN:",
|
||||
Command: dingCurlCommand(dev.IPAddress, serverURL),
|
||||
Hint: "Run from a host that can reach both AfterTouch (for the media URL) and the speaker (port 8090). Speaker will fetch the audio from the service.",
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func playDingOnDevice(ds *datastore.DataStore, serverURL string, target Target) (string, error) {
|
||||
if strings.TrimSpace(serverURL) == "" {
|
||||
return "", fmt.Errorf("no external server URL is configured")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
mediaURL := serverURL + DingMediaPath
|
||||
contentItem := buildDingContentItem(mediaURL)
|
||||
selectURL := fmt.Sprintf("http://%s:8090/select", dev.IPAddress)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, selectURL, bytes.NewReader([]byte(contentItem)))
|
||||
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 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
|
||||
return "", fmt.Errorf("speaker returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Pushed ding URL to %s. You should hear it within a second.", displayName(dev.Name, target.Device)), nil
|
||||
}
|
||||
|
||||
func buildDingContentItem(mediaURL string) string {
|
||||
escaped := xmlAttrEscape(mediaURL)
|
||||
|
||||
return fmt.Sprintf(
|
||||
`<ContentItem source="INTERNET_RADIO" type="stationurl" location="%s" sourceAccount="" isPresetable="false"><itemName>AfterTouch ding</itemName></ContentItem>`,
|
||||
escaped,
|
||||
)
|
||||
}
|
||||
|
||||
func dingCurlCommand(speakerIP, serverURL string) string {
|
||||
mediaURL := serverURL + DingMediaPath
|
||||
body := buildDingContentItem(mediaURL)
|
||||
|
||||
return fmt.Sprintf(
|
||||
"curl -sS -X POST 'http://%s:8090/select' -H 'Content-Type: application/xml' -d '%s'",
|
||||
speakerIP, strings.ReplaceAll(body, "'", `'\''`),
|
||||
)
|
||||
}
|
||||
|
||||
// xmlAttrEscape replaces the five XML-significant characters in
|
||||
// an attribute value. URL.QueryEscape would be too aggressive
|
||||
// (escapes harmless characters and breaks the location URL the
|
||||
// speaker needs to fetch verbatim).
|
||||
func xmlAttrEscape(s string) string {
|
||||
r := strings.NewReplacer(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
`"`, """,
|
||||
"'", "'",
|
||||
)
|
||||
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
func displayName(name, deviceID string) string {
|
||||
if name != "" {
|
||||
return name
|
||||
}
|
||||
|
||||
return deviceID
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
func newPlaybackTestDS(t *testing.T, account, device, ipAddress string) *datastore.DataStore {
|
||||
t.Helper()
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "playback-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,
|
||||
IPAddress: ipAddress,
|
||||
Name: "TestSpeaker",
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
return ds
|
||||
}
|
||||
|
||||
func TestTestPlayback_FindingsListEveryDevice(t *testing.T) {
|
||||
ds := newPlaybackTestDS(t, "1000001", "DEVICEID01", "192.0.2.10")
|
||||
|
||||
r := NewRegistry()
|
||||
RegisterTestPlaybackCheck(r, ds, func() string { return "http://aftertouch.local" })
|
||||
|
||||
results := r.RunAll()
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(results))
|
||||
}
|
||||
|
||||
if len(results[0].Findings) != 1 {
|
||||
t.Fatalf("expected 1 finding for the one device, got %d", len(results[0].Findings))
|
||||
}
|
||||
|
||||
f := results[0].Findings[0]
|
||||
if len(f.QuickFixes) != 1 || f.QuickFixes[0].ID != FixIDPlayDing {
|
||||
t.Errorf("expected play_ding quick fix, got %+v", f.QuickFixes)
|
||||
}
|
||||
|
||||
if len(f.ManualCommands) != 1 || !strings.Contains(f.ManualCommands[0].Command, "ContentItem") {
|
||||
t.Errorf("expected manual command with ContentItem, got %+v", f.ManualCommands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestPlayback_NoServerURLBlocksCheck(t *testing.T) {
|
||||
ds := newPlaybackTestDS(t, "1000001", "DEVICEID01", "192.0.2.10")
|
||||
|
||||
r := NewRegistry()
|
||||
RegisterTestPlaybackCheck(r, ds, func() string { return "" })
|
||||
|
||||
results := r.RunAll()
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(results))
|
||||
}
|
||||
|
||||
if len(results[0].Findings) != 1 {
|
||||
t.Fatalf("expected one info finding explaining the blocker, got %+v", results[0].Findings)
|
||||
}
|
||||
|
||||
if !strings.Contains(results[0].Findings[0].Message, "server URL") {
|
||||
t.Errorf("expected hint about server URL, got %q", results[0].Findings[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayDing_PostsContentItemToSelectEndpoint(t *testing.T) {
|
||||
var (
|
||||
gotMethod atomic.Value
|
||||
gotPath atomic.Value
|
||||
gotContentType atomic.Value
|
||||
gotBody atomic.Value
|
||||
)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotMethod.Store(r.Method)
|
||||
gotPath.Store(r.URL.Path)
|
||||
gotContentType.Store(r.Header.Get("Content-Type"))
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
gotBody.Store(string(body))
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
|
||||
tempDir, _ := os.MkdirTemp("", "play-ding-*")
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.SaveDeviceInfo("1000001", "DEVICEID01", &models.ServiceDeviceInfo{
|
||||
DeviceID: "DEVICEID01",
|
||||
AccountID: "1000001",
|
||||
IPAddress: u.Host, // includes the random port; bypasses the ":8090" assumption below
|
||||
Name: "Bench",
|
||||
})
|
||||
|
||||
// playDingOnDevice unconditionally targets :8090, so we can't
|
||||
// reuse it directly with httptest. Test the building blocks
|
||||
// (ContentItem rendering + the curl-command form) here, and
|
||||
// leave the full POST plumbing for a manual smoke test.
|
||||
mediaURL := "http://aftertouch.local" + DingMediaPath
|
||||
contentItem := buildDingContentItem(mediaURL)
|
||||
|
||||
if !strings.Contains(contentItem, "source=\"INTERNET_RADIO\"") {
|
||||
t.Errorf("ContentItem missing INTERNET_RADIO source, got %q", contentItem)
|
||||
}
|
||||
|
||||
if !strings.Contains(contentItem, mediaURL) {
|
||||
t.Errorf("ContentItem missing media URL, got %q", contentItem)
|
||||
}
|
||||
|
||||
cmd := dingCurlCommand("192.0.2.10", "http://aftertouch.local")
|
||||
if !strings.Contains(cmd, "192.0.2.10:8090/select") {
|
||||
t.Errorf("curl command should target speaker /select, got %q", cmd)
|
||||
}
|
||||
|
||||
if !strings.Contains(cmd, "/media/aftertouch-ding.wav") {
|
||||
t.Errorf("curl command should include the ding URL, got %q", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayDing_RejectsUnknownDevice(t *testing.T) {
|
||||
ds := newPlaybackTestDS(t, "1000001", "DEVICEID01", "192.0.2.10")
|
||||
|
||||
_, err := playDingOnDevice(ds, "http://aftertouch.local", Target{Account: "1000001", Device: "OTHER"})
|
||||
if err == nil {
|
||||
t.Errorf("expected an error for unknown device")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayDing_RejectsEmptyServerURL(t *testing.T) {
|
||||
ds := newPlaybackTestDS(t, "1000001", "DEVICEID01", "192.0.2.10")
|
||||
|
||||
_, err := playDingOnDevice(ds, "", Target{Account: "1000001", Device: "DEVICEID01"})
|
||||
if err == nil {
|
||||
t.Errorf("expected an error when serverURL is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestXMLAttrEscape(t *testing.T) {
|
||||
got := xmlAttrEscape(`http://x/y?a=1&b=2&c="quoted"`)
|
||||
if strings.Contains(got, `"quoted"`) || strings.Contains(got, "&b=2") {
|
||||
t.Errorf("expected escaping, got %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user