mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
Adds an SSH-free third migration path that drives the SoundTouch device's diagnostic shell on TCP port 17000, plus a hardened replacement for the fragile /setMargeAccount HTTP pairing call. * `pkg/telnet` — new reusable, dependency-free client (sibling of `pkg/ssh`) with deadline-driven Dial / Probe / SendCommand / Close. Mock-server tests cover happy path, command-not-found, mid-stream close, and the wedged-device read-timeout scenario. * `setup.MigrationMethodTelnet` — runs `sys configuration` for all four URLs plus the parallel `envswitch boseurls set` persistence layer that otherwise wins on reboot, then verifies with `getpdo CurrentSystemConfiguration`. Aborts on the first non-OK response so configuration is never half-written. No SSH backup or rw pre-flight (the path is SSH-free by design). * `setup.PairAccount` — probes :8090/supportedURLs first, time-bounds POST /setMargeAccount aggressively (5s connect / 12s total) to avoid the hangs reported in #236, and falls back to `envswitch accountid set <id>` over telnet when the HTTP endpoint is missing or wedged. Returns a PairAccountResult breadcrumb so the UI can show which path actually succeeded. * `setup.Reboot(deviceIP, method)` — gains a RebootMethod selector; RebootMethodSSH stays the default (preserving prior behavior), RebootMethodTelnet sends `sys reboot` over a fresh telnet session and treats the inevitable socket-close as success. * New endpoints on `/setup`: - GET /account-id-suggestions/{deviceId} — returns the device's current margeAccountUUID (from :8090/info) plus known account IDs from the datastore, so the UI can offer reuse. - POST /pair-account/{deviceId}?account_id=NNNNNNN — invokes PairAccount; the existing reboot endpoint reads ?method=ssh|telnet from the query string. * Helpers `IsValidAccountID` (exactly 7 digits) and `GenerateAccountID` (crypto/rand, retries on collision against a known-IDs list). Documentation in docs/analysis/TELNET-MIGRATION-METHOD.md is updated to match the implementation: bare-URL convention for `soundtouch-service`, no automatic `sys reboot` (user-initiated via the existing button with a method selector), and the realised package layout. The /etc/hosts method is intentionally not exposed in the new flow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
95 lines
2.1 KiB
Go
95 lines
2.1 KiB
Go
package setup
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestReboot_DefaultIsSSH(t *testing.T) {
|
|
var ranCmds []string
|
|
|
|
m := &Manager{
|
|
NewSSH: func(host string) SSHClient {
|
|
return &mockSSH{runFunc: func(cmd string) (string, error) {
|
|
ranCmds = append(ranCmds, cmd)
|
|
return "ok\n", nil
|
|
}}
|
|
},
|
|
}
|
|
|
|
if _, err := m.Reboot("192.0.2.1", ""); err != nil {
|
|
t.Fatalf("Reboot: %v", err)
|
|
}
|
|
|
|
found := false
|
|
for _, c := range ranCmds {
|
|
if strings.Contains(c, "reboot") {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
t.Errorf("expected SSH `reboot` command, got %v", ranCmds)
|
|
}
|
|
}
|
|
|
|
func TestReboot_TelnetSendsSysReboot(t *testing.T) {
|
|
f := &fakeTelnet{
|
|
responses: map[string]string{"sys reboot": "OK\n"},
|
|
}
|
|
|
|
m := &Manager{
|
|
NewTelnet: func(host string) TelnetClient { return f },
|
|
}
|
|
|
|
if _, err := m.Reboot("192.0.2.1", RebootMethodTelnet); err != nil {
|
|
t.Fatalf("Reboot: %v", err)
|
|
}
|
|
|
|
if len(f.commands) != 1 || f.commands[0] != "sys reboot" {
|
|
t.Errorf("commands = %v, want [sys reboot]", f.commands)
|
|
}
|
|
}
|
|
|
|
func TestReboot_TelnetTreatsCloseAsSuccess(t *testing.T) {
|
|
// The device closes the socket as part of rebooting. SendCommand surfaces
|
|
// that as an EOF/closed error; the reboot path must absorb it.
|
|
f := &fakeTelnet{
|
|
fail: map[string]error{"sys reboot": errors.New("EOF")},
|
|
}
|
|
|
|
m := &Manager{
|
|
NewTelnet: func(host string) TelnetClient { return f },
|
|
}
|
|
|
|
out, err := m.Reboot("192.0.2.1", RebootMethodTelnet)
|
|
if err != nil {
|
|
t.Fatalf("Reboot should swallow socket-close after sys reboot, got %v", err)
|
|
}
|
|
|
|
if !strings.Contains(out, "connection closed by reboot") {
|
|
t.Errorf("output should annotate the close, got %q", out)
|
|
}
|
|
}
|
|
|
|
func TestReboot_TelnetSurfacesDialError(t *testing.T) {
|
|
f := &fakeTelnet{dialErr: errors.New("connection refused")}
|
|
m := &Manager{
|
|
NewTelnet: func(host string) TelnetClient { return f },
|
|
}
|
|
|
|
if _, err := m.Reboot("192.0.2.1", RebootMethodTelnet); err == nil {
|
|
t.Fatal("expected dial error, got nil")
|
|
}
|
|
}
|
|
|
|
func TestReboot_UnknownMethodErrors(t *testing.T) {
|
|
m := &Manager{}
|
|
|
|
if _, err := m.Reboot("192.0.2.1", RebootMethod("ftp")); err == nil {
|
|
t.Fatal("expected error for unsupported reboot method")
|
|
}
|
|
}
|