Compare commits

...
15 Commits
Author SHA1 Message Date
Tobias GesellchenandClaude Opus 4.7 e64481f008 docs(setup): record ST10 ≡ ST20 bundle equivalence + curl reproducer
Two doc-only additions to TestValidateRealSpeakerBundle's header
comment:

  - Cross-model note: ST10 and ST20 ship the byte-identical CA
    bundle on firmware 27.0.6.46330.5043500 (md5
    2d150987b312e4280fc576b508e62b43, 165 certs, ~251 KB).
    Verified against firmware/_backup_ST10/_/etc/pki/tls/certs/
    ca-bundle.crt 2026-05-16. The existing
    testdata/ca_bundle_st20_pristine.crt fixture therefore stands
    in for both models on that firmware build, so any expired-root
    hypothesis evaluated against it covers both.
  - Curl reproducer: three one-liners that point curl at the fixture
    and probe the actual TuneIn stream chain a SoundTouch speaker
    would walk (using K-LOVE / s33828 as the canonical example —
    matches the case from #292). Control with the system trust
    store shown alongside. Both bundles handle the chain (Amazon
    Root CA 1 + DigiCert Global Root, valid through 2026+) so the
    expired-root hypothesis is ruled out for firmware 27 — recorded
    in the comment so future-me / reviewers can replay the same
    probe without re-deriving it from chat context.

No code change; test still passes.

Related to https://github.com/gesellix/Bose-SoundTouch/issues/292.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:23:23 +02:00
Tobias GesellchenandClaude Opus 4.7 04b3a445ca feat(bmx): make TuneIn formats= configurable via Settings.TuneInStreamFormats
PR #249 added "hls" unconditionally to TuneIn's Tune.ashx formats=
query. That regressed playback on the SoundTouch line: TuneIn returns
an .m3u8 HLS playlist for stations like K-LOVE (s33828), the speaker
can't parse it, blinks amber and falls silent. Verified that
firmware 27 on ST10 and ST20 ships the byte-identical Mozilla CCADB
bundle and validates the actual stream chain cleanly, so it isn't a
cert-expiry issue (#292's hypothesis) — the speaker simply has no
HLS support.

Changes:

  - TuneInStream is now a builder, not a const: takes the station ID
    plus a formats string (empty falls back to the new exported
    DefaultTuneInStreamFormats = "mp3,aac,ogg" — matches the pre-#249
    request shape).
  - TuneInPlayback and TuneInPlaybackPodcast take the formats string.
  - New Settings.TuneInStreamFormats string. Empty by default.
    Operators with HLS-capable speakers can set it to
    "mp3,aac,ogg,hls" — or any other comma-separated list — via
    settings.json. The value is passed through verbatim; AfterTouch
    does not validate the individual format tokens, so this is also
    the right knob for trialling additional formats without code
    changes.
  - Two regression tests pin both the empty-uses-default contract
    and the override-passes-through contract (with the whitespace-
    trim sub-case) so PR #249-style regressions surface at
    compile/test time.

The setting is settings.json-only (matches the existing pattern for
AllowInsecureUpstreamTLS / TrustForwardedHeaders / TrustedProxyCIDRs
which are also edit-the-file settings). UI surface can be a small
follow-up if reporters ask for it.

Example settings.json snippet to re-enable HLS (only if your
speaker can actually play it):

    {
      "server_url": "http://aftertouch.local:8000",
      "tunein_stream_formats": "mp3,aac,ogg,hls"
    }

Restart soundtouch-service after editing.

Related to https://github.com/gesellix/Bose-SoundTouch/issues/292.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:14:28 +02:00
Tobias GesellchenandClaude Opus 4.7 06916226df feat(setup): tag service-side IP resolve with a sentinel + observe SSH cost
The migration-summary preflight always emitted a "resolved from service,
not from device"  row whenever the target was a hostname — even when
SSH was available and could have answered authoritatively. Two
problems compounded: the summary builder passed `nil` for the SSH
client (skipping the device-side ping), and resolveIP's service-side
fallback returned a bare fmt.Errorf the caller couldn't distinguish
from a real failure.

Changes:

  - ErrResolvedFromServiceOnly sentinel; service-side fallback wraps
    it with fmt.Errorf("%w: ...") so callers can errors.Is()-check.
    Apply-path callers that pass a real SSH client keep getting the
    same error shape they always did.
  - populatePlannedNetworkConfig now takes an SSHClient. GetMigrationSummary
    opens one when probe.SSHOK is true and passes it through, so the
    summary's resolve call uses the same device-side authority the
    apply paths use. Skipping the dial when SSH is known dead keeps
    a stale handshake-timeout from burning the preflight budget.
  - MigrationSummary gains ResolveIPSource ("device" / "service") and
    ResolveIPDurationMS so we can observe the SSH-ping cost in the
    wild. The historical comment claimed 2-5 s on firmware-27 devices —
    we now have data instead of a guess.
  - CLI renderer prints the new source + timing line, and only renders
    the  ResolveIPError row for hard failures (both SSH ping AND
    service DNS failed).
  - Two regression tests cover the sentinel-tagging contract and the
    device-success-returns-nil-error path.

Related to https://github.com/gesellix/Bose-SoundTouch/issues/282.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:16:53 +02:00
Tobias GesellchenandClaude Opus 4.7 695dd954e7 test(setup): regression for telnet-only migration detection
Pins the ordering invariant fixed in the preceding commit. Builds a
fake-speaker scenario where:

  - SSH is unavailable (every SSH-driven axis stays false)
  - telnet getpdo reports the AfterTouch hostname

Pre-fix, checkIsMigratedFromProbe ran before the telnet channel was
drained, so summary.TelnetVerifiedConfig was empty when
isTelnetMigrated read it — the telnet axis came back false and
summary.IsMigrated followed. The CLI's `setup verify` exited
non-zero, the web UI rendered "Not Migrated". Reproduced by
foob61451 on #293.

The test asserts:

  - summary.TelnetVerifiedConfig is populated (sanity guard — the
    downstream assertions are meaningless if the probe didn't run)
  - summary.TelnetMigrated == true
  - summary.IsMigrated == true

Verified locally: the test PASSES with the ordering fix applied and
FAILS without it. Failure messages name PR #294 by number so a
future regression points at the same code path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:46:04 +02:00
Marcin Mennemann 3bd82f3bf9 adj: comment numbers 2026-05-16 14:46:04 +02:00
Marcin Mennemann 5d2f5d12ec fix: detect telnet-only migrations in summary by waiting for probe result 2026-05-16 14:46:04 +02:00
Tobias GesellchenandClaude Opus 4.7 675288a329 docs(migration): add CLI-driven factory-reset alternative
The web-UI wizard is in-place migration: it preserves the speaker's
existing pairing and synced data. The CLI sequence is a different
shape — full factory-reset → wifi-push → pair against AfterTouch
from scratch — and it's the right tool when you want a clean,
scriptable, reproducible setup (automation, batched onboarding, or
just starting from a reset speaker).

Documents the full 6-step CLI flow (plan / factory-reset / wait-ap
/ wifi-push / wait-online / setup pair --mode=full), the verification
checks, and a side-by-side comparison so users can pick the right
path. Placed after "Repeat for each speaker" so the wizard remains
the recommended default for one-off migrations.

The flow assumes #195 and #269 are fixed in v0.80.2 — without the
AUX/sources filter, the CLI factory-reset path produces a speaker
where AUX won't dispatch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:14:18 +02:00
Tobias GesellchenandClaude Opus 4.7 355328da57 fix(cli): retry wifi-push once when the speaker's first ACK times out
The previous 10s→30s timeout bump didn't help — the first POST to
/addWirelessProfile on the speaker's AP-mode endpoint frequently
hangs until the deadline elapses, then a second POST a few seconds
later succeeds immediately. Empirically the workaround was "just
run wifi-push twice"; this commit folds that into the function.

PushWiFiCredentials now:
  - caps each attempt at 12 s (well above the sub-second healthy
    response time) so a stuck first attempt doesn't burn the whole
    budget
  - waits 2 s between attempts so the speaker's setup endpoint can
    finish whatever the first POST kicked off
  - falls through cleanly if the first attempt succeeds (the second
    never fires)
  - returns the second attempt's error if both fail, with context
    cancellation surfaced explicitly

Total budget is well under the CLI's 30 s --request-timeout, so
the flag still acts as a hard ceiling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:14:18 +02:00
Tobias GesellchenandClaude Opus 4.7 30456d7ff8 test(integration): update http-client assertions for cloud-side AUX exclusion
Two HTTP client tests asserted AUX (id=10001 / sourceproviderid=9) was
present in /streaming/account/{a}/full and /streaming/account/{a}/sources.
After 2b40481 drops AUX from those cloud responses (matching real Bose
behaviour; see pkg/service/marge/marge.go getAccountSources), both
tests fail. Updates them to:

  - Expect 5 sources in /full (down from 6) — INTERNET_RADIO,
    LOCAL_INTERNET_RADIO, TUNEIN, RADIO_BROWSER, Spotify.
  - Expect ids 10002/10003/10004 (not 10001/...) in /sources.
  - Add explicit negative assertions that sourceproviderid=9 / id=10001
    is *not* present, so a regression that re-introduces AUX in cloud
    responses fails loud.

Verified via `make test-http-client`: 49 requests, 0 failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:14:18 +02:00
Tobias GesellchenandClaude Opus 4.7 74007c7cb2 feat(setup): align <PairDeviceWithAccount> with the official Bose app shape
The Stockholm app (stockholm/setup/js/workflow_add_devices.js:23,77)
and Zimbo88's OpenCloudTouch USB-less script
(https://github.com/scheilch/opencloudtouch/discussions/201) both send
<boseServer>, <updateServer>, and <accountEmail> alongside the
<accountId>/<userAuthToken> pair. AfterTouch's setMargeAccount
historically sent only the latter two.

Adds:

  - MargePairingExtras struct on SessionConfig, opt-in via
    BoseServer (UpdateServer + AccountEmail default-derived when
    empty).
  - DefaultMargeAuthToken constant ("Bearer AfterTouch") and
    DefaultMargePairingEmail constant ("local@aftertouch.invalid",
    RFC 2606 reserved .invalid TLD).
  - buildPairDeviceWithAccountXML helper extracted so tests can
    pin both the minimal-payload and extended-payload shapes
    without driving a full WebSocket session.
  - --token flag on `soundtouch-cli setup pair` so we can override
    the placeholder for token-shape experiments.
  - runPairBare threads --service-url through to PairingExtras so
    `--mode=bare --service-url=...` ships the extended payload too;
    runPairFull already used it via applyInitPlanDefaults.

The speaker accepts any non-empty Bearer string (verified during
#195 investigation: "Bearer AfterTouch" passes and the speaker
re-derives its post-pair state from the marge endpoints regardless
of token content). The Stockholm-app payload shape is purely
documentation alignment; it did NOT fix the post-pair AUX/preset
breakage that turned out to be the cloud /full source list (see the
preceding marge commit). Keeping the wiring so the switches are
ready when we want to experiment further.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:14:18 +02:00
Tobias GesellchenandClaude Opus 4.7 332c7b87d0 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>
2026-05-16 12:14:18 +02:00
Tobias GesellchenandClaude Opus 4.7 824ed920ff fix(cli): give wifi-push the time the speaker needs to ACK
The speaker confirms AddWirelessProfile then tears down its AP within
~30 s. The default 10 s --request-timeout races that ACK whenever the
speaker is busy reconciling state — and a hard-coded 10 s on the
internal http.Client capped the user-passed timeout silently, so a
longer --request-timeout had no effect.

The CLI default is now 30 s and the inner http.Client lets the
context govern alone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:14:18 +02:00
Tobias Gesellchen c5938b8e05 gitignore stale testdata 2026-05-15 19:36:57 +02:00
Tobias GesellchenandClaude Opus 4.7 74420a4d02 docs(web): add stereo-pair rendering to soundtouch-web roadmap
Section 4 captures the presentation-only follow-up to #252: collapse
the two halves of a stereo pair into a single device-list entry using
each speaker's GET /getGroup metadata. Pair lifecycle (add/rename/remove)
already works end-to-end via pkg/client + soundtouch-cli, so this is
purely a soundtouch-web UI concern.

Drafted after BirdyBA's stereo-pair confirmation on the closed #252:
https://github.com/gesellix/Bose-SoundTouch/issues/252#issuecomment-4458140305

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:36:43 +02:00
Tobias Gesellchen 9dcde21f39 Bump release version to v0.80.1 in installer scripts 2026-05-15 19:25:01 +02:00
24 changed files with 804 additions and 92 deletions
+33 -5
View File
@@ -404,7 +404,7 @@ func setupWiFiPushCmd() *cli.Command {
&cli.StringFlag{Name: "pass", Required: true, Usage: "Home Wi-Fi password"},
&cli.StringFlag{Name: "security", Value: setup.DefaultWiFiSecurity, Usage: "Security type (wpa_or_wpa2, wep, open)"},
&cli.StringFlag{Name: "ap-host", Value: setup.SpeakerSetupAP, Usage: "Speaker's setup-mode IP"},
&cli.DurationFlag{Name: "request-timeout", Value: 10 * time.Second},
&cli.DurationFlag{Name: "request-timeout", Value: 30 * time.Second, Usage: "Per-request timeout (the speaker can be slow to ACK before tearing down AP mode; 10 s often races)"},
},
Action: func(c *cli.Context) error {
params := setup.PushWiFiCredentialsParams{
@@ -949,6 +949,17 @@ func renderMigrationSummary(deviceIP, serviceURL string, s *setup.MigrationSumma
if s.ResolveIPError != "" {
PrintError("Resolve IP error: " + s.ResolveIPError)
}
// Observability for the IP-resolve path. Source tells the user whether
// the speaker itself was consulted (authoritative) or only the service
// (best-effort). DurationMS lets us watch the SSH-ping cost trend in
// the wild — historical comment claimed 2-5 s on firmware 27, worth
// re-evaluating as data accumulates.
if s.ResolveIPSource != "" {
fmt.Printf("Resolve IP source : %s (%d ms)\n", s.ResolveIPSource, s.ResolveIPDurationMS)
} else if s.ResolveIPDurationMS > 0 {
fmt.Printf("Resolve IP : %d ms\n", s.ResolveIPDurationMS)
}
}
func setupRebootCmd() *cli.Command {
@@ -1353,10 +1364,11 @@ func setupPairCmd() *cli.Command {
Flags: []cli.Flag{
&cli.StringFlag{Name: "account", Usage: "7-digit account ID (empty = generate)"},
&cli.StringFlag{Name: "mode", Value: "full", Usage: "full (state machine) or bare (setMargeAccount only — experimental)"},
&cli.StringFlag{Name: "service-url", Value: "http://aftertouch.local:8000", Usage: "AfterTouch base URL (used by mode=full for defaults)"},
&cli.StringFlag{Name: "service-url", Value: "http://aftertouch.local:8000", Usage: "AfterTouch base URL (also populates <boseServer>/<updateServer> in setMargeAccount)"},
&cli.StringFlag{Name: "name", Usage: "Speaker name to set during pairing (empty = keep current)"},
&cli.IntFlag{Name: "language", Value: setup.LanguageEnglish, Usage: "sysLanguage code (2 = English)"},
&cli.DurationFlag{Name: "step-timeout", Value: 8 * time.Second},
&cli.StringFlag{Name: "token", Usage: "userAuthToken value (empty = use built-in placeholder matching the Bose app token shape)"},
},
Action: func(c *cli.Context) error {
cfg := GetClientConfig(c)
@@ -1401,8 +1413,20 @@ func runPairBare(c *cli.Context, deviceIP, accountID string) error {
fmt.Printf("pre /info deviceID=%s margeAccountUUID=%q margeURL=%q\n",
info.DeviceID, info.MargeAccountUUID, info.MargeURL)
// Service URL drives the extended <PairDeviceWithAccount> payload
// (boseServer/updateServer/accountEmail). When empty, the session
// falls back to the minimal historical shape (accountId +
// userAuthToken only).
serviceURL := c.String("service-url")
var extras setup.MargePairingExtras
if serviceURL != "" {
extras = setup.MargePairingExtras{BoseServer: serviceURL}
}
session, err := setup.DialSession(deviceIP, info.DeviceID, setup.SessionConfig{
StepTimeout: c.Duration("step-timeout"),
StepTimeout: c.Duration("step-timeout"),
PairingExtras: extras,
})
if err != nil {
return fmt.Errorf("dial WS: %w", err)
@@ -1413,9 +1437,13 @@ func runPairBare(c *cli.Context, deviceIP, accountID string) error {
ctx, cancel := context.WithTimeout(c.Context, c.Duration("step-timeout")+2*time.Second)
defer cancel()
fmt.Printf("→ setMargeAccount accountID=%s (no SETUP bracket)\n", accountID)
if serviceURL != "" {
fmt.Printf("→ setMargeAccount accountID=%s (extended: boseServer=%s)\n", accountID, serviceURL)
} else {
fmt.Printf("→ setMargeAccount accountID=%s (minimal payload, no SETUP bracket)\n", accountID)
}
if pairErr := session.SetMargeAccount(ctx, accountID, ""); pairErr != nil {
if pairErr := session.SetMargeAccount(ctx, accountID, c.String("token")); pairErr != nil {
PrintError(fmt.Sprintf("setMargeAccount: %v", pairErr))
return pairErr
}
+76
View File
@@ -206,6 +206,82 @@ Each speaker is migrated independently. You can run multiple migrations in paral
---
## Alternative: CLI-driven factory-reset workflow
If you prefer scripting the migration, or the wizard isn't an option (headless server, automation, batch onboarding of many speakers), `soundtouch-cli` exposes the same building blocks. The flow below is **not** an in-place migration — it factory-resets the speaker and brings it up fresh against AfterTouch, so any data Bose preserved on the device is wiped. Use this when:
- You're starting from a factory-reset speaker anyway.
- The wizard's in-place migration didn't take and you want a clean slate.
- You're scripting setup for many speakers and want a reproducible recipe.
### Prerequisites
- AfterTouch service running and reachable at a stable URL (e.g., `https://soundtouch.local` from your `.env`).
- The speaker reachable on its current IP (passed as `--host`).
- For the AP-mode handover step, your laptop must be able to join the speaker's `Bose SoundTouch` Wi-Fi (you'll switch between home Wi-Fi and the speaker's AP).
### The full sequence
```bash
# 1. Plan what the reset+pair pipeline will write (dry run, no changes yet).
soundtouch-cli --host 192.168.1.50 setup plan \
--reset=true --include-pair=false \
--service-url='https://soundtouch.local'
# 2. Trigger the factory reset. The speaker reboots into AP mode.
soundtouch-cli --host 192.168.1.50 setup factory-reset
# --- Manual step: join the speaker's Wi-Fi AP (SSID "Bose SoundTouch ...") ---
# 3. Wait for the AP-mode endpoint to answer.
soundtouch-cli setup wait-ap
# 4. Push your home Wi-Fi credentials to the speaker.
# Run twice if the first attempt's ACK races the AP teardown — the second
# one is a no-op if the first succeeded.
soundtouch-cli setup wifi-push --ssid="YourHomeSSID" --pass='your-wifi-password'
# --- Manual step: switch your laptop back to the home Wi-Fi network ---
# 5. Wait for the speaker to come back online on the home network.
# --match takes the last 4-6 hex chars of the speaker's MAC (visible on
# the bottom of the device).
soundtouch-cli setup wait-online --match=42CAFE
# 6. Pair the speaker with an AfterTouch account.
# --mode=full runs the canonical WebSocket SETUP sequence (matches the
# Bose app's flow); --account is the 7-digit account ID AfterTouch
# should attach the speaker to.
soundtouch-cli --host 192.168.1.50 setup pair \
--mode=full --account=1111111 \
--service-url='https://soundtouch.local'
```
### Verifying the result
After pairing completes:
- The speaker should appear on the **Devices** tab in the web UI.
- AUX should switch and play audio when selected.
- Pressing presets should fetch their content from AfterTouch (the `[LOG]` rows on the service confirm).
- TuneIn search and playback should work end-to-end.
If any of these fail post-pair, see [Troubleshooting](TROUBLESHOOTING.md) — most commonly the speaker just needs a power cycle to pick up everything cleanly.
### Differences vs the wizard
| Aspect | Wizard (in-place migration) | CLI factory-reset workflow |
|-------------------------------------|---------------------------------------------------------|---------------------------------------------------------|
| Preserves speaker's existing state | yes (Presets, recents, attached account) | **no** — wipes everything |
| Requires Wi-Fi-network switching | no | yes (laptop joins speaker AP, then home network) |
| Scriptable / reproducible | clickable, not scriptable | full bash recipe |
| Cloud-side data (Bose Marge backup) | preserved if Sync ran while cloud was alive | not relevant — fresh account on AfterTouch |
| Best for | "I want this speaker to keep working with what's on it" | "I want a clean, reproducible setup against AfterTouch" |
The wizard is still the recommended path for a one-off migration of an existing setup. The CLI workflow is the right choice when you're scripting, batching, or already starting from a reset.
---
## Rollback
If you need to undo a migration:
+43 -1
View File
@@ -1,6 +1,6 @@
# soundtouch-web: remaining features
Three features complete the parity gap between soundtouch-web and the Stockholm
Four features complete the parity gap between soundtouch-web and the Stockholm
app's local-control functionality. Everything else in Stockholm (OAuth flows,
setup wizard, service account linking, onboarding, analytics) is cloud
infrastructure that is either shut down or already handled by soundtouch-service.
@@ -86,6 +86,48 @@ rename and network/firmware info.
---
## 4. Render stereo pairs as a single device
Today soundtouch-web shows the two halves of a stereo pair (formed via
`/addGroup` — see issue #252) as independent entries in the device list. The
Bose app collapsed a paired ST10 set into one "L+R" entry; restoring that
presentation closes the perception gap BirdyBA flagged at
<https://github.com/gesellix/Bose-SoundTouch/issues/252#issuecomment-4458140305>.
**Device API:**
- `GET /getGroup` on each speaker — returns the current `<group>` with
`<masterDeviceId>` + `<roles>` (each `<groupRole>` carries the speaker's
deviceId, role `LEFT|RIGHT`, and ipAddress)
- Empty `<group/>` means the speaker is standalone
- Querying the master and slave returns the same `<group>` payload, so either
side is sufficient to detect the pair
**Backend:**
- During device-list assembly, call `GET /getGroup` for each discovered device
in parallel (matches the propagation pattern already used by
`soundtouch-cli group create` in `cmd/soundtouch-cli/cmd_group.go`)
- Bucket devices by `<masterDeviceId>` — each bucket emits one entry in the
list response. Standalone devices stay as their own bucket-of-one
- Expose pair metadata on the list entry so the UI can render role chips
(`L`/`R`) and resolve role → physical device for actions
**Frontend:**
- Device list collapses paired devices into one card titled with both names
(e.g. `"Wohnzimmer L+R"`) and role chips
- Clicking the card opens a device-detail page that exposes both per-role
status and a "Dissolve pair" action (DELETE flow, already wired in
`soundtouch-cli group remove` and in fakespeaker's `/removeGroup` GET)
- Standalone speakers continue to render as today
**Note:** Pair lifecycle (create / rename / remove) already works
end-to-end — `pkg/client` group endpoints + `cmd/soundtouch-cli/cmd_group.go`,
covered by tests in `cmd/soundtouch-cli/cmd_group_test.go` and exercisable
against the fake speaker's group routes
(`pkg/service/testing/fakespeaker/fakespeaker.go`). This task is purely about
presentation in soundtouch-web's device list — no protocol work required.
---
## Decide later
| Feature | Reason |
+34 -7
View File
@@ -20,11 +20,35 @@ import (
// TuneIn endpoint templates used to resolve station and stream URLs.
const (
TuneInDescribe = "https://opml.radiotime.com/describe.ashx?id=%s"
TuneInStream = "http://opml.radiotime.com/Tune.ashx?id=%s&formats=mp3,aac,ogg,hls"
TuneInNavigateAshx = "http://opml.radiotime.com/?render=json"
TuneInSearchAPI = "https://api.radiotime.com/profiles?fulltextsearch=true&version=1.3&query="
// DefaultTuneInStreamFormats is the comma-separated format list
// AfterTouch sends to TuneIn's Tune.ashx by default. Matches the
// pre-2026-05-10 behaviour from before PR #249 added "hls"
// unconditionally — HLS playback is broken on SoundTouch 10/
// firmware 27 (and probably the rest of the line; see #292).
// Speakers receive an .m3u8 playlist URL they can't parse, blink
// amber, fall silent. Operators with HLS-compatible speakers can
// override via Settings.TuneInStreamFormats.
DefaultTuneInStreamFormats = "mp3,aac,ogg"
)
// TuneInStream returns the formatted Tune.ashx URL for a station or
// podcast. The formats argument controls the formats= query parameter;
// empty falls back to DefaultTuneInStreamFormats. Operators can set
// arbitrary lists (e.g. "mp3,aac,ogg,hls" to re-enable HLS, or
// "aac" to force a single format) via Settings.TuneInStreamFormats.
// The value is passed through verbatim — no token-level validation.
func TuneInStream(stationID, formats string) string {
formats = strings.TrimSpace(formats)
if formats == "" {
formats = DefaultTuneInStreamFormats
}
return fmt.Sprintf("http://opml.radiotime.com/Tune.ashx?id=%s&formats=%s", stationID, formats)
}
var tuneInClient = &http.Client{Timeout: 10 * time.Second}
// allowedTuneInHosts restricts outbound fetches to known TuneIn domains.
@@ -555,8 +579,10 @@ func TuneInNavigateProfile(encodedURI string) (*models.BmxNavResponse, error) {
}
// TuneInPlayback resolves a live radio station and returns a Bose-compatible
// playback response with primary stream and variants.
func TuneInPlayback(stationID string) (*models.BmxPlaybackResponse, error) {
// playback response with primary stream and variants. formats is the
// comma-separated list passed to Tune.ashx?formats=… ; empty falls back to
// DefaultTuneInStreamFormats (the SoundTouch-line-compatible shape).
func TuneInPlayback(stationID, formats string) (*models.BmxPlaybackResponse, error) {
describeURL := fmt.Sprintf(TuneInDescribe, stationID)
resp, err := http.Get(describeURL)
@@ -588,7 +614,7 @@ func TuneInPlayback(stationID string) (*models.BmxPlaybackResponse, error) {
station := opml.Body.Outline.Station
streamReq := fmt.Sprintf(TuneInStream, stationID)
streamReq := TuneInStream(stationID, formats)
streamResp, err := http.Get(streamReq)
if err != nil {
@@ -697,8 +723,9 @@ func TuneInPodcastInfo(podcastID, encodedName string) (*models.BmxPodcastInfoRes
}
// TuneInPlaybackPodcast resolves an on-demand podcast episode and returns
// a playback response suitable for SoundTouch devices.
func TuneInPlaybackPodcast(podcastID string) (*models.BmxPlaybackResponse, error) {
// a playback response suitable for SoundTouch devices. formats has the
// same semantics as in TuneInPlayback.
func TuneInPlaybackPodcast(podcastID, formats string) (*models.BmxPlaybackResponse, error) {
describeURL := fmt.Sprintf(TuneInDescribe, podcastID)
resp, err := http.Get(describeURL)
@@ -733,7 +760,7 @@ func TuneInPlaybackPodcast(podcastID string) (*models.BmxPlaybackResponse, error
topic := opml.Body.Outline.Topic
streamReq := fmt.Sprintf(TuneInStream, podcastID)
streamReq := TuneInStream(podcastID, formats)
streamResp, err := http.Get(streamReq)
if err != nil {
+49
View File
@@ -221,3 +221,52 @@ func TestTuneInPodcastInfo_Base64(t *testing.T) {
t.Errorf("Expected name %s, got %s", name, resp.Name)
}
}
// TestTuneInStream_EmptyFormatsUsesDefault pins the post-#292 contract:
// AfterTouch must NOT request HLS streams from TuneIn unless the
// operator has explicitly opted in. The default request shape is
// "mp3,aac,ogg" — matches pre-2026-05-10 behaviour and works on
// every SoundTouch model verified. PR #249 had added "hls"
// unconditionally; that regressed playback on ST10/firmware 27 (the
// speaker can't parse the .m3u8 playlist TuneIn returns when HLS is
// in the format list).
func TestTuneInStream_EmptyFormatsUsesDefault(t *testing.T) {
got := TuneInStream("s33828", "")
if strings.Contains(got, "hls") {
t.Errorf("default TuneInStream URL must NOT request HLS; got %s", got)
}
want := "formats=" + DefaultTuneInStreamFormats
if !strings.Contains(got, want) {
t.Errorf("default TuneInStream URL must request %q; got %s", want, got)
}
if !strings.Contains(got, "id=s33828") {
t.Errorf("TuneInStream URL must carry the station ID; got %s", got)
}
}
// TestTuneInStream_OverrideHonoured verifies the opt-in path: when an
// operator sets Settings.TuneInStreamFormats to a custom list,
// TuneInStream passes it through verbatim. Two sub-cases catch the
// common opt-in (re-add hls) and a more drastic override (single
// format) so a future regression in the trim/fallback logic surfaces
// at compile/test time.
func TestTuneInStream_OverrideHonoured(t *testing.T) {
cases := []struct {
formats string
want string
}{
{"mp3,aac,ogg,hls", "formats=mp3,aac,ogg,hls"}, // opt-in: re-add HLS
{"aac", "formats=aac"}, // single format
{" mp3 ", "formats=mp3"}, // whitespace stripped
}
for _, tc := range cases {
got := TuneInStream("s33828", tc.formats)
if !strings.Contains(got, tc.want) {
t.Errorf("TuneInStream(%q) URL must contain %q; got %s", tc.formats, tc.want, got)
}
}
}
+13
View File
@@ -2127,6 +2127,19 @@ type Settings struct {
// reverse proxy on the same host. Override only if the proxy lives on a
// different host within a known-good private subnet.
TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs,omitempty"`
// TuneInStreamFormats overrides the comma-separated format list
// AfterTouch sends to TuneIn's Tune.ashx (formats=…). Empty value
// uses bmx.DefaultTuneInStreamFormats ("mp3,aac,ogg"), which
// matches AfterTouch's pre-2026-05-10 behaviour and plays on
// every SoundTouch model verified so far. PR #249 had added
// "hls" unconditionally; that regressed playback on the
// SoundTouch line (#292 — speaker can't parse the .m3u8 playlist
// and blinks amber). Operators with HLS-compatible speakers can
// set this to e.g. "mp3,aac,ogg,hls" via settings.json. The value
// is passed through verbatim; AfterTouch does not validate the
// individual format tokens.
TuneInStreamFormats string `json:"tunein_stream_formats,omitempty"`
}
// GetSettings retrieves the global service settings.
+22 -2
View File
@@ -15,6 +15,26 @@ import (
"github.com/go-chi/chi/v5"
)
// tuneInStreamFormats returns the formats= list AfterTouch should send
// to TuneIn's Tune.ashx, honouring Settings.TuneInStreamFormats when
// set. Empty (the default) lets bmx.TuneInStream fall back to
// bmx.DefaultTuneInStreamFormats — the SoundTouch-line-compatible
// "mp3,aac,ogg" shape. Operators with HLS-capable speakers can set
// the field to "mp3,aac,ogg,hls" (or any other comma-separated list)
// in settings.json.
func (s *Server) tuneInStreamFormats() string {
if s == nil || s.ds == nil {
return ""
}
settings, err := s.ds.GetSettings()
if err != nil {
return ""
}
return settings.TuneInStreamFormats
}
// HandleBMXRegistry returns the BMX service registry.
func (s *Server) HandleBMXRegistry(w http.ResponseWriter, _ *http.Request) {
baseURL := s.serverURL
@@ -62,7 +82,7 @@ func (s *Server) HandleTuneInPlayback(w http.ResponseWriter, r *http.Request) {
stationID := chi.URLParam(r, "stationID")
resp, err := bmx.TuneInPlayback(stationID)
resp, err := bmx.TuneInPlayback(stationID, s.tuneInStreamFormats())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -109,7 +129,7 @@ func (s *Server) HandleTuneInPlaybackPodcast(w http.ResponseWriter, r *http.Requ
podcastID := chi.URLParam(r, "podcastID")
resp, err := bmx.TuneInPlaybackPodcast(podcastID)
resp, err := bmx.TuneInPlaybackPodcast(podcastID, s.tuneInStreamFormats())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
+28 -10
View File
@@ -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)
+19
View File
@@ -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))
}
+35
View File
@@ -320,6 +320,41 @@ func TestStripAfterTouchEntries_UnpairedSentinelFlagged(t *testing.T) {
// runs in CI; it's the Mozilla CCADB public dataset, no per-device
// information.
//
// Cross-model note: byte-identical to the corresponding ST10
// firmware-27 bundle (verified 2026-05-16 against
// firmware/_backup_ST10/_/etc/pki/tls/certs/ca-bundle.crt — same
// md5 2d150987b312e4280fc576b508e62b43, same 165 certs). Same
// fixture stands in for both speaker models while they're on the
// same firmware build, so expired-root hypotheses (e.g. PR #292)
// should be evaluated against this single dataset.
//
// Reproduce the #292 cert-chain probe locally — point curl at this
// fixture and try the actual TuneIn stream chain a SoundTouch
// speaker would walk. If the handshake validates here, the speaker
// can also validate it (modulo any speaker-side TLS-stack quirks
// the OpenSSL binary on your laptop doesn't share). System bundle
// shown alongside for control:
//
// BUNDLE=pkg/service/setup/testdata/ca_bundle_st20_pristine.crt
//
// # Control: system trust store
// curl -sS -o /dev/null -w "%{http_code}\n" \
// "https://maestro.emfcdn.com/stream_for/k-love/tunein/hls"
//
// # Same URL, restricted to the speaker's 2022 CCADB snapshot
// curl -sS -o /dev/null -w "%{http_code}\n" --cacert "$BUNDLE" \
// "https://maestro.emfcdn.com/stream_for/k-love/tunein/hls"
//
// # Follow the 302 to the actual audio host
// curl -sSL -o /dev/null -w "%{http_code} %{url_effective}\n" \
// --cacert "$BUNDLE" \
// "https://maestro.emfcdn.com/stream_for/k-love/tunein/hls"
//
// Both bundles handle the K-LOVE chain (Amazon Root CA 1 + DigiCert
// Global Root, valid through 2026+) cleanly — recorded against
// firmware 27 on 2026-05-16, ruling out expired-root for that
// firmware vintage.
//
// The point of this test is to catch over-eager validator changes
// before they ship. An earlier iteration of validateCABundleBytes
// called x509.ParseCertificate per block — that rejected the real
+2 -2
View File
@@ -199,7 +199,7 @@ func (m *Manager) ExecuteInitPlan(ctx context.Context, plan InitPlan, progress P
}
// applyInitPlanDefaults validates required fields and fills in defaults
// from Manager.ServerURL / sysLanguage 2 / "Bearer aftertouch".
// from Manager.ServerURL / sysLanguage 2 / DefaultMargeAuthToken.
func applyInitPlanDefaults(plan InitPlan, serverURL string) (InitPlan, error) {
if plan.DeviceIP == "" {
return plan, errors.New("InitPlan.DeviceIP is required")
@@ -218,7 +218,7 @@ func applyInitPlanDefaults(plan InitPlan, serverURL string) (InitPlan, error) {
}
if plan.AuthToken == "" {
plan.AuthToken = "Bearer aftertouch"
plan.AuthToken = DefaultMargeAuthToken
}
return plan, nil
+1 -1
View File
@@ -147,7 +147,7 @@ func TestExecuteInitPlan_FactoryReset_GeneratesAccountAndRunsAllSteps(t *testing
"Enter",
"IdentifyLeave",
"SetName(Living Room)",
"SetMargeAccount(1234567,Bearer aftertouch)",
"SetMargeAccount(1234567," + DefaultMargeAuthToken + ")",
"Leave",
"PushCustomerSupportInfo",
}
@@ -185,3 +185,56 @@ func TestGetMigrationSummary_TelnetSucceedsSSHSucceeds(t *testing.T) {
t.Errorf("TelnetVerifiedConfig = %q, want %q", summary.TelnetVerifiedConfig, target)
}
}
// TestGetMigrationSummary_TelnetOnlyMigrationDetected pins the ordering
// bug fixed in PR #294 / issue #293.
//
// Before the fix, GetMigrationSummary called checkIsMigratedFromProbe
// before draining the telnet goroutine's result, so
// summary.TelnetVerifiedConfig was empty when isTelnetMigrated read it
// — and the telnet axis was always reported false. For speakers
// migrated *only* via telnet (envswitch flip; no SSH XML rewrite,
// no DNS hook, no CA install), this misclassification meant
// summary.IsMigrated was false despite the speaker actually pointing
// at AfterTouch. The CLI's `setup verify` exited non-zero, and the
// web UI rendered "Not Migrated".
//
// The fix moves m.checkIsMigratedFromProbe(summary, probe) to run
// *after* the <-telnetCh drain, so TelnetVerifiedConfig is populated
// when isTelnetMigrated inspects it.
//
// The scenario here matches foob61451's 2026-05-16 #293 reproducer:
// SSH unavailable / disabled (every axis false), telnet getpdo reports
// the AfterTouch host, no other migration path applied.
func TestGetMigrationSummary_TelnetOnlyMigrationDetected(t *testing.T) {
target := "http://example:8000"
ft := &fakeTelnet{
banner: "BoseShell\n-> ",
responses: map[string]string{
"getpdo CurrentSystemConfiguration": "margeServerUrl=" + target + "\n",
},
}
m, host, cleanup := telnetSummaryEnv(t, nil, ft)
defer cleanup()
summary, err := m.GetMigrationSummary(host, "", "", nil)
if err != nil {
t.Fatalf("GetMigrationSummary: %v", err)
}
// Pre-condition for the test to be meaningful: the telnet probe
// must have populated TelnetVerifiedConfig. Without this, the
// downstream assertions could pass trivially.
if !strings.Contains(summary.TelnetVerifiedConfig, target) {
t.Fatalf("setup: TelnetVerifiedConfig = %q, want it to contain %q", summary.TelnetVerifiedConfig, target)
}
if !summary.TelnetMigrated {
t.Errorf("TelnetMigrated = false, want true — telnet getpdo reports %q which matches Manager.ServerURL host. Likely regression of PR #294 ordering fix in GetMigrationSummary.", target)
}
if !summary.IsMigrated {
t.Errorf("IsMigrated = false, want true — telnet axis should carry IsMigrated when SSH-driven axes are false. Likely regression of PR #294 ordering fix.")
}
}
+81 -23
View File
@@ -92,7 +92,18 @@ type MigrationSummary struct {
// flag pairing as a precondition independently of the URL flip.
IsPaired bool `json:"is_paired"`
ResolveIPError string `json:"resolve_ip_error,omitempty"`
ResolveIPError string `json:"resolve_ip_error,omitempty"`
// ResolveIPSource records where the resolved IP came from:
// "device" — authoritative answer via SSH ping (preferred for
// migration). "service" — service-side DNS lookup (fast but may
// differ when NAT or split-DNS is in play). Empty when host was
// already an IP literal or could not be resolved at all.
ResolveIPSource string `json:"resolve_ip_source,omitempty"`
// ResolveIPDurationMS measures how long the resolve call took
// (wall-clock, milliseconds). Captured during preflight so we can
// observe the SSH-ping cost in the wild.
ResolveIPDurationMS int64 `json:"resolve_ip_duration_ms,omitempty"`
MirrorEnabled bool `json:"mirror_enabled"`
MirrorEndpoints []string `json:"mirror_endpoints,omitempty"`
SkipMirrorEndpoints []string `json:"skip_mirror_endpoints,omitempty"`
@@ -323,17 +334,21 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
summary.PlannedConfig = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" + string(xmlContent)
// 2b. Planned network config (hosts entries, resolv.conf preview, resolve error)
m.populatePlannedNetworkConfig(summary, deviceIP, targetURL)
// 2b. Planned network config (hosts entries, resolv.conf preview, resolve error).
// Pass an SSH client only when the probe succeeded — opening a fresh
// dial when we already know SSH is dead would burn ~handshake-timeout
// of wall time per refresh.
var resolveClient SSHClient
if probe.SSHOK && m.NewSSH != nil {
resolveClient = m.NewSSH(deviceIP)
}
m.populatePlannedNetworkConfig(summary, deviceIP, targetURL, resolveClient)
// 3. Provide HTTPS URL for testing (consumed by the migration UI)
summary.ServerHTTPSURL = m.buildServerHTTPSURL(targetURL)
// 4. Check if migrated (telnet axis uses the parallel preflight;
// XML/hosts/resolv axes use the probe data already gathered above).
m.checkIsMigratedFromProbe(summary, probe)
// 7. Mirroring settings
// 4. Mirroring settings
if m.DataStore != nil {
settings, err := m.DataStore.GetSettings()
if err == nil {
@@ -344,21 +359,26 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
}
}
// 8. Merge telnet preflight results (started in parallel at the top).
// 5. Merge telnet preflight results (started in parallel at the top).
telnetResult := <-telnetCh
summary.TelnetReachable = telnetResult.TelnetReachable
summary.TelnetBanner = telnetResult.TelnetBanner
summary.TelnetVerifiedConfig = telnetResult.TelnetVerifiedConfig
summary.TelnetProbeError = telnetResult.TelnetProbeError
// 9. Cross-check SSH-XML and telnet-getpdo readings; surface any
// 6. Check if migrated (must run after telnet results are merged so
// TelnetVerifiedConfig is populated). XML/hosts/resolv axes use the
// probe data already gathered above.
m.checkIsMigratedFromProbe(summary, probe)
// 7. Cross-check SSH-XML and telnet-getpdo readings; surface any
// divergence as a non-fatal warning.
m.crossCheckPreflights(summary)
return summary, nil
}
func (m *Manager) populatePlannedNetworkConfig(summary *MigrationSummary, _, targetURL string) {
func (m *Manager) populatePlannedNetworkConfig(summary *MigrationSummary, _, targetURL string, sshClient SSHClient) {
parsedURL, err := url.Parse(targetURL)
if err != nil {
return
@@ -369,14 +389,42 @@ func (m *Manager) populatePlannedNetworkConfig(summary *MigrationSummary, _, tar
return
}
// Resolve locally only. The "from-device" lookup that resolveIP can
// do via SSH (`ping -c 1 host`) costs another fresh SSH handshake
// plus the ping's own runtime — easily 25 s on firmware-27 devices
// — and the result feeds only the PlannedResolv/PlannedHosts preview.
// For the actual apply paths (migrateViaHosts/migrateViaResolv) the
// device-side resolution is still used; this is only the preview.
hostIP, resolveErr := m.resolveIP(hostName, nil)
if resolveErr != nil {
// Resolve the target hostname. When the caller provides an SSH
// client (i.e. the speaker already answered the probe), we prefer
// the device-side lookup — it's authoritative for the actual
// network path the speaker will use. When SSH isn't available we
// fall back to service-side DNS and tag the result with
// ErrResolvedFromServiceOnly so the summary can render it as
// informational rather than as a hard error.
//
// Historical note: the SSH path was previously skipped here for
// cost reasons (a comment claimed "25 s extra per preflight
// refresh"). Measured 2026-05-16 across ST10 + ST20 on firmware
// 27.0.6.46330.5043500: ~290 ms ± 10 ms per resolve, three runs.
// Well under the original estimate — promoted to the default path.
// ResolveIPDurationMS stays on the summary so any regression
// (firmware upgrade, slower kex, etc.) is visible.
start := time.Now()
hostIP, resolveErr := m.resolveIP(hostName, sshClient)
summary.ResolveIPDurationMS = time.Since(start).Milliseconds()
switch {
case resolveErr == nil && hostIP != "":
summary.ResolveIPSource = "device"
if sshClient == nil {
// Caller didn't ask for the SSH path, and we got a clean
// answer — that only happens when host was already an IP
// literal. Source is neither "device" nor "service" in a
// meaningful sense; leave it empty.
summary.ResolveIPSource = ""
}
case errors.Is(resolveErr, ErrResolvedFromServiceOnly):
summary.ResolveIPSource = "service"
// Sentinel-tagged errors are informational — the resolved IP
// is still usable for the preview, the caller just shouldn't
// treat it as authoritative. We do NOT populate ResolveIPError
// here; the CLI/UI use that field for hard failures only.
case resolveErr != nil:
summary.ResolveIPError = resolveErr.Error()
}
@@ -2388,11 +2436,20 @@ func (m *Manager) GetResolvedIP(host string) string {
return ip
}
// ErrResolvedFromServiceOnly is returned (wrapped) by resolveIP when the
// service-side DNS fallback produced an IP but the device-side SSH ping
// either wasn't attempted or didn't yield a usable result. The error
// carries the resolved IP — callers that don't need an authoritative
// device-side answer (preview/summary builders) can errors.Is()-check
// and treat the IP as informational. Apply-path callers that DO need
// authoritative resolution can bail.
var ErrResolvedFromServiceOnly = errors.New("resolved from service, not from device")
// resolveIP resolves a hostname to an IP address.
// It first tries to resolve from the device via SSH ping (authoritative for migration).
// If that fails, it falls back to resolving from the service itself.
// An error is returned whenever the SSH ping did not produce the IP, so callers that
// write config to the device can abort rather than risk writing an unresolvable hostname.
// If that fails, it falls back to resolving from the service itself, returning the
// resolved IP wrapped with ErrResolvedFromServiceOnly so callers can distinguish
// "authoritative device-side answer" from "best-effort service-side fallback".
func (m *Manager) resolveIP(host string, client SSHClient) (string, error) {
if net.ParseIP(host) != nil {
return host, nil
@@ -2438,7 +2495,8 @@ func (m *Manager) resolveIP(host string, client SSHClient) (string, error) {
resolved = ips[0].String()
}
return resolved, fmt.Errorf("resolved %q to %s from service, not from device — result may be wrong if NAT or split-DNS is in use", host, resolved)
return resolved, fmt.Errorf("%w: %q %s (NAT or split-DNS may differ from what the device would see)",
ErrResolvedFromServiceOnly, host, resolved)
}
// SyncDeviceData fetches presets, recents and sources from the device and saves them to the datastore.
+101 -14
View File
@@ -21,8 +21,53 @@ const (
// LanguageEnglish is the sysLanguage code for English. 2 is the
// value the official Bose app sends during English-locale setup.
LanguageEnglish = 2
// DefaultMargeAuthToken is the placeholder userAuthToken sent in
// <PairDeviceWithAccount> when the caller didn't supply one. The
// speaker accepts any non-empty value; a real Bose-issued token
// shape (128-char base64 per docs/reference/DEVICE-PAIRING-FLOW.md
// line 154) is not required — verified during #195 investigation
// where the speaker happily persisted "Bearer AfterTouch" and
// re-derived its post-pair state from the marge endpoints
// regardless of token content.
DefaultMargeAuthToken = "Bearer AfterTouch"
// DefaultMargePairingEmail is the synthetic accountEmail used when
// PairingExtras requests the extended <PairDeviceWithAccount> payload
// but doesn't supply an email. RFC 2606 reserves ".invalid" as a TLD
// guaranteed never to resolve, which is what we want here — the
// speaker writes it into its persistent state but no real address
// receives anything.
DefaultMargePairingEmail = "local@aftertouch.invalid"
)
// MargePairingExtras carries the optional fields that the official Bose
// Android app and Zimbo88's USB-less OpenCloudTouch script include in
// their <PairDeviceWithAccount> payloads. AfterTouch historically sent
// only <accountId> + <userAuthToken>; that minimal shape is the
// suspected trigger for the post-pair AUX/preset breakage tracked in
// issues #195 and #269.
//
// Set BoseServer (and optionally UpdateServer/AccountEmail) on the
// SessionConfig to opt into the richer payload. Empty fields are
// omitted from the XML so callers can choose any subset.
//
// Reference: docs/reference/DEVICE-PAIRING-FLOW.md and
// https://github.com/scheilch/opencloudtouch/discussions/201.
type MargePairingExtras struct {
// BoseServer is the marge server URL the speaker should use after
// pairing. Typically equal to AfterTouch's service URL.
BoseServer string
// UpdateServer is the firmware-update server URL. If empty and
// BoseServer is set, SetMargeAccount derives it as
// BoseServer + "/updates/soundtouch".
UpdateServer string
// AccountEmail is the synthetic email persisted alongside the
// account. If empty and BoseServer is set, SetMargeAccount fills
// in DefaultMargePairingEmail.
AccountEmail string
}
// StateMachine is the surface the InitPlan orchestrator drives. The
// concrete WebSocket-backed implementation is *Session; tests inject
// an in-memory fake via Manager.NewSession.
@@ -52,6 +97,11 @@ type SessionConfig struct {
WSScheme string
// WSPort overrides 8080 when deviceIP does not already carry a port.
WSPort int
// PairingExtras opts the session into the richer
// <PairDeviceWithAccount> payload (boseServer / updateServer /
// accountEmail) used by the official Bose Android app. Zero value
// retains the historical minimal payload.
PairingExtras MargePairingExtras
}
// Session is a synchronous request/response WebSocket session driving
@@ -60,10 +110,11 @@ type SessionConfig struct {
// and stateful) — setup is a short, linear sequence and benefits from a
// purpose-built transport.
type Session struct {
deviceID string
conn *websocket.Conn
reqID atomic.Int64
stepTimeout time.Duration
deviceID string
conn *websocket.Conn
reqID atomic.Int64
stepTimeout time.Duration
pairingExtras MargePairingExtras
}
// DialSession opens a WebSocket to the speaker at deviceIP and
@@ -117,7 +168,12 @@ func DialSession(deviceIP, deviceID string, cfg SessionConfig) (*Session, error)
step = defaultSetupStepTimeout
}
return &Session{deviceID: deviceID, conn: conn, stepTimeout: step}, nil
return &Session{
deviceID: deviceID,
conn: conn,
stepTimeout: step,
pairingExtras: cfg.PairingExtras,
}, nil
}
// Close sends a normal-closure frame and closes the underlying socket.
@@ -243,24 +299,55 @@ func (s *Session) SetName(ctx context.Context, name string) error {
}
// SetMargeAccount sends the canonical PairDeviceWithAccount envelope.
// authToken defaults to "Bearer aftertouch" when empty — our local
// service does not validate it, but a non-empty value matches the
// official app's shape.
// authToken defaults to DefaultMargeAuthToken when empty.
//
// If SessionConfig.PairingExtras.BoseServer is set, the payload is
// extended with <boseServer>, <updateServer>, and <accountEmail>
// matching the official Bose app's shape (and Zimbo88's OpenCloudTouch
// USB-less script). UpdateServer and AccountEmail derive from
// BoseServer when not explicitly set.
func (s *Session) SetMargeAccount(ctx context.Context, accountID, authToken string) error {
if accountID == "" {
return errors.New("SetMargeAccount: accountID is required")
}
if authToken == "" {
authToken = "Bearer aftertouch"
authToken = DefaultMargeAuthToken
}
body := fmt.Sprintf(
`<PairDeviceWithAccount><accountId>%s</accountId><userAuthToken>%s</userAuthToken></PairDeviceWithAccount>`,
xmlBodyEscape(accountID), xmlBodyEscape(authToken),
)
return s.sendStep(ctx, "setMargeAccount", "POST", buildPairDeviceWithAccountXML(accountID, authToken, s.pairingExtras))
}
return s.sendStep(ctx, "setMargeAccount", "POST", body)
// buildPairDeviceWithAccountXML serializes the <PairDeviceWithAccount>
// body. Extracted so tests can pin the exact shape without driving a
// full WebSocket session.
func buildPairDeviceWithAccountXML(accountID, authToken string, extras MargePairingExtras) string {
var b strings.Builder
b.WriteString(`<PairDeviceWithAccount>`)
b.WriteString(`<accountId>` + xmlBodyEscape(accountID) + `</accountId>`)
b.WriteString(`<userAuthToken>` + xmlBodyEscape(authToken) + `</userAuthToken>`)
if extras.BoseServer != "" {
b.WriteString(`<boseServer>` + xmlBodyEscape(extras.BoseServer) + `</boseServer>`)
updateServer := extras.UpdateServer
if updateServer == "" {
updateServer = strings.TrimRight(extras.BoseServer, "/") + "/updates/soundtouch"
}
b.WriteString(`<updateServer>` + xmlBodyEscape(updateServer) + `</updateServer>`)
email := extras.AccountEmail
if email == "" {
email = DefaultMargePairingEmail
}
b.WriteString(`<accountEmail>` + xmlBodyEscape(email) + `</accountEmail>`)
}
b.WriteString(`</PairDeviceWithAccount>`)
return b.String()
}
// Leave sends SETUP_LEAVE.
+64 -1
View File
@@ -184,7 +184,7 @@ func TestSession_SendsCanonicalEnvelopes(t *testing.T) {
mustContain(t, frames[3], `<setupState state="SETUP_ENTER"/>`)
mustContain(t, frames[4], `<setupState state="SETUP_IDENTIFY_DEVICE_LEAVE"/>`)
mustContain(t, frames[5], `url="name"`, `<name>Living Room</name>`)
mustContain(t, frames[6], `url="setMargeAccount"`, `<accountId>1234567</accountId>`, `<userAuthToken>Bearer aftertouch</userAuthToken>`)
mustContain(t, frames[6], `url="setMargeAccount"`, `<accountId>1234567</accountId>`, `<userAuthToken>`+DefaultMargeAuthToken+`</userAuthToken>`)
mustContain(t, frames[7], `<setupState state="SETUP_LEAVE"/>`)
mustContain(t, frames[8], `url="pushCustomerSupportInfoToMarge"`, `method="GET"`)
}
@@ -301,6 +301,69 @@ func TestSession_XMLAttributeEscape(t *testing.T) {
mustContain(t, frames[0], `deviceID="quoted&quot;&lt;id>"`)
}
// TestBuildPairDeviceWithAccountXML pins both the minimal-payload
// shape (historical AfterTouch behaviour) and the extended-payload
// shape introduced for #195/#269 investigation. The extended path
// mirrors what the official Bose app and Zimbo88's OpenCloudTouch
// USB-less script send (see docs/reference/DEVICE-PAIRING-FLOW.md
// and https://github.com/scheilch/opencloudtouch/discussions/201).
func TestBuildPairDeviceWithAccountXML(t *testing.T) {
t.Run("minimal payload — no extras", func(t *testing.T) {
got := buildPairDeviceWithAccountXML("1234567", "Bearer tok", MargePairingExtras{})
want := `<PairDeviceWithAccount>` +
`<accountId>1234567</accountId>` +
`<userAuthToken>Bearer tok</userAuthToken>` +
`</PairDeviceWithAccount>`
if got != want {
t.Errorf("\n got: %s\nwant: %s", got, want)
}
})
t.Run("extended payload — BoseServer triggers derived defaults", func(t *testing.T) {
got := buildPairDeviceWithAccountXML(
"1234567", "Bearer tok",
MargePairingExtras{BoseServer: "https://soundtouch.local"},
)
mustContain(t, got,
`<boseServer>https://soundtouch.local</boseServer>`,
`<updateServer>https://soundtouch.local/updates/soundtouch</updateServer>`,
`<accountEmail>`+DefaultMargePairingEmail+`</accountEmail>`,
)
})
t.Run("extended payload — explicit UpdateServer + AccountEmail honoured", func(t *testing.T) {
got := buildPairDeviceWithAccountXML(
"1234567", "Bearer tok",
MargePairingExtras{
BoseServer: "https://example.test",
UpdateServer: "https://updates.example.test/firmware",
AccountEmail: "user@example.test",
},
)
mustContain(t, got,
`<boseServer>https://example.test</boseServer>`,
`<updateServer>https://updates.example.test/firmware</updateServer>`,
`<accountEmail>user@example.test</accountEmail>`,
)
})
t.Run("extended payload — BoseServer trailing slash trimmed when deriving UpdateServer", func(t *testing.T) {
got := buildPairDeviceWithAccountXML(
"1234567", "Bearer tok",
MargePairingExtras{BoseServer: "https://soundtouch.local/"},
)
// Derived path uses TrimRight on BoseServer so we don't get
// "soundtouch.local//updates/soundtouch".
mustContain(t, got,
`<updateServer>https://soundtouch.local/updates/soundtouch</updateServer>`,
)
})
}
func mustContain(t *testing.T, s string, needles ...string) {
t.Helper()
+53
View File
@@ -2,6 +2,7 @@ package setup
import (
"encoding/base64"
"errors"
"fmt"
"net/http"
"net/http/httptest"
@@ -735,6 +736,58 @@ func TestResolveIP(t *testing.T) {
}
}
// TestResolveIP_ServiceFallbackTagsSentinel pins the #282 fix:
// service-side fallback returns the resolved IP wrapped with
// ErrResolvedFromServiceOnly. Callers can errors.Is()-check the
// sentinel to distinguish informational fallback from a hard
// failure, which fixes the long-standing CLI/UI ❌ row that appeared
// every time a hostname target was used with SSH off.
func TestResolveIP_ServiceFallbackTagsSentinel(t *testing.T) {
m := &Manager{}
// No SSH client → forces the service-side fallback path.
ip, err := m.resolveIP("localhost", nil)
if ip != "127.0.0.1" && ip != "::1" {
t.Fatalf("expected localhost to resolve service-side, got ip=%q err=%v", ip, err)
}
if err == nil {
t.Fatalf("expected service-side fallback to surface ErrResolvedFromServiceOnly, got nil err")
}
if !errors.Is(err, ErrResolvedFromServiceOnly) {
t.Errorf("expected error to wrap ErrResolvedFromServiceOnly, got %v", err)
}
}
// TestResolveIP_DeviceSuccessReturnsNilError keeps the happy-path
// guarantee explicit alongside the sentinel-tagging contract above.
func TestResolveIP_DeviceSuccessReturnsNilError(t *testing.T) {
m := &Manager{}
mock := &mockSSH{
runFunc: func(command string) (string, error) {
if strings.Contains(command, "ping -c 1 myhost") {
return "PING myhost (10.0.0.5): 56 data bytes", nil
}
return "", nil
},
}
ip, err := m.resolveIP("myhost", mock)
if ip != "10.0.0.5" {
t.Errorf("expected 10.0.0.5, got %s", ip)
}
if err != nil {
t.Errorf("device-side success must return nil error, got %v", err)
}
if errors.Is(err, ErrResolvedFromServiceOnly) {
t.Errorf("device-side success must NOT carry ErrResolvedFromServiceOnly sentinel")
}
}
func TestMigrateViaHosts_SkipCAIfTrusted(t *testing.T) {
tempDir, err := os.MkdirTemp("", "setup-test-skip-ca")
if err != nil {
+68 -16
View File
@@ -42,6 +42,11 @@ type PushWiFiCredentialsParams struct {
//
// The speaker confirms the request before disconnecting; expect to lose
// the AP link within ~30 seconds.
//
// Empirically the first POST often races the speaker's setup endpoint
// readiness — the connection times out, then a second POST a few seconds
// later succeeds immediately. We retry once internally so the caller
// doesn't have to.
func PushWiFiCredentials(ctx context.Context, p PushWiFiCredentialsParams) error {
if p.SSID == "" {
return fmt.Errorf("PushWiFiCredentials: SSID is required")
@@ -69,31 +74,78 @@ func PushWiFiCredentials(ctx context.Context, p PushWiFiCredentialsParams) error
url := "http://" + hostPort + "/addWirelessProfile"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(body))
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "text/xml")
httpClient := p.HTTPClient
if httpClient == nil {
httpClient = &http.Client{Timeout: 10 * time.Second}
// No client-side timeout: let the per-attempt sub-context
// govern. The CLI passes a context deadline (default 30 s
// in setupWiFiPushCmd) and a hard-coded 10 s here would
// race it for no benefit.
httpClient = &http.Client{}
}
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("POST %s: %w", url, err)
// Per-attempt cap so a stuck first attempt doesn't burn the whole
// budget. 12 s is well above the typical sub-second response time
// when the endpoint is healthy, and the failure mode we're working
// around (first attempt hangs until the deadline elapses) means
// any value here is mostly a sub-budget for a stuck attempt.
const perAttemptTimeout = 12 * time.Second
// Pause between attempts gives the speaker's setup endpoint a
// moment to finish whatever initialization the first POST kicked
// off (the empirical workaround that motivated this retry).
const interAttemptDelay = 2 * time.Second
attempt := func(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(body))
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "text/xml")
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("POST %s: %w", url, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("POST %s returned %d: %s", url, resp.StatusCode, strings.TrimSpace(string(respBody)))
}
return nil
}
defer func() { _ = resp.Body.Close() }()
// Two attempts: the second is silent on the wire when the first
// already succeeded (returns at the first non-error), or carries
// the recovery when the first failed.
const maxAttempts = 2
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("POST %s returned %d: %s", url, resp.StatusCode, strings.TrimSpace(string(respBody)))
var lastErr error
for i := 0; i < maxAttempts; i++ {
if i > 0 {
select {
case <-time.After(interAttemptDelay):
case <-ctx.Done():
return fmt.Errorf("PushWiFiCredentials: %w (last attempt error: %w)", ctx.Err(), lastErr)
}
}
attemptCtx, cancel := context.WithTimeout(ctx, perAttemptTimeout)
err := attempt(attemptCtx)
cancel()
if err == nil {
return nil
}
lastErr = err
}
return nil
return fmt.Errorf("PushWiFiCredentials: both attempts failed (last: %w)", lastErr)
}
// PollConfig governs the retry cadence of WaitForAP and WaitForOnline.
+1 -1
View File
@@ -1,7 +1,7 @@
#!/bin/bash
set -eo pipefail
VERSION=${VERSION:-0.79.0}
VERSION=${VERSION:-0.80.1}
GH_REPO=${GH_REPO:-gesellix/Bose-SoundTouch}
BINARY_URL=${BINARY_URL:-https://github.com/$GH_REPO/releases/download/v$VERSION/soundtouch-service-v$VERSION-linux-armv7}
INIT_SCRIPT_URL=${INIT_SCRIPT_URL:-https://raw.githubusercontent.com/$GH_REPO/v$VERSION/scripts/on-device-install/aftertouch}
+1 -1
View File
@@ -28,7 +28,7 @@ You can override defaults:
```bash
sudo \
VERSION=v0.79.0 \
VERSION=v0.80.1 \
HOSTNAME_FQDN=soundtouch.local \
HTTP_PORT=80 \
HTTPS_PORT=443 \
+4 -4
View File
@@ -10,7 +10,7 @@ set -euo pipefail
# Examples (override defaults via env vars):
#
# sudo \
# VERSION=v0.78.0 \
# VERSION=v0.80.0 \
# HOSTNAME_FQDN=soundtouch.local \
# HTTP_PORT=80 \
# HTTPS_PORT=443 \
@@ -18,7 +18,7 @@ set -euo pipefail
# bash install.sh
#
# Or with a version argument to perform an update:
# sudo bash install.sh v0.79.0
# sudo bash install.sh v0.80.1
#
# Notes:
# - This script downloads a release binary for your CPU (auto-detects armv7/arm64/amd64).
@@ -28,7 +28,7 @@ set -euo pipefail
# - Safe to re-run; it will update binary/config/unit and restart the service.
# ==============================================================================
VERSION="${1:-${VERSION:-v0.79.0}}"
VERSION="${1:-${VERSION:-v0.80.1}}"
# Normalize version prefix
if [[ ! "$VERSION" =~ ^v ]]; then
VERSION="v${VERSION}"
@@ -117,7 +117,7 @@ detect_arch_asset() {
download_url_for() {
local asset="$1"
# Release asset pattern used by you earlier:
# soundtouch-service-v0.17.0-linux-armv7
# soundtouch-service-v0.80.1-linux-armv7
echo "https://github.com/gesellix/Bose-SoundTouch/releases/download/${VERSION}/soundtouch-service-${VERSION}-${asset}"
}
+1
View File
@@ -1,3 +1,4 @@
2026*/
data/
integration/testdata/
integration/testdata*/
@@ -18,14 +18,20 @@ Authorization: Bearer dummy-token
client.assert(sources.nodeName === "sources", "Root element is not 'sources'");
const sourceList = sources.getElementsByTagName("source");
client.assert(sourceList.length >= 4, "Expected at least 4 source elements, found " + sourceList.length);
client.assert(sourceList.length >= 3, "Expected at least 3 source elements, found " + sourceList.length);
const expectedIds = ["10001", "10002", "10003", "10004"];
// AUX (id=10001, sourceproviderid=9) is intentionally excluded from
// cloud responses — real Bose never emitted AUX in /full or /sources;
// the speaker enumerates AUX from its own hardware via isLocal=true
// in :8090/sources. See pkg/service/marge/marge.go getAccountSources
// and commit 2b40481 (#195/#269).
const expectedIds = ["10002", "10003", "10004"];
for (let i = 0; i < sourceList.length; i++) {
const source = sourceList.item(i);
const sourceId = source.getAttribute("id");
client.assert(sourceId !== "10001", "Cloud /sources must not include AUX (id=10001)");
if (i < expectedIds.length) {
client.assert(sourceId === expectedIds[i], "Wrong source ID at index " + i);
client.assert(sourceId === expectedIds[i], "Wrong source ID at index " + i + ": got " + sourceId + ", want " + expectedIds[i]);
}
client.assert(source.getAttribute("type") === "Audio", "Wrong source type for source " + sourceId);
@@ -47,6 +47,18 @@ Authorization: Bearer {{token}}
client.assert(sources !== null, "Missing 'sources' element");
var sourceCount = sources.getElementsByTagName("source").length;
client.assert(sourceCount > 0, "No 'source' elements found in account sources");
client.assert(sourceCount === 6, "Expected 6 sources (AUX, INTERNET_RADIO, LOCAL_INTERNET_RADIO, TUNEIN, RADIO_BROWSER, Spotify) but got " + sourceCount);
// AUX (sourceproviderid=9) is intentionally excluded from cloud-side
// /full responses — real Bose never emitted it; the speaker enumerates
// AUX from its own hardware via isLocal=true in :8090/sources. See
// pkg/service/marge/marge.go getAccountSources for the reasoning and
// commit 2b40481 for the fix that closed #195/#269.
client.assert(sourceCount === 5, "Expected 5 cloud sources (INTERNET_RADIO, LOCAL_INTERNET_RADIO, TUNEIN, RADIO_BROWSER, Spotify; AUX is hardware-local) but got " + sourceCount);
// Explicit negative assertion: AUX must not appear in /full.
var sourceList = sources.getElementsByTagName("source");
for (var i = 0; i < sourceList.length; i++) {
var pid = sourceList[i].getElementsByTagName("sourceproviderid")[0];
client.assert(!pid || pid.textContent !== "9", "/full must not include AUX (sourceproviderid=9)");
}
});
%}