diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go
index 9d19eb1..c041e04 100644
--- a/cmd/soundtouch-service/main.go
+++ b/cmd/soundtouch-service/main.go
@@ -871,6 +871,12 @@ func setupRouter(server *handlers.Server) *chi.Mux {
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)
r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
r.URL.Path = "/media/favicon-braille.svg"
server.HandleMedia()(w, r)
@@ -1090,6 +1096,7 @@ 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)
diff --git a/pkg/service/handlers/handlers_telnet_probe.go b/pkg/service/handlers/handlers_telnet_probe.go
new file mode 100644
index 0000000..eb38042
--- /dev/null
+++ b/pkg/service/handlers/handlers_telnet_probe.go
@@ -0,0 +1,80 @@
+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(``))
+}
+
+// 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)
+ }
+}
diff --git a/pkg/service/handlers/probe_registry.go b/pkg/service/handlers/probe_registry.go
new file mode 100644
index 0000000..30b2d73
--- /dev/null
+++ b/pkg/service/handlers/probe_registry.go
@@ -0,0 +1,61 @@
+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)
+}
diff --git a/pkg/service/handlers/probe_registry_test.go b/pkg/service/handlers/probe_registry_test.go
new file mode 100644
index 0000000..07f21fe
--- /dev/null
+++ b/pkg/service/handlers/probe_registry_test.go
@@ -0,0 +1,46 @@
+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")
+ }
+}
diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go
index 817a6f3..08e6e5a 100644
--- a/pkg/service/handlers/server.go
+++ b/pkg/service/handlers/server.go
@@ -61,6 +61,7 @@ type Server struct {
amazonClientSecret string
amazonRedirectURI string
amazonService *amazon.Service
+ probes *probeRegistry
}
// RequestSnapshot represents an immutable snapshot of an HTTP request.
@@ -95,6 +96,7 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, pro
recordEnabled: recordEnabled,
discoveryInterval: 5 * time.Minute,
discoveryEnabled: true,
+ probes: newProbeRegistry(),
}
return s
diff --git a/pkg/service/setup/telnet_probe.go b/pkg/service/setup/telnet_probe.go
new file mode 100644
index 0000000..fb0021d
--- /dev/null
+++ b/pkg/service/setup/telnet_probe.go
@@ -0,0 +1,189 @@
+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 /probe/`
+// 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 `: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 ` 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 err := t.Dial(); err != nil {
+ return nil, fmt.Errorf("telnet dial %s:17000 failed: %w", deviceIP, err)
+ }
+
+ 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
+}
diff --git a/pkg/service/setup/telnet_probe_test.go b/pkg/service/setup/telnet_probe_test.go
new file mode 100644
index 0000000..a57a04b
--- /dev/null
+++ b/pkg/service/setup/telnet_probe_test.go
@@ -0,0 +1,305 @@
+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/ 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")
+ }
+}