Harden player source selection reconciliation

Use a single POST command with bounded delayed readback, revision-order
REST and WebSocket state, and explicit pending, confirmed, unverified, or
failed outcomes. Mark stale source inventories unusable until a successful
refresh and preserve legacy API error handling outside this command path.

Rebased onto main. One conflict could not be resolved by picking a side:
this change introduced its own per-field fencing (fieldRevision,
deviceStatusFieldRevisions, MergeNowPlaying/MergeVolume/MergePresets/
MergeSources/MergeBass/MergeIsConnected) over the same six fields that
main's StatusField mechanism (BeginFieldPoll/CompleteFieldPoll/
ApplyFieldEvent, added by the #654/#668 stack) already orders. Landing
both would leave two independent generation counters guarding the same
state, which is the divergent-fencing bug class that stack already had to
fix three times. Resolved in favour of main's mechanism:

  - the Merge* methods and their counters are dropped; the WebSocket
    handlers and updateDeviceStatus use main's calls;
  - NowPlayingRevision is derived from the existing FieldNowPlaying
    generation via recordFieldRevision, not a second counter;
  - SetStatus derives Revision from the stored status and supersedes all
    field generations, so a replacement cannot reset the revision a
    browser is ordering on;
  - updateSourcesCache records a failed /sources read through
    CompleteFieldPoll, keeping the newer-failure-fences-older-success
    property the original had via MergeSourcesFailure;
  - the three tests that called the dropped API were ported to it.

Everything else in this change is unmodified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukáš Lipinský
2026-09-05 20:36:58 +02:00
committed by Tobias Gesellchen
co-authored by Claude Opus 5
parent c4e3ad33ad
commit 5ea96d0cfa
15 changed files with 1820 additions and 51 deletions
+2
View File
@@ -11,6 +11,7 @@ GOTEST=$(GOCMD) test
GOGET=$(GOCMD) get
GOMOD=$(GOCMD) mod
GOFMT=gofmt
NODE?=node
# Build parameters
BINARY_NAME=soundtouch-cli
@@ -155,6 +156,7 @@ test-coverage:
test-browser:
@echo "Running browser-level compatibility tests..."
$(GOTEST) -tags browsertest -v ./pkg/service/soundtouchweb/...
$(NODE) --test pkg/service/soundtouchweb/static/js/api.test.mjs
# Unit tests for the embedded player's static JS modules (see
# pkg/service/soundtouchweb/frontend_test/), run via Node's built-in test
@@ -11,15 +11,69 @@ package soundtouchweb
import (
"context"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/chromedp/chromedp"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
"github.com/go-chi/chi/v5"
)
func newPlayerFixtureServer(t *testing.T, moduleScript string, configure func(chi.Router)) *httptest.Server {
t.Helper()
r := chi.NewRouter()
staticFS, err := fs.Sub(StaticFS, "static")
if err != nil {
t.Fatalf("open static fixture filesystem: %v", err)
}
r.Get("/app/static/*", http.StripPrefix("/app/static", http.FileServer(http.FS(staticFS))).ServeHTTP)
configure(r)
r.Get("/fixture", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = fmt.Fprintf(w, `<!doctype html>
<html><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script type="importmap">{"imports":{"preact":"/app/static/lib/preact.module.js","preact/hooks":"/app/static/lib/preact-hooks.module.js","htm":"/app/static/lib/htm.module.js"}}</script>
<link rel="stylesheet" href="/app/static/css/app.css">
</head><body><div class="app"><div id="fixture"></div></div>
<script type="module">%s</script></body></html>`, moduleScript)
})
server := httptest.NewServer(r)
t.Cleanup(server.Close)
return server
}
const sourceFixtureScript = `
import { h, render } from 'preact';
import { Sources } from '/app/static/js/components/Sources.js';
const sources = [
{ Source: 'AUX', SourceAccount: 'AUX1', DisplayName: 'Aux 1', Status: 'READY' },
{ Source: 'PRODUCT', SourceAccount: '', DisplayName: 'Product', Status: 'READY' },
{ Source: 'SPOTIFY', SourceAccount: 'spotify-user', DisplayName: 'Spotify', Status: 'READY' },
];
let revision = 0;
window.renderStatus = (source = 'STANDBY', account = '', sourcesStale = false, deviceId = 'speaker', revisionOverride = null, sourceItems = sources) => {
const nextRevision = revisionOverride ?? ++revision;
return render(h(Sources, {
deviceId,
status: { revision: nextRevision, nowPlayingRevision: nextRevision, sources: { SourceItem: sourceItems }, sourcesStale, nowPlaying: { Source: source, SourceAccount: account } },
readbackDelays: [100, 250, 500],
}), document.getElementById('fixture'));
};
window.renderStatus();
`
// newHeadlessChromeContext returns a context bound to a fresh headless
// Chrome instance, torn down automatically at the end of the test.
func newHeadlessChromeContext(t *testing.T) context.Context {
@@ -141,3 +195,776 @@ func TestPlayerRendersUnderForcedShimMode(t *testing.T) {
t.Error("app did not render under forced es-module-shims shim mode")
}
}
func TestFrontendDeviceStateRejectsNonNewerStatusRevisions(t *testing.T) {
const revisionFixture = `
import { mergeDevicesSnapshot, mergeStatusUpdate } from '/app/static/js/app.js';
const current = {
speaker: { info: { name: 'Current' }, status: { revision: 5, sourcesStale: false, nowPlaying: { Track: 'new' } } },
};
const snapshot = mergeDevicesSnapshot(current, {
speaker: { info: { name: 'Renamed' }, status: { revision: 4, nowPlaying: { Track: 'old' } } },
added: { info: { name: 'Added' }, status: { revision: 1 } },
});
const equal = mergeStatusUpdate(snapshot, 'speaker', { revision: 5, nowPlaying: { Track: 'equal' } });
const older = mergeStatusUpdate(equal, 'speaker', { revision: 3, nowPlaying: { Track: 'older' } });
const newer = mergeStatusUpdate(older, 'speaker', { revision: 6, nowPlaying: { Track: 'newest' } });
const stale = mergeStatusUpdate(newer, 'speaker', {
revision: 6,
sourcesStale: true,
nowPlaying: { Track: 'must not replace canonical state' },
});
const adversarial = Object.fromEntries([
['__proto__', { status: { revision: 1, nowPlaying: { Track: 'old proto' } } }],
['constructor', { status: { revision: 1, nowPlaying: { Track: 'old constructor' } } }],
]);
const protoUpdated = mergeStatusUpdate(adversarial, '__proto__', {
revision: 2,
nowPlaying: { Track: 'new proto' },
});
const constructorUpdated = mergeStatusUpdate(protoUpdated, 'constructor', {
revision: 2,
nowPlaying: { Track: 'new constructor' },
});
window.revisionChecks = {
snapshotKeptStatus: snapshot.speaker.status.revision === 5 && snapshot.speaker.status.nowPlaying.Track === 'new',
snapshotUpdatedInfo: snapshot.speaker.info.name === 'Renamed' && snapshot.added.status.revision === 1,
equalRejected: equal === snapshot,
olderRejected: older === equal,
newerAccepted: newer !== older && newer.speaker.status.revision === 6 && newer.speaker.status.nowPlaying.Track === 'newest',
derivedStaleAccepted: stale !== newer && stale.speaker.status.sourcesStale === true &&
stale.speaker.status.nowPlaying.Track === 'newest',
staleCannotClearAtEqualRevision: mergeStatusUpdate(stale, 'speaker', {
revision: 6,
sourcesStale: false,
}) === stale,
unknownRejected: mergeStatusUpdate(newer, 'unknown', { revision: 99 }) === newer,
adversarialIDsAreOwnDataProperties:
Object.prototype.hasOwnProperty.call(constructorUpdated, '__proto__') &&
Object.prototype.hasOwnProperty.call(constructorUpdated, 'constructor'),
adversarialIDsKeepObjectPrototype: Object.getPrototypeOf(constructorUpdated) === Object.prototype,
adversarialIDsUpdateOnlyTheirRecords:
constructorUpdated.__proto__.status.nowPlaying.Track === 'new proto' &&
constructorUpdated.constructor.status.nowPlaying.Track === 'new constructor' &&
Object.prototype.polluted === undefined,
};
`
server := newPlayerFixtureServer(t, revisionFixture, func(chi.Router) {})
ctx := newHeadlessChromeContext(t)
var checks map[string]bool
if err := chromedp.Run(ctx,
chromedp.Navigate(server.URL+"/fixture"),
chromedp.Poll(`window.revisionChecks !== undefined`, nil),
chromedp.Evaluate(`window.revisionChecks`, &checks),
); err != nil {
t.Fatalf("exercise frontend revision state owner: %v", err)
}
for name, passed := range checks {
if !passed {
t.Errorf("revision check %s failed", name)
}
}
}
func TestDerivedSourceExpiryDisablesCommandsAtEqualRevision(t *testing.T) {
const sourceExpiryFixture = `
import { h, render } from 'preact';
import { mergeStatusUpdate } from '/app/static/js/app.js';
import { Sources } from '/app/static/js/components/Sources.js';
const ready = [{ Source: 'AUX', SourceAccount: 'AUX1', DisplayName: 'Aux 1', Status: 'READY' }];
let devices = {
speaker: {
status: {
revision: 5,
nowPlayingRevision: 5,
sourcesStale: false,
sources: { SourceItem: ready },
nowPlaying: { Source: 'STANDBY', SourceAccount: '' },
},
},
};
function redraw() {
render(h(Sources, {
deviceId: 'speaker',
status: devices.speaker.status,
readbackDelays: [100],
}), document.getElementById('fixture'));
}
window.expireSources = () => {
devices = mergeStatusUpdate(devices, 'speaker', {
revision: 5,
nowPlayingRevision: 5,
sourcesStale: true,
sources: { SourceItem: ready },
nowPlaying: { Source: 'AUX', SourceAccount: 'AUX1' },
});
redraw();
};
redraw();
`
var mu sync.Mutex
writes := 0
server := newPlayerFixtureServer(t, sourceExpiryFixture, func(r chi.Router) {
r.Post("/api/control/devices/speaker/action/source", func(w http.ResponseWriter, _ *http.Request) {
mu.Lock()
writes++
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true}`))
})
})
ctx := newHeadlessChromeContext(t)
var trackSource string
if err := chromedp.Run(ctx,
chromedp.Navigate(server.URL+"/fixture"),
chromedp.WaitVisible(`.source-btn`, chromedp.ByQuery),
chromedp.Evaluate(`window.expireSources()`, nil),
chromedp.Poll(`document.querySelector('.source-btn')?.disabled === true`, nil),
chromedp.Evaluate(`document.querySelector('.source-btn').click()`, nil),
chromedp.Sleep(50*time.Millisecond),
chromedp.Evaluate(`document.querySelector('.source-btn').classList.contains('active') ? 'AUX' : 'STANDBY'`, &trackSource),
); err != nil {
t.Fatalf("apply derived source expiry: %v", err)
}
mu.Lock()
defer mu.Unlock()
if writes != 0 || trackSource != "STANDBY" {
t.Fatalf("derived expiry writes=%d projected source=%q, want 0 and STANDBY", writes, trackSource)
}
}
func TestSourceSelectionUsesOneWriteAndAbsoluteReadbacks(t *testing.T) {
type sourceRun struct {
body webtypes.SourceRequest
startedAt time.Time
readTimes []time.Duration
}
var mu sync.Mutex
var runs []sourceRun
server := newPlayerFixtureServer(t, sourceFixtureScript, func(r chi.Router) {
r.Post("/api/control/devices/speaker/action/source", func(w http.ResponseWriter, req *http.Request) {
var body webtypes.SourceRequest
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
mu.Lock()
runs = append(runs, sourceRun{body: body, startedAt: time.Now()})
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
if body.Source == "SPOTIFY" {
_ = json.NewEncoder(w).Encode(webtypes.APIResponse{Success: false, Error: "source rejected"})
return
}
_ = json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true})
})
r.Get("/api/control/devices/speaker", func(w http.ResponseWriter, _ *http.Request) {
mu.Lock()
run := &runs[len(runs)-1]
run.readTimes = append(run.readTimes, time.Since(run.startedAt))
readCount := len(run.readTimes)
body := run.body
mu.Unlock()
nowPlaying := map[string]string{"Source": "STANDBY", "SourceAccount": ""}
if body.Source == "AUX" && readCount >= 2 {
nowPlaying = map[string]string{"Source": body.Source, "SourceAccount": body.Account}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: map[string]any{
"status": map[string]any{"revision": readCount + 1, "nowPlayingRevision": readCount + 1, "nowPlaying": nowPlaying},
}})
})
})
ctx := newHeadlessChromeContext(t)
var immediatelyPending bool
if err := chromedp.Run(ctx,
chromedp.Navigate(server.URL+"/fixture"),
chromedp.WaitVisible(`.source-btn`, chromedp.ByQuery),
chromedp.Click(`.source-btn:nth-child(1)`, chromedp.ByQuery),
chromedp.Evaluate(`document.querySelector('.source-btn:nth-child(1)').getAttribute('aria-busy') === 'true'`, &immediatelyPending),
chromedp.Poll(`document.querySelector('.source-command-status').textContent === 'Source selected'`, nil),
chromedp.Click(`.source-btn:nth-child(2)`, chromedp.ByQuery),
chromedp.Poll(`document.querySelector('.source-command-status').textContent === 'Source selection unverified'`, nil),
chromedp.Click(`.source-btn:nth-child(3)`, chromedp.ByQuery),
chromedp.Poll(`document.querySelector('.source-btn:nth-child(3)').classList.contains('unverified')`, nil),
); err != nil {
t.Fatalf("exercise source commands: %v", err)
}
if !immediatelyPending {
t.Error("source command did not expose pending state immediately")
}
mu.Lock()
defer mu.Unlock()
if len(runs) != 3 {
t.Fatalf("source writes = %d, want exactly one for each of 3 commands", len(runs))
}
wantBodies := []webtypes.SourceRequest{
{Source: "AUX", Account: "AUX1"},
{Source: "PRODUCT", Account: ""},
{Source: "SPOTIFY", Account: "spotify-user"},
}
for i, want := range wantBodies {
if runs[i].body != want {
t.Errorf("write %d body = %+v, want %+v", i, runs[i].body, want)
}
}
if got := len(runs[0].readTimes); got != 3 {
t.Errorf("confirmed command readbacks = %d, want 3", got)
}
if got := len(runs[1].readTimes); got != 3 {
t.Errorf("unverified command readbacks = %d, want 3", got)
}
if got := len(runs[2].readTimes); got != 3 {
t.Errorf("transport-uncertain write readbacks = %d, want 3", got)
}
for i, want := range []time.Duration{100 * time.Millisecond, 250 * time.Millisecond, 500 * time.Millisecond} {
got := runs[0].readTimes[i]
if got < want-40*time.Millisecond || got > want+150*time.Millisecond {
t.Errorf("confirmed readback %d at %s, want absolute deadline near %s", i, got, want)
}
}
for i, want := range []time.Duration{100 * time.Millisecond, 250 * time.Millisecond, 500 * time.Millisecond} {
got := runs[1].readTimes[i]
if got < want-40*time.Millisecond || got > want+150*time.Millisecond {
t.Errorf("unverified readback %d at %s, want absolute deadline near %s", i, got, want)
}
}
}
func TestSourceSelectionTreatsSelfAccountAsOmitted(t *testing.T) {
const fixture = `
import { h, render } from 'preact';
import { Sources } from '/app/static/js/components/Sources.js';
const sourceItems = [{ Source: 'AUX', SourceAccount: 'AUX', DisplayName: 'AUX IN', Status: 'READY' }];
render(h(Sources, {
deviceId: 'speaker',
status: {
revision: 1,
nowPlayingRevision: 1,
nowPlaying: { Source: 'STANDBY', SourceAccount: '' },
sources: { SourceItem: sourceItems },
},
readbackDelays: [100, 250, 500],
}), document.getElementById('fixture'));
`
var mu sync.Mutex
writes := 0
var request webtypes.SourceRequest
server := newPlayerFixtureServer(t, fixture, func(r chi.Router) {
r.Post("/api/control/devices/speaker/action/source", func(w http.ResponseWriter, req *http.Request) {
mu.Lock()
writes++
_ = json.NewDecoder(req.Body).Decode(&request)
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true}`))
})
r.Get("/api/control/devices/speaker", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"status":{"revision":2,"nowPlayingRevision":2,"nowPlaying":{"Source":"AUX","SourceAccount":""}}}}`))
})
})
ctx := newHeadlessChromeContext(t)
var active bool
if err := chromedp.Run(ctx,
chromedp.Navigate(server.URL+"/fixture"),
chromedp.WaitVisible(`.source-btn`, chromedp.ByQuery),
chromedp.Click(`.source-btn`, chromedp.ByQuery),
chromedp.Poll(`document.querySelector('.source-command-status').textContent === 'Source selected'`, nil),
chromedp.Evaluate(`document.querySelector('.source-btn').classList.contains('active')`, &active),
); err != nil {
t.Fatalf("select source whose self-account is omitted by now_playing: %v", err)
}
mu.Lock()
defer mu.Unlock()
if writes != 1 || request != (webtypes.SourceRequest{Source: "AUX", Account: "AUX"}) {
t.Fatalf("writes=%d request=%+v, want one AUX/AUX write", writes, request)
}
if !active {
t.Fatal("source with omitted self-account was not projected active")
}
}
func TestSourceSelectionReadbacksDoNotWaitForSlowWriteResponse(t *testing.T) {
var mu sync.Mutex
startedAt := time.Time{}
var readTimes []time.Duration
server := newPlayerFixtureServer(t, sourceFixtureScript, func(r chi.Router) {
r.Post("/api/control/devices/speaker/action/source", func(w http.ResponseWriter, _ *http.Request) {
mu.Lock()
startedAt = time.Now()
mu.Unlock()
time.Sleep(700 * time.Millisecond)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true}`))
})
r.Get("/api/control/devices/speaker", func(w http.ResponseWriter, _ *http.Request) {
mu.Lock()
readTimes = append(readTimes, time.Since(startedAt))
readCount := len(readTimes)
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"success":true,"data":{"status":{"revision":%d,"nowPlayingRevision":%d,"nowPlaying":{"Source":"STANDBY","SourceAccount":""}}}}`, readCount+1, readCount+1)
})
})
ctx := newHeadlessChromeContext(t)
if err := chromedp.Run(ctx,
chromedp.Navigate(server.URL+"/fixture"),
chromedp.WaitVisible(`.source-btn`, chromedp.ByQuery),
chromedp.Click(`.source-btn:nth-child(1)`, chromedp.ByQuery),
chromedp.Poll(`document.querySelector('.source-command-status').textContent === 'Source selection unverified'`, nil),
); err != nil {
t.Fatalf("exercise slow source response: %v", err)
}
mu.Lock()
defer mu.Unlock()
if len(readTimes) != 3 {
t.Fatalf("readbacks = %d, want 3", len(readTimes))
}
for i, want := range []time.Duration{100 * time.Millisecond, 250 * time.Millisecond, 500 * time.Millisecond} {
if got := readTimes[i]; got < want-40*time.Millisecond || got > want+150*time.Millisecond {
t.Errorf("readback %d at %s, want absolute deadline near %s", i, got, want)
}
}
}
func TestSourceSelectionLaterFirmwareErrorOverridesProvisionalConfirmation(t *testing.T) {
var mu sync.Mutex
reads := 0
server := newPlayerFixtureServer(t, sourceFixtureScript, func(r chi.Router) {
r.Post("/api/control/devices/speaker/action/source", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true}`))
})
r.Get("/api/control/devices/speaker", func(w http.ResponseWriter, _ *http.Request) {
mu.Lock()
reads++
read := reads
mu.Unlock()
source := "AUX"
account := "AUX1"
if read == 2 {
source = "INVALID_SOURCE"
account = ""
}
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"success":true,"data":{"status":{"revision":%d,"nowPlayingRevision":%d,"nowPlaying":{"Source":%q,"SourceAccount":%q}}}}`, read+1, read+1, source, account)
})
})
ctx := newHeadlessChromeContext(t)
var provisional bool
if err := chromedp.Run(ctx,
chromedp.Navigate(server.URL+"/fixture"),
chromedp.WaitVisible(`.source-btn`, chromedp.ByQuery),
chromedp.Click(`.source-btn:nth-child(1)`, chromedp.ByQuery),
chromedp.Poll(`document.querySelector('.source-command-status').textContent === 'Source selected, confirming'`, nil),
chromedp.Evaluate(`document.querySelector('.source-btn:nth-child(1)').classList.contains('provisional-confirmed')`, &provisional),
chromedp.Poll(`document.querySelector('.source-command-status').textContent === 'Source selection failed'`, nil),
); err != nil {
t.Fatalf("exercise provisional source rejection: %v", err)
}
if !provisional {
t.Error("early matching readback did not expose provisional confirmation")
}
mu.Lock()
defer mu.Unlock()
if reads != 2 {
t.Errorf("readbacks = %d, want provisional match followed by authoritative rejection", reads)
}
}
func TestSourceSelectionRejectsReadbackWithOnlyNewerAggregateRevision(t *testing.T) {
var mu sync.Mutex
reads := 0
server := newPlayerFixtureServer(t, sourceFixtureScript, func(r chi.Router) {
r.Post("/api/control/devices/speaker/action/source", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true}`))
})
r.Get("/api/control/devices/speaker", func(w http.ResponseWriter, _ *http.Request) {
mu.Lock()
reads++
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"status":{"revision":99,"nowPlayingRevision":1,"nowPlaying":{"Source":"AUX","SourceAccount":"AUX1"}}}}`))
})
})
ctx := newHeadlessChromeContext(t)
if err := chromedp.Run(ctx,
chromedp.Navigate(server.URL+"/fixture"),
chromedp.WaitVisible(`.source-btn`, chromedp.ByQuery),
chromedp.Click(`.source-btn:nth-child(1)`, chromedp.ByQuery),
chromedp.Poll(`document.querySelector('.source-command-status').textContent === 'Source selection unverified'`, nil),
); err != nil {
t.Fatalf("exercise stale source readback: %v", err)
}
mu.Lock()
defer mu.Unlock()
if reads != 3 {
t.Errorf("readbacks = %d, want all 3 after stale matching responses", reads)
}
}
func TestSourceSelectionTreatsFirmwareErrorSourceAsFailed(t *testing.T) {
var mu sync.Mutex
reads := 0
server := newPlayerFixtureServer(t, sourceFixtureScript, func(r chi.Router) {
r.Post("/api/control/devices/speaker/action/source", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true}`))
})
r.Get("/api/control/devices/speaker", func(w http.ResponseWriter, _ *http.Request) {
mu.Lock()
reads++
read := reads
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"success":true,"data":{"status":{"revision":%d,"nowPlayingRevision":%d,"nowPlaying":{"Source":"INVALID_SOURCE","SourceAccount":""}}}}`, read+1, read+1)
})
})
ctx := newHeadlessChromeContext(t)
if err := chromedp.Run(ctx,
chromedp.Navigate(server.URL+"/fixture"),
chromedp.WaitVisible(`.source-btn`, chromedp.ByQuery),
chromedp.Click(`.source-btn:nth-child(1)`, chromedp.ByQuery),
chromedp.Poll(`document.querySelector('.source-command-status').textContent === 'Source selection failed'`, nil),
); err != nil {
t.Fatalf("exercise firmware source rejection: %v", err)
}
mu.Lock()
defer mu.Unlock()
if reads != 1 {
t.Errorf("readbacks = %d, want 1 after authoritative firmware rejection", reads)
}
}
func TestSourceSelectionLaterAuthoritativeSourceClearsFinalProjection(t *testing.T) {
transitions := []struct {
name, source, account string
activeButtons int
}{
{name: "airplay", source: "AIRPLAY"},
{name: "spotify", source: "SPOTIFY", account: "spotify-user", activeButtons: 1},
{name: "standby", source: "STANDBY"},
{name: "invalid source", source: "INVALID_SOURCE"},
}
for _, transition := range transitions {
t.Run(transition.name, func(t *testing.T) {
var mu sync.Mutex
reads := 0
server := newPlayerFixtureServer(t, sourceFixtureScript, func(r chi.Router) {
r.Post("/api/control/devices/speaker/action/source", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true}`))
})
r.Get("/api/control/devices/speaker", func(w http.ResponseWriter, _ *http.Request) {
mu.Lock()
reads++
revision := reads + 1
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"success":true,"data":{"status":{"revision":%d,"nowPlayingRevision":%d,"nowPlaying":{"Source":"AUX","SourceAccount":"AUX1"}}}}`, revision, revision)
})
})
ctx := newHeadlessChromeContext(t)
var commandCleared, auxInactive bool
var activeButtons int
if err := chromedp.Run(ctx,
chromedp.Navigate(server.URL+"/fixture"),
chromedp.WaitVisible(`.source-btn`, chromedp.ByQuery),
chromedp.Click(`.source-btn:nth-child(1)`, chromedp.ByQuery),
chromedp.Poll(`document.querySelector('.source-command-status').textContent === 'Source selected'`, nil),
chromedp.Evaluate(fmt.Sprintf(`window.renderStatus(%q, %q, false, 'speaker', 100)`, transition.source, transition.account), nil),
chromedp.Poll(`document.querySelector('.source-command-status').textContent === ''`, nil),
chromedp.Evaluate(`document.querySelector('.source-command-status').textContent === ''`, &commandCleared),
chromedp.Evaluate(`!document.querySelector('.source-btn:nth-child(1)').classList.contains('active')`, &auxInactive),
chromedp.Evaluate(`document.querySelectorAll('.source-btn.active').length`, &activeButtons),
); err != nil {
t.Fatalf("apply later authoritative source: %v", err)
}
if !commandCleared || !auxInactive || activeButtons != transition.activeButtons {
t.Errorf("projection after %s: cleared=%v auxInactive=%v activeButtons=%d want=%d",
transition.source, commandCleared, auxInactive, activeButtons, transition.activeButtons)
}
})
}
}
func TestSourceReadbackPublishesThroughAppDeviceState(t *testing.T) {
const fixture = `
import { h, render } from 'preact';
import { useState } from 'preact/hooks';
import { mergeStatusUpdate } from '/app/static/js/app.js';
import { NowPlaying } from '/app/static/js/components/NowPlaying.js';
import { Sources } from '/app/static/js/components/Sources.js';
const sourceItems = [{ Source: 'AUX', SourceAccount: 'AUX1', DisplayName: 'Aux 1', Status: 'READY' }];
function Fixture() {
const [devices, setDevices] = useState({ speaker: { status: {
revision: 1,
nowPlayingRevision: 1,
nowPlaying: { Source: 'STANDBY' },
sources: { SourceItem: sourceItems },
} } });
const status = devices.speaker.status;
return h('div', {},
h(NowPlaying, { nowPlaying: status.nowPlaying }),
h(Sources, {
deviceId: 'speaker',
status,
readbackDelays: [100, 250, 500],
onStatusReadback: next => setDevices(previous => mergeStatusUpdate(previous, 'speaker', next)),
}),
);
}
render(h(Fixture), document.getElementById('fixture'));
`
var mu sync.Mutex
reads := 0
server := newPlayerFixtureServer(t, fixture, func(r chi.Router) {
r.Post("/api/control/devices/speaker/action/source", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true}`))
})
r.Get("/api/control/devices/speaker", func(w http.ResponseWriter, _ *http.Request) {
mu.Lock()
reads++
revision := reads + 1
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: map[string]any{
"status": map[string]any{
"revision": revision,
"nowPlayingRevision": revision,
"nowPlaying": map[string]string{
"Source": "AUX", "SourceAccount": "AUX1", "Track": "Confirmed track",
},
"sources": map[string]any{"SourceItem": []map[string]string{
{"Source": "AUX", "SourceAccount": "AUX1", "DisplayName": "Aux 1", "Status": "READY"},
}},
},
}})
})
})
ctx := newHeadlessChromeContext(t)
var title string
if err := chromedp.Run(ctx,
chromedp.Navigate(server.URL+"/fixture"),
chromedp.WaitVisible(`.source-btn`, chromedp.ByQuery),
chromedp.Click(`.source-btn`, chromedp.ByQuery),
chromedp.Poll(`document.querySelector('.track-title')?.textContent === 'Confirmed track'`, nil),
chromedp.Text(`.track-title`, &title, chromedp.ByQuery),
chromedp.Poll(`document.querySelector('.source-command-status').textContent === 'Source selected'`, nil),
); err != nil {
t.Fatalf("publish source readback through app state: %v", err)
}
if title != "Confirmed track" {
t.Fatalf("NowPlaying title = %q, want confirmed readback", title)
}
}
func TestSourceSelectionResetsWhenDeviceChanges(t *testing.T) {
var mu sync.Mutex
writes := 0
server := newPlayerFixtureServer(t, sourceFixtureScript, func(r chi.Router) {
r.Post("/api/control/devices/speaker/action/source", func(w http.ResponseWriter, _ *http.Request) {
mu.Lock()
writes++
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true}`))
})
r.Get("/api/control/devices/speaker", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true}`))
})
})
ctx := newHeadlessChromeContext(t)
var statusText string
if err := chromedp.Run(ctx,
chromedp.Navigate(server.URL+"/fixture"),
chromedp.WaitVisible(`.source-btn`, chromedp.ByQuery),
chromedp.Click(`.source-btn:nth-child(1)`, chromedp.ByQuery),
chromedp.Evaluate(`window.renderStatus('STANDBY', '', false, 'other-speaker')`, nil),
chromedp.Sleep(600*time.Millisecond),
chromedp.Text(`.source-command-status`, &statusText, chromedp.ByQuery),
); err != nil {
t.Fatalf("switch source component device: %v", err)
}
mu.Lock()
defer mu.Unlock()
if statusText != "" || writes != 1 {
t.Errorf("device switch left command state=%q or writes=%d, want cleared state and one original write", statusText, writes)
}
}
func TestSourceSelectionFencesOlderReadbackAndStatus(t *testing.T) {
var mu sync.Mutex
writes := map[string]int{}
currentSource := ""
readRevision := 10
server := newPlayerFixtureServer(t, sourceFixtureScript, func(r chi.Router) {
r.Post("/api/control/devices/speaker/action/source", func(w http.ResponseWriter, req *http.Request) {
var body webtypes.SourceRequest
_ = json.NewDecoder(req.Body).Decode(&body)
mu.Lock()
writes[body.Source]++
currentSource = body.Source
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true})
})
r.Get("/api/control/devices/speaker", func(w http.ResponseWriter, _ *http.Request) {
mu.Lock()
source := currentSource
readRevision++
revision := readRevision
mu.Unlock()
if source == "AUX" {
time.Sleep(400 * time.Millisecond)
}
w.Header().Set("Content-Type", "application/json")
responseSource := "STANDBY"
responseAccount := ""
if source == "AUX" {
responseSource = source
responseAccount = "AUX1"
}
_ = json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: map[string]any{
"status": map[string]any{
"revision": revision,
"nowPlayingRevision": revision,
"nowPlaying": map[string]string{
"Source": responseSource, "SourceAccount": responseAccount,
},
},
}})
})
})
ctx := newHeadlessChromeContext(t)
var outcome, productClass string
if err := chromedp.Run(ctx,
chromedp.Navigate(server.URL+"/fixture"),
chromedp.WaitVisible(`.source-btn`, chromedp.ByQuery),
chromedp.Click(`.source-btn:nth-child(1)`, chromedp.ByQuery),
chromedp.Sleep(140*time.Millisecond),
chromedp.Click(`.source-btn:nth-child(2)`, chromedp.ByQuery),
chromedp.Evaluate(`window.renderStatus('AUX', 'AUX1')`, nil),
chromedp.Poll(`document.querySelector('.source-command-status').textContent === 'Source selection unverified'`, nil),
chromedp.Text(`.source-command-status`, &outcome, chromedp.ByQuery),
chromedp.AttributeValue(`.source-btn:nth-child(2)`, "class", &productClass, nil, chromedp.ByQuery),
); err != nil {
t.Fatalf("exercise source generation fence: %v", err)
}
if outcome != "Source selection unverified" || !strings.Contains(productClass, "unverified") {
t.Errorf("newer outcome overwritten: status=%q class=%q", outcome, productClass)
}
mu.Lock()
defer mu.Unlock()
if writes["AUX"] != 1 || writes["PRODUCT"] != 1 {
t.Errorf("writes = %#v, want one AUX and one PRODUCT write", writes)
}
}
func TestStaleSourcesRemainVisibleButCannotBeSelected(t *testing.T) {
var mu sync.Mutex
writes := 0
server := newPlayerFixtureServer(t, sourceFixtureScript, func(r chi.Router) {
r.Post("/api/control/devices/speaker/action/source", func(w http.ResponseWriter, _ *http.Request) {
mu.Lock()
writes++
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true}`))
})
})
ctx := newHeadlessChromeContext(t)
var sourceCount, disabledCount int
var staleText, staleRole string
var described bool
if err := chromedp.Run(ctx,
chromedp.Navigate(server.URL+"/fixture"),
chromedp.WaitVisible(`.source-btn`, chromedp.ByQuery),
chromedp.Evaluate(`window.renderStatus('STANDBY', '', true)`, nil),
chromedp.WaitVisible(`#source-stale-status`, chromedp.ByQuery),
chromedp.Evaluate(`document.querySelectorAll('.source-btn').length`, &sourceCount),
chromedp.Evaluate(`document.querySelectorAll('.source-btn:disabled').length`, &disabledCount),
chromedp.Text(`#source-stale-status`, &staleText, chromedp.ByQuery),
chromedp.AttributeValue(`#source-stale-status`, "role", &staleRole, nil, chromedp.ByQuery),
chromedp.Evaluate(`[...document.querySelectorAll('.source-btn')].every(button => button.getAttribute('aria-describedby') === 'source-stale-status')`, &described),
chromedp.Evaluate(`document.querySelector('.source-btn').click()`, nil),
chromedp.Sleep(50*time.Millisecond),
); err != nil {
t.Fatalf("render stale source cache: %v", err)
}
mu.Lock()
staleWrites := writes
mu.Unlock()
if sourceCount != 3 || disabledCount != sourceCount {
t.Errorf("stale sources: rendered=%d disabled=%d, want all 3 retained and disabled", sourceCount, disabledCount)
}
if staleText != "Source list out of date" || staleRole != "status" || !described {
t.Errorf("stale indication: text=%q role=%q described=%v", staleText, staleRole, described)
}
if staleWrites != 0 {
t.Errorf("stale source selection issued %d writes, want 0", staleWrites)
}
var enabledCount int
var staleIndicatorMissing bool
if err := chromedp.Run(ctx,
chromedp.Evaluate(`window.renderStatus('STANDBY', '', false)`, nil),
chromedp.Poll(`document.querySelectorAll('.source-btn:disabled').length === 0`, nil),
chromedp.Evaluate(`document.querySelectorAll('.source-btn:not(:disabled)').length`, &enabledCount),
chromedp.Evaluate(`document.querySelector('#source-stale-status') === null`, &staleIndicatorMissing),
); err != nil {
t.Fatalf("render refreshed source cache: %v", err)
}
if enabledCount != sourceCount || !staleIndicatorMissing {
t.Errorf("fresh sources: enabled=%d want=%d staleIndicatorMissing=%v", enabledCount, sourceCount, staleIndicatorMissing)
}
var noInventoryText, noInventoryRole string
if err := chromedp.Run(ctx,
chromedp.Evaluate(`window.renderStatus('STANDBY', '', false, 'speaker', null, [])`, nil),
chromedp.WaitVisible(`#source-inventory-status`, chromedp.ByQuery),
chromedp.Text(`#source-inventory-status`, &noInventoryText, chromedp.ByQuery),
chromedp.AttributeValue(`#source-inventory-status`, "role", &noInventoryRole, nil, chromedp.ByQuery),
); err != nil {
t.Fatalf("render missing source inventory: %v", err)
}
if noInventoryText != "Source list unavailable" || noInventoryRole != "status" {
t.Errorf("missing source inventory: text=%q role=%q", noInventoryText, noInventoryRole)
}
}
+39 -7
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"strconv"
@@ -697,19 +698,50 @@ func (app *WebApp) handleBassControl(w http.ResponseWriter, r *http.Request, dev
app.sendControlResponse(w, err, fmt.Sprintf("Bass set to %d", bassReq.Level))
}
// handleSourceControl processes source control requests
// handleSourceControl processes source control requests. POST with an exact
// {source, account} JSON body is canonical. GET query parameters remain as a
// temporary compatibility surface and are explicitly marked deprecated.
func (app *WebApp) handleSourceControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
sourceParam := r.URL.Query().Get("name")
var sourceParam, accountParam string
switch r.Method {
case http.MethodPost:
r.Body = http.MaxBytesReader(w, r.Body, 8<<10)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
var sourceReq webtypes.SourceRequest
if err := decoder.Decode(&sourceReq); err != nil {
app.sendError(w, "Invalid source data", http.StatusBadRequest)
return
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
app.sendError(w, "Invalid source data", http.StatusBadRequest)
return
}
sourceParam = sourceReq.Source
accountParam = sourceReq.Account
case http.MethodGet:
w.Header().Set("Deprecation", "true")
w.Header().Set("Warning", `299 - "GET source control is deprecated; use POST with a JSON body"`)
sourceParam = r.URL.Query().Get("name")
accountParam = r.URL.Query().Get("account")
default:
app.sendError(w, "POST required for source control", http.StatusMethodNotAllowed)
return
}
if sourceParam == "" {
app.sendError(w, "Source name required", http.StatusBadRequest)
return
}
// Forward the optional account parameter as sourceAccount. Devices with
// multiple jacks that share source="AUX" (e.g. ST-5 CD/Aux inputs)
// disambiguate them via distinct sourceAccount values (AUX, AUX1, …).
accountParam := r.URL.Query().Get("account")
// Forward account verbatim as sourceAccount. Devices with multiple jacks
// that share source="AUX" (e.g. ST-5 CD/Aux inputs) disambiguate them via
// distinct sourceAccount values (AUX, AUX1, …).
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
+67 -6
View File
@@ -850,12 +850,10 @@ func TestHandleDevicePlay_SourceAccountFiltering(t *testing.T) {
}
}
// TestHandleSourceControl_ForwardsAccount verifies that the account query
// parameter is forwarded as sourceAccount in the /select XML. Devices like
// the ST-5 expose multiple AUX jacks that share source="AUX" and are only
// disambiguated by distinct sourceAccount values (AUX, AUX1, …). Regression
// test for issue #444, where the handler dropped the account parameter.
func TestHandleSourceControl_ForwardsAccount(t *testing.T) {
// TestHandleSourceControl_LegacyGETForwardsAccount verifies that the temporary
// GET compatibility route still forwards sourceAccount while clearly marking
// the response deprecated.
func TestHandleSourceControl_LegacyGETForwardsAccount(t *testing.T) {
tests := []struct {
name string
query string
@@ -904,6 +902,12 @@ func TestHandleSourceControl_ForwardsAccount(t *testing.T) {
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if got := w.Header().Get("Deprecation"); got != "true" {
t.Errorf("Deprecation header = %q, want true", got)
}
if got := w.Header().Get("Warning"); !strings.Contains(got, "use POST") {
t.Errorf("Warning header = %q, want POST migration guidance", got)
}
if want := `source="AUX"`; !strings.Contains(capturedBody, want) {
t.Errorf("XML should contain %q, got: %s", want, capturedBody)
@@ -1060,6 +1064,63 @@ func TestHandleZoneAddRejectsStandbyMaster(t *testing.T) {
}
}
func TestHandleSourceControl_CanonicalPOSTForwardsExactBody(t *testing.T) {
var capturedBody string
speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/select" {
b, _ := io.ReadAll(r.Body)
capturedBody = string(b)
}
w.WriteHeader(http.StatusOK)
}))
defer speaker.Close()
app := NewWebApp()
conn := webtypes.NewDeviceConnection(
client.NewClient(&client.Config{Host: speaker.URL}),
&models.DeviceInfo{Name: "Test Speaker"},
)
conn.SetStatus(&webtypes.DeviceStatus{IsConnected: true, LastActivity: time.Now()})
app.AddDevice("source-device", conn)
body := strings.NewReader(`{"source":"AUX","account":"AUX1"}`)
req := httptest.NewRequest(http.MethodPost, "/api/control/devices/source-device/action/source", body)
req.Header.Set("Content-Type", "application/json")
req = withChiParams(req, map[string]string{"id": "source-device", "action": "source"})
w := httptest.NewRecorder()
app.HandleAPIControl(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if got := w.Header().Get("Deprecation"); got != "" {
t.Errorf("canonical POST unexpectedly marked deprecated: %q", got)
}
for _, want := range []string{`source="AUX"`, `sourceAccount="AUX1"`} {
if !strings.Contains(capturedBody, want) {
t.Errorf("XML should contain %q, got: %s", want, capturedBody)
}
}
}
func TestHandleSourceControl_CanonicalPOSTRejectsUnknownFields(t *testing.T) {
app := NewWebApp()
conn := webtypes.NewDeviceConnection(nil, &models.DeviceInfo{Name: "Test Speaker"})
app.AddDevice("source-device", conn)
body := strings.NewReader(`{"source":"AUX","account":"AUX1","name":"legacy"}`)
req := httptest.NewRequest(http.MethodPost, "/api/control/devices/source-device/action/source", body)
req = withChiParams(req, map[string]string{"id": "source-device", "action": "source"})
w := httptest.NewRecorder()
app.HandleAPIControl(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
// TestHandleZoneRemove_UsesRemoveZoneSlave is the #511 regression: removing one
// member from a multi-member zone must target that member via /removeZoneSlave.
// The previous implementation rebuilt the zone with /setZone and the remaining
+3 -1
View File
@@ -71,7 +71,9 @@ func (app *WebApp) MountWeb(r chi.Router, discoveryService *discovery.UnifiedDis
r.Get("/recents", app.HandleDeviceRecents)
// Low-level "play this ContentItem" primitive (not a provider).
r.Post("/play", app.HandleDevicePlay)
// Generic key / preset / source / bass actions.
// Generic key / preset / source / bass actions. Source selection is
// canonically POSTed as JSON; its GET form remains temporarily for
// compatibility and marks every response as deprecated.
r.Get("/action/{action}", app.HandleAPIControl)
r.Post("/action/{action}", app.HandleAPIControl)
r.Get("/ws", app.HandleDeviceWebSocket)
@@ -0,0 +1,145 @@
package soundtouchweb
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
)
// TestUpdateDeviceStatusDoesNotRefreshNowPlayingRevisionOnFailure: a poll round
// where /now_playing failed but /volume succeeded must merge the volume and
// leave now-playing authority untouched. A source selection waiting for
// confirmation reads NowPlayingRevision, so advancing it on a failed read
// would confirm a selection nothing actually verified.
func TestUpdateDeviceStatusDoesNotRefreshNowPlayingRevisionOnFailure(t *testing.T) {
speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/volume" {
_, _ = w.Write([]byte(`<volume><targetvolume>35</targetvolume><actualvolume>35</actualvolume><muteenabled>false</muteenabled></volume>`))
return
}
http.Error(w, "unavailable", http.StatusServiceUnavailable)
}))
defer speaker.Close()
conn := webtypes.NewDeviceConnection(client.NewClient(&client.Config{Host: speaker.URL}), nil)
conn.SetStatus(&webtypes.DeviceStatus{NowPlaying: &models.NowPlaying{Source: "SPOTIFY"}})
baseline := conn.Status()
NewWebApp().UpdateDeviceStatus("speaker", conn)
updated := conn.Status()
if updated.Revision <= baseline.Revision || updated.Volume == nil || updated.Volume.ActualVolume != 35 {
t.Fatalf("unrelated successful field was not merged: %+v", updated)
}
if updated.NowPlaying.Source != "SPOTIFY" || updated.NowPlayingRevision != baseline.NowPlayingRevision {
t.Fatalf("failed now-playing read advanced source authority: %+v", updated)
}
}
// TestUpdateSourcesCacheRetainsFailureAndRefreshesSuccess: a failed refresh
// keeps the previous inventory and its read time but marks it stale; the next
// success replaces both and restores actionability.
func TestUpdateSourcesCacheRetainsFailureAndRefreshesSuccess(t *testing.T) {
conn := webtypes.NewDeviceConnection(nil, nil)
// Read times must sit inside sourceCacheTTL of now: Status() derives
// staleness against the wall clock, so fixed calendar dates would read
// back as expired no matter what this test does.
firstRead := time.Now()
oldSources := &models.Sources{SourceItem: []models.SourceItem{{Source: "AUX"}}}
updateSourcesCache(conn, conn.BeginFieldPoll(webtypes.FieldSources), oldSources, nil, firstRead)
if !updateSourcesCache(conn, conn.BeginFieldPoll(webtypes.FieldSources), nil,
errors.New("temporary read failure"), firstRead.Add(time.Second)) {
t.Fatal("failed source readback was not recorded")
}
status := conn.Status()
if status.Sources != oldSources || !status.SourcesReadAt.Equal(firstRead) || !status.SourcesStale {
t.Fatalf("failed source readback changed the cache: %+v", status)
}
secondRead := time.Now()
newSources := &models.Sources{SourceItem: []models.SourceItem{{Source: "PRODUCT"}}}
if !updateSourcesCache(conn, conn.BeginFieldPoll(webtypes.FieldSources), newSources, nil, secondRead) {
t.Fatal("successful source readback was not reported as an update")
}
status = conn.Status()
if status.Sources != newSources || !status.SourcesReadAt.Equal(secondRead) || status.SourcesStale {
t.Fatalf("successful source readback did not refresh the cache: %+v", status)
}
}
// TestHandleAPIDevicePublishesCanonicalReadback: the player's bounded readback
// after a source selection reads GET /devices/{id}, so that response must
// carry the same revisions the connection now holds -- not a snapshot taken
// before its own refresh.
func TestHandleAPIDevicePublishesCanonicalReadback(t *testing.T) {
speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
responses := map[string]string{
"/now_playing": `<nowPlaying source="AUX" sourceAccount="AUX1"><track>Confirmed track</track><playStatus>PLAY_STATE</playStatus></nowPlaying>`,
"/volume": `<volume><targetvolume>35</targetvolume><actualvolume>35</actualvolume><muteenabled>false</muteenabled></volume>`,
"/presets": `<presets></presets>`,
"/sources": `<sources><sourceItem source="AUX" sourceAccount="AUX1" status="READY" isLocal="true">Aux 1</sourceItem></sources>`,
"/bass": `<bass><targetbass>0</targetbass><actualbass>0</actualbass></bass>`,
}
body, ok := responses[r.URL.Path]
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(body))
}))
defer speaker.Close()
app := NewWebApp()
conn := webtypes.NewDeviceConnection(
client.NewClient(&client.Config{Host: speaker.URL}),
&models.DeviceInfo{Name: "Speaker"},
)
conn.SetStatus(&webtypes.DeviceStatus{NowPlaying: &models.NowPlaying{Source: "STANDBY"}})
baselineRevision := conn.Status().NowPlayingRevision
conn.WebSocket = &client.WebSocketClient{}
app.AddDevice("speaker", conn)
req := httptest.NewRequest(http.MethodGet, "/api/control/devices/speaker", nil)
req = withChiParams(req, map[string]string{"id": "speaker"})
w := httptest.NewRecorder()
app.HandleAPIDevice(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET device status = %d: %s", w.Code, w.Body.String())
}
var response struct {
Success bool `json:"success"`
Data struct {
Status webtypes.DeviceStatus `json:"status"`
} `json:"data"`
}
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("decode device readback: %v", err)
}
canonical := conn.Status()
if !response.Success || canonical.NowPlaying.Source != "AUX" || canonical.NowPlaying.Track != "Confirmed track" {
t.Fatalf("canonical readback not merged: response=%+v status=%+v", response, canonical)
}
if response.Data.Status.NowPlaying.Source != canonical.NowPlaying.Source ||
response.Data.Status.Revision != canonical.Revision ||
response.Data.Status.NowPlayingRevision != canonical.NowPlayingRevision ||
canonical.NowPlayingRevision <= baselineRevision {
t.Fatalf("response did not publish canonical status: response=%+v status=%+v", response.Data.Status, canonical)
}
}
+13 -1
View File
@@ -700,11 +700,23 @@ img { display: block; max-width: 100%; }
font-size: .8rem;
transition: background .1s, border-color .1s;
}
.source-btn:hover { background: var(--bg); }
.source-btn:hover:not(:disabled) { background: var(--bg); }
.source-btn:disabled { cursor: not-allowed; opacity: .55; }
.source-btn.active { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); }
.source-btn.pending { cursor: wait; }
.source-btn.provisional-confirmed { border-style: dashed; cursor: progress; }
.source-btn.unverified { border-color: #a76b00; }
.source-btn.failed { border-color: var(--offline); }
.source-btn.local { border-style: dashed; }
.source-icon { font-size: .9rem; line-height: 1; }
.source-name { font-weight: 500; }
.source-command-status {
min-height: 1.2em;
margin-top: .35rem;
color: var(--text-dim);
font-size: .75rem;
}
.source-command-status.availability { color: #8a5a00; }
.stereo-pair-note {
display: flex;
+19 -1
View File
@@ -5,6 +5,20 @@ async function req(url, opts = {}) {
return r.json();
}
async function checkedReq(url, opts = {}) {
const r = await fetch(url, opts);
let response;
try {
response = await r.json();
} catch (_) {
throw new Error(`Request failed (${r.status})`);
}
if (!r.ok || response?.success === false) {
throw new Error(response?.error || `Request failed (${r.status})`);
}
return response;
}
export const api = {
devices: () => req('/api/control/devices'),
device: (id) => req(`/api/control/devices/${id}`),
@@ -51,7 +65,11 @@ export const api = {
tuneInSearchNext: (cursor) => req(`/api/control/providers/tunein/search/next?cursor=${encodeURIComponent(cursor)}`),
control: (id, action, presetId) => req(`/api/control/devices/${id}/action/${action}?id=${presetId}`),
storePreset: (id, slotId) => req(`/api/control/devices/${id}/action/storepreset?id=${slotId}`),
selectSource: (id, source, account) => req(`/api/control/devices/${id}/action/source?name=${encodeURIComponent(source)}&account=${encodeURIComponent(account || '')}`),
selectSource: (id, source, account) => checkedReq(`/api/control/devices/${id}/action/source`, {
method: 'POST',
headers: JSON_HEADERS,
body: JSON.stringify({ source, account: account ?? '' }),
}),
tuneInPlay: (deviceId, item) => req(`/api/control/devices/${deviceId}/providers/tunein/play`, {
method: 'POST',
headers: JSON_HEADERS,
@@ -0,0 +1,51 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { api } from './api.js';
test('selectSource posts the exact source and account body', async () => {
const requests = [];
globalThis.fetch = async (url, options) => {
requests.push({ url, options });
return new Response('{"success":true}', {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
};
await api.selectSource('speaker', 'AUX', 'AUX1');
await api.selectSource('speaker', 'PRODUCT');
const request = requests[0];
assert.equal(request.url, '/api/control/devices/speaker/action/source');
assert.equal(request.options.method, 'POST');
assert.deepEqual(JSON.parse(request.options.body), { source: 'AUX', account: 'AUX1' });
assert.deepEqual(JSON.parse(requests[1].options.body), { source: 'PRODUCT', account: '' });
});
test('source selection rejects non-2xx responses', async () => {
globalThis.fetch = async () => new Response('{"success":false,"error":"offline"}', {
status: 503,
headers: { 'Content-Type': 'application/json' },
});
await assert.rejects(api.selectSource('speaker', 'AUX', 'AUX1'), /offline/);
});
test('source selection rejects application failures on 2xx responses', async () => {
globalThis.fetch = async () => new Response('{"success":false,"error":"rejected"}', {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
await assert.rejects(api.selectSource('speaker', 'AUX', 'AUX1'), /rejected/);
});
test('legacy API consumers retain response-level error handling', async () => {
globalThis.fetch = async () => new Response('{"success":false,"error":"offline"}', {
status: 503,
headers: { 'Content-Type': 'application/json' },
});
assert.deepEqual(await api.devices(), { success: false, error: 'offline' });
});
+87 -15
View File
@@ -21,7 +21,79 @@ import { removeDeviceAndRefresh } from './deviceRemoval.js';
const html = htm.bind(h);
function DeviceDetail({ deviceId, devices, onBack, onDevicesChanged, notify, onRemove }) {
function statusRevision(status) {
const revision = status?.revision;
return Number.isSafeInteger(revision) && revision >= 0 ? revision : null;
}
// The server advances DeviceStatus.revision on every projection, so a frame
// carrying a revision no newer than what we already hold is stale -- a slow
// `devices` snapshot overtaken by a `status_update` delta, or a REST refresh
// overtaken by either. A device we have never seen is always accepted.
function acceptsNewerStatus(current, incoming) {
const currentRevision = statusRevision(current);
const incomingRevision = statusRevision(incoming);
if (currentRevision === null) return true;
return incomingRevision !== null && incomingRevision > currentRevision;
}
// The server derives sourcesStale at read time, so two reads at the same
// revision can disagree about it. Carry that one derived bit forward without
// letting an otherwise-stale frame replace the canonical state.
function mergeDerivedStatus(current, incoming) {
const currentRevision = statusRevision(current);
const incomingRevision = statusRevision(incoming);
if (currentRevision === null || incomingRevision !== currentRevision ||
current?.sourcesStale === true || incoming?.sourcesStale !== true) {
return current;
}
return { ...current, sourcesStale: true };
}
export function mergeDevicesSnapshot(previous, snapshot) {
return Object.fromEntries(Object.entries(snapshot || {}).map(([deviceId, incoming]) => {
const current = Object.prototype.hasOwnProperty.call(previous, deviceId)
? previous[deviceId] : null;
if (!current || acceptsNewerStatus(current.status, incoming?.status)) {
return [deviceId, incoming];
}
return [deviceId, {
...incoming,
status: mergeDerivedStatus(current.status, incoming?.status),
}];
}));
}
function replaceDevice(previous, deviceId, device) {
return Object.fromEntries([
...Object.entries(previous),
[deviceId, device],
]);
}
export function mergeStatusUpdate(previous, deviceId, status) {
// Object.prototype.hasOwnProperty, not a plain previous[deviceId] truthy
// check: a deviceId of "__proto__" or "constructor" would otherwise
// resolve through the prototype chain to a truthy value and pass the
// check despite not being a real, known device.
if (!Object.prototype.hasOwnProperty.call(previous, deviceId) ||
!acceptsNewerStatus(previous[deviceId]?.status, status)) {
const current = previous[deviceId]?.status;
const merged = mergeDerivedStatus(current, status);
if (merged === current) return previous;
return replaceDevice(previous, deviceId, {
...previous[deviceId],
status: merged,
});
}
return replaceDevice(previous, deviceId, {
...previous[deviceId],
status,
});
}
function DeviceDetail({ deviceId, devices, onBack, onDevicesChanged, notify, onRemove, onStatusReadback }) {
const device = devices[deviceId];
if (!device) {
@@ -47,7 +119,11 @@ function DeviceDetail({ deviceId, devices, onBack, onDevicesChanged, notify, onR
<${NowPlaying} nowPlaying=${device.status?.nowPlaying} deviceId=${deviceId} presets=${device.status?.presets} />
<${Controls} deviceId=${deviceId} status=${device.status} />
<${Presets} deviceId=${deviceId} status=${device.status} />
<${Sources} deviceId=${deviceId} status=${device.status} />
<${Sources}
deviceId=${deviceId}
status=${device.status}
onStatusReadback=${status => onStatusReadback(deviceId, status)}
/>
<${StereoPair}
deviceId=${deviceId}
device=${device}
@@ -136,7 +212,7 @@ function App() {
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'devices') {
setDevices(msg.data || {});
setDevices(previous => mergeDevicesSnapshot(previous, msg.data));
} else if (msg.type === 'discovery_status') {
if (msg.data?.isDiscovering !== undefined) {
setIsDiscovering(msg.data.isDiscovering);
@@ -150,17 +226,7 @@ function App() {
showToast(`Found ${msg.data.deviceCount} device(s)`);
}
} else if (msg.type === 'status_update' && msg.deviceId) {
setDevices(prev => {
// Object.prototype.hasOwnProperty, not a plain prev[msg.deviceId]
// truthy check: a deviceId of "__proto__" or "constructor" would
// otherwise resolve through the prototype chain to a truthy value
// and pass the check despite not being a real, known device.
if (!Object.prototype.hasOwnProperty.call(prev, msg.deviceId)) return prev;
return {
...prev,
[msg.deviceId]: { ...prev[msg.deviceId], status: msg.data },
};
});
setDevices(previous => mergeStatusUpdate(previous, msg.deviceId, msg.data));
}
};
@@ -203,6 +269,10 @@ function App() {
setDevices(resp.data || {});
}
function mergeDeviceReadback(deviceId, status) {
setDevices(previous => mergeStatusUpdate(previous, deviceId, status));
}
async function removeDevice(id) {
const name = devices[id]?.info?.name || id;
if (!confirm(`Remove "${name}" from AfterTouch?\n\nThis does not reset the speaker. A device still online may reappear after the next discovery scan.`)) {
@@ -302,6 +372,7 @@ function App() {
onDevicesChanged=${refreshDevices}
notify=${showToast}
onRemove=${removeDevice}
onStatusReadback=${mergeDeviceReadback}
/>
` : page === 'tunein' ? html`
<${TuneInBrowser} key="tunein-browser" devices=${devices} />
@@ -334,4 +405,5 @@ function App() {
`;
}
render(html`<${App} />`, document.getElementById('app'));
const appRoot = document.getElementById('app');
if (appRoot) render(html`<${App} />`, appRoot);
@@ -1,4 +1,5 @@
import { h } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import htm from 'htm';
import { api } from '../api.js';
@@ -11,31 +12,219 @@ const SOURCE_ICONS = {
AIRPLAY: '📡', PRODUCT: '🔊',
};
export function Sources({ deviceId, status }) {
const SOURCE_READBACK_DELAYS_MS = [2000, 5000, 10000];
function isErrorSource(source) {
return source === 'INVALID_SOURCE' || source?.endsWith('_ERROR');
}
function sourceAccountIdentity(source, account) {
return account && account !== source ? account : '';
}
function sourceAccountsMatch(source, left, right) {
return sourceAccountIdentity(source, left) === sourceAccountIdentity(source, right);
}
export function Sources({
deviceId,
status,
onStatusReadback,
readbackDelays = SOURCE_READBACK_DELAYS_MS,
}) {
const [command, setCommand] = useState(null);
const commandRef = useRef({ generation: 0, active: null, timers: [] });
const mountedRef = useRef(false);
const items = status?.sources?.SourceItem ?? [];
const currentSource = status?.nowPlaying?.Source;
const currentAccount = status?.nowPlaying?.SourceAccount;
const nowPlayingRevision = Number.isSafeInteger(status?.nowPlayingRevision)
? status.nowPlayingRevision : null;
const sourcesStale = status?.sourcesStale === true;
function clearReadbacks() {
commandRef.current.timers.forEach(clearTimeout);
commandRef.current.timers = [];
}
useEffect(() => {
if (!mountedRef.current) {
mountedRef.current = true;
return;
}
commandRef.current.generation += 1;
commandRef.current.active = null;
clearReadbacks();
setCommand(null);
}, [deviceId]);
useEffect(() => {
return () => {
commandRef.current.generation += 1;
commandRef.current.active = null;
clearReadbacks();
};
}, []);
useEffect(() => {
if (!command || nowPlayingRevision === null || command.startNowPlayingRevision === null ||
nowPlayingRevision <= command.startNowPlayingRevision ||
command.outcome === 'failed' || command.outcome === 'unverified') return;
const matches = currentSource === command.source &&
sourceAccountsMatch(command.source, currentAccount, command.account);
if (command.outcome === 'final-confirmed') {
if (nowPlayingRevision > command.confirmedRevision && !matches) {
setCommand(previous => previous?.generation === command.generation
? null : previous);
}
return;
}
if (isErrorSource(currentSource)) {
clearReadbacks();
commandRef.current.active = null;
setCommand(previous => previous?.generation === command.generation
? { ...previous, outcome: 'failed', error: currentSource }
: previous);
} else if (matches && command.outcome === 'pending') {
setCommand(previous => previous?.generation === command.generation
? { ...previous, outcome: 'provisional-confirmed' }
: previous);
}
}, [command, currentSource, currentAccount, nowPlayingRevision]);
const ready = items.filter(s => s.Status === 'READY');
if (ready.length === 0) return null;
const availabilityMessage = sourcesStale
? 'Source list out of date'
: (ready.length === 0 ? 'Source list unavailable' : '');
const availabilityId = sourcesStale
? 'source-stale-status'
: (ready.length === 0 ? 'source-inventory-status' : null);
function select(src) {
api.selectSource(deviceId, src.Source, src.SourceAccount ?? '');
async function select(src) {
clearReadbacks();
const generation = commandRef.current.generation + 1;
const target = { source: src.Source, account: src.SourceAccount ?? '' };
commandRef.current.generation = generation;
const active = { generation, latestReadback: -1, writeError: null };
commandRef.current.active = active;
setCommand({
...target,
generation,
outcome: 'pending',
startNowPlayingRevision: nowPlayingRevision,
});
const startedAt = Date.now();
readbackDelays.forEach((delay, index) => {
const timer = setTimeout(async () => {
if (commandRef.current.active !== active) return;
active.latestReadback = index;
try {
const response = await api.device(deviceId);
if (commandRef.current.active !== active || active.latestReadback !== index) return;
const readbackStatus = response?.data?.status;
const nowPlaying = readbackStatus?.nowPlaying;
const readbackRevision = readbackStatus?.nowPlayingRevision;
const revisionIsNewer = nowPlayingRevision !== null &&
Number.isSafeInteger(readbackRevision) &&
readbackRevision > nowPlayingRevision;
onStatusReadback?.(readbackStatus);
if (revisionIsNewer && isErrorSource(nowPlaying?.Source)) {
clearReadbacks();
commandRef.current.active = null;
setCommand({
...target,
generation,
outcome: 'failed',
error: nowPlaying.Source,
startNowPlayingRevision: nowPlayingRevision,
});
} else if (revisionIsNewer && nowPlaying?.Source === target.source &&
sourceAccountsMatch(target.source, nowPlaying?.SourceAccount, target.account)) {
const isFinalReadback = index === readbackDelays.length - 1;
setCommand({
...target,
generation,
outcome: isFinalReadback ? 'final-confirmed' : 'provisional-confirmed',
startNowPlayingRevision: nowPlayingRevision,
confirmedRevision: readbackRevision,
});
if (isFinalReadback) {
clearReadbacks();
commandRef.current.active = null;
}
} else if (index === readbackDelays.length - 1) {
commandRef.current.active = null;
setCommand({
...target,
generation,
outcome: 'unverified',
error: active.writeError?.message,
startNowPlayingRevision: nowPlayingRevision,
});
}
} catch (_) {
if (commandRef.current.active === active && active.latestReadback === index &&
index === readbackDelays.length - 1) {
commandRef.current.active = null;
setCommand({
...target,
generation,
outcome: 'unverified',
error: active.writeError?.message,
startNowPlayingRevision: nowPlayingRevision,
});
}
}
}, Math.max(0, delay - (Date.now() - startedAt)));
commandRef.current.timers.push(timer);
});
try {
await api.selectSource(deviceId, target.source, target.account);
} catch (error) {
if (commandRef.current.active === active) active.writeError = error;
}
}
const projectsTarget = command && (command.outcome === 'pending' ||
command.outcome === 'provisional-confirmed' || command.outcome === 'final-confirmed');
const projectedSource = projectsTarget
? command.source : currentSource;
const projectedAccount = projectsTarget
? command.account : (currentAccount ?? '');
const outcomeText = {
pending: 'Selecting source',
'provisional-confirmed': 'Source selected, confirming',
'final-confirmed': 'Source selected',
unverified: 'Source selection unverified',
failed: 'Source selection failed',
};
return html`
<div class="sources-section">
<h3 class="section-title">Sources</h3>
<div class="source-list">
${ready.map(src => {
const isActive = src.Source === currentSource &&
(!src.SourceAccount || src.SourceAccount === currentAccount);
const account = src.SourceAccount ?? '';
const isTarget = command?.source === src.Source && command?.account === account;
const isActive = src.Source === projectedSource &&
sourceAccountsMatch(src.Source, account, projectedAccount);
const outcome = isTarget ? command.outcome : '';
return html`
<button
key=${src.Source + (src.SourceAccount || '')}
class="source-btn ${isActive ? 'active' : ''} ${src.IsLocal ? 'local' : ''}"
key=${src.Source + account}
class="source-btn ${isActive ? 'active' : ''} ${src.IsLocal ? 'local' : ''} ${outcome}"
onClick=${() => select(src)}
title=${src.Source}
disabled=${sourcesStale}
title=${availabilityMessage || (outcome ? outcomeText[outcome] : src.Source)}
aria-describedby=${availabilityId}
aria-busy=${outcome === 'pending' || outcome === 'provisional-confirmed' ? 'true' : null}
>
<span class="source-icon">${SOURCE_ICONS[src.Source] || '🔊'}</span>
<span class="source-name">${src.DisplayName || src.Source}</span>
@@ -43,6 +232,14 @@ export function Sources({ deviceId, status }) {
`;
})}
</div>
<div
id=${availabilityId}
class="source-command-status ${availabilityMessage ? 'availability' : ''}"
role="status"
aria-live="polite"
>
${availabilityMessage || (command ? outcomeText[command.outcome] : '')}
</div>
</div>
`;
}
}
+31 -5
View File
@@ -752,6 +752,7 @@ func (app *WebApp) updateDeviceStatus(_ string, conn *webtypes.DeviceConnection,
volume, volumeErr := conn.Client.GetVolume()
presets, presetsErr := conn.Client.GetPresets()
sources, sourcesErr := conn.Client.GetSources()
sourcesReadAt := time.Now()
bass, bassErr := conn.Client.GetBass()
var (
@@ -797,13 +798,14 @@ func (app *WebApp) updateDeviceStatus(_ string, conn *webtypes.DeviceConnection,
if sourcesErr == nil {
anyFetchSucceeded = true
conn.CompleteFieldPoll(webtypes.FieldSources, sourcesGen, func(s *webtypes.DeviceStatus) {
s.Sources = sources
s.LastActivity = time.Now()
})
}
// Unlike the other fields, a FAILED /sources read is merged too: the last
// known inventory stays visible but is marked stale so it cannot be acted
// on. Running through CompleteFieldPoll is what makes a newer failure
// fence an older, still-in-flight success.
updateSourcesCache(conn, sourcesGen, sources, sourcesErr, sourcesReadAt)
if bassErr == nil {
anyFetchSucceeded = true
@@ -849,6 +851,30 @@ func (app *WebApp) updateDeviceStatus(_ string, conn *webtypes.DeviceConnection,
}
}
// updateSourcesCache records a source refresh, successful or not. A failure
// keeps the previous inventory and its read time, but marks it stale so the
// player stops offering it until a read succeeds again.
func updateSourcesCache(
conn *webtypes.DeviceConnection,
generation uint64,
sources *models.Sources,
err error,
readAt time.Time,
) bool {
return conn.CompleteFieldPoll(webtypes.FieldSources, generation, func(s *webtypes.DeviceStatus) {
if err != nil {
s.SourcesStale = true
return
}
s.Sources = sources
s.SourcesReadAt = readAt
s.SourcesStale = false
s.LastActivity = time.Now()
})
}
func (app *WebApp) applyGroupUpdatedEvent(
conn *webtypes.DeviceConnection,
event *models.GroupUpdatedEvent,
@@ -0,0 +1,120 @@
package webtypes
import (
"encoding/json"
"strings"
"sync"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
// TestNowPlayingRejectsPollOlderThanEvent is the core ordering property the
// player's source confirmation relies on: a /now_playing poll that started
// before a nowPlayingUpdated event must not overwrite the event's result when
// it finishes late.
func TestNowPlayingRejectsPollOlderThanEvent(t *testing.T) {
conn := NewDeviceConnection(nil, nil)
pollGeneration := conn.BeginFieldPoll(FieldNowPlaying)
eventNowPlaying := &models.NowPlaying{Track: "new event"}
conn.ApplyFieldEvent(FieldNowPlaying, func(status *DeviceStatus) {
status.NowPlaying = eventNowPlaying
})
if conn.CompleteFieldPoll(FieldNowPlaying, pollGeneration, func(status *DeviceStatus) {
status.NowPlaying = &models.NowPlaying{Track: "old poll"}
}) {
t.Fatal("older NowPlaying poll was accepted after the event")
}
if got := conn.Status().NowPlaying; got != eventNowPlaying {
t.Fatalf("older poll replaced NowPlaying event: %+v", got)
}
}
func TestDeviceStatusRevisionAdvancesForEveryProjection(t *testing.T) {
conn := NewDeviceConnection(nil, nil)
if got := conn.Status().Revision; got != 0 {
t.Fatalf("initial revision = %d, want 0", got)
}
conn.UpdateStatus(func(*DeviceStatus) {})
if got := conn.Status().Revision; got != 1 {
t.Fatalf("revision after UpdateStatus = %d, want 1", got)
}
// A caller-supplied Revision is never trusted: resetting it would make a
// browser holding a higher revision reject every later update.
conn.SetStatus(&DeviceStatus{Revision: 99})
if got := conn.Status().Revision; got != 2 {
t.Fatalf("revision after SetStatus = %d, want 2", got)
}
}
func TestUnrelatedProjectionDoesNotAdvanceNowPlayingRevision(t *testing.T) {
conn := NewDeviceConnection(nil, nil)
conn.SetStatus(&DeviceStatus{NowPlaying: &models.NowPlaying{Source: "SPOTIFY"}})
baseline := conn.Status()
volumeGeneration := conn.BeginFieldPoll(FieldVolume)
conn.CompleteFieldPoll(FieldVolume, volumeGeneration, func(status *DeviceStatus) {
status.Volume = &models.Volume{ActualVolume: 35}
})
updated := conn.Status()
if updated.Revision <= baseline.Revision {
t.Fatalf("aggregate revision = %d, want newer than %d", updated.Revision, baseline.Revision)
}
if updated.NowPlayingRevision != baseline.NowPlayingRevision {
t.Fatalf("now-playing revision = %d, want unchanged %d after volume update",
updated.NowPlayingRevision, baseline.NowPlayingRevision)
}
}
func TestDeviceStatusRevisionIsMonotonicWithConcurrentProjections(t *testing.T) {
conn := NewDeviceConnection(nil, nil)
const projections = 64
var wg sync.WaitGroup
wg.Add(projections)
for i := range projections {
go func() {
defer wg.Done()
if i%2 == 0 {
conn.UpdateStatus(func(*DeviceStatus) {})
return
}
conn.SetStatus(&DeviceStatus{})
}()
}
wg.Wait()
if got := conn.Status().Revision; got != projections {
t.Fatalf("final revision = %d, want %d", got, projections)
}
}
func TestDeviceStatusJSONExposesPublicRevisionsOnly(t *testing.T) {
conn := NewDeviceConnection(nil, nil)
conn.ApplyFieldEvent(FieldNowPlaying, func(status *DeviceStatus) {
status.NowPlaying = &models.NowPlaying{Track: "test"}
})
encoded, err := json.Marshal(conn.Status())
if err != nil {
t.Fatalf("marshal DeviceStatus: %v", err)
}
jsonStatus := string(encoded)
if !strings.Contains(jsonStatus, `"revision":1`) {
t.Fatalf("public revision missing from JSON: %s", jsonStatus)
}
if !strings.Contains(jsonStatus, `"nowPlayingRevision":1`) {
t.Fatalf("now-playing revision missing from JSON: %s", jsonStatus)
}
if strings.Contains(jsonStatus, "fieldGen") {
t.Fatalf("internal field generation leaked into JSON: %s", jsonStatus)
}
}
@@ -0,0 +1,113 @@
package webtypes
import (
"encoding/json"
"strings"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
func TestSourceCacheStatusAtTTLBoundary(t *testing.T) {
readAt := time.Date(2026, time.August, 30, 12, 0, 0, 0, time.UTC)
sources := &models.Sources{SourceItem: []models.SourceItem{{Source: "AUX"}}}
status := &DeviceStatus{
Sources: sources,
SourcesReadAt: readAt,
}
fresh := sourceCacheStatusAt(status, readAt.Add(sourceCacheTTL-time.Nanosecond), sourceCacheTTL)
if fresh.SourcesStale {
t.Fatal("source cache became stale before its TTL elapsed")
}
stale := sourceCacheStatusAt(status, readAt.Add(sourceCacheTTL), sourceCacheTTL)
if !stale.SourcesStale {
t.Fatal("source cache was not stale at its TTL boundary")
}
if stale.Sources != sources {
t.Fatal("stale projection did not retain the last successful source list")
}
refreshed := *stale
refreshed.SourcesReadAt = readAt.Add(sourceCacheTTL + time.Second)
refreshed.SourcesStale = false
got := sourceCacheStatusAt(&refreshed, refreshed.SourcesReadAt, sourceCacheTTL)
if got.SourcesStale {
t.Fatal("successful source readback did not clear staleness immediately")
}
}
func TestSourceCacheWithoutSuccessfulReadIsNotStale(t *testing.T) {
status := &DeviceStatus{Sources: &models.Sources{}}
got := sourceCacheStatusAt(status, time.Now().Add(time.Hour), time.Nanosecond)
if got.SourcesStale {
t.Fatal("source cache without a recorded successful read was marked stale")
}
}
func TestSourceCacheFailureWithoutInventoryIsExplicit(t *testing.T) {
conn := NewDeviceConnection(nil, nil)
conn.CompleteFieldPoll(FieldSources, conn.BeginFieldPoll(FieldSources), func(status *DeviceStatus) {
status.SourcesStale = true
})
status := conn.Status()
if status.Sources != nil || !status.SourcesStale {
t.Fatalf("initial source failure was not represented without inventory: %+v", status)
}
encoded, err := json.Marshal(status)
if err != nil {
t.Fatalf("marshal initial source failure: %v", err)
}
if got := string(encoded); !strings.Contains(got, `"sourcesStale":true`) {
t.Fatalf("initial source failure omitted stale state: %s", got)
}
}
// TestSourceCacheFailureFencesOlderOverlappingSuccess: a /sources read that
// FAILED must not be undone by an older, still-in-flight read that happens to
// succeed after it. Both go through CompleteFieldPoll(FieldSources, ...), so
// the failure's newer generation wins.
func TestSourceCacheFailureFencesOlderOverlappingSuccess(t *testing.T) {
conn := NewDeviceConnection(nil, nil)
readAt := time.Date(2026, time.August, 30, 12, 0, 0, 0, time.UTC)
retained := &models.Sources{SourceItem: []models.SourceItem{{Source: "AUX"}}}
firstGeneration := conn.BeginFieldPoll(FieldSources)
secondGeneration := conn.BeginFieldPoll(FieldSources)
conn.CompleteFieldPoll(FieldSources, secondGeneration, func(status *DeviceStatus) {
status.Sources = retained
status.SourcesReadAt = readAt
status.SourcesStale = true
})
older := &models.Sources{SourceItem: []models.SourceItem{{Source: "PRODUCT"}}}
if conn.CompleteFieldPoll(FieldSources, firstGeneration, func(status *DeviceStatus) {
status.Sources = older
status.SourcesStale = false
}) {
t.Fatal("older success was accepted after newer failure")
}
if status := conn.Status(); !status.SourcesStale || status.Sources != retained {
t.Fatalf("older success cleared newer failure: %+v", status)
}
newer := &models.Sources{SourceItem: []models.SourceItem{{Source: "BLUETOOTH"}}}
newerRead := time.Now()
conn.CompleteFieldPoll(FieldSources, conn.BeginFieldPoll(FieldSources), func(status *DeviceStatus) {
status.Sources = newer
status.SourcesReadAt = newerRead
status.SourcesStale = false
})
if status := conn.Status(); status.SourcesStale || status.Sources != newer {
t.Fatalf("newer source success did not restore actionability: %+v", status)
}
}
+96 -5
View File
@@ -111,6 +111,8 @@ type DeviceStatus struct {
Volume *models.Volume `json:"volume,omitempty"`
Presets *models.Presets `json:"presets,omitempty"`
Sources *models.Sources `json:"sources,omitempty"`
SourcesStale bool `json:"sourcesStale,omitempty"`
SourcesReadAt time.Time `json:"-"`
Bass *models.Bass `json:"bass,omitempty"`
Group *models.Group `json:"group,omitempty"`
Connectivity Connectivity `json:"connectivity"`
@@ -119,6 +121,18 @@ type DeviceStatus struct {
SpeakerConnectionState *SpeakerConnectionState `json:"speakerConnectionState,omitempty"`
IsConnected bool `json:"isConnected"`
LastActivity time.Time `json:"lastActivity"`
// Revision is a per-connection monotonic counter advanced by every
// successful UpdateStatus. It lets the browser order a full `devices`
// snapshot against a `status_update` delta -- without it the two frames
// carry no sequence at all and a slow snapshot can clobber a newer delta.
Revision uint64 `json:"revision"`
// NowPlayingRevision is the FieldNowPlaying generation that last wrote
// NowPlaying. Revision alone cannot answer "did now-playing actually
// change?", because an unrelated field's merge advances it too; a source
// selection waiting for authoritative confirmation needs exactly that
// distinction.
NowPlayingRevision uint64 `json:"nowPlayingRevision"`
}
// Connectivity is the player's aggregate view of HTTP and event-stream
@@ -138,6 +152,10 @@ const (
offlineGracePeriod = 60 * time.Second
)
// sourceCacheTTL bounds how long a successfully-read source inventory stays
// actionable without a fresh confirmation from the speaker.
const sourceCacheTTL = 30 * time.Second
// SpeakerConnectionState is the network state reported by the speaker.
type SpeakerConnectionState struct {
State string `json:"state"`
@@ -247,7 +265,20 @@ func (c *DeviceConnection) Info() *models.DeviceInfo {
// mutated. Use UpdateStatus or SetStatus to apply changes. Never returns
// nil for connections built via NewDeviceConnection.
func (c *DeviceConnection) Status() *DeviceStatus {
return c.status.Load()
return sourceCacheStatusAt(c.status.Load(), time.Now(), sourceCacheTTL)
}
func sourceCacheStatusAt(status *DeviceStatus, now time.Time, ttl time.Duration) *DeviceStatus {
stale := status.SourcesStale || status.Sources != nil && !status.SourcesReadAt.IsZero() &&
!now.Before(status.SourcesReadAt.Add(ttl))
if stale == status.SourcesStale {
return status
}
next := *status
next.SourcesStale = stale
return &next
}
// Done returns a channel that is closed when the connection is removed
@@ -334,8 +365,40 @@ func (c *DeviceConnection) FinishWebSocketLoop() {
// SetStatus atomically replaces the entire status. Use sparingly —
// UpdateStatus is the preferred entry point because it preserves
// concurrent changes from other goroutines.
//
// Revision is derived from the currently stored status rather than trusted
// from the caller: a replacement that reset it to the caller's zero value
// would make every browser holding a higher revision reject this device's
// subsequent updates outright. Replacing the whole status also supersedes
// every field, so each StatusField generation is advanced past any poll
// still in flight.
func (c *DeviceConnection) SetStatus(s *DeviceStatus) {
c.status.Store(s)
nowPlayingGeneration := c.supersedeAllFields()
for {
old := c.status.Load()
next := *s
next.Revision = old.Revision + 1
next.NowPlayingRevision = nowPlayingGeneration
if c.status.CompareAndSwap(old, &next) {
return
}
}
}
// supersedeAllFields advances every StatusField generation past whatever is
// currently in flight and returns FieldNowPlaying's new generation.
func (c *DeviceConnection) supersedeAllFields() uint64 {
c.fieldGenMu.Lock()
defer c.fieldGenMu.Unlock()
for field := range c.fieldGen {
c.fieldGen[field].issued++
c.fieldGen[field].applied = c.fieldGen[field].issued
}
return c.fieldGen[FieldNowPlaying].applied
}
// BeginFieldPoll reserves a generation for an asynchronous fetch of field,
@@ -367,7 +430,10 @@ func (c *DeviceConnection) CompleteFieldPoll(field StatusField, generation uint6
c.fieldGen[field].applied = generation
c.fieldGenMu.Unlock()
c.UpdateStatus(mut)
c.UpdateStatus(func(status *DeviceStatus) {
mut(status)
recordFieldRevision(status, field, generation)
})
return true
}
@@ -378,10 +444,26 @@ func (c *DeviceConnection) CompleteFieldPoll(field StatusField, generation uint6
func (c *DeviceConnection) ApplyFieldEvent(field StatusField, mut func(*DeviceStatus)) {
c.fieldGenMu.Lock()
c.fieldGen[field].issued++
c.fieldGen[field].applied = c.fieldGen[field].issued
generation := c.fieldGen[field].issued
c.fieldGen[field].applied = generation
c.fieldGenMu.Unlock()
c.UpdateStatus(mut)
c.UpdateStatus(func(status *DeviceStatus) {
mut(status)
recordFieldRevision(status, field, generation)
})
}
// recordFieldRevision publishes the generation that just wrote field, for the
// fields whose ordering a client needs to observe. Only FieldNowPlaying is
// published today: a source selection confirms itself by waiting for a
// now-playing write strictly newer than the one it started from, and the
// aggregate Revision cannot express that, because any other field's merge
// advances it too.
func recordFieldRevision(status *DeviceStatus, field StatusField, generation uint64) {
if field == FieldNowPlaying {
status.NowPlayingRevision = generation
}
}
// UpdateStatus atomically applies mut to a copy of the current status
@@ -397,11 +479,14 @@ func (c *DeviceConnection) ApplyFieldEvent(field StatusField, mut func(*DeviceSt
// reader still holding the previous snapshot). Production callers
// receive these values fresh from the device API, so this is the
// natural shape.
//
// Every successful store advances Revision exactly once.
func (c *DeviceConnection) UpdateStatus(mut func(*DeviceStatus)) {
for {
old := c.status.Load()
next := *old
mut(&next)
next.Revision = old.Revision + 1
if c.status.CompareAndSwap(old, &next) {
return
@@ -735,6 +820,12 @@ type BassRequest struct {
Level int `json:"level"`
}
// SourceRequest represents an exact source selection request.
type SourceRequest struct {
Source string `json:"source"`
Account string `json:"account"`
}
// WebSocketMessage represents messages sent over WebSocket
type WebSocketMessage struct {
Type string `json:"type"`