mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 00:26:29 +00:00
refactor(setup): split high-complexity functions into per-axis helpers
Brings the five remaining gocyclo > 20 warnings to zero by extracting cohesive sub-functions; same observable behaviour, smaller surface to read at each call site. Bonus: the new helpers are individually testable. - pkg/models/clockdisplay.go: split ClockDisplay.UnmarshalXML attr handling into applyClockDisplayOuterAttrs (legacy flat shape) and applyClockConfigAttrs (current nested shape). - pkg/service/setup/ssh_probe_apply.go: split applyProbeToSummary into applyProbeCurrentConfig / applyProbeResolvConf / applyProbeRemoteServices / applyProbeCACert — one helper per MigrationSummary axis the probe populates. - pkg/service/setup/init_plan.go: split ExecuteInitPlan into applyInitPlanDefaults, runURLRewrite, resolveAccountID, and verifyPairing. Cleans up several shadowed err variables in the process. - cmd/soundtouch-cli/cmd_setup.go: split renderInspectReport into renderInspectIdentityAndPairing / renderInspectNetwork / renderInspectSources / renderInspectPresets / renderInspectRuntimeURLs, and buildPlanSteps into resetSteps + migrationSteps helpers. golangci-lint run ./pkg/service/setup/... ./pkg/models/... ./cmd/soundtouch-cli/... now reports zero findings. Tests green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
9e384840ba
commit
e3450ffd00
+236
-198
@@ -86,115 +86,156 @@ func renderInspectReport(r *setup.InspectReport) {
|
||||
fmt.Printf("Speaker @ %s\n", r.DeviceIP)
|
||||
fmt.Println(strings.Repeat("─", 40))
|
||||
|
||||
renderInspectIdentityAndPairing(r)
|
||||
renderInspectNetwork(r)
|
||||
renderInspectSources(r)
|
||||
renderInspectPresets(r)
|
||||
renderInspectRuntimeURLs(r)
|
||||
}
|
||||
|
||||
func renderInspectIdentityAndPairing(r *setup.InspectReport) {
|
||||
if r.InfoErr != nil {
|
||||
PrintError(fmt.Sprintf("/info: %v", r.InfoErr))
|
||||
} else if r.Info != nil {
|
||||
i := r.Info
|
||||
|
||||
fmt.Println("Identity")
|
||||
fmt.Printf(" deviceID : %s\n", i.DeviceID)
|
||||
|
||||
if suffix := deviceIDSuffix(i.DeviceID); suffix != "" {
|
||||
fmt.Printf(" → use as --match suffix for wait-online: %s\n", suffix)
|
||||
}
|
||||
|
||||
fmt.Printf(" name : %s\n", i.Name)
|
||||
fmt.Printf(" type : %s\n", i.Type)
|
||||
|
||||
for _, comp := range i.Components {
|
||||
if comp.SoftwareVersion != "" {
|
||||
fmt.Printf(" softwareVersion : %s (component %s)\n", comp.SoftwareVersion, comp.Category)
|
||||
}
|
||||
|
||||
if comp.SerialNumber != "" {
|
||||
fmt.Printf(" serialNumber : %s (component %s)\n", comp.SerialNumber, comp.Category)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("Pairing")
|
||||
|
||||
if i.MargeAccountUUID == "" {
|
||||
PrintWarning("margeAccountUUID is empty — device is unpaired (factory-reset state)")
|
||||
} else {
|
||||
fmt.Printf(" margeAccountUUID : %s\n", i.MargeAccountUUID)
|
||||
}
|
||||
|
||||
fmt.Printf(" margeURL : %s\n", i.MargeURL)
|
||||
fmt.Println()
|
||||
return
|
||||
}
|
||||
|
||||
if r.Info == nil {
|
||||
return
|
||||
}
|
||||
|
||||
i := r.Info
|
||||
|
||||
fmt.Println("Identity")
|
||||
fmt.Printf(" deviceID : %s\n", i.DeviceID)
|
||||
|
||||
if suffix := deviceIDSuffix(i.DeviceID); suffix != "" {
|
||||
fmt.Printf(" → use as --match suffix for wait-online: %s\n", suffix)
|
||||
}
|
||||
|
||||
fmt.Printf(" name : %s\n", i.Name)
|
||||
fmt.Printf(" type : %s\n", i.Type)
|
||||
|
||||
for _, comp := range i.Components {
|
||||
if comp.SoftwareVersion != "" {
|
||||
fmt.Printf(" softwareVersion : %s (component %s)\n", comp.SoftwareVersion, comp.Category)
|
||||
}
|
||||
|
||||
if comp.SerialNumber != "" {
|
||||
fmt.Printf(" serialNumber : %s (component %s)\n", comp.SerialNumber, comp.Category)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("Pairing")
|
||||
|
||||
if i.MargeAccountUUID == "" {
|
||||
PrintWarning("margeAccountUUID is empty — device is unpaired (factory-reset state)")
|
||||
} else {
|
||||
fmt.Printf(" margeAccountUUID : %s\n", i.MargeAccountUUID)
|
||||
}
|
||||
|
||||
fmt.Printf(" margeURL : %s\n", i.MargeURL)
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func renderInspectNetwork(r *setup.InspectReport) {
|
||||
if r.NetworkErr != nil {
|
||||
PrintError(fmt.Sprintf("/networkInfo: %v", r.NetworkErr))
|
||||
} else if r.Network != nil {
|
||||
fmt.Println("Network")
|
||||
|
||||
for i := range r.Network.Interfaces.Interfaces {
|
||||
iface := &r.Network.Interfaces.Interfaces[i]
|
||||
|
||||
fmt.Printf(" %s\n", iface.Type)
|
||||
fmt.Printf(" state : %s\n", iface.State)
|
||||
|
||||
if iface.IPAddress != "" {
|
||||
fmt.Printf(" ipAddress : %s\n", iface.IPAddress)
|
||||
}
|
||||
|
||||
if iface.MacAddress != "" {
|
||||
fmt.Printf(" macAddress : %s\n", iface.MacAddress)
|
||||
}
|
||||
|
||||
if iface.SSID != "" {
|
||||
fmt.Printf(" ssid : %s\n", iface.SSID)
|
||||
fmt.Printf(" → use as --ssid for wifi-push: %s\n", iface.SSID)
|
||||
}
|
||||
|
||||
if iface.Signal != "" {
|
||||
fmt.Printf(" signal : %s\n", iface.Signal)
|
||||
}
|
||||
|
||||
if iface.FrequencyKHz != 0 {
|
||||
fmt.Printf(" frequency : %d kHz\n", iface.FrequencyKHz)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
return
|
||||
}
|
||||
|
||||
if r.Network == nil {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("Network")
|
||||
|
||||
for i := range r.Network.Interfaces.Interfaces {
|
||||
iface := &r.Network.Interfaces.Interfaces[i]
|
||||
|
||||
fmt.Printf(" %s\n", iface.Type)
|
||||
fmt.Printf(" state : %s\n", iface.State)
|
||||
|
||||
if iface.IPAddress != "" {
|
||||
fmt.Printf(" ipAddress : %s\n", iface.IPAddress)
|
||||
}
|
||||
|
||||
if iface.MacAddress != "" {
|
||||
fmt.Printf(" macAddress : %s\n", iface.MacAddress)
|
||||
}
|
||||
|
||||
if iface.SSID != "" {
|
||||
fmt.Printf(" ssid : %s\n", iface.SSID)
|
||||
fmt.Printf(" → use as --ssid for wifi-push: %s\n", iface.SSID)
|
||||
}
|
||||
|
||||
if iface.Signal != "" {
|
||||
fmt.Printf(" signal : %s\n", iface.Signal)
|
||||
}
|
||||
|
||||
if iface.FrequencyKHz != 0 {
|
||||
fmt.Printf(" frequency : %d kHz\n", iface.FrequencyKHz)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func renderInspectSources(r *setup.InspectReport) {
|
||||
if r.SourcesErr != nil {
|
||||
PrintError(fmt.Sprintf("/sources: %v", r.SourcesErr))
|
||||
} else if r.Sources != nil {
|
||||
fmt.Printf("Sources (%d)\n", len(r.Sources.SourceItem))
|
||||
renderSourceTable(r.Sources.SourceItem)
|
||||
fmt.Println()
|
||||
return
|
||||
}
|
||||
|
||||
if r.Sources == nil {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Sources (%d)\n", len(r.Sources.SourceItem))
|
||||
renderSourceTable(r.Sources.SourceItem)
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func renderInspectPresets(r *setup.InspectReport) {
|
||||
if r.PresetsErr != nil {
|
||||
PrintError(fmt.Sprintf("/presets: %v", r.PresetsErr))
|
||||
} else if r.Presets != nil {
|
||||
fmt.Printf("Presets (%d)\n", len(r.Presets.Presets))
|
||||
|
||||
for _, p := range r.Presets.Presets {
|
||||
fmt.Printf(" [%s] %s (source=%s)\n", p.ID, p.ContentItem.ItemName, p.ContentItem.Source)
|
||||
}
|
||||
|
||||
if len(r.Presets.Presets) == 0 {
|
||||
fmt.Println(" (none)")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
return
|
||||
}
|
||||
|
||||
if r.Presets == nil {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Presets (%d)\n", len(r.Presets.Presets))
|
||||
|
||||
for _, p := range r.Presets.Presets {
|
||||
fmt.Printf(" [%s] %s (source=%s)\n", p.ID, p.ContentItem.ItemName, p.ContentItem.Source)
|
||||
}
|
||||
|
||||
if len(r.Presets.Presets) == 0 {
|
||||
fmt.Println(" (none)")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func renderInspectRuntimeURLs(r *setup.InspectReport) {
|
||||
if r.RuntimeErr != nil {
|
||||
PrintError(fmt.Sprintf("telnet getpdo: %v", r.RuntimeErr))
|
||||
} else if r.RuntimeURLs != "" {
|
||||
fmt.Println("Runtime URL configuration (telnet getpdo)")
|
||||
|
||||
for _, line := range strings.Split(r.RuntimeURLs, "\n") {
|
||||
fmt.Printf(" %s\n", line)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
return
|
||||
}
|
||||
|
||||
if r.RuntimeURLs == "" {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("Runtime URL configuration (telnet getpdo)")
|
||||
|
||||
for _, line := range strings.Split(r.RuntimeURLs, "\n") {
|
||||
fmt.Printf(" %s\n", line)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// sourceLine is the per-source row before any width-padding decisions
|
||||
@@ -1092,60 +1133,7 @@ func buildPlanSteps(
|
||||
var steps []planStep
|
||||
|
||||
if reset {
|
||||
steps = append(steps, planStep{
|
||||
title: "Factory-reset the speaker",
|
||||
cmd: fmt.Sprintf("soundtouch-cli setup factory-reset --host=%s", host),
|
||||
reason: "Wipes account pairing, presets, Wi-Fi — gives a clean baseline for the SETUP state machine.",
|
||||
})
|
||||
|
||||
steps = append(steps, planStep{
|
||||
title: "Connect this host to the speaker's setup AP",
|
||||
cmd: `# macOS: networksetup -setairportnetwork en0 "Bose SoundTouch XXXX"`,
|
||||
reason: "After reset the speaker broadcasts its own Wi-Fi at 192.0.2.1.",
|
||||
manual: true,
|
||||
})
|
||||
|
||||
steps = append(steps, planStep{
|
||||
title: "Wait for the speaker's setup-mode IP to respond",
|
||||
cmd: "soundtouch-cli setup wait-ap",
|
||||
})
|
||||
|
||||
ssidArg := wifiSSID
|
||||
if ssidArg == "" {
|
||||
if currentSSID := inspectedSSID(inspect); currentSSID != "" {
|
||||
ssidArg = currentSSID
|
||||
} else {
|
||||
ssidArg = "<HOME_SSID>"
|
||||
}
|
||||
}
|
||||
|
||||
steps = append(steps, planStep{
|
||||
title: "Push home Wi-Fi credentials to the speaker",
|
||||
cmd: fmt.Sprintf(`soundtouch-cli setup wifi-push --ssid=%q --pass=<HOME_PASS>`, ssidArg),
|
||||
reason: "Speaker leaves AP mode and joins your home network within ~30 s.",
|
||||
})
|
||||
|
||||
steps = append(steps, planStep{
|
||||
title: "Switch this host back to home Wi-Fi",
|
||||
cmd: fmt.Sprintf(`# macOS: networksetup -setairportnetwork en0 %q <HOME_PASS>`, ssidArg),
|
||||
reason: "Required so wait-online's mDNS browse reaches the right network segment.",
|
||||
manual: true,
|
||||
})
|
||||
|
||||
match := ""
|
||||
if inspect.Info != nil {
|
||||
match = deviceIDSuffix(inspect.Info.DeviceID)
|
||||
}
|
||||
|
||||
if match == "" {
|
||||
match = "<deviceID-suffix>"
|
||||
}
|
||||
|
||||
steps = append(steps, planStep{
|
||||
title: "Discover the speaker's new IP via mDNS",
|
||||
cmd: fmt.Sprintf("soundtouch-cli setup wait-online --match=%s", match),
|
||||
})
|
||||
|
||||
steps = append(steps, resetSteps(host, wifiSSID, inspect)...)
|
||||
host = "<NEW_IP>" // subsequent commands target the discovered IP
|
||||
}
|
||||
|
||||
@@ -1153,60 +1141,8 @@ func buildPlanSteps(
|
||||
return steps
|
||||
}
|
||||
|
||||
if !reset && (summary == nil || !summary.IsMigrated) {
|
||||
method, methodReason := recommendMigrationMethod(serviceURL, summary)
|
||||
if method == "" {
|
||||
steps = append(steps, planStep{
|
||||
title: "Enable SSH on the speaker (USB-stick procedure)",
|
||||
cmd: "# See `soundtouch-cli setup ssh-check` output for the USB-stick steps.",
|
||||
reason: "Telnet won't respond and SSH is closed — no transport available to apply a migration.",
|
||||
manual: true,
|
||||
})
|
||||
} else {
|
||||
if method == setup.MigrationMethodResolvConf || method == setup.MigrationMethodHosts {
|
||||
if summary != nil && !summary.CACertTrusted {
|
||||
steps = append(steps, planStep{
|
||||
title: "Install AfterTouch's CA cert on the speaker",
|
||||
cmd: fmt.Sprintf("soundtouch-cli setup install-ca --host=%s --service-url=%s",
|
||||
host, serviceURL),
|
||||
reason: "DNS-redirect methods keep using https://*.bose.com URLs — the device needs to trust AfterTouch's cert.",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
steps = append(steps, planStep{
|
||||
title: fmt.Sprintf("Apply URL migration using method=%s", method),
|
||||
cmd: fmt.Sprintf("soundtouch-cli setup migrate --host=%s --service-url=%s --method=%s",
|
||||
host, serviceURL, method),
|
||||
reason: methodReason,
|
||||
})
|
||||
|
||||
steps = append(steps, planStep{
|
||||
title: "Reboot the speaker",
|
||||
cmd: fmt.Sprintf("soundtouch-cli setup reboot --host=%s", host),
|
||||
reason: "The envswitch parallel-persistence layer only fully wins on next boot; reboot now to lock the new URLs in before pairing.",
|
||||
})
|
||||
}
|
||||
} else if reset {
|
||||
// Reset path always re-applies a migration after wifi-push.
|
||||
method, methodReason := recommendMigrationMethod(serviceURL, nil)
|
||||
if method == "" {
|
||||
method = setup.MigrationMethodTelnet
|
||||
methodReason = "Default: envswitch — works on most firmware-27 devices without SSH."
|
||||
}
|
||||
|
||||
steps = append(steps, planStep{
|
||||
title: fmt.Sprintf("Apply URL migration using method=%s", method),
|
||||
cmd: fmt.Sprintf("soundtouch-cli setup migrate --host=%s --service-url=%s --method=%s",
|
||||
host, serviceURL, method),
|
||||
reason: methodReason,
|
||||
})
|
||||
|
||||
steps = append(steps, planStep{
|
||||
title: "Reboot the speaker",
|
||||
cmd: fmt.Sprintf("soundtouch-cli setup reboot --host=%s", host),
|
||||
reason: "The envswitch parallel-persistence layer only fully wins on next boot; reboot now to lock the new URLs in before pairing.",
|
||||
})
|
||||
if reset || summary == nil || !summary.IsMigrated {
|
||||
steps = append(steps, migrationSteps(host, serviceURL, summary, reset)...)
|
||||
}
|
||||
|
||||
if includePair && (reset || (summary != nil && !summary.IsPaired)) {
|
||||
@@ -1220,6 +1156,108 @@ func buildPlanSteps(
|
||||
return steps
|
||||
}
|
||||
|
||||
// resetSteps composes the factory-reset → AP-switch → wait-ap →
|
||||
// wifi-push → home-switch → wait-online prefix that --reset adds.
|
||||
func resetSteps(host, wifiSSID string, inspect *setup.InspectReport) []planStep {
|
||||
ssidArg := wifiSSID
|
||||
if ssidArg == "" {
|
||||
if currentSSID := inspectedSSID(inspect); currentSSID != "" {
|
||||
ssidArg = currentSSID
|
||||
} else {
|
||||
ssidArg = "<HOME_SSID>"
|
||||
}
|
||||
}
|
||||
|
||||
match := ""
|
||||
if inspect != nil && inspect.Info != nil {
|
||||
match = deviceIDSuffix(inspect.Info.DeviceID)
|
||||
}
|
||||
|
||||
if match == "" {
|
||||
match = "<deviceID-suffix>"
|
||||
}
|
||||
|
||||
return []planStep{
|
||||
{
|
||||
title: "Factory-reset the speaker",
|
||||
cmd: fmt.Sprintf("soundtouch-cli setup factory-reset --host=%s", host),
|
||||
reason: "Wipes account pairing, presets, Wi-Fi — gives a clean baseline for the SETUP state machine.",
|
||||
},
|
||||
{
|
||||
title: "Connect this host to the speaker's setup AP",
|
||||
cmd: `# macOS: networksetup -setairportnetwork en0 "Bose SoundTouch XXXX"`,
|
||||
reason: "After reset the speaker broadcasts its own Wi-Fi at 192.0.2.1.",
|
||||
manual: true,
|
||||
},
|
||||
{
|
||||
title: "Wait for the speaker's setup-mode IP to respond",
|
||||
cmd: "soundtouch-cli setup wait-ap",
|
||||
},
|
||||
{
|
||||
title: "Push home Wi-Fi credentials to the speaker",
|
||||
cmd: fmt.Sprintf(`soundtouch-cli setup wifi-push --ssid=%q --pass=<HOME_PASS>`, ssidArg),
|
||||
reason: "Speaker leaves AP mode and joins your home network within ~30 s.",
|
||||
},
|
||||
{
|
||||
title: "Switch this host back to home Wi-Fi",
|
||||
cmd: fmt.Sprintf(`# macOS: networksetup -setairportnetwork en0 %q <HOME_PASS>`, ssidArg),
|
||||
reason: "Required so wait-online's mDNS browse reaches the right network segment.",
|
||||
manual: true,
|
||||
},
|
||||
{
|
||||
title: "Discover the speaker's new IP via mDNS",
|
||||
cmd: fmt.Sprintf("soundtouch-cli setup wait-online --match=%s", match),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// migrationSteps composes the migrate + reboot pair (plus optional
|
||||
// install-ca prelude for DNS-redirect methods). In --reset mode the
|
||||
// summary is nil and we default to method=telnet; otherwise we let the
|
||||
// recommender pick based on the speaker's current capabilities.
|
||||
func migrationSteps(host, serviceURL string, summary *setup.MigrationSummary, reset bool) []planStep {
|
||||
var steps []planStep
|
||||
|
||||
method, methodReason := recommendMigrationMethod(serviceURL, summary)
|
||||
|
||||
if method == "" {
|
||||
if reset {
|
||||
method = setup.MigrationMethodTelnet
|
||||
methodReason = "Default: envswitch — works on most firmware-27 devices without SSH."
|
||||
} else {
|
||||
return []planStep{{
|
||||
title: "Enable SSH on the speaker (USB-stick procedure)",
|
||||
cmd: "# See `soundtouch-cli setup ssh-check` output for the USB-stick steps.",
|
||||
reason: "Telnet won't respond and SSH is closed — no transport available to apply a migration.",
|
||||
manual: true,
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
dnsRedirect := method == setup.MigrationMethodResolvConf || method == setup.MigrationMethodHosts
|
||||
if dnsRedirect && summary != nil && !summary.CACertTrusted {
|
||||
steps = append(steps, planStep{
|
||||
title: "Install AfterTouch's CA cert on the speaker",
|
||||
cmd: fmt.Sprintf("soundtouch-cli setup install-ca --host=%s --service-url=%s", host, serviceURL),
|
||||
reason: "DNS-redirect methods keep using https://*.bose.com URLs — the device needs to trust AfterTouch's cert.",
|
||||
})
|
||||
}
|
||||
|
||||
steps = append(steps, planStep{
|
||||
title: fmt.Sprintf("Apply URL migration using method=%s", method),
|
||||
cmd: fmt.Sprintf("soundtouch-cli setup migrate --host=%s --service-url=%s --method=%s", host, serviceURL, method),
|
||||
reason: methodReason,
|
||||
})
|
||||
|
||||
steps = append(steps, planStep{
|
||||
title: "Reboot the speaker",
|
||||
cmd: fmt.Sprintf("soundtouch-cli setup reboot --host=%s", host),
|
||||
reason: "The envswitch parallel-persistence layer only fully wins on next boot; reboot now to lock the new URLs in before pairing.",
|
||||
})
|
||||
|
||||
return steps
|
||||
}
|
||||
|
||||
// recommendMigrationMethod picks a migration method from the speaker's
|
||||
// current capabilities. Returns "" when no transport is available.
|
||||
//
|
||||
|
||||
+56
-43
@@ -76,7 +76,46 @@ func mapFromWireFormat(wire string) string {
|
||||
// either because it appears in legacy captures or for forward-compat with
|
||||
// firmwares that may revert.
|
||||
func (c *ClockDisplay) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
for _, attr := range start.Attr {
|
||||
applyClockDisplayOuterAttrs(c, start.Attr)
|
||||
|
||||
for {
|
||||
tok, err := d.Token()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
if t.Name.Local == "clockConfig" {
|
||||
applyClockConfigAttrs(c, t.Attr)
|
||||
}
|
||||
|
||||
if err := d.Skip(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
case xml.CharData:
|
||||
if text := strings.TrimSpace(string(t)); text != "" {
|
||||
c.Value = text
|
||||
}
|
||||
|
||||
case xml.EndElement:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyClockDisplayOuterAttrs handles the legacy flat-attribute format
|
||||
// (deviceID, enabled, format, brightness, autoDim, timeZone) that older
|
||||
// fixtures used directly on the <clockDisplay> element.
|
||||
func applyClockDisplayOuterAttrs(c *ClockDisplay, attrs []xml.Attr) {
|
||||
for _, attr := range attrs {
|
||||
switch attr.Name.Local {
|
||||
case "deviceID":
|
||||
c.DeviceID = attr.Value
|
||||
@@ -92,52 +131,26 @@ func (c *ClockDisplay) UnmarshalXML(d *xml.Decoder, start xml.StartElement) erro
|
||||
c.TimeZone = attr.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
tok, err := d.Token()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
if t.Name.Local == "clockConfig" {
|
||||
for _, attr := range t.Attr {
|
||||
switch attr.Name.Local {
|
||||
case "timezoneInfo":
|
||||
c.TimeZone = attr.Value
|
||||
case "userEnable":
|
||||
c.Enabled = attr.Value == "true"
|
||||
case "timeFormat":
|
||||
if mapped := mapFromWireFormat(attr.Value); mapped != "" {
|
||||
c.Format = mapped
|
||||
}
|
||||
case "brightnessLevel":
|
||||
c.Brightness, _ = strconv.Atoi(attr.Value)
|
||||
}
|
||||
}
|
||||
// applyClockConfigAttrs handles the nested <clockConfig> attributes
|
||||
// (timezoneInfo, userEnable, timeFormat, brightnessLevel) — the shape
|
||||
// FW 27 emits and accepts.
|
||||
func applyClockConfigAttrs(c *ClockDisplay, attrs []xml.Attr) {
|
||||
for _, attr := range attrs {
|
||||
switch attr.Name.Local {
|
||||
case "timezoneInfo":
|
||||
c.TimeZone = attr.Value
|
||||
case "userEnable":
|
||||
c.Enabled = attr.Value == "true"
|
||||
case "timeFormat":
|
||||
if mapped := mapFromWireFormat(attr.Value); mapped != "" {
|
||||
c.Format = mapped
|
||||
}
|
||||
|
||||
if err := d.Skip(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
case xml.CharData:
|
||||
text := strings.TrimSpace(string(t))
|
||||
if text != "" {
|
||||
c.Value = text
|
||||
}
|
||||
|
||||
case xml.EndElement:
|
||||
return nil
|
||||
case "brightnessLevel":
|
||||
c.Brightness, _ = strconv.Atoi(attr.Value)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClockFormat represents supported clock display formats
|
||||
|
||||
+107
-59
@@ -95,24 +95,9 @@ type ProgressFunc func(StepEvent)
|
||||
// The returned InitPlan reflects any defaulting that happened (generated
|
||||
// account ID, defaulted language, etc.) so callers can persist it.
|
||||
func (m *Manager) ExecuteInitPlan(ctx context.Context, plan InitPlan, progress ProgressFunc) (InitPlan, error) {
|
||||
if plan.DeviceIP == "" {
|
||||
return plan, errors.New("InitPlan.DeviceIP is required")
|
||||
}
|
||||
|
||||
if plan.ServiceURL == "" {
|
||||
plan.ServiceURL = m.ServerURL
|
||||
}
|
||||
|
||||
if plan.ServiceURL == "" {
|
||||
return plan, errors.New("InitPlan.ServiceURL is required (and Manager.ServerURL is empty)")
|
||||
}
|
||||
|
||||
if plan.Language == 0 {
|
||||
plan.Language = LanguageEnglish
|
||||
}
|
||||
|
||||
if plan.AuthToken == "" {
|
||||
plan.AuthToken = "Bearer aftertouch"
|
||||
plan, err := applyInitPlanDefaults(plan, m.ServerURL)
|
||||
if err != nil {
|
||||
return plan, err
|
||||
}
|
||||
|
||||
emit := func(kind StepKind, name string, status StepStatus, err error) {
|
||||
@@ -131,44 +116,13 @@ func (m *Manager) ExecuteInitPlan(ctx context.Context, plan InitPlan, progress P
|
||||
|
||||
emit(StepReadDeviceInfo, "read /info", StatusOK, nil)
|
||||
|
||||
if plan.SkipURLRewrite {
|
||||
emit(StepURLRewrite, "telnet URL rewrite", StatusSkipped, nil)
|
||||
} else {
|
||||
emit(StepURLRewrite, "telnet URL rewrite", StatusRunning, nil)
|
||||
|
||||
urls := defaultTelnetURLs(plan.ServiceURL)
|
||||
if _, rwErr := m.migrateViaTelnet(plan.DeviceIP, plan.ServiceURL, urls); rwErr != nil {
|
||||
emit(StepURLRewrite, "telnet URL rewrite", StatusFailed, rwErr)
|
||||
return plan, fmt.Errorf("URL rewrite: %w", rwErr)
|
||||
}
|
||||
|
||||
emit(StepURLRewrite, "telnet URL rewrite", StatusOK, nil)
|
||||
if rewriteErr := m.runURLRewrite(plan, emit); rewriteErr != nil {
|
||||
return plan, rewriteErr
|
||||
}
|
||||
|
||||
if plan.AccountID == "" {
|
||||
if info.MargeAccountUUID != "" && IsValidAccountID(info.MargeAccountUUID) {
|
||||
plan.AccountID = info.MargeAccountUUID
|
||||
emit(StepGenerateAccountID, "reuse existing margeAccountUUID="+plan.AccountID, StatusOK, nil)
|
||||
} else {
|
||||
emit(StepGenerateAccountID, "generate account ID", StatusRunning, nil)
|
||||
|
||||
known := listKnownAccountIDs(m)
|
||||
|
||||
id, genErr := GenerateAccountID(known)
|
||||
if genErr != nil {
|
||||
emit(StepGenerateAccountID, "generate account ID", StatusFailed, genErr)
|
||||
return plan, fmt.Errorf("generate account ID: %w", genErr)
|
||||
}
|
||||
|
||||
plan.AccountID = id
|
||||
|
||||
emit(StepGenerateAccountID, "generate account ID="+id, StatusOK, nil)
|
||||
}
|
||||
} else if !IsValidAccountID(plan.AccountID) {
|
||||
invalidErr := fmt.Errorf("invalid AccountID %q: must be exactly 7 digits", plan.AccountID)
|
||||
emit(StepGenerateAccountID, "validate account ID", StatusFailed, invalidErr)
|
||||
|
||||
return plan, invalidErr
|
||||
plan, err = m.resolveAccountID(plan, info, emit)
|
||||
if err != nil {
|
||||
return plan, err
|
||||
}
|
||||
|
||||
emit(StepDialWebSocket, "dial websocket", StatusRunning, nil)
|
||||
@@ -237,24 +191,118 @@ func (m *Manager) ExecuteInitPlan(ctx context.Context, plan InitPlan, progress P
|
||||
emit(st.kind, st.name, StatusOK, nil)
|
||||
}
|
||||
|
||||
if err := m.verifyPairing(plan, emit); err != nil {
|
||||
return plan, err
|
||||
}
|
||||
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// applyInitPlanDefaults validates required fields and fills in defaults
|
||||
// from Manager.ServerURL / sysLanguage 2 / "Bearer aftertouch".
|
||||
func applyInitPlanDefaults(plan InitPlan, serverURL string) (InitPlan, error) {
|
||||
if plan.DeviceIP == "" {
|
||||
return plan, errors.New("InitPlan.DeviceIP is required")
|
||||
}
|
||||
|
||||
if plan.ServiceURL == "" {
|
||||
plan.ServiceURL = serverURL
|
||||
}
|
||||
|
||||
if plan.ServiceURL == "" {
|
||||
return plan, errors.New("InitPlan.ServiceURL is required (and Manager.ServerURL is empty)")
|
||||
}
|
||||
|
||||
if plan.Language == 0 {
|
||||
plan.Language = LanguageEnglish
|
||||
}
|
||||
|
||||
if plan.AuthToken == "" {
|
||||
plan.AuthToken = "Bearer aftertouch"
|
||||
}
|
||||
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// runURLRewrite applies the telnet envswitch URL rewrite step unless the
|
||||
// caller asked to skip it.
|
||||
func (m *Manager) runURLRewrite(plan InitPlan, emit func(StepKind, string, StepStatus, error)) error {
|
||||
if plan.SkipURLRewrite {
|
||||
emit(StepURLRewrite, "telnet URL rewrite", StatusSkipped, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
emit(StepURLRewrite, "telnet URL rewrite", StatusRunning, nil)
|
||||
|
||||
urls := defaultTelnetURLs(plan.ServiceURL)
|
||||
if _, rwErr := m.migrateViaTelnet(plan.DeviceIP, plan.ServiceURL, urls); rwErr != nil {
|
||||
emit(StepURLRewrite, "telnet URL rewrite", StatusFailed, rwErr)
|
||||
return fmt.Errorf("URL rewrite: %w", rwErr)
|
||||
}
|
||||
|
||||
emit(StepURLRewrite, "telnet URL rewrite", StatusOK, nil)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveAccountID populates plan.AccountID — reusing the device's
|
||||
// existing margeAccountUUID, generating a fresh non-colliding 7-digit
|
||||
// ID, or validating a user-supplied value.
|
||||
func (m *Manager) resolveAccountID(plan InitPlan, info *DeviceInfoXML, emit func(StepKind, string, StepStatus, error)) (InitPlan, error) {
|
||||
if plan.AccountID != "" {
|
||||
if !IsValidAccountID(plan.AccountID) {
|
||||
invalidErr := fmt.Errorf("invalid AccountID %q: must be exactly 7 digits", plan.AccountID)
|
||||
emit(StepGenerateAccountID, "validate account ID", StatusFailed, invalidErr)
|
||||
|
||||
return plan, invalidErr
|
||||
}
|
||||
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
if info.MargeAccountUUID != "" && IsValidAccountID(info.MargeAccountUUID) {
|
||||
plan.AccountID = info.MargeAccountUUID
|
||||
emit(StepGenerateAccountID, "reuse existing margeAccountUUID="+plan.AccountID, StatusOK, nil)
|
||||
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
emit(StepGenerateAccountID, "generate account ID", StatusRunning, nil)
|
||||
|
||||
id, genErr := GenerateAccountID(listKnownAccountIDs(m))
|
||||
if genErr != nil {
|
||||
emit(StepGenerateAccountID, "generate account ID", StatusFailed, genErr)
|
||||
return plan, fmt.Errorf("generate account ID: %w", genErr)
|
||||
}
|
||||
|
||||
plan.AccountID = id
|
||||
|
||||
emit(StepGenerateAccountID, "generate account ID="+id, StatusOK, nil)
|
||||
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// verifyPairing re-reads /info after the state machine finished and
|
||||
// confirms the device's margeAccountUUID matches what we asked for.
|
||||
func (m *Manager) verifyPairing(plan InitPlan, emit func(StepKind, string, StepStatus, error)) error {
|
||||
emit(StepVerify, "verify /info margeAccountUUID", StatusRunning, nil)
|
||||
|
||||
verify, err := m.GetLiveDeviceInfo(plan.DeviceIP)
|
||||
if err != nil {
|
||||
emit(StepVerify, "verify /info", StatusFailed, err)
|
||||
return plan, fmt.Errorf("verify /info: %w", err)
|
||||
return fmt.Errorf("verify /info: %w", err)
|
||||
}
|
||||
|
||||
if verify.MargeAccountUUID != plan.AccountID {
|
||||
err := fmt.Errorf("post-init /info shows margeAccountUUID=%q, want %q", verify.MargeAccountUUID, plan.AccountID)
|
||||
emit(StepVerify, "verify /info", StatusFailed, err)
|
||||
mismatchErr := fmt.Errorf("post-init /info shows margeAccountUUID=%q, want %q", verify.MargeAccountUUID, plan.AccountID)
|
||||
emit(StepVerify, "verify /info", StatusFailed, mismatchErr)
|
||||
|
||||
return plan, err
|
||||
return mismatchErr
|
||||
}
|
||||
|
||||
emit(StepVerify, "verify /info margeAccountUUID="+plan.AccountID, StatusOK, nil)
|
||||
|
||||
return plan, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// listKnownAccountIDs collects account IDs already known to the local
|
||||
|
||||
@@ -32,7 +32,23 @@ func (m *Manager) applyProbeToSummary(
|
||||
summary.CurrentConfig = fmt.Sprintf("SSH connection failed: %v", probe.Err)
|
||||
}
|
||||
|
||||
// Current SoundTouchSdkPrivateCfg.xml (+ .original backup, if any)
|
||||
m.applyProbeCurrentConfig(summary, probe, plannedCfg, proxyURL, targetURL, options)
|
||||
applyProbeResolvConf(summary, probe)
|
||||
applyProbeRemoteServices(summary, probe)
|
||||
m.applyProbeCACert(summary, probe)
|
||||
}
|
||||
|
||||
// applyProbeCurrentConfig populates CurrentConfig / OriginalConfig and
|
||||
// parses the on-disk SoundTouchSdkPrivateCfg.xml into ParsedCurrentConfig.
|
||||
// Also applies proxy options to the planned config when the caller asks
|
||||
// for it — that path needs the parsed current config.
|
||||
func (m *Manager) applyProbeCurrentConfig(
|
||||
summary *MigrationSummary,
|
||||
probe *speakerProbe,
|
||||
plannedCfg *PrivateCfg,
|
||||
proxyURL, targetURL string,
|
||||
options map[string]string,
|
||||
) {
|
||||
if cfg, ok := probe.Files[SoundTouchSdkPrivateCfgPath]; ok && cfg != "" {
|
||||
summary.CurrentConfig = cfg
|
||||
fmt.Printf("Current config from %s (length: %d):\n%q\n", probeDeviceTagFor(summary), len(cfg), cfg)
|
||||
@@ -54,47 +70,68 @@ func (m *Manager) applyProbeToSummary(
|
||||
if orig, ok := probe.Files[SoundTouchSdkPrivateCfgPath+".original"]; ok && orig != "" {
|
||||
summary.OriginalConfig = orig
|
||||
}
|
||||
}
|
||||
|
||||
// /etc/resolv.conf — cached so checkIsMigratedFromProbe doesn't dial again
|
||||
// applyProbeResolvConf caches the /etc/resolv.conf contents on the
|
||||
// summary so checkIsMigratedFromProbe doesn't have to make another dial.
|
||||
func applyProbeResolvConf(summary *MigrationSummary, probe *speakerProbe) {
|
||||
if resolv, ok := probe.Files["/etc/resolv.conf"]; ok {
|
||||
summary.CurrentResolvConf = resolv
|
||||
}
|
||||
}
|
||||
|
||||
// remote_services markers (SSH enablement state)
|
||||
// applyProbeRemoteServices records which remote_services marker files
|
||||
// exist on the device — these toggle SSH enablement state.
|
||||
func applyProbeRemoteServices(summary *MigrationSummary, probe *speakerProbe) {
|
||||
for _, loc := range []string{"/etc/remote_services", "/mnt/nv/remote_services", "/tmp/remote_services"} {
|
||||
if probe.Exists[loc] {
|
||||
summary.RemoteServicesFound = append(summary.RemoteServicesFound, loc)
|
||||
summary.RemoteServicesEnabled = true
|
||||
if !probe.Exists[loc] {
|
||||
continue
|
||||
}
|
||||
|
||||
if loc != "/tmp/remote_services" {
|
||||
summary.RemoteServicesPersistent = true
|
||||
}
|
||||
summary.RemoteServicesFound = append(summary.RemoteServicesFound, loc)
|
||||
summary.RemoteServicesEnabled = true
|
||||
|
||||
if loc != "/tmp/remote_services" {
|
||||
summary.RemoteServicesPersistent = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CA trust: check the ca-bundle for our injection label first; fall
|
||||
// back to matching the cert payload itself when Manager.Crypto is set
|
||||
// (web-UI/in-process path; the remote CLI has no Crypto so this stays
|
||||
// false until install-ca is run).
|
||||
if bundle, ok := probe.Files["/etc/pki/tls/certs/ca-bundle.crt"]; ok && bundle != "" {
|
||||
switch {
|
||||
case strings.Contains(bundle, CALabel):
|
||||
summary.CACertTrusted = true
|
||||
case m.Crypto != nil:
|
||||
if caCertPEM, err := os.ReadFile(m.Crypto.GetCACertPath()); err == nil {
|
||||
for _, line := range strings.Split(string(caCertPEM), "\n") {
|
||||
if line == "" || strings.Contains(line, "BEGIN CERTIFICATE") || strings.Contains(line, "END CERTIFICATE") {
|
||||
continue
|
||||
}
|
||||
// applyProbeCACert checks the device's CA bundle for our injection. The
|
||||
// fast path is the CALabel grep (works without Manager.Crypto and is the
|
||||
// only path the CLI ever uses). The fallback that compares the actual
|
||||
// cert payload runs only when Manager.Crypto is configured — i.e., in
|
||||
// the web-UI / in-process flow.
|
||||
func (m *Manager) applyProbeCACert(summary *MigrationSummary, probe *speakerProbe) {
|
||||
bundle, ok := probe.Files["/etc/pki/tls/certs/ca-bundle.crt"]
|
||||
if !ok || bundle == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.Contains(bundle, line) {
|
||||
summary.CACertTrusted = true
|
||||
}
|
||||
if strings.Contains(bundle, CALabel) {
|
||||
summary.CACertTrusted = true
|
||||
return
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
if m.Crypto == nil {
|
||||
return
|
||||
}
|
||||
|
||||
caCertPEM, err := os.ReadFile(m.Crypto.GetCACertPath())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(string(caCertPEM), "\n") {
|
||||
if line == "" || strings.Contains(line, "BEGIN CERTIFICATE") || strings.Contains(line, "END CERTIFICATE") {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.Contains(bundle, line) {
|
||||
summary.CACertTrusted = true
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user