fix(setup): fire <sourcesUpdated/> after data sync to recover post-factory-reset sources

Closes the AfterTouch-side half of issue #234. After a factory reset
the speaker's /sources only lists the always-on local entries (AUX,
BLUETOOTH, AIRPLAY, NOTIFICATION, QPLAY, plus a SpotifyConnectUserName
placeholder); TUNEIN, LOCAL_INTERNET_RADIO, DEEZER, and linked
Spotify accounts are absent until the device receives the
<sourcesUpdated/> notification the reporter ran by hand. SyncDeviceData
now POSTs that notification as the final step, so users get the
visible-source-list recovery for free when they click Data Sync.

The other half — re-creating Marge.xml so playback resumes — is
already handled by the wizard's pair-account flow: it detects an
empty <margeAccountUUID/> in /info and prompts the user to pick a
known account or generate a new one. The wizard's pairing UI is
deliberately user-driven (the user picks the ID); the notification
nudge is purely automatic because there's no choice to make.

Implementation routes through the existing client surface rather
than reinventing it. setup.notifySpeakerSourcesUpdated delegates to
pkg/client.Client.NotifySourcesUpdated — the same path
handlers_mgmt.go already uses after music-service account changes
(handlers_mgmt.go:304, :637). The wire shape lives in one place
(pkg/models.NewSourcesUpdatedNotification). Fire-and-forget: a
notification failure logs but doesn't fail the sync.

Adjacent UX changes:

  - docs/guides/TROUBLESHOOTING.md: new section "Presets flash then
    revert to 'Select a preset' after a factory reset". Names the
    symptom, the Marge.xml + reduced-/sources cause, and walks the
    user through re-opening the Migration tab + Data Sync.

  - pkg/service/handlers/web/js/script.js: devices list now renders
    a "⚠ Not paired — re-pair" badge in the account-ID column for
    speakers whose live /info reports an empty margeAccountUUID.
    Clicking it opens the Migration tab pre-filled with that device,
    surfacing the wizard's existing "Not paired (factory-reset or
    never paired)" flow without making users discover it cold.

  - pkg/service/testing/fakespeaker/testdata/info.xml: demo speaker
    now reports margeAccountUUID=1234567 instead of the misleading
    0000000 (which AfterTouch happens to accept as syntactically
    valid but is not a documented sentinel anywhere — the convention
    is empty for factory-reset, a real 7-digit number otherwise,
    matching pkg/client/testdata/info_response_st{10,20}.xml).
    Screenshots regenerated accordingly.

Test scaffolding:

  - fakespeaker grows a POST /notification recorder that captures
    body + Content-Type; tests assert on s.Notifications().
  - TestIssue234_FactoryResetSpeakerSyncsReducedSources now drives
    SyncDeviceData end-to-end (exercises the wiring) and asserts
    the notification fires with the right deviceID and shape.
  - TestFakeSpeakerNotificationRecorder pins the recorder contract
    and the POST-only method gate.

Refs #234.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-15 16:25:07 +02:00
co-authored by Claude Opus 4.7
parent 61c33d527c
commit 6e6e4838e6
11 changed files with 282 additions and 18 deletions
+31
View File
@@ -130,6 +130,37 @@ Expected when the misconfiguration is present: `443=000` plus a `curl: (7) Faile
**Fix:** route `:443` to AfterTouch's HTTPS listener — see [HTTPS-SETUP.md → Binding to port 443](HTTPS-SETUP.md#binding-to-port-443). The AfterTouch settings page shows a ✅ / ❌ indicator for `:443` reachability once the routing is in place.
### ❌ Presets flash then revert to "Select a preset" after a factory reset
**Symptoms:**
- You factory-reset a SoundTouch (Wave / 10 / 20 / 30 / …) that was previously migrated.
- After reconnecting it to Wi-Fi, AfterTouch sees the speaker again, but pressing a preset on the device or in the app makes the display briefly show the preset name and then revert to *"Select a preset or explore music in the SoundTouch App"*.
- Spotify presets show the same revert unless Spotify Connect is started from the mobile app first.
- The speaker's `/sources` is missing TUNEIN / LOCAL_INTERNET_RADIO / DEEZER / your linked Spotify account — only AUX, BLUETOOTH, AIRPLAY, the SpotifyConnectUserName placeholder, NOTIFICATION, and QPLAY appear.
**Cause:**
A factory reset wipes `/mnt/nv/BoseApp-Persistence/1/Marge.xml` — the file that carries the speaker's auth token for the AfterTouch (or Bose) cloud service. The migrated URL configuration is preserved (it lives in `envswitch`), so the speaker keeps talking to AfterTouch, but with no token it can't authenticate for preset playback. Separately, the device's `/sources` cache is reduced until it receives a `<sourcesUpdated/>` notification.
**Fix:**
1. **Re-open the Migration tab** in the AfterTouch UI. The wizard reads `/info`, sees `margeAccountUUID` is empty, and renders:
> **Current: ❌ Not paired (factory-reset or never paired) — set an ID to pair as part of Apply**
The devices list now also shows a `⚠ Not paired — re-pair` badge next to such speakers, so you don't have to remember to open the Migration tab cold.
2. **Pick the previously-used account ID** from the "pick from datastore" dropdown (if AfterTouch remembers it), or click **Generate** for a fresh one.
3. **Click Apply.** The wizard runs `pair-account` along with the rest, recreating `Marge.xml` on the device with the chosen ID.
4. **Click Data Sync** (Tab 3). AfterTouch persists the speaker's presets/recents/sources and posts a `<sourcesUpdated/>` notification to the device — the missing TUNEIN / LOCAL_INTERNET_RADIO / DEEZER / linked Spotify entries reappear in `/sources` automatically.
5. Press a preset. It should play normally.
If presets still won't play after step 5, capture `logread -f | grep -v '127.0.0.1:'` on the speaker (see [DEVICE-LOGGING.md](../DEVICE-LOGGING.md#1-accessing-system-logs-requires-root)) while pressing the preset and file an issue with the snippet — the lines around the failed playback name the deeper cause.
### ❌ "Connection refused"
**Symptoms:**
Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 514 KiB

After

Width:  |  Height:  |  Size: 518 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 482 KiB

After

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 97 KiB

+25 -1
View File
@@ -1809,7 +1809,31 @@ async function updateDeviceInfo(deviceId, ip) {
if (deviceIdEl && info.deviceID) deviceIdEl.innerText = info.deviceID;
const accountIdEl = row.querySelector(".col-accountid");
if (accountIdEl && info.margeAccountUUID) accountIdEl.innerText = info.margeAccountUUID;
if (accountIdEl) {
if (info.margeAccountUUID) {
accountIdEl.innerText = info.margeAccountUUID;
accountIdEl.style.color = "#666";
} else {
// Empty <margeAccountUUID/> in /info → speaker is
// either factory-reset or never paired. The
// Migration tab's wizard already detects this
// state and prompts for re-pairing; this badge
// surfaces the affordance from the devices list
// so users don't have to know to open the
// Migration tab cold. See issue #234.
accountIdEl.replaceChildren();
const badge = document.createElement("a");
badge.href = "#";
badge.onclick = (e) => {
e.preventDefault();
prepareMigration(deviceId);
};
badge.innerText = "⚠ Not paired — re-pair";
badge.style.color = "#c62828";
badge.title = "Open the Migration tab to re-pair this speaker (factory-reset or never paired).";
accountIdEl.appendChild(badge);
}
}
}
} catch (error) {
console.warn("Failed to fetch live info for " + ip, error);
+49 -7
View File
@@ -115,12 +115,22 @@ func TestIssue234_FactoryResetSpeakerSyncsReducedSources(t *testing.T) {
t.Errorf("DeviceID = %q, want %q", info.DeviceID, "DEADBEEFCAFE")
}
// 2. Source round-trip: reduced list survives sync verbatim.
const accountID = "issue234"
// 2. End-to-end sync. Driving SyncDeviceData rather than
// syncSources directly exercises the wiring between
// syncSources and notifySpeakerSourcesUpdated — the source
// list still lands on disk (assertions below) AND the
// sourcesUpdated notification fires against the device.
// SyncDeviceData derives accountID/deviceID from /info; with
// an empty margeAccountUUID the account falls through to
// "default".
if err := m.SyncDeviceData(deviceIP); err != nil {
t.Fatalf("SyncDeviceData: %v", err)
}
const deviceID = "DEADBEEFCAFE"
m.syncSources(deviceIP, accountID, deviceID)
const (
accountID = "default"
deviceID = "DEADBEEFCAFE"
)
sourcesPath := filepath.Join(tempDir, "accounts", accountID, "devices", deviceID, "Sources.xml")
@@ -144,14 +154,46 @@ func TestIssue234_FactoryResetSpeakerSyncsReducedSources(t *testing.T) {
}
// Casualties: TUNEIN / LOCAL_INTERNET_RADIO are the symptom of
// #234 — they should remain absent until auto-recovery lands.
// #234 — they should remain absent on the persisted side
// because fakespeaker is stateless (the next /sources read
// returns the same reduced fixture even after the
// notification). On a real speaker the device would react to
// the notification, re-expose the missing sources, and the
// next Data Sync would persist them — that second-sync step
// is the runbook user-facing flow, not something we model
// here.
for _, missingKey := range []string{
`<sourceKey type="TUNEIN"`,
`<sourceKey type="LOCAL_INTERNET_RADIO"`,
} {
if strings.Contains(content, missingKey) {
t.Errorf("persisted Sources.xml unexpectedly contains %s — auto-recovery for issue #234 may have landed; if so, flip this assertion and the doc-comment;\nbody:\n%s",
t.Errorf("persisted Sources.xml unexpectedly contains %s — fixture changed?;\nbody:\n%s",
missingKey, content)
}
}
// 3. The sourcesUpdated notification must have fired against
// the device with the right deviceID and shape, regardless of
// what the fakespeaker decided to do with it.
notifs := s.Notifications()
if len(notifs) != 1 {
t.Fatalf("Notifications() returned %d entries, want exactly 1 sourcesUpdated POST", len(notifs))
}
// The exact serialization (self-closing vs long-form
// <sourcesUpdated></sourcesUpdated>) is up to encoding/xml and
// not protocol-meaningful — assert on the load-bearing pieces
// instead of the byte-identical body.
body := string(notifs[0].Body)
if !strings.Contains(body, `deviceID="DEADBEEFCAFE"`) {
t.Errorf("notification body missing deviceID; got: %q", body)
}
if !strings.Contains(body, "sourcesUpdated") {
t.Errorf("notification body missing sourcesUpdated; got: %q", body)
}
if !strings.Contains(notifs[0].ContentType, "xml") {
t.Errorf("notification Content-Type = %q, want something xml-shaped", notifs[0].ContentType)
}
}
+41 -1
View File
@@ -16,6 +16,7 @@ import (
"strings"
"time"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
@@ -2490,7 +2491,16 @@ func (m *Manager) SyncDeviceData(deviceIP string) error {
// 4. Fetch Sources
m.syncSources(deviceIP, accountID, deviceID)
// 5. Create off-device backup of system configuration
// 5. Nudge the device to re-render its source list. After a factory
// reset (issue #234) the speaker's /sources only lists the always-on
// local entries until it receives a <sourcesUpdated/> notification;
// the reporter's workaround was to POST this by hand. Wiring it into
// the sync flow means the user gets the visible recovery for free
// after they click Data Sync — re-pairing (which the wizard already
// detects + prompts for) is the orthogonal half of the fix.
m.notifySpeakerSourcesUpdated(deviceIP, deviceID)
// 6. Create off-device backup of system configuration
_ = m.BackupConfigOffDevice(deviceIP)
return nil
@@ -2666,3 +2676,33 @@ func (m *Manager) syncSources(deviceIP, accountID, deviceID string) {
_ = m.DataStore.SaveConfiguredSources(accountID, deviceID, configuredSources)
}
}
// notifySpeakerSourcesUpdated POSTs the <sourcesUpdated/> notification
// to /notification on the device, mirroring the manual workaround
// documented in issue #234. The device responds by re-evaluating its
// /sources catalogue — after a factory reset that's what makes TUNEIN /
// LOCAL_INTERNET_RADIO / DEEZER / linked Spotify accounts reappear in
// the list. The wizard's pair-account flow restores playback (it
// recreates the Marge.xml token); this nudge restores the *visible*
// source list. Both are needed for a full #234 recovery; this is the
// half AfterTouch can automate without user input.
//
// Delegates the HTTP plumbing to pkg/client.Client.NotifySourcesUpdated,
// which is the same path handlers_mgmt.go uses after music-service
// account changes — keeping the wire-shape definition in one place
// (pkg/models.NewSourcesUpdatedNotification).
//
// Fire-and-forget: a network failure (or the device returning an
// unexpected response) doesn't fail the surrounding sync. The sync's
// persisted state is already on disk by the time we fire the
// notification; whether the device acts on it is observable on the
// next sync.
func (m *Manager) notifySpeakerSourcesUpdated(deviceIP, deviceID string) {
c := client.NewClientFromHost(deviceIP)
if err := c.NotifySourcesUpdated(deviceID); err != nil {
log.Printf("[SYNC] notify %s: %v", deviceIP, err)
return
}
log.Printf("[SYNC] notify %s sourcesUpdated -> ok", deviceIP)
}
+68 -8
View File
@@ -17,6 +17,7 @@ import (
"io"
"net"
"net/http"
"sync"
"time"
)
@@ -57,6 +58,34 @@ type Server struct {
srv *http.Server
httpAddr string
telnet *telnetServer
mu sync.Mutex
notifications []NotificationCall
}
// NotificationCall records a single POST /notification request the
// fake received. Tests use it to assert that AfterTouch (or any
// other component under test) fired the expected speaker-side
// notification.
type NotificationCall struct {
// Body is the request body verbatim.
Body []byte
// ContentType is the value of the Content-Type header.
ContentType string
}
// Notifications returns a snapshot of every POST /notification call
// the fake has received, in arrival order. The slice is independent
// of the server's internal state — callers can keep it for assertions
// without holding a lock.
func (s *Server) Notifications() []NotificationCall {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]NotificationCall, len(s.notifications))
copy(out, s.notifications)
return out
}
// Start binds the configured listeners and serves them in background
@@ -73,17 +102,18 @@ func Start(cfg Config) (*Server, error) {
return nil, fmt.Errorf("fakespeaker: listen %s: %w", httpListen, err)
}
mux := http.NewServeMux()
registerRoutes(mux, cfg.FixtureOverrides)
s := &Server{
srv: &http.Server{
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
},
httpAddr: ln.Addr().String(),
}
mux := http.NewServeMux()
registerRoutes(mux, cfg.FixtureOverrides, s)
s.srv = &http.Server{
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
_ = s.srv.Serve(ln)
}()
@@ -130,7 +160,7 @@ func (s *Server) Stop(ctx context.Context) error {
return nil
}
func registerRoutes(mux *http.ServeMux, overrides map[string][]byte) {
func registerRoutes(mux *http.ServeMux, overrides map[string][]byte, s *Server) {
fixture := func(route, embedPath string) {
mux.HandleFunc(route, serveFixtureOr(embedPath, overrides[route]))
}
@@ -147,6 +177,36 @@ func registerRoutes(mux *http.ServeMux, overrides map[string][]byte) {
mux.HandleFunc("/addGroup", handleAddGroup)
mux.HandleFunc("/updateGroup", handleUpdateGroup)
mux.HandleFunc("/removeGroup", handleRemoveGroup)
mux.HandleFunc("/notification", s.handleNotification)
}
// handleNotification records a POST /notification call so tests can
// assert that AfterTouch fired the expected speaker-side nudge (e.g.
// the <sourcesUpdated/> notification that recovers the source list
// after a factory reset, per issue #234). GET returns 405 — real
// speakers expose /notification as POST-only.
func (s *Server) handleNotification(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
body, _ := io.ReadAll(http.MaxBytesReader(w, r.Body, 64*1024))
s.mu.Lock()
s.notifications = append(s.notifications, NotificationCall{
Body: body,
ContentType: r.Header.Get("Content-Type"),
})
s.mu.Unlock()
// Real speakers respond with <status>/notification</status>; the
// pkg/client.Client.NotifySourcesUpdated path validates that
// shape, so the fake has to match it too.
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8" ?>` + "\n<status>/notification</status>\n"))
}
// serveFixtureOr returns a handler that writes override (when non-nil)
@@ -228,6 +228,73 @@ func TestFakeSpeakerFixtureOverride_ReplacesEmbeddedBody(t *testing.T) {
}
}
func TestFakeSpeakerNotificationRecorder(t *testing.T) {
s, err := Start(Config{})
if err != nil {
t.Fatalf("start: %v", err)
}
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = s.Stop(ctx)
})
body := `<updates deviceID="DEADBEEFCAFE"><sourcesUpdated/></updates>`
req, err := http.NewRequest(http.MethodPost,
"http://"+s.HTTPAddr()+"/notification",
strings.NewReader(body))
if err != nil {
t.Fatalf("build request: %v", err)
}
req.Header.Set("Content-Type", "application/xml")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
got := s.Notifications()
if len(got) != 1 {
t.Fatalf("Notifications() returned %d entries, want 1", len(got))
}
// The test POSTs the request body verbatim; the recorder must
// return it byte-identical. (Wire-shape variation — self-closing
// vs long-form sourcesUpdated — happens upstream in
// pkg/client.NotifySourcesUpdated, not here.)
if string(got[0].Body) != body {
t.Errorf("body = %q, want %q", got[0].Body, body)
}
if got[0].ContentType != "application/xml" {
t.Errorf("ContentType = %q, want application/xml", got[0].ContentType)
}
// GET on the same path is a 405 — real speakers don't expose it.
getResp, err := http.Get("http://" + s.HTTPAddr() + "/notification") //nolint:noctx
if err != nil {
t.Fatalf("get: %v", err)
}
defer func() { _ = getResp.Body.Close() }()
if getResp.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("GET status = %d, want 405", getResp.StatusCode)
}
if got := getResp.Header.Get("Allow"); got != "POST" {
t.Errorf("Allow header = %q, want POST", got)
}
}
func TestFakeSpeakerRemoveGroupRejectsNonGET(t *testing.T) {
s, err := Start(Config{})
if err != nil {
+1 -1
View File
@@ -2,7 +2,7 @@
<info deviceID="DEADBEEFCAFE">
<name>Demo SoundTouch</name>
<type>SoundTouch 10</type>
<margeAccountUUID>0000000</margeAccountUUID>
<margeAccountUUID>1234567</margeAccountUUID>
<components>
<component>
<componentCategory>SCM</componentCategory>