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>
173 lines
4.3 KiB
Go
173 lines
4.3 KiB
Go
// Package telnet provides a minimal line-oriented client for the SoundTouch
|
|
// device's diagnostic shell on TCP port 17000.
|
|
//
|
|
// The protocol observed in the wild is a plain TCP stream with no Telnet
|
|
// option negotiation (no IAC sequences), so the client uses the standard
|
|
// library's net package directly. All I/O is deadline-driven so a wedged
|
|
// device can never stall the caller indefinitely.
|
|
package telnet
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
// Default values for a fresh Client.
|
|
const (
|
|
DefaultPort = 17000
|
|
DefaultDialTimeout = 2 * time.Second
|
|
DefaultReadTimeout = 5 * time.Second
|
|
DefaultWriteTimeout = 2 * time.Second
|
|
// idleWindow is how long we wait for further bytes after the first
|
|
// byte of a response before treating the response as complete.
|
|
idleWindow = 400 * time.Millisecond
|
|
)
|
|
|
|
// Client is a connected (or about-to-be-connected) session to a SoundTouch
|
|
// diagnostic shell. A Client is not safe for concurrent use; create one per
|
|
// device interaction.
|
|
type Client struct {
|
|
Host string
|
|
Port int
|
|
DialTimeout time.Duration
|
|
ReadTimeout time.Duration
|
|
WriteTimeout time.Duration
|
|
|
|
conn net.Conn
|
|
}
|
|
|
|
// NewClient returns a Client targeting host:17000 with the default timeouts.
|
|
func NewClient(host string) *Client {
|
|
return &Client{
|
|
Host: host,
|
|
Port: DefaultPort,
|
|
DialTimeout: DefaultDialTimeout,
|
|
ReadTimeout: DefaultReadTimeout,
|
|
WriteTimeout: DefaultWriteTimeout,
|
|
}
|
|
}
|
|
|
|
// Dial establishes the TCP connection. Subsequent calls are a no-op as long
|
|
// as the existing connection is still open.
|
|
func (c *Client) Dial() error {
|
|
if c.conn != nil {
|
|
return nil
|
|
}
|
|
|
|
addr := net.JoinHostPort(c.Host, strconv.Itoa(c.Port))
|
|
|
|
conn, err := net.DialTimeout("tcp", addr, c.DialTimeout)
|
|
if err != nil {
|
|
return fmt.Errorf("dial %s: %w", addr, err)
|
|
}
|
|
|
|
c.conn = conn
|
|
|
|
return nil
|
|
}
|
|
|
|
// Close terminates the TCP connection. Calling Close on a closed Client is a
|
|
// no-op.
|
|
func (c *Client) Close() error {
|
|
if c.conn == nil {
|
|
return nil
|
|
}
|
|
|
|
err := c.conn.Close()
|
|
c.conn = nil
|
|
|
|
return err
|
|
}
|
|
|
|
// Probe reads any banner the device emits immediately after connect. It
|
|
// returns whatever bytes arrive within a short window; an empty banner is
|
|
// not treated as an error because some firmware revisions stay silent until
|
|
// the first command.
|
|
func (c *Client) Probe() (string, error) {
|
|
if c.conn == nil {
|
|
return "", errors.New("telnet: not connected")
|
|
}
|
|
|
|
if err := c.conn.SetReadDeadline(time.Now().Add(idleWindow * 2)); err != nil {
|
|
return "", fmt.Errorf("set read deadline: %w", err)
|
|
}
|
|
|
|
buf := make([]byte, 1024)
|
|
|
|
n, err := c.conn.Read(buf)
|
|
if err != nil && !errors.Is(err, os.ErrDeadlineExceeded) {
|
|
return "", fmt.Errorf("read banner: %w", err)
|
|
}
|
|
|
|
return string(buf[:n]), nil
|
|
}
|
|
|
|
// SendCommand writes cmd followed by CRLF and reads the device's response.
|
|
// The read terminates when the connection has been idle for idleWindow after
|
|
// the first byte arrived, or when the overall ReadTimeout is reached.
|
|
//
|
|
// Returns the raw response text (callers decide what counts as success — the
|
|
// device's textual conventions vary by firmware: some commands return "OK",
|
|
// others echo state, others return nothing).
|
|
func (c *Client) SendCommand(cmd string) (string, error) {
|
|
if c.conn == nil {
|
|
return "", errors.New("telnet: not connected")
|
|
}
|
|
|
|
if err := c.conn.SetWriteDeadline(time.Now().Add(c.WriteTimeout)); err != nil {
|
|
return "", fmt.Errorf("set write deadline: %w", err)
|
|
}
|
|
|
|
if _, err := c.conn.Write([]byte(cmd + "\r\n")); err != nil {
|
|
return "", fmt.Errorf("write %q: %w", cmd, err)
|
|
}
|
|
|
|
overall := time.Now().Add(c.ReadTimeout)
|
|
|
|
var buf bytes.Buffer
|
|
|
|
chunk := make([]byte, 1024)
|
|
haveBytes := false
|
|
|
|
for {
|
|
deadline := overall
|
|
|
|
if haveBytes {
|
|
d := time.Now().Add(idleWindow)
|
|
if d.Before(overall) {
|
|
deadline = d
|
|
}
|
|
}
|
|
|
|
if err := c.conn.SetReadDeadline(deadline); err != nil {
|
|
return buf.String(), fmt.Errorf("set read deadline: %w", err)
|
|
}
|
|
|
|
n, err := c.conn.Read(chunk)
|
|
if n > 0 {
|
|
buf.Write(chunk[:n])
|
|
|
|
haveBytes = true
|
|
}
|
|
|
|
if err == nil {
|
|
continue
|
|
}
|
|
|
|
if errors.Is(err, os.ErrDeadlineExceeded) {
|
|
if haveBytes {
|
|
return buf.String(), nil
|
|
}
|
|
|
|
return buf.String(), fmt.Errorf("timed out waiting for response to %q", cmd)
|
|
}
|
|
|
|
return buf.String(), fmt.Errorf("read after %q: %w", cmd, err)
|
|
}
|
|
}
|