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>
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>
TestPrintRoutes compares the live router against
testdata/router_routes.txt; the deletion commit (ba69fc0) changed the
route set but didn't regenerate the golden file. Drops
/probe/{token}[/*] and /setup/telnet-probe/{deviceId}; adds
/setup/peer-probe/{deviceId}.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MIGRATION-GUIDE.md step 5 — replaces the "Telnet round-trip probe"
bullet with two honest variants: the new passive observer for
already-migrated speakers, and a skip-row explainer for not-yet-
migrated speakers pointing at the Apply + reboot cycle. The rollback
section drops the obsolete tangent about the probe step leaving
persisted URLs untouched (the probe no longer exists, and the wizard
already writes both layers).
TELNET-MIGRATION-METHOD.md — §9.4's pre-flight table swaps the
deprecated `POST /setup/telnet-probe` row for the new
`POST /setup/peer-probe` row plus a skip-explainer row for the
not-yet-migrated case. §9.5 gains a "REMOVED — see §9.8" header
pointer (the section is kept as historical record of what was
tried). §9.6's backend-additions table replaces the deleted
`probeRegistry` + `RunTelnetRoundTripProbe` + `/setup/telnet-probe`
row with the `peerObserver` + `RunPeerReachabilityProbe` +
`/setup/peer-probe` row that supersedes it.
NEXT.md is local-working-tree only (deliberately untracked) and
gains a ✅ Resolved header pointing at §9.8; not part of this
commit.
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 /setup/migrate/{deviceIP} reference table covered only the legacy
self/proxied/original mode selectors, with a one-line "Custom service
URL" mention of target_url. The wizard has been writing literal
per-field URLs via marge_url / stats_url / sw_update_url / bmx_url
for weeks; external API callers had nothing to read.
Expanded the table into three blocks with precedence rules:
1. Top-level params — method, target_url, proxy_url with the
four migration mechanisms (xml / telnet / resolv, hosts marked
deprecated).
2. Per-field implementation mode — the legacy self/proxied/original
family, kept for API back-compat with a note that the UI no
longer sets them.
3. Per-field literal URL overrides — marge_url / stats_url /
sw_update_url / bmx_url with a "literal wins over mode" rule
and the soundcork-suffix-propagates-to-envswitch note.
Three example curl invocations (canonical XML, soundcork telnet,
resolv with HTTPS) replace the old proxy=original-only snippet up
top.
Also added stub reference entries for POST /setup/telnet-probe and
the internal GET /probe/{token}[/*] catch-all — the SSH-less
reachability check the wizard runs automatically in its pre-flight
panel.
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 guide still described the pre-wizard UI: "SSH status, CA trust
status, and connection test results before letting you apply the
redirect" and two methods (XML / DNS). The migration tab now opens
with the state card + Plan card + Customize three-axis form + visible
pre-flight panel, and a third transport (Telnet:17000) lets users
without SSH access migrate too.
Updates:
- Step 3 retitled "Enable shell access on each speaker" with two
sub-sections: SSH (the richest option, required for XML / DNS /
CA install) and Telnet:17000 (the SSH-less fallback, no setup
required, HTTP-only).
- Step 5 rewritten to walk through the actual UI:
* the state card's three rows (Transports, Migration State,
Preconditions) with the action affordances inline
* the Plan card — target URL with Save as default, per-field
Service URLs editor with validation and soundcork-mode,
account pairing, and Apply Suggested Plan
* the visible pre-flight checks panel with its three or four
checks per method and the Proceed Anyway / Cancel branch
* Customize this migration with three independent axes
- Step 6 mentions the auto-expand of Customize on Apply success
and the per-transport reboot picking.
- Rollback section adds the telnet-only "reboot reverts the
runtime layer if envswitch isn't written" property, plus the
rename to "Revert to Defaults" matching the button label.
The image reference (ui-migration.png) stays pointing at the
existing screenshot; a fresh capture is needed once the wizard is
final but the surrounding prose is now accurate either way.
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>
The feasibility analysis (§§1–8) was written before any of the wizard
shipped, and §7 forecast the surface area roughly. The migration tab
grew considerably during implementation — three-axis state model,
Plan card with per-field URL editor and validation, Customize
three-axis form, visible pre-flight panel, account pairing folded
into the wizard, and the SSH-less round-trip probe — none of which
the original §7 captures faithfully.
Added §9 "What actually shipped (post-implementation addendum)" with:
§9.1 Three-axis state model (per-axis migration booleans, IsPaired,
the state-card layout)
§9.2 Plan card per-field URL editor (single source of URL overrides
for both XML and Telnet, live optimistic preview)
§9.3 Customize three-axis form (URL flip / DNS / CA radios driving
applyCustomPlan)
§9.4 Pre-flight panel (visible check list, decision tree, override
affordances)
§9.5 Telnet round-trip probe (the SSH-less reachability check via
swUpdateUrl flip + :8090/swUpdateCheck trigger + probe-token
registry)
§9.6 Backend additions worth knowing (applyURLOverrides, parser,
option allow-list, telnet timeout bumps)
§9.7 Future probe candidates (pushCustomerSupportInfoToMarge;
running the round-trip probe on SSH-capable speakers too)
§§1–8 stay verbatim as the historical feasibility record, with a
forward-pointer at the head of §7 so readers know the as-shipped
state is documented further down.
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>