Files
Bose-SoundTouch/pkg/service/setup/factory_reset.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

74 lines
2.2 KiB
Go

package setup
import (
"errors"
"fmt"
"strings"
)
// FactoryReset issues `sys factorydefault` over the device's port-17000
// diagnostic shell. The device wipes its persistent state (account
// pairing, Wi-Fi credentials, presets, source configuration) and reboots
// into setup mode — broadcasting its own `Bose SoundTouch XXXX` access
// point on 192.0.2.1.
//
// After this call the device is unreachable on the home network until
// the caller pushes new Wi-Fi credentials via PushWiFiCredentials (see
// wifi_provision.go).
func (m *Manager) FactoryReset(deviceIP string) (string, error) {
if m.NewTelnet == nil {
return "", errors.New("FactoryReset: Manager.NewTelnet is nil")
}
var logs strings.Builder
t := m.NewTelnet(deviceIP)
if err := t.Dial(); err != nil {
return logs.String(), fmt.Errorf("telnet dial %s:17000: %w", deviceIP, err)
}
defer func() { _ = t.Close() }()
banner, _ := t.Probe()
if banner != "" {
fmt.Fprintf(&logs, "Telnet banner: %q\n", strings.TrimSpace(banner))
}
resp, err := t.SendCommand("sys factorydefault")
if err != nil {
// A graceful close right after the command is normal — the device
// reboots immediately. We treat "connection closed" responses as
// success rather than failure.
if isExpectedDisconnect(err) {
fmt.Fprintf(&logs, "→ sys factorydefault\n(device disconnected — reset accepted)\n")
return logs.String(), nil
}
return logs.String(), fmt.Errorf("sys factorydefault: %w", err)
}
fmt.Fprintf(&logs, "→ sys factorydefault\n%s\n", strings.TrimRight(resp, "\r\n"))
if isCommandNotFound(resp) {
return logs.String(), fmt.Errorf("device rejected `sys factorydefault` (firmware does not expose this command)")
}
return logs.String(), nil
}
// isExpectedDisconnect reports whether an error from SendCommand is the
// normal "device closed the socket while rebooting" pattern, which we
// see during factory-reset.
func isExpectedDisconnect(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "eof") ||
strings.Contains(msg, "connection reset") ||
strings.Contains(msg, "connection closed") ||
strings.Contains(msg, "broken pipe")
}