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

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
}