mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
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>
158 lines
4.6 KiB
Go
158 lines
4.6 KiB
Go
package setup
|
|
|
|
import (
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
|
|
"github.com/gesellix/bose-soundtouch/pkg/models"
|
|
)
|
|
|
|
// InspectOptions controls how Manager.Inspect probes the speaker.
|
|
type InspectOptions struct {
|
|
// IncludeTelnet runs `getpdo CurrentSystemConfiguration` over telnet to
|
|
// capture the speaker's runtime URL configuration. Slower and not
|
|
// always reachable on hardened firmware, hence opt-in.
|
|
IncludeTelnet bool
|
|
}
|
|
|
|
// InspectSection is one slice of an InspectReport. Each section has an
|
|
// independent error so a partial failure (e.g. /presets refused) does not
|
|
// hide the rest of the report.
|
|
type InspectSection struct {
|
|
Name string
|
|
Err error
|
|
}
|
|
|
|
// InspectReport summarises everything we can learn about a speaker
|
|
// without writing to it. Used to populate UI before factory-reset / pair
|
|
// flows and to record the deviceID-suffix for later wait-online calls.
|
|
type InspectReport struct {
|
|
DeviceIP string
|
|
|
|
Info *DeviceInfoXML `json:"info,omitempty"`
|
|
InfoErr error `json:"-"`
|
|
Network *models.NetworkInformation `json:"network,omitempty"`
|
|
NetworkErr error `json:"-"`
|
|
Sources *models.Sources `json:"sources,omitempty"`
|
|
SourcesErr error `json:"-"`
|
|
Presets *PresetList `json:"presets,omitempty"`
|
|
PresetsErr error `json:"-"`
|
|
RuntimeURLs string `json:"runtime_urls,omitempty"`
|
|
RuntimeErr error `json:"-"`
|
|
}
|
|
|
|
// PresetList is a minimal preset summary — just enough to render a
|
|
// "preset N: <name>" overview. The full preset model lives in
|
|
// pkg/models, but we don't need it here.
|
|
type PresetList struct {
|
|
XMLName xml.Name `xml:"presets"`
|
|
Presets []struct {
|
|
ID string `xml:"id,attr"`
|
|
ContentItem struct {
|
|
Source string `xml:"source,attr"`
|
|
SourceAccount string `xml:"sourceAccount,attr"`
|
|
Type string `xml:"type,attr"`
|
|
ItemName string `xml:"itemName"`
|
|
} `xml:"ContentItem"`
|
|
} `xml:"preset"`
|
|
}
|
|
|
|
// Inspect gathers a non-destructive snapshot of the speaker at deviceIP.
|
|
// Every probe is best-effort: individual section errors are recorded on
|
|
// the report rather than aborting the whole call.
|
|
func (m *Manager) Inspect(deviceIP string, opts InspectOptions) *InspectReport {
|
|
r := &InspectReport{DeviceIP: deviceIP}
|
|
|
|
r.Info, r.InfoErr = m.GetLiveDeviceInfo(deviceIP)
|
|
r.Network, r.NetworkErr = m.fetchNetworkInfo(deviceIP)
|
|
r.Sources, r.SourcesErr = m.fetchSources(deviceIP)
|
|
r.Presets, r.PresetsErr = m.fetchPresets(deviceIP)
|
|
|
|
if opts.IncludeTelnet {
|
|
r.RuntimeURLs, r.RuntimeErr = m.fetchRuntimeURLs(deviceIP)
|
|
}
|
|
|
|
return r
|
|
}
|
|
|
|
func (m *Manager) fetchXML(deviceIP, path string, out any) error {
|
|
url := buildDeviceURL(deviceIP, path)
|
|
|
|
resp, err := m.HTTPGet(url)
|
|
if err != nil {
|
|
return fmt.Errorf("GET %s: %w", url, err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return fmt.Errorf("GET %s returned %d", url, resp.StatusCode)
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("read %s: %w", url, err)
|
|
}
|
|
|
|
return xml.Unmarshal(body, out)
|
|
}
|
|
|
|
func (m *Manager) fetchNetworkInfo(deviceIP string) (*models.NetworkInformation, error) {
|
|
var n models.NetworkInformation
|
|
if err := m.fetchXML(deviceIP, "/networkInfo", &n); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &n, nil
|
|
}
|
|
|
|
func (m *Manager) fetchSources(deviceIP string) (*models.Sources, error) {
|
|
var s models.Sources
|
|
if err := m.fetchXML(deviceIP, "/sources", &s); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &s, nil
|
|
}
|
|
|
|
func (m *Manager) fetchPresets(deviceIP string) (*PresetList, error) {
|
|
var p PresetList
|
|
if err := m.fetchXML(deviceIP, "/presets", &p); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &p, nil
|
|
}
|
|
|
|
// fetchRuntimeURLs reads the device's runtime URL configuration via the
|
|
// port-17000 diagnostic shell. The response is a multi-line text blob —
|
|
// we return it as-is so the caller can decide how to format it.
|
|
func (m *Manager) fetchRuntimeURLs(deviceIP string) (string, error) {
|
|
if m.NewTelnet == nil {
|
|
return "", errors.New("telnet probe disabled: Manager.NewTelnet is nil")
|
|
}
|
|
|
|
t := m.NewTelnet(deviceIP)
|
|
if err := t.Dial(); err != nil {
|
|
return "", fmt.Errorf("telnet dial %s:17000: %w", deviceIP, err)
|
|
}
|
|
|
|
defer func() { _ = t.Close() }()
|
|
|
|
_, _ = t.Probe()
|
|
|
|
resp, err := t.SendCommand("getpdo CurrentSystemConfiguration")
|
|
if err != nil {
|
|
return "", fmt.Errorf("getpdo: %w", err)
|
|
}
|
|
|
|
if isCommandNotFound(resp) {
|
|
return "", errors.New("device rejected `getpdo` (firmware does not expose this command)")
|
|
}
|
|
|
|
return strings.TrimRight(resp, "\r\n"), nil
|
|
}
|