mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
fix(marge): drop AUX from cloud /full and /sources to unblock dispatch
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 <source>. 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), <name>AUX</name> (real Bose: empty), an empty <credential> (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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
824ed920ff
commit
332c7b87d0
@@ -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 <name> 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{
|
||||
"<sourceproviderid>9</sourceproviderid>", // AUX
|
||||
"<sourceproviderid>2</sourceproviderid>", // INTERNET_RADIO
|
||||
"<sourceproviderid>11</sourceproviderid>", // LOCAL_INTERNET_RADIO
|
||||
"<sourceproviderid>25</sourceproviderid>", // 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, "<sourceproviderid>9</sourceproviderid>") {
|
||||
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{
|
||||
"<sources>",
|
||||
"<source id=\"10004\" type=\"Audio\"",
|
||||
"<source id=\"10003\" type=\"Audio\"",
|
||||
"<source id=\"10002\" type=\"Audio\"",
|
||||
"<source id=\"10001\" type=\"Audio\"",
|
||||
}
|
||||
|
||||
for _, snippet := range expectedSnippets {
|
||||
@@ -642,6 +656,10 @@ func TestMargeAccountSourcesNoDevices(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(bodyStr, "<source id=\"10001\"") {
|
||||
t.Errorf("Response must not include AUX (id=10001); body:\n%s", bodyStr)
|
||||
}
|
||||
|
||||
// Verify that no sources have empty display names
|
||||
if strings.Count(bodyStr, "displayName=\"\"") != 0 {
|
||||
t.Errorf("Expected no sources with empty displayName, got %d: %s", strings.Count(bodyStr, "displayName=\"\""), bodyStr)
|
||||
|
||||
@@ -1036,6 +1036,25 @@ func getAccountSources(ds *datastore.DataStore, account, lastDeviceID string) []
|
||||
|
||||
for i := range sources {
|
||||
s := sources[i]
|
||||
// Real Bose's /streaming/account/{a}/full never emitted AUX as
|
||||
// a cloud-side <source> (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
|
||||
// <credential>, non-empty <name>/<username>) 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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user