Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb71253690 | ||
|
|
0e8ab1cd89 | ||
|
|
952200ee26 | ||
|
|
62dd53777d | ||
|
|
f0de4864b6 | ||
|
|
9a7646bf58 | ||
|
|
d74bb9b5ca | ||
|
|
dc924e351c | ||
|
|
fa2883f66b | ||
|
|
10c9edbb25 | ||
|
|
93c3f68443 |
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 334 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 544 KiB After Width: | Height: | Size: 512 KiB |
|
Before Width: | Height: | Size: 516 KiB After Width: | Height: | Size: 463 KiB |
|
Before Width: | Height: | Size: 266 KiB After Width: | Height: | Size: 95 KiB |
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -759,9 +759,37 @@ func mapToFullResponseSource(s models.ConfiguredSource) models.FullResponseSourc
|
||||
fullSource.Username = s.SourceKeyAccount
|
||||
}
|
||||
|
||||
// SourceProviderID is a required protobuf field inside recents/preset
|
||||
// source blocks. A persisted source that lost its SourceKey.Type (e.g.
|
||||
// poisoned by an older "INVALID" classification) lands here with an
|
||||
// empty value, so fall back to the canonical default whose ID matches.
|
||||
if fullSource.SourceProviderID == "" && s.ID != "" {
|
||||
if def := canonicalProviderIDByID(s.ID); def != "" {
|
||||
fullSource.SourceProviderID = def
|
||||
}
|
||||
}
|
||||
|
||||
return fullSource
|
||||
}
|
||||
|
||||
// canonicalProviderIDByID returns the canonical SourceProviderID for one of
|
||||
// the well-known built-in source IDs (10001..10005), or "" if the ID isn't
|
||||
// recognised.
|
||||
func canonicalProviderIDByID(id string) string {
|
||||
switch id {
|
||||
case "10002":
|
||||
return strconv.Itoa(constants.InternetRadioProviderID)
|
||||
case "10003":
|
||||
return strconv.Itoa(constants.LocalInternetRadioProviderID)
|
||||
case "10004":
|
||||
return strconv.Itoa(constants.TuneinProviderID)
|
||||
case "10005":
|
||||
return strconv.Itoa(constants.RadioBrowserProviderID)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func mapPresetsToFullResponse(presets []models.ServicePreset, sources []models.ConfiguredSource) []models.FullResponsePreset {
|
||||
var fullPresets []models.FullResponsePreset
|
||||
|
||||
@@ -1135,10 +1163,13 @@ func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parity: use self-closing tags for empty components and sourceSettings
|
||||
// Parity: use self-closing tags for empty components and sourceSettings.
|
||||
// NOTE: do NOT strip empty <sourceproviderid> elements here — the speaker
|
||||
// decodes /full into a protobuf message where recents>recent>source>
|
||||
// sourceproviderid is a *required* field, so removing even an empty element
|
||||
// trips "missing required field" and aborts the whole account sync.
|
||||
data = bytes.ReplaceAll(data, []byte("<components></components>"), []byte("<components/>"))
|
||||
data = bytes.ReplaceAll(data, []byte("<sourceSettings></sourceSettings>"), []byte("<sourceSettings/>"))
|
||||
data = bytes.ReplaceAll(data, []byte("<sourceproviderid></sourceproviderid>"), []byte(""))
|
||||
|
||||
return append([]byte(constants.XMLHeader), data...), nil
|
||||
}
|
||||
@@ -1482,16 +1513,18 @@ func classifyLearnedSource(src *models.ConfiguredSource, sourceID, location, sou
|
||||
switch {
|
||||
case sourceProviderID == strconv.Itoa(constants.TuneinProviderID) || sourceID == constants.ProviderTunein || strings.Contains(location, "/v1/playback/station/"):
|
||||
classifyAsTuneIn(src)
|
||||
case sourceID == constants.ProviderLocalInternetRadio:
|
||||
case sourceProviderID == strconv.Itoa(constants.LocalInternetRadioProviderID) || sourceID == constants.ProviderLocalInternetRadio || strings.Contains(location, "/custom/v1/playback/"):
|
||||
classifyAsLocalInternetRadio(src)
|
||||
case strings.Contains(location, "spotify") || strings.Contains(location, "c3BvdGlme") || sourceID == constants.ProviderSpotify:
|
||||
classifyAsSpotify(src)
|
||||
case strings.Contains(location, "amazon") || sourceID == constants.ProviderAmazon || sourceProviderID == strconv.Itoa(constants.AmazonProviderID):
|
||||
classifyAsAmazon(src)
|
||||
default:
|
||||
src.SourceKey.Type = "INVALID"
|
||||
src.SourceKeyType = "INVALID"
|
||||
}
|
||||
// If we can't classify, leave SourceKey.Type empty so the canonical-by-ID
|
||||
// fallback in mapToFullResponseSource and the read-side applyCanonicalDefaults
|
||||
// still have a chance to repair it. Writing a literal "INVALID" used to lock
|
||||
// the source out of every repair path, producing a <source> block with no
|
||||
// <sourceproviderid> and breaking the speaker's protobuf required-field check.
|
||||
}
|
||||
|
||||
func classifyAsTuneIn(src *models.ConfiguredSource) {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package marge
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// TestAccountFullToXML_RecentWithPoisonedSourceProviderID is a regression test
|
||||
// for the production failure where the speaker's BoseApp rejected the
|
||||
// /streaming/account/.../full response with:
|
||||
//
|
||||
// protobuf::FatalException - CHECK failed: IsInitialized():
|
||||
// Message of type "MargePB.account" is missing required fields:
|
||||
// devices.device[1].recents.recent[0].source.sourceproviderid
|
||||
//
|
||||
// Trigger sequence reproduced here:
|
||||
//
|
||||
// 1. The device POSTs a "laut.fm" recent (location "/custom/v1/playback/...")
|
||||
// against an account that has no Sources.xml yet.
|
||||
// 2. classifyLearnedSource fails to recognise /custom/v1/playback/ and the
|
||||
// numeric source id "10003", and historically wrote sourceKey type="INVALID"
|
||||
// with an empty sourceproviderid.
|
||||
// 3. The persisted Sources.xml then re-appears in /full with an empty
|
||||
// <sourceproviderid> element inside recents>recent>source, which the
|
||||
// post-marshal cleanup stripped entirely — making the speaker's protobuf
|
||||
// decode fail on a required field.
|
||||
//
|
||||
// The fix combines three things, all exercised below:
|
||||
//
|
||||
// - classifyLearnedSource recognises LocalInternetRadio via the
|
||||
// /custom/v1/playback/ URL pattern and via sourceProviderID == "11".
|
||||
// - mapToFullResponseSource falls back to the canonical SourceProviderID
|
||||
// keyed by source ID, so already-poisoned data on disk still renders a
|
||||
// non-empty providerid.
|
||||
// - AccountFullToXML no longer strips empty <sourceproviderid> elements
|
||||
// inside recents/preset source blocks.
|
||||
func TestAccountFullToXML_RecentWithPoisonedSourceProviderID(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "marge-recent-provid-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
account := "1234567"
|
||||
device := "ABCDEF012345"
|
||||
|
||||
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", device)
|
||||
if err := os.MkdirAll(deviceDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create device dir: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, "DeviceInfo.xml"), []byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="ABCDEF012345">
|
||||
<name>Kitchen</name>
|
||||
<type>SoundTouch</type>
|
||||
<moduleType>10 sm2</moduleType>
|
||||
</info>`), 0644); err != nil {
|
||||
t.Fatalf("Failed to write DeviceInfo.xml: %v", err)
|
||||
}
|
||||
|
||||
// Sources.xml reproduces the poisoned entry observed in the user's
|
||||
// backup (May 11): id="10003" with sourceKey type="INVALID" and no
|
||||
// sourceproviderid attribute. Older repair paths (applyCanonicalDefaults,
|
||||
// ensureSourceProviderID) all key off sourceKey.type, so the entry stays
|
||||
// broken at load time.
|
||||
sourcesXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sources>
|
||||
<source id="10003" secret="" secretType="">
|
||||
<credential type=""></credential>
|
||||
<sourceKey type="INVALID" account=""></sourceKey>
|
||||
</source>
|
||||
</sources>`
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(sourcesXML), 0644); err != nil {
|
||||
t.Fatalf("Failed to write Sources.xml: %v", err)
|
||||
}
|
||||
|
||||
// Recents.xml references the poisoned source via <sourceid>10003</sourceid>.
|
||||
// The location is a laut.fm stream proxied through /custom/v1/playback/ —
|
||||
// exactly the URL pattern the old classifier failed to recognise.
|
||||
recentsXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<recents>
|
||||
<recent deviceID="ABCDEF012345" utcTime="1778014606" id="260505002">
|
||||
<contentItem source="INVALID" type="stationurl" location="http://192.168.123.123/custom/v1/playback/aHR0cHM6Ly9zdHJlYW0ubGF1dC5mbS9zbW9vdGgtamF6eg==" sourceAccount="" isPresetable="true">
|
||||
<itemName>Smooth Jazz Instrumental 24/7</itemName>
|
||||
</contentItem>
|
||||
<createdOn>2026-05-05T20:56:49.305+00:00</createdOn>
|
||||
<updatedOn>2026-05-05T20:56:49.305+00:00</updatedOn>
|
||||
<sourceid>10003</sourceid>
|
||||
</recent>
|
||||
</recents>`
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), []byte(recentsXML), 0644); err != nil {
|
||||
t.Fatalf("Failed to write Recents.xml: %v", err)
|
||||
}
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
|
||||
fullXML, err := AccountFullToXML(ds, account)
|
||||
if err != nil {
|
||||
t.Fatalf("AccountFullToXML failed: %v", err)
|
||||
}
|
||||
|
||||
body := string(fullXML)
|
||||
|
||||
// Locate the recents block and assert every <source> inside it carries a
|
||||
// non-empty <sourceproviderid>. Without the fix, the post-marshal
|
||||
// strip-empty step deletes the empty element and the speaker rejects
|
||||
// the message with "missing required field".
|
||||
recentsRE := regexp.MustCompile(`(?s)<recents>(.*?)</recents>`)
|
||||
matches := recentsRE.FindAllStringSubmatch(body, -1)
|
||||
if len(matches) == 0 {
|
||||
t.Fatalf("Expected at least one <recents> block; body:\n%s", body)
|
||||
}
|
||||
|
||||
sourceInRecentRE := regexp.MustCompile(`(?s)<source(?:\s[^>]*)?>(.*?)</source>`)
|
||||
|
||||
for _, recentsBlock := range matches {
|
||||
for _, src := range sourceInRecentRE.FindAllStringSubmatch(recentsBlock[1], -1) {
|
||||
inner := src[1]
|
||||
if !strings.Contains(inner, "<sourceproviderid>") {
|
||||
t.Errorf("<source> inside <recents> has no <sourceproviderid> element; block:\n%s", src[0])
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.Contains(inner, "<sourceproviderid></sourceproviderid>") {
|
||||
t.Errorf("<source> inside <recents> has empty <sourceproviderid>; block:\n%s", src[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And spot-check the canonical fallback fired for the laut.fm recent.
|
||||
if !strings.Contains(body, "<sourceproviderid>11</sourceproviderid>") {
|
||||
t.Errorf("Expected <sourceproviderid>11</sourceproviderid> (LocalInternetRadio) in /full; body:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyLearnedSource_LocalInternetRadioCustomPlayback locks in the
|
||||
// classifier behaviour: a recent POSTed with a /custom/v1/playback/ URL must
|
||||
// classify as LocalInternetRadio. Previously this fell into the "INVALID"
|
||||
// default and poisoned Sources.xml — see the regression test above.
|
||||
func TestClassifyLearnedSource_LocalInternetRadioCustomPlayback(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
sourceID string
|
||||
location string
|
||||
sourceProviderID string
|
||||
}{
|
||||
{
|
||||
name: "laut.fm /custom/v1/playback URL",
|
||||
sourceID: "10003",
|
||||
location: "http://192.168.123.123/custom/v1/playback/aHR0cHM6Ly9zdHJlYW0ubGF1dC5mbS9zbW9vdGgtamF6eg==",
|
||||
},
|
||||
{
|
||||
name: "sourceProviderID==11 alone",
|
||||
sourceID: "999999",
|
||||
location: "http://example.invalid/whatever",
|
||||
sourceProviderID: "11",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
src := createLearnedSource(tc.sourceID, tc.location, "", "", tc.sourceProviderID, "", "")
|
||||
|
||||
if src.SourceKey.Type == "INVALID" || src.SourceKeyType == "INVALID" {
|
||||
t.Errorf("classifier wrote INVALID for %s; src=%+v", tc.name, src)
|
||||
}
|
||||
|
||||
if src.SourceKey.Type != "LOCAL_INTERNET_RADIO" {
|
||||
t.Errorf("expected SourceKey.Type=LOCAL_INTERNET_RADIO, got %q", src.SourceKey.Type)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyLearnedSource_UnknownLeavesKeyEmpty verifies the new default
|
||||
// branch leaves SourceKey.Type empty instead of writing the literal "INVALID"
|
||||
// sentinel that locks the source out of every downstream repair path.
|
||||
func TestClassifyLearnedSource_UnknownLeavesKeyEmpty(t *testing.T) {
|
||||
src := createLearnedSource("SOMETHING_UNKNOWN", "http://example.invalid/nothing", "", "", "", "", "")
|
||||
|
||||
if src.SourceKey.Type == "INVALID" || src.SourceKeyType == "INVALID" {
|
||||
t.Errorf("classifier still writes INVALID sentinel; src=%+v", src)
|
||||
}
|
||||
|
||||
if src.SourceKey.Type != "" {
|
||||
t.Errorf("expected SourceKey.Type empty for an unrecognised source, got %q", src.SourceKey.Type)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<recents/>
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
set -eo pipefail
|
||||
|
||||
VERSION=${VERSION:-0.73.0}
|
||||
VERSION=${VERSION:-0.74.0}
|
||||
GH_REPO=${GH_REPO:-gesellix/Bose-SoundTouch}
|
||||
BINARY_URL=${BINARY_URL:-https://github.com/$GH_REPO/releases/download/v$VERSION/soundtouch-service-v$VERSION-linux-armv7}
|
||||
INIT_SCRIPT_URL=${INIT_SCRIPT_URL:-https://raw.githubusercontent.com/$GH_REPO/v$VERSION/scripts/on-device-install/aftertouch}
|
||||
|
||||
@@ -28,7 +28,7 @@ set -euo pipefail
|
||||
# - Safe to re-run; it will update binary/config/unit and restart the service.
|
||||
# ==============================================================================
|
||||
|
||||
VERSION="${1:-${VERSION:-v0.24.0}}"
|
||||
VERSION="${1:-${VERSION:-v0.74.0}}"
|
||||
# Normalize version prefix
|
||||
if [[ ! "$VERSION" =~ ^v ]]; then
|
||||
VERSION="v${VERSION}"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||