Compare commits

...
9 Commits
Author SHA1 Message Date
Tobias GesellchenandClaude Opus 4.7 bb71253690 feat(screenshots): add headless-Chrome capture pipeline with fake speaker
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>
2026-05-11 20:37:23 +02:00
Tobias GesellchenandClaude Opus 4.7 0e8ab1cd89 test(service): update routes snapshot after round-trip probe removal
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>
2026-05-11 20:31:24 +02:00
Tobias GesellchenandClaude Opus 4.7 952200ee26 docs: align migration guide and analysis with simplified pre-flight
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>
2026-05-11 20:31:24 +02:00
Tobias GesellchenandClaude Opus 4.7 62dd53777d remove(service): delete deprecated telnet round-trip probe
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>
2026-05-11 20:31:24 +02:00
Tobias GesellchenandClaude Opus 4.7 f0de4864b6 deprecate(service): mark active telnet round-trip probe for removal
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>
2026-05-11 20:31:24 +02:00
Tobias GesellchenandClaude Opus 4.7 9a7646bf58 feat(web): branch pre-flight on migration state
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>
2026-05-11 20:31:24 +02:00
Tobias GesellchenandClaude Opus 4.7 d74bb9b5ca feat(service): add passive peer-reachability probe handler
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>
2026-05-11 20:31:24 +02:00
Tobias GesellchenandClaude Opus 4.7 dc924e351c feat(service): add peer observer registry and middleware
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>
2026-05-11 20:31:24 +02:00
dependabot[bot] fa2883f66b deps(deps): Bump the golang group with 2 updates
Bumps the golang group with 2 updates: [golang.org/x/net](https://github.com/golang/net) and [golang.org/x/tools](https://github.com/golang/tools).


Updates `golang.org/x/net` from 0.53.0 to 0.54.0
- [Commits](https://github.com/golang/net/compare/v0.53.0...v0.54.0)

Updates `golang.org/x/tools` from 0.44.0 to 0.45.0
- [Release notes](https://github.com/golang/tools/releases)
- [Commits](https://github.com/golang/tools/compare/v0.44.0...v0.45.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.54.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/tools
  dependency-version: 0.45.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-11 15:52:45 +02:00
36 changed files with 1598 additions and 741 deletions
+2
View File
@@ -16,12 +16,14 @@ dist/
/soundtouch-cli
/soundtouch-service
/soundtouch-web
/dummy-speaker
/example-mdns
/example-upnp
/example-unified
/mdns-scanner
/websocket-demo
/main
/screenshots
# Environment configuration
.env
+6 -1
View File
@@ -1,4 +1,4 @@
.PHONY: all build build-cli test test-coverage check fmt vet lint clean dev help
.PHONY: all build build-cli test test-coverage check fmt vet lint clean dev help screenshots
# Go parameters
GOCMD=go
@@ -336,6 +336,10 @@ docker-run-ports:
@echo "Running Docker container with port mapping (discovery will be manual)..."
docker run --rm -it -p 8000:8000 -v $$(pwd)/data:/app/data soundtouch-service
screenshots:
@echo "Capturing documentation screenshots..."
@bash scripts/screenshots/run.sh
help:
@echo "Available targets:"
@echo " build - Build the CLI tool, service, and examples"
@@ -356,6 +360,7 @@ help:
@echo " dev - Build and show CLI help"
@echo " dev-service - Build and run service locally"
@echo " dev-service-proxy - Build and run service with proxy (PROXY_URL=url required)"
@echo " screenshots - Capture documentation screenshots (headless Chrome via chromedp)"
@echo " dev-discover - Build and run device discovery"
@echo " dev-info - Build and get device info (HOST=ip required)"
@echo " dev-mdns - Build and run mDNS discovery example"
+107
View File
@@ -0,0 +1,107 @@
// Command dummy-speaker runs an HTTP-only fake SoundTouch speaker and
// optionally registers it with a running soundtouch-service so the web UI
// has a device to display.
//
// Intended for documentation screenshots and local UI smoke checks. Do not
// use against a real network — the fixture payload is synthetic and would
// confuse other tooling that expects live device data.
//
// Example:
//
// dummy-speaker --port 8090 --register http://localhost:8000
package main
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/testing/fakespeaker"
)
func main() {
listen := flag.String("listen", "127.0.0.1:8090", "bind address for the fake speaker's HTTP API")
telnetListen := flag.String("telnet-listen", "127.0.0.1:17000", "bind address for the fake speaker's telnet diagnostic shell (empty to disable)")
register := flag.String("register", "", "service base URL (e.g. http://localhost:8000) to self-register with via POST /setup/devices")
registerAs := flag.String("register-as", "", "address to send to /setup/devices (defaults to --listen)")
flag.Parse()
s, err := fakespeaker.Start(fakespeaker.Config{
HTTPListen: *listen,
TelnetListen: *telnetListen,
})
if err != nil {
log.Fatalf("start fake speaker: %v", err)
}
log.Printf("fake speaker HTTP listening on http://%s", s.HTTPAddr())
if addr := s.TelnetAddr(); addr != "" {
log.Printf("fake speaker telnet listening on tcp://%s", addr)
}
if *register != "" {
target := *registerAs
if target == "" {
target = s.HTTPAddr()
}
if err := registerWithService(*register, target); err != nil {
log.Printf("self-register failed: %v (continuing anyway)", err)
} else {
log.Printf("registered %s with service at %s", target, *register)
}
}
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
log.Printf("shutting down")
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := s.Stop(ctx); err != nil {
log.Printf("stop: %v", err)
}
}
func registerWithService(serviceURL, deviceAddr string) error {
body, err := json.Marshal(map[string]string{"ip": deviceAddr})
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, serviceURL+"/setup/devices", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 300 {
return fmt.Errorf("service responded %s", resp.Status)
}
return nil
}
+7 -7
View File
@@ -865,18 +865,19 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Use(server.SnapshotMiddleware)
r.Use(server.OriginMiddleware)
r.Use(middleware.Recoverer)
r.Use(server.PeerObserverMiddleware)
r.Use(server.ShortcutMiddleware)
r.Use(server.MirrorMiddleware)
r.Use(server.RecordMiddleware)
r.Get("/", server.HandleRoot)
r.Get("/health", server.HandleHealth)
// Telnet round-trip probe inbound. The orchestrator temporarily
// sets the speaker's swUpdateUrl to /probe/{token}; the speaker
// then fans out a request that we observe here. Catch-all suffix
// because firmware may append path components (e.g. /index.xml).
r.Get("/probe/{token}", server.HandleProbeInbound)
r.Get("/probe/{token}/*", server.HandleProbeInbound)
// Passive peer-reachability probe. Registers a device IP with the
// in-process observer, nudges :8090/swUpdateCheck, and waits for
// any inbound from that IP. Used post-migration where the daemon
// caches its swUpdateUrl at boot and the active round-trip can't
// reach it without a reboot.
r.Post("/setup/peer-probe/{deviceId}", server.HandlePeerProbe)
r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
r.URL.Path = "/media/favicon-braille.svg"
server.HandleMedia()(w, r)
@@ -1096,7 +1097,6 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Post("/test-connection/{deviceId}", server.HandleTestConnection)
r.Post("/test-hosts/{deviceId}", server.HandleTestHostsRedirection)
r.Post("/test-dns/{deviceId}", server.HandleTestDNSRedirection)
r.Post("/telnet-probe/{deviceId}", server.HandleTelnetProbe)
r.Get("/ca.crt", server.HandleGetCACert)
r.Get("/proxy-settings", server.HandleGetProxySettings)
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
+1 -3
View File
@@ -47,8 +47,6 @@ GET /mgmt/spotify/accounts handlers.(
GET /mgmt/spotify/callback handlers.(*Server).HandleMgmtSpotifyCallback-fm
GET /mgmt/spotify/token handlers.(*Server).HandleMgmtSpotifyToken-fm
GET /oauth/* handlers.(*Server).HandleBoseProxy-fm
GET /probe/{token} handlers.(*Server).HandleProbeInbound-fm
GET /probe/{token}/* handlers.(*Server).HandleProbeInbound-fm
GET /proxy/* handlers.(*Server).HandleProxyRequest-fm
GET /setup/account-id-suggestions/{deviceId} handlers.(*Server).HandleAccountIDSuggestions-fm
GET /setup/ca.crt handlers.(*Server).HandleGetCACert-fm
@@ -125,13 +123,13 @@ POST /setup/discover handlers.(
POST /setup/ensure-remote-services/{deviceId} handlers.(*Server).HandleEnsureRemoteServices-fm
POST /setup/migrate/{deviceId} handlers.(*Server).HandleMigrateDevice-fm
POST /setup/pair-account/{deviceId} handlers.(*Server).HandlePairAccount-fm
POST /setup/peer-probe/{deviceId} handlers.(*Server).HandlePeerProbe-fm
POST /setup/proxy-settings handlers.(*Server).HandleUpdateProxySettings-fm
POST /setup/reboot/{deviceId} handlers.(*Server).HandleRebootDevice-fm
POST /setup/remove-remote-services/{deviceId} handlers.(*Server).HandleRemoveRemoteServices-fm
POST /setup/revert/{deviceId} handlers.(*Server).HandleRevertMigration-fm
POST /setup/settings handlers.(*Server).HandleUpdateSettings-fm
POST /setup/sync/{deviceId} handlers.(*Server).HandleInitialSync-fm
POST /setup/telnet-probe/{deviceId} handlers.(*Server).HandleTelnetProbe-fm
POST /setup/test-connection/{deviceId} handlers.(*Server).HandleTestConnection-fm
POST /setup/test-dns/{deviceId} handlers.(*Server).HandleTestDNSRedirection-fm
POST /setup/test-hosts/{deviceId} handlers.(*Server).HandleTestHostsRedirection-fm
+110 -8
View File
@@ -561,12 +561,13 @@ abort.
Checks:
| Check | When | Backend route |
|-------------------------------|-----------------------------------------------|--------------------------------|
| Backend summary re-check | always | `GET /setup/summary` |
| HTTPS connection from device | `ssh_success && server_https_url` | `POST /setup/test-connection` |
| Telnet round-trip probe | `!ssh_success && telnet_reachable` (see §9.5) | `POST /setup/telnet-probe` |
| DNS redirection from device | `methods.includes("resolv") && ssh_success` | `POST /setup/test-dns` |
| Check | When | Backend route |
|---------------------------------------|------------------------------------------------------------|--------------------------------|
| Backend summary re-check | always | `GET /setup/summary` |
| HTTPS connection from device | `ssh_success && server_https_url` | `POST /setup/test-connection` |
| Reachability check (passive observer) | `telnet_reachable && is_migrated` (see §9.8) | `POST /setup/peer-probe` |
| Round-trip skip explainer | `telnet_reachable && !is_migrated` — runs after reboot | _none_ (UI-side skip row) |
| DNS redirection from device | `methods.includes("resolv") && ssh_success` | `POST /setup/test-dns` |
The HTTPS check uses `use_explicit_ca=true` so it exercises the trust
path even when CA install is part of the plan (i.e. forward-looking).
@@ -576,6 +577,12 @@ is reachable") rather than silently dropped, per the user's
### 9.5 Telnet round-trip probe — the SSH-less reachability check
> **REMOVED — see §9.8.** Empirical testing showed the swUpdate
> daemon caches its target URL at boot and ignores live config
> writes, so the active flip described below could never reach the
> running daemon. The section is retained as a historical record of
> what was tried; the running code uses the passive observer in §9.8.
The reachability gap §7 left open for USB-unlock-refusing speakers is
closed by `Manager.RunTelnetRoundTripProbe`
(`pkg/service/setup/telnet_probe.go`). Sequence:
@@ -613,7 +620,7 @@ configured `swUpdateUrl`.
| `telnetURLsFromOptions(targetURL, options)` | `pkg/service/setup/telnet_migration.go` | Same option family as above, plus envswitch arg derivation rule (arg1 = final Marge verbatim; the soundcork-suffix case drops out). |
| Per-axis booleans + `IsPaired` + `Warnings` | `MigrationSummary` | Surfaces partial-state cells and SSH-XML ⇄ telnet-getpdo cross-check disagreements. |
| `parseGetpdoConfig` | `pkg/service/setup/preflight_crosscheck.go` | Parses the Protobuf-text-like nested-block reply (`key { text: "..." }`) FW 27.0.6 actually sends, plus the legacy `key=value` shape as a tolerance path. |
| `probeRegistry` + `RunTelnetRoundTripProbe` + `/setup/telnet-probe` | `pkg/service/handlers` / `pkg/service/setup` | §9.5. |
| `peerObserver` + `RunPeerReachabilityProbe` + `/setup/peer-probe` | `pkg/service/handlers` / `pkg/service/setup` | §9.8. Replaces the removed `probeRegistry` + `RunTelnetRoundTripProbe` + `/setup/telnet-probe` from §9.5. |
| `migrationOptionKeys` allow-list | `pkg/service/handlers/migration_options.go` | Unknown query keys never reach the manager. Both XML mode keys and `*_url` keys are recognised. |
| Telnet client default timeouts: dial 4s, read 7s, write 3s, idle 600ms | `pkg/telnet/telnet.go` | Bumped from the original 2s/5s/2s/400ms after observing transient i/o-timeout flakes on healthy speakers that recovered on retry. |
@@ -625,4 +632,99 @@ configured `swUpdateUrl`.
implemented.
- Running the round-trip probe on SSH-capable speakers too (as
additional validation alongside the curl-from-device HTTPS test),
not just as the SSH-less fallback it is today.
not just as the SSH-less fallback it is today. **Subsumed by §9.8
— the round-trip probe is being removed; the passive observer is
transport-agnostic and replaces it for migrated speakers.**
### 9.8 The swUpdate daemon-cache finding and removal of §9.5
The §9.5 round-trip probe was retired after empirical testing on a
fully-migrated speaker (FW 27.0.6) revealed that the `swUpdate`
daemon **caches its target URL at boot and ignores live config
writes**. The diagnostic sequence:
1. Manual telnet flip of both layers — `sys configuration swUpdateUrl
<probe-url>` (runtime) **and** `envswitch boseurls set <marge>
<probe-url>` (persistence). `getpdo CurrentSystemConfiguration`
confirmed both writes stuck.
2. HTTP GET `:8090/swUpdateCheck` to trigger fan-out.
3. Service access log showed the device outbound landed on
`/updates/soundtouch` (the **previous** `swUpdateUrl` value, current
at the last daemon boot) and `/streaming/software/update/account/<id>`
(a separate Bose URL the daemon hits, routed to this service by DNS
interception). The probe URL was never dialed.
This falsifies the original NEXT.md hypothesis that the persistence
layer would override the runtime layer for the daemon's fan-out, and
points instead at daemon-level URL caching. Two consequences:
- **The §9.5 probe cannot work on migrated speakers without a
reboot.** The cached URL is set when the daemon starts; flipping
config after that point has no effect on what the daemon dials.
- **The §9.5 probe likely cannot work on unmigrated speakers
either**, for the same reason — the daemon caches whatever URL it
read at startup, which on an unmigrated speaker is the Bose cloud
URL. We have no service running with the probe URL registered on
unmigrated speakers, so the original "it worked in testing" claim
has no empirical basis; it likely failed silently because nothing
was watching.
The honest replacement is a **passive observer** (see
`pkg/service/setup/peer_probe.go`):
1. Register the device IP with an in-process observer
(`handlers.peerObserver`, wired via `PeerObserverMiddleware`).
2. Nudge `:8090/swUpdateCheck` to make the daemon fan out *something*
sooner than its ~5min timer.
3. Wait up to 30s for any inbound from that IP. On a migrated
speaker, DNS interception means the daemon's outbounds (update
fan-out, marge polls, BMX registry calls) all funnel through this
service regardless of which URL the daemon resolved internally —
so reachability reduces to *"did the device dial us at all."*
Endpoint: `POST /setup/peer-probe/{deviceId}`. No device-state
mutation; safe to re-run. Returns `{ok, result: {reached,
observed_path, elapsed_ms}, error}` with the same UI keying as the
old probe (`result.reached`).
#### 9.8.1 The pre-flight panel branch
The web UI's pre-flight orchestrator (`runApplyPreflight` in
`script.js`) branches on `summary.is_migrated`:
| Migration state | Reachability row |
|-----------------------------------|------------------------------------------------------------------------------------------------------------------------------|
| Migrated (`is_migrated=true`) | "Reachability check (passive observer)" — calls `POST /setup/peer-probe/{deviceId}`. |
| Not migrated (incl. partial) | Skip row "Round-trip validation runs after Apply + reboot" with the rationale "daemon caches swUpdateUrl at boot". |
Per-axis booleans (`xml_migrated`, `hosts_migrated`, `resolv_migrated`,
`telnet_migrated`) remain visible in the State card, so the user can
see which parts of the migration are already in place even when the
overall flag is false. The skip row does not attempt the active probe
on unmigrated speakers — the canonical telnet flow is:
```
Apply telnet config → user-initiated reboot → re-run pre-flight on
the now-migrated speaker → passive observer confirms fan-out.
```
#### 9.8.2 Removal trail
Removed (or scheduled for removal in a follow-up commit) at the time
of §9.8 landing:
- `pkg/service/setup/telnet_probe.go` — `RunTelnetRoundTripProbe`,
`ProbeRegistrar`, `TelnetProbeResult`, `generateProbeToken`.
- `pkg/service/handlers/handlers_telnet_probe.go` — `HandleTelnetProbe`,
`HandleProbeInbound`, `telnetProbeTimeout`, `telnetProbeResponse`.
- `pkg/service/handlers/probe_registry.go` — `probeRegistry` + tests.
- `Server.probes` field.
- Routes `/probe/{token}`, `/probe/{token}/*`, `/setup/telnet-probe/{deviceId}`.
- The `target_url` query-param plumbing on the deprecated endpoint.
- `script.js` — `checkTelnetRoundTrip` (orchestrator call site removed
in the commit that added the branch; function itself removed later).
`isCommandNotFound` and `parseGetpdoConfig` stay — they are also used
by the migration writer (`telnet_migration.go`), preflight reader
(`telnet_preflight.go`), pairing path (`marge_pairing.go`), and
cross-check (`preflight_crosscheck.go`).
+3 -2
View File
@@ -165,7 +165,8 @@ The wizard switches to a visible **Pre-flight checks** panel and runs every appl
- **Backend summary re-check** — confirms transports, hostname resolution, and that the URLs you plan to write match what the backend would produce.
- **HTTPS connection from device** (SSH-capable speakers) — uploads a temporary CA and runs `curl` from the speaker to your service.
- **Telnet round-trip probe** (SSH-less speakers) — temporarily points the speaker's swUpdateUrl at our service via telnet, triggers `:8090/swUpdateCheck`, and watches the inbound land.
- **Reachability check (passive observer)** (already-migrated speakers) — nudges `:8090/swUpdateCheck` on the device and watches for *any* request from the speaker to land on the service. Used when the speaker is already migrated and the service is the natural target of its outbounds.
- **"Round-trip validation runs after Apply + reboot"** (not-yet-migrated speakers) — surfaced as a skip row with a rationale. The speaker's swUpdate daemon caches its URL at boot, so there is no useful no-reboot round-trip check pre-migration; the canonical telnet flow is Apply → reboot → re-run pre-flight on the migrated speaker.
- **DNS redirection from device** — when DNS interception is part of the plan.
On all-green, the wizard auto-proceeds. On any failure, it pauses with *Proceed Anyway* / *Cancel* buttons so you can override on a known-false-positive (slow DNS, etc.) or fix the underlying issue and retry.
@@ -210,7 +211,7 @@ Each speaker is migrated independently. You can run multiple migrations in paral
If you need to undo a migration:
- **From the web UI**: Use the **Revert to Defaults** action on the device — this restores the `.original` backup files created on the speaker during the XML migration.
- **Telnet-only migrations**: the wizard writes only the runtime configuration layer via telnet; the speaker's persistent "envswitch" layer keeps the original Bose URLs. **A single reboot reverts a telnet-only migration automatically.** To make a telnet migration permanent, the wizard also writes `envswitch boseurls set …` as part of the URL flip step — only the *probe* step (used by the pre-flight check) leaves the persisted URLs untouched.
- **Telnet-only migrations**: the wizard writes both the runtime configuration layer (`sys configuration …`) and the persistent layer (`envswitch boseurls set …`) so the migration survives reboot. If you want to revert quickly, the cleanest path is to re-run the wizard with the original Bose URLs in the URL editor.
- **Via SSH**: The original XML config is backed up on the speaker with a `.original` suffix. Restore it manually if the UI is unreachable.
- **Factory reset**: As a last resort, perform a factory reset (see [Device Initial Setup](DEVICE-INITIAL-SETUP.md) for button sequences). This wipes all configuration and returns the speaker to out-of-box state.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 334 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 KiB

After

Width:  |  Height:  |  Size: 512 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 516 KiB

After

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

After

Width:  |  Height:  |  Size: 95 KiB

+9 -2
View File
@@ -3,6 +3,7 @@ module github.com/gesellix/bose-soundtouch
go 1.26.3
require (
github.com/chromedp/chromedp v0.15.1
github.com/go-chi/chi/v5 v5.2.5
github.com/google/gopacket v1.1.19
github.com/gorilla/websocket v1.5.3
@@ -18,13 +19,19 @@ require (
)
require (
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
golang.org/x/image v0.40.0 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.44.0 // indirect
golang.org/x/tools v0.45.0 // indirect
)
+23 -4
View File
@@ -1,3 +1,9 @@
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU=
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag=
github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ=
github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -5,6 +11,14 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
@@ -16,9 +30,13 @@ github.com/hashicorp/mdns v1.0.6/go.mod h1:X4+yWh+upFECLOki1doUPaKpgNQII9gy4bUdC
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
@@ -69,8 +87,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -88,6 +106,7 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
@@ -127,8 +146,8 @@ golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -0,0 +1,66 @@
package handlers
import (
"encoding/json"
"net/http"
"time"
"github.com/go-chi/chi/v5"
)
// peerProbeTimeout caps how long the passive observer waits for any
// inbound from the device IP after the :8090/swUpdateCheck nudge. 30s
// is comfortable for daemon wake-up latency on slow devices while still
// keeping the panel responsive; result.ElapsedMs surfaces the actual
// observed latency so the budget can be tuned from real data.
const peerProbeTimeout = 30 * time.Second
// peerProbeResponse is the body of POST /setup/peer-probe/{deviceId}.
type peerProbeResponse struct {
OK bool `json:"ok"`
Result any `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
// HandlePeerProbe runs the post-migration passive reachability check.
// Registers interest in the device's IP, nudges :8090/swUpdateCheck,
// and reports whether any inbound from that IP landed within
// peerProbeTimeout. Any inbound counts — on a migrated speaker, DNS
// interception routes the daemon's outbounds (update fan-out, marge,
// BMX) through this service regardless of which URL the daemon
// resolved internally, so the question reduces to "did the device
// dial us at all."
//
// Unlike the deprecated round-trip probe, this handler does not mutate
// device state. It presupposes the speaker is already migrated; the
// pre-flight orchestrator is responsible for only calling it in that
// state.
func (s *Server) HandlePeerProbe(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
writeJSONError(w, http.StatusBadRequest, "Device ID is required")
return
}
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
if err != nil {
writeJSONError(w, http.StatusNotFound, err.Error())
return
}
result, err := s.sm.RunPeerReachabilityProbe(deviceIP, s.peerObserver, peerProbeTimeout)
w.Header().Set("Content-Type", "application/json")
body := peerProbeResponse{
OK: err == nil && result != nil && result.Reached,
Result: result,
}
if err != nil {
body.Error = err.Error()
}
if err := json.NewEncoder(w).Encode(body); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
@@ -1,80 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"time"
"github.com/go-chi/chi/v5"
)
// telnetProbeTimeout caps how long the orchestrator waits for the
// device's outbound swUpdateCheck fan-out to land on /probe/{token}.
// 6s lines up with the existing telnet preflight budgets and is well
// above the median observed round-trip (<1s on FW 27.0.6).
const telnetProbeTimeout = 6 * time.Second
// HandleProbeInbound is the catch-all for /probe/{token}/* — the path
// the round-trip orchestrator sets as the speaker's swUpdateUrl. Any
// hit signals the registered channel; the response body is a minimal
// XML stub so the speaker's swUpdateCheck doesn't error out on a
// missing structure.
func (s *Server) HandleProbeInbound(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
if token != "" {
s.probes.Signal(token)
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?><swUpdateIndex/>`))
}
// telnetProbeResponse is the body of POST /setup/telnet-probe/{deviceId}.
type telnetProbeResponse struct {
OK bool `json:"ok"`
Result any `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
// HandleTelnetProbe runs the SSH-less round-trip reachability check.
// Generates a token, temporarily points the speaker's swUpdateUrl at
// /probe/{token} via telnet, triggers :8090/swUpdateCheck, and reports
// whether the device's outbound landed on our service within
// telnetProbeTimeout.
//
// Query params:
// - target_url (optional) — defaults to the configured server URL.
func (s *Server) HandleTelnetProbe(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
writeJSONError(w, http.StatusBadRequest, "Device ID is required")
return
}
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
if err != nil {
writeJSONError(w, http.StatusNotFound, err.Error())
return
}
targetURL := r.URL.Query().Get("target_url")
if targetURL == "" {
targetURL = s.sm.ServerURL
}
result, err := s.sm.RunTelnetRoundTripProbe(deviceIP, targetURL, s.probes, telnetProbeTimeout)
w.Header().Set("Content-Type", "application/json")
body := telnetProbeResponse{
OK: err == nil && result != nil && result.Reached,
Result: result,
}
if err != nil {
body.Error = err.Error()
}
if err := json.NewEncoder(w).Encode(body); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
@@ -0,0 +1,31 @@
package handlers
import (
"net"
"net/http"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
)
// PeerObserverMiddleware records every incoming request's source IP and
// path in the peerObserver registry. It fires on every request before
// the handler runs, so passive reachability probes can register a device
// IP and learn whether any inbound landed in their wait window.
//
// Placement: after TrustedRealIPMiddleware (so r.RemoteAddr reflects the
// trusted client IP) and after Recoverer (so any panic inside this
// middleware is contained). Before any short-circuiting middleware
// would be unnecessary — Signal runs before next.ServeHTTP, so the
// observation lands regardless of how later middleware handles the
// request.
func (s *Server) PeerObserverMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil && host != "" {
s.peerObserver.Signal(host, setup.PeerHit{Path: r.URL.Path, At: time.Now()})
}
next.ServeHTTP(w, r)
})
}
+72
View File
@@ -0,0 +1,72 @@
package handlers
import (
"sync"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
)
// peerObserver is the rendezvous between the passive reachability probe
// (which registers interest in a device IP and waits for any inbound)
// and the chi middleware (which signals on every request whose source
// IP matches a registration).
//
// Unlike probeRegistry, which keys on a unique per-probe token, this
// observer keys on the device's IP — the probe doesn't mutate device
// state, so there's no token to thread through the request path. Any
// inbound from the IP counts as proof of reachability.
//
// PeerHit and the abstract handle interface live in the setup package
// alongside the probe logic; this type implements that interface.
type peerObserver struct {
mu sync.Mutex
pending map[string]chan setup.PeerHit
}
func newPeerObserver() *peerObserver {
return &peerObserver{pending: make(map[string]chan setup.PeerHit)}
}
// Register creates a one-shot buffered channel keyed by IP. The buffer
// of 1 lets the middleware deliver the first hit and silently drop
// subsequent hits during the wait window without blocking. Caller is
// responsible for pairing every Register with Forget.
func (o *peerObserver) Register(ip string) <-chan setup.PeerHit {
o.mu.Lock()
defer o.mu.Unlock()
ch := make(chan setup.PeerHit, 1)
o.pending[ip] = ch
return ch
}
// Signal delivers a hit to the channel for ip, non-blocking. Returns
// true when a matching registration existed AND the hit was delivered
// (i.e. the channel had buffer space — first hit during the window).
// Subsequent hits during the same window return false without blocking.
func (o *peerObserver) Signal(ip string, hit setup.PeerHit) bool {
o.mu.Lock()
defer o.mu.Unlock()
ch, ok := o.pending[ip]
if !ok {
return false
}
select {
case ch <- hit:
return true
default:
return false
}
}
// Forget removes the entry. Safe to call regardless of whether a hit
// landed — does not affect already-returned channels.
func (o *peerObserver) Forget(ip string) {
o.mu.Lock()
defer o.mu.Unlock()
delete(o.pending, ip)
}
@@ -0,0 +1,71 @@
package handlers
import (
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
)
func TestPeerObserver_RegisterSignalForget(t *testing.T) {
o := newPeerObserver()
ch := o.Register("192.168.1.42")
if ch == nil {
t.Fatal("Register returned nil channel")
}
first := setup.PeerHit{Path: "/updates/soundtouch", At: time.Now()}
if !o.Signal("192.168.1.42", first) {
t.Error("Signal returned false for registered IP")
}
// Second signal while the buffer is still full (no reader yet) drops
// silently and returns false — only the first hit per window matters.
if o.Signal("192.168.1.42", setup.PeerHit{Path: "/streaming/x"}) {
t.Error("second Signal returned true; expected false (buffer full, undrained)")
}
select {
case got := <-ch:
if got.Path != first.Path {
t.Errorf("hit.Path = %q, want %q", got.Path, first.Path)
}
case <-time.After(100 * time.Millisecond):
t.Error("Signal did not deliver hit to channel")
}
o.Forget("192.168.1.42")
// After Forget, Signal returns false.
if o.Signal("192.168.1.42", first) {
t.Error("Signal returned true after Forget")
}
}
func TestPeerObserver_UnknownIP(t *testing.T) {
o := newPeerObserver()
if o.Signal("10.0.0.1", setup.PeerHit{Path: "/anything"}) {
t.Error("Signal returned true for unregistered IP")
}
}
func TestPeerObserver_SignalIsNonBlocking(t *testing.T) {
o := newPeerObserver()
o.Register("192.168.1.42") // never drain
done := make(chan struct{})
go func() {
for i := 0; i < 100; i++ {
o.Signal("192.168.1.42", setup.PeerHit{Path: "/x"})
}
close(done)
}()
select {
case <-done:
// Signal never blocked even with no reader and a full buffer.
case <-time.After(500 * time.Millisecond):
t.Fatal("Signal blocked when buffer was full — must drop silently")
}
}
-61
View File
@@ -1,61 +0,0 @@
package handlers
import "sync"
// probeRegistry is the rendezvous between the telnet round-trip probe
// orchestrator (which registers a one-shot token and waits for an
// inbound) and the /probe/{token}/* HTTP handler (which closes the
// matching channel when the speaker's swUpdateCheck fan-out lands).
type probeRegistry struct {
mu sync.Mutex
pending map[string]chan struct{}
}
func newProbeRegistry() *probeRegistry {
return &probeRegistry{pending: make(map[string]chan struct{})}
}
// Register creates a one-shot channel keyed by token. The caller waits
// on the returned channel for the matching inbound; the channel is
// closed by Signal. Must be paired with Forget to release the entry.
func (r *probeRegistry) Register(token string) <-chan struct{} {
r.mu.Lock()
defer r.mu.Unlock()
ch := make(chan struct{})
r.pending[token] = ch
return ch
}
// Signal closes the channel for token (idempotent — repeated hits on
// the same probe path are tolerated, the device sometimes retries).
// Returns true when a matching registration existed.
func (r *probeRegistry) Signal(token string) bool {
r.mu.Lock()
defer r.mu.Unlock()
ch, ok := r.pending[token]
if !ok {
return false
}
select {
case <-ch:
// already closed; nothing to do
default:
close(ch)
}
return true
}
// Forget removes the entry. Safe to call after Register's channel has
// been closed (or never signalled); does not affect already-returned
// channels.
func (r *probeRegistry) Forget(token string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.pending, token)
}
@@ -1,46 +0,0 @@
package handlers
import (
"testing"
"time"
)
func TestProbeRegistry_RegisterSignalForget(t *testing.T) {
r := newProbeRegistry()
ch := r.Register("abc123")
if ch == nil {
t.Fatal("Register returned nil channel")
}
if !r.Signal("abc123") {
t.Error("Signal returned false for registered token")
}
select {
case <-ch:
// channel closed as expected
case <-time.After(100 * time.Millisecond):
t.Error("Signal did not close the channel")
}
// Signal again on the same token must be idempotent (no panic on
// double close).
if !r.Signal("abc123") {
t.Error("second Signal returned false")
}
r.Forget("abc123")
// After Forget, Signal returns false.
if r.Signal("abc123") {
t.Error("Signal returned true after Forget")
}
}
func TestProbeRegistry_UnknownToken(t *testing.T) {
r := newProbeRegistry()
if r.Signal("never-registered") {
t.Error("Signal returned true for unregistered token")
}
}
+2 -2
View File
@@ -61,7 +61,7 @@ type Server struct {
amazonClientSecret string
amazonRedirectURI string
amazonService *amazon.Service
probes *probeRegistry
peerObserver *peerObserver
}
// RequestSnapshot represents an immutable snapshot of an HTTP request.
@@ -96,7 +96,7 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, pro
recordEnabled: recordEnabled,
discoveryInterval: 5 * time.Minute,
discoveryEnabled: true,
probes: newProbeRegistry(),
peerObserver: newPeerObserver(),
}
return s
+41 -26
View File
@@ -3018,23 +3018,28 @@ async function checkDNSRedirectionFromDevice(deviceId, targetUrl) {
}
}
// checkTelnetRoundTrip is the SSH-less alternative to the curl-from-
// device HTTPS test: temporarily flips the speaker's swUpdateUrl via
// telnet, triggers :8090/swUpdateCheck, and reports whether the
// device's outbound landed on our /probe/{token} catch-all. See
// pkg/service/setup/telnet_probe.go for the orchestration details.
async function checkTelnetRoundTrip(deviceId, targetUrl) {
// checkPeerReachability is the post-migration passive observation
// check: register interest in the device IP, nudge :8090/swUpdateCheck,
// and report whether any inbound from that IP landed on this service
// within the timeout. Used in place of the active swUpdateUrl round-
// trip on already-migrated speakers, where the swUpdate daemon caches
// its URL at boot and the active flip can't reach it without a reboot.
// See pkg/service/setup/peer_probe.go for orchestration details.
async function checkPeerReachability(deviceId) {
try {
const q = `?target_url=${encodeURIComponent(targetUrl)}`;
const resp = await fetch(`/setup/telnet-probe/${encodeURIComponent(deviceId)}${q}`, {method: "POST"});
const resp = await fetch(`/setup/peer-probe/${encodeURIComponent(deviceId)}`, {method: "POST"});
const result = await resp.json();
if (result.ok) {
const ms = result.result && result.result.elapsed_ms;
return {status: "ok", message: ms ? `reached in ${ms}ms` : undefined};
const path = result.result && result.result.observed_path;
let msg = "";
if (ms !== undefined && ms !== null) msg = `${ms}ms`;
if (path) msg = msg ? `${msg} (${path})` : path;
return {status: "ok", message: msg || undefined};
}
if (result.error) return {status: "fail", message: result.error.split("\n")[0]};
if (result.result && result.result.reached === false) {
return {status: "fail", message: "probe inbound not observed before timeout"};
return {status: "fail", message: "no inbound from device before timeout"};
}
return {status: "fail", message: "probe failed"};
} catch (e) {
@@ -3068,21 +3073,23 @@ async function runApplyPreflight(deviceId, methods, opts, targetUrl) {
results.push({name: "Backend summary re-check", status: "ok"});
const summary = r.summary;
// Step 2: reachability from the device. Each transport gets its
// own check — they exercise different network paths:
// Step 2: reachability from the device. Two evidence sources:
//
// - SSH (curl from device) verifies inbound TCP from the
// speaker to our HTTP/HTTPS port using the speaker's normal
// userspace stack.
// - Telnet round-trip exercises the outbound from the
// speaker's `swUpdateUrl` fan-out, which uses a different
// code path in the firmware. A speaker that passes the SSH
// curl test but fails the round-trip probe (or vice versa)
// reveals a real connectivity asymmetry worth surfacing.
// userspace stack. Works pre- or post-migration as long as
// SSH is unlocked.
// - Passive observer (post-migration only) verifies the
// swUpdate daemon is actually dialing this service. Replaces
// the deprecated active swUpdateUrl round-trip, which the
// daemon ignores because it caches its URL at boot.
//
// Both checks run when both transports are reachable. If neither
// is reachable, the row is surfaced as a deliberate skip rather
// than silently dropped.
// On an unmigrated/partially-migrated telnet-only speaker, no
// no-reboot validation of the daemon's outbound is possible — we
// surface a skip row explaining "Apply + reboot is required to
// validate the fan-out". Per-axis migration state is still visible
// in the State card above, so the user can see which parts are
// already in place.
const connectionTestURL = preflightConnectionTestURL(summary, methods, targetUrl);
const ranAnyReachability = (summary.ssh_success && !!connectionTestURL) || summary.telnet_reachable;
@@ -3097,11 +3104,19 @@ async function runApplyPreflight(deviceId, methods, opts, targetUrl) {
}
if (summary.telnet_reachable) {
const item = addPreflightItem("Telnet round-trip probe (swUpdateUrl)");
setPreflightItemStatus(item, "running");
const cr = await checkTelnetRoundTrip(deviceId, targetUrl);
setPreflightItemStatus(item, cr.status, cr.message);
results.push({name: "Telnet round-trip probe", ...cr});
if (summary.is_migrated) {
const label = "Reachability check (passive observer)";
const item = addPreflightItem(label);
setPreflightItemStatus(item, "running");
const cr = await checkPeerReachability(deviceId);
setPreflightItemStatus(item, cr.status, cr.message);
results.push({name: label, ...cr});
} else {
const label = "Round-trip validation runs after Apply + reboot";
const item = addPreflightItem(label);
setPreflightItemStatus(item, "skip", "daemon caches swUpdateUrl at boot; reboot required to validate fan-out");
results.push({name: label, status: "skip", message: "runs after Apply + reboot"});
}
}
if (!ranAnyReachability) {
+94
View File
@@ -0,0 +1,94 @@
package setup
import (
"errors"
"fmt"
"time"
)
// PeerHit is the payload the observer middleware delivers to a probe
// waiter when a request from a registered peer IP lands on the service.
type PeerHit struct {
Path string
At time.Time
}
// PeerObserverHandle is the abstract view of the peer-observer registry
// the probe needs: register interest in an IP, eventually forget it.
// The handlers package's peerObserver satisfies this implicitly.
type PeerObserverHandle interface {
Register(ip string) <-chan PeerHit
Forget(ip string)
}
// PeerProbeResult is the JSON-serializable outcome of a passive
// reachability probe. Reached is the canonical success bit the UI keys
// off; ObservedPath and ElapsedMs are diagnostic.
type PeerProbeResult struct {
Reached bool `json:"reached"`
ObservedPath string `json:"observed_path,omitempty"`
ElapsedMs int64 `json:"elapsed_ms"`
}
// RunPeerReachabilityProbe is the post-migration reachability check
// that replaces the active swUpdateUrl round-trip. The sequence:
//
// 1. Register the device IP with the observer.
// 2. Nudge :8090/swUpdateCheck on the device to make the swUpdate
// daemon fan out *something* sooner than its own ~5min timer.
// 3. Wait up to timeout for any inbound from that IP.
//
// Any inbound counts as proof of reachability — on a migrated speaker,
// DNS interception means the daemon's outbounds (update fan-out, marge
// polls, BMX registry calls) all funnel through this service regardless
// of which URL the daemon resolved internally. We don't need a specific
// URL to land; we just need *the device* to dial us.
//
// The nudge is fire-and-forget. If :8090 is unreachable, the request
// returns quickly and we still wait for the daemon's own next fan-out
// (or time out). No state on the device is mutated; the probe is safe
// to re-run.
func (m *Manager) RunPeerReachabilityProbe(deviceIP string, observer PeerObserverHandle, timeout time.Duration) (*PeerProbeResult, error) {
if observer == nil {
return nil, errors.New("peer probe not configured: observer is nil")
}
if deviceIP == "" {
return nil, errors.New("peer probe: deviceIP is required")
}
hitCh := observer.Register(deviceIP)
defer observer.Forget(deviceIP)
// Nudge the device. Fire-and-forget — we don't gate on the response
// because the swUpdateCheck endpoint returns immediately after
// enqueuing, and the daemon's fan-out is what we actually want to
// observe. HTTPGet can be nil in test contexts.
if m.HTTPGet != nil {
swCheckURL := fmt.Sprintf("http://%s:8090/swUpdateCheck", deviceIP)
go func() {
resp, err := m.HTTPGet(swCheckURL)
if err != nil {
return
}
_ = resp.Body.Close()
}()
}
start := time.Now()
result := &PeerProbeResult{}
select {
case hit := <-hitCh:
result.Reached = true
result.ObservedPath = hit.Path
case <-time.After(timeout):
result.Reached = false
}
result.ElapsedMs = time.Since(start).Milliseconds()
return result, nil
}
+142
View File
@@ -0,0 +1,142 @@
package setup
import (
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
)
// fakePeerObserver is a deterministic PeerObserverHandle for unit
// tests. It exposes the channel returned from Register so the test can
// signal it manually to simulate a device inbound landing.
type fakePeerObserver struct {
mu sync.Mutex
channels map[string]chan PeerHit
forgotten []string
}
func newFakePeerObserver() *fakePeerObserver {
return &fakePeerObserver{channels: map[string]chan PeerHit{}}
}
func (o *fakePeerObserver) Register(ip string) <-chan PeerHit {
o.mu.Lock()
defer o.mu.Unlock()
ch := make(chan PeerHit, 1)
o.channels[ip] = ch
return ch
}
func (o *fakePeerObserver) Forget(ip string) {
o.mu.Lock()
defer o.mu.Unlock()
delete(o.channels, ip)
o.forgotten = append(o.forgotten, ip)
}
func (o *fakePeerObserver) signal(ip string, hit PeerHit) {
o.mu.Lock()
defer o.mu.Unlock()
ch, ok := o.channels[ip]
if !ok {
return
}
select {
case ch <- hit:
default:
}
}
func peerProbeManager(onTrigger func()) *Manager {
return &Manager{
HTTPGet: func(url string) (*http.Response, error) {
if onTrigger != nil {
onTrigger()
}
rr := httptest.NewRecorder()
rr.WriteHeader(200)
return rr.Result(), nil
},
}
}
func TestRunPeerReachabilityProbe_HappyPath(t *testing.T) {
obs := newFakePeerObserver()
// On nudge, simulate the device fanning out to /updates/soundtouch
// which the middleware would signal as a hit on this IP.
m := peerProbeManager(func() {
obs.signal("192.168.1.42", PeerHit{Path: "/updates/soundtouch", At: time.Now()})
})
result, err := m.RunPeerReachabilityProbe("192.168.1.42", obs, 2*time.Second)
if err != nil {
t.Fatalf("RunPeerReachabilityProbe error: %v", err)
}
if !result.Reached {
t.Error("Reached = false, want true")
}
if result.ObservedPath != "/updates/soundtouch" {
t.Errorf("ObservedPath = %q, want %q", result.ObservedPath, "/updates/soundtouch")
}
if len(obs.forgotten) != 1 || obs.forgotten[0] != "192.168.1.42" {
t.Errorf("Forget not called for IP: forgotten = %v", obs.forgotten)
}
}
func TestRunPeerReachabilityProbe_Timeout(t *testing.T) {
obs := newFakePeerObserver()
m := peerProbeManager(nil) // nudge fires but device never responds
start := time.Now()
result, err := m.RunPeerReachabilityProbe("192.168.1.42", obs, 200*time.Millisecond)
elapsed := time.Since(start)
if err != nil {
t.Fatalf("RunPeerReachabilityProbe error: %v", err)
}
if result.Reached {
t.Error("Reached = true, want false (no hit)")
}
if elapsed < 200*time.Millisecond {
t.Errorf("returned early after %v; expected >= 200ms timeout", elapsed)
}
if len(obs.forgotten) != 1 {
t.Errorf("Forget not called after timeout: forgotten = %v", obs.forgotten)
}
}
func TestRunPeerReachabilityProbe_NilObserver(t *testing.T) {
m := peerProbeManager(nil)
_, err := m.RunPeerReachabilityProbe("192.168.1.42", nil, time.Second)
if err == nil {
t.Error("expected error for nil observer, got nil")
}
}
func TestRunPeerReachabilityProbe_EmptyIP(t *testing.T) {
m := peerProbeManager(nil)
obs := newFakePeerObserver()
_, err := m.RunPeerReachabilityProbe("", obs, time.Second)
if err == nil {
t.Error("expected error for empty deviceIP, got nil")
}
}
func TestRunPeerReachabilityProbe_NilHTTPGetTimesOut(t *testing.T) {
// With nil HTTPGet the nudge is skipped entirely; the probe just
// waits for the device to dial in on its own. Useful in tests and
// in environments where the trigger isn't safe to fire.
m := &Manager{} // HTTPGet nil
obs := newFakePeerObserver()
result, err := m.RunPeerReachabilityProbe("192.168.1.42", obs, 100*time.Millisecond)
if err != nil {
t.Fatalf("error: %v", err)
}
if result.Reached {
t.Error("Reached = true with no nudge and no signal")
}
}
-194
View File
@@ -1,194 +0,0 @@
package setup
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"net/url"
"strings"
"time"
)
// ProbeRegistrar is the rendezvous between the round-trip probe
// orchestrator (which registers a token and waits) and an HTTP layer
// (which signals the channel when the device's outbound lands on the
// matching /probe/{token}/* path). The handlers package wires its
// probeRegistry into this interface.
type ProbeRegistrar interface {
Register(token string) <-chan struct{}
Forget(token string)
}
// TelnetProbeResult records what RunTelnetRoundTripProbe observed.
// Reached reports whether the device's outbound landed on our service
// within the configured timeout; Restored reports whether the
// temporary swUpdateUrl override was reverted to the captured
// original. The orchestrator always attempts the restore even on the
// failure path, so a Reached=false + Restored=true is the common
// "couldn't reach us, device is back to its old configuration" state.
type TelnetProbeResult struct {
Reached bool `json:"reached"`
Restored bool `json:"restored"`
OriginalURL string `json:"original_url,omitempty"`
ProbeURL string `json:"probe_url,omitempty"`
ElapsedMs int64 `json:"elapsed_ms"`
Logs string `json:"logs,omitempty"`
}
// generateProbeToken returns a random hex token suitable for use in a
// URL path. 12 bytes → 24 hex chars; collision probability is
// negligible for the dozens-of-probes-per-session scope.
func generateProbeToken() (string, error) {
b := make([]byte, 12)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// RunTelnetRoundTripProbe is the SSH-less reachability check that
// fills the gap the curl-from-device HTTPS test leaves on USB-
// unlock-refusing speakers. The sequence:
//
// 1. Telnet `getpdo CurrentSystemConfiguration` to capture the
// speaker's current swUpdateUrl.
// 2. Generate a token, register a one-shot signal channel under it.
// 3. Telnet `sys configuration swUpdateUrl <targetURL>/probe/<token>`
// to point the runtime layer at our service. Deliberately NOT
// `envswitch boseurls set …` — the persistence layer keeps the
// original, so a reboot heals the device naturally if our
// restore step fails.
// 4. HTTP GET `<deviceIP>:8090/swUpdateCheck` to make the speaker
// fan out a request to the new swUpdateUrl.
// 5. Wait on the registered channel up to timeout.
// 6. Telnet `sys configuration swUpdateUrl <originalURL>` to revert.
//
// Returns Reached=true only if the inbound landed before the timeout
// fired. Restore runs in a deferred call so it executes even when
// earlier steps fail.
func (m *Manager) RunTelnetRoundTripProbe(deviceIP, targetURL string, registrar ProbeRegistrar, timeout time.Duration) (*TelnetProbeResult, error) {
if m.NewTelnet == nil {
return nil, errors.New("telnet probe not configured: Manager.NewTelnet is nil")
}
if registrar == nil {
return nil, errors.New("telnet probe not configured: registrar is nil")
}
parsedTarget, err := url.Parse(strings.TrimSpace(targetURL))
if err != nil || parsedTarget.Host == "" {
return nil, fmt.Errorf("invalid target URL %q: hostname required", targetURL)
}
result := &TelnetProbeResult{}
var logs strings.Builder
t := m.NewTelnet(deviceIP)
if dialErr := t.Dial(); dialErr != nil {
return nil, fmt.Errorf("telnet dial %s:17000 failed: %w", deviceIP, dialErr)
}
defer func() { _ = t.Close() }()
// 1. Capture the current swUpdateUrl from getpdo. If the device
// refuses getpdo we cannot safely flip the URL — abort.
verify, err := t.SendCommand("getpdo CurrentSystemConfiguration")
if err != nil {
return nil, fmt.Errorf("getpdo CurrentSystemConfiguration failed: %w", err)
}
if isCommandNotFound(verify) {
return nil, errors.New("device rejected getpdo CurrentSystemConfiguration — cannot capture original URL")
}
parsed := parseGetpdoConfig(verify)
originalURL := parsed["swUpdateUrl"]
if originalURL == "" {
return nil, errors.New("could not parse original swUpdateUrl from getpdo response")
}
result.OriginalURL = originalURL
fmt.Fprintf(&logs, "Original swUpdateUrl: %s\n", originalURL)
// 2. Token + registration.
token, err := generateProbeToken()
if err != nil {
return nil, fmt.Errorf("generate probe token: %w", err)
}
probeCh := registrar.Register(token)
defer registrar.Forget(token)
probeURL := fmt.Sprintf("%s://%s/probe/%s", parsedTarget.Scheme, parsedTarget.Host, token)
result.ProbeURL = probeURL
fmt.Fprintf(&logs, "Probe URL: %s\n", probeURL)
// 3. Set swUpdateUrl to the probe URL via telnet. Deferred restore
// runs regardless of subsequent failures.
setCmd := "sys configuration swUpdateUrl " + probeURL
resp, err := t.SendCommand(setCmd)
if err != nil {
return result, fmt.Errorf("telnet set swUpdateUrl failed: %w", err)
}
if isCommandNotFound(resp) {
return result, fmt.Errorf("device rejected %q (firmware does not expose this command)", setCmd)
}
fmt.Fprintf(&logs, "→ %s\n%s\n", setCmd, strings.TrimRight(resp, "\r\n"))
defer func() {
restoreCmd := "sys configuration swUpdateUrl " + originalURL
if rresp, rerr := t.SendCommand(restoreCmd); rerr == nil && !isCommandNotFound(rresp) {
result.Restored = true
fmt.Fprintf(&logs, "→ %s (restored)\n%s\n", restoreCmd, strings.TrimRight(rresp, "\r\n"))
} else if rerr != nil {
fmt.Fprintf(&logs, "Restore failed: %v (envswitch persistence will heal on next reboot)\n", rerr)
}
result.Logs = logs.String()
}()
// 4. Trigger the device's outbound via :8090/swUpdateCheck. The
// HTTP call is fire-and-forget — we don't need its response, only
// that the device fans out to the probe URL we just set.
swCheckURL := fmt.Sprintf("http://%s:8090/swUpdateCheck", deviceIP)
go func() {
if m.HTTPGet == nil {
return
}
resp, err := m.HTTPGet(swCheckURL)
if err != nil {
return
}
_ = resp.Body.Close()
}()
// 5. Wait for the inbound.
start := time.Now()
select {
case <-probeCh:
result.Reached = true
fmt.Fprintf(&logs, "Probe inbound observed after %v\n", time.Since(start))
case <-time.After(timeout):
result.Reached = false
fmt.Fprintf(&logs, "Probe timed out after %v\n", timeout)
}
result.ElapsedMs = time.Since(start).Milliseconds()
return result, nil
}
-305
View File
@@ -1,305 +0,0 @@
package setup
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
// fakeRegistrar is a deterministic ProbeRegistrar for unit tests. It
// exposes the channel it returned from Register so the test can
// signal it manually to simulate the device's outbound landing on our
// service.
type fakeRegistrar struct {
mu sync.Mutex
channels map[string]chan struct{}
registered []string
forgotten []string
}
func newFakeRegistrar() *fakeRegistrar {
return &fakeRegistrar{channels: map[string]chan struct{}{}}
}
func (r *fakeRegistrar) Register(token string) <-chan struct{} {
r.mu.Lock()
defer r.mu.Unlock()
ch := make(chan struct{})
r.channels[token] = ch
r.registered = append(r.registered, token)
return ch
}
func (r *fakeRegistrar) Forget(token string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.channels, token)
r.forgotten = append(r.forgotten, token)
}
// fire closes the channel for the most-recently-registered token so
// the orchestrator's select wakes.
func (r *fakeRegistrar) fire() {
r.mu.Lock()
defer r.mu.Unlock()
if len(r.registered) == 0 {
return
}
last := r.registered[len(r.registered)-1]
ch, ok := r.channels[last]
if !ok {
return
}
select {
case <-ch:
default:
close(ch)
}
}
// telnetProbeManager builds a Manager pre-wired for probe tests:
// fakeTelnet supplies getpdo and sys configuration responses, and
// HTTPGet is overridden so the :8090/swUpdateCheck trigger doesn't
// reach out to anything real. The httptest server simulates the
// device's swUpdateCheck so we observe the request landing.
func telnetProbeManager(ft *fakeTelnet, onTrigger func()) *Manager {
m := &Manager{
ServerURL: "http://example:8000",
NewTelnet: func(string) TelnetClient { return ft },
HTTPGet: func(url string) (*http.Response, error) {
if onTrigger != nil {
onTrigger()
}
rr := httptest.NewRecorder()
rr.WriteHeader(200)
return rr.Result(), nil
},
}
return m
}
func TestRunTelnetRoundTripProbe_HappyPath(t *testing.T) {
target := "http://example:8000"
ft := &fakeTelnet{
responses: map[string]string{
"getpdo CurrentSystemConfiguration": `swUpdateUrl {
text: "https://worldwide.bose.com/updates/soundtouch"
}
`,
},
}
registrar := newFakeRegistrar()
// The :8090 trigger should cause the device to fan out to the
// probe URL. In the test we simulate by closing the channel from
// the trigger goroutine.
m := telnetProbeManager(ft, func() { registrar.fire() })
// fakeTelnet returns "Command not found\n" for unmapped commands.
// We need `sys configuration swUpdateUrl …` (any value) to look
// like a success. Pre-populate the map with the canonical happy
// response — the test will fill in the actual command after
// generateProbeToken runs, but we can pattern-match instead.
// Trick: keep the responses map empty for the set command and
// override the fakeTelnet behaviour.
ft.responses = map[string]string{
"getpdo CurrentSystemConfiguration": `swUpdateUrl {
text: "https://worldwide.bose.com/updates/soundtouch"
}
`,
}
// The set/restore commands aren't in the responses map; the
// fakeTelnet defaults to "Command not found\n" which would fail
// the run. Override by injecting an OK response for any command
// starting with "sys configuration swUpdateUrl ".
origSendCommand := ft.SendCommand
_ = origSendCommand // unused — fakeTelnet uses a method, not a field.
// Use a custom telnet client that returns OK for sys configuration.
customTelnet := &probeFakeTelnet{
responses: ft.responses,
}
m.NewTelnet = func(string) TelnetClient { return customTelnet }
result, err := m.RunTelnetRoundTripProbe("192.0.2.1", target, registrar, 2*time.Second)
if err != nil {
t.Fatalf("RunTelnetRoundTripProbe: %v", err)
}
if !result.Reached {
t.Errorf("Reached = false, want true")
}
if !result.Restored {
t.Errorf("Restored = false, want true (restore command should have succeeded)")
}
if result.OriginalURL != "https://worldwide.bose.com/updates/soundtouch" {
t.Errorf("OriginalURL = %q, want the captured value", result.OriginalURL)
}
if !strings.Contains(result.ProbeURL, "/probe/") {
t.Errorf("ProbeURL = %q, want a /probe/<token> path", result.ProbeURL)
}
if len(registrar.forgotten) != 1 {
t.Errorf("Forget calls = %d, want 1", len(registrar.forgotten))
}
}
// probeFakeTelnet returns OK for any "sys configuration swUpdateUrl …"
// command and falls back to the responses map for everything else.
type probeFakeTelnet struct {
responses map[string]string
commands []string
}
func (f *probeFakeTelnet) Dial() error { return nil }
func (f *probeFakeTelnet) Close() error { return nil }
func (f *probeFakeTelnet) Probe() (string, error) { return "", nil }
func (f *probeFakeTelnet) SendCommand(cmd string) (string, error) {
f.commands = append(f.commands, cmd)
if resp, ok := f.responses[cmd]; ok {
return resp, nil
}
if strings.HasPrefix(cmd, "sys configuration swUpdateUrl ") {
return "OK\n", nil
}
return "Command not found\n", nil
}
func TestRunTelnetRoundTripProbe_TimeoutWhenInboundNeverArrives(t *testing.T) {
target := "http://example:8000"
registrar := newFakeRegistrar()
// Do NOT fire the registrar — simulate the device not making the
// outbound (e.g. firewall, hung firmware).
m := telnetProbeManager(nil, nil)
m.NewTelnet = func(string) TelnetClient {
return &probeFakeTelnet{
responses: map[string]string{
"getpdo CurrentSystemConfiguration": `swUpdateUrl {
text: "https://worldwide.bose.com/updates/soundtouch"
}
`,
},
}
}
result, err := m.RunTelnetRoundTripProbe("192.0.2.1", target, registrar, 100*time.Millisecond)
if err != nil {
t.Fatalf("expected nil error on timeout, got %v", err)
}
if result.Reached {
t.Errorf("Reached = true, want false (no inbound was fired)")
}
if !result.Restored {
t.Errorf("Restored = false, want true even on the timeout path")
}
}
func TestRunTelnetRoundTripProbe_AbortsWhenGetpdoMissesSwUpdateURL(t *testing.T) {
target := "http://example:8000"
registrar := newFakeRegistrar()
m := telnetProbeManager(nil, nil)
m.NewTelnet = func(string) TelnetClient {
return &probeFakeTelnet{
responses: map[string]string{
// No swUpdateUrl key — older firmware variant. We refuse
// to flip anything because we wouldn't know what to
// restore to.
"getpdo CurrentSystemConfiguration": `margeServerUrl {
text: "https://streaming.bose.com"
}
`,
},
}
}
_, err := m.RunTelnetRoundTripProbe("192.0.2.1", target, registrar, 100*time.Millisecond)
if err == nil {
t.Fatal("expected error when getpdo response has no swUpdateUrl, got nil")
}
if !strings.Contains(err.Error(), "swUpdateUrl") {
t.Errorf("err = %v, want it to mention the missing field", err)
}
}
func TestRunTelnetRoundTripProbe_AbortsWhenDeviceRejectsSysConfiguration(t *testing.T) {
target := "http://example:8000"
registrar := newFakeRegistrar()
m := telnetProbeManager(nil, nil)
m.NewTelnet = func(string) TelnetClient {
return &probeFakeTelnetReject{
responses: map[string]string{
"getpdo CurrentSystemConfiguration": `swUpdateUrl {
text: "https://worldwide.bose.com/updates/soundtouch"
}
`,
},
}
}
_, err := m.RunTelnetRoundTripProbe("192.0.2.1", target, registrar, 100*time.Millisecond)
if err == nil {
t.Fatal("expected error when device rejects sys configuration, got nil")
}
if !strings.Contains(err.Error(), "firmware does not expose") {
t.Errorf("err = %v, want a firmware-rejection message", err)
}
}
type probeFakeTelnetReject struct {
responses map[string]string
}
func (f *probeFakeTelnetReject) Dial() error { return nil }
func (f *probeFakeTelnetReject) Close() error { return nil }
func (f *probeFakeTelnetReject) Probe() (string, error) { return "", nil }
func (f *probeFakeTelnetReject) SendCommand(cmd string) (string, error) {
if resp, ok := f.responses[cmd]; ok {
return resp, nil
}
// Any other command, including sys configuration, is rejected.
return "Command not found\n", nil
}
func TestRunTelnetRoundTripProbe_DialFailure(t *testing.T) {
registrar := newFakeRegistrar()
m := &Manager{
ServerURL: "http://example:8000",
NewTelnet: func(string) TelnetClient {
return &fakeTelnet{dialErr: errors.New("connection refused")}
},
}
_, err := m.RunTelnetRoundTripProbe("192.0.2.1", "http://example:8000", registrar, 100*time.Millisecond)
if err == nil {
t.Fatal("expected dial error, got nil")
}
if !strings.Contains(err.Error(), "connection refused") {
t.Errorf("err = %v, want to wrap connection refused", err)
}
}
func TestRunTelnetRoundTripProbe_InvalidTargetURL(t *testing.T) {
registrar := newFakeRegistrar()
m := &Manager{
ServerURL: "http://example:8000",
NewTelnet: func(string) TelnetClient { return &fakeTelnet{} },
}
_, err := m.RunTelnetRoundTripProbe("192.0.2.1", "not-a-url", registrar, 100*time.Millisecond)
if err == nil {
t.Fatal("expected error on invalid target URL, got nil")
}
}
@@ -0,0 +1,139 @@
// Package fakespeaker runs a minimal HTTP server that impersonates the
// SoundTouch device's :8090 API surface with sanitized, embedded fixture
// data. It exists so docs/screenshot tooling and integration setups can
// register a "speaker" without depending on real hardware or leaking
// personal data into committed artifacts.
//
// The fixture set is deliberately narrow: enough for the soundtouch-service
// to accept device registration and render initial UI views. Extend the
// route set as additional pre-flight or migration flows need coverage.
package fakespeaker
import (
"context"
"embed"
"errors"
"fmt"
"net"
"net/http"
"time"
)
//go:embed testdata/info.xml testdata/presets.xml testdata/recents.xml
var fixtures embed.FS
// Config configures a fake speaker. The zero value is valid and binds the
// HTTP API to a random port on 127.0.0.1 with no telnet listener.
type Config struct {
// HTTPListen is the bind address for the device's :8090 HTTP API
// (e.g. "127.0.0.1:8090" or ":8090"). Empty means "127.0.0.1:0" —
// let the OS pick a port.
HTTPListen string
// TelnetListen is the bind address for the device's :17000
// diagnostic shell. Empty disables the telnet listener entirely.
// Use "127.0.0.1:17000" to match the real port the wizard probes.
TelnetListen string
}
// Server is a running fake speaker. It bundles whichever sub-servers
// were enabled in the Config; consult HTTPAddr / TelnetAddr to discover
// where they actually bound.
type Server struct {
srv *http.Server
httpAddr string
telnet *telnetServer
}
// Start binds the configured listeners and serves them in background
// goroutines. It returns once they are ready (so callers can immediately
// use the resolved addresses) or with an error if any bind failed.
func Start(cfg Config) (*Server, error) {
httpListen := cfg.HTTPListen
if httpListen == "" {
httpListen = "127.0.0.1:0"
}
ln, err := net.Listen("tcp", httpListen)
if err != nil {
return nil, fmt.Errorf("fakespeaker: listen %s: %w", httpListen, err)
}
mux := http.NewServeMux()
registerRoutes(mux)
s := &Server{
srv: &http.Server{
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
},
httpAddr: ln.Addr().String(),
}
go func() {
_ = s.srv.Serve(ln)
}()
if cfg.TelnetListen != "" {
ts, terr := startTelnetServer(cfg.TelnetListen)
if terr != nil {
_ = s.srv.Close()
return nil, terr
}
s.telnet = ts
}
return s, nil
}
// HTTPAddr returns the resolved HTTP listen address as "host:port".
func (s *Server) HTTPAddr() string {
return s.httpAddr
}
// TelnetAddr returns the resolved telnet listen address as "host:port",
// or "" if the telnet listener is disabled.
func (s *Server) TelnetAddr() string {
if s.telnet == nil {
return ""
}
return s.telnet.Addr()
}
// Stop shuts all sub-servers down, blocking until in-flight requests
// finish or ctx is cancelled.
func (s *Server) Stop(ctx context.Context) error {
if s.telnet != nil {
s.telnet.Stop()
}
if err := s.srv.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
func registerRoutes(mux *http.ServeMux) {
mux.HandleFunc("/info", serveFixture("testdata/info.xml"))
mux.HandleFunc("/presets", serveFixture("testdata/presets.xml"))
mux.HandleFunc("/recents", serveFixture("testdata/recents.xml"))
}
func serveFixture(path string) http.HandlerFunc {
body, err := fixtures.ReadFile(path)
if err != nil {
// Embed failure is a build-time programmer error; surface it
// loudly the first time the route is hit.
return func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "fakespeaker: missing fixture "+path+": "+err.Error(), http.StatusInternalServerError)
}
}
return func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
_, _ = w.Write(body)
}
}
@@ -0,0 +1,64 @@
package fakespeaker
import (
"context"
"encoding/xml"
"io"
"net/http"
"testing"
"time"
)
func TestFakeSpeakerServesFixtures(t *testing.T) {
s, err := Start(Config{})
if err != nil {
t.Fatalf("start: %v", err)
}
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = s.Stop(ctx)
})
cases := []struct {
path string
root string
}{
{"/info", "info"},
{"/presets", "presets"},
{"/recents", "recents"},
}
for _, tc := range cases {
t.Run(tc.path, func(t *testing.T) {
resp, err := http.Get("http://" + s.HTTPAddr() + tc.path) //nolint:noctx
if err != nil {
t.Fatalf("get %s: %v", tc.path, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
var root struct {
XMLName xml.Name
}
if err := xml.Unmarshal(body, &root); err != nil {
t.Fatalf("parse XML: %v\n%s", err, body)
}
if root.XMLName.Local != tc.root {
t.Fatalf("root element = %q, want %q", root.XMLName.Local, tc.root)
}
})
}
}
+155
View File
@@ -0,0 +1,155 @@
package fakespeaker
import (
"bufio"
"errors"
"fmt"
"net"
"strings"
"sync"
"time"
)
// telnetBanner mimics what a real SoundTouch device emits on connect to
// :17000. The exact wording is not load-bearing for the migration UI —
// only TelnetReachable is — but a non-empty banner matches the production
// shape and gets surfaced in the wizard for diagnostic value.
const telnetBanner = "Welcome to the Bose SoundTouch diagnostic shell\r\n"
// telnetGetpdoResponse simulates the protobuf-text-like reply to
// `getpdo CurrentSystemConfiguration` for an *unmigrated* speaker — every
// URL still points at the Bose cloud. This is the happy path for a
// documentation screenshot: the wizard renders as "Not Migrated", lists
// the original URLs, and offers the migration plan.
//
// The shape matches what preflight_crosscheck.parseGetpdoConfig expects:
// "<key> {\n text: \"<value>\"\n}".
const telnetGetpdoResponse = `margeServerUrl {
text: "https://streaming.bose.com"
}
statsServerUrl {
text: "https://stats.bose.com"
}
swUpdateUrl {
text: "https://worldwide.bose.com/updates/soundtouch"
}
bmxRegistryUrl {
text: "https://bmxservice.bose.com/bmx/registry/v1/services"
}
->OK
`
// telnetServer is a minimal TCP server that satisfies the read-only pre-flight
// probe in pkg/service/setup/telnet_preflight.go. It handles only the commands
// the wizard actually issues and answers every other line with a stub.
type telnetServer struct {
ln net.Listener
addr string
wg sync.WaitGroup
once sync.Once
done chan struct{}
}
func startTelnetServer(listen string) (*telnetServer, error) {
ln, err := net.Listen("tcp", listen)
if err != nil {
return nil, fmt.Errorf("fakespeaker telnet: listen %s: %w", listen, err)
}
s := &telnetServer{
ln: ln,
addr: ln.Addr().String(),
done: make(chan struct{}),
}
s.wg.Add(1)
go s.accept()
return s, nil
}
func (s *telnetServer) Addr() string {
return s.addr
}
func (s *telnetServer) Stop() {
s.once.Do(func() {
close(s.done)
_ = s.ln.Close()
})
s.wg.Wait()
}
func (s *telnetServer) accept() {
defer s.wg.Done()
for {
conn, err := s.ln.Accept()
if err != nil {
// Listener closed → graceful shutdown; any other error means
// the OS gave up on us and we should also stop.
if errors.Is(err, net.ErrClosed) {
return
}
select {
case <-s.done:
return
default:
}
continue
}
s.wg.Add(1)
go s.handle(conn)
}
}
func (s *telnetServer) handle(conn net.Conn) {
defer s.wg.Done()
defer func() { _ = conn.Close() }()
// Banner on connect — clients read it via Probe() before any command.
_ = conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
if _, err := conn.Write([]byte(telnetBanner)); err != nil {
return
}
reader := bufio.NewReader(conn)
for {
// No idle deadline — let the client drive the cadence. The client
// closes the socket after it has its answer (~600 ms idle window),
// which surfaces here as io.EOF and ends the loop.
line, err := reader.ReadString('\n')
if err != nil {
return
}
resp := respondTo(strings.TrimRight(line, "\r\n"))
_ = conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
if _, werr := conn.Write([]byte(resp)); werr != nil {
return
}
}
}
func respondTo(cmd string) string {
cmd = strings.TrimSpace(cmd)
switch cmd {
case "getpdo CurrentSystemConfiguration":
return telnetGetpdoResponse
case "":
return "->OK\r\n"
default:
// Unrecognized commands get a benign acknowledgement so the
// probe loop never hangs waiting for a response.
return "->OK\r\n"
}
}
@@ -0,0 +1,81 @@
package fakespeaker
import (
"bufio"
"context"
"net"
"strings"
"testing"
"time"
)
func TestTelnetServerBannerAndGetpdo(t *testing.T) {
s, err := Start(Config{TelnetListen: "127.0.0.1:0"})
if err != nil {
t.Fatalf("start: %v", err)
}
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = s.Stop(ctx)
})
if s.TelnetAddr() == "" {
t.Fatalf("telnet listener not started")
}
conn, err := net.DialTimeout("tcp", s.TelnetAddr(), 2*time.Second)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer func() { _ = conn.Close() }()
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
reader := bufio.NewReader(conn)
banner, err := reader.ReadString('\n')
if err != nil {
t.Fatalf("read banner: %v", err)
}
if !strings.Contains(banner, "Bose SoundTouch") {
t.Errorf("banner = %q, want substring %q", banner, "Bose SoundTouch")
}
if _, err := conn.Write([]byte("getpdo CurrentSystemConfiguration\r\n")); err != nil {
t.Fatalf("write: %v", err)
}
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
buf := make([]byte, 1024)
var got strings.Builder
for {
n, err := conn.Read(buf)
if n > 0 {
got.Write(buf[:n])
}
if err != nil {
break
}
if strings.Contains(got.String(), "->OK") {
break
}
}
out := got.String()
for _, want := range []string{"margeServerUrl", "streaming.bose.com", "swUpdateUrl"} {
if !strings.Contains(out, want) {
t.Errorf("response missing %q\nfull response:\n%s", want, out)
}
}
}
+32
View File
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8" ?>
<info deviceID="DEADBEEFCAFE">
<name>Demo SoundTouch</name>
<type>SoundTouch 10</type>
<margeAccountUUID>0000000</margeAccountUUID>
<components>
<component>
<componentCategory>SCM</componentCategory>
<softwareVersion>27.0.6.46330.5043500 epdbuild.trunk.demo.2026-01-01T00:00:00</softwareVersion>
<serialNumber>SN0000000000000000DEMO</serialNumber>
</component>
<component>
<componentCategory>PackagedProduct</componentCategory>
<softwareVersion>27.0.6.46330.5043500 epdbuild.trunk.demo.2026-01-01T00:00:00</softwareVersion>
<serialNumber>000000P00000000DEMO</serialNumber>
</component>
</components>
<margeURL>https://streaming.bose.com</margeURL>
<networkInfo type="SCM">
<macAddress>02:00:00:00:00:01</macAddress>
<ipAddress>127.0.0.1</ipAddress>
</networkInfo>
<networkInfo type="SMSC">
<macAddress>02:00:00:00:00:01</macAddress>
<ipAddress>127.0.0.1</ipAddress>
</networkInfo>
<moduleType>sm2</moduleType>
<variant>rhino</variant>
<variantMode>normal</variantMode>
<countryCode>GB</countryCode>
<regionCode>GB</regionCode>
</info>
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" ?>
<presets>
<preset id="1" createdOn="1700000000" updatedOn="1700000000">
<ContentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s24939" sourceAccount="" isPresetable="true">
<itemName>Demo Radio</itemName>
<containerArt>https://example.invalid/preset1.jpg</containerArt>
</ContentItem>
</preset>
<preset id="2" createdOn="1700000000" updatedOn="1700000000">
<ContentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s00000" sourceAccount="" isPresetable="true">
<itemName>Demo News</itemName>
<containerArt>https://example.invalid/preset2.jpg</containerArt>
</ContentItem>
</preset>
</presets>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8" ?>
<recents/>
+194
View File
@@ -0,0 +1,194 @@
// Command screenshots drives a headless Chrome via chromedp to capture
// PNG screenshots of the soundtouch-service web UI for documentation.
//
// It is deliberately decoupled from any speaker/service setup: callers
// are responsible for having the service reachable at --base and any
// required devices already registered. See cmd/dummy-speaker for a
// matching no-hardware backend.
//
// Manifest format (JSON):
//
// {
// "shots": [
// {
// "name": "ui-settings",
// "path": "/web/",
// "click_selector": "button[onclick*=\"tab-settings\"]",
// "wait_selector": "#tab-settings.active",
// "viewport": {"width": 1280, "height": 900},
// "settle_ms": 250
// }
// ]
// }
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"path/filepath"
"time"
"github.com/chromedp/chromedp"
)
type viewport struct {
Width int `json:"width"`
Height int `json:"height"`
Scale float64 `json:"scale"`
}
type shot struct {
Name string `json:"name"`
Path string `json:"path"`
ClickSelector string `json:"click_selector,omitempty"`
WaitSelector string `json:"wait_selector,omitempty"`
Evaluate string `json:"evaluate,omitempty"` // JS to run after the click (e.g. to programmatically select a device + trigger summary)
WaitAfterEval string `json:"wait_after_eval,omitempty"` // selector to wait for once the JS evaluation has completed
Viewport viewport `json:"viewport,omitempty"`
SettleMs int `json:"settle_ms,omitempty"`
}
type manifest struct {
Shots []shot `json:"shots"`
}
func main() {
base := flag.String("base", "http://localhost:8000", "service base URL")
manifestPath := flag.String("manifest", "scripts/screenshots/manifest.json", "path to shot manifest JSON")
outDir := flag.String("out", "docs/images", "output directory for PNGs")
timeoutSec := flag.Int("timeout", 30, "per-shot timeout (seconds)")
flag.Parse()
m, err := readManifest(*manifestPath)
if err != nil {
log.Fatalf("read manifest: %v", err)
}
if err := os.MkdirAll(*outDir, 0o755); err != nil {
log.Fatalf("mkdir %s: %v", *outDir, err)
}
allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(),
append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", true),
chromedp.Flag("disable-gpu", true),
chromedp.Flag("hide-scrollbars", true),
)...)
defer cancelAlloc()
browserCtx, cancelBrowser := chromedp.NewContext(allocCtx)
defer cancelBrowser()
if err := chromedp.Run(browserCtx); err != nil {
log.Fatalf("launch browser: %v", err)
}
failed := 0
for _, sh := range m.Shots {
log.Printf("capturing %s", sh.Name)
if err := capture(browserCtx, *base, *outDir, sh, time.Duration(*timeoutSec)*time.Second); err != nil {
log.Printf(" failed: %v", err)
failed++
continue
}
log.Printf(" ok")
}
if failed > 0 {
log.Fatalf("%d shot(s) failed", failed)
}
}
func readManifest(path string) (*manifest, error) {
raw, err := os.ReadFile(path) //nolint:gosec
if err != nil {
return nil, err
}
var m manifest
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
return &m, nil
}
func capture(parent context.Context, baseURL, outDir string, sh shot, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(parent, timeout)
defer cancel()
w, h := sh.Viewport.Width, sh.Viewport.Height
if w == 0 {
w = 1280
}
if h == 0 {
h = 900
}
scale := sh.Viewport.Scale
if scale == 0 {
scale = 2 // retina-equivalent DPR; sharper text in captured PNGs
}
settle := time.Duration(sh.SettleMs) * time.Millisecond
if settle == 0 {
settle = 200 * time.Millisecond
}
tabCtx, tabCancel := chromedp.NewContext(ctx)
defer tabCancel()
url := baseURL + sh.Path
actions := []chromedp.Action{
chromedp.EmulateViewport(int64(w), int64(h), chromedp.EmulateScale(scale)),
chromedp.Navigate(url),
chromedp.WaitReady("body", chromedp.ByQuery),
}
if sh.ClickSelector != "" {
actions = append(actions,
chromedp.WaitVisible(sh.ClickSelector, chromedp.ByQuery),
chromedp.Click(sh.ClickSelector, chromedp.ByQuery),
)
}
if sh.WaitSelector != "" {
actions = append(actions, chromedp.WaitVisible(sh.WaitSelector, chromedp.ByQuery))
}
if sh.Evaluate != "" {
actions = append(actions, chromedp.Evaluate(sh.Evaluate, nil))
}
if sh.WaitAfterEval != "" {
actions = append(actions, chromedp.WaitVisible(sh.WaitAfterEval, chromedp.ByQuery))
}
actions = append(actions, chromedp.Sleep(settle))
var buf []byte
actions = append(actions, chromedp.FullScreenshot(&buf, 100))
if err := chromedp.Run(tabCtx, actions...); err != nil {
return fmt.Errorf("chromedp: %w", err)
}
outPath := filepath.Join(outDir, sh.Name+".png")
if err := os.WriteFile(outPath, buf, 0o644); err != nil { //nolint:gosec
return fmt.Errorf("write %s: %w", outPath, err)
}
return nil
}
+34
View File
@@ -0,0 +1,34 @@
{
"shots": [
{
"name": "ui-settings",
"path": "/",
"click_selector": "button[onclick*=\"tab-settings\"]",
"wait_selector": "#tab-settings.active",
"settle_ms": 300
},
{
"name": "ui-devices",
"path": "/",
"click_selector": "button[onclick*=\"tab-devices\"]",
"wait_selector": "#tab-devices.active",
"settle_ms": 500
},
{
"name": "ui-sync",
"path": "/",
"click_selector": "button[onclick*=\"tab-sync\"]",
"wait_selector": "#tab-sync.active",
"settle_ms": 300
},
{
"name": "ui-migration",
"path": "/",
"click_selector": "button[onclick*=\"tab-migration\"]",
"wait_selector": "#tab-migration.active",
"evaluate": "prepareMigration('DEADBEEFCAFE')",
"wait_after_eval": "#migration-summary",
"settle_ms": 4000
}
]
}
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# Orchestrates an end-to-end screenshot capture: spins up a clean
# soundtouch-service + dummy-speaker, drives the web UI in headless
# Chrome via the chromedp runner, then tears everything down.
#
# Outputs to docs/images/ by default. Override with OUT_DIR=/some/path.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$REPO_ROOT"
OUT_DIR="${OUT_DIR:-docs/images}"
SERVICE_PORT="${SERVICE_PORT:-8000}"
SPEAKER_PORT="${SPEAKER_PORT:-8090}"
DATA_DIR="$(mktemp -d -t soundtouch-screenshots-XXXXXX)"
LOG_DIR="$(mktemp -d -t soundtouch-screenshot-logs-XXXXXX)"
SERVICE_PID=""
SPEAKER_PID=""
cleanup() {
set +e
if [ -n "$SPEAKER_PID" ] && kill -0 "$SPEAKER_PID" 2>/dev/null; then
kill "$SPEAKER_PID"
wait "$SPEAKER_PID" 2>/dev/null
fi
if [ -n "$SERVICE_PID" ] && kill -0 "$SERVICE_PID" 2>/dev/null; then
kill "$SERVICE_PID"
wait "$SERVICE_PID" 2>/dev/null
fi
rm -rf "$DATA_DIR"
echo "logs retained at $LOG_DIR"
}
trap cleanup EXIT
echo "==> building binaries"
go build -o "$LOG_DIR/soundtouch-service" ./cmd/soundtouch-service
go build -o "$LOG_DIR/dummy-speaker" ./cmd/dummy-speaker
go build -o "$LOG_DIR/screenshots" ./scripts/screenshots
echo "==> seeding settings.json (generic hostname + discovery off to avoid leaking real network info)"
cat > "$DATA_DIR/settings.json" <<'EOF'
{
"server_url": "http://aftertouch.local:8000",
"https_server_url": "https://aftertouch.local:8443",
"discovery_enabled": false,
"discovery_interval": "1h"
}
EOF
echo "==> starting soundtouch-service on :$SERVICE_PORT (data: $DATA_DIR)"
"$LOG_DIR/soundtouch-service" --port "$SERVICE_PORT" --data-dir "$DATA_DIR" \
> "$LOG_DIR/service.log" 2>&1 &
SERVICE_PID=$!
echo "==> waiting for service to be ready"
for i in $(seq 1 30); do
if curl -fsS "http://127.0.0.1:$SERVICE_PORT/setup/devices" > /dev/null 2>&1; then
break
fi
if ! kill -0 "$SERVICE_PID" 2>/dev/null; then
echo "service died early; log tail:"
tail -40 "$LOG_DIR/service.log"
exit 1
fi
sleep 0.5
done
echo "==> starting dummy-speaker on :$SPEAKER_PORT (registering with service)"
# Register as bare IP (no port) so the service appends :8090 for HTTP and
# :17000 for telnet exactly the way it does with real hardware. This is
# also why the listeners below bind to the canonical Bose ports.
"$LOG_DIR/dummy-speaker" \
--listen "127.0.0.1:$SPEAKER_PORT" \
--telnet-listen "127.0.0.1:17000" \
--register "http://127.0.0.1:$SERVICE_PORT" \
--register-as "127.0.0.1" \
> "$LOG_DIR/speaker.log" 2>&1 &
SPEAKER_PID=$!
sleep 1
if ! kill -0 "$SPEAKER_PID" 2>/dev/null; then
echo "dummy-speaker died early; log tail:"
tail -40 "$LOG_DIR/speaker.log"
exit 1
fi
echo "==> capturing screenshots into $OUT_DIR"
"$LOG_DIR/screenshots" \
--base "http://127.0.0.1:$SERVICE_PORT" \
--manifest scripts/screenshots/manifest.json \
--out "$OUT_DIR"
echo "==> done"