mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-31 14:57:17 +00:00
Compare commits
@@ -171,6 +171,7 @@ test-http-client:
|
||||
/workdir/get_full_account.http \
|
||||
/workdir/create_group.http \
|
||||
/workdir/get_group.http \
|
||||
/workdir/rename_device.http \
|
||||
/workdir/unregister_device.http \
|
||||
--report; \
|
||||
EXIT_CODE=$$?; \
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -936,6 +936,16 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Get("/presets/all", server.HandleMargeAccountPresets)
|
||||
r.Get("/provider_settings", server.HandleMargeProviderSettings)
|
||||
|
||||
// All `/device` routes share one chi subrouter. Two
|
||||
// overlapping subrouters (`/device` + `/device/{device}`)
|
||||
// caused chi's radix-tree resolver to bind a runtime
|
||||
// request to the more-specific prefix even when only the
|
||||
// less-specific subrouter had a matching method handler,
|
||||
// producing the [UNHANDLED] → upstream-proxy fall-through
|
||||
// behind issue #285's first-attempted fix. One subrouter
|
||||
// keeps every device-scoped path resolvable; see
|
||||
// TestPUTRenameRoutesToLocalHandler for the regression
|
||||
// against the production router.
|
||||
r.Route("/device", func(r chi.Router) {
|
||||
r.Post("/", server.HandleMargeAddDevice)
|
||||
r.Post("/{device}", server.HandleMargeAddDevice)
|
||||
@@ -944,21 +954,20 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
// when the user renames via Bose App or
|
||||
// `soundtouch-cli name set`. Issue #285.
|
||||
r.Put("/{device}", server.HandleMargeUpdateDevice)
|
||||
})
|
||||
r.Delete("/{device}", server.HandleMargeRemoveDevice)
|
||||
|
||||
r.Route("/device/{device}", func(r chi.Router) {
|
||||
r.Get("/presets", server.HandleMargePresets)
|
||||
r.Post("/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Put("/preset/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Delete("/preset/{presetNumber}", server.HandleMargeRemovePreset)
|
||||
r.Get("/recent", server.HandleMargeRecents)
|
||||
r.Get("/recents", server.HandleMargeRecents)
|
||||
r.Post("/recent", server.HandleMargeAddRecent)
|
||||
r.Get("/{device}/presets", server.HandleMargePresets)
|
||||
r.Post("/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Put("/{device}/preset/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Delete("/{device}/preset/{presetNumber}", server.HandleMargeRemovePreset)
|
||||
r.Get("/{device}/recent", server.HandleMargeRecents)
|
||||
r.Get("/{device}/recents", server.HandleMargeRecents)
|
||||
r.Post("/{device}/recent", server.HandleMargeAddRecent)
|
||||
|
||||
r.Get("/group", server.HandleMargeDeviceGroup)
|
||||
r.Get("/group/", server.HandleMargeDeviceGroup)
|
||||
r.Get("/group/server", server.HandleMargeDeviceGroupServer)
|
||||
r.Get("/group/member", server.HandleMargeDeviceGroupMember)
|
||||
r.Get("/{device}/group", server.HandleMargeDeviceGroup)
|
||||
r.Get("/{device}/group/", server.HandleMargeDeviceGroup)
|
||||
r.Get("/{device}/group/server", server.HandleMargeDeviceGroupServer)
|
||||
r.Get("/{device}/group/member", server.HandleMargeDeviceGroupMember)
|
||||
})
|
||||
|
||||
// Speakers POST to /group/ (with trailing slash) when forwarding
|
||||
@@ -968,8 +977,6 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Post("/group/", server.HandleMargeAddGroup)
|
||||
r.Post("/group/{groupId}", server.HandleMargeModifyGroup)
|
||||
r.Delete("/group/{groupId}", server.HandleMargeDeleteGroup)
|
||||
|
||||
r.Delete("/device/{device}", server.HandleMargeRemoveDevice)
|
||||
})
|
||||
|
||||
r.Get("/device/{device}/streaming_token", server.HandleMargeStreamingToken)
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"reflect"
|
||||
"runtime"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@@ -102,3 +104,56 @@ func TestPrintRoutes(t *testing.T) {
|
||||
t.Errorf("Router routes changed! Diff the snapshot at %s with %s", snapshotPath, actualPath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPUTRenameRoutesToLocalHandler reproduces the runtime routing
|
||||
// behaviour the user saw on their deployed v0.80.0: a PUT to
|
||||
// /streaming/account/{a}/device/{d} should land on
|
||||
// HandleMargeUpdateDevice, not fall through to the [UNHANDLED]
|
||||
// proxy. The handlers-package test (TestIssue285_*) uses a simplified
|
||||
// router that doesn't have the overlapping `/device` and
|
||||
// `/device/{device}` route groups, so it can't catch a chi radix-
|
||||
// tree resolution that prefers the more-specific subrouter.
|
||||
//
|
||||
// This test exercises the actual production setupRouter so a
|
||||
// regression in the route topology is caught against the same chi
|
||||
// behaviour speakers will see.
|
||||
func TestPUTRenameRoutesToLocalHandler(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "router-rename-")
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir temp: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
server := handlers.NewServer(ds, nil, "http://localhost:8000", false, false, false)
|
||||
r := setupRouter(server)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
body := `<?xml version="1.0" encoding="UTF-8" ?><device deviceid="A81B6A536A98"><name>Sound Machinechen</name><macaddress>A81B6A536A98</macaddress></device>`
|
||||
|
||||
req, err := http.NewRequest(http.MethodPut,
|
||||
ts.URL+"/streaming/account/1111111/device/A81B6A536A98",
|
||||
strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("build request: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/xml")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("PUT: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// 200 means our local HandleMargeUpdateDevice handled it.
|
||||
// 401 / 502 / anything else means the request fell through to
|
||||
// the [UNHANDLED] proxy and got the upstream response — which
|
||||
// is exactly the failure mode #285 was supposed to fix.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("PUT status = %d, want 200 (local handler). Anything else means the request fell through to [UNHANDLED] proxy — chi is routing to a different subrouter than the PUT registration intended.", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ DELETE /setup/dns-discoveries handlers.(
|
||||
DELETE /setup/interactions/sessions handlers.(*Server).HandleCleanupSessions-fm
|
||||
DELETE /setup/interactions/sessions/{session} handlers.(*Server).HandleDeleteSession-fm
|
||||
DELETE /setup/parity-mismatches handlers.(*Server).HandleClearParityMismatches-fm
|
||||
DELETE /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeRemoveDevice-fm
|
||||
DELETE /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeRemovePreset-fm
|
||||
DELETE /streaming/account/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
|
||||
GET / handlers.(*Server).HandleRoot-fm
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Spotify OAuth Integration
|
||||
|
||||
> **New here?** Start with [spotify-overview.md](spotify-overview.md) for the
|
||||
> mental model (Spotify Connect vs OAuth-intercept, DNS rewrite gotcha,
|
||||
> end-to-end token lifecycle). This document zooms in on the OAuth flows and
|
||||
> management endpoints.
|
||||
|
||||
The SoundTouch service supports Spotify OAuth integration to broker access tokens for SoundTouch speakers. This is particularly useful for maintaining Spotify Connect functionality after the Bose cloud shutdown (scheduled for May 2026).
|
||||
|
||||
## OAuth Flows
|
||||
@@ -91,43 +96,23 @@ sequenceDiagram
|
||||
Note over Speaker: Speaker now has Spotify access
|
||||
```
|
||||
|
||||
## Boot Primer Script
|
||||
## Priming Speakers
|
||||
|
||||
A boot primer script that uses these endpoints to feed Spotify tokens to speakers via ZeroConf is available in the `scripts/spotify/` directory: [spotify-boot-primer.sh](../../scripts/spotify/spotify-boot-primer.sh).
|
||||
|
||||
This script can be installed on the speaker itself (which runs embedded Linux) to automatically prime Spotify Connect at boot time. See [README.md](../../scripts/spotify/README.md) and [INSTALL.md](../../scripts/spotify/INSTALL.md) for instructions.
|
||||
|
||||
### Automated Installation via Service
|
||||
|
||||
The SoundTouch service provides a dedicated management endpoint to automatically handle the installation of the Spotify boot primer on the speaker:
|
||||
`POST /mgmt/devices/{deviceId}/spotify/install-primer`
|
||||
|
||||
### Automated Installation Steps
|
||||
When you run the Spotify primer installation, the service performs the following:
|
||||
1. **Directories**: Creates `/mnt/nv/bin` and `/mnt/nv/BoseApp-Persistence/1` on the speaker.
|
||||
2. **Binary**: Uploads the `spotify-boot-primer` script to the speaker.
|
||||
3. **Configuration**: Automatically generates and uploads `spotify-primer.conf` containing the service's URL and management credentials.
|
||||
4. **Boot Hook**: Injects a call to the primer in the speaker's `/mnt/nv/rc.local` using idempotent markers.
|
||||
5. **Environment**: Updates `/mnt/nv/.profile` to include `/mnt/nv/bin` in the `PATH` for easier manual troubleshooting via SSH.
|
||||
|
||||
- **Idempotent Patching**: The service uses explicit markers to inject the hook, ensuring it doesn't corrupt existing content.
|
||||
- **Coexistence**: The service-injected hook is designed to coexist with a manually installed `rc.local` (e.g., from the community gist). It only adds a call to `/mnt/nv/bin/spotify-boot-primer` if it's not already managed by a service-controlled block.
|
||||
- **Markers**: Look for the following markers in your speaker's `/mnt/nv/rc.local`:
|
||||
- `# --- Aftertouch Spotify hook START ---`
|
||||
- `# --- Aftertouch Spotify hook END ---`
|
||||
- **Cleanup**: Reverting a migration via the service will cleanly remove these marker-delimited blocks.
|
||||
> **Note:** The on-device boot-primer flow (installing `spotify-boot-primer.sh` onto the speaker's `/mnt/nv` and hooking it from `rc.local`) is **deprecated**. AfterTouch now uses a server-centric model: the service registers a `SPOTIFY` source in marge for the device's paired account and pushes credentials via ZeroConf from the server side, triggered on `power_on` and a manual "Prime" action. See [spotify-priming-strategy.md](spotify-priming-strategy.md) for the current model and rationale.
|
||||
>
|
||||
> The artifacts under `scripts/spotify/` are kept as historical reference for users who still rely on the on-device approach. There is no longer a `/mgmt/devices/{deviceId}/spotify/install-primer` endpoint.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|--------|---------------------------------------------------|-------|-----------------------------------------------------------------------|
|
||||
| POST | `/mgmt/devices/{deviceId}/spotify/install-primer` | Basic | Install Spotify boot primer on speaker (deviceId or IP) |
|
||||
| GET | `/mgmt/spotify/callback` | None | Browser OAuth callback (redirect from Spotify, returns HTML) |
|
||||
| POST | `/mgmt/spotify/init` | Basic | Start OAuth flow, returns authorization URL |
|
||||
| GET | `/mgmt/spotify/callback` | None | Browser OAuth callback (redirect from Spotify, returns HTML) |
|
||||
| POST | `/mgmt/spotify/confirm` | Basic | Mobile app confirm (ueberboese deep link delivers code, returns JSON) |
|
||||
| GET | `/mgmt/spotify/accounts` | Basic | List linked Spotify accounts (tokens stripped) |
|
||||
| GET | `/mgmt/spotify/token` | Basic | Get fresh access token (auto-refreshes if expired) |
|
||||
| POST | `/mgmt/spotify/entity` | Basic | Resolve Spotify URI to name + image URL |
|
||||
| POST | `/mgmt/spotify/prime` | Basic | Manually trigger server-side priming of a discovered speaker |
|
||||
|
||||
## Security
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
# Spotify on SoundTouch — Overview
|
||||
|
||||
This is the entry point for understanding how Spotify works on a SoundTouch
|
||||
speaker behind AfterTouch. Read this first; the deeper docs assume you already
|
||||
have the mental model below.
|
||||
|
||||
> **Premium likely required.** As far as we know, Spotify Connect on
|
||||
> SoundTouch only works with a Spotify Premium account — this matches our
|
||||
> testing and matches what other SoundTouch-replacement projects report, but
|
||||
> we have not exhaustively verified every account tier or region. None of the
|
||||
> workarounds in this document change Spotify's account-tier requirements.
|
||||
|
||||
## Two completely separate Spotify paths
|
||||
|
||||
These are routinely confused. They share a speaker and a Spotify account, but
|
||||
they ride on different infrastructure and fail for different reasons.
|
||||
|
||||
### 1. Spotify Connect (speaker-native, independent of AfterTouch)
|
||||
|
||||
- The speaker advertises itself on the LAN as a Spotify Connect endpoint
|
||||
(mDNS service `_spotify-connect._tcp`).
|
||||
- You open the Spotify app on your phone or desktop, tap the Connect device
|
||||
picker, and select the SoundTouch.
|
||||
- Audio streams directly from Spotify's CDN to the speaker. Token handling,
|
||||
session setup, and playback all happen between Spotify and the speaker.
|
||||
- **AfterTouch is not involved.** It still works even if AfterTouch is
|
||||
offline.
|
||||
|
||||
This is the simplest path. If you only want to push playback from your phone,
|
||||
you do not need to link Spotify to AfterTouch at all — see [Manual kick-start
|
||||
alternative](#manual-kick-start-alternative) below.
|
||||
|
||||
### 2. OAuth-intercept path (managed by AfterTouch)
|
||||
|
||||
This is what enables features that originate **from the speaker**:
|
||||
|
||||
- Spotify presets on the speaker's buttons.
|
||||
- Spotify playback from the Bose app's source picker.
|
||||
- "Resume Spotify" after a power cycle without touching the Spotify app.
|
||||
|
||||
After Bose's cloud shutdown (May 2026), the speaker can no longer reach
|
||||
Bose's OAuth server for Spotify token refresh. AfterTouch intercepts those
|
||||
calls via DNS, brokers tokens with Spotify using your linked account, and
|
||||
hands them back to the speaker.
|
||||
|
||||
The rest of this document describes that path.
|
||||
|
||||
## Setup at a glance
|
||||
|
||||
Full step-by-step is in
|
||||
[docs/guides/MUSIC-SERVICES.md](../guides/MUSIC-SERVICES.md). Summary:
|
||||
|
||||
1. **Register a Spotify developer app** (one-time, by the AfterTouch operator).
|
||||
2. **Configure AfterTouch** with the Client ID, Client Secret, and Redirect
|
||||
URI in the Settings tab.
|
||||
3. **Authorize your Spotify account** via the Local Account tab — completes
|
||||
the OAuth flow and persists a long-lived refresh token to AfterTouch's
|
||||
datastore.
|
||||
4. **Prime each speaker** so its source list and ZeroConf state know about
|
||||
Spotify.
|
||||
|
||||
After step 4, presets and Bose-app-initiated Spotify playback work.
|
||||
|
||||
## The DNS rewrite — easy to miss, breaks everything
|
||||
|
||||
Bose firmware does **not** read a separate OAuth server hostname from
|
||||
configuration. It derives the OAuth host from the marge host by inserting
|
||||
`oauth` into the first label:
|
||||
|
||||
| Purpose | Hostname |
|
||||
|-----------------|---------------------------|
|
||||
| Marge / sources | `streaming.bose.com` |
|
||||
| OAuth refresh | `streamingoauth.bose.com` |
|
||||
|
||||
**Both hostnames must resolve to AfterTouch.** AfterTouch's DNS server hijacks
|
||||
both, but if you bypass that DNS server (e.g. by hard-coding only the marge
|
||||
hostname in `/etc/hosts`, or by routing only one through a custom resolver),
|
||||
token refresh will silently die while the speaker still pulls sources.
|
||||
Symptom: the speaker briefly streams Spotify after priming, then stops at the
|
||||
first token refresh ~1 hour later.
|
||||
|
||||
If you self-host AfterTouch at e.g. `aftertouch.local`, you would equivalently
|
||||
need `aftertouchoauth.local` for the OAuth interception path.
|
||||
|
||||
## End-to-end token lifecycle
|
||||
|
||||
What actually happens, from priming to steady-state playback:
|
||||
|
||||
1. **Operator links Spotify account.** OAuth flow stores
|
||||
`{user_id, refresh_token, bose_secret}` in `spotify/accounts.json`. The
|
||||
`bose_secret` is an opaque surrogate (e.g. `bs-deadbeef…`) that AfterTouch
|
||||
issues; the speaker only ever sees this surrogate, never the real Spotify
|
||||
refresh token.
|
||||
2. **Priming runs.** Either on speaker `power_on`, on discovery, or on a
|
||||
manual `POST /mgmt/spotify/prime`. AfterTouch:
|
||||
- Resolves the speaker's currently-paired account via live `:8090/info`
|
||||
(`margeAccountUUID`).
|
||||
- Writes a `SPOTIFY` `ConfiguredSource` into marge under that account with
|
||||
`secret = bose_secret`, `secretType = token_version_3`.
|
||||
- POSTs `<updates><sourcesUpdated/></updates>` to the speaker's
|
||||
`:8090/notification`, causing the speaker to re-fetch
|
||||
`/streaming/account/{account}/full` and pick up the new source.
|
||||
- Optionally pushes a fresh access token to the speaker's ZeroConf
|
||||
endpoint (`:8200/zc?action=addUser`). This is best-effort — see
|
||||
[ZeroConf clientId and benign 404s](#zeroconf-clientid-and-benign-404s).
|
||||
3. **Speaker pulls sources.** It now has a SPOTIFY entry with the surrogate
|
||||
as its credential. The speaker stores this; from its perspective the
|
||||
surrogate is the refresh token.
|
||||
4. **Speaker uses Spotify.** When it needs a fresh access token (every ~1 h
|
||||
on Spotify's clock), it POSTs to
|
||||
`streamingoauth.bose.com/oauth/device/{deviceID}/music/musicprovider/15/token/cs3`
|
||||
with the surrogate.
|
||||
5. **AfterTouch translates.** DNS hijack routes the request to AfterTouch,
|
||||
which looks up the surrogate, performs the real refresh against Spotify
|
||||
using the stored refresh token, and returns the resulting access token to
|
||||
the speaker.
|
||||
6. **Speaker uses the access token** for Spotify Web API metadata calls
|
||||
(artwork, track lookups, playback container resolution).
|
||||
|
||||
Forensic details of the request shapes are in
|
||||
[docs/reference/spotify-account-addition.md](../reference/spotify-account-addition.md).
|
||||
The cryptographic specifics of the ZeroConf `addUser` blob are in
|
||||
[spotify-priming-strategy.md](spotify-priming-strategy.md).
|
||||
|
||||
## ZeroConf clientId and benign 404s
|
||||
|
||||
`GET http://<speaker>:8200/zc?action=getInfo` returns, among other fields:
|
||||
|
||||
```json
|
||||
"clientID": "79ebcb219e8e4e9a892e796607931810"
|
||||
"tokenType": "accesstoken"
|
||||
"activeUser": "<spotify-user-id-or-empty>"
|
||||
```
|
||||
|
||||
That `clientID` is **Bose's official Spotify Connect partner client_id**,
|
||||
baked into firmware. It is **not** the client_id of the developer app you
|
||||
registered for AfterTouch — those are two unrelated OAuth apps, by design.
|
||||
The Bose-baked one is what Spotify Connect uses when a Spotify mobile app
|
||||
discovers the speaker on the LAN. The AfterTouch-registered one is what
|
||||
brokers refresh tokens for the OAuth-intercept path. They never converge.
|
||||
|
||||
**Implication:** an access token AfterTouch obtained under its own client_id
|
||||
is not directly usable as a Spotify Connect session token. Pushing it via
|
||||
ZeroConf `addUser` is best-effort, and the speaker may respond with a `404`
|
||||
and an empty body when its `activeUser` already matches the username being
|
||||
pushed — that is the firmware's idiomatic "no transition required" signal,
|
||||
not a failure. AfterTouch recognises this case (`zeroconf.ErrAddUserNoOp`)
|
||||
and logs it as an expected no-op rather than an error.
|
||||
|
||||
A 404 **with a body**, or any other non-2xx, is treated as a real failure
|
||||
and logged loudly with the response headers and body so it can be
|
||||
diagnosed.
|
||||
|
||||
## Manual kick-start alternative
|
||||
|
||||
You can skip the OAuth setup entirely if you only want playback pushed from
|
||||
the Spotify app:
|
||||
|
||||
1. Open the Spotify mobile/desktop app.
|
||||
2. Start any track.
|
||||
3. Open the Connect device picker, select the SoundTouch.
|
||||
|
||||
The speaker now holds an in-memory Spotify Connect session and can play
|
||||
until next reboot. Presets and Bose-app-initiated Spotify playback will
|
||||
still not work — those require the OAuth-intercept path — but Spotify-app-
|
||||
initiated playback does.
|
||||
|
||||
## Troubleshooting quick reference
|
||||
|
||||
| Symptom | Most likely cause |
|
||||
|----------------------------------------------------|------------------------------------------------------------------------------------------------|
|
||||
| Preset stores then fails: "invalid SourceID" | No `SPOTIFY` source in marge for the speaker's paired account. Re-run priming. |
|
||||
| Preset stores fine; playback dies after ~1 hour | `streamingoauth.bose.com` not pointed at AfterTouch (DNS rewrite gap). |
|
||||
| Speaker has source but `Sources.xml` looks stale | `<sourcesUpdated/>` notification did not reach the speaker. Re-run priming or POST it by hand. |
|
||||
| ZeroConf `addUser` returns 404, empty body | Benign no-op; speaker already has `activeUser` set. Marge path is authoritative. |
|
||||
| Spotify Connect device picker doesn't show speaker | Unrelated to AfterTouch; check the speaker's mDNS visibility on the LAN. |
|
||||
|
||||
## Where to go next
|
||||
|
||||
- **Setup walkthrough:** [docs/guides/MUSIC-SERVICES.md](../guides/MUSIC-SERVICES.md)
|
||||
- **OAuth flow details (browser + mobile + endpoint table):** [spotify-oauth.md](spotify-oauth.md)
|
||||
- **Priming strategy, ZeroConf DH protocol, deployment topologies:** [spotify-priming-strategy.md](spotify-priming-strategy.md)
|
||||
- **Forensic request/response analysis from the Stockholm app:** [docs/reference/spotify-account-addition.md](../reference/spotify-account-addition.md)
|
||||
@@ -1,5 +1,9 @@
|
||||
# Spotify Priming Strategy
|
||||
|
||||
> **New here?** Start with [spotify-overview.md](spotify-overview.md) for the
|
||||
> mental model. This document goes deep on the priming protocol, ZeroConf DH
|
||||
> exchange, and deployment topologies.
|
||||
|
||||
This document outlines the strategy for ensuring Bose SoundTouch devices are correctly "primed" for Spotify Connect integration within the AfterTouch ecosystem.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
This guide explains how to link your Spotify or Amazon Music account to AfterTouch so your speakers can stream music from those services.
|
||||
|
||||
> For Spotify, a higher-level mental model of how the integration works —
|
||||
> Spotify Connect vs. AfterTouch's OAuth-intercept path, the
|
||||
> `streamingoauth.bose.com` DNS gotcha, and the token lifecycle — is in
|
||||
> [docs/concepts/spotify-overview.md](../concepts/spotify-overview.md).
|
||||
> Read that if priming or playback isn't behaving as you'd expect.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -597,6 +597,16 @@ type ServiceDeviceInfo struct {
|
||||
DiscoveryMethod string `json:"discovery_method,omitempty"`
|
||||
AccountID string `json:"account_id,omitempty"`
|
||||
Components []ServiceComponent `json:"components,omitempty" xml:"-"`
|
||||
// CreatedOn is the ISO8601 timestamp the device was first
|
||||
// registered against the account. Preserved across renames so
|
||||
// AfterTouch's PUT response matches real Bose's "first paired
|
||||
// in 2017" semantics rather than rewriting `now()` on every
|
||||
// update. Empty for never-persisted records.
|
||||
CreatedOn string `json:"created_on,omitempty" xml:"-"`
|
||||
// UpdatedOn is the ISO8601 timestamp of the most recent change
|
||||
// to the device record (rename, IP refresh, …). Refreshed by
|
||||
// every SaveDeviceInfo write that mutates a known device.
|
||||
UpdatedOn string `json:"updated_on,omitempty" xml:"-"`
|
||||
}
|
||||
|
||||
// ServiceComponent represents a hardware or software component of a device.
|
||||
|
||||
@@ -2,6 +2,10 @@ package amazon
|
||||
|
||||
import "github.com/gesellix/bose-soundtouch/pkg/service/zeroconf"
|
||||
|
||||
// ErrAddUserNoOp re-exports zeroconf.ErrAddUserNoOp for callers that don't
|
||||
// want a direct dependency on the zeroconf package.
|
||||
var ErrAddUserNoOp = zeroconf.ErrAddUserNoOp
|
||||
|
||||
// PushAmazonCredentials pushes Amazon Music credentials to a speaker using the
|
||||
// ZeroConf DH key exchange protocol. Falls back to simplified token push if
|
||||
// the speaker does not support DH (older firmware).
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -565,6 +565,8 @@ func (ds *DataStore) getDeviceInfoNoLock(account, device string) (*models.Servic
|
||||
MacAddress string `xml:"macAddress"`
|
||||
} `xml:"networkInfo"`
|
||||
DiscoveryMethod string `xml:"discoveryMethod"`
|
||||
CreatedOn string `xml:"createdOn,omitempty"`
|
||||
UpdatedOn string `xml:"updatedOn,omitempty"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(data, &info); err != nil {
|
||||
@@ -577,6 +579,8 @@ func (ds *DataStore) getDeviceInfoNoLock(account, device string) (*models.Servic
|
||||
ProductCode: fmt.Sprintf("%s %s", info.Type, info.ModuleType),
|
||||
Name: info.Name,
|
||||
DiscoveryMethod: info.DiscoveryMethod,
|
||||
CreatedOn: info.CreatedOn,
|
||||
UpdatedOn: info.UpdatedOn,
|
||||
}
|
||||
|
||||
for _, comp := range info.Components {
|
||||
@@ -789,6 +793,8 @@ func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo
|
||||
MacAddress string `xml:"macAddress"`
|
||||
} `xml:"networkInfo"`
|
||||
DiscoveryMethod string `xml:"discoveryMethod"`
|
||||
CreatedOn string `xml:"createdOn,omitempty"`
|
||||
UpdatedOn string `xml:"updatedOn,omitempty"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(data, &info); err != nil {
|
||||
@@ -1192,6 +1198,8 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
|
||||
Components []componentXML `xml:"components>component"`
|
||||
NetworkInfo []NetworkInfoXML `xml:"networkInfo"`
|
||||
DiscoveryMethod string `xml:"discoveryMethod,omitempty"`
|
||||
CreatedOn string `xml:"createdOn,omitempty"`
|
||||
UpdatedOn string `xml:"updatedOn,omitempty"`
|
||||
}
|
||||
|
||||
// Parsing product code back to type and moduleType (best effort)
|
||||
@@ -1203,6 +1211,8 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
|
||||
Type: devType,
|
||||
ModuleType: moduleType,
|
||||
DiscoveryMethod: info.DiscoveryMethod,
|
||||
CreatedOn: info.CreatedOn,
|
||||
UpdatedOn: info.UpdatedOn,
|
||||
}
|
||||
|
||||
if ix.DiscoveryMethod == "" {
|
||||
@@ -1266,6 +1276,21 @@ func (ds *DataStore) mergeWithExistingDeviceInfo(account, device string, info *m
|
||||
if info.DiscoveryMethod == "" {
|
||||
info.DiscoveryMethod = existing.DiscoveryMethod
|
||||
}
|
||||
|
||||
// CreatedOn is set once at first persistence and never re-derived
|
||||
// from inbound data — preserve unconditionally so the
|
||||
// "first-paired" timestamp survives renames, IP refreshes, etc.
|
||||
// UpdatedOn is the opposite: every write that reaches here is by
|
||||
// definition an update, so callers that want it refreshed must
|
||||
// set it explicitly. If they didn't, fall back to the existing
|
||||
// value (better than a regression to empty).
|
||||
if existing.CreatedOn != "" {
|
||||
info.CreatedOn = existing.CreatedOn
|
||||
}
|
||||
|
||||
if info.UpdatedOn == "" {
|
||||
info.UpdatedOn = existing.UpdatedOn
|
||||
}
|
||||
}
|
||||
|
||||
func (ds *DataStore) parseProductCode(productCode string) (string, string) {
|
||||
@@ -2102,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
|
||||
|
||||
@@ -588,7 +588,7 @@ func (s *Server) HandleMargeAddDevice(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
deviceID, data, err := marge.AddDeviceToAccount(s.ds, account, body)
|
||||
deviceID, data, err := marge.AddDeviceToAccount(s.ds, account, body, r.RemoteAddr)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -637,20 +637,32 @@ func (s *Server) HandleMargeUpdateDevice(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
bodyDeviceID, data, err := marge.AddDeviceToAccount(s.ds, account, body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
// Validate body deviceID against the URL segment *before* the
|
||||
// upsert in AddDeviceToAccount runs — otherwise a mismatched PUT
|
||||
// would still persist a row for the body's deviceID before the
|
||||
// 400 response, leaving spurious state in the datastore.
|
||||
var probe struct {
|
||||
DeviceID string `xml:"deviceid,attr"`
|
||||
}
|
||||
if xmlErr := xml.Unmarshal(body, &probe); xmlErr != nil {
|
||||
http.Error(w, xmlErr.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if bodyDeviceID != device {
|
||||
if probe.DeviceID != device {
|
||||
http.Error(w,
|
||||
fmt.Sprintf("device ID in body (%q) does not match URL (%q)", bodyDeviceID, device),
|
||||
fmt.Sprintf("device ID in body (%q) does not match URL (%q)", probe.DeviceID, device),
|
||||
http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_, data, err := marge.AddDeviceToAccount(s.ds, account, body, r.RemoteAddr)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(data)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -65,20 +65,26 @@ func TestIssue285_RenamePutAcceptedAndPersisted(t *testing.T) {
|
||||
_ = ds.Initialize()
|
||||
|
||||
const (
|
||||
accountID = "3981561"
|
||||
deviceID = "884AEAEEBD27"
|
||||
oldName = "Wohnzimmer"
|
||||
newName = "Wohnzimmer SB"
|
||||
accountID = "3981561"
|
||||
deviceID = "884AEAEEBD27"
|
||||
oldName = "Wohnzimmer"
|
||||
newName = "Wohnzimmer SB"
|
||||
preExistingIP = "192.168.0.109"
|
||||
preExistingPaired = "2017-02-07T11:13:03.000+00:00"
|
||||
)
|
||||
|
||||
// 1. Seed datastore with the device under its original name —
|
||||
// modelling a pre-existing paired device the user is now
|
||||
// renaming.
|
||||
// 1. Seed datastore with the device under its original name and
|
||||
// a known pre-existing first-paired timestamp. The pre-existing
|
||||
// data models a long-paired device the user is now renaming —
|
||||
// CreatedOn must survive the PUT (real Bose preserves it
|
||||
// across renames; see parity capture at
|
||||
// data/parity_mismatches/1771797308__streaming_account_3230304_device_A81B6A536A98.json).
|
||||
if err := ds.SaveDeviceInfo(accountID, deviceID, &models.ServiceDeviceInfo{
|
||||
DeviceID: deviceID,
|
||||
AccountID: accountID,
|
||||
Name: oldName,
|
||||
IPAddress: "192.168.0.109",
|
||||
IPAddress: preExistingIP,
|
||||
CreatedOn: preExistingPaired,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed datastore: %v", err)
|
||||
}
|
||||
@@ -146,10 +152,35 @@ func TestIssue285_RenamePutAcceptedAndPersisted(t *testing.T) {
|
||||
t.Errorf("response still carries old name %q; body:\n%s", oldName, respBody)
|
||||
}
|
||||
|
||||
// Parity assertion: the pre-existing first-paired CreatedOn
|
||||
// must survive the rename. This is the load-bearing fix versus
|
||||
// the prior behaviour that rewrote `now()` on every PUT, and
|
||||
// matches what real Bose's pre-shutdown 200 OK responses
|
||||
// carried (see the parity capture referenced above).
|
||||
if !bytes.Contains(respBody, []byte(`<createdOn>`+preExistingPaired+`</createdOn>`)) {
|
||||
t.Errorf("response did not preserve pre-existing CreatedOn %q; body:\n%s", preExistingPaired, respBody)
|
||||
}
|
||||
|
||||
// Parity assertion: the pre-existing IP address must survive
|
||||
// the rename. The request body doesn't carry an `<ipaddress>`,
|
||||
// so the datastore merge has to inject what was already on
|
||||
// disk rather than writing back empty.
|
||||
if !bytes.Contains(respBody, []byte(`<ipaddress>`+preExistingIP+`</ipaddress>`)) {
|
||||
t.Errorf("response did not preserve pre-existing IPAddress %q; body:\n%s", preExistingIP, respBody)
|
||||
}
|
||||
|
||||
// Parity assertion: UpdatedOn refreshes. Don't pin the exact
|
||||
// value — it's "now()" — but assert it's present and
|
||||
// non-empty.
|
||||
if !bytes.Contains(respBody, []byte(`<updatedOn>`)) ||
|
||||
bytes.Contains(respBody, []byte(`<updatedOn></updatedOn>`)) {
|
||||
t.Errorf("response missing or empty <updatedOn>; body:\n%s", respBody)
|
||||
}
|
||||
|
||||
// 4. Persistence assertion: the datastore now reflects the new
|
||||
// name. This is what the Bose App reads back on its next
|
||||
// /streaming/account/.../full poll, which is what closes the
|
||||
// visible rename loop.
|
||||
// name AND keeps the original CreatedOn. This is what the
|
||||
// Bose App reads back on its next /streaming/account/.../full
|
||||
// poll, which is what closes the visible rename loop.
|
||||
persisted, err := ds.GetDeviceInfo(accountID, deviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("read persisted device info: %v", err)
|
||||
@@ -158,6 +189,109 @@ func TestIssue285_RenamePutAcceptedAndPersisted(t *testing.T) {
|
||||
if persisted.Name != newName {
|
||||
t.Errorf("persisted Name = %q, want %q", persisted.Name, newName)
|
||||
}
|
||||
|
||||
if persisted.CreatedOn != preExistingPaired {
|
||||
t.Errorf("persisted CreatedOn = %q, want %q (preserved across rename)", persisted.CreatedOn, preExistingPaired)
|
||||
}
|
||||
|
||||
if persisted.IPAddress != preExistingIP {
|
||||
t.Errorf("persisted IPAddress = %q, want %q (preserved across rename)", persisted.IPAddress, preExistingIP)
|
||||
}
|
||||
|
||||
if persisted.UpdatedOn == "" {
|
||||
t.Errorf("persisted UpdatedOn is empty; want a fresh timestamp from the rename")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssue285_NewDeviceGetsRemoteAddrAndFreshTimestamps covers the
|
||||
// "first-time registration" path on a PUT (which can happen if the
|
||||
// speaker emits a rename before AfterTouch has ever heard of it).
|
||||
// With no pre-existing datastore record:
|
||||
//
|
||||
// - CreatedOn must be a fresh timestamp (no record to preserve).
|
||||
// - IPAddress must come from r.RemoteAddr (the inbound connection)
|
||||
// since the request body doesn't carry one.
|
||||
// - UpdatedOn must be the same fresh timestamp.
|
||||
//
|
||||
// Pairs with the parity-preservation assertions in the main test:
|
||||
// existing records win, but new records seed sensibly instead of
|
||||
// landing with empty CreatedOn / IPAddress.
|
||||
func TestIssue285_NewDeviceGetsRemoteAddrAndFreshTimestamps(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "issue285-new-")
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir temp: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
const (
|
||||
accountID = "1111111"
|
||||
deviceID = "A81B6A536A98"
|
||||
newName = "Sound Machinechen"
|
||||
)
|
||||
|
||||
r, _ := setupRouter("http://localhost:8001", ds)
|
||||
ts := httptest.NewServer(r)
|
||||
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
body := []byte(`<?xml version="1.0" encoding="UTF-8" ?>` +
|
||||
`<device deviceid="` + deviceID + `"><name>` + newName + `</name><macaddress>` + deviceID + `</macaddress></device>`)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPut,
|
||||
ts.URL+"/streaming/account/"+accountID+"/device/"+deviceID,
|
||||
bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("build request: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/xml")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("PUT: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("PUT status = %d, want 200; body:\n%s", resp.StatusCode, respBody)
|
||||
}
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read response: %v", err)
|
||||
}
|
||||
|
||||
// CreatedOn present and non-empty (will be "now()" since no
|
||||
// prior record existed).
|
||||
if !bytes.Contains(respBody, []byte(`<createdOn>`)) ||
|
||||
bytes.Contains(respBody, []byte(`<createdOn></createdOn>`)) {
|
||||
t.Errorf("first-registration response missing CreatedOn; body:\n%s", respBody)
|
||||
}
|
||||
|
||||
// IPAddress should be the httptest connection's remote host
|
||||
// (127.0.0.1) since the body didn't carry one and there was
|
||||
// no existing record to preserve from.
|
||||
if !bytes.Contains(respBody, []byte(`<ipaddress>127.0.0.1</ipaddress>`)) {
|
||||
t.Errorf("first-registration response missing IPAddress from RemoteAddr; body:\n%s", respBody)
|
||||
}
|
||||
|
||||
// Persistence: CreatedOn and IPAddress on disk too.
|
||||
persisted, err := ds.GetDeviceInfo(accountID, deviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("read persisted device info: %v", err)
|
||||
}
|
||||
|
||||
if persisted.CreatedOn == "" {
|
||||
t.Errorf("persisted CreatedOn is empty for new device; want a fresh timestamp")
|
||||
}
|
||||
|
||||
if persisted.IPAddress != "127.0.0.1" {
|
||||
t.Errorf("persisted IPAddress = %q, want %q (from RemoteAddr)", persisted.IPAddress, "127.0.0.1")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssue285_RenamePutRejectsMismatchedDeviceID pins the safety
|
||||
@@ -205,4 +339,15 @@ func TestIssue285_RenamePutRejectsMismatchedDeviceID(t *testing.T) {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("PUT status = %d, want 400; body:\n%s", resp.StatusCode, respBody)
|
||||
}
|
||||
|
||||
// Mismatched body must be rejected *before* the upsert runs —
|
||||
// otherwise the datastore ends up with a row keyed on the body's
|
||||
// deviceID even though we return 400. Verify by reading both keys.
|
||||
if got, _ := ds.GetDeviceInfo("3981561", "DEADBEEFCAFE"); got != nil {
|
||||
t.Fatalf("body deviceID DEADBEEFCAFE was persisted despite 400 response: %+v", got)
|
||||
}
|
||||
|
||||
if got, _ := ds.GetDeviceInfo("3981561", urlDeviceID); got != nil {
|
||||
t.Fatalf("URL deviceID %s was persisted despite 400 response: %+v", urlDeviceID, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
|
||||
)
|
||||
|
||||
// TestPrimeDeviceWithSpotify_RegistersMargeSource is a regression test for the
|
||||
// "AddPreset - failed due to invalid SourceID" failure observed when storing a
|
||||
// Spotify preset on a primed device. The watchdog priming path used to push
|
||||
// ZeroConf credentials without writing a SPOTIFY ConfiguredSource into the
|
||||
// marge datastore — so marge.UpdatePreset later had nothing to match
|
||||
// SourceID="SPOTIFY" against and rejected the storePreset request.
|
||||
//
|
||||
// This test verifies that PrimeDeviceWithSpotify now also calls marge.AddSource
|
||||
// for the device's account, producing a ConfiguredSource with
|
||||
// SourceProviderID="15" (constants.SpotifyProviderID).
|
||||
func TestPrimeDeviceWithSpotify_RegistersMargeSource(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
ds := datastore.NewDataStore(tmpDir)
|
||||
server := NewServer(ds, nil, "http://localhost", false, false, false)
|
||||
|
||||
// Fake speaker that accepts the ZeroConf push via the simplified
|
||||
// (non-DH) fallback AND records whether /notification (sourcesUpdated)
|
||||
// was hit.
|
||||
var notified atomic.Bool
|
||||
|
||||
speakerTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/notification" {
|
||||
notified.Store(true)
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = io.WriteString(w, `<?xml version="1.0" encoding="UTF-8" ?><status>/notification</status>`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
switch r.URL.Query().Get("action") {
|
||||
case "getInfo":
|
||||
http.Error(w, "not supported", http.StatusNotFound)
|
||||
case "addUser":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer speakerTS.Close()
|
||||
|
||||
speakerHostPort := strings.TrimPrefix(speakerTS.URL, "http://")
|
||||
|
||||
speakerHost, _, err := net.SplitHostPort(speakerHostPort)
|
||||
if err != nil {
|
||||
t.Fatalf("split speaker URL: %v", err)
|
||||
}
|
||||
|
||||
// Register the device under a real account so the IP→account lookup succeeds.
|
||||
const accountID = "acc-prime"
|
||||
const deviceID = "DEVPRIME"
|
||||
devInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: deviceID,
|
||||
AccountID: accountID,
|
||||
Name: "Test Speaker",
|
||||
IPAddress: speakerHost,
|
||||
}
|
||||
|
||||
if err := ds.SaveDeviceInfo(accountID, deviceID, devInfo); err != nil {
|
||||
t.Fatalf("SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
// marge.AddSource walks the account/devices dir — make sure the per-device
|
||||
// subdir exists so the source actually gets persisted.
|
||||
if err := os.MkdirAll(filepath.Join(ds.AccountDevicesDir(accountID), deviceID), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll device dir: %v", err)
|
||||
}
|
||||
|
||||
// Pre-seed a linked Spotify account so PrimeDeviceWithSpotify has something
|
||||
// to push. The token is valid for an hour so GetFreshToken won't try to
|
||||
// refresh against a live endpoint. We point the token endpoint at a noop
|
||||
// URL just in case, so a stray refresh would fail loudly rather than fan
|
||||
// out to the internet.
|
||||
spotifyDir := filepath.Join(tmpDir, "spotify")
|
||||
if err := os.MkdirAll(spotifyDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll spotify dir: %v", err)
|
||||
}
|
||||
|
||||
accountsPayload := map[string]map[string]any{
|
||||
"spotify-user": {
|
||||
"user_id": "spotify-user",
|
||||
"display_name": "Spotify User",
|
||||
"email": "user@example.com",
|
||||
"access_token": "fresh-access-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"expires_at": time.Now().Add(time.Hour).Unix(),
|
||||
"bose_secret": "bs-deadbeef",
|
||||
},
|
||||
}
|
||||
accountsJSON, err := json.Marshal(accountsPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal accounts: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(spotifyDir, "accounts.json"), accountsJSON, 0o600); err != nil {
|
||||
t.Fatalf("write accounts.json: %v", err)
|
||||
}
|
||||
|
||||
ss := spotify.NewSpotifyService("client-id", "client-secret", "http://localhost/callback", tmpDir)
|
||||
// Unused fallback token endpoint — defensive in case the test ever drifts
|
||||
// to an expired token.
|
||||
ss.SetEndpoints("http://127.0.0.1:1/token", "http://127.0.0.1:1")
|
||||
|
||||
if err := ss.Load(); err != nil {
|
||||
t.Fatalf("Load spotify accounts: %v", err)
|
||||
}
|
||||
|
||||
if len(ss.GetAccounts()) != 1 {
|
||||
t.Fatalf("expected 1 spotify account after Load, got %d", len(ss.GetAccounts()))
|
||||
}
|
||||
|
||||
server.SetSpotifyService(ss)
|
||||
|
||||
// Sanity: no SPOTIFY source registered yet.
|
||||
sources, _ := ds.GetConfiguredSources(accountID, deviceID)
|
||||
if hasSpotifySource(sources) {
|
||||
t.Fatalf("precondition failed: SPOTIFY source already present before priming")
|
||||
}
|
||||
|
||||
// Pass host:port so the ZeroConf push hits our test server instead of the
|
||||
// hard-coded :8200 fallback. The IP→account lookup strips the port before
|
||||
// matching against devInfo.IPAddress.
|
||||
server.PrimeDeviceWithSpotify(speakerHostPort)
|
||||
|
||||
sources, err = ds.GetConfiguredSources(accountID, deviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetConfiguredSources after priming: %v", err)
|
||||
}
|
||||
|
||||
if !hasSpotifySource(sources) {
|
||||
for _, src := range sources {
|
||||
t.Logf("source after priming: ID=%s providerID=%s keyType=%s account=%s", src.ID, src.SourceProviderID, src.SourceKey.Type, src.SourceKey.Account)
|
||||
}
|
||||
|
||||
t.Fatalf("expected a SPOTIFY ConfiguredSource (providerID=%d) after priming", constants.SpotifyProviderID)
|
||||
}
|
||||
|
||||
// The speaker's on-device Sources.xml only refreshes when we tell it to —
|
||||
// without this notification storePreset keeps failing even though marge
|
||||
// already has the SPOTIFY source.
|
||||
deadline := time.Now().Add(1 * time.Second)
|
||||
for time.Now().Before(deadline) && !notified.Load() {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
|
||||
if !notified.Load() {
|
||||
t.Errorf("speaker did not receive a sourcesUpdated /notification after priming")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrimeDeviceWithSpotify_SkipsWhenDeviceUnmapped ensures that priming a
|
||||
// device whose IP is not associated with any account does NOT fabricate a
|
||||
// source under the "default" account — the previous behavior would silently
|
||||
// pollute marge with sources for devices that never asked.
|
||||
func TestPrimeDeviceWithSpotify_SkipsWhenDeviceUnmapped(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
ds := datastore.NewDataStore(tmpDir)
|
||||
server := NewServer(ds, nil, "http://localhost", false, false, false)
|
||||
|
||||
speakerTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Query().Get("action") {
|
||||
case "getInfo":
|
||||
http.Error(w, "not supported", http.StatusNotFound)
|
||||
case "addUser":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer speakerTS.Close()
|
||||
|
||||
speakerURL, _ := url.Parse(speakerTS.URL)
|
||||
speakerHostPort := speakerURL.Host
|
||||
|
||||
// Pre-seed a Spotify account but do NOT register any device.
|
||||
spotifyDir := filepath.Join(tmpDir, "spotify")
|
||||
_ = os.MkdirAll(spotifyDir, 0o755)
|
||||
accountsPayload := map[string]map[string]any{
|
||||
"spotify-user": {
|
||||
"user_id": "spotify-user",
|
||||
"display_name": "Spotify User",
|
||||
"access_token": "fresh-access-token",
|
||||
"expires_at": time.Now().Add(time.Hour).Unix(),
|
||||
"bose_secret": "bs-deadbeef",
|
||||
},
|
||||
}
|
||||
|
||||
accountsJSON, err := json.Marshal(accountsPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal accounts: %v", err)
|
||||
}
|
||||
|
||||
_ = os.WriteFile(filepath.Join(spotifyDir, "accounts.json"), accountsJSON, 0o600)
|
||||
|
||||
ss := spotify.NewSpotifyService("client-id", "client-secret", "http://localhost/callback", tmpDir)
|
||||
ss.SetEndpoints("http://127.0.0.1:1/token", "http://127.0.0.1:1")
|
||||
|
||||
if err := ss.Load(); err != nil {
|
||||
t.Fatalf("Load spotify accounts: %v", err)
|
||||
}
|
||||
|
||||
server.SetSpotifyService(ss)
|
||||
|
||||
server.PrimeDeviceWithSpotify(speakerHostPort)
|
||||
|
||||
// "default" account should have no SPOTIFY source added by us.
|
||||
sources, _ := ds.GetConfiguredSources("default", "")
|
||||
if hasSpotifySource(sources) {
|
||||
t.Errorf("priming an unmapped device wrote a SPOTIFY source under 'default' — should have been skipped")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrimeDeviceWithSpotify_LiveMargeAccountUUIDWins covers the production
|
||||
// scenario the previous test didn't catch: a device whose datastore
|
||||
// ServiceDeviceInfo.AccountID is "default" (or stale) but whose live
|
||||
// :8090/info reports a real paired margeAccountUUID. The SPOTIFY source must
|
||||
// land under the paired account — that's the account marge.UpdatePreset
|
||||
// receives storePreset under, so writing anywhere else means the preset still
|
||||
// fails with "AddPreset - failed due to invalid SourceID".
|
||||
//
|
||||
// Mirrors setup.populateDeviceInfo's resolution order (datastore ← live /info)
|
||||
// rather than guessing.
|
||||
func TestPrimeDeviceWithSpotify_LiveMargeAccountUUIDWins(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
ds := datastore.NewDataStore(tmpDir)
|
||||
server := NewServer(ds, nil, "http://localhost", false, false, false)
|
||||
|
||||
const (
|
||||
datastoreAccount = "default" // stale / fallback
|
||||
pairedAccount = "1111111" // live margeAccountUUID from /info
|
||||
deviceID = "DEVPAIR"
|
||||
)
|
||||
|
||||
// Fake speaker that serves both /info and the ZeroConf /zc.
|
||||
var speakerHost string
|
||||
|
||||
speakerTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/info"):
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = io.WriteString(w, `<?xml version="1.0" encoding="UTF-8" ?>`+
|
||||
`<info deviceID="`+deviceID+`">`+
|
||||
`<name>Paired Speaker</name><type>SoundTouch 20</type>`+
|
||||
`<margeAccountUUID>`+pairedAccount+`</margeAccountUUID>`+
|
||||
`</info>`)
|
||||
case r.URL.Path == "/notification":
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = io.WriteString(w, `<?xml version="1.0" encoding="UTF-8" ?><status>/notification</status>`)
|
||||
default:
|
||||
switch r.URL.Query().Get("action") {
|
||||
case "getInfo":
|
||||
http.Error(w, "not supported", http.StatusNotFound)
|
||||
case "addUser":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer speakerTS.Close()
|
||||
|
||||
speakerHostPort := strings.TrimPrefix(speakerTS.URL, "http://")
|
||||
speakerHost, _, _ = net.SplitHostPort(speakerHostPort)
|
||||
|
||||
// Register the device under the STALE account so the datastore lookup
|
||||
// would yield the wrong answer if used in isolation.
|
||||
devInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: deviceID,
|
||||
AccountID: datastoreAccount,
|
||||
Name: "Paired Speaker",
|
||||
IPAddress: speakerHost,
|
||||
}
|
||||
if err := ds.SaveDeviceInfo(datastoreAccount, deviceID, devInfo); err != nil {
|
||||
t.Fatalf("SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
// And make sure the paired account's device dir exists so
|
||||
// marge.AddSource can persist the source (it walks accounts/devices/...).
|
||||
if err := os.MkdirAll(filepath.Join(ds.AccountDevicesDir(pairedAccount), deviceID), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll paired dir: %v", err)
|
||||
}
|
||||
|
||||
// Pre-seed a Spotify account so priming has something to push.
|
||||
spotifyDir := filepath.Join(tmpDir, "spotify")
|
||||
_ = os.MkdirAll(spotifyDir, 0o755)
|
||||
accountsPayload := map[string]map[string]any{
|
||||
"spotify-user": {
|
||||
"user_id": "spotify-user",
|
||||
"display_name": "Spotify User",
|
||||
"access_token": "fresh-access-token",
|
||||
"expires_at": time.Now().Add(time.Hour).Unix(),
|
||||
"bose_secret": "bs-deadbeef",
|
||||
},
|
||||
}
|
||||
|
||||
accountsJSON, err := json.Marshal(accountsPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal accounts: %v", err)
|
||||
}
|
||||
|
||||
_ = os.WriteFile(filepath.Join(spotifyDir, "accounts.json"), accountsJSON, 0o600)
|
||||
|
||||
ss := spotify.NewSpotifyService("client-id", "client-secret", "http://localhost/callback", tmpDir)
|
||||
ss.SetEndpoints("http://127.0.0.1:1/token", "http://127.0.0.1:1")
|
||||
|
||||
if err := ss.Load(); err != nil {
|
||||
t.Fatalf("Load spotify accounts: %v", err)
|
||||
}
|
||||
|
||||
server.SetSpotifyService(ss)
|
||||
|
||||
// Wire a real setup.Manager so resolvePairedAccount reaches /info.
|
||||
// HTTPGet uses the default net/http client, which hits the httptest
|
||||
// server directly via deviceIP=host:port.
|
||||
server.sm = setup.NewManager("http://localhost", ds, nil)
|
||||
|
||||
server.PrimeDeviceWithSpotify(speakerHostPort)
|
||||
|
||||
// SPOTIFY source must be under the PAIRED account, not the datastore one.
|
||||
pairedSources, err := ds.GetConfiguredSources(pairedAccount, deviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetConfiguredSources(paired): %v", err)
|
||||
}
|
||||
|
||||
if !hasSpotifySource(pairedSources) {
|
||||
t.Errorf("expected SPOTIFY source under paired account %s, got %d sources", pairedAccount, len(pairedSources))
|
||||
}
|
||||
|
||||
// And it must NOT have been written under the stale datastore account.
|
||||
staleSources, _ := ds.GetConfiguredSources(datastoreAccount, deviceID)
|
||||
if hasSpotifySource(staleSources) {
|
||||
t.Errorf("SPOTIFY source unexpectedly written under stale datastore account %s — should follow live margeAccountUUID", datastoreAccount)
|
||||
}
|
||||
}
|
||||
|
||||
func hasSpotifySource(sources []models.ConfiguredSource) bool {
|
||||
for _, src := range sources {
|
||||
if src.SourceProviderID == "15" || src.SourceKey.Type == constants.ProviderSpotify {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -3,19 +3,24 @@ package handlers
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/amazon"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/marge"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
|
||||
@@ -659,13 +664,123 @@ func (s *Server) PrimeDeviceWithSpotify(deviceIP string) {
|
||||
|
||||
log.Printf("[Spotify Watchdog] Proactively priming %s with Spotify user %s", deviceIP, username)
|
||||
|
||||
// Register the SPOTIFY source in our marge datastore before pushing credentials.
|
||||
// Without this, storePreset later fails with "AddPreset - failed due to invalid SourceID"
|
||||
// because marge.UpdatePreset can't match SourceID="SPOTIFY" against any ConfiguredSource.
|
||||
s.registerSpotifySourceForDevice(deviceIP, accounts)
|
||||
|
||||
if err := s.pushSpotifyTokenToDevice(deviceIP, username, accessToken); err != nil {
|
||||
log.Printf("[Spotify Watchdog] Failed to prime %s: %v", deviceIP, err)
|
||||
// addUser may return a benign 404+empty-body no-op when the speaker
|
||||
// already has the activeUser set. The zeroconf-level log already
|
||||
// recorded the specifics; here we just upgrade the watchdog's view to
|
||||
// "primed" since marge holds the authoritative SPOTIFY source.
|
||||
if errors.Is(err, spotify.ErrAddUserNoOp) {
|
||||
log.Printf("[Spotify Watchdog] Successfully primed %s (ZeroConf addUser was an expected no-op)", deviceIP)
|
||||
} else {
|
||||
log.Printf("[Spotify Watchdog] Failed to prime %s: %v", deviceIP, err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[Spotify Watchdog] Successfully primed %s", deviceIP)
|
||||
}
|
||||
}
|
||||
|
||||
// registerSpotifySourceForDevice writes a SPOTIFY ConfiguredSource into the marge
|
||||
// datastore under the device's currently-paired account. No-op (with a log
|
||||
// message) if the device can't be resolved to an account — falling back to
|
||||
// "default" here would risk polluting an unrelated account's source list, and
|
||||
// any storePreset the device sends will be under its real paired account anyway.
|
||||
func (s *Server) registerSpotifySourceForDevice(deviceIP string, accounts []spotify.Account) {
|
||||
host := deviceIP
|
||||
if h, _, err := net.SplitHostPort(deviceIP); err == nil {
|
||||
host = h
|
||||
}
|
||||
|
||||
accountID, deviceID := s.resolvePairedAccount(deviceIP, host)
|
||||
if accountID == "" {
|
||||
log.Printf("[Spotify Watchdog] No paired account for %s yet — skipping marge source registration", deviceIP)
|
||||
return
|
||||
}
|
||||
|
||||
registered := false
|
||||
|
||||
for _, acc := range accounts {
|
||||
credential := acc.BoseSecret
|
||||
if credential == "" {
|
||||
credential = acc.AccessToken
|
||||
}
|
||||
|
||||
if _, err := marge.AddSource(s.ds, accountID, acc.UserID, strconv.Itoa(constants.SpotifyProviderID), credential, "token_version_3", acc.DisplayName); err != nil {
|
||||
log.Printf("[Spotify Watchdog] Failed to register Spotify source for account %s: %v", accountID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("[Spotify Watchdog] Registered Spotify source %s for account %s (device %s)", acc.UserID, accountID, deviceID)
|
||||
|
||||
registered = true
|
||||
}
|
||||
|
||||
// Tell the speaker its sources list changed so it re-fetches from marge.
|
||||
// Without this its on-device Sources.xml stays stale until something else
|
||||
// triggers a sync — which leaves storePreset failing with
|
||||
// "AddPreset - failed due to invalid SourceID" even though our marge
|
||||
// datastore already has the SPOTIFY entry.
|
||||
if registered && deviceID != "" {
|
||||
c := client.NewClientFromHost(deviceIP)
|
||||
if err := c.NotifySourcesUpdated(deviceID); err != nil {
|
||||
log.Printf("[Spotify Watchdog] sourcesUpdated notification for %s failed: %v", deviceIP, err)
|
||||
} else {
|
||||
log.Printf("[Spotify Watchdog] Notified %s to re-sync sources (deviceID=%s)", deviceIP, deviceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolvePairedAccount returns the device's currently-paired account ID and its
|
||||
// canonical deviceID. It prefers the live :8090/info margeAccountUUID (matches
|
||||
// what the device will actually send on storePreset) and falls back to the
|
||||
// datastore record. Mirrors setup.populateDeviceInfo's resolution order so
|
||||
// priming and migration agree on which account a device belongs to.
|
||||
//
|
||||
// deviceIP is the original input (may carry a :port for tests); host is the
|
||||
// bare host for datastore IPAddress matching.
|
||||
func (s *Server) resolvePairedAccount(deviceIP, host string) (accountID, deviceID string) {
|
||||
if devInfo := s.findExistingDeviceInfoByIP(host); devInfo != nil {
|
||||
accountID = devInfo.AccountID
|
||||
deviceID = devInfo.DeviceID
|
||||
}
|
||||
|
||||
if s.sm != nil {
|
||||
if info, err := s.sm.GetLiveDeviceInfo(deviceIP); err == nil {
|
||||
if info.MargeAccountUUID != "" {
|
||||
accountID = info.MargeAccountUUID
|
||||
}
|
||||
|
||||
if info.DeviceID != "" {
|
||||
deviceID = info.DeviceID
|
||||
}
|
||||
} else {
|
||||
log.Printf("[Spotify Watchdog] live /info lookup for %s failed: %v (falling back to datastore account=%q)", deviceIP, err, accountID)
|
||||
}
|
||||
}
|
||||
|
||||
return accountID, deviceID
|
||||
}
|
||||
|
||||
// findExistingDeviceInfoByIP looks up a device record by IP address across all accounts.
|
||||
func (s *Server) findExistingDeviceInfoByIP(ip string) *models.ServiceDeviceInfo {
|
||||
allDevices, err := s.ds.ListAllDevices()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := range allDevices {
|
||||
if allDevices[i].IPAddress == ip {
|
||||
return &allDevices[i]
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) pushSpotifyTokenToDevice(deviceIP, username, accessToken string) error {
|
||||
var zcURL string
|
||||
if _, _, err := net.SplitHostPort(deviceIP); err == nil {
|
||||
@@ -701,7 +816,11 @@ func (s *Server) PrimeDeviceWithAmazon(deviceIP string) {
|
||||
log.Printf("[Amazon Watchdog] Proactively priming %s with Amazon user %s", deviceIP, username)
|
||||
|
||||
if err := s.pushAmazonTokenToDevice(deviceIP, username, accessToken); err != nil {
|
||||
log.Printf("[Amazon Watchdog] Failed to prime %s: %v", deviceIP, err)
|
||||
if errors.Is(err, amazon.ErrAddUserNoOp) {
|
||||
log.Printf("[Amazon Watchdog] Successfully primed %s (ZeroConf addUser was an expected no-op)", deviceIP)
|
||||
} else {
|
||||
log.Printf("[Amazon Watchdog] Failed to prime %s: %v", deviceIP, err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[Amazon Watchdog] Successfully primed %s", deviceIP)
|
||||
}
|
||||
|
||||
+88
-10
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -1035,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))
|
||||
}
|
||||
@@ -1816,8 +1836,26 @@ func formatRecentResponse(recentObj *models.ServiceRecent, matchingSrc *models.C
|
||||
return append([]byte(header+"\n"), data...)
|
||||
}
|
||||
|
||||
// AddDeviceToAccount adds a new device to the specified account.
|
||||
func AddDeviceToAccount(ds *datastore.DataStore, account string, sourceXML []byte) (string, []byte, error) {
|
||||
// AddDeviceToAccount upserts a device record for the given account.
|
||||
// Called by both the device-create (POST) and device-rename (PUT)
|
||||
// handlers — the persistence layer doesn't distinguish; only the
|
||||
// response status differs.
|
||||
//
|
||||
// remoteAddr is the speaker's address as seen by the HTTP server
|
||||
// (r.RemoteAddr, "host:port"). When the request body doesn't carry
|
||||
// an `<ipaddress>` and the datastore has no IP for this device yet,
|
||||
// we fall back to remoteAddr's host portion. An empty remoteAddr
|
||||
// is treated as "no fallback available" — never errors.
|
||||
//
|
||||
// Timestamps:
|
||||
// - CreatedOn is preserved from any existing datastore record so a
|
||||
// rename doesn't reset the "first paired in 2017" semantics real
|
||||
// Bose emits. New devices get CreatedOn = now() at first save.
|
||||
// - UpdatedOn is set to now() on every call.
|
||||
//
|
||||
// Returns the persisted deviceID and the marge XML response shape
|
||||
// (`<device deviceid="…"><createdOn/><ipaddress/><name/><updatedOn/></device>`).
|
||||
func AddDeviceToAccount(ds *datastore.DataStore, account string, sourceXML []byte, remoteAddr string) (string, []byte, error) {
|
||||
var newDeviceElem struct {
|
||||
DeviceID string `xml:"deviceid,attr"`
|
||||
Name string `xml:"name"`
|
||||
@@ -1827,28 +1865,68 @@ func AddDeviceToAccount(ds *datastore.DataStore, account string, sourceXML []byt
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
now := FormatTime(time.Now())
|
||||
|
||||
// Build the info to save. Empty fields are filled in by the
|
||||
// datastore's mergeWithExistingDeviceInfo (which preserves IP,
|
||||
// MAC, CreatedOn, etc.) before the write — so the precedence
|
||||
// here is "explicit > merged > remoteAddr fallback".
|
||||
info := &models.ServiceDeviceInfo{
|
||||
DeviceID: newDeviceElem.DeviceID,
|
||||
Name: newDeviceElem.Name,
|
||||
MacAddress: newDeviceElem.MACAddress,
|
||||
// Other fields will be filled by discovery later or default
|
||||
UpdatedOn: now,
|
||||
}
|
||||
|
||||
existing, _ := ds.GetDeviceInfo(account, newDeviceElem.DeviceID)
|
||||
|
||||
// CreatedOn: preserve from existing record for renames; set
|
||||
// now() only on first registration (no prior record OR the
|
||||
// record has no CreatedOn — older AfterTouch installs may
|
||||
// have records without one).
|
||||
if existing != nil && existing.CreatedOn != "" {
|
||||
info.CreatedOn = existing.CreatedOn
|
||||
} else {
|
||||
info.CreatedOn = now
|
||||
}
|
||||
|
||||
// IPAddress: prefer existing record's IP (the speaker may be
|
||||
// hitting us through a different network path right now, e.g.
|
||||
// SSH port-forward, and the persisted IP is the one other
|
||||
// flows like DNS hints care about). Fall back to the inbound
|
||||
// connection's remote address only when there's no existing
|
||||
// IP to preserve. Invalid remoteAddr leaves info.IPAddress
|
||||
// empty, which the merge then handles.
|
||||
if existing == nil || existing.IPAddress == "" {
|
||||
if remoteAddr != "" {
|
||||
if host, _, splitErr := net.SplitHostPort(remoteAddr); splitErr == nil {
|
||||
info.IPAddress = host
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := ds.SaveDeviceInfo(account, newDeviceElem.DeviceID, info); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
createdOn := FormatTime(time.Now())
|
||||
res := fmt.Sprintf(`<device deviceid="%s">`, EscapeXML(newDeviceElem.DeviceID))
|
||||
res += fmt.Sprintf(`<createdOn>%s</createdOn>`, EscapeXML(createdOn))
|
||||
res += `<ipaddress></ipaddress>`
|
||||
res += fmt.Sprintf(`<name>%s</name>`, EscapeXML(newDeviceElem.Name))
|
||||
res += fmt.Sprintf(`<updatedOn>%s</updatedOn>`, EscapeXML(createdOn))
|
||||
// Re-read the persisted record so the response XML reflects
|
||||
// the merged state (preserved CreatedOn, preserved IP if the
|
||||
// new info had none and the existing record did, etc.).
|
||||
persisted, err := ds.GetDeviceInfo(account, newDeviceElem.DeviceID)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("re-read persisted device info: %w", err)
|
||||
}
|
||||
|
||||
res := fmt.Sprintf(`<device deviceid="%s">`, EscapeXML(persisted.DeviceID))
|
||||
res += fmt.Sprintf(`<createdOn>%s</createdOn>`, EscapeXML(persisted.CreatedOn))
|
||||
res += fmt.Sprintf(`<ipaddress>%s</ipaddress>`, EscapeXML(persisted.IPAddress))
|
||||
res += fmt.Sprintf(`<name>%s</name>`, EscapeXML(persisted.Name))
|
||||
res += fmt.Sprintf(`<updatedOn>%s</updatedOn>`, EscapeXML(persisted.UpdatedOn))
|
||||
res += `</device>`
|
||||
|
||||
header := constants.XMLHeader
|
||||
|
||||
return newDeviceElem.DeviceID, append([]byte(header), []byte(res)...), nil
|
||||
return persisted.DeviceID, append([]byte(header), []byte(res)...), nil
|
||||
}
|
||||
|
||||
// RemoveDeviceFromAccount removes a device from the specified account.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -2,6 +2,11 @@ package spotify
|
||||
|
||||
import "github.com/gesellix/bose-soundtouch/pkg/service/zeroconf"
|
||||
|
||||
// ErrAddUserNoOp re-exports zeroconf.ErrAddUserNoOp so callers in the spotify
|
||||
// package don't need a direct dependency on the zeroconf package to recognise
|
||||
// the benign-no-op sentinel.
|
||||
var ErrAddUserNoOp = zeroconf.ErrAddUserNoOp
|
||||
|
||||
// ZeroConfGetInfo fetches the speaker's DH public key via GET ?action=getInfo.
|
||||
func ZeroConfGetInfo(zcBaseURL string) ([]byte, error) {
|
||||
return zeroconf.GetInfo(zcBaseURL)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -26,6 +28,15 @@ import (
|
||||
// (AUTHENTICATION_SPOTIFY_TOKEN = 4). Both Spotify and Amazon use this value.
|
||||
const AuthTypeOAuthToken uint64 = 4
|
||||
|
||||
// ErrAddUserNoOp signals a benign 404-with-empty-body reply from the speaker's
|
||||
// ?action=addUser endpoint. SoundTouch firmware uses that exact response shape
|
||||
// to mean "no transition required" — typically because the requested
|
||||
// activeUser is already the active one. It is NOT a credential or transport
|
||||
// failure; the speaker silently kept its current state. Callers that have
|
||||
// already written the authoritative source record to marge (the path
|
||||
// presets/playback actually go through) should treat this as success.
|
||||
var ErrAddUserNoOp = errors.New("zeroconf: addUser no-op (speaker already in target state)")
|
||||
|
||||
// dhPrimeBytes is the 768-bit MODP Group 1 prime from RFC 2409 §6.1.
|
||||
var dhPrimeBytes = []byte{
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
@@ -333,12 +344,56 @@ func PushCredentials(zcBaseURL, username, accessToken string) error {
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if isAddUserNoOp(resp.StatusCode, body) {
|
||||
logAddUserNoOp("DH", base, username, resp)
|
||||
|
||||
return ErrAddUserNoOp
|
||||
}
|
||||
|
||||
logAddUserFailure("DH", base, username, resp, body)
|
||||
|
||||
return fmt.Errorf("pushCredentials: addUser status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isAddUserNoOp recognises the narrow firmware pattern (status 404, empty body)
|
||||
// that signals "no transition required". Anything else — including 404 with a
|
||||
// body, or any other non-2xx — falls through to the real failure path so we
|
||||
// don't silently swallow genuine errors.
|
||||
func isAddUserNoOp(status int, body []byte) bool {
|
||||
return status == http.StatusNotFound && len(bytes.TrimSpace(body)) == 0
|
||||
}
|
||||
|
||||
// logAddUserNoOp emits a single line marking the benign no-op explicitly —
|
||||
// kept visible (not Debug-level) so the operator can correlate it with priming
|
||||
// runs, but worded so it's clearly not a failure.
|
||||
func logAddUserNoOp(path string, base *url.URL, username string, resp *http.Response) {
|
||||
log.Printf("[ZeroConf] addUser produced expected no-op via %s path (speaker already has activeUser=%q or equivalent state): url=%s status=%d body=<empty> — marge source registration is authoritative for preset/playback",
|
||||
path, username, withAction(base, "addUser"), resp.StatusCode)
|
||||
}
|
||||
|
||||
// logAddUserFailure emits a single diagnostic line capturing what the speaker
|
||||
// said about an `?action=addUser` rejection. Bose firmware often returns 4xx
|
||||
// with an empty body, so the headers (libspotify version, content-type,
|
||||
// content-length) are the only clue about whether the speaker refused the
|
||||
// transition, the credential, or the action entirely. Kept verbose on purpose —
|
||||
// these failures are rare and worth grepping for.
|
||||
func logAddUserFailure(path string, base *url.URL, username string, resp *http.Response, body []byte) {
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
cl := resp.Header.Get("Content-Length")
|
||||
server := resp.Header.Get("Server")
|
||||
|
||||
bodySummary := strings.TrimSpace(string(body))
|
||||
if bodySummary == "" {
|
||||
bodySummary = "<empty>"
|
||||
}
|
||||
|
||||
log.Printf("[ZeroConf] addUser rejected via %s path: url=%s userName=%q status=%d server=%q content-type=%q content-length=%q body=%q",
|
||||
path, withAction(base, "addUser"), username, resp.StatusCode, server, ct, cl, bodySummary)
|
||||
}
|
||||
|
||||
// pushSimplifiedToken is the fallback for firmware that does not support DH
|
||||
// key exchange. It sends the raw OAuth access token directly as the blob.
|
||||
func pushSimplifiedToken(zcBaseURL, username, accessToken string) error {
|
||||
@@ -364,6 +419,14 @@ func pushSimplifiedToken(zcBaseURL, username, accessToken string) error {
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if isAddUserNoOp(resp.StatusCode, body) {
|
||||
logAddUserNoOp("simplified", base, username, resp)
|
||||
|
||||
return ErrAddUserNoOp
|
||||
}
|
||||
|
||||
logAddUserFailure("simplified", base, username, resp, body)
|
||||
|
||||
return fmt.Errorf("pushSimplifiedToken: status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package zeroconf
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -363,3 +364,112 @@ func TestValidateZcBaseURL(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPushCredentials_AddUserNoOp covers the firmware quirk we observed in
|
||||
// production: ?action=addUser sometimes returns 404 with an empty body when
|
||||
// the speaker already has the requested user as its active one. That is NOT a
|
||||
// failure — the speaker silently kept its state. PushCredentials must signal
|
||||
// this via ErrAddUserNoOp so the watchdog can demote it from "Failed to prime"
|
||||
// to a benign success.
|
||||
func TestPushCredentials_AddUserNoOp(t *testing.T) {
|
||||
_, speakerPublicBytes, err := GenerateDHKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("speaker keygen: %v", err)
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Query().Get("action") {
|
||||
case "getInfo":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": 101,
|
||||
"publicKey": base64.StdEncoding.EncodeToString(speakerPublicBytes),
|
||||
})
|
||||
case "addUser":
|
||||
// Firmware no-op: 404 + empty body, no Server / Content-Type header.
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err = PushCredentials(srv.URL+"/zc", "gesellix", "fresh-access-token")
|
||||
if !errors.Is(err, ErrAddUserNoOp) {
|
||||
t.Fatalf("PushCredentials: got %v, want ErrAddUserNoOp", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPushCredentials_AddUserNoOpInSimplifiedPath asserts the same narrow
|
||||
// pattern is recognised on the simplified-token fallback (firmware that
|
||||
// 404s getInfo entirely).
|
||||
func TestPushCredentials_AddUserNoOpInSimplifiedPath(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Query().Get("action") {
|
||||
case "getInfo":
|
||||
http.Error(w, "not supported", http.StatusNotFound)
|
||||
case "addUser":
|
||||
w.WriteHeader(http.StatusNotFound) // empty body
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := PushCredentials(srv.URL+"/zc", "gesellix", "raw-access-token")
|
||||
if !errors.Is(err, ErrAddUserNoOp) {
|
||||
t.Fatalf("PushCredentials (simplified path): got %v, want ErrAddUserNoOp", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPushCredentials_AddUserRealError_NotMisclassified guards the narrowness
|
||||
// of isAddUserNoOp: a 404 *with* a body (or any non-404 error) must still
|
||||
// surface as a regular error, not the benign sentinel. Otherwise we'd silently
|
||||
// swallow genuine credential rejections that happen to come back as 4xx.
|
||||
func TestPushCredentials_AddUserRealError_NotMisclassified(t *testing.T) {
|
||||
_, speakerPublicBytes, err := GenerateDHKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("speaker keygen: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
}{
|
||||
{"404 with body should NOT be no-op", http.StatusNotFound, "spotifyError=12 invalid_token"},
|
||||
{"400 empty body should NOT be no-op", http.StatusBadRequest, ""},
|
||||
{"500 empty body should NOT be no-op", http.StatusInternalServerError, ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Query().Get("action") {
|
||||
case "getInfo":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": 101,
|
||||
"publicKey": base64.StdEncoding.EncodeToString(speakerPublicBytes),
|
||||
})
|
||||
case "addUser":
|
||||
w.WriteHeader(tc.status)
|
||||
if tc.body != "" {
|
||||
_, _ = w.Write([]byte(tc.body))
|
||||
}
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := PushCredentials(srv.URL+"/zc", "gesellix", "fresh-access-token")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if errors.Is(err, ErrAddUserNoOp) {
|
||||
t.Errorf("got ErrAddUserNoOp, want a real failure for status=%d body=%q", tc.status, tc.body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
});
|
||||
%}
|
||||
|
||||
@@ -33,6 +33,10 @@ Authorization: Bearer {{token}}
|
||||
|
||||
const ipaddress = device.getElementsByTagName("ipaddress")[0];
|
||||
client.assert(ipaddress !== undefined, "Response body should contain <ipaddress>");
|
||||
|
||||
// Capture the initial createdOn so rename_device.http can
|
||||
// assert it survives a PUT later in the test sequence.
|
||||
client.global.set("initialCreatedOn", createdOn.textContent);
|
||||
});
|
||||
%}
|
||||
|
||||
@@ -61,6 +65,13 @@ Authorization: Bearer {{token}}
|
||||
const createdOn = device.getElementsByTagName("createdOn")[0];
|
||||
client.assert(createdOn !== undefined, "Response body should contain <createdOn>");
|
||||
client.assert(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|(\+\d{2}:\d{2}))$/.test(createdOn.textContent), "createdOn should be a valid ISO8601 timestamp");
|
||||
// This variant POST re-uses the same deviceId as the first
|
||||
// register above, so it's an upsert — issue #285 made the
|
||||
// datastore preserve the original createdOn unconditionally.
|
||||
// The captured global is the load-bearing assertion: if it
|
||||
// ever differs here, CreatedOn preservation regressed.
|
||||
client.assert(createdOn.textContent === client.global.get("initialCreatedOn"),
|
||||
"createdOn (" + createdOn.textContent + ") differs from the value captured by the first POST (" + client.global.get("initialCreatedOn") + ") — upsert must preserve the original timestamp");
|
||||
|
||||
const name = device.getElementsByTagName("name")[0];
|
||||
client.assert(name !== undefined, "Response body should contain <name>");
|
||||
@@ -68,7 +79,7 @@ Authorization: Bearer {{token}}
|
||||
|
||||
const updatedOn = device.getElementsByTagName("updatedOn")[0];
|
||||
client.assert(updatedOn !== undefined, "Response body should contain <updatedOn>");
|
||||
client.assert(updatedOn.textContent === createdOn.textContent, "updatedOn should match createdOn for a new device");
|
||||
client.assert(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|(\+\d{2}:\d{2}))$/.test(updatedOn.textContent), "updatedOn should be a valid ISO8601 timestamp");
|
||||
|
||||
const ipaddress = device.getElementsByTagName("ipaddress")[0];
|
||||
client.assert(ipaddress !== undefined, "Response body should contain <ipaddress>");
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
### PUT /streaming/account/{accountId}/device/{deviceId} (Rename Device)
|
||||
###
|
||||
### Speakers fire this against AfterTouch when the user renames them
|
||||
### via the Bose App or `soundtouch-cli name set`. Before issue #285's
|
||||
### route fix the request fell through to the [UNHANDLED] catch-all
|
||||
### and proxied to streaming.bose.com (401), so the speaker retried
|
||||
### in a loop and the Bose App showed the rename spinning forever.
|
||||
### This test runs after register_device.http (which creates the
|
||||
### initial record) and after power_on.http (which seeds the IP at
|
||||
### 192.168.1.100). The rename PUT must:
|
||||
###
|
||||
### - return 200 OK
|
||||
### - echo the new name
|
||||
### - preserve <createdOn> from the initial registration
|
||||
### - preserve <ipaddress> from the power_on update (request body
|
||||
### doesn't carry one; the datastore merge keeps the existing)
|
||||
### - refresh <updatedOn> to "now"
|
||||
PUT {{host}}/streaming/account/{{accountId}}/device/{{deviceId}}
|
||||
Content-Type: application/vnd.bose.streaming-v1.2+xml
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<device deviceid="{{deviceId}}">
|
||||
<name>{{deviceName}} (renamed)</name>
|
||||
<macaddress>{{macAddress1}}</macaddress>
|
||||
</device>
|
||||
|
||||
> {%
|
||||
client.test("Rename returned 200 OK", function() {
|
||||
client.assert(response.status === 200, "Response status should be 200, got " + response.status);
|
||||
client.assert(response.contentType.mimeType === "application/vnd.bose.streaming-v1.2+xml", "Response Content-Type should be application/vnd.bose.streaming-v1.2+xml");
|
||||
});
|
||||
|
||||
client.test("Response carries the new name and matches the deviceId", function() {
|
||||
const doc = response.body;
|
||||
const device = doc.getElementsByTagName("device")[0];
|
||||
client.assert(device !== undefined, "Response body should contain <device>");
|
||||
client.assert(device.getAttribute("deviceid") === client.variables.environment.get("deviceId"), "Response deviceid should match");
|
||||
|
||||
const name = device.getElementsByTagName("name")[0];
|
||||
client.assert(name !== undefined, "Response body should contain <name>");
|
||||
client.assert(name.textContent === client.variables.environment.get("deviceName") + " (renamed)", "Response should carry the renamed value");
|
||||
});
|
||||
|
||||
client.test("createdOn preserved across rename, updatedOn refreshed", function() {
|
||||
const device = response.body.getElementsByTagName("device")[0];
|
||||
|
||||
const createdOn = device.getElementsByTagName("createdOn")[0];
|
||||
client.assert(createdOn !== undefined, "Response body should contain <createdOn>");
|
||||
client.assert(createdOn.textContent.length > 0, "createdOn should not be empty (it must survive the rename, not be reset)");
|
||||
client.assert(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|(\+\d{2}:\d{2}))$/.test(createdOn.textContent), "createdOn should be ISO8601");
|
||||
|
||||
// The load-bearing assertion: the value must match what
|
||||
// register_device.http captured on the initial POST. If it
|
||||
// doesn't, the rename PUT regressed the "first-paired in
|
||||
// 2017" semantics — the exact bug behind the parity capture
|
||||
// at data/parity_mismatches/1771797308__streaming_account_3230304_device_A81B6A536A98.json.
|
||||
const initial = client.global.get("initialCreatedOn");
|
||||
client.assert(initial !== undefined && initial.length > 0,
|
||||
"initialCreatedOn was not captured by register_device.http — test ordering issue");
|
||||
client.assert(createdOn.textContent === initial,
|
||||
"createdOn (" + createdOn.textContent + ") differs from the value captured at registration (" + initial + ") — the rename PUT must preserve the original timestamp");
|
||||
|
||||
const updatedOn = device.getElementsByTagName("updatedOn")[0];
|
||||
client.assert(updatedOn !== undefined, "Response body should contain <updatedOn>");
|
||||
client.assert(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|(\+\d{2}:\d{2}))$/.test(updatedOn.textContent), "updatedOn should be ISO8601");
|
||||
});
|
||||
|
||||
client.test("ipaddress preserved from earlier power_on payload", function() {
|
||||
const device = response.body.getElementsByTagName("device")[0];
|
||||
const ipaddress = device.getElementsByTagName("ipaddress")[0];
|
||||
client.assert(ipaddress !== undefined, "Response body should contain <ipaddress>");
|
||||
client.assert(ipaddress.textContent === client.variables.environment.get("deviceIp"),
|
||||
"ipaddress should be preserved from the prior power_on (" + client.variables.environment.get("deviceIp") + "), not overwritten — got " + ipaddress.textContent);
|
||||
});
|
||||
%}
|
||||
|
||||
### PUT with mismatched deviceid in body (safety check)
|
||||
###
|
||||
### If the body's deviceid attribute doesn't match the URL's {device}
|
||||
### segment the handler refuses with 400 rather than silently re-key
|
||||
### the persisted record under the wrong account/device. Pins the
|
||||
### second TestIssue285_* unit-test assertion at the integration
|
||||
### layer.
|
||||
PUT {{host}}/streaming/account/{{accountId}}/device/{{deviceId}}
|
||||
Content-Type: application/vnd.bose.streaming-v1.2+xml
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<device deviceid="DEADBEEFCAFE">
|
||||
<name>Rogue Rename</name>
|
||||
<macaddress>DEADBEEFCAFE</macaddress>
|
||||
</device>
|
||||
|
||||
> {%
|
||||
client.test("Mismatched body deviceid returns 400", function() {
|
||||
client.assert(response.status === 400, "Response status should be 400 for body/url deviceid mismatch, got " + response.status);
|
||||
});
|
||||
%}
|
||||
Reference in New Issue
Block a user