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>
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>
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>
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 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>
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>
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>
Implements the speaker-side group API surface (path 1 of the two
approaches gmuth outlined in issue #252): clients form, rename, and
dissolve stereo pairs directly on the device, and the resulting
GroupService.xml persists on disk in the same shape the device emits
over /getGroup.
What landed:
- pkg/models/group.go: Status field + IsEmpty() helper, matching the
GET /getGroup response shape (id-attr, masterDeviceId, roles,
senderIPAddress).
- pkg/client/client.go: GetGroup, AddGroup, UpdateGroup, RemoveGroup.
The endpoint name is /getGroup (not /group, despite some wiki docs)
— confirmed against a real ST-10's /supportedURLs. RemoveGroup uses
GET per the wire spec.
- cmd/soundtouch-cli/cmd_group.go + main.go: new `group` subcommand
with status / create --left --right [--name] / rename / remove,
mirroring gmuth's group.sh recipe.
WebSocket notifications:
- pkg/models/websocket.go: EventTypeGroupUpdated +
GroupUpdatedEvent + dispatch helpers. The device fans this out to
both LEFT and RIGHT speakers on every group mutation, including
empty-group teardowns; the parse test covers both shapes.
- pkg/client/websocket.go: OnGroupUpdated registration and dispatch.
- cmd/soundtouch-cli/cmd_events.go: `group` filter +
handleGroupEvent formatter.
WebSocket observability (came up while validating the above against
a real device):
- New RawMessageHandler type + OnRawMessage hook that fires for every
incoming frame before parsing, with the parse error alongside.
- New --debug flag on `events subscribe` with modes all / unknown /
errors. Raw output goes to stderr so it composes cleanly with
shell redirects.
The pkg/client refactor in this commit also adopts speaker.HTTPPort
(introduced in the previous refactor) — the unexported
defaultSoundTouchPort and three hard-coded 8090 literals are gone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The HTTP port and on-device paths for the SoundTouch speaker were
duplicated across pkg/client (unexported) and pkg/service/constants
(under a service-layer prefix). Both spots needed the same values, and
the next round of work (group/persistence handling in the CLI) would
have created a third — or worse, dragged pkg/service into the CLI's
dependency graph just for a port number.
pkg/speaker is a no-deps leaf that holds the speaker-protocol
constants: HTTPPort, the request paths, and the on-device persistence
file locations (now including GroupServiceFileLocation, for the
upcoming stereo-pair sync work). The client library, the service, the
CLI, and tests can all import it without introducing a layering edge.
This commit moves nothing into pkg/speaker that doesn't belong there —
the service-specific constants (provider IDs, file names, date stub,
etc.) stay in pkg/service/constants. Only the genuinely
protocol-level values move.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Refreshes docs/images/ui-{settings,devices,sync,migration}.png by
driving the web UI in chromedp against a synthetic speaker, so
documentation can be regenerated without real hardware and without
leaking personal data from the local network.
Three independent pieces:
- pkg/service/testing/fakespeaker — embeddable library serving the
HTTP and telnet surface the migration wizard probes (/info,
/presets, /recents and a getpdo CurrentSystemConfiguration reply
that places the device on the unmigrated happy path).
- cmd/dummy-speaker — thin CLI wrapping the library; self-registers
with a running service via POST /setup/devices.
- scripts/screenshots — chromedp runner driven by a JSON manifest;
decoupled from speaker/service setup so it can target any backend
URL. run.sh orchestrates a one-shot end-to-end capture and seeds
settings.json with a generic hostname plus discovery disabled to
keep real-network state out of the captures.
Captures are at DPR=2 for retina-sharp text.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hard-deletes everything marked DEPRECATED in the previous commit:
Files:
- pkg/service/setup/telnet_probe.go
- pkg/service/setup/telnet_probe_test.go
- pkg/service/handlers/handlers_telnet_probe.go
- pkg/service/handlers/probe_registry.go
- pkg/service/handlers/probe_registry_test.go
Edits:
- Server.probes field + initialization (server.go).
- Routes /probe/{token}, /probe/{token}/*, and
/setup/telnet-probe/{deviceId} (main.go).
- checkTelnetRoundTrip() in script.js.
The passive observer (peer_probe.go + handlers_peer_probe.go) is now
the only reachability check for migrated speakers; unmigrated/partial
states surface a skip row pointing at the Apply + reboot cycle, as
documented in TELNET-MIGRATION-METHOD.md §9.8.
isCommandNotFound and parseGetpdoConfig remain — they are used by
telnet_migration, telnet_preflight, marge_pairing, and
preflight_crosscheck.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The swUpdate daemon caches its target URL at boot and ignores live
`sys configuration` writes, so the active flip in
RunTelnetRoundTripProbe never reaches the running daemon — confirmed
empirically on a fully-migrated speaker (FW 27.0.6) where both the
runtime and persistence layers were flipped and the device still
dialed the previously-cached `/updates/soundtouch` URL plus
DNS-intercepted `/streaming/software/update/account/*`. The probe URL
was never observed.
Marks DEPRECATED:
- pkg/service/setup/telnet_probe.go: ProbeRegistrar,
TelnetProbeResult, generateProbeToken, RunTelnetRoundTripProbe.
- pkg/service/handlers/handlers_telnet_probe.go: HandleTelnetProbe,
HandleProbeInbound, telnetProbeTimeout, telnetProbeResponse.
- pkg/service/handlers/probe_registry.go: probeRegistry.
- Server.probes field.
- /probe/{token}[/*] and /setup/telnet-probe/{deviceId} routes.
Adds §9.8 to docs/analysis/TELNET-MIGRATION-METHOD.md documenting the
daemon-cache finding, the diagnostic that confirmed it, the passive
observer replacement, the pre-flight branch on migration state, and
the canonical telnet flow (Apply config → reboot → passive
validation). All code symbols remain in place this commit; the
follow-up commit performs the hard delete.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pre-flight panel's reachability check now picks one of two paths
based on summary.is_migrated:
- Migrated → run the new passive peer-reachability probe
(POST /setup/peer-probe/{deviceId}) and label the row
"Reachability check (passive observer)".
- Not migrated (incl. partial) → render a skip row
"Round-trip validation runs after Apply + reboot" with the
rationale "daemon caches swUpdateUrl at boot". Per-axis state
remains visible in the State card so the user sees which parts
are already in place.
Adds checkPeerReachability() alongside checkTelnetRoundTrip(). The
latter is marked DEPRECATED inline — no longer called by the
orchestrator, scheduled for removal in a follow-up commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RunPeerReachabilityProbe is the post-migration replacement for the
active swUpdateUrl round-trip: register the device IP with the
in-process observer, nudge :8090/swUpdateCheck, and wait for any
inbound from that IP. No device-state mutation. Any inbound counts
as proof — on a migrated speaker, DNS interception routes the
daemon's outbounds through this service regardless of which URL it
resolved internally, so reachability reduces to "did the device
dial us at all."
PeerHit and the abstract observer interface live in setup alongside
the probe logic; handlers.peerObserver implements the interface and
the existing observer files now import from setup.
Route: POST /setup/peer-probe/{deviceId}. Timeout: 30s, surfaced as
result.ElapsedMs so the budget can be tuned from real data. The
pre-flight orchestrator gains the branch in the next commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an in-process observer that records device->service requests by
source IP. PeerObserverMiddleware fires on every inbound after RealIP
trust and Recoverer; the registry exposes Register/Signal/Forget keyed
on the device IP with a buffered one-shot delivery.
No callers yet — this is the substrate for the passive reachability
probe that replaces the broken active swUpdateUrl round-trip on
migrated speakers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The speaker decodes /streaming/account/.../full into a protobuf message where
recents>recent>source>sourceproviderid is a required field. A laut.fm recent
(location "/custom/v1/playback/...") POSTed against an account with no
Sources.xml fell into classifyLearnedSource's default branch, which wrote
sourceKey type="INVALID" with no providerid. That entry then re-appeared
in /full with an empty <sourceproviderid> element, which the post-marshal
strip-empty step deleted entirely — aborting the speaker's account sync
with "MargePB.account.devices.device[N].recents.recent[K].source.sourceproviderid"
missing and forcing a 60-second retry loop.
Three changes, each defended by the new regression test:
* classifyLearnedSource recognises LocalInternetRadio via sourceProviderID
== 11 and via the /custom/v1/playback/ URL pattern, and stops writing the
"INVALID" sentinel that locked sources out of every read-side repair path.
* mapToFullResponseSource falls back to the canonical SourceProviderID
keyed by source ID (10002/10003/10004/10005) so already-poisoned data
on disk still renders a non-empty providerid at /full time, with no
manual data scrub required.
* AccountFullToXML no longer strips empty <sourceproviderid> elements.
The strip-empty was added for parity with upstream's standalone <sources>
block, but it's wrong inside recents/preset source blocks where the field
is protobuf-required.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
"Test first, decide later" affordance: the same check sequence Apply
runs is now reachable without committing to the migration. Useful
for spot-checking a speaker after editing URLs, or for verifying a
fresh device is reachable before the user commits to writing
anything.
Two buttons, one per Apply path:
- #plan-preflight-btn (Suggested Plan side) — reads the chosen
method from plan-apply-btn.dataset.method, same source the
real Apply uses, so what's tested matches what would be
applied.
- #customize-preflight-btn (Custom Plan side) — walks the same
radio choices applyCustomPlan reads and builds the same
methods array, then runs the checks against it.
Both share the existing pre-flight panel and runApplyPreflight
orchestrator. New renderPreflightPreviewSummary terminates the
panel with a single Close button instead of Proceed Anyway /
Cancel — there's nothing to proceed to in preview mode.
Both Pre-flight buttons share the disabled-state gate of their
Apply counterparts (no plan / invalid URLs disables both) so users
can't accidentally pre-flight a plan that wouldn't apply.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the SSH-capable branch and the telnet-only branch were
mutually exclusive — speakers with both transports reachable only
got the curl-from-device HTTPS check, never the round-trip probe.
That left a class of bugs invisible to pre-flight: an asymmetric
network path where the speaker's userspace can reach our service
(curl works) but the swUpdateUrl fan-out can't (or vice versa).
Each transport now gets its own check; both run when both are
reachable. The two exercise meaningfully different code paths in
the speaker:
- SSH curl-from-device: speaker's normal userspace HTTP stack
over an arbitrary inbound TCP to our HTTP/HTTPS port.
- Telnet round-trip: speaker's firmware-internal swUpdateCheck
fan-out, which writes to its own DNS resolver and outbound
HTTP code path that the curl test doesn't go near.
A speaker that passes one and fails the other reveals a real
connectivity asymmetry worth surfacing before the migration
writes its target URLs.
Cost: ~1s extra on the success path (probe is fast on healthy FW
27.0.6), up to ~6s extra on the timeout path. The probe restores
the runtime swUpdateUrl unconditionally so there's no lingering
state regardless of outcome.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The landing-tab overview still framed SSH as a hard prerequisite —
"Migration requires SSH access." That was true under the original
design, but the wizard now probes both SSH and Telnet:17000
automatically and uses whichever the device exposes. SSH-less
speakers (USB-unlock-refusing firmware like SA-5, ST520, recent ST
Portables) can migrate over telnet without ever opening a shell.
Updates:
- Prerequisite box retitled "Speaker shell access" with two
sub-bullets that match the state card's Transports row:
* SSH — richest option, required for XML / DNS / CA install,
same USB-stick procedure as before
* Telnet:17000 — SSH-less fallback, no setup, HTTP-only
- Step 1 (Settings) now mentions that Target URL can be edited
inline on the Migration tab with Save as default, since the
Settings tab is no longer the only place to set it.
- Step 4 (Migration) replaces "we recommend the XML Configuration
method" with a description of the actual wizard: Apply
Suggested Plan, Customize three-axis form, and the visible
pre-flight check.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pre-flight connection check always hit summary.server_https_url
(the HTTPS health endpoint), regardless of what URL the migration
would actually write to the speaker. That gave a useful baseline
("can the device reach our service over HTTPS at all?") but didn't
test the right thing for HTTP-target migrations — the dominant
configuration when SSH is available and the user goes with the
Suggested Plan's XML+HTTP default.
preflightConnectionTestURL now picks the test URL by intent:
- methods.includes("resolv") → server_https_url. DNS interception
leaves the device hitting https://*.bose.com (firmware-hardcoded
scheme) which DNS redirects to our HTTPS endpoint; testing the
health URL is the right shape.
- URL-flip methods (xml / telnet) → derived from the user's
targetUrl: scheme + host + "/health". HTTP-target migrations get
an HTTP test, HTTPS-target migrations get an HTTPS test (still
with use_explicit_ca=true so the trust path is forward-looking
when CA install is part of the plan).
- Fallback to server_https_url when targetUrl can't be parsed, so
older call shapes keep working.
The row label is now dynamic: "HTTPS connection from device" or
"HTTP connection from device" depending on the actual test scheme,
so the panel tells the user which path is being exercised.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The /etc/hosts migration method has been hidden from the UI since
before the wizard refactor — the Customize three-axis form doesn't
expose it, the suggested-plan engine never picks it, and
onCustomizeChange explicitly force-hides the legacy
#hosts-redirection-test pane. The pane was sitting in the DOM doing
nothing.
Removed:
- The hosts-redirection-test <div> (button, result pane, header)
- test-hosts-btn.onclick wiring in showSummary
- The testHostsRedirection() function (orphaned once the button is
gone)
- The show("hosts-redirection-test", false) toggle in
onCustomizeChange (orphaned once the pane is gone)
Backend untouched:
- /setup/test-hosts/{deviceId} and HandleTestHostsRedirection still
exist for API back-compat. Same pattern we used when retiring the
XML method's self/proxied/original dropdowns — only the UI
surface moves; the manager-level entry points stay.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two lint findings flagged by golangci-lint:
- telnet_probe.go:90 — t.Dial()'s local err shadowed the outer
url.Parse error (govet shadow). Renamed the inner one to
dialErr.
- migration_summary_telnet_test.go:20 — telnetSummaryEnv didn't
call t.Helper(), so test failures pointed at the helper rather
than the calling test (thelper). Now mirrors the t.Helper() in
telnetSummaryEnvWithInfo.
No behaviour change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Telnet method pane still said "After a successful migration a
Pair Account panel will appear below this one" — but pair-account-pane
was removed three commits ago when pairing was folded into the Plan
card as a configured-up-front step that runs as part of Apply. The
note pointed users at a panel that no longer exists.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the placeholder "skip — telnet round-trip probe not yet
implemented" branch with an actual call to POST /setup/telnet-probe
when SSH is unreachable but Telnet:17000 is. SSH-less speakers now
get real reachability verification before any migration step runs,
instead of being silently ignored by the pre-flight pipeline.
Decision tree for the reachability check:
- SSH reachable → HTTPS connection test from device (existing)
- Telnet:17000 only → Telnet round-trip probe (new)
- neither → skip with "no transport reachable" message
The probe row reports its result inline with the existing pre-flight
panel idiom (🕐 / ⟳ / ✅ / ❌), surfacing elapsed_ms on success so
users see how long the round-trip took. Failure messages from the
backend (timeout, sys configuration rejected, dial refused) propagate
verbatim so the user knows which step of the orchestration tripped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fills the SSH-less gap the curl-from-device test leaves in the
pre-flight panel: instead of skipping connectivity verification on
USB-unlock-refusing speakers, we drive a round-trip from the device
itself using only telnet:17000 and the device's own :8090 API.
Sequence (Manager.RunTelnetRoundTripProbe):
1. telnet `getpdo CurrentSystemConfiguration` — capture the
speaker's current swUpdateUrl so we can restore it.
2. Generate a random hex token; register a one-shot signal
channel under it via the new probeRegistry on Server.
3. telnet `sys configuration swUpdateUrl <targetURL>/probe/<token>`
— runtime layer only, no envswitch boseurls set, so the
persistence layer keeps the original and a reboot heals the
device naturally if our restore step fails.
4. HTTP GET :8090/swUpdateCheck — the cleanest :8090 endpoint
that triggers exactly one outbound to the configured
swUpdateUrl. Read-only on the cloud side, doesn't depend on
margeAccountUUID, doesn't start an actual update.
5. Wait on the registered channel up to telnetProbeTimeout (6s).
6. telnet `sys configuration swUpdateUrl <original>` — restore
in a deferred call so it runs even on the failure path.
New /probe/{token}[/*] catch-all on the root router signals the
matching channel when the speaker's outbound lands; the response is
a minimal `<swUpdateIndex/>` so the device's swUpdateCheck doesn't
choke on a missing structure. The {token}/* sub-path is registered
because some firmware appends a path component to the configured
swUpdateUrl.
POST /setup/telnet-probe/{deviceId}?target_url=… exposes the
orchestrator as a single REST call returning {ok, result: {reached,
restored, original_url, probe_url, elapsed_ms, logs}, error?}.
Tests cover: happy path with channel signalled by the fake registrar
when the :8090 trigger fires, timeout when no inbound arrives,
abort when getpdo doesn't expose swUpdateUrl, abort when the
firmware rejects sys configuration, dial failure, invalid target URL.
Frontend wiring (visible pre-flight panel) lands in the next
commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pairing was previously its own post-telnet pop-up pane —
loadAccountIDSuggestions(deviceId) was called only after a successful
telnet migration, leaving the user to interact with a separate panel
and click a separate "Pair Account" button. XML migrations didn't
surface pairing at all.
The Plan card now has its own Account pairing section between Service
URLs and Suggested plan, with the same affordances (current state,
7-digit input, Generate button, datastore picker) but always
visible. The implicit intent — read by readPlanPairTarget — is:
- empty input + currently paired → no pairing step (current ID kept)
- empty input + currently unpaired → no pairing step (warning hint visible)
- input matches summary.account_id → no pairing step
- input is exactly 7 digits, differs → pair step queued at Apply
- input is non-empty but malformed → blocks Apply with a clear error
Both Apply orchestrators (applySuggestedPlan, applyCustomPlan) now
queue a `pairAccount(deviceId, accountId)` call when the intent says
to. It runs *after* the URL flip / DNS / CA steps so the user sees
the migration succeed before pairing — pairing is independent of
the migration target so order is purely UX. First-failure-aborts is
preserved: a pair-account error stops the rest of the sequence.
Removed:
- #pair-account-pane HTML and all its descendants
- loadAccountIDSuggestions / generateAccountID / pairAccount(deviceId)
(the old pane-bound functions)
- the "if method === telnet → loadAccountIDSuggestions" trigger in migrate()
Added:
- renderPlanPairing(summary, deviceId) — populates the section on
every showSummary
- loadPlanAccountSuggestions(deviceId) — fetches /setup/account-id-
suggestions; gracefully degrades on failure
- onPlanPairIDChange / onPlanPairPick / generatePlanAccountID — UI
handlers with implicit-intent status hints
- readPlanPairTarget — orchestrator-facing intent extractor
- pairAccount(deviceId, accountId) — POSTs and throws on failure
(replaces the old pane-bound function with a step-friendly shape)
- resetPlanCardForDeviceSwitch clears the pairing input on speaker
change so the previous device's ID can't leak
Backend untouched — all the pairing endpoints (/setup/account-id-
suggestions, /setup/pair-account) and the setup.PairAccount + telnet-
fallback logic stay exactly as-is.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the silent confirm()-dialog pre-flight with an inline panel
that pops up the moment Apply is clicked, walks through each
applicable check live, and surfaces the result before any backend
operation touches the speaker.
Three checks run in order:
1. Backend summary re-check (always) — the existing
runPreflightCheck logic, repackaged as the first row in the
panel. Catches transport/resolve_ip drift since the cached
summary loaded.
2. HTTPS connection from the device (when SSH is reachable) —
reuses /setup/test-connection with use_explicit_ca=true so the
test exercises the trust path even when CA install is part of
the plan. Identical to the manual "Test with Explicit CA.crt"
button under HTTPS Connection Test, but runs without requiring
the user to click it. SSH-less devices show a "skip" row with
a note pointing at the future telnet round-trip probe.
3. DNS redirection from the device (only when resolv is in the
plan and SSH is reachable) — reuses /setup/test-dns. Same
parity as #2 with the manual "Test DNS Redirection" button.
UX:
- Each check renders with 🕐 pending → ⟳ running → ✅ ok / ❌
fail / — skipped, so the user sees feedback while the backend
works.
- On all green: a 700ms hold lets the success state register, then
Apply auto-proceeds.
- On any red: a "Proceed Anyway" / "Cancel" pair appears; default
is to abort, but the user can override on a known false-positive.
Both Apply paths (applySuggestedPlan and applyCustomPlan) now share
runApplyPreflight and awaitPreflightDecision; the unused
confirmPreflightIssues helper is removed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Plan-card preview is now optimistic and renders client-side on
every keystroke (previous commit), so the view can drift from what
the backend would actually do — at least until the next summary
fetch. Runtime state can also drift between the cached summary the
user is looking at and the moment they click Apply (a transport
goes down, DNS hostname stops resolving, etc).
Adds runPreflightCheck which both Apply paths call once before
kicking off any backend operation:
- applySuggestedPlan calls it with the single chosen method.
- applyCustomPlan calls it with the full list of operations the
sequence will run (flip method, optional resolv, optional
trust-ca) so the SSH/Telnet reachability requirement is checked
against the actual fresh summary, not the stale cached one.
The check covers four classes of inconsistency:
- resolve_ip_error from the device's perspective
- SSH reachable when xml / resolv / trust-ca is queued
- Telnet:17000 reachable when telnet is queued
- The backend's planned_config XML contains every per-field URL
override we're about to send (sanity check that the client's
optimistic preview agrees with the server's render before we
write to the speaker)
On any issue, confirmPreflightIssues shows them in a confirm()
dialog so the user can override on a known-false-positive (slow
DNS, etc.) but the default is to abort.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related fixes for the Plan-card → Customize-pane preview flow:
1. Live planned-XML preview. The Customize panel's "Planned Config
(AfterTouch)" pane previously showed summary.planned_config —
server-rendered, only updated on the next showSummary fetch. So
editing a URL field in the Plan card had no visible effect on the
preview until the user manually refreshed. The new
renderPlannedXMLPreview composes the same XML client-side from
plan-target-url + the four override inputs, mirroring exactly what
migrateViaXML writes (target-derived defaults + applyURLOverrides),
and is called from validatePlanURLs which already runs on every
keystroke.
2. Per-device form-state isolation on speaker switch. The Plan card
inputs preserve manual edits across summary refreshes (force=false)
so a user's typed URL doesn't get clobbered by a re-fetch. That
semantic is right within one device but wrong across devices: if
the user edited a URL on speaker A and then picked speaker B in
the dropdown, A's value silently appeared in B's preview.
showSummary now compares the previous summary-device-id to the new
one and, on change, calls resetPlanCardForDeviceSwitch to clear
the four URL inputs, the Soundcork checkbox, the "saved" hint
dataset, the URL-validation banner, and both apply-status lines.
The downstream fillPlanURLInputs(defaults, force=false) then fills
the now-empty inputs with the new device's canonical defaults.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
With XML+resolv selected together, the bottom panes rendered as
"Current XML | Planned XML | Planned resolv hook" plus a separate
full-width "Current /etc/resolv.conf" block above — three panes plus
a hanger above, each pair scattered.
Restructured into two side-by-side .diff-container rows that each
pair their own Current/Planned columns:
- #xml-diff-row — Current Config (on Speaker) | Planned Config (AfterTouch)
- #resolv-diff-row — Current /etc/resolv.conf | Planned /etc/resolv.conf Hook
current-resolv-pane moved out of its standalone wrapper into the
resolv row. The deprecated #planned-hosts-pane is removed entirely
(hosts is no longer offered as a method, per the earlier UI cleanup).
onCustomizeChange now toggles the row IDs instead of per-pane IDs,
and uses display:"" rather than display:"block" so the .diff-container
flex layout isn't accidentally overridden.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the migration-method dropdown and its toggleMigrationMethod
visibility logic with a unified three-axis form inside the Customize
details:
- URL flip transport: XML over SSH / Telnet (Port 17000) / Skip
- DNS interception: None / /etc/resolv.conf hook
- Local CA install: checkbox (SSH-only)
Each radio/checkbox has a transport-availability hint next to it
(e.g. "(SSH unreachable)" or "(already trusted)") so users see *why*
an option is disabled before they pick. renderCustomizeForm runs on
every summary load to recompute these hints and pick a valid initial
selection when the previous default isn't reachable.
applyCustomPlan orchestrates the chosen combination as a sequence of
existing backend calls:
- URL flip != none → POST /setup/migrate?method={xml,telnet}
- DNS = resolv → POST /setup/migrate?method=resolv
(already includes the CA install, so an explicit
CA step is skipped in that case)
- CA install only → POST /setup/trust-ca
Steps run in order; the first failure aborts the rest. After the
sequence completes, refreshSummary repopulates the state card.
migrate() now takes the method as an explicit parameter instead of
reading it from the dropdown; applySuggestedPlan and applyCustomPlan
both pass it directly. The legacy "Confirm Migration" button is
removed (Apply Custom Plan supersedes it). The reboot-method picker
now reads the URL flip radio rather than the dropdown.
The legacy per-method preview/test panes (xml-diff, planned-xml,
planned-resolv, current-resolv, dns-redirection-test) become
visibility-driven by the radio choices via onCustomizeChange instead
of the dropdown's toggleMigrationMethod (now removed). The hosts-
related panes are forced hidden — hosts is the deprecated method.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Plan card's per-field URL editor now drives both XML and Telnet
migrations via the same marge_url / stats_url / sw_update_url / bmx_url
options, so the two duplicate places that used to set those values are
gone:
- The XML method's "Service Implementations" table (#service-options)
with its self/proxied/original dropdowns. The legacy options keys
(marge / stats / sw_update / bmx) stay accepted by the backend's
applyProxyOptions for any direct API user, but the UI no longer
sets them.
- The "URL Targets" sub-pane inside #telnet-method-pane with its
parallel set of telnet-marge-url / etc. inputs and its own
Reset-to-defaults button. The Telnet pane retains its
explanatory header and limitations note (no CA install, pairing
panel below) — only the duplicate URL editor is gone.
Stripped the now-dead JS:
- showSummary's #service-options visibility toggle and
parsed_current_config-driven population of orig-marge etc.
- showSummary's reads of opt-marge / opt-stats / opt-sw_update /
opt-bmx in the summary query string.
- migrate's reads of those same fields in the migrate query string.
- fillTelnetURLInputs / readTelnetURLOptions /
resetTelnetURLsToDefaults / defaultTelnetURLs entirely.
- renderTelnetPreflight entirely (its writes were all into the
removed elements; the state card and Plan card now own all the
surfaces it used to populate).
- toggleMigrationMethod's serviceOptions branches.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a Service URLs section to the Plan card with four free-form URL
inputs (margeServerUrl, statsServerUrl, swUpdateUrl, bmxRegistryUrl), a
"Current on Device" column populated from telnet getpdo (falling back
to the SSH-read XML config), a Soundcork-mode checkbox that flips the
/marge suffix on margeServerUrl, and a Reset-to-defaults button.
Validation runs on every keystroke (oninput) and on each summary
render: each URL must parse via the URL constructor, the scheme must
be http or https, the hostname must be non-empty, and "localhost" or
"127.0.0.1" are explicitly rejected (the speaker can't reach this
machine via that name). Invalid inputs get a red border, an inline
error list surfaces under the table, and the Apply Suggested Plan
button is disabled until everything is valid. migrate() also gates on
validatePlanURLs() and surfaces a clear status message rather than
sending typoed URLs that would silently brick the speaker.
The Plan card's per-field URLs feed both XML and Telnet migrations
via the marge_url / stats_url / sw_update_url / bmx_url options the
backend's applyURLOverrides honors. The legacy XML dropdowns
(self/proxied/original) and the duplicate URL Targets table inside
the Telnet pane stay in the markup for now — the next iteration
removes them once we're confident the Plan card flow covers
everything.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>