mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-31 14:57:17 +00:00
Compare commits
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 2–5 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 "2–5 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"<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()
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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}
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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,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)");
|
||||
}
|
||||
});
|
||||
%}
|
||||
|
||||
Reference in New Issue
Block a user