From 332c7b87d01b4baafd0c509d6cb768a1bdfea43f Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sat, 16 May 2026 11:42:32 +0200 Subject: [PATCH] fix(marge): drop AUX from cloud /full and /sources to unblock dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #195 and #269. Both issues reported the same symptom on freshly-paired speakers: AUX selection and preset playback failed post-pair, while /sources at :8090 still reported the sources as READY. The bug was upstream in AfterTouch's cloud-side responses. Real Bose's /streaming/account/{a}/full never emitted AUX as a cloud . Verified across 61 captured upstream /full bodies covering 4669 source elements: zero match sourceproviderid=9 (AUX), zero match the literal string "AUX". Captures sample at scripts/android/captures/var/lib/soundtouch-service/parity_mismatches/. The captured speakers are SoundTouch 20s which do have physical AUX inputs — Bose deliberately kept AUX out of /full and let the speaker enumerate it locally via isLocal=true. AfterTouch's getAccountSources unconditionally included AUX (id=10001) with the wrong shape: a displayName="AUX IN" attribute (real Bose: never), AUX (real Bose: empty), an empty (real Bose: empty for INTERNET_RADIO providerid=2 only, never present for AUX since AUX wasn't there). The speaker's source- reconciliation logic treated AfterTouch's malformed AUX entry as a cloud-side inconsistency and refused dispatch to AUX — even though the local availability check kept reporting it READY. This was the actual cause behind a long red-herring trail (TPDA :30034 storm, IoT.xml/AVS bootstrap, userAuthToken shape, SETUP state machine bracket). All of those are universal across the firmware family; spotty has the same TPDA storm in logread and AUX still works there. Only the cloud-source-list shape diverged between working and broken speakers. The filter applies in getAccountSources because both AccountFullToXML and AccountSourcesToXML go through it. AUX stays in GetDefaultSources for non-cloud consumers (web UI source picker, default-sources init). Three handler tests updated to assert AUX is intentionally excluded from cloud responses. Verified by gesellix on rhino 2026-05-16 via full factory-reset → wifi-push → setup pair → AUX press → audio plays. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/service/handlers/handlers_marge_test.go | 38 +++++++++++++++------ pkg/service/marge/marge.go | 19 +++++++++++ 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/pkg/service/handlers/handlers_marge_test.go b/pkg/service/handlers/handlers_marge_test.go index f9f1c3f..b809ac0 100644 --- a/pkg/service/handlers/handlers_marge_test.go +++ b/pkg/service/handlers/handlers_marge_test.go @@ -64,12 +64,16 @@ func TestMargeCreateAccount(t *testing.T) { t.Errorf("Expected 7-digit ID, got %v", resp.ID) } - // Verify it has default sources - if len(resp.Sources) != 5 { - t.Errorf("Expected 5 default sources, got %d", len(resp.Sources)) + // Verify default sources. AUX (id=10001, sourceproviderid=9) is + // intentionally excluded from cloud responses — real Bose never + // emitted AUX in /full; the speaker enumerates AUX from its own + // hardware via isLocal=true in :8090/sources. See + // pkg/service/marge/marge.go getAccountSources. + if len(resp.Sources) != 4 { + t.Errorf("Expected 4 cloud default sources (AUX excluded), got %d", len(resp.Sources)) } else { - if resp.Sources[0].ID != "10001" { - t.Errorf("Expected first source ID 10001, got %s", resp.Sources[0].ID) + if resp.Sources[0].ID != "10002" { + t.Errorf("Expected first cloud source ID 10002 (INTERNET_RADIO), got %s", resp.Sources[0].ID) } } @@ -380,10 +384,12 @@ func TestMargeAccountFullExcludesEmptyAmazonSource(t *testing.T) { t.Errorf("/full response must not include an empty-credential Amazon source; body:\n%s", bodyStr) } - // The 6 sources from lastDeviceID's stored Sources.xml must all be present. - // Checked by sourceproviderid since may hold a display name rather than the type string. + // The cloud-visible sources from lastDeviceID's stored Sources.xml + // must all be present. AUX (sourceproviderid=9) is intentionally + // excluded — real Bose never emitted AUX in /full; the speaker + // enumerates AUX from its own hardware via isLocal=true. See + // pkg/service/marge/marge.go getAccountSources. for _, wantProviderID := range []string{ - "9", // AUX "2", // INTERNET_RADIO "11", // LOCAL_INTERNET_RADIO "25", // TUNEIN @@ -394,6 +400,11 @@ func TestMargeAccountFullExcludesEmptyAmazonSource(t *testing.T) { t.Errorf("/full response is missing source with %s; body:\n%s", wantProviderID, bodyStr) } } + + // And explicitly assert AUX is NOT present. + if strings.Contains(bodyStr, "9") { + t.Errorf("/full response must not include AUX (sourceproviderid=9); body:\n%s", bodyStr) + } } func TestMargeAccountSources(t *testing.T) { @@ -627,13 +638,16 @@ func TestMargeAccountSourcesNoDevices(t *testing.T) { body, _ := io.ReadAll(res.Body) bodyStr := string(body) - // Verify that we get the default sources with correct IDs and empty display names + // Verify that we get the default cloud sources with correct IDs. AUX + // (id=10001) is intentionally excluded — real Bose never emitted AUX + // in cloud responses; the speaker enumerates AUX from its own + // hardware (isLocal=true on :8090/sources). See + // pkg/service/marge/marge.go getAccountSources. expectedSnippets := []string{ "", " (verified across 61 captured upstream + // /full responses in scripts/android/captures/.../ + // parity_mismatches/). AUX is hardware-local — the speaker + // enumerates it via isLocal=true in its own /sources response, + // it doesn't need the cloud to list it. AfterTouch emitting a + // malformed AUX entry here (with displayName=, empty + // , non-empty /) is the suspected + // trigger for issue #195: the speaker's source-reconciliation + // code marks AUX as cloud-side inconsistent and refuses + // dispatch, even though the local availability check reports + // it READY. We still keep AUX in getDefaultSources() because + // other call sites (default-sources init at startup, the + // SoundTouch web UI source picker) rely on it; the filter + // just keeps it out of /full's wire shape. + if s.SourceKeyType == constants.ProviderAux { + continue + } + PrepareConfiguredSource(&s) fullSources = append(fullSources, mapToFullResponseSource(s)) }