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>
Add the loopback-filtered command `logread -f | grep -v '127.0.0.1'` to
DEVICE-LOGGING.md's Pro-Tip section with a one-line rationale (strips
the speaker's in-device localhost chatter so cloud/AfterTouch attempts
are readable). Cross-link from the new Curl 7 entry in TROUBLESHOOTING
so users hitting that symptom find the SSH/logread how-to.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Speakers connect to Bose hostnames over implicit HTTPS (:443) while
AfterTouch's listener defaults to :8443. Without iptables / setcap /
reverse-proxy in front, the speaker side sees Curl 7 / connection
refused and AfterTouch's HTTP log stays silent — a recurring source
of confusion (see #214, #269).
Add a server-side probe (Check443Reachability) that dials both
localhost:443 and the DNS-resolved LAN IP on :443. Run it once at
service startup with a 2s timeout and emit a [WARN] log with the
exact iptables/setcap commands keyed to the configured listener port.
Expose the result via GET /setup/settings (with a shorter inline
timeout) so the web UI renders a ✅/❌ line next to Target Domain
and a complementary browser-side fetch probe — the browser sits on
the LAN exactly where speakers do, and timing-to-error distinguishes
TCP refused from TLS handshake started even with an untrusted CA.
Both the startup WARN and the UI row are gated on dns_enabled,
since :443 only matters for the DNS migration path; SDK-override
migration uses the port from the configured URL.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add an end-to-end IntelliJ HTTP Client test that replays the exact
request shape a SoundTouch 10 master sends to its configured Marge
server during stereo-pair formation (captured live in issue #252):
POST /streaming/account/{accountId}/group/
Authorization: Bearer <token>
Content-Type: application/vnd.bose.streaming-v1.2+xml
<group>
<masterDeviceId>...</masterDeviceId>
<name>TEST</name>
<roles>
<groupRole><deviceId>...</deviceId><role>LEFT</role></groupRole>
<groupRole><deviceId>...</deviceId><role>RIGHT</role></groupRole>
</roles>
</group>
Assertions cover the wire contract that fails loudly if regressed:
trailing-slash URL is matched, response is 201 Created with the vendor
media type, Location header references the new group under the
account, and the body echoes masterDeviceId, name, and both groupRole
entries.
Wired into the make test-http-client target, sequenced before
get_group.http so the GET runs against the post-create state.
get_group.http's assertion only checks for the presence of a <group>
element, so adding a populated group beforehand is compatible.
Refs #252
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SoundTouch 10 firmware 27.x posts the addGroup payload to the Marge
URL with a trailing slash ("/streaming/account/<id>/group/") when the
master is forming a stereo pair. AfterTouch only registered the no-
slash form, so chi returned 404, the master's MargeClient retried
every 15 s, the slave kept connecting to the master's audio transport
but was rejected with "Group STP NOT FOUND" because the master never
finished AddingMaster, and the group eventually reverted -- the symptom
reported in #252.
Register POST /group/ alongside POST /group in both Marge route trees
(the /marge/streaming/... mount and the bare /streaming/... mount that
serves direct device traffic). The GET device-group routes already had
both forms; this brings the POST in line.
Add TestMargeAddGroup_FromSpeakerCapture, which replays the exact
request captured live from BirdyBA's master log: URL with trailing
slash, Authorization Bearer header, vendor Content-Type, and the
minimal XML body (no <senderIPAddress>, no per-role <ipAddress>, no
<status>, no numeric group id). The test failed with 404 before this
change and now returns 201 Created with the proper Location header,
pinning the exact wire contract so future refactors fail loudly.
Refs #252
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The speaker's GroupService state machine uses the presence of
<senderIPAddress> in the addGroup payload to decide whether it should
form the group as master or join as slave: "SenderIp is provided, I am
the slave". Sending the same XML to both speakers (with senderIP set to
the master's IP) made the master also conclude it was the slave, enter
AddingSlave, time out after 5 s waiting for a master that never
confirmed, and revert. The slave briefly showed GROUP_OK before
following the master back to NoGroup -- the "stereo pair appears for a
few seconds, then disappears" symptom reported in #252.
Send two distinct payloads from propagateAddGroup: the master receives
the base request with no senderIPAddress, the slave receives a copy
with senderIPAddress set to the master's IP. The base request built by
createGroup no longer carries senderIPAddress; the per-role injection
is contained inside propagateAddGroup where the master/slave roles are
unambiguous.
Update TestPropagateAddGroup_BothSucceed to assert the master's body
has no <senderIPAddress> while the slave's body does, so any future
regression on either side fails the test.
Refs #252
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
createGroup used to POST only to the LEFT (master) speaker and rely on
the master to propagate the group to the slave via marge. That round-
trip is the source of the "context deadline exceeded" failures reported
in #252 — the master blocks waiting for marge while the CLI times out
client-side. SoundCork's working ST10 implementation addresses each
speaker directly, which avoids the inter-device coordination entirely.
Changes:
* Build the group request with senderIPAddress = master IP (the fhem
wiki documents this field; SoundCork sets it; we previously omitted
it).
* propagateAddGroup() POSTs the same payload to both speakers
concurrently via a sync.WaitGroup and returns per-side outcomes.
* postAddGroup() flags a non-GROUP_OK response Status as an error so
the caller doesn't have to re-parse the body.
* On partial failure (one side succeeded), surface a remove command
the user can run to clean up.
Tests cover the happy path (both succeed, payload shape correct), the
right-side-fails path, the non-GROUP_OK response, and an empty-status
response (some firmware omits Status entirely on a successful echo).
Refs #252. Optimistic fix — still pending feedback from BirdyBA's
two-curl test on real ST10s before we're confident.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Document the remaining feature gap between soundtouch-web and the
Stockholm app's local-control functionality (seek/scrub, queue view,
per-device settings) and the explicit non-goals (anything cloud-bound
that is either shut down or already handled by soundtouch-service).
Acts as both a contributor checklist and a public statement of what
the web UI will and won't try to cover.
Link the page under the Concepts section in SUMMARY.md so it shows up
in the published docs and satisfies the docs-consistency test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Users on systems without a local telnet binary (modern macOS, Windows
without OptionalFeatures, minimal Linux distros) need a workable
recipe to reach the speaker's port-17000 shell. Add a one-line docker
run snippet that uses busybox-extras telnet inside an alpine
container, parameterised by the target speaker IP.
Placed at the top of the reference page so a reader who lands there
asking "how do I run telnet?" sees the fallback before the command
listings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace floating major-tag references (uses: foo/bar@vN) with the
specific commit SHAs they currently resolve to, annotated with the
fully-versioned tag (# vX.Y.Z) for human readability. Pinning to a SHA
makes the action behaviour reproducible across runs and removes the
supply-chain risk of a maintainer (or attacker) moving a tag to a new
commit.
One documented exception: semgrep/semgrep-action does not publish
v1.x.y semver tags — v1 is their only canonical release name on that
line — so it keeps a "# v1" annotation with an inline explanation.
actions/dependency-review-action's previous "@v5" reference would have
failed at run time: that repo only ships fully-versioned tags
(v5.0.0), no moving v5 alias. Pinned to v5.0.0 explicitly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The lookup branch of AccountDeviceDir does up to two Stat() syscalls,
so its wall-clock cost is dominated by filesystem latency. On shared
CI runners that latency varies enough that the existing 70 ms absolute
threshold has been tripped repeatedly -- the previous bump from 50 ms
to 70 ms in d97cd45 was the same story. Incrementally relaxing an
absolute bound to track CI noise is a treadmill.
Replace the lookup-time wall-clock check with a ratio against the
in-memory update cost (currently ~8x on dev machines, ~12x on CI).
The 30x threshold leaves comfortable headroom for noise while still
catching an algorithmic regression in the lookup path, where the ratio
would explode well past 30 (an O(n^2) walk over 1000 entries would
push it into the hundreds).
The update path's absolute cap stays in place as a backstop against
catastrophic regressions in that hot in-memory path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the user passes --bind <iface> and doesn't set --interface,
discovery now reuses the same interface name instead of auto-picking.
Common single-interface setups stop needing to repeat the flag, while
the two flags remain independent for the cases that legitimately want
HTTP and discovery on different interfaces.
Update the --interface help text to document the default. The --bind
text is unchanged: it still describes the HTTP listener address.
Refs #264
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous implementation silently returned the literal interface
name when the interface existed but had no IPv4 address (or when
listing addresses failed). That reproduces the exact error from #264
("listen tcp: lookup eth103 on ...: no such host") for users on
IPv6-only or admin-down interfaces, so the fix only worked for the
happy path.
Return an explicit error for those cases and fatal in main with a
message that identifies the offending --bind value. Add an IPv6
fallback (single non-link-local address, bracketed) and treat any
ambiguity -- multiple IPv4 or multiple IPv6 addresses on the same
interface -- as an error rather than picking one silently. Log when an
interface name was resolved to an IP so the indirection is visible.
Update the --bind flag help text to reflect the supported inputs.
Add a test covering the pass-through cases (host, IP, empty, unknown
name) and a portable loopback-interface test that skips cleanly when
the loopback isn't in a single-IPv4 configuration.
Refs #264
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
soundtouch.local relied on mDNS resolution, which works on developer
macOS but not in CI/Linux. With the new server_url validation, an
unresolvable hostname now correctly causes DNS to refuse to start --
which flips dnsEnabled to false and made the test fail honestly instead
of passing while DNS was silently broken. Switch the fixture to
127.0.0.1 so the test exercises the DNS-enabled path everywhere.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Refuse to start the DNS server and reject Settings updates whose
server_url does not resolve to a routable IP. Without this, a
misconfigured hostname caused the DNS server to answer every intercepted
Bose hostname with `CNAME .`, leaving speakers unable to reach the
service while everything looked healthy. The Settings page now displays
the resolved intercept IP (or the resolve error) next to "Target
Domain", so misconfigurations are visible up front instead of buried in
the DNS log.
Refs #269
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
On a multi-homed host the discovery layer used to walk net.Interfaces()
and pick the first non-loopback IPv4 NIC, while UPnP/SSDP bound a
wildcard UDP socket and let the kernel route the multicast send. That
meant --bind on soundtouch-web only moved the HTTP listener; the
discovery still went out whatever interface the kernel preferred (often
the wrong one on hosts where the speakers sit behind a secondary NIC).
Introduce a separate DiscoveryInterface knob:
* pkg/config: DiscoveryInterface field + DISCOVERY_INTERFACE env var.
* pkg/discovery/mdns: NewMDNSDiscoveryServiceWithInterface; the
interface resolver now honours an explicit name and validates it
has a usable IPv4 address before handing it to hashicorp/mdns.
* pkg/discovery/upnp: when an interface is configured, bind the UDP
socket's source IP to the NIC's IPv4 and call
ipv4.PacketConn.SetMulticastInterface so M-SEARCH leaves the right
NIC. Without an interface, behaviour is unchanged.
* cmd/soundtouch-web: new --interface flag (DISCOVERY_INTERFACE env)
plumbed into the config before the discovery service is built.
go.mod/go.sum reflect promoting golang.org/x/net from indirect to a
direct dependency (now imported for ipv4.PacketConn).
Refs #264.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The flag's value is concatenated with ":PORT" and passed to
http.ListenAndServe, so it has always been a host/IP. The previous help
text invited users to pass an interface name like "eth0", which then
failed with a confusing DNS lookup error.
Refs #264.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
term.ReadPassword takes an int, but syscall.Stdin is syscall.Handle
(uintptr) on Windows. The explicit cast keeps the call building on
Windows while a //nolint:unconvert silences the false positive on Unix
where syscall.Stdin is already int.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings the five remaining gocyclo > 20 warnings to zero by extracting
cohesive sub-functions; same observable behaviour, smaller surface to
read at each call site. Bonus: the new helpers are individually testable.
- pkg/models/clockdisplay.go: split ClockDisplay.UnmarshalXML attr
handling into applyClockDisplayOuterAttrs (legacy flat shape) and
applyClockConfigAttrs (current nested shape).
- pkg/service/setup/ssh_probe_apply.go: split applyProbeToSummary into
applyProbeCurrentConfig / applyProbeResolvConf /
applyProbeRemoteServices / applyProbeCACert — one helper per
MigrationSummary axis the probe populates.
- pkg/service/setup/init_plan.go: split ExecuteInitPlan into
applyInitPlanDefaults, runURLRewrite, resolveAccountID, and
verifyPairing. Cleans up several shadowed err variables in the
process.
- cmd/soundtouch-cli/cmd_setup.go: split renderInspectReport into
renderInspectIdentityAndPairing / renderInspectNetwork /
renderInspectSources / renderInspectPresets / renderInspectRuntimeURLs,
and buildPlanSteps into resetSteps + migrationSteps helpers.
golangci-lint run ./pkg/service/setup/... ./pkg/models/...
./cmd/soundtouch-cli/... now reports zero findings. Tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Rename SetupStateMachine → setup.StateMachine, SetupSessionConfig →
setup.SessionConfig, SetupSession → setup.Session, and
DialSetupSession → setup.DialSession. The Setup* prefix only stutters
in package context (`setup.SetupSession`); the renamed forms read
cleaner at every call site (revive: exported).
- Iterate r.Network.Interfaces.Interfaces by index in cmd_setup.go
rather than by value — each NetworkInterface is 168 bytes and the
per-iteration copy was unnecessary (gocritic: rangeValCopy).
Test fixtures (fakeSetupSession → fakeSession, TestSetupSession_* →
TestSession_*) renamed by the same substring replacement to keep
naming consistent inside the package.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes the lint hits that pointed at real bugs or dead code; leaves the
remaining style-only suggestions (rangeValCopy micro-copies, gocyclo
informational, intentional name choices like SetupStateMachine) alone.
- pkg/models/clockdisplay.go: restore <clockDisplay> XMLName tag on both
ClockDisplay and ClockDisplayRequest. The earlier `xml:"-"` clashed
with ClockDisplayUpdatedEvent.ClockDisplay's `xml:"clockDisplay"` tag
(SA5008). Custom MarshalXML/UnmarshalXML still own the wire format.
- pkg/service/setup/setup.go: drop the now-unused checkRemoteServices
helper (replaced by applyProbeToSummary) and rename the unused
deviceIP parameter of populatePlannedNetworkConfig to _.
- pkg/service/setup/setup_session.go: collapse sendStep's (string, error)
return to plain error — every caller already discarded the string.
- pkg/service/setup/init_plan.go: rename shadowed err variables to
rwErr / genErr / invalidErr / nilErr / stepErr.
- cmd/soundtouch-cli/cmd_setup.go: drop redundant int(syscall.Stdin)
conversion (already int) and rename a shadowed err to pairErr.
go build ./..., go vet ./..., and tests for the touched packages all
green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add `soundtouch-cli setup` subcommand group covering the full reset →
re-provision → pair lifecycle as a scriptable alternative to the web UI:
inspect, verify, plan, factory-reset, wait-ap, wifi-push, wait-online,
ssh-check, install-ca, migrate, reboot, pair (bare | full state machine)
Supporting library code lives in pkg/service/setup: factory_reset.go,
wifi_provision.go, inspect.go, init_plan.go, setup_session.go.
Confirmed against ST10 firmware 27.0.6 that bare setMargeAccount over
WebSocket — no SETUP_START/SETUP_ENTER/SETUP_LEAVE bracket — is
sufficient to pair a factory-reset speaker; the firmware materializes
SystemConfigurationDB.xml and Sources.xml itself and the pairing
survives reboot. Result and field-by-field SystemConfigurationDB
comparison documented in docs/analysis/SETUP-WEBSOCKET-EXPERIMENT.md.
Captures the device's pre-reset DELETE-to-marge plus its LAN peer
notification flow in docs/analysis/FACTORY-RESET-PROTOCOL.md.
Perf: batch GetMigrationSummary's SSH probes into one Run() call via
ssh_probe.go / ssh_probe_apply.go — was ~8 sequential dials at
500-1000 ms each on FW 27 crypto, now one round-trip. Same data shape,
same MigrationSummary fields populated.
Fixes /clockTime and /clockDisplay wire formats — firmware 27 rejects
the legacy flat XML ("Error parsing request"). ClockTimeRequest now
uses utcTime attribute; ClockDisplayRequest emits the nested
<clockConfig> envelope with timezoneInfo/timeFormat/brightnessLevel.
Removes cmd/example-init-speaker (superseded by setup pair).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>