diff --git a/pkg/service/setup/migration_summary_telnet_test.go b/pkg/service/setup/migration_summary_telnet_test.go
new file mode 100644
index 0000000..b7651cd
--- /dev/null
+++ b/pkg/service/setup/migration_summary_telnet_test.go
@@ -0,0 +1,139 @@
+package setup
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// telnetSummaryEnv builds a Manager whose:
+// - SSH client is the supplied mockSSH (or a no-op if nil).
+// - Telnet client is the supplied fakeTelnet.
+// - Live :8090/info call hits an httptest server returning a minimal XML.
+//
+// The deviceIP returned is the httptest server's listener addr ("host:port"),
+// so the live-info call works; the SSH and telnet clients ignore the addr
+// and return whatever the fakes are scripted to return.
+func telnetSummaryEnv(t *testing.T, ssh *mockSSH, ft *fakeTelnet) (*Manager, string, func()) {
+ t.Helper()
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/xml")
+ _, _ = fmt.Fprint(w, `Test`)
+ }))
+
+ m := NewManager("http://example:8000", nil, nil)
+ m.NewSSH = func(string) SSHClient {
+ if ssh != nil {
+ return ssh
+ }
+ return &mockSSH{runFunc: func(string) (string, error) { return "", errors.New("ssh disabled in test") }}
+ }
+ m.NewTelnet = func(string) TelnetClient { return ft }
+
+ return m, server.Listener.Addr().String(), server.Close
+}
+
+func TestGetMigrationSummary_TelnetSucceedsSSHFails(t *testing.T) {
+ target := "http://example:8000"
+ ft := &fakeTelnet{
+ banner: "BoseShell\n-> ",
+ responses: map[string]string{
+ "getpdo CurrentSystemConfiguration": "margeServerUrl=" + target + "\n",
+ },
+ }
+
+ m, host, cleanup := telnetSummaryEnv(t, nil, ft)
+ defer cleanup()
+
+ summary, err := m.GetMigrationSummary(host, "", "", nil)
+ if err != nil {
+ t.Fatalf("GetMigrationSummary: %v", err)
+ }
+
+ if summary.SSHSuccess {
+ t.Errorf("SSHSuccess = true, want false")
+ }
+
+ if !summary.TelnetReachable {
+ t.Errorf("TelnetReachable = false, want true")
+ }
+
+ if !strings.Contains(summary.TelnetBanner, "BoseShell") {
+ t.Errorf("TelnetBanner = %q, want it to contain BoseShell", summary.TelnetBanner)
+ }
+
+ if !strings.Contains(summary.TelnetVerifiedConfig, target) {
+ t.Errorf("TelnetVerifiedConfig = %q, want it to contain %q", summary.TelnetVerifiedConfig, target)
+ }
+}
+
+func TestGetMigrationSummary_TelnetFailsSSHFails(t *testing.T) {
+ ft := &fakeTelnet{dialErr: errors.New("connection refused")}
+
+ m, host, cleanup := telnetSummaryEnv(t, nil, ft)
+ defer cleanup()
+
+ summary, err := m.GetMigrationSummary(host, "", "", nil)
+ if err != nil {
+ t.Fatalf("GetMigrationSummary: %v", err)
+ }
+
+ if summary.SSHSuccess {
+ t.Errorf("SSHSuccess = true, want false")
+ }
+
+ if summary.TelnetReachable {
+ t.Errorf("TelnetReachable = true, want false")
+ }
+
+ if !strings.Contains(summary.TelnetProbeError, "connection refused") {
+ t.Errorf("TelnetProbeError = %q, want connection refused", summary.TelnetProbeError)
+ }
+}
+
+func TestGetMigrationSummary_TelnetSucceedsSSHSucceeds(t *testing.T) {
+ target := "http://example:8000"
+ ft := &fakeTelnet{
+ responses: map[string]string{
+ "getpdo CurrentSystemConfiguration": "margeServerUrl=" + target + "\n",
+ },
+ }
+
+ // SSH mock returns enough for SSHSuccess to be true (cat /opt/Bose/etc/...).
+ ssh := &mockSSH{
+ runFunc: func(cmd string) (string, error) {
+ switch {
+ case strings.HasPrefix(cmd, "cat "+SoundTouchSdkPrivateCfgPath):
+ return `` + target + ``, nil
+ case strings.HasPrefix(cmd, "[ -f"):
+ return "", errors.New("not found")
+ default:
+ return "", nil
+ }
+ },
+ }
+
+ m, host, cleanup := telnetSummaryEnv(t, ssh, ft)
+ defer cleanup()
+
+ summary, err := m.GetMigrationSummary(host, "", "", nil)
+ if err != nil {
+ t.Fatalf("GetMigrationSummary: %v", err)
+ }
+
+ if !summary.SSHSuccess {
+ t.Errorf("SSHSuccess = false, want true")
+ }
+
+ if !summary.TelnetReachable {
+ t.Errorf("TelnetReachable = false, want true")
+ }
+
+ if !strings.Contains(summary.TelnetVerifiedConfig, target) {
+ t.Errorf("TelnetVerifiedConfig = %q, want %q", summary.TelnetVerifiedConfig, target)
+ }
+}
diff --git a/pkg/service/setup/setup.go b/pkg/service/setup/setup.go
index e2aff8f..90a6d07 100644
--- a/pkg/service/setup/setup.go
+++ b/pkg/service/setup/setup.go
@@ -244,6 +244,20 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
SSHSuccess: false,
}
+ // Run the telnet preflight in parallel with the SSH-based probes below.
+ // Both transports are queried independently: SSH gives access to
+ // /etc/hosts, /etc/resolv.conf and the on-device XML config; telnet's
+ // `getpdo CurrentSystemConfiguration` reports the live URL set without
+ // needing root. They are complementary, so we wait for both and merge
+ // the results — total wall time = max(ssh, telnet).
+ telnetCh := make(chan MigrationSummary, 1)
+
+ go func() {
+ var local MigrationSummary
+ m.telnetPreflight(&local, deviceIP)
+ telnetCh <- local
+ }()
+
// Populate device info from datastore and live info
m.populateDeviceInfo(summary, deviceIP)
@@ -322,6 +336,13 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
}
}
+ // 8. Merge telnet preflight results (started in parallel at the top).
+ telnetResult := <-telnetCh
+ summary.TelnetReachable = telnetResult.TelnetReachable
+ summary.TelnetBanner = telnetResult.TelnetBanner
+ summary.TelnetVerifiedConfig = telnetResult.TelnetVerifiedConfig
+ summary.TelnetProbeError = telnetResult.TelnetProbeError
+
return summary, nil
}