mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-20 09:36:16 +00:00
feat(setup): cross-check SSH-XML against telnet-getpdo URL fields
When both preflights succeed, GetMigrationSummary now compares the URL fields in the parsed SoundTouchSdkPrivateCfg.xml (read via SSH) against the matching keys in `getpdo CurrentSystemConfiguration` (read via telnet) and appends a Warnings entry for any field whose values differ. The two sources can briefly disagree because `sys configuration …` writes the runtime layer while envswitch writes the parallel persistence layer and the on-device XML file is only re-rendered after a reboot. The warning text says exactly that, so the UI can surface a non-fatal hint instead of treating a freshly-migrated-but-not-yet-rebooted device as broken. Adds Warnings []string on MigrationSummary, parseGetpdoConfig (a key=value parser tolerant to banner/prompt noise), and crossCheckPreflights wired in as step 9 of GetMigrationSummary. 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
cb7c3f319d
commit
720f12d4d7
@@ -0,0 +1,82 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// crossCheckPreflights compares the URL fields visible via SSH (from the
|
||||
// parsed SoundTouchSdkPrivateCfg.xml) with the same fields visible via
|
||||
// telnet (from `getpdo CurrentSystemConfiguration`). Any field that is
|
||||
// reported by both transports but with different values is recorded as
|
||||
// a non-fatal warning.
|
||||
//
|
||||
// In practice the two sources can diverge briefly: `sys configuration …`
|
||||
// writes the runtime fields, while `envswitch boseurls set …` writes a
|
||||
// parallel persistence layer that wins on next boot — and the XML file
|
||||
// is only re-rendered after a reboot. A warning here is therefore not an
|
||||
// error per se; it usually means "reboot the device to make the two
|
||||
// layers agree."
|
||||
func (m *Manager) crossCheckPreflights(summary *MigrationSummary) {
|
||||
if summary.ParsedCurrentConfig == nil || summary.TelnetVerifiedConfig == "" {
|
||||
return
|
||||
}
|
||||
|
||||
telnet := parseGetpdoConfig(summary.TelnetVerifiedConfig)
|
||||
xml := summary.ParsedCurrentConfig
|
||||
|
||||
pairs := []struct {
|
||||
name string
|
||||
xmlValue string
|
||||
}{
|
||||
{"margeServerUrl", xml.MargeServerUrl},
|
||||
{"statsServerUrl", xml.StatsServerUrl},
|
||||
{"swUpdateUrl", xml.SwUpdateUrl},
|
||||
{"bmxRegistryUrl", xml.BmxRegistryUrl},
|
||||
}
|
||||
|
||||
for _, p := range pairs {
|
||||
telnetValue, hasTelnet := telnet[p.name]
|
||||
if !hasTelnet || p.xmlValue == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if telnetValue == p.xmlValue {
|
||||
continue
|
||||
}
|
||||
|
||||
summary.Warnings = append(summary.Warnings, fmt.Sprintf(
|
||||
"%s differs between transports: SSH-XML=%q telnet-getpdo=%q (a reboot usually re-syncs the runtime layer with the persisted XML)",
|
||||
p.name, p.xmlValue, telnetValue,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// parseGetpdoConfig extracts key=value pairs from `getpdo CurrentSystemConfiguration`
|
||||
// output. The format observed in the wild is one pair per line; any line
|
||||
// that does not match key=value is silently skipped, so the parser is
|
||||
// tolerant to banner text or trailing prompt characters.
|
||||
func parseGetpdoConfig(text string) map[string]string {
|
||||
out := map[string]string{}
|
||||
|
||||
for _, raw := range strings.Split(text, "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
i := strings.IndexByte(line, '=')
|
||||
if i <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(line[:i])
|
||||
val := strings.TrimSpace(line[i+1:])
|
||||
|
||||
if key != "" {
|
||||
out[key] = val
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseGetpdoConfig_StandardLines(t *testing.T) {
|
||||
in := "margeServerUrl=http://example:8000\nbmxRegistryUrl=http://example:8000/bmx/registry/v1/services\n"
|
||||
|
||||
got := parseGetpdoConfig(in)
|
||||
|
||||
if got["margeServerUrl"] != "http://example:8000" {
|
||||
t.Errorf("margeServerUrl = %q, want http://example:8000", got["margeServerUrl"])
|
||||
}
|
||||
|
||||
if got["bmxRegistryUrl"] != "http://example:8000/bmx/registry/v1/services" {
|
||||
t.Errorf("bmxRegistryUrl = %q", got["bmxRegistryUrl"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGetpdoConfig_TolerantToNoise(t *testing.T) {
|
||||
in := "BoseShell\n-> getpdo CurrentSystemConfiguration\nmargeServerUrl=http://example:8000\nrandom line without equals\n statsServerUrl = http://example:8000 \n-> "
|
||||
|
||||
got := parseGetpdoConfig(in)
|
||||
|
||||
if got["margeServerUrl"] != "http://example:8000" {
|
||||
t.Errorf("margeServerUrl = %q, want http://example:8000", got["margeServerUrl"])
|
||||
}
|
||||
|
||||
if got["statsServerUrl"] != "http://example:8000" {
|
||||
t.Errorf("statsServerUrl = %q, want trimmed http://example:8000", got["statsServerUrl"])
|
||||
}
|
||||
|
||||
if _, exists := got["random line without equals"]; exists {
|
||||
t.Errorf("non-key=value line should not be parsed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossCheckPreflights_AgreementProducesNoWarnings(t *testing.T) {
|
||||
m := &Manager{ServerURL: "http://example:8000"}
|
||||
|
||||
summary := &MigrationSummary{
|
||||
ParsedCurrentConfig: &PrivateCfg{
|
||||
MargeServerUrl: "http://example:8000",
|
||||
StatsServerUrl: "http://example:8000",
|
||||
SwUpdateUrl: "http://example:8000/updates/soundtouch",
|
||||
BmxRegistryUrl: "http://example:8000/bmx/registry/v1/services",
|
||||
},
|
||||
TelnetVerifiedConfig: "margeServerUrl=http://example:8000\n" +
|
||||
"statsServerUrl=http://example:8000\n" +
|
||||
"swUpdateUrl=http://example:8000/updates/soundtouch\n" +
|
||||
"bmxRegistryUrl=http://example:8000/bmx/registry/v1/services\n",
|
||||
}
|
||||
|
||||
m.crossCheckPreflights(summary)
|
||||
|
||||
if len(summary.Warnings) != 0 {
|
||||
t.Errorf("Warnings = %v, want none when both transports agree", summary.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossCheckPreflights_MismatchProducesWarning(t *testing.T) {
|
||||
m := &Manager{ServerURL: "http://example:8000"}
|
||||
|
||||
// SSH-XML still shows the original cloud URL (envswitch wrote the
|
||||
// runtime layer but the on-device file hasn't been re-rendered).
|
||||
summary := &MigrationSummary{
|
||||
ParsedCurrentConfig: &PrivateCfg{
|
||||
MargeServerUrl: "https://streaming.bose.com",
|
||||
},
|
||||
TelnetVerifiedConfig: "margeServerUrl=http://example:8000\n",
|
||||
}
|
||||
|
||||
m.crossCheckPreflights(summary)
|
||||
|
||||
if len(summary.Warnings) != 1 {
|
||||
t.Fatalf("Warnings = %v, want exactly one warning", summary.Warnings)
|
||||
}
|
||||
|
||||
w := summary.Warnings[0]
|
||||
|
||||
if !strings.Contains(w, "margeServerUrl") {
|
||||
t.Errorf("warning %q should name the field", w)
|
||||
}
|
||||
|
||||
if !strings.Contains(w, "streaming.bose.com") || !strings.Contains(w, "example:8000") {
|
||||
t.Errorf("warning %q should quote both values", w)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossCheckPreflights_NoWarningWhenTelnetMissesField(t *testing.T) {
|
||||
m := &Manager{ServerURL: "http://example:8000"}
|
||||
|
||||
summary := &MigrationSummary{
|
||||
ParsedCurrentConfig: &PrivateCfg{
|
||||
MargeServerUrl: "http://example:8000",
|
||||
StatsServerUrl: "http://example:8000",
|
||||
},
|
||||
// getpdo only echoes margeServerUrl — statsServerUrl is silently
|
||||
// absent on this firmware. Absence is not a disagreement.
|
||||
TelnetVerifiedConfig: "margeServerUrl=http://example:8000\n",
|
||||
}
|
||||
|
||||
m.crossCheckPreflights(summary)
|
||||
|
||||
if len(summary.Warnings) != 0 {
|
||||
t.Errorf("Warnings = %v, want none when a field is missing from one transport", summary.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossCheckPreflights_OnlyOneTransportPresent(t *testing.T) {
|
||||
m := &Manager{ServerURL: "http://example:8000"}
|
||||
|
||||
t.Run("telnet only", func(t *testing.T) {
|
||||
summary := &MigrationSummary{
|
||||
TelnetVerifiedConfig: "margeServerUrl=http://example:8000\n",
|
||||
}
|
||||
m.crossCheckPreflights(summary)
|
||||
if len(summary.Warnings) != 0 {
|
||||
t.Errorf("Warnings = %v, want none when SSH didn't read the XML", summary.Warnings)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ssh only", func(t *testing.T) {
|
||||
summary := &MigrationSummary{
|
||||
ParsedCurrentConfig: &PrivateCfg{MargeServerUrl: "http://example:8000"},
|
||||
}
|
||||
m.crossCheckPreflights(summary)
|
||||
if len(summary.Warnings) != 0 {
|
||||
t.Errorf("Warnings = %v, want none when telnet didn't respond", summary.Warnings)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -93,6 +93,12 @@ type MigrationSummary struct {
|
||||
// KnownAccountIDs are accountIDs already present in the local datastore;
|
||||
// the UI offers them as choices when pairing a fresh device.
|
||||
KnownAccountIDs []string `json:"known_account_ids,omitempty"`
|
||||
|
||||
// Warnings holds non-fatal advisories emitted during summary
|
||||
// construction — currently the cross-check between SSH-XML and
|
||||
// telnet-getpdo readings of the device's URL configuration. The UI
|
||||
// should display them as informational hints, not errors.
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
// SSHClient defines the interface for SSH operations.
|
||||
@@ -343,6 +349,10 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
|
||||
summary.TelnetVerifiedConfig = telnetResult.TelnetVerifiedConfig
|
||||
summary.TelnetProbeError = telnetResult.TelnetProbeError
|
||||
|
||||
// 9. Cross-check SSH-XML and telnet-getpdo readings; surface any
|
||||
// divergence as a non-fatal warning.
|
||||
m.crossCheckPreflights(summary)
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user