mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
Addresses recommendations 4 and 7 from #515 comment 5231931569: a green-looking getpdo readback only confirms the sys configuration writes were accepted, not that they'll survive a reboot (that's what the envswitch-persisted layer decides). Labels the getpdo line in both migrateViaTelnet and runTelnetInjection's CLI/log output accordingly, softens migrateViaTelnet's "succeeded" wording to "accepted", and adds the same one-line caveat to TELNET-MIGRATION-METHOD.md #2.3 (previously only in TELNET-COMMAND-REFERENCE.md). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
155 lines
5.3 KiB
Go
155 lines
5.3 KiB
Go
package setup
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// telnetURLs holds the four URLs the migration writes via telnet. Most
|
|
// users keep all four pointing at the same service base; per-field
|
|
// overrides exist mainly so soundcork users can append /marge to the
|
|
// marge URL.
|
|
type telnetURLs struct {
|
|
Marge string
|
|
Stats string
|
|
SwUpdate string
|
|
BmxRegistry string
|
|
}
|
|
|
|
// defaultTelnetURLs returns the canonical URL set derived from the
|
|
// soundtouch-service base targetURL.
|
|
func defaultTelnetURLs(targetURL string) telnetURLs {
|
|
return telnetURLs{
|
|
Marge: targetURL,
|
|
Stats: targetURL,
|
|
SwUpdate: targetURL + "/updates/soundtouch",
|
|
BmxRegistry: targetURL + "/bmx/registry/v1/services",
|
|
}
|
|
}
|
|
|
|
// telnetURLsFromOptions resolves the four URLs from targetURL plus
|
|
// per-field overrides supplied via the migration options map. Recognised
|
|
// keys are marge_url, stats_url, sw_update_url, bmx_url; missing or empty
|
|
// entries fall back to the canonical default.
|
|
//
|
|
// We deliberately do not expose a "proxied"/"original" semantic here
|
|
// (unlike the XML method's applyProxyOptions): per the discussion that
|
|
// motivated this iteration, the goal is to keep the user model simple —
|
|
// one base URL plus optional path suffixes — and let the service layer
|
|
// hold any non-trivial logic.
|
|
func telnetURLsFromOptions(targetURL string, options map[string]string) telnetURLs {
|
|
u := defaultTelnetURLs(targetURL)
|
|
|
|
if v := options["marge_url"]; v != "" {
|
|
u.Marge = v
|
|
}
|
|
|
|
if v := options["stats_url"]; v != "" {
|
|
u.Stats = v
|
|
}
|
|
|
|
if v := options["sw_update_url"]; v != "" {
|
|
u.SwUpdate = v
|
|
}
|
|
|
|
if v := options["bmx_url"]; v != "" {
|
|
u.BmxRegistry = v
|
|
}
|
|
|
|
return u
|
|
}
|
|
|
|
// Commands returns the canonical sequence of telnet commands. Order
|
|
// matters: `sys configuration …` writes the runtime layer; the closing
|
|
// `envswitch boseurls set …` writes the parallel persistence layer that
|
|
// otherwise wins on the next reboot.
|
|
//
|
|
// Envswitch derivation rule: arg1 mirrors u.Marge verbatim, arg2 mirrors
|
|
// u.SwUpdate verbatim. Soundcork users who set Marge to "<base>/marge"
|
|
// therefore get "envswitch boseurls set <base>/marge <base>/updates/soundtouch"
|
|
// without any extra plumbing — the parallel layer stays consistent with
|
|
// the runtime layer.
|
|
func (u telnetURLs) Commands() []string {
|
|
return []string{
|
|
"sys configuration bmxRegistryUrl " + u.BmxRegistry,
|
|
"sys configuration statsServerUrl " + u.Stats,
|
|
"sys configuration margeServerUrl " + u.Marge,
|
|
"sys configuration swUpdateUrl " + u.SwUpdate,
|
|
"envswitch boseurls set " + u.Marge + " " + u.SwUpdate,
|
|
}
|
|
}
|
|
|
|
// migrateViaTelnet runs the URL-configuration sequence over the device's
|
|
// port-17000 diagnostic shell. It writes configuration only — reboot is left
|
|
// to the user, who triggers it via the existing reboot button (which now
|
|
// accepts a method=telnet|ssh selector).
|
|
//
|
|
// The sequence aborts on the first non-OK response so we never half-write the
|
|
// configuration; the caller can retry safely after fixing the underlying
|
|
// issue (closed port, hardened firmware, etc.).
|
|
//
|
|
// targetURL is kept as a separate verification anchor: most users have
|
|
// every URL share that base, so substring-matching it against the
|
|
// device's `getpdo` reply is the simplest "did the writes stick?" check
|
|
// that still works for the soundcork "/marge on one field" case.
|
|
func (m *Manager) migrateViaTelnet(deviceIP, targetURL string, urls telnetURLs) (string, error) {
|
|
if m.NewTelnet == nil {
|
|
return "", errors.New("telnet migration not configured: 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 failed: %w", deviceIP, err)
|
|
}
|
|
|
|
defer func() { _ = t.Close() }()
|
|
|
|
banner, _ := t.Probe()
|
|
if banner != "" {
|
|
fmt.Fprintf(&logs, "Telnet banner: %q\n", strings.TrimSpace(banner))
|
|
}
|
|
|
|
for _, cmd := range urls.Commands() {
|
|
resp, err := t.SendCommand(cmd)
|
|
if err != nil {
|
|
return logs.String(), fmt.Errorf("telnet command %q failed: %w", cmd, err)
|
|
}
|
|
|
|
fmt.Fprintf(&logs, "→ %s\n%s\n", cmd, strings.TrimRight(resp, "\r\n"))
|
|
|
|
if isCommandNotFound(resp) {
|
|
return logs.String(), fmt.Errorf("device rejected %q (firmware does not expose this command)", cmd)
|
|
}
|
|
}
|
|
|
|
verify, err := t.SendCommand("getpdo CurrentSystemConfiguration")
|
|
if err != nil {
|
|
return logs.String(), fmt.Errorf("verification command failed: %w", err)
|
|
}
|
|
|
|
fmt.Fprintf(&logs, "→ getpdo CurrentSystemConfiguration (runtime layer only — confirms the writes were accepted, not that they'll survive a reboot)\n%s\n", strings.TrimRight(verify, "\r\n"))
|
|
|
|
if !strings.Contains(verify, targetURL) {
|
|
return logs.String(), fmt.Errorf("verification failed: getpdo response does not contain %q (device may have rejected the new URLs)", targetURL)
|
|
}
|
|
|
|
logs.WriteString("Telnet writes accepted (runtime layer). Reboot the device so the envswitch-persisted layer takes over.\n")
|
|
|
|
return logs.String(), nil
|
|
}
|
|
|
|
// isCommandNotFound returns true if the device's response to a command
|
|
// indicates the command is not available on this firmware. Different firmware
|
|
// builds use slightly different wording; we accept any of the observed
|
|
// variants.
|
|
func isCommandNotFound(resp string) bool {
|
|
low := strings.ToLower(resp)
|
|
|
|
return strings.Contains(low, "command not found") ||
|
|
strings.Contains(low, "unknown command") ||
|
|
strings.Contains(low, "not implemented")
|
|
}
|