For every known device, probe http://<ip>:8090/info from the
service and emit findings for:
- Unreachable speakers — surfaces a copyable curl command the
operator can run from a host on the speaker's LAN.
- Speakers replying 200 but with empty <margeAccountUUID> —
the TPDA pairing-state failure mode documented in
discussion #223 ("Account ID = (empty)" in logread).
- Non-200 HTTP responses and malformed /info bodies, both as
warnings with the underlying detail in the finding.
Uses the ProbeGet helper from the previous commit; the dual-mode
fallback is the curl command emitted via ManualCommands when
server-side reach fails — appropriate when AfterTouch is hosted
off the speaker's LAN.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diagnostic checks coming next need to talk to speakers on the
LAN, which the service can't always reach — e.g. AfterTouch
hosted publicly while the operator's browser sits on the speaker
subnet. Establish the dual-mode primitive first so subsequent
checks can use it consistently:
- ProbeGet(ctx, url, timeout) issues a short-timeout GET and
always returns a CurlCommand the operator can run from a host
that can reach the target, regardless of whether the
server-side fetch succeeded.
- Finding gains an optional ManualCommands field; the admin UI
renders each as a labelled, copyable code block with a Copy
button and an optional hint line.
No new checks yet — that's the next commit. This one only adds
the primitive and the rendering path so each subsequent check is
a one-file diff.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A 600 ms two-chirp sound derived from the braille S+T pair that
makes up the AfterTouch logo. Used as the test-playback target so
operators can confirm a freshly migrated speaker actually emits
audio without depending on TuneIn or any external service.
Mapping: dot rows → pitches (A5/E5/A4), dot columns → stereo
channels. S (dots 2,3,4) renders first, then T (dots 2,3,4,5) —
audibly "S plus one more voice".
Generator under scripts/gen-aftertouch-ding regenerates the file
on demand:
go run ./scripts/gen-aftertouch-ding \
-o pkg/service/handlers/static/media/aftertouch-ding.wav
22050 Hz stereo 16-bit PCM, ~52 KB. Picked up by the existing
static/media/* embed in handlers_media.go, so it's served at
GET /media/aftertouch-ding.wav once handlers can play it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cloud-deploy operators on Discussion #295 needed to leave the
admin UI for docker logs / journalctl to see what the service was
doing. Mirror log.Default() output into an in-memory ring buffer
and expose it under /setup/logs so the admin UI can show a live
trace alongside the existing tabs.
The buffer is a second sink under log.SetOutput(io.MultiWriter(
os.Stderr, buf)) — stderr keeps receiving every line verbatim,
so docker logs / journalctl are unaffected. Default capacity
2000 lines (~400 KB), tunable via SOUNDTOUCH_LOG_BUFFER_LINES.
- pkg/service/logbuf: io.Writer ring with \n splitting,
partial-line buffering, monotonic Seq, Since(since, limit)
reporting dropped count when the caller falls behind.
- New /setup/logs (GET) returns {entries, nextSince, dropped,
capacity}. Polls at 1.5s while the tab is active; paused on
document.hidden.
- "8. Logs" tab with substring filter, tail-follow toggle
(auto-disables when the user scrolls up), monospace dark view.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Discussion #295 surfaced that a paired device without Sources.xml
silently breaks playback — /full omits TUNEIN and selection fails
with 1005. initializeDefaultSources only runs at startup over
existing devices, so a device that checks in later is never
seeded.
Add a Health tab to the admin UI that runs registered checks
against the datastore and offers one-click remediations. The
first check flags missing Sources.xml per device; its quick fix
writes the canonical defaults via SaveConfiguredSources. The
check/fix registry is designed so adding Presets.xml,
Recents.xml, or future reachability probes is a one-file diff.
- New /setup/health (GET) and /setup/health/fix (POST) routes
- pkg/service/health: Registry, Check, Finding, QuickFix types
- Sources.xml-present check + create_default_sources fix
- "7. Health" tab in pkg/service/handlers/web/
Inspired by issue #327's MAINTENANCE tab proposal; curl/URL
helper content from that issue can slot into the same tab in
a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The b95bdae split changed BmxPlayback.Href to raw `Tune.ashx?id=…` URLs,
which the speaker's BMX module fetches directly — failing `IsItBose`,
sending no auth, and getting 401 from radiotime. Restore the v0.85.0
shape (`/v1/playback/{station|episodes}/{id}`) so playback flows back
through HandleTuneInPlayback. Also restore play-link emission for Topic
search results (single podcast episodes); `Tune.ashx?id=t<N>` accepts
them like station IDs, so the same path works.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Sets up Dependabot for JS dependency updates
- Adds GitHub workflow for automated static dependency updates
- Creates update script for Preact and other static JS libraries
- Updates Preact to latest version via new automation
- Refactors BMX service to support multiple radio providers
- Adds RadioBrowser.com API integration with search and browse
- Splits TuneIn logic into separate module for better organization
- Adds new web UI components for radio station discovery
- Includes new SVG icons for RadioBrowser branding
ConnectDeviceWebSocket was a one-shot: connect, wait for disconnect,
log, return. Once the device-side WebSocket died (idle timeout, blip,
speaker reboot), the goroutine ended and conn.WebSocket stayed
pointing at the (now-dead) client — which made the duplicate-spawn
guard `if device.WebSocket == nil` at the five callsites in
handler.go correctly skip spawning, but with nothing else trying to
reconnect, the speaker's status flow froze for the rest of the
process's lifetime. The browser kept receiving status_update
messages on the 5 s ticker (HandleWebSocket), but every payload
carried the same stale data the service last knew.
Symptom: load the page, NowPlaying shows fresh state; some minutes
later, the speaker switches presets or tracks but NowPlaying never
updates — even though playback itself works because those are
one-shot HTTP calls that don't depend on the WebSocket.
Fix: wrap the connect-and-wait in a for-loop with exponential
backoff (1 s → 30 s cap, reset on every successful connect). The
goroutine now lives for the device entry's lifetime; conn.WebSocket
is updated on each successful reconnect and never cleared, so the
existing guards keep working without spawning duplicate loops.
Pre-existing main bug — preserved by the relocation, surfaced when
testing the rebased branch. Fix is contained to the one function;
behaviour is byte-identical for the happy path (one connect, no
disconnect ever).
go build ./... clean. go test ./... clean (only pre-existing
TestDocsConsistency fails). golangci-lint run ./... 0 issues.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stations still showed a dim ▶ inside the .tunein-item-arrow span
while programs (with the new pill button from 34d4692) showed a
circled play button. Two different play affordances side by side
looked accidental.
Now every item with a playback link renders the same pill button,
and the arrow span carries only the drill-in chevron. Per item type:
Stations (play only) pill ▶
Programs (navigate + play) pill ▶ + chevron ›
Genres (navigate only) chevron ›
The pill stops event propagation, so clicking it triggers play
without bubbling to the row's navigate handler — that lets row
clicks keep drilling into programs while the button cuts straight
to "play latest episode."
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Preact TuneInBrowser hid the play affordance whenever an item
also had a navigate link. TuneIn programs have BOTH (drill into
episodes + play latest episode, after backend PR #317), so the
button never appeared on program rows — only the chevron.
Old vanilla UI showed both. Restored:
- navigate(item) keeps its current behaviour (path wins for row
clicks, falls through to play if there's no path) — that lets
pure-leaf items (stations) still play on whole-row click.
- New explicit .tunein-play-btn rendered conditionally when an item
has BOTH a navigate link and a playback link. Stops event
propagation so clicking it triggers play (device picker overlay)
instead of bubbling to the row's navigate handler.
- CSS: pill-shaped 32px button using the same --accent / --text-dim
tokens the rest of the UI uses; hover state swaps to --accent /
--accent-fg to avoid same-on-same contrast in either theme.
The chevron stays as the row's "drill in" indicator for any
navigable item, including programs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ports app's commit b040c8a. Five new handlers + five new routes for
master/slave stereo-pair and multi-room management; the Zone.js
frontend was already shipped in the Preact swap.
HandleGetZone GET /api/zone/{id}
Returns zone info enriched with member names and role flags
(isMaster / isSlave / isStandalone) computed from the perspective
of the queried device. Each member carries IP, hwID, and friendly
name so the frontend can render readable rows.
HandleZoneAdd POST /api/zone/{id}/add/{slaveId}
Adds a slave to the zone where {id} is or becomes the master.
Standalone master gets a fresh ZoneRequest; existing zone is
extended via ToZoneRequest + AddMember.
HandleZoneRemove POST /api/zone/{id}/remove/{slaveId}
Removes a named slave from the master's existing zone.
HandleZoneDissolve POST /api/zone/{id}/dissolve
Issues a single-member ZoneRequest so the master goes standalone.
HandleZoneLeave POST /api/zone/{id}/leave
Slave-side leave: looks up the master via findIPByHwID using the
slave's current zone info, then dispatches RemoveMember against
the master's client (the speaker protocol requires the master to
own the SetZone call).
Translation notes:
- All handlers go through app.GetDevice(id) instead of direct
app.Devices[id] access — matches main's encapsulated-registry
refactor (post-base on main, see registry_test.go).
- findIPByHwID iterates via app.DeviceSnapshot() instead of ranging
over the raw map.
- pkg/client (GetZone/SetZone) and pkg/models (ZoneInfo/ZoneRequest/
Member/NewZoneRequest/AddMember/RemoveMember/IsStandalone/
ToZoneRequest) API surface confirmed unchanged from app's base —
verbatim function calls.
Risk recap (per the earlier audit): this was flagged medium-risk
because of pkg/client zone-API drift. Verified clean — all symbols
exist with the expected signatures on current main. The #252 stereo-
pair work that landed on main was in cmd/soundtouch-cli/cmd_group.go
(parallel POST to LEFT and RIGHT), which doesn't intersect with the
single-master SetZone pattern these handlers use.
go build ./... clean. go test ./... clean (only pre-existing
TestDocsConsistency fails, untracked-file issue, unrelated).
golangci-lint run ./pkg/service/soundtouchweb/... ./cmd/soundtouch-web/...
0 issues.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ports app's commit 3122c4e to the package layout. Two new handlers
and route registrations; the frontend was already shipped in the
Preact swap.
HandleDeviceRecents GET /api/device-recents/{id}
Returns the speaker's /recents list as APIResponse{Success,Data}.
Backs the Recents.js component (lazy-loaded list under the
device-detail view; hides itself when the device returns no
recents).
HandleDevicePlay POST /api/device-play/{id}
Generic content-item player. Decodes a {source,type,location,
sourceAccount,itemName,containerArt,isPresetable} JSON body into
a *models.ContentItem and runs Client.SelectContentItem. Used by
Recents.js to replay items the speaker reports, regardless of
source — TuneIn, Spotify, AUX, etc. Different from HandlePlayTuneIn
which is TuneIn-specific.
Translation note: app's bodies used app.Devices[id] directly; main's
registry is encapsulated behind GetDevice/AddDevice/TouchDevice (see
the post-base refactor that introduced registry_test.go), so this
commit uses app.GetDevice(id) instead. Same lookup, just through the
maintained API.
go build ./... clean. go test ./... clean (only pre-existing
TestDocsConsistency fails, untracked-file issue, unrelated).
golangci-lint run ./pkg/service/soundtouchweb/... 0 issues.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replays app's commit-1 architectural restructure onto current main —
mechanical move only, behaviour preserved verbatim. main.go shrinks
from 333 to ~190 lines; the binary now orchestrates lifecycle and
flag handling, the package owns the WebApp's responsibilities.
Moves (no logic change vs the previous main.go bodies):
main.go addDevice → (*WebApp).AddDeviceByHost in discovery.go
main.go discoverDevices → (*WebApp).DiscoverDevices in discovery.go
main.go setupRoutes → (*WebApp).Mount(r, ds) in mount.go
inline serveIndex closure → (*WebApp).serveIndex in mount.go
New helper:
soundtouchweb.NewDiscoveryService(interfaceName) wraps
config.LoadFromEnv + cfg adjustments + NewUnifiedDiscoveryService.
Single source of truth for the web UI's discovery settings;
identical to the inline wiring main.go used to do.
main.go still owns (kept verbatim, post-base on main):
- --port / --bind / --interface / --devices flags
- resolveBindAddr (NIC-name → IP resolution for --bind)
- defaultDiscoveryInterface (--bind ↔ --interface defaulting)
- Startup goroutine sequence: broadcast start → preseed loop
(AddDeviceByHost for each --devices entry) → DiscoverDevices →
broadcast complete + device list
- http.ListenAndServe
Behaviour parity checklist:
- Routes registered: identical set (see Mount). /api/discover still
reuses the startup discoveryService instance, same as before.
- Preseeded --devices still added BEFORE the mDNS/UPnP sweep, so the
UI doesn't briefly show empty for hosts that come from --devices.
- Discovery interface still pinned via --interface (or inherited from
--bind), threaded through NewDiscoveryService.
- Static FS still served at /static/*, SPA fallback at / /devices
/device/* still hits the same index.html.
go build ./... clean. go test ./... clean (only pre-existing
TestDocsConsistency fails, untracked-file issue, unrelated).
golangci-lint run ./... 0 issues.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings forward the frontend rewrite from the `app` branch
(6723515 + later refinements) onto the relocated package layout.
The Go side untouched — main.go's orchestration, discovery, routes,
and handlers all remain. Only the static-asset layer changes.
Frontend (lives in pkg/service/soundtouchweb/static/):
- index.html (importmap-driven, ES modules, no build step)
- css/app.css (CSS-custom-property design system, dark by default)
- js/api.js (typed-ish fetch wrappers)
- js/app.js (Preact App shell: routing, toast, websocket reconnect)
- js/components/{DeviceList,NowPlaying,Controls,Presets,Sources,
Recents,Zone,TuneInBrowser}.js
- img/favicon.{ico,svg}
- lib/{preact,preact-hooks,htm}.module.js (vendored ES modules)
Backend wiring:
- New pkg/service/soundtouchweb/embed.go exports `StaticFS embed.FS`
via `//go:embed static`. main.go drops its own `//go:embed` and
consumes `soundtouchweb.StaticFS` instead, so the static tree
lives alongside the handlers it serves.
- cmd/soundtouch-web/static/{index.html,css/app.css,js/app.js} are
deleted; the old `cmd/soundtouch-web/static/` directory is empty
now and removed entirely.
Path rename vs. app branch:
- app's importmap pointed at `/static/vendor/preact*.js` and the
vendor files were never committed because `.gitignore:44 vendor/`
silently masked them. Renamed to `/static/lib/` to escape the
global rule and `git add`-ed the three modules.
Known regressions vs. main's vanilla UI (acceptable for this commit;
flag in review or follow-up if any matter):
- Per-card power toggle on the device list — Preact only exposes
power inside the device-detail view, not on the list card.
- WebSocket reconnect uses `location.reload()` after 5s; main had
exponential backoff. Functional, simpler, less elegant.
- Theme icon control absent (Preact UI is dark-only via CSS vars;
no light-mode toggle).
Features carried over and confirmed at the route-shape level:
device list / device detail / nowPlaying / volume+key+power controls
/ presets / sources / TuneIn search + browse + play / discovery /
toasts / WebSocket status updates.
go build ./... clean. go test ./... clean (only pre-existing
TestDocsConsistency fails). golangci-lint run ./... 0 issues.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mechanical relocation only — zero semantic change. Sets up the package
layout that the future Preact-UI rewrite (branch `app`) wants, while
preserving every line of main's current logic. Subsequent commits will
land the additive parts (frontend rewrite, recents, zones, bass control)
on top of this clean base.
Moves (`git mv`, content unchanged except package decl):
cmd/soundtouch-web/handlers/handlers.go → pkg/service/soundtouchweb/handler.go
cmd/soundtouch-web/handlers/handlers_test.go → pkg/service/soundtouchweb/handler_test.go
cmd/soundtouch-web/handlers/websocket.go → pkg/service/soundtouchweb/websocket.go
cmd/soundtouch-web/handlers/registry_test.go → pkg/service/soundtouchweb/registry_test.go
cmd/soundtouch-web/webtypes/types.go → pkg/service/soundtouchweb/webtypes/types.go
cmd/soundtouch-web/webtypes/types_test.go → pkg/service/soundtouchweb/webtypes/types_test.go
cmd/soundtouch-web/webtypes/status_test.go → pkg/service/soundtouchweb/webtypes/status_test.go
cmd/soundtouch-web/static/img/tunein-{dark,mono}.svg → pkg/service/soundtouchweb/static/img/
Adjustments:
- `package handlers` → `package soundtouchweb` in the 4 moved handler-tier
files (plus their package-doc comments).
- Import paths rewritten in cmd/soundtouch-web/{main.go,spa_test.go} and
in the moved files themselves: cmd/soundtouch-web/{handlers,webtypes}
→ pkg/service/soundtouchweb/{,webtypes}.
- `handlers.` selector renamed to `soundtouchweb.` in the callers.
- `.golangci.yml` errcheck waiver extended from `cmd/.*\.go` to also
cover `pkg/service/soundtouchweb/.*\.go`. Same code that the
cmd-tier waiver applied to; same waiver follows it. Documented as
a carry-over with the intent to tighten in a follow-up review.
Not changed:
- `cmd/soundtouch-web/main.go` keeps the `//go:embed static` pointing at
the still-vanilla `cmd/soundtouch-web/static/`. The frontend rewrite
(Preact UI) lands in a later commit; this one is mechanical.
- `cmd/soundtouch-web/resolve_bind_addr_test.go` stays put — it tests
main.go-local flag plumbing.
go build ./... clean. go test ./... clean (only pre-existing
TestDocsConsistency fails, untracked-file issue, unrelated).
golangci-lint run ./... 0 issues.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
skip history.pushState when the hash already matches, so popstate -> selectMigrationDevice -> showSummary no longer pushes a duplicate entry that traps browser-Back in an oscillation between identical `#tab-migration?<id>` entries.
Three files carried 192.168.123.x as placeholder IPs in examples and
fixtures. RFC-1918 private space — same reader-confusion concern as
the broader 192.168.1.* sweep in 136d24a. Switched to 192.0.2.x
preserving the last octet so the reader-side intent ("CLI host arg
example", "test fixture URL") stays clear.
- docs/analysis/FACTORY-RESET-PROTOCOL.md — 14 CLI --host examples + 1 log-fragment
- docs/analysis/TELNET-COMMAND-REFERENCE.md — 1 docker-run env example
- pkg/service/marge/recents_sourceproviderid_regression_test.go
— 2 XML location URLs (matched-pair within file)
docs/analysis/BOSE-LAB-RUNBOOK.md keeps its 192.168.10/24 subnet
unchanged — that's the documented Pi-as-AP network for the runbook,
not a placeholder.
go test ./pkg/service/marge/... clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes the docs-tier RFC-5737 rollout by sweeping the remaining
192.168.1.x references that lived outside .md / .txt / test files:
- .env.example — active PREFERRED_DEVICES default + examples
- .github/ISSUE_TEMPLATE/*.yml + workflows — issue template + CI examples
- cmd/websocket-demo/main.go, doc.go — top-level docs
- examples/*/main.go (7 files) — example program comments
- pkg/client/client.go — godoc examples
- pkg/models/doc.go — package godoc
- pkg/service/{amazon,spotify,zeroconf}/zeroconf.go — godoc comments
- pkg/service/handlers/web/index.html — placeholder text in the UI
- scripts/prepare-release.sh — example invocations
- scripts/spotify/spotify-prime-speaker.sh — usage comment
- tests/integration/http-client/http-client.env.json — fixture IPs
Same mapping as the docs commit (136d24a): 192.168.1.X → 192.0.2.X
preserving the last octet.
One semantic carve-out: the three zeroconf `zcBaseURL` godoc comments
in pkg/service/{amazon,spotify,zeroconf}/zeroconf.go switched to
192.168.10.10 instead of the doc range, because validateZcBaseURL
only accepts RFC-1918 / loopback / link-local. The comment must show
a value the validator actually accepts — see the matching test fix
in 92f66a2 for the same reason.
go build ./... clean. go test ./... clean except the pre-existing
TestDocsConsistency (untracked DEVICE-LOCAL-INSTALL.md, unrelated).
golangci-lint run ./... — 0 issues after a gofmt fix on
examples/zone-slave-operations/main.go.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the .md/.txt sweep across all tracked _test.go, testdata XML,
and .http integration files. Test files are self-contained (producer
+ assertion in the same file), so the matched-pair swap stays green
under `go test ./...`.
Mapping applied:
192.168.178.[0-9]+ → 192.0.2.[same]
192.168.1.[0-9]+ → 192.0.2.[same]
Sound Machinechen → Living Room SoundTouch
A Sound Machine → Kitchen SoundTouch
A81B6A536A98 + case/separator variants → AABBCCDDEEFF (etc.)
A81B6A849D99 → AABBCCDDEE01
A81B6A849D88 → AABBCCDDEE03
A81B6A536A09 → AABBCCDDEE04
884AEAEEBD27 → AABBCCDDEE02
3230304 → 1000001
9569497 → 1000002
Two semantic fixes alongside the bulk swap:
- pkg/service/zeroconf/zeroconf_test.go: the "private 192" and
"strips query" cases pin acceptance of RFC-1918 192.168/16. They
must use a real 192.168 value; doc-range IPs would (correctly) be
rejected by validateZcBaseURL. Switched to 192.168.10.10 — generic
enough not to match any home LAN default, real enough for the
validator. Added a comment explaining why this single test still
carries a 192.168 literal.
- pkg/service/setup/setup_test.go: TestTestDNSRedirection mocks the
device's `od -An -tu1` byte output, which is space-separated
octets ("192 168 1 100"). My sed only matched the dot-separated
form, so the mock was returning the old IP while the test
assertions had moved to the doc range. Updated to " 192 0 2 100".
go build ./... clean. go test ./... clean (only TestDocsConsistency
remains failing, which is a pre-existing/untracked-file issue).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After the proxy/mirror removal there is no proxy left in the service,
but the parallel partial-update endpoint /setup/proxy-settings stuck
around with its legacy name. It serves a legitimate purpose distinct
from the bulk /setup/settings POST: the three checkboxes
(Redact / Log Bodies / Record) use onchange-triggered live save,
while /setup/settings drives a Save-button form for dozens of fields.
Folding the two endpoints together would either lose the live-toggle
UX or send half-edited draft form data on every toggle, so the
partial-update endpoint earns its keep — it just needed the right
name.
Renamed symbols (no behaviour change):
Go handler funcs:
HandleGetProxySettings → HandleGetLoggingSettings
HandleUpdateProxySettings → HandleUpdateLoggingSettings
GetProxySettings → GetLoggingSettings
Route:
/setup/proxy-settings → /setup/logging-settings
JS:
fetchProxySettings() → fetchLoggingSettings()
updateProxySettings() → updateLoggingSettings()
HTML element IDs (cosmetic, kept consistent):
proxy-redact / proxy-log-body / proxy-record
→ logging-redact / logging-log-body / logging-record
HTML heading:
"Proxy Logging:" → "Logging:"
JSON payload shapes (request + response keys) are UNCHANGED: the
endpoint still emits / accepts {"redact", "log_body", "record"}.
Persisted Settings on disk are UNCHANGED. CLI flags are UNCHANGED.
Server struct fields redactLogs / logBodies / recordEnabled
(renamed earlier this session) are UNCHANGED.
testdata/router_routes.txt regenerated. go build clean. go test
./... clean except pre-existing TestDocsConsistency (untracked-file
issue, unrelated). golangci-lint 0 issues.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After the proxy/mirror removal, two internal Server fields kept their
historical "proxy" prefix even though no proxy code exists anymore:
- s.proxyRedact still controls recorder.Redact for sensitive-header
scrubbing (server.go:393)
- s.proxyLogBody still controls the [UNHANDLED] body preview in the
catch-all (handlers_catchall.go:14)
Both names misled — they read as proxy-related. Renamed to match the
public-facing names that have been used all along: the CLI flags are
--redact-logs / --log-bodies, the persisted Settings fields are
RedactLogs / LogBodies, and the JSON keys are redact_logs / log_bodies.
proxyRedact → redactLogs
proxyLogBody → logBodies
Also renamed the file that now contains only HandleNotFound:
pkg/service/handlers/handlers_proxy.go → handlers_catchall.go
pkg/service/handlers/handlers_proxy_test.go → handlers_catchall_test.go
git mv preserves history. NewServer's positional parameter list is
unchanged at the call site (cmd/soundtouch-service/main.go:391).
go build ./... clean. go test ./... clean except the pre-existing
TestDocsConsistency (unrelated). golangci-lint run ./... 0 issues.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The jsdiff library at pkg/service/handlers/web/js/diff.min.js (29 KB)
was loaded by the management UI to render rich diffs on the parity-
mismatch detail view. The previous two commits removed both the tab
and the JS consumer; the asset, its <script> tag, and the served-
asset test stanza were left behind.
Removes:
- pkg/service/handlers/web/js/diff.min.js (the asset itself)
- web/index.html: <script src="/web/js/diff.min.js"></script>
- handlers_media_test.go: the // 3. Test diff.min.js stanza in
TestStaticWeb, and renumbers the trailing "// 4. Test Favicon"
comment to "// 3."
No remaining Diff./jsdiff/diffChars/diffLines references in any
tracked JS or HTML. go build + TestStaticMedia + TestStaticWeb stay
green. The //go:embed pattern in handlers_media.go is web/js/*
(wildcard), so the embed bundle regenerates without the asset on
the next build with no directive edit needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extract a buildVersionInfo helper from HandleHealth so both endpoints
emit identical version + VCS metadata. JSON callers hitting / now get
the same release context they get from /health; under go run/test
where debug.ReadBuildInfo lacks VCS settings, version falls back to
"0.0.1" and the vcs_* keys are omitted (instead of empty strings).
The HTML branch of / is unchanged — the embedded index.html keeps its
own version-display story.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a `soundtouch-cli source tunein` subcommand that takes a TuneIn
guide ID and routes it through the right SelectContentItem shape —
`--station`, `--episode`, `--program`, or `--id` with prefix
auto-detect. The flag picks the ContentItem Type (`stationurl` for
stations/episodes, `tracklisturl` for programs) and the location
template, then enriches the now-playing metadata from TuneIn's describe
endpoint unless `--no-lookup` is set.
Program IDs (`p<N>`) are containers, not streams. The legacy OPML
`Tune.ashx?id=p<N>` returns `#STATUS: 400`, which pre-filter went out
to the speaker verbatim. Fix in three layers:
1. `parseTuneInStreamBody` filters `#`-prefixed comment lines out of
Tune.ashx responses and errors when nothing playable remains, so
a broken TuneIn reply surfaces as a real 500 instead of corrupting
the playback response.
2. `TuneInPlaybackPodcast` expands `p<N>` to its newest episode via
`api.radiotime.com/profiles/{id}/contents` (same JSON shape as
api.tunein.com; uses the radiotime mirror so all program traffic
stays on the host already in `allowedTuneInHosts`).
3. `tuneInSearchProfile` (Program search items) and
`TuneInNavigateProfile` (program detail hero) now emit
`BmxPlayback` links, so soundtouch-web renders play buttons on
program cards and on the profile hero — clicking either plays the
latest episode via the same backend expansion.
Tests pin the parser contracts (`#STATUS: 400` filter, program-contents
episode pick) and the navigate Program-only playback emission. CLI
resolver has table-driven coverage for kind selection, prefix
auto-detect, and conflicting-flag errors.
Endpoint contract + raw probe responses captured under
`_/i226/tunein-api-findings.md` and `_/i226/tunein-probe/` for future
reference.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two of the eight CodeQL alerts on PR #313 had clean, low-cost fixes:
- go/clear-text-logging (#141, #142): the SiriusXM stub logged the
raw Authorization header value at INFO. The header carries a
long-lived bearer token (margeAuthToken) — capturing service logs
would yield replayable credentials. Switch to logging only the
boolean presence (`authPresent=%t`).
- go/bad-redirect-check (#138): the Stockholm handler's bare-path
redirect uses cfg.BasePath verbatim. basePath is operator-provided
(CLI flag / STOCKHOLM_BASE_PATH env), not request input — but a
value like "//evil.com" would still produce a scheme-relative
redirect to an external host. Reject any leading-double-slash or
embedded backslash at construction time so the redirect target
can only ever be an absolute local path.
The remaining CodeQL alerts are out of scope here:
- go/request-forgery on proxy.go (#139, #140): the /api/http-proxy
endpoint takes a user-provided url= parameter and fetches it by
design — that's the whole point of the proxy. Mitigations
already in place: isProxyLoop rejects self-references; the proxy
is only reachable under a LAN trust model.
- go/path-injection on static.go (#143, #144, #145): the
path-traversal guard in resolveStaticFile (string-prefix check
on absolute paths) is sound, but CodeQL doesn't trace it across
the function boundary. A clearer refactor to filepath.Rel might
silence the alert; deferred.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bmx_services.json advertises SIRIUSXM_EVEREST at
`{BMX_SERVER}/core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter`
and bmx_services_availability.json lists it as available, so speakers
that try SiriusXM hit that path. Without a route we 404'd silently
and the call was invisible in our logs.
- HandleSiriusXMLiveAdapter at the bare base URL returns the
SIRIUSXM_EVEREST service descriptor (selected by id.name from
bmx_services.json, with {BMX_SERVER}/{MEDIA_SERVER} substitution).
Mirrors deborahgu/soundcork main.py:805 in shape.
- HandleSiriusXMLiveAdapterSubpath catches every sub-path advertised
by the descriptor's _links (/availability, /token, /navigate,
/logout) plus the playback URLs the speaker discovers via navigate.
Logs the request with method+path+UA+Authorization+RawQuery, then
404s — giving the next implementation pass concrete data about
what the speaker actually asks for.
Two helpers added to handlers_bmx.go (shared with any future
BMX-segment stub):
- extractBMXService(json, name) — find a service entry by id.name.
- (*Server).applyBMXTemplate(content) — {BMX_SERVER}/{MEDIA_SERVER}
substitution, identical to what HandleBMXRegistry does inline.
Routes registered next to Orion at the top level — same convention
(no /bmx/ prefix) because bmx_services.json advertises baseUrl without
that prefix and speakers reach the path verbatim under either
migration mode.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Seven BMX adapter handlers required a non-empty `Authorization` header
and returned 401 from writeBMXUnauthorized when missing:
TuneIn: Playback, PodcastInfo, PlaybackPodcast, Report, Navigate, Search
Orion: Playback
Speakers calling these endpoints directly carry their margeAuthToken in
the header, so the gate works for them. But the Stockholm browser
proxy (pkg/service/stockholm/proxy.go injectBackendHeaders) only injects
Authorization for hosts ending in .bose.com or .apigee.net with a marge
path — when Stockholm calls back into our own service for TuneIn
browsing/playback/search/etc., no header is added and every request
401s.
Disable the gate at all seven sites; log the missing-header case so the
absence remains visible. Keep writeBMXUnauthorized as the future-restore
point (//nolint:unused) — when the gate comes back (e.g. behind a
BMX_STRICT_AUTH env-var or once the Stockholm proxy learns to inject
Authorization for our own host), callers will use this helper again.
Tests that assert 401 for missing Authorization (TestBMXUnauthorized,
TestHandleTuneInReport/Unauthorized, TestHandleTuneInNavigate/Unauthorized,
TestHandleTuneInSearch/Unauthorized) are `t.Skip`'d with a pointer back
to handlers_bmx_tunein.go — they stay in the file to come back to life
the day the gate does.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
handlers_bmx.go had grown to ~426 lines covering registry + availability +
shared helpers + TuneIn (9 handlers) + Orion (2 handlers) + our own
custom-playback adapter. The test files were already split per service
(handlers_bmx_test.go, handlers_bmx_tunein_test.go,
handlers_bmx_report_test.go) — the production code now matches that
shape.
Pure move, no logic change:
- handlers_bmx.go → BMX registry + availability + shared
helpers (writeBMXUnauthorized,
bmxServicesJSON file-level vars)
- handlers_bmx_tunein.go → all TuneIn handlers (Playback,
PodcastInfo, PlaybackPodcast, Token,
Report, Navigate, Search, Favorite,
DeleteFavorite) plus tuneInStreamFormats
helper and parseTuneInNavigatePath
- handlers_bmx_orion.go → Orion (LOCAL_INTERNET_RADIO) Token +
Playback
- handlers_bmx_custom.go → our own /custom/v1/playback adapter
(not a Bose-official BMX service —
kept distinct from Orion for clarity)
Imports are tightened per file. No public API change; tests pass the
same as before this commit.
A future iteration may extract a common BMX-service interface once 3-4
services are fully implemented. Until then, file-per-service is the
shape — see memory project_bmx_service_interface.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Stockholm "kilo" constant (a7928d7b43dcd49f0af31e5aeed26458) was
duplicated as a string literal in bridge.go and state.go. To a future
reader the hex blob can read like a leaked secret, which it is not —
it's a published default carried over from the upstream
krahl/soundcork-stockholm-app project (BackendApplication.java). The
Stockholm JS expects exactly this value via getConstant("kilo") when
nothing else has stored a different one.
Promote to a named const in util.go with the explanation, and reference
it from both call sites. Tests keep the literal so they continue to
catch any accidental change to the wire value.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements pkg/service/stockholm with bridge (appSend/runQueue), HTTP
proxy, static serving, config URL rewriting, native state persistence,
and device discovery. Mounts under a configurable base path (/stockholm
by default) with correct http.StripPrefix routing and apiBase-prefixed
bridge API routes matching the patched JS window.__stockholmBase calls.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The ST10's /presets response after a factory reset emits self-closing
<preset/> entries with no ContentItem child. cmd/soundtouch-cli's
getPresets() handled the missing ContentItem in GetDisplayName() but
then dereferenced preset.ContentItem.Source on the next line, panicking
with "invalid memory address or nil pointer dereference" the moment the
loop reached the first empty entry.
A second placeholder shape was observed on healthy devices that were
never reset: <preset id="0"><ContentItem source="INVALID_SOURCE"
isPresetable="true"/></preset>. ContentItem is non-nil here, so the
previous "ContentItem != nil" guard at other call sites still let
these placeholders through into listings and into the AfterTouch
datastore.
Fix shape:
pkg/models/presets.go - extend Preset.IsEmpty() to recognise both
shapes (ContentItem == nil, OR Source == "" / "INVALID_SOURCE").
HasPresets, GetEmptyPresetSlots and GetUsedPresetSlots become honest
about which slots actually carry playable content.
cmd/soundtouch-cli/cmd_info.go (the crash site) - filter the slice
via IsEmpty before the print loop, and switch the still-printed
fields to the existing nil-safe Get* helpers.
pkg/service/setup/setup.go - upgrade syncPresets's "ContentItem ==
nil" continue-guard to IsEmpty so Shape B placeholders don't get
persisted in the AfterTouch datastore and then surface as junk
rows in the admin web UI.
cmd/soundtouch-cli/cmd_events.go, cmd/websocket-demo/main.go - same
nil-guard upgrade. These already nil-checked so were crash-safe;
the change is for consistency and to stop printing
"Preset 0: (INVALID_SOURCE)" demo lines.
examples/preset-management/main.go - had the same latent crash as
cmd_info.go; same fix shape.
Regression tests in pkg/models/presets_test.go cover both shapes using
the exact XML observed in the wild: the reporter's three <preset/>
placeholders plus the three INVALID_SOURCE entries from a live device.
The reporter XML test walks every preset through the same accessor
path the CLI used and asserts no panic.
The soundtouch-web Go code does not deref preset.ContentItem.X
anywhere - presets flow through as JSON - so no separate crash trap
exists there. The web frontend will pick up the cleaner data once
syncPresets stops persisting placeholders.
Closes#308
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
storePreset on the speaker was failing with "AddPreset - failed due to
invalid SourceID" because the watchdog priming path only pushed ZeroConf
credentials and never registered a SPOTIFY ConfiguredSource in marge.
PrimeDeviceWithSpotify now:
- resolves the device's paired account via live :8090/info
(margeAccountUUID), falling back to ServiceDeviceInfo.AccountID — same
order as setup.populateDeviceInfo;
- writes a SPOTIFY ConfiguredSource under that account (providerID=15,
BoseSecret as credential), mirroring bridgeSpotifyToMarge;
- POSTs `<updates><sourcesUpdated/></updates>` so the speaker re-fetches
its on-device Sources.xml from marge.
Also introduce zeroconf.ErrAddUserNoOp for the narrow firmware quirk
(404 + empty body on ?action=addUser when activeUser already matches).
Recognised only on that exact pattern; real 4xx/5xx still surface loudly
with full response details. Same treatment applied to Amazon priming.
Docs:
- new docs/concepts/spotify-overview.md anchors the topic (mental model,
streamingoauth.bose.com DNS gotcha, token lifecycle, clientId notes,
troubleshooting table);
- spotify-oauth.md drops the removed install-primer endpoint and the
on-device boot-primer install sections, adds /mgmt/spotify/prime;
- spotify-priming-strategy.md and MUSIC-SERVICES.md link to the
overview.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two doc-only additions to TestValidateRealSpeakerBundle's header
comment:
- Cross-model note: ST10 and ST20 ship the byte-identical CA
bundle on firmware 27.0.6.46330.5043500 (md5
2d150987b312e4280fc576b508e62b43, 165 certs, ~251 KB).
Verified against firmware/_backup_ST10/_/etc/pki/tls/certs/
ca-bundle.crt 2026-05-16. The existing
testdata/ca_bundle_st20_pristine.crt fixture therefore stands
in for both models on that firmware build, so any expired-root
hypothesis evaluated against it covers both.
- Curl reproducer: three one-liners that point curl at the fixture
and probe the actual TuneIn stream chain a SoundTouch speaker
would walk (using K-LOVE / s33828 as the canonical example —
matches the case from #292). Control with the system trust
store shown alongside. Both bundles handle the chain (Amazon
Root CA 1 + DigiCert Global Root, valid through 2026+) so the
expired-root hypothesis is ruled out for firmware 27 — recorded
in the comment so future-me / reviewers can replay the same
probe without re-deriving it from chat context.
No code change; test still passes.
Related to https://github.com/gesellix/Bose-SoundTouch/issues/292.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR #249 added "hls" unconditionally to TuneIn's Tune.ashx formats=
query. That regressed playback on the SoundTouch line: TuneIn returns
an .m3u8 HLS playlist for stations like K-LOVE (s33828), the speaker
can't parse it, blinks amber and falls silent. Verified that
firmware 27 on ST10 and ST20 ships the byte-identical Mozilla CCADB
bundle and validates the actual stream chain cleanly, so it isn't a
cert-expiry issue (#292's hypothesis) — the speaker simply has no
HLS support.
Changes:
- TuneInStream is now a builder, not a const: takes the station ID
plus a formats string (empty falls back to the new exported
DefaultTuneInStreamFormats = "mp3,aac,ogg" — matches the pre-#249
request shape).
- TuneInPlayback and TuneInPlaybackPodcast take the formats string.
- New Settings.TuneInStreamFormats string. Empty by default.
Operators with HLS-capable speakers can set it to
"mp3,aac,ogg,hls" — or any other comma-separated list — via
settings.json. The value is passed through verbatim; AfterTouch
does not validate the individual format tokens, so this is also
the right knob for trialling additional formats without code
changes.
- Two regression tests pin both the empty-uses-default contract
and the override-passes-through contract (with the whitespace-
trim sub-case) so PR #249-style regressions surface at
compile/test time.
The setting is settings.json-only (matches the existing pattern for
AllowInsecureUpstreamTLS / TrustForwardedHeaders / TrustedProxyCIDRs
which are also edit-the-file settings). UI surface can be a small
follow-up if reporters ask for it.
Example settings.json snippet to re-enable HLS (only if your
speaker can actually play it):
{
"server_url": "http://aftertouch.local:8000",
"tunein_stream_formats": "mp3,aac,ogg,hls"
}
Restart soundtouch-service after editing.
Related to https://github.com/gesellix/Bose-SoundTouch/issues/292.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The migration-summary preflight always emitted a "resolved from service,
not from device" ❌ row whenever the target was a hostname — even when
SSH was available and could have answered authoritatively. Two
problems compounded: the summary builder passed `nil` for the SSH
client (skipping the device-side ping), and resolveIP's service-side
fallback returned a bare fmt.Errorf the caller couldn't distinguish
from a real failure.
Changes:
- ErrResolvedFromServiceOnly sentinel; service-side fallback wraps
it with fmt.Errorf("%w: ...") so callers can errors.Is()-check.
Apply-path callers that pass a real SSH client keep getting the
same error shape they always did.
- populatePlannedNetworkConfig now takes an SSHClient. GetMigrationSummary
opens one when probe.SSHOK is true and passes it through, so the
summary's resolve call uses the same device-side authority the
apply paths use. Skipping the dial when SSH is known dead keeps
a stale handshake-timeout from burning the preflight budget.
- MigrationSummary gains ResolveIPSource ("device" / "service") and
ResolveIPDurationMS so we can observe the SSH-ping cost in the
wild. The historical comment claimed 2-5 s on firmware-27 devices —
we now have data instead of a guess.
- CLI renderer prints the new source + timing line, and only renders
the ❌ ResolveIPError row for hard failures (both SSH ping AND
service DNS failed).
- Two regression tests cover the sentinel-tagging contract and the
device-success-returns-nil-error path.
Related to https://github.com/gesellix/Bose-SoundTouch/issues/282.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pins the ordering invariant fixed in the preceding commit. Builds a
fake-speaker scenario where:
- SSH is unavailable (every SSH-driven axis stays false)
- telnet getpdo reports the AfterTouch hostname
Pre-fix, checkIsMigratedFromProbe ran before the telnet channel was
drained, so summary.TelnetVerifiedConfig was empty when
isTelnetMigrated read it — the telnet axis came back false and
summary.IsMigrated followed. The CLI's `setup verify` exited
non-zero, the web UI rendered "Not Migrated". Reproduced by
foob61451 on #293.
The test asserts:
- summary.TelnetVerifiedConfig is populated (sanity guard — the
downstream assertions are meaningless if the probe didn't run)
- summary.TelnetMigrated == true
- summary.IsMigrated == true
Verified locally: the test PASSES with the ordering fix applied and
FAILS without it. Failure messages name PR #294 by number so a
future regression points at the same code path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous 10s→30s timeout bump didn't help — the first POST to
/addWirelessProfile on the speaker's AP-mode endpoint frequently
hangs until the deadline elapses, then a second POST a few seconds
later succeeds immediately. Empirically the workaround was "just
run wifi-push twice"; this commit folds that into the function.
PushWiFiCredentials now:
- caps each attempt at 12 s (well above the sub-second healthy
response time) so a stuck first attempt doesn't burn the whole
budget
- waits 2 s between attempts so the speaker's setup endpoint can
finish whatever the first POST kicked off
- falls through cleanly if the first attempt succeeds (the second
never fires)
- returns the second attempt's error if both fail, with context
cancellation surfaced explicitly
Total budget is well under the CLI's 30 s --request-timeout, so
the flag still acts as a hard ceiling.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Stockholm app (stockholm/setup/js/workflow_add_devices.js:23,77)
and Zimbo88's OpenCloudTouch USB-less script
(https://github.com/scheilch/opencloudtouch/discussions/201) both send
<boseServer>, <updateServer>, and <accountEmail> alongside the
<accountId>/<userAuthToken> pair. AfterTouch's setMargeAccount
historically sent only the latter two.
Adds:
- MargePairingExtras struct on SessionConfig, opt-in via
BoseServer (UpdateServer + AccountEmail default-derived when
empty).
- DefaultMargeAuthToken constant ("Bearer AfterTouch") and
DefaultMargePairingEmail constant ("local@aftertouch.invalid",
RFC 2606 reserved .invalid TLD).
- buildPairDeviceWithAccountXML helper extracted so tests can
pin both the minimal-payload and extended-payload shapes
without driving a full WebSocket session.
- --token flag on `soundtouch-cli setup pair` so we can override
the placeholder for token-shape experiments.
- runPairBare threads --service-url through to PairingExtras so
`--mode=bare --service-url=...` ships the extended payload too;
runPairFull already used it via applyInitPlanDefaults.
The speaker accepts any non-empty Bearer string (verified during
#195 investigation: "Bearer AfterTouch" passes and the speaker
re-derives its post-pair state from the marge endpoints regardless
of token content). The Stockholm-app payload shape is purely
documentation alignment; it did NOT fix the post-pair AUX/preset
breakage that turned out to be the cloud /full source list (see the
preceding marge commit). Keeping the wiring so the switches are
ready when we want to experiment further.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes#195 and #269. Both issues reported the same symptom on
freshly-paired speakers: AUX selection and preset playback failed
post-pair, while /sources at :8090 still reported the sources as
READY. The bug was upstream in AfterTouch's cloud-side responses.
Real Bose's /streaming/account/{a}/full never emitted AUX as a
cloud <source>. Verified across 61 captured upstream /full bodies
covering 4669 source elements: zero match sourceproviderid=9 (AUX),
zero match the literal string "AUX". Captures sample at
scripts/android/captures/var/lib/soundtouch-service/parity_mismatches/.
The captured speakers are SoundTouch 20s which do have physical AUX
inputs — Bose deliberately kept AUX out of /full and let the speaker
enumerate it locally via isLocal=true.
AfterTouch's getAccountSources unconditionally included AUX
(id=10001) with the wrong shape: a displayName="AUX IN" attribute
(real Bose: never), <name>AUX</name> (real Bose: empty), an empty
<credential> (real Bose: empty for INTERNET_RADIO providerid=2 only,
never present for AUX since AUX wasn't there). The speaker's source-
reconciliation logic treated AfterTouch's malformed AUX entry as a
cloud-side inconsistency and refused dispatch to AUX — even though
the local availability check kept reporting it READY.
This was the actual cause behind a long red-herring trail (TPDA
:30034 storm, IoT.xml/AVS bootstrap, userAuthToken shape, SETUP
state machine bracket). All of those are universal across the
firmware family; spotty has the same TPDA storm in logread and AUX
still works there. Only the cloud-source-list shape diverged
between working and broken speakers.
The filter applies in getAccountSources because both AccountFullToXML
and AccountSourcesToXML go through it. AUX stays in
GetDefaultSources for non-cloud consumers (web UI source picker,
default-sources init). Three handler tests updated to assert AUX is
intentionally excluded from cloud responses.
Verified by gesellix on rhino 2026-05-16 via full factory-reset →
wifi-push → setup pair → AUX press → audio plays.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>