mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-31 14:57:17 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5f5bdb916 | ||
|
|
04f7388051 | ||
|
|
db33f7f22e |
@@ -244,7 +244,10 @@ func renameGroup(c *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeGroup tears down the device's stereo pair.
|
||||
// removeGroup tears down the device's stereo pair by sending /removeGroup to
|
||||
// every member in parallel. Sending it only to the master (as the old code
|
||||
// did) leaves the slave stuck in GroupSlave state indefinitely — mirrors the
|
||||
// same symmetry as createGroup (see issue #252 comment there).
|
||||
func removeGroup(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader("Removing stereo pair", clientConfig.Host, clientConfig.Port)
|
||||
@@ -255,11 +258,76 @@ func removeGroup(c *cli.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := stClient.RemoveGroup(); err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to remove group: %v", err))
|
||||
// Fetch current group to learn every member's IP before tearing down.
|
||||
group, err := stClient.GetGroup()
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to read current group: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if group.IsEmpty() {
|
||||
fmt.Println("Device is not in a stereo pair — nothing to remove")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Collect the unique set of member IPs. The master is always reachable
|
||||
// via clientConfig.Host; the roles carry all members including slaves.
|
||||
type memberResult struct {
|
||||
ip string
|
||||
err error
|
||||
}
|
||||
|
||||
members := make([]string, 0, len(group.Roles.Roles))
|
||||
seen := map[string]bool{}
|
||||
|
||||
for _, role := range group.Roles.Roles {
|
||||
if role.IPAddress != "" && !seen[role.IPAddress] {
|
||||
seen[role.IPAddress] = true
|
||||
members = append(members, role.IPAddress)
|
||||
}
|
||||
}
|
||||
|
||||
// Always include the addressed host even if the group response omitted IPs.
|
||||
if !seen[clientConfig.Host] {
|
||||
members = append(members, clientConfig.Host)
|
||||
}
|
||||
|
||||
results := make([]memberResult, len(members))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, ip := range members {
|
||||
wg.Add(1)
|
||||
|
||||
go func(idx int, host string) {
|
||||
defer wg.Done()
|
||||
|
||||
mc, mcErr := clientForHost(c, host)
|
||||
if mcErr != nil {
|
||||
results[idx] = memberResult{ip: host, err: mcErr}
|
||||
return
|
||||
}
|
||||
|
||||
results[idx] = memberResult{ip: host, err: mc.RemoveGroup()}
|
||||
}(i, ip)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
anyErr := false
|
||||
|
||||
for _, r := range results {
|
||||
if r.err != nil {
|
||||
PrintError(fmt.Sprintf("%s /removeGroup failed: %v", r.ip, r.err))
|
||||
|
||||
anyErr = true
|
||||
}
|
||||
}
|
||||
|
||||
if anyErr {
|
||||
return fmt.Errorf("/removeGroup propagation failed")
|
||||
}
|
||||
|
||||
PrintSuccess("Stereo pair removed")
|
||||
|
||||
return nil
|
||||
|
||||
@@ -1097,6 +1097,11 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
|
||||
r.Post("/group/", server.HandleMargeAddGroup)
|
||||
r.Post("/group/{groupId}", server.HandleMargeModifyGroup)
|
||||
r.Delete("/group/{groupId}", server.HandleMargeDeleteGroup)
|
||||
// Speakers send DELETE /group/ (no group ID, trailing slash) during
|
||||
// stereo-pair teardown; master and slave use their own account IDs
|
||||
// so each deletes its own copy.
|
||||
r.Delete("/group", server.HandleMargeDeleteAccountGroups)
|
||||
r.Delete("/group/", server.HandleMargeDeleteAccountGroups)
|
||||
})
|
||||
|
||||
r.Get("/device/{device}/streaming_token", server.HandleMargeStreamingToken)
|
||||
@@ -1144,6 +1149,8 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
|
||||
r.Post("/group/", server.HandleMargeAddGroup)
|
||||
r.Post("/group/{groupId}", server.HandleMargeModifyGroup)
|
||||
r.Delete("/group/{groupId}", server.HandleMargeDeleteGroup)
|
||||
r.Delete("/group", server.HandleMargeDeleteAccountGroups)
|
||||
r.Delete("/group/", server.HandleMargeDeleteAccountGroups)
|
||||
r.Get("/devices/{device}/presets", server.HandleMargePresets)
|
||||
r.Get("/devices/{device}/recents", server.HandleMargeRecents)
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
CONNECT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
CONNECT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
DELETE /accounts/{account}/devices/{device} handlers.(*Server).HandleMargeRemoveDevice-fm
|
||||
DELETE /accounts/{account}/group handlers.(*Server).HandleMargeDeleteAccountGroups-fm
|
||||
DELETE /accounts/{account}/group/ handlers.(*Server).HandleMargeDeleteAccountGroups-fm
|
||||
DELETE /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
|
||||
DELETE /bmx/tunein/v1/favorite/{stationID} handlers.(*Server).HandleTuneInDeleteFavorite-fm
|
||||
DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
@@ -12,6 +14,8 @@ DELETE /setup/interactions/sessions/{session} handlers.(
|
||||
DELETE /setup/sources/{account}/{device}/{sourceID} handlers.(*Server).HandleDeleteSource-fm
|
||||
DELETE /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeRemoveDevice-fm
|
||||
DELETE /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeRemovePreset-fm
|
||||
DELETE /streaming/account/{account}/group handlers.(*Server).HandleMargeDeleteAccountGroups-fm
|
||||
DELETE /streaming/account/{account}/group/ handlers.(*Server).HandleMargeDeleteAccountGroups-fm
|
||||
DELETE /streaming/account/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
|
||||
GET / handlers.(*Server).HandleRoot-fm
|
||||
GET /accounts/{account}/devices handlers.(*Server).HandleMargeAccountDevices-fm
|
||||
|
||||
@@ -2816,6 +2816,37 @@ func (ds *DataStore) DeleteGroup(account, groupID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteAllGroupsForAccount removes every Group_*.xml file stored under
|
||||
// account. Speakers send DELETE /streaming/account/{id}/group/ (no group
|
||||
// ID) during stereo-pair teardown; since master and slave may live in
|
||||
// different accounts each speaker deletes its own copy. Returns nil if no
|
||||
// group files are found — idempotent by design.
|
||||
func (ds *DataStore) DeleteAllGroupsForAccount(account string) error {
|
||||
ds.fileMutex.Lock()
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
dir := ds.AccountDevicesDir(account)
|
||||
|
||||
entries, err := ds.rootReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil // nothing to delete
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasPrefix(e.Name(), "Group_") || !strings.HasSuffix(e.Name(), ".xml") {
|
||||
continue
|
||||
}
|
||||
|
||||
_ = ds.rootRemove(filepath.Join(dir, e.Name()))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveTuneInFavorite records a TuneIn station as favorited by creating a marker file.
|
||||
// File presence indicates the station is a favorite; no content is stored.
|
||||
func (ds *DataStore) SaveTuneInFavorite(stationID string) error {
|
||||
|
||||
@@ -52,21 +52,33 @@ type Options struct {
|
||||
ReleaseDuration float64 // seconds of fade-out per chirp. Default 0.060.
|
||||
|
||||
Peak float64 // final-mix headroom; 0 < Peak <= 1.0. Default 0.85.
|
||||
|
||||
// Repeat is the total number of times the complete ding is played.
|
||||
// Speakers need a moment to start buffering after receiving a
|
||||
// ContentItem, so the first repetition may be missed; later ones
|
||||
// will be heard. Default 3.
|
||||
Repeat int
|
||||
|
||||
// RepeatGapDuration is the silence inserted between successive
|
||||
// repetitions, in seconds. Default 0.40.
|
||||
RepeatGapDuration float64
|
||||
}
|
||||
|
||||
// DefaultOptions returns the canonical option set used by the
|
||||
// runtime handler when no overrides are supplied.
|
||||
func DefaultOptions() Options {
|
||||
return Options{
|
||||
SampleRate: 22050,
|
||||
PitchHigh: 880.00,
|
||||
PitchMid: 659.2551,
|
||||
PitchLow: 440.00,
|
||||
ChirpDuration: 0.25,
|
||||
GapDuration: 0.10,
|
||||
AttackDuration: 0.020,
|
||||
ReleaseDuration: 0.060,
|
||||
Peak: 0.85,
|
||||
SampleRate: 22050,
|
||||
PitchHigh: 880.00,
|
||||
PitchMid: 659.2551,
|
||||
PitchLow: 440.00,
|
||||
ChirpDuration: 0.25,
|
||||
GapDuration: 0.10,
|
||||
AttackDuration: 0.020,
|
||||
ReleaseDuration: 0.060,
|
||||
Peak: 0.85,
|
||||
Repeat: 3,
|
||||
RepeatGapDuration: 0.40,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +127,14 @@ func (o Options) WithDefaults() Options {
|
||||
o.Peak = d.Peak
|
||||
}
|
||||
|
||||
if o.Repeat <= 0 {
|
||||
o.Repeat = d.Repeat
|
||||
}
|
||||
|
||||
if o.RepeatGapDuration <= 0 {
|
||||
o.RepeatGapDuration = d.RepeatGapDuration
|
||||
}
|
||||
|
||||
return o
|
||||
}
|
||||
|
||||
@@ -147,6 +167,25 @@ func Render(opts Options) []byte {
|
||||
renderChirp(left, right, 0, chirpN, attackN, releaseN, voicesS, opts.SampleRate)
|
||||
renderChirp(left, right, chirpN+gapN, chirpN, attackN, releaseN, voicesT, opts.SampleRate)
|
||||
|
||||
// Repeat: append silence + a copy of the base audio for each
|
||||
// additional repetition. Speakers need a moment to start buffering
|
||||
// after receiving a ContentItem; repeating ensures at least one
|
||||
// instance is audible even if the first is missed.
|
||||
if opts.Repeat > 1 {
|
||||
repeatGapN := int(math.Round(float64(opts.SampleRate) * opts.RepeatGapDuration))
|
||||
|
||||
baseLeft := append([]float64{}, left...)
|
||||
baseRight := append([]float64{}, right...)
|
||||
silence := make([]float64, repeatGapN)
|
||||
|
||||
for i := 1; i < opts.Repeat; i++ {
|
||||
left = append(left, silence...)
|
||||
right = append(right, silence...)
|
||||
left = append(left, baseLeft...)
|
||||
right = append(right, baseRight...)
|
||||
}
|
||||
}
|
||||
|
||||
normalise(left, right, opts.Peak)
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
@@ -45,13 +45,13 @@ func TestRender_ProducesWAVHeader(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_DefaultSizeApproximately52KB(t *testing.T) {
|
||||
func TestRender_DefaultSizeApproximately229KB(t *testing.T) {
|
||||
data := Render(DefaultOptions())
|
||||
|
||||
// Default: 22050 Hz * 2 channels * 2 bytes * 0.6 s = 52920 data
|
||||
// + ~44 byte header.
|
||||
const wantData = 22050 * 2 * 2 * 60 / 100 // 0.6 seconds, integer math
|
||||
if got := len(data); got < wantData || got > wantData+200 {
|
||||
// Default: 3 repetitions of 0.6 s + 2 gaps of 0.4 s = 2.6 s total.
|
||||
// 22050 Hz * 2 ch * 2 bytes * 2.6 s ≈ 229320 data bytes + 44 byte header.
|
||||
const wantData = 22050 * 2 * 2 * 260 / 100 // 2.6 seconds, integer math
|
||||
if got := len(data); got < wantData || got > wantData+500 {
|
||||
t.Errorf("expected ~%d bytes, got %d", wantData, got)
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,15 @@ func TestRender_HugeSampleRateDoesNotTruncateOrPanic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_RepeatProducesLongerAudio(t *testing.T) {
|
||||
once := Render(Options{Repeat: 1}.WithDefaults())
|
||||
thrice := Render(Options{Repeat: 3}.WithDefaults())
|
||||
|
||||
if len(thrice) <= len(once) {
|
||||
t.Errorf("expected Repeat:3 to produce more bytes than Repeat:1: %d vs %d", len(thrice), len(once))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithDefaults_FillsZeroFields(t *testing.T) {
|
||||
got := Options{PitchHigh: 1000}.WithDefaults()
|
||||
if got.PitchHigh != 1000 {
|
||||
|
||||
@@ -35,6 +35,8 @@ var dingDefaultCache struct {
|
||||
// release-ms int milliseconds; default 60
|
||||
// sample-rate Hz, int; default 22050
|
||||
// peak 0..1 float; default 0.85
|
||||
// repeat int 1..10; default 3
|
||||
// repeat-gap-ms int milliseconds; default 400
|
||||
//
|
||||
// The default option set is rendered once via sync.Once and the
|
||||
// resulting bytes are reused across subsequent default requests —
|
||||
@@ -124,6 +126,16 @@ func parseDingOptions(r *http.Request) (ding.Options, bool) {
|
||||
touched = true
|
||||
}
|
||||
|
||||
if v, ok := repeatParam(q.Get("repeat")); ok {
|
||||
opts.Repeat = v
|
||||
touched = true
|
||||
}
|
||||
|
||||
if v, ok := millisecondsParam(q.Get("repeat-gap-ms")); ok {
|
||||
opts.RepeatGapDuration = v
|
||||
touched = true
|
||||
}
|
||||
|
||||
if !touched {
|
||||
return ding.DefaultOptions(), true
|
||||
}
|
||||
@@ -167,6 +179,21 @@ const (
|
||||
dingMaxSampleRate = 192000
|
||||
)
|
||||
|
||||
// repeatParam parses the "repeat" query knob (integer, 1–10).
|
||||
// Values outside that range silently fall back to the default.
|
||||
func repeatParam(raw string) (int, bool) {
|
||||
if raw == "" {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil || v < 1 || v > 10 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return v, true
|
||||
}
|
||||
|
||||
func sampleRateParam(raw string) (int, bool) {
|
||||
if raw == "" {
|
||||
return 0, false
|
||||
|
||||
@@ -27,6 +27,10 @@ type healthFixRequest struct {
|
||||
type healthFixResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message,omitempty"`
|
||||
// Refresh tells the UI whether to re-fetch health after this fix.
|
||||
// false for persistent affordances (e.g. play_ding) that don't
|
||||
// change any check state, so no "Loading…" flash occurs.
|
||||
Refresh bool `json:"refresh"`
|
||||
}
|
||||
|
||||
// HandleHealthChecks runs every registered health check and
|
||||
@@ -73,7 +77,7 @@ func (s *Server) HandleHealthFix(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
msg, err := s.healthRegistry.RunFix(req.CheckID, req.FixID, req.Target)
|
||||
msg, refresh, err := s.healthRegistry.RunFix(req.CheckID, req.FixID, req.Target)
|
||||
if err != nil {
|
||||
if errors.Is(err, health.ErrFixNotFound) {
|
||||
writeJSONError(w, http.StatusNotFound, err.Error())
|
||||
@@ -87,7 +91,7 @@ func (s *Server) HandleHealthFix(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(healthFixResponse{OK: true, Message: msg}); err != nil {
|
||||
if err := json.NewEncoder(w).Encode(healthFixResponse{OK: true, Message: msg, Refresh: refresh}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -901,7 +901,7 @@ func (s *Server) HandleMargeModifyGroup(w http.ResponseWriter, r *http.Request)
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// HandleMargeDeleteGroup removes a stereo group.
|
||||
// HandleMargeDeleteGroup removes a stereo group identified by {groupId}.
|
||||
func (s *Server) HandleMargeDeleteGroup(w http.ResponseWriter, r *http.Request) {
|
||||
account := chi.URLParam(r, "account")
|
||||
groupID := chi.URLParam(r, "groupId")
|
||||
@@ -921,6 +921,28 @@ func (s *Server) HandleMargeDeleteGroup(w http.ResponseWriter, r *http.Request)
|
||||
_, _ = w.Write([]byte(constants.XMLHeader + `<status>Group deleted successfully</status>`))
|
||||
}
|
||||
|
||||
// HandleMargeDeleteAccountGroups removes all stereo groups stored for an
|
||||
// account. Speakers send DELETE /streaming/account/{id}/group/ (trailing
|
||||
// slash, no group ID) during stereo-pair teardown. Master and slave often
|
||||
// live in different accounts, so each speaker deletes its own copy here.
|
||||
func (s *Server) HandleMargeDeleteAccountGroups(w http.ResponseWriter, r *http.Request) {
|
||||
account := chi.URLParam(r, "account")
|
||||
|
||||
if !validatePathID(account) {
|
||||
http.Error(w, "Invalid account ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.ds.DeleteAllGroupsForAccount(account); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(constants.XMLHeader + `<status>Group deleted successfully</status>`))
|
||||
}
|
||||
|
||||
// HandleMusicProviderIsEligible returns the music provider eligibility.
|
||||
func (s *Server) HandleMusicProviderIsEligible(w http.ResponseWriter, _ *http.Request) {
|
||||
// For now, we return false as seen in the interaction sample.
|
||||
|
||||
@@ -4368,8 +4368,10 @@ async function runQuickFix(checkId, fixId, target, confirmMsg, button) {
|
||||
status.textContent = data.message || "Done.";
|
||||
status.style.color = "#2e7d32";
|
||||
}
|
||||
// Refresh to drop the resolved finding.
|
||||
setTimeout(fetchHealth, 400);
|
||||
// Re-fetch health so resolved findings disappear from the list.
|
||||
// Skipped when the server signals refresh:false (persistent
|
||||
// affordances like play_ding that don't change check state).
|
||||
if (data.refresh !== false) setTimeout(fetchHealth, 400);
|
||||
} catch (e) {
|
||||
if (status) {
|
||||
status.textContent = `Failed: ${e.message || e}`;
|
||||
|
||||
@@ -77,7 +77,7 @@ func TestSourcesXMLPresent_QuickFix_MaterialisesDefaults(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
RegisterSourcesXMLPresent(r, ds)
|
||||
|
||||
msg, err := r.RunFix(CheckIDSourcesXMLPresent, FixIDCreateDefaultSources, Target{
|
||||
msg, _, err := r.RunFix(CheckIDSourcesXMLPresent, FixIDCreateDefaultSources, Target{
|
||||
Account: account,
|
||||
Device: device,
|
||||
})
|
||||
@@ -143,7 +143,7 @@ func TestSourcesXMLPresent_FixRejectsEmptyTarget(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
RegisterSourcesXMLPresent(r, ds)
|
||||
|
||||
if _, err := r.RunFix(CheckIDSourcesXMLPresent, FixIDCreateDefaultSources, Target{}); err == nil {
|
||||
if _, _, err := r.RunFix(CheckIDSourcesXMLPresent, FixIDCreateDefaultSources, Target{}); err == nil {
|
||||
t.Errorf("expected error for empty target, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package health
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -28,6 +29,23 @@ const FixIDPlayDing = "play_ding"
|
||||
// pkg/service/handlers (see static/media embed).
|
||||
const DingMediaPath = "/media/aftertouch-ding.wav"
|
||||
|
||||
// DingCustomPath is the AfterTouch custom-playback prefix. The speaker
|
||||
// fetches this URL, AfterTouch responds with a BMX JSON payload, and the
|
||||
// speaker plays via LOCAL_INTERNET_RADIO — avoiding the INTERNET_RADIO
|
||||
// FLAC-parser path that causes UNKNOWN_SOURCE_ERROR (1005) on some firmware
|
||||
// versions (see issue #345).
|
||||
const DingCustomPath = "/custom/v1/playback/"
|
||||
|
||||
// dingCustomURL builds the LOCAL_INTERNET_RADIO proxy URL for the ding WAV.
|
||||
// The WAV URL is base64url-encoded into the path; the name query param sets
|
||||
// the display name on the speaker.
|
||||
func dingCustomURL(serverURL string) string {
|
||||
mediaURL := serverURL + DingMediaPath
|
||||
encoded := base64.URLEncoding.EncodeToString([]byte(mediaURL))
|
||||
|
||||
return serverURL + DingCustomPath + encoded + "?name=AfterTouch+ding"
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -42,7 +60,10 @@ func RegisterTestPlaybackCheck(r *Registry, ds *datastore.DataStore, serverURLFn
|
||||
},
|
||||
})
|
||||
|
||||
r.RegisterFix(CheckIDTestPlayback, FixIDPlayDing, func(target Target) (string, error) {
|
||||
// play_ding is a persistent operator affordance, not a resolvable
|
||||
// finding — success doesn't change any check state, so the UI
|
||||
// should not re-fetch health afterwards (no "Loading…" flash).
|
||||
r.RegisterFixNoRefresh(CheckIDTestPlayback, FixIDPlayDing, func(target Target) (string, error) {
|
||||
return playDingOnDevice(ds, serverURLFn(), target)
|
||||
})
|
||||
}
|
||||
@@ -80,7 +101,7 @@ func runTestPlaybackCheck(ds *datastore.DataStore, serverURL string) []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),
|
||||
Details: fmt.Sprintf("Pushes the ding WAV to the speaker via LOCAL_INTERNET_RADIO (custom-playback proxy at %s%s). Confirms migration is healthy end-to-end without depending on TuneIn or any external service.", serverURL, DingCustomPath),
|
||||
QuickFixes: []QuickFix{{
|
||||
ID: FixIDPlayDing,
|
||||
Label: "Play ding",
|
||||
@@ -114,8 +135,8 @@ func playDingOnDevice(ds *datastore.DataStore, serverURL string, target Target)
|
||||
return "", fmt.Errorf("device %s has no IP address recorded", target.Device)
|
||||
}
|
||||
|
||||
mediaURL := serverURL + DingMediaPath
|
||||
contentItem := buildDingContentItem(mediaURL)
|
||||
customURL := dingCustomURL(serverURL)
|
||||
contentItem := buildDingContentItem(customURL)
|
||||
selectURL := fmt.Sprintf("http://%s:8090/select", dev.IPAddress)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
@@ -143,18 +164,23 @@ func playDingOnDevice(ds *datastore.DataStore, serverURL string, target Target)
|
||||
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)
|
||||
// buildDingContentItem returns the XML ContentItem that pushes the ding to a
|
||||
// speaker. Uses LOCAL_INTERNET_RADIO with the AfterTouch custom-playback
|
||||
// proxy URL so the speaker fetches a BMX JSON response and plays via the
|
||||
// LOCAL_INTERNET_RADIO code path — avoiding the INTERNET_RADIO FLAC-parser
|
||||
// issue that causes UNKNOWN_SOURCE_ERROR (1005) on some firmware versions
|
||||
// (issue #345).
|
||||
func buildDingContentItem(customURL string) string {
|
||||
escaped := xmlAttrEscape(customURL)
|
||||
|
||||
return fmt.Sprintf(
|
||||
`<ContentItem source="INTERNET_RADIO" type="stationurl" location="%s" sourceAccount="" isPresetable="false"><itemName>AfterTouch ding</itemName></ContentItem>`,
|
||||
`<ContentItem source="LOCAL_INTERNET_RADIO" type="stationurl" location="%s" sourceAccount="" isPresetable="true"><itemName>AfterTouch ding</itemName></ContentItem>`,
|
||||
escaped,
|
||||
)
|
||||
}
|
||||
|
||||
func dingCurlCommand(speakerIP, serverURL string) string {
|
||||
mediaURL := serverURL + DingMediaPath
|
||||
body := buildDingContentItem(mediaURL)
|
||||
body := buildDingContentItem(dingCustomURL(serverURL))
|
||||
|
||||
return fmt.Sprintf(
|
||||
"curl -sS -X POST 'http://%s:8090/select' -H 'Content-Type: application/xml' -d '%s'",
|
||||
|
||||
@@ -118,24 +118,31 @@ func TestPlayDing_PostsContentItemToSelectEndpoint(t *testing.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)
|
||||
const serverBase = "http://aftertouch.local"
|
||||
customURL := dingCustomURL(serverBase)
|
||||
contentItem := buildDingContentItem(customURL)
|
||||
|
||||
if !strings.Contains(contentItem, "source=\"INTERNET_RADIO\"") {
|
||||
t.Errorf("ContentItem missing INTERNET_RADIO source, got %q", contentItem)
|
||||
if !strings.Contains(contentItem, "source=\"LOCAL_INTERNET_RADIO\"") {
|
||||
t.Errorf("ContentItem missing LOCAL_INTERNET_RADIO source, got %q", contentItem)
|
||||
}
|
||||
|
||||
if !strings.Contains(contentItem, mediaURL) {
|
||||
t.Errorf("ContentItem missing media URL, got %q", contentItem)
|
||||
if !strings.Contains(contentItem, DingCustomPath) {
|
||||
t.Errorf("ContentItem missing custom-playback path, got %q", contentItem)
|
||||
}
|
||||
|
||||
cmd := dingCurlCommand("192.0.2.10", "http://aftertouch.local")
|
||||
// The WAV URL is base64-encoded inside the custom URL — verify the
|
||||
// custom URL itself is present in the ContentItem.
|
||||
if !strings.Contains(contentItem, customURL) {
|
||||
t.Errorf("ContentItem missing custom URL, got %q", contentItem)
|
||||
}
|
||||
|
||||
cmd := dingCurlCommand("192.0.2.10", serverBase)
|
||||
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)
|
||||
if !strings.Contains(cmd, DingCustomPath) {
|
||||
t.Errorf("curl command should include the custom-playback path, got %q", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -114,18 +114,29 @@ type CheckResult struct {
|
||||
// registered under the (checkID, fixID) pair.
|
||||
var ErrFixNotFound = errors.New("quick fix not registered")
|
||||
|
||||
// fixEntry pairs a FixFunc with its refresh policy. refresh=true
|
||||
// means the UI should re-run fetchHealth after the fix succeeds so
|
||||
// resolved findings disappear from the list. refresh=false is used
|
||||
// for persistent affordances (e.g. play_ding) that never change check
|
||||
// state — no re-render is needed and the brief "Loading…" flash is
|
||||
// avoided.
|
||||
type fixEntry struct {
|
||||
fn FixFunc
|
||||
refresh bool
|
||||
}
|
||||
|
||||
// Registry owns the set of checks and fixes for one Server
|
||||
// instance. The default zero value is not usable; construct via
|
||||
// NewRegistry.
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
checks []Check
|
||||
fixes map[string]FixFunc // key: "<checkID>/<fixID>"
|
||||
fixes map[string]fixEntry // key: "<checkID>/<fixID>"
|
||||
}
|
||||
|
||||
// NewRegistry returns an empty Registry.
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{fixes: map[string]FixFunc{}}
|
||||
return &Registry{fixes: map[string]fixEntry{}}
|
||||
}
|
||||
|
||||
// Register adds a check to the registry. Duplicate IDs replace
|
||||
@@ -147,12 +158,25 @@ func (r *Registry) Register(c Check) {
|
||||
|
||||
// RegisterFix associates a FixFunc with the given (checkID, fixID)
|
||||
// pair. A QuickFix with that ID can be advertised by any Finding
|
||||
// emitted by the matching check.
|
||||
// emitted by the matching check. After a successful run the UI will
|
||||
// re-fetch health so resolved findings disappear from the list.
|
||||
func (r *Registry) RegisterFix(checkID, fixID string, fn FixFunc) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
r.fixes[fixKey(checkID, fixID)] = fn
|
||||
r.fixes[fixKey(checkID, fixID)] = fixEntry{fn: fn, refresh: true}
|
||||
}
|
||||
|
||||
// RegisterFixNoRefresh is like RegisterFix but signals the UI that
|
||||
// re-fetching health after the fix runs is unnecessary. Use this for
|
||||
// persistent operator affordances (e.g. play_ding) whose success
|
||||
// doesn't change any check state — skipping the re-fetch avoids a
|
||||
// distracting "Loading…" flash with no benefit.
|
||||
func (r *Registry) RegisterFixNoRefresh(checkID, fixID string, fn FixFunc) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
r.fixes[fixKey(checkID, fixID)] = fixEntry{fn: fn, refresh: false}
|
||||
}
|
||||
|
||||
// RunAll executes every registered check and returns the results
|
||||
@@ -183,20 +207,22 @@ func (r *Registry) RunAll() []CheckResult {
|
||||
return out
|
||||
}
|
||||
|
||||
// RunFix dispatches to the FixFunc registered for (checkID,
|
||||
// fixID). The returned string is forwarded as the user-facing
|
||||
// success message. ErrFixNotFound is returned when no fix is
|
||||
// registered.
|
||||
func (r *Registry) RunFix(checkID, fixID string, target Target) (string, error) {
|
||||
// RunFix dispatches to the FixFunc registered for (checkID, fixID).
|
||||
// Returns the user-facing success message, whether the UI should
|
||||
// re-fetch health afterwards, and any execution error.
|
||||
// ErrFixNotFound is returned when no fix is registered.
|
||||
func (r *Registry) RunFix(checkID, fixID string, target Target) (string, bool, error) {
|
||||
r.mu.RLock()
|
||||
fn, ok := r.fixes[fixKey(checkID, fixID)]
|
||||
entry, ok := r.fixes[fixKey(checkID, fixID)]
|
||||
r.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%w: %s/%s", ErrFixNotFound, checkID, fixID)
|
||||
return "", false, fmt.Errorf("%w: %s/%s", ErrFixNotFound, checkID, fixID)
|
||||
}
|
||||
|
||||
return fn(target)
|
||||
msg, err := entry.fn(target)
|
||||
|
||||
return msg, entry.refresh, err
|
||||
}
|
||||
|
||||
func fixKey(checkID, fixID string) string {
|
||||
|
||||
@@ -58,7 +58,7 @@ func TestRegistry_RunFix_Dispatch(t *testing.T) {
|
||||
return "applied", nil
|
||||
})
|
||||
|
||||
msg, err := r.RunFix("c1", "f1", Target{Account: "A", Device: "D"})
|
||||
msg, refresh, err := r.RunFix("c1", "f1", Target{Account: "A", Device: "D"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -67,6 +67,10 @@ func TestRegistry_RunFix_Dispatch(t *testing.T) {
|
||||
t.Errorf("unexpected message: %q", msg)
|
||||
}
|
||||
|
||||
if !refresh {
|
||||
t.Errorf("expected refresh=true for a fix registered via RegisterFix")
|
||||
}
|
||||
|
||||
if captured.Account != "A" || captured.Device != "D" {
|
||||
t.Errorf("target not propagated to fix: %+v", captured)
|
||||
}
|
||||
@@ -75,7 +79,7 @@ func TestRegistry_RunFix_Dispatch(t *testing.T) {
|
||||
func TestRegistry_RunFix_NotFound(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
|
||||
_, err := r.RunFix("nope", "also-nope", Target{})
|
||||
_, _, err := r.RunFix("nope", "also-nope", Target{})
|
||||
if !errors.Is(err, ErrFixNotFound) {
|
||||
t.Errorf("expected ErrFixNotFound, got %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user