fix(setup): gate setup pair --mode=full on configuration status (#615)

A speaker can be reachable, named, and already account-paired yet still
report SOUNDTOUCH_NOT_CONFIGURED, leaving the "install the Bose app"
prompt on screen (reported for ST30 Series II/III in #615). Only a full
pass through the WebSocket setup state machine clears it, but running
that unconditionally risks re-running the bracket on speakers that
don't need or support it.

Add Manager.PreflightInitPlan: checks /supportedURLs for
/setMargeAccount, then requires /soundTouchConfigurationStatus to read
exactly SOUNDTOUCH_NOT_CONFIGURED before ExecuteInitPlan runs.
Already-configured devices are a no-op; an unsupported route or an
unrecognised status value aborts instead of guessing.
This commit is contained in:
Tobias Gesellchen
2026-08-17 21:29:39 +02:00
parent d873d88b4f
commit e57708ea11
6 changed files with 241 additions and 8 deletions
+12 -1
View File
@@ -2081,6 +2081,17 @@ func runPairBare(c *cli.Context, deviceIP, accountID string) error {
func runPairFull(c *cli.Context, deviceIP, accountID string) error {
m := setup.NewManager(c.String("service-url"), nil, nil)
needed, status, err := m.PreflightInitPlan(deviceIP)
if err != nil {
PrintError(fmt.Sprintf("preflight: %v", err))
return err
}
if !needed {
PrintSuccess(fmt.Sprintf("Device already configured (status=%s) — nothing to do.", status))
return nil
}
plan := setup.InitPlan{
DeviceIP: deviceIP,
ServiceURL: c.String("service-url"),
@@ -2094,7 +2105,7 @@ func runPairFull(c *cli.Context, deviceIP, accountID string) error {
ctx, cancel := context.WithTimeout(c.Context, 60*time.Second)
defer cancel()
_, err := m.ExecuteInitPlan(ctx, plan, func(e setup.StepEvent) {
_, err = m.ExecuteInitPlan(ctx, plan, func(e setup.StepEvent) {
switch e.Status {
case setup.StatusOK:
fmt.Printf("[%d] %s — ok\n", e.Kind, e.Name)
@@ -112,6 +112,14 @@ Factory-reset the same speaker again and run the full state machine — the same
This drives `setup.Manager.ExecuteInitPlan` with `SkipURLRewrite=true`, which runs:
> **Update (#615):** `--mode=full` now preflights via `Manager.PreflightInitPlan`
> before opening the WebSocket — it checks `/supportedURLs` for
> `/setMargeAccount` and requires `/soundTouchConfigurationStatus` to read
> `SOUNDTOUCH_NOT_CONFIGURED`, and no-ops on an already-configured device.
> A freshly factory-reset speaker (as in this experiment) reports
> `SOUNDTOUCH_NOT_CONFIGURED`, so the preflight passes through unchanged;
> see `docs/content/docs/reference/DEVICE-PAIRING-FLOW.md`.
```
SETUP_START
SETUP_IDENTIFY_DEVICE_ENTER
@@ -1427,6 +1427,15 @@ name during pairing (empty keeps current). `--language` defaults to `2`
(English). `--token` defaults to a built-in placeholder matching the Bose
app's token shape.
`--mode=full` first reads `/supportedURLs` and `/soundTouchConfigurationStatus`
and only runs the state machine when the device reports
`SOUNDTOUCH_NOT_CONFIGURED` (see [#615](https://github.com/gesellix/Bose-SoundTouch/issues/615):
a speaker can be reachable, named, and already account-paired yet still
report `SOUNDTOUCH_NOT_CONFIGURED`, leaving the "install the Bose app"
prompt on screen — only a full pass through the state machine clears it).
An already-configured device is a no-op; an unsupported route or an
unrecognised status value fails the command instead of guessing.
#### `setup sync`
Pulls presets, recents, and sources from the speaker into AfterTouch's
@@ -100,6 +100,20 @@ All subsequent messages (except `selectLastWiFiSource`, see below) use this enve
## Phase 2 — Pairing a New Speaker
> **Preflight (AfterTouch's `setup pair --mode=full`).** Before opening the
> WebSocket, AfterTouch reads `GET /supportedURLs` (must list
> `/setMargeAccount`) and `GET /soundTouchConfigurationStatus`, and only
> runs the state machine below when the status is exactly
> `SOUNDTOUCH_NOT_CONFIGURED`. This matters because a speaker can be
> reachable, named, and already have a `margeAccountUUID` set, yet still
> report `SOUNDTOUCH_NOT_CONFIGURED` — the firmware keeps prompting to
> install the Bose app until a full acknowledged pass through this state
> machine runs, not just `setMargeAccount` on its own. Already-configured
> devices are a no-op; an unsupported route or an unrecognised status value
> aborts without writing anything. See
> [#615](https://github.com/gesellix/Bose-SoundTouch/issues/615) and
> `Manager.PreflightInitPlan` (`pkg/service/setup/marge_pairing.go`).
### 2.1 Setup State Machine
The pairing flow uses a setup state machine on the device. States must be sent in order.
+76
View File
@@ -225,6 +225,82 @@ func (m *Manager) postSetMargeAccount(deviceIP, accountID string) error {
return nil
}
// ConfigurationStatus values reported by GET /soundTouchConfigurationStatus.
// See issue #615: a speaker can be reachable, named, and already
// account-paired yet still report SOUNDTOUCH_NOT_CONFIGURED, which leaves
// the firmware nagging the owner to install the Bose app. Only a full pass
// through the WebSocket setup state machine (ExecuteInitPlan) clears it.
const (
ConfigurationStatusConfigured = "SOUNDTOUCH_CONFIGURED"
ConfigurationStatusNotConfigured = "SOUNDTOUCH_NOT_CONFIGURED"
)
// ReadConfigurationStatus fetches /soundTouchConfigurationStatus and returns
// its raw status attribute (e.g. "SOUNDTOUCH_CONFIGURED").
func (m *Manager) ReadConfigurationStatus(deviceIP string) (string, error) {
url := buildDeviceURL(deviceIP, "/soundTouchConfigurationStatus")
client := &http.Client{Timeout: supportedURLsTimeout}
resp, err := client.Get(url)
if err != nil {
return "", fmt.Errorf("GET %s: %w", url, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
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)
}
var doc struct {
Status string `xml:"status,attr"`
}
if err := xml.Unmarshal(body, &doc); err != nil {
return "", fmt.Errorf("parse %s: %w", url, err)
}
return doc.Status, nil
}
// PreflightInitPlan reports whether ExecuteInitPlan should be run against
// deviceIP, gated on the two conditions from issue #615: /setMargeAccount
// must be listed in /supportedURLs, and the device's current
// /soundTouchConfigurationStatus must be exactly SOUNDTOUCH_NOT_CONFIGURED.
// needed=false with a nil error means "already configured, nothing to do."
// Any other outcome (unsupported route, unrecognised status value) is
// treated as unknown and returned as an error rather than guessed at.
func (m *Manager) PreflightInitPlan(deviceIP string) (needed bool, status string, err error) {
supported, probeErr := m.probeSetMargeAccount(deviceIP)
if probeErr != nil {
return false, "", fmt.Errorf("supportedURLs probe: %w", probeErr)
}
if !supported {
return false, "", errors.New("/setMargeAccount is not listed in /supportedURLs — device does not support this pairing path")
}
status, err = m.ReadConfigurationStatus(deviceIP)
if err != nil {
return false, "", fmt.Errorf("read /soundTouchConfigurationStatus: %w", err)
}
switch status {
case ConfigurationStatusConfigured:
return false, status, nil
case ConfigurationStatusNotConfigured:
return true, status, nil
default:
return false, status, fmt.Errorf("unexpected /soundTouchConfigurationStatus value %q", status)
}
}
// buildDeviceURL builds a URL for a SoundTouch device's HTTP API. If
// deviceIP already includes a port (test scenarios using httptest) it is
// reused as-is; otherwise the canonical port 8090 is appended.
+122 -7
View File
@@ -16,13 +16,14 @@ import (
// device's :8090 HTTP API. It records POSTs to /setMargeAccount so tests
// can assert on the body.
type fakeDevice struct {
srv *httptest.Server
addr string // "host:port" usable as deviceIP
supportsSetMarge bool
postStatus int // status code returned for POST /setMargeAccount
postDelay time.Duration
gotPostBody string
margeAccountUUID string // served by /info; empty means "unpaired"
srv *httptest.Server
addr string // "host:port" usable as deviceIP
supportsSetMarge bool
postStatus int // status code returned for POST /setMargeAccount
postDelay time.Duration
gotPostBody string
margeAccountUUID string // served by /info; empty means "unpaired"
configurationStatus string // served by /soundTouchConfigurationStatus; empty = route not served (404)
}
func newFakeDevice(t *testing.T) *fakeDevice {
@@ -62,6 +63,16 @@ func newFakeDevice(t *testing.T) *fakeDevice {
fmt.Fprintf(w, `<info deviceID="AABBCCDDEE0A"><margeAccountUUID>%s</margeAccountUUID></info>`, d.margeAccountUUID)
})
mux.HandleFunc("/soundTouchConfigurationStatus", func(w http.ResponseWriter, _ *http.Request) {
if d.configurationStatus == "" {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/xml")
fmt.Fprintf(w, `<SoundTouchConfigurationStatus status="%s" />`, d.configurationStatus)
})
d.srv = httptest.NewServer(mux)
u := d.srv.URL[len("http://"):]
@@ -360,6 +371,110 @@ func TestEnsureMargeAccountPaired_PropagatesPairingFailure(t *testing.T) {
}
}
func TestReadConfigurationStatus_ReturnsRawStatus(t *testing.T) {
d := newFakeDevice(t)
d.configurationStatus = ConfigurationStatusConfigured
m := &Manager{}
status, err := m.ReadConfigurationStatus(d.addr)
if err != nil {
t.Fatalf("ReadConfigurationStatus: %v", err)
}
if status != ConfigurationStatusConfigured {
t.Errorf("status = %q, want %q", status, ConfigurationStatusConfigured)
}
}
func TestReadConfigurationStatus_ErrorsWhenRouteUnsupported(t *testing.T) {
d := newFakeDevice(t)
d.configurationStatus = ""
m := &Manager{}
if _, err := m.ReadConfigurationStatus(d.addr); err == nil {
t.Fatal("expected an error when the route is unsupported (404)")
}
}
func TestPreflightInitPlan_NotConfiguredNeedsRepair(t *testing.T) {
d := newFakeDevice(t)
d.configurationStatus = ConfigurationStatusNotConfigured
m := &Manager{}
needed, status, err := m.PreflightInitPlan(d.addr)
if err != nil {
t.Fatalf("PreflightInitPlan: %v", err)
}
if !needed {
t.Error("needed should be true for SOUNDTOUCH_NOT_CONFIGURED")
}
if status != ConfigurationStatusNotConfigured {
t.Errorf("status = %q, want %q", status, ConfigurationStatusNotConfigured)
}
}
func TestPreflightInitPlan_AlreadyConfiguredIsNoOp(t *testing.T) {
d := newFakeDevice(t)
d.configurationStatus = ConfigurationStatusConfigured
m := &Manager{}
needed, status, err := m.PreflightInitPlan(d.addr)
if err != nil {
t.Fatalf("PreflightInitPlan: %v", err)
}
if needed {
t.Error("needed should be false for SOUNDTOUCH_CONFIGURED")
}
if status != ConfigurationStatusConfigured {
t.Errorf("status = %q, want %q", status, ConfigurationStatusConfigured)
}
}
func TestPreflightInitPlan_UnsupportedSetMargeAccountFailsClosed(t *testing.T) {
d := newFakeDevice(t)
d.supportsSetMarge = false
d.configurationStatus = ConfigurationStatusNotConfigured
m := &Manager{}
needed, _, err := m.PreflightInitPlan(d.addr)
if err == nil {
t.Fatal("expected an error when /setMargeAccount is not listed in /supportedURLs")
}
if needed {
t.Error("needed should be false when preflight fails")
}
}
func TestPreflightInitPlan_UnrecognisedStatusFailsClosed(t *testing.T) {
d := newFakeDevice(t)
d.configurationStatus = "SOMETHING_UNEXPECTED"
m := &Manager{}
needed, status, err := m.PreflightInitPlan(d.addr)
if err == nil {
t.Fatal("expected an error for an unrecognised status value")
}
if needed {
t.Error("needed should be false when the status is unrecognised")
}
if status != "SOMETHING_UNEXPECTED" {
t.Errorf("status = %q, want the raw unrecognised value returned alongside the error", status)
}
}
func TestIsValidAccountID(t *testing.T) {
cases := []struct {
in string