mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 00:26:29 +00:00
feat(setup): read-only telnet preflight populating MigrationSummary
Adds Manager.telnetPreflight that dials port 17000, captures the banner, and runs `getpdo CurrentSystemConfiguration` to read back the device's live URL configuration. Errors are recorded on TelnetProbeError instead of returned, so the probe is best-effort and never breaks summary construction. This is the data-gathering layer that the four already-declared TelnetReachable / TelnetBanner / TelnetVerifiedConfig / TelnetProbeError fields on MigrationSummary were waiting for. Subsequent iterations wire the preflight into GetMigrationSummary (in parallel with SSH) and use TelnetVerifiedConfig as a SSH-free signal for "already migrated". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
eab1b7a15a
commit
c84cfeb757
@@ -0,0 +1,57 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// telnetPreflight performs a read-only check of the device's port-17000
|
||||
// diagnostic shell and populates the Telnet* fields on summary.
|
||||
//
|
||||
// It exists so the migration UI can decide whether to offer the telnet
|
||||
// method, and so a telnet-only (SSH-less) device can still tell us whether
|
||||
// it is already pointing at our service. The probe is deliberately scoped
|
||||
// to safe, non-mutating commands:
|
||||
//
|
||||
// 1. TCP dial :17000 (Manager.NewTelnet handles the timeouts).
|
||||
// 2. Read whatever banner the shell emits.
|
||||
// 3. `getpdo CurrentSystemConfiguration` — a read-only command; if the
|
||||
// device answers with "command not found" we record that too so the UI
|
||||
// can disable the telnet option with a reason.
|
||||
//
|
||||
// Errors are recorded on summary.TelnetProbeError rather than returned, so
|
||||
// preflight is best-effort and never breaks the rest of GetMigrationSummary.
|
||||
func (m *Manager) telnetPreflight(summary *MigrationSummary, deviceIP string) {
|
||||
if m.NewTelnet == nil {
|
||||
summary.TelnetProbeError = "telnet client not configured"
|
||||
return
|
||||
}
|
||||
|
||||
t := m.NewTelnet(deviceIP)
|
||||
|
||||
if err := t.Dial(); err != nil {
|
||||
summary.TelnetProbeError = fmt.Sprintf("dial %s:17000: %v", deviceIP, err)
|
||||
return
|
||||
}
|
||||
|
||||
defer func() { _ = t.Close() }()
|
||||
|
||||
summary.TelnetReachable = true
|
||||
|
||||
if banner, _ := t.Probe(); banner != "" {
|
||||
summary.TelnetBanner = strings.TrimSpace(banner)
|
||||
}
|
||||
|
||||
resp, err := t.SendCommand("getpdo CurrentSystemConfiguration")
|
||||
if err != nil {
|
||||
summary.TelnetProbeError = fmt.Sprintf("getpdo CurrentSystemConfiguration: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if isCommandNotFound(resp) {
|
||||
summary.TelnetProbeError = "device rejected getpdo CurrentSystemConfiguration (firmware does not expose it)"
|
||||
return
|
||||
}
|
||||
|
||||
summary.TelnetVerifiedConfig = strings.TrimRight(resp, "\r\n")
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTelnetPreflight_HappyPath(t *testing.T) {
|
||||
target := "http://example:8000"
|
||||
f := &fakeTelnet{
|
||||
banner: "BoseShell\n-> ",
|
||||
responses: map[string]string{
|
||||
"getpdo CurrentSystemConfiguration": "margeServerUrl=" + target + "\nbmxRegistryUrl=" + target + "/bmx/registry/v1/services\n",
|
||||
},
|
||||
}
|
||||
m := newFakeTelnetManager(f)
|
||||
|
||||
summary := &MigrationSummary{}
|
||||
m.telnetPreflight(summary, "192.0.2.1")
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if summary.TelnetProbeError != "" {
|
||||
t.Errorf("TelnetProbeError = %q, want empty on happy path", summary.TelnetProbeError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelnetPreflight_DialFailureRecorded(t *testing.T) {
|
||||
f := &fakeTelnet{dialErr: errors.New("connection refused")}
|
||||
m := newFakeTelnetManager(f)
|
||||
|
||||
summary := &MigrationSummary{}
|
||||
m.telnetPreflight(summary, "192.0.2.1")
|
||||
|
||||
if summary.TelnetReachable {
|
||||
t.Errorf("TelnetReachable = true, want false on dial failure")
|
||||
}
|
||||
|
||||
if !strings.Contains(summary.TelnetProbeError, "connection refused") {
|
||||
t.Errorf("TelnetProbeError = %q, want it to wrap connection refused", summary.TelnetProbeError)
|
||||
}
|
||||
|
||||
if len(f.commands) != 0 {
|
||||
t.Errorf("commands sent on dial failure: %v, want none", f.commands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelnetPreflight_GetpdoCommandNotFoundRecorded(t *testing.T) {
|
||||
f := &fakeTelnet{
|
||||
responses: map[string]string{
|
||||
// Default fakeTelnet behaviour returns "Command not found\n" for
|
||||
// any command not in the map. We rely on that here.
|
||||
},
|
||||
}
|
||||
m := newFakeTelnetManager(f)
|
||||
|
||||
summary := &MigrationSummary{}
|
||||
m.telnetPreflight(summary, "192.0.2.1")
|
||||
|
||||
if !summary.TelnetReachable {
|
||||
t.Errorf("TelnetReachable = false, want true (TCP dial succeeded)")
|
||||
}
|
||||
|
||||
if summary.TelnetVerifiedConfig != "" {
|
||||
t.Errorf("TelnetVerifiedConfig = %q, want empty when getpdo is rejected", summary.TelnetVerifiedConfig)
|
||||
}
|
||||
|
||||
if !strings.Contains(summary.TelnetProbeError, "getpdo") {
|
||||
t.Errorf("TelnetProbeError = %q, want it to mention the rejected command", summary.TelnetProbeError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelnetPreflight_TransportErrorRecorded(t *testing.T) {
|
||||
f := &fakeTelnet{
|
||||
fail: map[string]error{
|
||||
"getpdo CurrentSystemConfiguration": errors.New("read: broken pipe"),
|
||||
},
|
||||
}
|
||||
m := newFakeTelnetManager(f)
|
||||
|
||||
summary := &MigrationSummary{}
|
||||
m.telnetPreflight(summary, "192.0.2.1")
|
||||
|
||||
if !summary.TelnetReachable {
|
||||
t.Errorf("TelnetReachable = false, want true (dial succeeded before send)")
|
||||
}
|
||||
|
||||
if !strings.Contains(summary.TelnetProbeError, "broken pipe") {
|
||||
t.Errorf("TelnetProbeError = %q, want it to wrap broken pipe", summary.TelnetProbeError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelnetPreflight_NoNewTelnetRecordsConfigurationError(t *testing.T) {
|
||||
m := &Manager{ServerURL: "http://example:8000"} // NewTelnet deliberately nil
|
||||
|
||||
summary := &MigrationSummary{}
|
||||
m.telnetPreflight(summary, "192.0.2.1")
|
||||
|
||||
if summary.TelnetReachable {
|
||||
t.Errorf("TelnetReachable = true, want false when NewTelnet is nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(summary.TelnetProbeError, "not configured") {
|
||||
t.Errorf("TelnetProbeError = %q, want it to mention configuration", summary.TelnetProbeError)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user