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>
Dockerfile.stockholm clones github.com/krahl/soundcork-stockholm-app at build
time and installs the required tools (prettier, patch, unzip, jq). No pre-built
image is published upstream, so users must run `make build-stockholm-image` once
before `make prepare-stockholm`.
`make prepare-stockholm` runs the upstream entrypoint logic (extract zip,
run prettier, apply patches) via a volume-mounted docker run, stopping before
`exec java` so we only collect the processed stockholm/ output. The Go service
then serves that directory directly with no patching required at runtime.
Prerequisites: Docker with internet access, and stockholm_zip/stockholm.zip
(Stockholm source zip placed manually — tracked directory, zip gitignored).
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>
Status was a value-typed DeviceStatus field on DeviceConnection,
written from the periodic poller (UpdateDeviceStatus) and from four
WebSocket event handlers (OnNowPlaying, OnVolumeUpdated,
OnConnectionState, OnPresetUpdated) while being read from every HTTP
handler and the WebSocket broadcaster. The struct was 8+ words wide
with time.Time and string members, so concurrent readers could
observe torn fields or mixed-update snapshots. The map-level race was
fixed in the previous commit; this one closes the per-connection
struct race.
Hide the field behind atomic.Pointer[DeviceStatus]:
Status() // returns current snapshot
SetStatus(*DeviceStatus) // wholesale replace
UpdateStatus(func(*DeviceStatus)) // CAS retry loop
NewDeviceConnection constructs a connection with the atomic pointer
pre-initialised, so Status() never returns nil for callers that go
through the constructor (the old struct-literal pattern is no longer
possible because the status field is now private).
UpdateDeviceStatus runs network fetches into local vars first, then
batches them into a single UpdateStatus call so the CAS loop only
retries the merge — not the slow IO. WebSocket event handlers and
the connect/disconnect transitions each use UpdateStatus, so any
ordering of poller + event delivery converges to a consistent
status.
The UpdateStatus docstring is explicit about the shallow-copy
contract: nested pointer fields (NowPlaying, Volume, Bass, Presets,
Sources) MUST be replaced, not mutated through, because the copy
mut receives shares those pointers with the prior snapshot. All
production callers already follow this pattern (every value comes
fresh from the device API).
Tests:
- types_test.go: migrated literal struct to NewDeviceConnection +
SetStatus, switched reads to Status().
- status_test.go (new): six tests covering constructor init,
SetStatus replacement semantics, UpdateStatus mutator
application, field preservation across UpdateStatus, snapshot
isolation (old snapshot stable under later writes), and a
concurrent stress test (16 writers + 32 readers x 200 ops) that
runs under -race.
- handlers_test.go, registry_test.go, spa_test.go: migrated to
constructor.
Not addressed by this commit:
- DeviceConnection.WebSocket (set once in ConnectDeviceWebSocket,
read elsewhere). Word-sized pointer, atomic at the hardware
level on amd64/arm64; race detector may still flag.
- DeviceConnection.LastSeen (written under devicesMu by the
registry, read outside that lock via DeviceSnapshot consumers).
time.Time is non-atomic but the read is cosmetic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Devices map on WebApp was written from the startup goroutine, the
/api/discover POST handler, and addDevice, while being read from every
HTTP handler and the WebSocket periodic-update loop — all without any
mutex. The Go runtime panics with "fatal error: concurrent map writes"
or "concurrent map read and map write" on any actual collision, so this
was a latent crash, not a tearing issue.
Hide the map behind a sync.RWMutex and a small API:
GetDevice(id) (*DeviceConnection, bool)
DeviceSnapshot() []DeviceEntry
DeviceCount() int
AddDevice(id, conn) bool // atomic insert-or-touch
TouchDevice(id) bool // fast-path LastSeen bump
Update every caller — handlers, websocket, main, tests — to go through
the API. addDevice's existing-host fast path uses TouchDevice; the
final insert uses AddDevice so a race with another writer is rejected
cleanly instead of silently overwriting.
Add a TestRegistryConcurrent stress test that runs 64 goroutines doing
12,800 operations across writers, touchers, and two reader patterns.
It exists to give `-race` (already on in CI) a concrete shape to catch
if the encapsulation ever leaks back out.
Struct-field races on conn.Status.* are not addressed by this change;
they need their own follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`addManualDevice` and the per-device branch of `discoverDevices` were
~40 lines of near-identical client setup, info fetch, connection
build, and map write — differing only in log wording. Extract a
shared `addDevice(app, host, port, source)` helper used by both
paths.
Side effects of consolidating:
- Duplicate-host guard (LastSeen bump) now applies to both paths, so
passing `--devices 1.2.3.4` twice is idempotent and matches how
discovery treats repeat sightings.
- Map write happens before the UpdateDeviceStatus goroutine launch,
so a concurrent GET /api/devices sees the device with
`IsConnected: false` instead of racing the status update.
- Log wording is consistent: "Failed to fetch device info from <host>
(<source>): <err>" and "Added <source> device <name> (<type>) at
<host>:<port>".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add three new entries to docs/guides/TROUBLESHOOTING.md so the next
reporter who hits these symptoms finds the answer without needing the
issue thread.
- "Every cloud source shows status=UNAVAILABLE / can't stream anything"
(Connection Issues). Three-step diagnostic checklist: :443
reachability preflight, margeAccountUUID check, filtered
`logread -f`. Distilled from the diagnostic ping on #224 plus
Thatboioofy's resolution (missing margeAccountUUID was the cause).
Sidebar clarifies that the firmware-internal placeholder sources
(SpotifyConnectUserName, SpotifyAlexaUserName, UPnPUserName,
StoredMusicUserName, QPlay{1,2}UserName, AirPlay2DefaultUserName)
are speaker-synthesized and their UNAVAILABLE status is never an
AfterTouch problem on its own.
- New section "Music Service & Preset Issues" with "Spotify preset
fails with 'Current content cannot be saved as preset'". Explains
the firmware-side isPresetable="false" gate on Connect-pushed
playback (foob61451's NowPlaying capture in #235), why an
OAuth-linked account flips it to true, and cross-links to
MUSIC-SERVICES.md and the new spotify-overview.md.
- "TuneIn (or Internet Radio) missing from /sources after a factory
reset". TuneIn is not a default source; the speaker only registers
it after first play. Captured from the #253 side-thread with both
app and `soundtouch-cli source content` recipes plus the
no-SSH caveat for newer hardware (SA-5).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
spotify-oauth.md (and any future docs) embed mermaid sequence/flow
diagrams as fenced code blocks. Kramdown emits those as
<pre><code class="language-mermaid">, which is not what Mermaid's
auto-renderer looks for, so on the rendered site they show up as raw
code instead of diagrams.
Add docs/_includes/head-custom.html (a hook the pages-themes/minimal
remote theme already exposes) to load Mermaid 11 as an ES module from
jsDelivr, rewrite pre/code.language-mermaid nodes into div.mermaid, and
call mermaid.run() once.
No Jekyll plugin or _config.yml change needed — the include slot is
honoured by the remote theme as-is.
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 web-UI wizard is in-place migration: it preserves the speaker's
existing pairing and synced data. The CLI sequence is a different
shape — full factory-reset → wifi-push → pair against AfterTouch
from scratch — and it's the right tool when you want a clean,
scriptable, reproducible setup (automation, batched onboarding, or
just starting from a reset speaker).
Documents the full 6-step CLI flow (plan / factory-reset / wait-ap
/ wifi-push / wait-online / setup pair --mode=full), the verification
checks, and a side-by-side comparison so users can pick the right
path. Placed after "Repeat for each speaker" so the wizard remains
the recommended default for one-off migrations.
The flow assumes #195 and #269 are fixed in v0.80.2 — without the
AUX/sources filter, the CLI factory-reset path produces a speaker
where AUX won't dispatch.
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>
Two HTTP client tests asserted AUX (id=10001 / sourceproviderid=9) was
present in /streaming/account/{a}/full and /streaming/account/{a}/sources.
After 2b40481 drops AUX from those cloud responses (matching real Bose
behaviour; see pkg/service/marge/marge.go getAccountSources), both
tests fail. Updates them to:
- Expect 5 sources in /full (down from 6) — INTERNET_RADIO,
LOCAL_INTERNET_RADIO, TUNEIN, RADIO_BROWSER, Spotify.
- Expect ids 10002/10003/10004 (not 10001/...) in /sources.
- Add explicit negative assertions that sourceproviderid=9 / id=10001
is *not* present, so a regression that re-introduces AUX in cloud
responses fails loud.
Verified via `make test-http-client`: 49 requests, 0 failures.
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>
The speaker confirms AddWirelessProfile then tears down its AP within
~30 s. The default 10 s --request-timeout races that ACK whenever the
speaker is busy reconciling state — and a hard-coded 10 s on the
internal http.Client capped the user-passed timeout silently, so a
longer --request-timeout had no effect.
The CLI default is now 30 s and the inner http.Client lets the
context govern alone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Section 4 captures the presentation-only follow-up to #252: collapse
the two halves of a stereo pair into a single device-list entry using
each speaker's GET /getGroup metadata. Pair lifecycle (add/rename/remove)
already works end-to-end via pkg/client + soundtouch-cli, so this is
purely a soundtouch-web UI concern.
Drafted after BirdyBA's stereo-pair confirmation on the closed#252:
https://github.com/gesellix/Bose-SoundTouch/issues/252#issuecomment-4458140305
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds rename_device.http between get_group.http and unregister_device.http
in the make test-http-client sequence. The new test fires the PUT
the speaker emits after a rename and asserts:
- 200 OK, content type vnd.bose.streaming-v1.2+xml
- the response carries the renamed value
- createdOn matches the value captured during register_device.http
(cross-request global), locking in the "first-paired" semantics
- ipaddress is preserved from the prior power_on, not reset by the
rename body's empty IP field
- a mismatched body deviceid is rejected with 400
register_device.http captures the initial createdOn into a global so
the rename test can assert equality rather than a flakier
updatedOn != createdOn heuristic. The variant POST's stale
updatedOn === createdOn assertion is replaced with an upsert-aware
equality against the same captured global.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
HandleMargeUpdateDevice used to call AddDeviceToAccount (an upsert)
and only check body-vs-URL deviceID after the row was already
written. A speaker sending a malformed PUT with the wrong deviceid
attribute would still leave a spurious record before getting 400.
Now we parse just the deviceid attribute, compare against the URL
segment, and only call into the upsert when they match. The
existing regression test gains two GetDeviceInfo assertions to lock
the no-spurious-row guarantee in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The PUT handler shipped in 5f31616 + the routing fix in 66b83b6 made
the rename PUT reach AfterTouch and return 200. But the response and
the on-disk record both drifted away from real Bose's parity on every
rename: CreatedOn was rewritten to now() (so the "first paired in
2017" semantics evaporated on the second rename) and IPAddress
landed empty (because the speaker's PUT body doesn't carry it and
the marge handler had no preservation path).
Pre-shutdown capture at
data/parity_mismatches/1771797308__streaming_account_3230304_device_A81B6A536A98.json
shows real Bose's 200 OK shape: createdOn pinned to the original
pairing timestamp (2017-02-07), ipaddress populated, only updatedOn
and name change across renames. Aligning with that.
Three small persistence additions:
- models.ServiceDeviceInfo grows CreatedOn + UpdatedOn (ISO8601
strings, omitempty so existing JSON consumers don't break).
- datastore.SaveDeviceInfo persists them inside the DeviceInfo.xml
payload as <createdOn> / <updatedOn> alongside the other fields.
- mergeWithExistingDeviceInfo preserves CreatedOn unconditionally
(it's the "first-paired" timestamp and never re-derived from
inbound data) and preserves UpdatedOn only if the caller didn't
set a fresh one.
marge.AddDeviceToAccount becomes precedence-aware:
- Reads the existing record once at the top.
- CreatedOn: preserved from existing if present, else now() for
first registration.
- IPAddress: preserves what's in the existing record; falls back
to r.RemoteAddr's host portion only when no prior IP exists.
Lets first-time PUTs seed an IP from the inbound connection
without later renames clobbering a known-good value.
- UpdatedOn: always now().
- Response XML now re-reads the persisted record so the
response body matches what's on disk — no parallel hand-built
XML drifting from the merge result.
Function signature gained a remoteAddr parameter. Both callers
(HandleMargeAddDevice and HandleMargeUpdateDevice) pass r.RemoteAddr.
Test coverage:
- TestIssue285_RenamePutAcceptedAndPersisted seeds the datastore
with a 2017 CreatedOn and a known IP, then PUTs the rename;
asserts both survive on disk AND in the response body, and
that UpdatedOn refreshes. The same pre-shutdown capture cited
above is the parity reference.
- TestIssue285_NewDeviceGetsRemoteAddrAndFreshTimestamps (new)
covers the no-prior-record path: first-time PUT against an
unknown device produces CreatedOn = now() and IPAddress
pulled from the inbound TCP connection. Pins the fallback
behaviour so it can't quietly stop seeding new devices.
Authorization is still not enforced — the speaker has no Bose token
to send post-shutdown, and we don't (yet) have a token-authority
story of our own. Adding a warn-only auth check is a deferred
follow-up (see NEXT.md). Real Bose returned 401 for this PUT in the
2026-05-15 capture; we knowingly accept anything.
Refs #285.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Issue #285's first fix (5f31616) registered the rename PUT inside a
chi subrouter at `/streaming/account/{account}/device`, alongside the
existing POST handlers. A *second* subrouter was already declared at
`/streaming/account/{account}/device/{device}` for the per-device
sub-resources (presets, recent, group, …). chi's radix tree treats
those two registrations as overlapping prefixes and at request time
prefers the more-specific `/device/{device}` subrouter — which had
no root-level method handlers. A PUT to /device/X fell through to
the [UNHANDLED] catch-all, got proxied to streaming.bose.com, came
back as 401 from CloudFront. Speakers retried in a loop.
The handlers-package regression test passed because the test router
in `pkg/service/handlers/main_test.go` is flatter (one subrouter for
device, no `/device/{device}` nested block). The route snapshot
test passed because `chi.Walk` enumerates each subrouter's
registrations independently — it doesn't simulate how the radix tree
will resolve a runtime request when subrouters overlap.
Reproduced against the actual production setupRouter in
TestPUTRenameRoutesToLocalHandler (new in router_test.go). Before
this commit: 404 / [UNHANDLED] / 401 proxy. After: 200 from
HandleMargeUpdateDevice.
Fix: collapse the two subrouters into one. All `/device` routes —
the POST/PUT/DELETE on the device resource itself plus the GET/POST
sub-resources — share a single `r.Route("/device", ...)` block with
explicit `/{device}/...` paths inside. No radix-tree ambiguity.
Knock-on: the `r.Delete("/device/{device}", server.HandleMargeRemoveDevice)`
that lived at the outer `/account/{account}` level moves into the
unified `/device` subrouter for symmetry. Its prior placement was
also being shadowed by the radix overlap, which is why the route
snapshot's first regeneration after this fix grew by exactly one
DELETE line — that route was never resolvable at runtime under the
old structure either.
Refs #285.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lifts the back-and-forth in issue #250 into the README so the next
user doesn't repeat the same three traps Gustour hit:
1. The `ssh -L 8000:localhost:8000` command must run on the user's
own machine, NOT inside the speaker's SSH session. Gustour
pasted it at the speaker's `root@mojo:~#` prompt; the tunnel
ended up speaker → speaker (loopback) and did nothing.
2. SoundTouch firmware offers only ssh-rsa/ssh-dss host-key
algorithms; modern OpenSSH refuses them by default with
`Unable to negotiate with <ip> port 22: no matching host key
type found`. The README's *initial* ssh command already
uses `-oHostKeyAlgorithms=+ssh-rsa`, but the port-forward
example didn't — adding it.
3. If the tunnel is correct and the browser still gets
ERR_CONNECTION_RESET, the daemon isn't listening. The previous
README left the user stranded here. Adds the diagnostic ladder
(`netstat`, `ps`, `logread | grep aftertouch`) that matches
the syslog-tag pattern shipped in the prior commit, plus the
`/etc/init.d/aftertouch start` + `status` retry — the new
status case can now distinguish "PID alive, listener up" from
"PID alive, listener silently died".
No script changes; pure docs lift.
Refs #250.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundles the install-time hygiene work for issues #268 and #250.
# Install location — #268
Stock SoundTouch rootfs has only a few MB free (~4 MB on the ST20
the reporter captured); the AfterTouch binary is ~12 MB. The previous
flow downloaded into tmpfs (/media/aftertouch) and then `mv`'d the
binary into /opt/aftertouch on rootfs — which fails with
"No space left on device" on any speaker with the standard layout.
install.sh now installs to /mnt/nv/aftertouch by default (the
persistent partition, ~30 MB free on the same captures) and points
/opt/aftertouch at it via a symlink so the init script's hardcoded
DAEMON path keeps working unchanged. Power users can override with
INSTALL_DIR=/some/other/path. The interactive prompt from the
community patch in #268's thread is dropped — STDIN is the curl
pipe under the documented `curl | sh` invocation, so a read prompt
would hang or read garbage.
uninstall.sh is updated to resolve the symlink and remove the
target before unlinking, so the 12 MB binary doesn't get orphaned
on /mnt/nv when users uninstall.
# Logging — #250
Issue #250 surfaced a "running but unreachable" state: the install
script reported AfterTouch as running, the init script's status
agreed, but `curl :8000` returned connection-refused. start-stop-
daemon's --background detaches stdout/stderr, so any panic the
daemon emitted before dying went to /dev/null with no diagnostic
trail.
The fix is to route the daemon's stdout/stderr through `logger -t
aftertouch` so output lands in BusyBox syslog — a bounded in-memory
ring buffer that never grows on disk (writing to a file in /mnt/nv
would have eaten the volume over months). Diagnostic flow is now:
logread | grep aftertouch | tail -20
logread -f | grep aftertouch # live tail
Matches the recipe already documented in TROUBLESHOOTING.md for the
speaker's own logs (Curl 7 section).
Tightening on top of the syslog change:
- The init script's `status` case now also curls localhost:8000
when the PID is alive — distinguishes "PID alive, listener up"
from "PID alive, listener silently died" (which is what fooled
everyone on #250). A bare PID-liveness check returned "running"
in both cases.
- install.sh's post-install verification now does its own 10s
curl probe after the init script returns; on failure it tails
the aftertouch syslog so the user sees the actual error rather
than the install script claiming success.
- `exec` is added inside the start-stop-daemon's shell wrapper so
--make-pidfile records the daemon's own PID (not the shell's),
which keeps `stop` semantics correct.
README updated to document the install location, INSTALL_DIR
override, and the syslog tag.
No automated tests — these are shell scripts the install pipeline
runs once on the device. All three scripts pass `bash -n` /
`sh -n` syntax checks. Real validation is end-user retest, gated on
the next release.
Refs #268, refs #250.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes issue #285. When the user renames an ST10 via the Bose App or
via `soundtouch-cli name set`, the speaker fires:
PUT http://<aftertouch>:8000/streaming/account/{accountID}/device/{deviceID}
Content-Type: application/xml
<device deviceid="…"><name>NEW</name><macaddress>…</macaddress></device>
The router only had POST registered for that path; PUT fell through
to chi's default handling and the speaker observed HTTP 502 (captured
verbatim in _/i285/Rename.log:38: "SimpleURLFetcher: retry needed,
Curl 0, http 502, retries remaining 0"). The speaker's SimpleURLFetcher
retried the PUT on a 15-second timer, the Bose App showed the rename
spinning indefinitely, and the device's display name never updated on
the AfterTouch side.
Implementation reuses marge.AddDeviceToAccount, which is already an
upsert via ds.SaveDeviceInfo — there's no semantic difference between
"add" and "update" at the persistence layer. The new handler
HandleMargeUpdateDevice differs from HandleMargeAddDevice only in the
HTTP envelope:
- 200 OK (not 201 Created — this is an update, not a fresh resource)
- no Location header (the resource already lives at the URL the
speaker is PUT-ing to)
- deviceID in the body must match the URL's {device} segment;
mismatch is a 400 rather than a silent re-key
Registered as `r.Put("/{device}", server.HandleMargeUpdateDevice)`
inside the existing `/streaming/account/{account}/device/` route
group in both cmd/soundtouch-service/main.go and the handlers-package
test router. Router-routes snapshot regenerated.
Test coverage in pkg/service/handlers/issue285_regression_test.go:
- TestIssue285_RenamePutAcceptedAndPersisted seeds the datastore
with a device under its original name, replays the literal log
payload from _/i285/Rename.log:36 against the real router, and
asserts 200 OK + new name in response body + new name persisted
on disk. testdata/issue285/rename_request.xml is the captured
payload byte-for-byte (accountID 3981561, deviceID 884AEAEEBD27,
rename to "Wohnzimmer SB" — same as the reporter).
- TestIssue285_RenamePutRejectsMismatchedDeviceID pins the safety
check: body deviceid != URL {device} → 400.
Closes#285.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the AfterTouch-side half of issue #234. After a factory reset
the speaker's /sources only lists the always-on local entries (AUX,
BLUETOOTH, AIRPLAY, NOTIFICATION, QPLAY, plus a SpotifyConnectUserName
placeholder); TUNEIN, LOCAL_INTERNET_RADIO, DEEZER, and linked
Spotify accounts are absent until the device receives the
<sourcesUpdated/> notification the reporter ran by hand. SyncDeviceData
now POSTs that notification as the final step, so users get the
visible-source-list recovery for free when they click Data Sync.
The other half — re-creating Marge.xml so playback resumes — is
already handled by the wizard's pair-account flow: it detects an
empty <margeAccountUUID/> in /info and prompts the user to pick a
known account or generate a new one. The wizard's pairing UI is
deliberately user-driven (the user picks the ID); the notification
nudge is purely automatic because there's no choice to make.
Implementation routes through the existing client surface rather
than reinventing it. setup.notifySpeakerSourcesUpdated delegates to
pkg/client.Client.NotifySourcesUpdated — the same path
handlers_mgmt.go already uses after music-service account changes
(handlers_mgmt.go:304, :637). The wire shape lives in one place
(pkg/models.NewSourcesUpdatedNotification). Fire-and-forget: a
notification failure logs but doesn't fail the sync.
Adjacent UX changes:
- docs/guides/TROUBLESHOOTING.md: new section "Presets flash then
revert to 'Select a preset' after a factory reset". Names the
symptom, the Marge.xml + reduced-/sources cause, and walks the
user through re-opening the Migration tab + Data Sync.
- pkg/service/handlers/web/js/script.js: devices list now renders
a "⚠ Not paired — re-pair" badge in the account-ID column for
speakers whose live /info reports an empty margeAccountUUID.
Clicking it opens the Migration tab pre-filled with that device,
surfacing the wizard's existing "Not paired (factory-reset or
never paired)" flow without making users discover it cold.
- pkg/service/testing/fakespeaker/testdata/info.xml: demo speaker
now reports margeAccountUUID=1234567 instead of the misleading
0000000 (which AfterTouch happens to accept as syntactically
valid but is not a documented sentinel anywhere — the convention
is empty for factory-reset, a real 7-digit number otherwise,
matching pkg/client/testdata/info_response_st{10,20}.xml).
Screenshots regenerated accordingly.
Test scaffolding:
- fakespeaker grows a POST /notification recorder that captures
body + Content-Type; tests assert on s.Notifications().
- TestIssue234_FactoryResetSpeakerSyncsReducedSources now drives
SyncDeviceData end-to-end (exercises the wiring) and asserts
the notification fires with the right deviceID and shape.
- TestFakeSpeakerNotificationRecorder pins the recorder contract
and the POST-only method gate.
Refs #234.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hardens TrustCACertFromBytes against the failure mode behind issue
#262 (corrupted /etc/pki/tls/certs/ca-bundle.crt on a SoundTouch 20)
and against silent transport-time corruption of our own writes.
Three-part change.
1. Atomic write path. The previous flow piped bytes straight into the
live bundle via `cat > <path>`; a dropped SSH session or partial
write left the device with a half-written trust store and no way
to roll back. The new path:
- uploads to <bundlePath>.aftertouch.tmp (sibling on the same
filesystem, same rw remount),
- reads the tmp back over SSH,
- validates the readback at the PEM-frame layer + the AfterTouch
sentinel bracketing,
- atomically `mv`s the tmp into place,
- on any verification failure: `rm -f` the tmp; the live bundle
is never touched, so there is no rollback semantics to reason
about.
The .original backup written on first install stays as
defense-in-depth (manual recovery for corruption from outside this
code path), but it is no longer the primary safety net.
2. New validators in pkg/service/setup/ca_validation.go.
- validateCABundleBytes: BEGIN/END marker counts match, every
decoded block is a CERTIFICATE with a non-empty body, decoded
block count equals BEGIN-marker count (catches a block with
unparseable base64 body), trailing non-PEM/non-comment content
rejected.
- validateAfterTouchLabelBracketing: CALabel appears exactly
twice and brackets exactly one CERTIFICATE block.
- stripAfterTouchEntries: collapses any number of stale
AfterTouch entries from the existing bundle. Older releases
reported to have appended without stripping, so long-lived
devices can carry several copies; we strip them all and log
the cleanup count rather than failing validation. Unpaired
sentinels (truncated prior install) surface as a structured
anomaly the caller logs and warns about.
The validators stay at the PEM-frame layer on purpose — an
earlier iteration called x509.ParseCertificate per block and
rejected the real ST20 bundle on block 29 (Go 1.23+ disallows
negative serial numbers, but Mozilla CCADB still ships ancient
CA roots that have them). Shipping that version would have made
every legitimate speaker install fail. The corruption mode #262
surfaces at the PEM-framing layer; x509-level checks aren't what
we needed.
3. testdata/ca_bundle_st20_pristine.crt is the pristine
/etc/pki/tls/certs/ca-bundle.crt captured off a real SoundTouch 20
(firmware 27.0.6.46330.5043500, snapshot 2022-08-04). Mozilla
CCADB public dataset, 165 certs, ~251 KB. TestValidateRealSpeakerBundle
locks in the cert count and asserts the strip pass is a no-op
against a bundle that has never been touched by AfterTouch.
Test infrastructure. mockSSH (both the setup-package and the
handlers-package copies) now mirrors UploadContent into a private
map so a subsequent `cat <path>` on the same path returns what was
written there. Lets the tmp-readback step in TrustCACertFromBytes
work against tests that only scripted the live-bundle path, without
per-test wiring. Two new behavioural tests in setup_test.go:
TestTrustCACert_StripsMultipleStaleEntriesSilently (pins the
multi-entry cleanup contract) and
TestTrustCACert_PostUploadVerificationFailureCleansUpTmp (pins the
rollback-free recovery: live bundle untouched, tmp removed).
Refs #262.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
f3a4658 dropped the auth check on HandleOrionPlayback while moving
the orion routes to their registry-advertised paths. The rationale at
the time was "data is the speaker's own input, nothing privileged"
and parity with soundcork's reference impl.
On reflection, requiring the Authorization header is the right
default here for two reasons:
1. Parity with the rest of our BMX playback surface (TuneIn
variants — see TestBMXUnauthorized's table — all gate on a
non-empty Authorization header). Orion being the lone unguarded
exception was a footgun, not a feature.
2. Real speakers obtain a Bearer token via the orion
/token endpoint before they follow a LOCAL_INTERNET_RADIO
preset, so the gate doesn't cost any legitimate caller. A
callerless GET (curl, scraper, casual probe) gets a clean 401
instead of a working playback resolver.
The check itself is the same shape as the other BMX handlers:
empty Authorization header → s.writeBMXUnauthorized → 401. Token
contents are not validated, only presence — sufficient for the
parity contract.
Test side:
- TestOrionPlayback regains its Bearer header (it had one before
the GET-method switch in f3a4658).
- TestBMXUnauthorized's table regains a sibling row for the orion
station endpoint with the GET + query-string shape.
- TestIssue218_OrionStationResolvesPresetStreamURL sends a Bearer
header on the loop-closing GET — added with a doc comment
naming the orion /token bootstrap a real speaker would do.
No route-table changes; the registry advertisement and route paths
from f3a4658 stay as they are.
Refs #218.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two-part iteration. First, the fakespeaker grows a `/now_playing`
route with a default STANDBY fixture — issue #235 is the first one in
this series that needs to override /now_playing, and adding the route
on its own would be infrastructure noise; bundled here it has an
immediate consumer.
The regression test then locks in the device-side signal at the heart
of #235: when a SoundTouch is targeted by Spotify Connect (Spotify
app sends audio to the speaker), the speaker's /now_playing reports
- source = SPOTIFY
- sourceAccount = SpotifyConnectUserName (the marker)
- ContentItem.location = /playback/container/<base64 spotify:...>
— a perfectly resolvable URI
- **ContentItem.isPresetable = false**
The contradiction (resolvable location + isPresetable=false) is the
reason the CLI's storeCurrentPreset at
cmd/soundtouch-cli/cmd_preset.go:41 refuses to act and emits "current
content cannot be preset" — exactly the reporter's symptom.
The test base64-decodes the location to surface the contradiction
explicitly: it should yield a `spotify:` URI. When AfterTouch grows a
fallback path (CLI --force, or service-side resolution to the
device's own Spotify integration via the SoundTouch Spotify source
provider), the assertion here stays sound — it tests what the device
emits, not what the CLI decides — but a sibling test should assert
the new fallback path produces a successful preset.
Fixture pattern matches the rest of the issue series:
testdata/issue235/ next to the test, fakespeaker driven via
FixtureOverrides, doc-comment naming what would have to change for
the assertion to flip.
Refs #235.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Issue #253 ("Edits to local Presets.xml don't propagate to
:8090/presets") has a three-hop propagation chain — disk → marge,
marge → device (via notification or power_on), device → :8090. Only
the first hop is in our reach; if it's broken, neither of the others
can recover.
This test writes presets_v1.xml directly to the datastore
(mimicking the reporter's hand-edit), calls PresetsToXML, asserts the
v1 markers (itemName "Initial Station", location s..INITIAL) land in
the rendered bytes. It then overwrites with presets_v2.xml and calls
PresetsToXML again, asserting:
- v2 markers ("Edited Station", s..EDITED) land,
- v1 markers are gone.
Current AfterTouch passes both assertions — disk→marge is sound, so
the reporter's symptom must originate downstream (notification
trigger missing, device-side firmware behaviour, or both). That
narrows the investigation surface for whoever picks up #253 next.
If this test ever flips (a caching layer is added without proper
invalidation, an in-memory presets handle is held across edits), the
fix is to invalidate the cache on disk write rather than weaken the
test — that contract is what the reporter relies on.
Pattern mirrors recents_sourceproviderid_regression_test.go: write
XML directly into the temp datastore filesystem and exercise the
marge function the handler calls (PresetsToXML at marge.go:370).
Fakespeaker isn't involved here — the failure surface is server-side,
not in what the device emits.
Refs #253.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the device-side state the reporter described in
https://github.com/gesellix/Bose-SoundTouch/issues/234 into the
fakespeaker via FixtureOverrides, and exercises GetLiveDeviceInfo +
syncSources against it.
The factory-reset state has two observable signals:
- `/info` returns an empty `<margeAccountUUID/>` because Marge.xml
is missing from the persistence partition. AfterTouch's
"is the device paired?" check at setup.go:632 keys on AccountID,
so this is the canonical "needs re-pairing" signal.
- `/sources` lists only AUX, BLUETOOTH, AIRPLAY, the
SpotifyConnectUserName placeholder, NOTIFICATION, and QPLAY —
TUNEIN, LOCAL_INTERNET_RADIO, and any post-pairing Spotify
accounts are gone until the speaker is nudged with a
`<sourcesUpdated/>` notification or re-pairs.
Today AfterTouch has no auto-recovery for either signal — it just
passes the state through. The test locks in that contract by
asserting:
- GetLiveDeviceInfo reports an empty MargeAccountUUID,
- persisted Sources.xml contains AUX/BLUETOOTH/AIRPLAY sourceKeys,
- persisted Sources.xml does NOT contain TUNEIN/LOCAL_INTERNET_RADIO.
When auto-recovery lands (e.g. an automatic POST of the
sourcesUpdated notification during sync, or marge-side source
replenishment), the absence assertions will flip — at which point
update them to assert the survivors are *present*, and adjust the
doc-comment so the contract stays in sync with the code.
Pattern mirrors pkg/service/setup/issue218_regression_test.go: a
testdata fixture next to the test, fakespeaker driven via
Config.FixtureOverrides, doc-comment naming what would have to
change for the assertion to flip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pairs with the existing pkg/service/setup/issue218_regression_test.go
"survives sync" assertion. This one takes the exact `location`
attribute the reporter pasted in issue #218 — the cloud URL embedded
in their LOCAL_INTERNET_RADIO preset — parses out the base64 `data`
query payload, sanity-checks it really does encode the documented
http://ais-sa3.cdnstream1.com/2440_128.aac stream URL, then hits the
preset's path-and-query on the real router and asserts the
BmxPlaybackResponse the speaker would receive: audio.streamUrl, name,
streamType, and the streams[] mirror.
Before f3a4658 this test would have 404'd because orion was nested
under the wrong `/bmx/` prefix. With the routing fix in place, the
two issue #218 regressions now bracket the failure end-to-end:
- setup test (sync side): the URL is preserved on the way in
- handlers test (this one): the URL works on the way out
No fix-side code changes; this is purely a regression-protection
addition that documents the contract resolved by f3a4658.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The BMX registry advertises orion at
`{BMX_SERVER}/core02/svc-bmx-adapter-orion/prod/orion` — no `/bmx/`
prefix. That matches the upstream Bose capture in
pkg/service/handlers/static/bmx_services_ustream.json. But our router
nested both orion routes inside the `/bmx/` chi group, so the speaker
asked `/core02/.../prod/orion/token` and our service routed
`/bmx/core02/.../prod/orion/token` — pure path mismatch. The legacy
preset URLs in issue #218 (LOCAL_INTERNET_RADIO presets pointing at
`https://content.api.bose.io/core02/svc-bmx-adapter-orion/prod/orion/station?data=...`)
also dead-ended for the same reason.
Three changes:
- Move `POST /core02/svc-bmx-adapter-orion/prod/orion/token` from the
`/bmx/` group to top level so it matches what the registry hands the
speaker.
- Add the missing `GET /core02/svc-bmx-adapter-orion/prod/orion/station`
that takes `data` as a query string. The handler reuses
bmx.PlayCustomStream — base64-decode the JSON blob (streamUrl/
imageUrl/name) and rewrap it into the standard BmxPlaybackResponse
shape, exactly the way soundcork's reference impl handles it
(soundcork main.py:786, bmx.py:720). No auth check on this endpoint:
`data` is the speaker's own preset payload, there's nothing
privileged to gate, and the upstream behaviour treats it the same way.
- Drop the local-invention `POST /bmx/orion/v1/playback/station/{data}`
route. Nothing advertised it, nothing real-world called it, and
keeping it as a "convenience alias" would have left a misleading
duplicate next to the canonical path.
TuneIn's `/bmx/tunein/...` routes stay where they are — TuneIn's
upstream baseUrl genuinely is `{BMX_SERVER}/bmx/tunein`, so the chi
group prefix is correct for that one.
Router snapshot regenerated; TestOrionPlayback flipped from
POST `/bmx/orion/v1/playback/station/{data}` to GET
`/core02/...station?data=...` (no auth header); the orion entry in
TestBMXUnauthorized's table is removed (the endpoint isn't authed
anymore, by design).
Refs #218.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces a per-route fixture-override hook on fakespeaker.Config so
open issues with concrete device-side payloads can become repeatable
regression tests, then demonstrates the pattern by wiring issue #218.
Foundation. Config grows a single optional field:
FixtureOverrides map[string][]byte
Routes named in the map (e.g. "/presets", "/sources", "/info") return
the supplied bytes; routes not in the map fall through to the embedded
testdata defaults the screenshot pipeline relies on. Stateful handlers
(/getGroup, /addGroup, /updateGroup, /removeGroup) are unaffected
because they're code-driven, not fixture-driven. The override slice is
snapshotted at construction so later mutations of the caller's slice
don't change the served body. Zero-value Config keeps the existing
behaviour, so cmd/dummy-speaker + scripts/screenshots are untouched.
Iteration zero — issue #218.
pkg/service/setup/issue218_regression_test.go starts a fakespeaker
serving the reporter's LOCAL_INTERNET_RADIO preset XML verbatim (URL:
content.api.bose.io/core02/svc-bmx-adapter-orion/prod/orion/station?…),
runs Manager.syncPresets against it, then asserts the persisted
Presets.xml retains the Bose cloud URL prefix. This locks in the
"location preserved through sync" contract; when AfterTouch starts
rewriting the URL to its own base (the eventual fix for #218), the
assertion flips and the fixture stays unchanged — the test is the
carrier for the decision.
Pattern reference for future issue regression tests: this exemplar
mirrors pkg/service/marge/recents_sourceproviderid_regression_test.go's
style (issue link, trigger chain in the doc-comment, locked-in
assertion) but is the first one to drive the device side via fakespeaker
rather than an inline httptest.NewServer. Subsequent issues with
device-side payloads (#234 factory-reset state, #235 Spotify-as-preset,
…) can reuse the FixtureOverrides hook without further infrastructure.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
on-device-install and raspberry-pi installers default to the new
v0.79.0 release binary. Also refreshes two stale comment examples in
the raspberry-pi install script (v0.17.0 → v0.78.0, v0.18.1 → v0.79.0)
so the in-file usage hints reflect the same era as the default.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
make screenshots was producing artifacts: a ghost Spotify pill on
ui-devices, empty Plan-card URL inputs on ui-migration with cascading
"localhost" warnings, and "Checking configuration…" placeholder text
instead of "❌ Not configured" on ui-settings. Two root causes, fixed
together so the run is deterministic again.
1. Fakespeaker too thin for the post-wizard inspect pipeline. The new
migration wizard probes /supportedURLs and reads /networkInfo and
/sources alongside the existing /info, /presets, /recents. Those
routes now exist with sanitized fixtures (deviceID DEADBEEFCAFE,
loopback IPs, no real MACs or account IDs). The full group endpoint
set is also wired: /getGroup and /removeGroup return the empty
<group/> shape a real un-paired device emits; /addGroup and
/updateGroup echo the posted body with <status>GROUP_OK</status>
inserted before </group>, matching the success path documented in
issue #252. /supportedURLs lists everything the fake now serves so
any caller that probes capabilities first (e.g. marge_pairing.go)
sees a coherent picture. Tests cover the GET routes' XML roots, the
POST echo + GROUP_OK insertion contract, and /removeGroup's
GET-only contract (405 with Allow: GET on other methods).
2. run.sh seed hit a DNS cliff. The :443 preflight shipped in 3727ae6
resolves server_url on every /setup/settings call, and the
populatePlannedNetworkConfig step does it again. With the previous
seed of http://aftertouch.local:8000 each lookup burned ~5s on DNS
timeout, which compounded across the wizard calls and pushed
ui-migration past chromedp's 30s per-shot budget. Switched the seed
to http://aftertouch.localhost:8000 — RFC 6761 means *.localhost
resolves to loopback via the system resolver in milliseconds
(verified ~8ms on macOS / glibc / systemd-resolved) — so the brand-
friendly hostname survives in the captured PNGs without the
timeout. Manifest settle times bumped (ui-settings 300→2000ms,
ui-devices 500→2500ms, ui-sync 300→1000ms) to give fetchSettings +
fetchSpotifyStatus time to complete in headless Chrome.
While here, softened validateURL's loopback message to acknowledge the
on-device-install case (AfterTouch running on the speaker itself, where
loopback works) instead of unconditionally telling users they're
wrong. The validation still flags 127.0.0.1 / localhost since it's the
wrong answer 99% of the time, but the message now frames the
constraint rather than scolding.
docs/images/ui-*.png regenerated against the new pipeline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>