Files
Bose-SoundTouch/pkg/service/setup/ssh_probe.go
T
Tobias GesellchenandClaude Opus 4.7 29a462da2b feat(setup): add CLI setup command group for end-to-end speaker provisioning
Add `soundtouch-cli setup` subcommand group covering the full reset →
re-provision → pair lifecycle as a scriptable alternative to the web UI:

  inspect, verify, plan, factory-reset, wait-ap, wifi-push, wait-online,
  ssh-check, install-ca, migrate, reboot, pair (bare | full state machine)

Supporting library code lives in pkg/service/setup: factory_reset.go,
wifi_provision.go, inspect.go, init_plan.go, setup_session.go.

Confirmed against ST10 firmware 27.0.6 that bare setMargeAccount over
WebSocket — no SETUP_START/SETUP_ENTER/SETUP_LEAVE bracket — is
sufficient to pair a factory-reset speaker; the firmware materializes
SystemConfigurationDB.xml and Sources.xml itself and the pairing
survives reboot. Result and field-by-field SystemConfigurationDB
comparison documented in docs/analysis/SETUP-WEBSOCKET-EXPERIMENT.md.
Captures the device's pre-reset DELETE-to-marge plus its LAN peer
notification flow in docs/analysis/FACTORY-RESET-PROTOCOL.md.

Perf: batch GetMigrationSummary's SSH probes into one Run() call via
ssh_probe.go / ssh_probe_apply.go — was ~8 sequential dials at
500-1000 ms each on FW 27 crypto, now one round-trip. Same data shape,
same MigrationSummary fields populated.

Fixes /clockTime and /clockDisplay wire formats — firmware 27 rejects
the legacy flat XML ("Error parsing request"). ClockTimeRequest now
uses utcTime attribute; ClockDisplayRequest emits the nested
<clockConfig> envelope with timezoneInfo/timeFormat/brightnessLevel.

Removes cmd/example-init-speaker (superseded by setup pair).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:43:36 +02:00

172 lines
4.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package setup
import (
"bufio"
"encoding/base64"
"strings"
)
// speakerProbe is the result of a single batched SSH round-trip that
// gathers everything GetMigrationSummary needs in one go. Without it,
// the summary makes ~8 sequential SSH dials; pkg/ssh opens a fresh
// TCP+SSH handshake on every Run(), and SoundTouch firmware accepts
// only legacy crypto so each handshake is ~500 ms1 s. Batching collapses
// that to one handshake.
type speakerProbe struct {
// SSHOK reports whether the batched probe completed successfully.
// false implies SSH is unreachable, auth failed, or the script
// errored — in all cases GetMigrationSummary falls back to its
// non-SSH paths (telnet preflight, HTTPS probe, etc).
SSHOK bool
// Files maps absolute device paths to their decoded contents.
// Missing keys mean the file did not exist or could not be read.
Files map[string]string
// Exists is the set of probe paths that exist on the device (for
// directories or non-text files we only need a yes/no signal).
Exists map[string]bool
// Err carries the underlying SSH error if SSHOK is false.
Err error
}
// Probe paths used by the batched script. Keep this list in lockstep
// with the consumers in GetMigrationSummary.
var (
probeFilePaths = []string{
SoundTouchSdkPrivateCfgPath, // current XML config
SoundTouchSdkPrivateCfgPath + ".original", // backup XML config
"/etc/resolv.conf", // DNS resolver
"/etc/hosts", // hostname overrides
"/etc/pki/tls/certs/ca-bundle.crt", // CA trust store
}
probeExistsPaths = []string{
"/etc/remote_services", // SSH-enablement marker (persistent)
"/mnt/nv/remote_services", // SSH-enablement marker (persistent, NV)
"/tmp/remote_services", // SSH-enablement marker (volatile)
"/mnt/nv/aftertouch.resolv.conf",
}
)
// probeSpeakerSSH runs one shell script over a single SSH connection
// and parses the result into a speakerProbe. The script emits framed
// blocks per file (base64-encoded so newlines/binary don't break the
// parser) and EXISTS lines per probe path.
func (m *Manager) probeSpeakerSSH(deviceIP string) *speakerProbe {
probe := &speakerProbe{
Files: make(map[string]string),
Exists: make(map[string]bool),
}
if m.NewSSH == nil {
return probe
}
script := buildSpeakerProbeScript(probeFilePaths, probeExistsPaths)
client := m.NewSSH(deviceIP)
output, err := client.Run(script)
if err != nil {
probe.Err = err
return probe
}
parseSpeakerProbe(probe, output)
return probe
}
// buildSpeakerProbeScript composes the POSIX-sh script that does all the
// probes in one execution. Kept separate so tests can verify the script
// shape without having to mock an SSH transport.
func buildSpeakerProbeScript(filePaths, existsPaths []string) string {
var b strings.Builder
b.WriteString("echo '@SSH_OK@'\n")
for _, p := range filePaths {
b.WriteString("if [ -f '")
b.WriteString(p)
b.WriteString("' ]; then\n")
b.WriteString(" echo '@FILE@")
b.WriteString(p)
b.WriteString("@'\n")
b.WriteString(" base64 < '")
b.WriteString(p)
b.WriteString("' 2>/dev/null | tr -d '\\n'\n")
b.WriteString(" echo\n")
b.WriteString(" echo '@END@'\n")
b.WriteString("fi\n")
}
for _, p := range existsPaths {
b.WriteString("if [ -e '")
b.WriteString(p)
b.WriteString("' ]; then echo '@EXISTS@")
b.WriteString(p)
b.WriteString("@'; fi\n")
}
return b.String()
}
// parseSpeakerProbe parses the script's stdout into the probe struct.
// The format is line-oriented:
//
// @SSH_OK@ — sentinel: script ran to completion
// @FILE@<path>@ — start-of-file marker
// <base64 contents> — exactly one line of base64 (no newlines)
// @END@ — end-of-file marker
// @EXISTS@<path>@ — path-exists assertion
//
// We tolerate any other lines as stray output and skip them.
func parseSpeakerProbe(probe *speakerProbe, output string) {
scanner := bufio.NewScanner(strings.NewReader(output))
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
var (
inFile bool
currentPath string
b64 strings.Builder
)
for scanner.Scan() {
line := scanner.Text()
switch {
case line == "@SSH_OK@":
probe.SSHOK = true
case strings.HasPrefix(line, "@FILE@") && strings.HasSuffix(line, "@"):
currentPath = strings.TrimSuffix(strings.TrimPrefix(line, "@FILE@"), "@")
inFile = true
b64.Reset()
case line == "@END@":
if inFile && currentPath != "" {
if decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64.String())); err == nil {
probe.Files[currentPath] = string(decoded)
}
}
inFile = false
currentPath = ""
b64.Reset()
case strings.HasPrefix(line, "@EXISTS@") && strings.HasSuffix(line, "@"):
path := strings.TrimSuffix(strings.TrimPrefix(line, "@EXISTS@"), "@")
probe.Exists[path] = true
default:
if inFile {
b64.WriteString(line)
}
}
}
}