Compare commits

...
11 Commits
Author SHA1 Message Date
Tobias GesellchenandClaude Opus 4.7 c668c732df fix(#308): handle placeholder presets without panicking
The ST10's /presets response after a factory reset emits self-closing
<preset/> entries with no ContentItem child. cmd/soundtouch-cli's
getPresets() handled the missing ContentItem in GetDisplayName() but
then dereferenced preset.ContentItem.Source on the next line, panicking
with "invalid memory address or nil pointer dereference" the moment the
loop reached the first empty entry.

A second placeholder shape was observed on healthy devices that were
never reset: <preset id="0"><ContentItem source="INVALID_SOURCE"
isPresetable="true"/></preset>. ContentItem is non-nil here, so the
previous "ContentItem != nil" guard at other call sites still let
these placeholders through into listings and into the AfterTouch
datastore.

Fix shape:

  pkg/models/presets.go - extend Preset.IsEmpty() to recognise both
  shapes (ContentItem == nil, OR Source == "" / "INVALID_SOURCE").
  HasPresets, GetEmptyPresetSlots and GetUsedPresetSlots become honest
  about which slots actually carry playable content.

  cmd/soundtouch-cli/cmd_info.go (the crash site) - filter the slice
  via IsEmpty before the print loop, and switch the still-printed
  fields to the existing nil-safe Get* helpers.

  pkg/service/setup/setup.go - upgrade syncPresets's "ContentItem ==
  nil" continue-guard to IsEmpty so Shape B placeholders don't get
  persisted in the AfterTouch datastore and then surface as junk
  rows in the admin web UI.

  cmd/soundtouch-cli/cmd_events.go, cmd/websocket-demo/main.go - same
  nil-guard upgrade. These already nil-checked so were crash-safe;
  the change is for consistency and to stop printing
  "Preset 0:  (INVALID_SOURCE)" demo lines.

  examples/preset-management/main.go - had the same latent crash as
  cmd_info.go; same fix shape.

Regression tests in pkg/models/presets_test.go cover both shapes using
the exact XML observed in the wild: the reporter's three <preset/>
placeholders plus the three INVALID_SOURCE entries from a live device.
The reporter XML test walks every preset through the same accessor
path the CLI used and asserts no panic.

The soundtouch-web Go code does not deref preset.ContentItem.X
anywhere - presets flow through as JSON - so no separate crash trap
exists there. The web frontend will pick up the cleaner data once
syncPresets stops persisting placeholders.

Closes #308

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:36:55 +02:00
Tobias GesellchenandClaude Opus 4.7 4e7a20f7ec refactor(soundtouch-web): make DeviceConnection.Status atomically swappable
Status was a value-typed DeviceStatus field on DeviceConnection,
written from the periodic poller (UpdateDeviceStatus) and from four
WebSocket event handlers (OnNowPlaying, OnVolumeUpdated,
OnConnectionState, OnPresetUpdated) while being read from every HTTP
handler and the WebSocket broadcaster. The struct was 8+ words wide
with time.Time and string members, so concurrent readers could
observe torn fields or mixed-update snapshots. The map-level race was
fixed in the previous commit; this one closes the per-connection
struct race.

Hide the field behind atomic.Pointer[DeviceStatus]:

  Status()                                 // returns current snapshot
  SetStatus(*DeviceStatus)                 // wholesale replace
  UpdateStatus(func(*DeviceStatus))        // CAS retry loop

NewDeviceConnection constructs a connection with the atomic pointer
pre-initialised, so Status() never returns nil for callers that go
through the constructor (the old struct-literal pattern is no longer
possible because the status field is now private).

UpdateDeviceStatus runs network fetches into local vars first, then
batches them into a single UpdateStatus call so the CAS loop only
retries the merge — not the slow IO. WebSocket event handlers and
the connect/disconnect transitions each use UpdateStatus, so any
ordering of poller + event delivery converges to a consistent
status.

The UpdateStatus docstring is explicit about the shallow-copy
contract: nested pointer fields (NowPlaying, Volume, Bass, Presets,
Sources) MUST be replaced, not mutated through, because the copy
mut receives shares those pointers with the prior snapshot. All
production callers already follow this pattern (every value comes
fresh from the device API).

Tests:
  - types_test.go: migrated literal struct to NewDeviceConnection +
    SetStatus, switched reads to Status().
  - status_test.go (new): six tests covering constructor init,
    SetStatus replacement semantics, UpdateStatus mutator
    application, field preservation across UpdateStatus, snapshot
    isolation (old snapshot stable under later writes), and a
    concurrent stress test (16 writers + 32 readers x 200 ops) that
    runs under -race.
  - handlers_test.go, registry_test.go, spa_test.go: migrated to
    constructor.

Not addressed by this commit:
  - DeviceConnection.WebSocket (set once in ConnectDeviceWebSocket,
    read elsewhere). Word-sized pointer, atomic at the hardware
    level on amd64/arm64; race detector may still flag.
  - DeviceConnection.LastSeen (written under devicesMu by the
    registry, read outside that lock via DeviceSnapshot consumers).
    time.Time is non-atomic but the read is cosmetic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:14:17 +02:00
Tobias GesellchenandClaude Opus 4.7 e7d1b44587 refactor(soundtouch-web): encapsulate WebApp device registry behind methods
The Devices map on WebApp was written from the startup goroutine, the
/api/discover POST handler, and addDevice, while being read from every
HTTP handler and the WebSocket periodic-update loop — all without any
mutex. The Go runtime panics with "fatal error: concurrent map writes"
or "concurrent map read and map write" on any actual collision, so this
was a latent crash, not a tearing issue.

Hide the map behind a sync.RWMutex and a small API:

  GetDevice(id) (*DeviceConnection, bool)
  DeviceSnapshot() []DeviceEntry
  DeviceCount() int
  AddDevice(id, conn) bool        // atomic insert-or-touch
  TouchDevice(id) bool            // fast-path LastSeen bump

Update every caller — handlers, websocket, main, tests — to go through
the API. addDevice's existing-host fast path uses TouchDevice; the
final insert uses AddDevice so a race with another writer is rejected
cleanly instead of silently overwriting.

Add a TestRegistryConcurrent stress test that runs 64 goroutines doing
12,800 operations across writers, touchers, and two reader patterns.
It exists to give `-race` (already on in CI) a concrete shape to catch
if the encapsulation ever leaks back out.

Struct-field races on conn.Status.* are not addressed by this change;
they need their own follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:14:17 +02:00
Tobias GesellchenandClaude Opus 4.7 2c50ce3ee8 refactor(soundtouch-web): unify manual and discovered device registration
`addManualDevice` and the per-device branch of `discoverDevices` were
~40 lines of near-identical client setup, info fetch, connection
build, and map write — differing only in log wording. Extract a
shared `addDevice(app, host, port, source)` helper used by both
paths.

Side effects of consolidating:

- Duplicate-host guard (LastSeen bump) now applies to both paths, so
  passing `--devices 1.2.3.4` twice is idempotent and matches how
  discovery treats repeat sightings.
- Map write happens before the UpdateDeviceStatus goroutine launch,
  so a concurrent GET /api/devices sees the device with
  `IsConnected: false` instead of racing the status update.
- Log wording is consistent: "Failed to fetch device info from <host>
  (<source>): <err>" and "Added <source> device <name> (<type>) at
  <host>:<port>".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:14:17 +02:00
Tobias Gesellchen 1269481411 lint 2026-05-17 10:30:25 +02:00
chrizg 712801259e feat(soundtouch-web): rename --host to --devices, support multiple devices via StringSliceFlag 2026-05-17 10:30:25 +02:00
chrizg 46546f5494 feat(soundtouch-web): add --host flag for manual device IP 2026-05-17 10:30:25 +02:00
chris 6d462191d9 docs: add SoundTouch 30 factory reset sequence (#305)
## Description

Add missing factory reset sequence for SoundTouch 30 (non-Series III).
The current table only lists SoundTouch 30 Series III. The SoundTouch 30
uses a different sequence: power on, then hold Preset 1 + Volume − for
10 s. The display counts down from 10 to 1 and shows "Hold to restore
factory settings" before restarting.

## Type of Change

Please check the type of change your PR introduces:

- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to not work as expected)
- [x] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
- [ ] Test improvements
- [ ] Build/CI improvements

## Related Issues

## Changes Made

### API Changes
- [ ] Added new endpoints
- [ ] Modified existing endpoints
- [ ] Added new CLI commands
- [ ] Modified existing CLI commands
- [ ] Added new configuration options

### Implementation Details
- Added missing table row for SoundTouch 30 (non-Series III) in the
factory reset sequences table. No new dependencies.

## Testing

### Automated Tests
- [ ] Unit tests added/updated
- [ ] Integration tests added/updated
- [ ] All existing tests pass
- [ ] Test coverage maintained or improved

### Manual Testing
- [x] Tested with real SoundTouch device(s)
- [ ] Tested CLI changes manually
- [ ] Tested in different network environments

**Device(s) tested with:**
- Device model: SoundTouch 30
- Firmware: 27.0.6.46330
- Test results: Factory reset sequence verified on real device

### Test Commands

## Documentation

- [ ] Updated relevant documentation
- [ ] Added code comments for complex logic
- [ ] Updated CLI help text
- [ ] Added usage examples
- [ ] Updated API documentation

**Documentation files updated:**
- [ ] README.md
- [ ] docs/API-Endpoints-Overview.md
- [ ] docs/CLI-REFERENCE.md
- [ ] Code documentation (godoc)

docs/DEVICE-INITIAL-SETUP.md

## Backward Compatibility

- [x] This change is backward compatible
- [ ] This change includes breaking changes (requires major version
bump)
- [ ] This change requires configuration migration

**Breaking changes (if any):**

## Security Considerations

- [x] No security implications
- [ ] Security review required
- [ ] Added input validation
- [ ] Updated authentication/authorization

## Performance Impact

- [x] No performance impact
- [ ] Performance improvement
- [ ] Potential performance regression (justify why)

**Performance notes:**

## Code Quality

- [ ] Code follows project style guidelines
- [ ] No linting errors
- [ ] No security warnings
- [ ] Memory leaks checked (if applicable)

### Pre-submission Checklist

- [ ] `make check` passes (format, lint, vet)
- [ ] `make test` passes
- [ ] No TODO comments left in production code
- [ ] Error handling is comprehensive
- [ ] Logging is appropriate (not too verbose, not too quiet)

## Deployment Notes

## Screenshots (if applicable)

## Additional Notes

## Review Requests
2026-05-17 10:15:10 +02:00
Tobias GesellchenandClaude Opus 4.7 f3c974cbbd docs(troubleshooting): capture three recurring symptoms from issues #224 #235 #253
Add three new entries to docs/guides/TROUBLESHOOTING.md so the next
reporter who hits these symptoms finds the answer without needing the
issue thread.

- "Every cloud source shows status=UNAVAILABLE / can't stream anything"
  (Connection Issues). Three-step diagnostic checklist: :443
  reachability preflight, margeAccountUUID check, filtered
  `logread -f`. Distilled from the diagnostic ping on #224 plus
  Thatboioofy's resolution (missing margeAccountUUID was the cause).
  Sidebar clarifies that the firmware-internal placeholder sources
  (SpotifyConnectUserName, SpotifyAlexaUserName, UPnPUserName,
  StoredMusicUserName, QPlay{1,2}UserName, AirPlay2DefaultUserName)
  are speaker-synthesized and their UNAVAILABLE status is never an
  AfterTouch problem on its own.

- New section "Music Service & Preset Issues" with "Spotify preset
  fails with 'Current content cannot be saved as preset'". Explains
  the firmware-side isPresetable="false" gate on Connect-pushed
  playback (foob61451's NowPlaying capture in #235), why an
  OAuth-linked account flips it to true, and cross-links to
  MUSIC-SERVICES.md and the new spotify-overview.md.

- "TuneIn (or Internet Radio) missing from /sources after a factory
  reset". TuneIn is not a default source; the speaker only registers
  it after first play. Captured from the #253 side-thread with both
  app and `soundtouch-cli source content` recipes plus the
  no-SSH caveat for newer hardware (SA-5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:12:28 +02:00
Tobias GesellchenandClaude Opus 4.7 862c1caca2 docs: render mermaid diagrams on the GitHub Pages site
spotify-oauth.md (and any future docs) embed mermaid sequence/flow
diagrams as fenced code blocks. Kramdown emits those as
<pre><code class="language-mermaid">, which is not what Mermaid's
auto-renderer looks for, so on the rendered site they show up as raw
code instead of diagrams.

Add docs/_includes/head-custom.html (a hook the pages-themes/minimal
remote theme already exposes) to load Mermaid 11 as an ES module from
jsDelivr, rewrite pre/code.language-mermaid nodes into div.mermaid, and
call mermaid.run() once.

No Jekyll plugin or _config.yml change needed — the include slot is
honoured by the remote theme as-is.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:54:39 +02:00
Tobias GesellchenandClaude Opus 4.7 b0d7e8aae2 feat(spotify): wire preset storage end-to-end via server-centric priming (#302)
storePreset on the speaker was failing with "AddPreset - failed due to
invalid SourceID" because the watchdog priming path only pushed ZeroConf
credentials and never registered a SPOTIFY ConfiguredSource in marge.

PrimeDeviceWithSpotify now:
- resolves the device's paired account via live :8090/info
(margeAccountUUID), falling back to ServiceDeviceInfo.AccountID — same
order as setup.populateDeviceInfo;
- writes a SPOTIFY ConfiguredSource under that account (providerID=15,
BoseSecret as credential), mirroring bridgeSpotifyToMarge;
- POSTs `<updates><sourcesUpdated/></updates>` so the speaker re-fetches
its on-device Sources.xml from marge.

Also introduce zeroconf.ErrAddUserNoOp for the narrow firmware quirk
(404 + empty body on ?action=addUser when activeUser already matches).
Recognised only on that exact pattern; real 4xx/5xx still surface loudly
with full response details. Same treatment applied to Amazon priming.

Docs:
- new docs/concepts/spotify-overview.md anchors the topic (mental model,
streamingoauth.bose.com DNS gotcha, token lifecycle, clientId notes,
troubleshooting table);
- spotify-oauth.md drops the removed install-primer endpoint and the
on-device boot-primer install sections, adds /mgmt/spotify/prime;
- spotify-priming-strategy.md and MUSIC-SERVICES.md link to the
overview.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:47:57 +02:00
29 changed files with 2040 additions and 228 deletions
+5 -3
View File
@@ -413,9 +413,11 @@ func handlePresetEvent(event *models.PresetUpdatedEvent, verbose bool) {
for _, preset := range presets.Preset {
fmt.Printf(" 📻 Preset %d:", preset.ID)
if preset.ContentItem != nil {
fmt.Printf(" %s", preset.ContentItem.ItemName)
fmt.Printf(" (%s)", preset.ContentItem.Source)
// IsEmpty catches both <preset/> and INVALID_SOURCE
// placeholders; using the nil-safe helpers below means the
// inner Printf never dereferences a nil ContentItem.
if !preset.IsEmpty() {
fmt.Printf(" %s (%s)", preset.GetDisplayName(), preset.GetSource())
}
fmt.Println()
+20 -7
View File
@@ -177,23 +177,36 @@ func getPresets(c *cli.Context) error {
fmt.Printf("Device Presets:\n")
if len(presets.Preset) == 0 {
// Filter out placeholder presets the firmware emits for unconfigured
// slots (issue #308): self-closing <preset/> after factory reset,
// or <ContentItem source="INVALID_SOURCE"/> on healthy devices.
// IsEmpty covers both shapes; accessing fields like ContentItem.Source
// directly on the first shape panics.
configured := make([]models.Preset, 0, len(presets.Preset))
for _, p := range presets.Preset {
if !p.IsEmpty() {
configured = append(configured, p)
}
}
if len(configured) == 0 {
fmt.Printf(" No presets configured\n")
return nil
}
fmt.Printf(" Configured Presets:\n")
for _, preset := range presets.Preset {
for _, preset := range configured {
fmt.Printf(" %d. %s\n", preset.ID, preset.GetDisplayName())
fmt.Printf(" Source: %s\n", preset.ContentItem.Source)
fmt.Printf(" Source: %s\n", preset.GetSource())
if preset.ContentItem.SourceAccount != "" && preset.ContentItem.SourceAccount != preset.ContentItem.Source {
fmt.Printf(" Account: %s\n", preset.ContentItem.SourceAccount)
if account := preset.GetSourceAccount(); account != "" && account != preset.GetSource() {
fmt.Printf(" Account: %s\n", account)
}
if preset.ContentItem.Location != "" {
fmt.Printf(" Location: %s\n", preset.ContentItem.Location)
if location := preset.GetLocation(); location != "" {
fmt.Printf(" Location: %s\n", location)
}
// Show preset creation time if available
+114 -23
View File
@@ -9,6 +9,7 @@ import (
"strconv"
"strings"
"sync"
"time"
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
"github.com/gesellix/bose-soundtouch/pkg/models"
@@ -17,18 +18,34 @@ import (
"github.com/gorilla/websocket"
)
// WebApp holds the application state and dependencies
// WebApp holds the application state and dependencies.
//
// The device registry (devices map + devicesMu) is encapsulated:
// callers go through GetDevice / DeviceSnapshot / AddDevice /
// TouchDevice / DeviceCount instead of touching the map directly.
// This prevents the concurrent-map-read/write panic that would
// otherwise be reachable any time an HTTP handler runs while
// discovery or the /api/discover endpoint is registering devices.
type WebApp struct {
Devices map[string]*webtypes.DeviceConnection
devicesMu sync.RWMutex
devices map[string]*webtypes.DeviceConnection
Upgrader websocket.Upgrader
WSClients map[*websocket.Conn]bool
WSMutex sync.RWMutex
}
// DeviceEntry pairs a device id with its connection. Used by
// DeviceSnapshot so callers can iterate without holding the lock.
type DeviceEntry struct {
ID string
Device *webtypes.DeviceConnection
}
// NewWebApp creates a new WebApp instance for SPA mode
func NewWebApp() *WebApp {
return &WebApp{
Devices: make(map[string]*webtypes.DeviceConnection),
devices: make(map[string]*webtypes.DeviceConnection),
WSClients: make(map[*websocket.Conn]bool),
Upgrader: websocket.Upgrader{
CheckOrigin: func(_ *http.Request) bool { return true },
@@ -36,17 +53,89 @@ func NewWebApp() *WebApp {
}
}
// GetDevice returns the device for id and whether it exists.
func (app *WebApp) GetDevice(id string) (*webtypes.DeviceConnection, bool) {
app.devicesMu.RLock()
defer app.devicesMu.RUnlock()
device, ok := app.devices[id]
return device, ok
}
// DeviceSnapshot returns a list of (id, *DeviceConnection) pairs taken
// under a single read lock. Callers can iterate the result without
// holding any registry lock. Devices added or removed after the call
// are not reflected; the pointers themselves remain valid because
// nothing deletes from the underlying map today.
func (app *WebApp) DeviceSnapshot() []DeviceEntry {
app.devicesMu.RLock()
defer app.devicesMu.RUnlock()
out := make([]DeviceEntry, 0, len(app.devices))
for id, device := range app.devices {
out = append(out, DeviceEntry{ID: id, Device: device})
}
return out
}
// DeviceCount returns the number of registered devices at call time.
func (app *WebApp) DeviceCount() int {
app.devicesMu.RLock()
defer app.devicesMu.RUnlock()
return len(app.devices)
}
// AddDevice atomically registers conn under id when id is not already
// known. If id existed, its LastSeen is bumped and AddDevice returns
// false (the caller should discard conn). Returns true if conn was
// inserted.
func (app *WebApp) AddDevice(id string, conn *webtypes.DeviceConnection) bool {
app.devicesMu.Lock()
defer app.devicesMu.Unlock()
if existing, ok := app.devices[id]; ok {
existing.LastSeen = time.Now()
return false
}
app.devices[id] = conn
return true
}
// TouchDevice bumps LastSeen for id if it exists; returns true if
// found. Use this as a fast-path check before doing the network work
// needed to construct a new DeviceConnection.
func (app *WebApp) TouchDevice(id string) bool {
app.devicesMu.Lock()
defer app.devicesMu.Unlock()
existing, ok := app.devices[id]
if !ok {
return false
}
existing.LastSeen = time.Now()
return true
}
// HandleAPIDevices returns all devices as JSON
func (app *WebApp) HandleAPIDevices(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
// Return all devices as JSON
devices := make(map[string]interface{})
for id, device := range app.Devices {
devices[id] = map[string]interface{}{
"info": device.DeviceInfo,
"status": device.Status,
"lastSeen": device.LastSeen,
snapshot := app.DeviceSnapshot()
devices := make(map[string]interface{}, len(snapshot))
for _, entry := range snapshot {
devices[entry.ID] = map[string]interface{}{
"info": entry.Device.DeviceInfo,
"status": entry.Device.Status(),
"lastSeen": entry.Device.LastSeen,
}
}
@@ -68,7 +157,7 @@ func (app *WebApp) HandleAPIDevice(w http.ResponseWriter, r *http.Request) {
return
}
device, exists := app.Devices[deviceID]
device, exists := app.GetDevice(deviceID)
if !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
@@ -88,7 +177,7 @@ func (app *WebApp) HandleAPIDevice(w http.ResponseWriter, r *http.Request) {
Success: true,
Data: map[string]interface{}{
"info": device.DeviceInfo,
"status": device.Status,
"status": device.Status(),
},
}
@@ -107,7 +196,7 @@ func (app *WebApp) HandleAPIControl(w http.ResponseWriter, r *http.Request) {
return
}
device, exists := app.Devices[deviceID]
device, exists := app.GetDevice(deviceID)
if !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
@@ -318,7 +407,7 @@ func (app *WebApp) HandleDeviceKey(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
key := chi.URLParam(r, "key")
device, exists := app.Devices[deviceID]
device, exists := app.GetDevice(deviceID)
if !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
@@ -350,7 +439,7 @@ func (app *WebApp) HandleDirectVolumeControl(w http.ResponseWriter, r *http.Requ
return
}
device, exists := app.Devices[deviceID]
device, exists := app.GetDevice(deviceID)
if !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
@@ -376,7 +465,7 @@ func (app *WebApp) HandleDirectVolumeControl(w http.ResponseWriter, r *http.Requ
func (app *WebApp) HandleDevicePower(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
device, exists := app.Devices[deviceID]
device, exists := app.GetDevice(deviceID)
if !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
@@ -403,7 +492,7 @@ func (app *WebApp) HandleDevicePower(w http.ResponseWriter, r *http.Request) {
func (app *WebApp) HandleDevicePowerStatus(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
device, exists := app.Devices[deviceID]
device, exists := app.GetDevice(deviceID)
if !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
@@ -444,12 +533,14 @@ func (app *WebApp) BroadcastDeviceList() {
app.WSMutex.RLock()
defer app.WSMutex.RUnlock()
devices := make(map[string]interface{})
for id, device := range app.Devices {
devices[id] = map[string]interface{}{
"info": device.DeviceInfo,
"status": device.Status,
"lastSeen": device.LastSeen,
snapshot := app.DeviceSnapshot()
devices := make(map[string]interface{}, len(snapshot))
for _, entry := range snapshot {
devices[entry.ID] = map[string]interface{}{
"info": entry.Device.DeviceInfo,
"status": entry.Device.Status(),
"lastSeen": entry.Device.LastSeen,
}
}
@@ -598,7 +689,7 @@ func (app *WebApp) HandlePlayTuneIn(w http.ResponseWriter, r *http.Request) {
return
}
device, exists := app.Devices[deviceID]
device, exists := app.GetDevice(deviceID)
if !exists {
app.sendError(w, fmt.Sprintf("Device '%s' not found", deviceID), http.StatusNotFound)
return
+13 -23
View File
@@ -28,19 +28,15 @@ func createTestApp() *WebApp {
},
}
device := &webtypes.DeviceConnection{
Client: nil, // No real client for unit tests
DeviceInfo: deviceInfo,
LastSeen: time.Now(),
Status: webtypes.DeviceStatus{
Volume: &models.Volume{ActualVolume: 50, MuteEnabled: false},
Bass: &models.Bass{ActualBass: 0},
IsConnected: true,
LastActivity: time.Now(),
},
}
device := webtypes.NewDeviceConnection(nil, deviceInfo)
device.SetStatus(&webtypes.DeviceStatus{
Volume: &models.Volume{ActualVolume: 50, MuteEnabled: false},
Bass: &models.Bass{ActualBass: 0},
IsConnected: true,
LastActivity: time.Now(),
})
app.Devices["test-device"] = device
app.AddDevice("test-device", device)
return app
}
@@ -59,13 +55,9 @@ func TestNewWebApp(t *testing.T) {
if app == nil {
t.Fatal("NewWebApp returned nil")
}
if app.Devices == nil {
t.Fatal("Devices map not initialized")
}
// At this point we know app and app.Devices are not nil
if len(app.Devices) != 0 {
t.Errorf("Expected empty devices map, got %d devices", len(app.Devices))
if count := app.DeviceCount(); count != 0 {
t.Errorf("Expected empty device registry, got %d devices", count)
}
}
@@ -549,11 +541,9 @@ func BenchmarkHandleAPIDevices(b *testing.B) {
// Add more devices for realistic benchmarking
for i := 0; i < 10; i++ {
deviceID := "device-" + string(rune('0'+i))
app.Devices[deviceID] = &webtypes.DeviceConnection{
Client: &client.Client{},
DeviceInfo: &models.DeviceInfo{Name: "Test Device " + deviceID},
Status: webtypes.DeviceStatus{IsConnected: true},
}
conn := webtypes.NewDeviceConnection(&client.Client{}, &models.DeviceInfo{Name: "Test Device " + deviceID})
conn.SetStatus(&webtypes.DeviceStatus{IsConnected: true})
app.AddDevice(deviceID, conn)
}
req := httptest.NewRequest("GET", "/api/devices", nil)
@@ -0,0 +1,194 @@
// Package handlers contains tests for the device registry API on
// WebApp (GetDevice, AddDevice, TouchDevice, DeviceSnapshot,
// DeviceCount).
package handlers
import (
"fmt"
"sync"
"testing"
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
func newRegistryDevice(name string) *webtypes.DeviceConnection {
conn := webtypes.NewDeviceConnection(nil, &models.DeviceInfo{Name: name})
conn.SetStatus(&webtypes.DeviceStatus{IsConnected: true})
return conn
}
func TestAddDevice_Inserts(t *testing.T) {
app := NewWebApp()
conn := newRegistryDevice("first")
if !app.AddDevice("host-1", conn) {
t.Fatal("AddDevice returned false on first insert")
}
got, ok := app.GetDevice("host-1")
if !ok {
t.Fatal("GetDevice did not find the device after AddDevice")
}
if got != conn {
t.Errorf("GetDevice returned a different pointer than inserted")
}
}
func TestAddDevice_RejectsDuplicateAndBumpsLastSeen(t *testing.T) {
app := NewWebApp()
original := newRegistryDevice("first")
app.AddDevice("host-1", original)
originalSeen := original.LastSeen
replacement := newRegistryDevice("second")
if app.AddDevice("host-1", replacement) {
t.Fatal("AddDevice returned true on duplicate; expected false")
}
got, _ := app.GetDevice("host-1")
if got != original {
t.Error("Duplicate AddDevice replaced the existing device pointer")
}
if !got.LastSeen.After(originalSeen) {
t.Error("Duplicate AddDevice did not bump LastSeen on existing device")
}
}
func TestTouchDevice(t *testing.T) {
app := NewWebApp()
if app.TouchDevice("missing") {
t.Error("TouchDevice returned true for unknown id")
}
conn := newRegistryDevice("first")
app.AddDevice("host-1", conn)
seenBefore := conn.LastSeen
if !app.TouchDevice("host-1") {
t.Fatal("TouchDevice returned false for known id")
}
if !conn.LastSeen.After(seenBefore) {
t.Error("TouchDevice did not bump LastSeen")
}
}
func TestDeviceSnapshotAndCount(t *testing.T) {
app := NewWebApp()
if got := app.DeviceCount(); got != 0 {
t.Errorf("DeviceCount on empty app = %d; want 0", got)
}
if snap := app.DeviceSnapshot(); len(snap) != 0 {
t.Errorf("DeviceSnapshot on empty app = %v; want []", snap)
}
for i := 0; i < 5; i++ {
app.AddDevice(fmt.Sprintf("host-%d", i), newRegistryDevice(fmt.Sprintf("n%d", i)))
}
if got := app.DeviceCount(); got != 5 {
t.Errorf("DeviceCount after 5 adds = %d; want 5", got)
}
snap := app.DeviceSnapshot()
if len(snap) != 5 {
t.Errorf("DeviceSnapshot len = %d; want 5", len(snap))
}
// Spot-check that the snapshot ids match what we inserted.
seen := map[string]bool{}
for _, entry := range snap {
seen[entry.ID] = true
}
for i := 0; i < 5; i++ {
id := fmt.Sprintf("host-%d", i)
if !seen[id] {
t.Errorf("DeviceSnapshot missing %s", id)
}
}
}
// TestRegistryConcurrent exercises the registry from many goroutines
// at once. Before the introduction of devicesMu this would either
// panic with "fatal error: concurrent map read and map write" or be
// flagged by the race detector. The test runs under `go test -race`
// in CI so a future regression that re-exposes the underlying map
// without locking would be caught here.
func TestRegistryConcurrent(t *testing.T) {
app := NewWebApp()
const workers = 16
const opsPerWorker = 200
var wg sync.WaitGroup
wg.Add(workers * 4)
// Writers: insert distinct ids across workers.
for w := 0; w < workers; w++ {
go func(worker int) {
defer wg.Done()
for i := 0; i < opsPerWorker; i++ {
id := fmt.Sprintf("w%d-%d", worker, i)
app.AddDevice(id, newRegistryDevice(id))
}
}(w)
}
// Touchers: bump LastSeen on a shared id (which may or may not
// exist yet — both branches are exercised).
for w := 0; w < workers; w++ {
go func() {
defer wg.Done()
for i := 0; i < opsPerWorker; i++ {
app.TouchDevice("shared")
}
}()
}
// Readers via snapshot.
for w := 0; w < workers; w++ {
go func() {
defer wg.Done()
for i := 0; i < opsPerWorker; i++ {
_ = app.DeviceSnapshot()
}
}()
}
// Readers via direct lookup.
for w := 0; w < workers; w++ {
go func() {
defer wg.Done()
for i := 0; i < opsPerWorker; i++ {
_, _ = app.GetDevice("shared")
_ = app.DeviceCount()
}
}()
}
wg.Wait()
// Sanity check: every writer inserted opsPerWorker devices, plus
// the "shared" entry was never AddDevice'd so should be absent.
if got, want := app.DeviceCount(), workers*opsPerWorker; got != want {
t.Errorf("DeviceCount after concurrent inserts = %d; want %d", got, want)
}
if _, ok := app.GetDevice("shared"); ok {
t.Error("shared device should not exist (only TouchDevice was called for it)")
}
}
+92 -61
View File
@@ -35,12 +35,14 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
app.WSMutex.Unlock()
// Send initial device list
devices := make(map[string]interface{})
for id, device := range app.Devices {
devices[id] = map[string]interface{}{
"info": device.DeviceInfo,
"status": device.Status,
"lastSeen": device.LastSeen,
snapshot := app.DeviceSnapshot()
devices := make(map[string]interface{}, len(snapshot))
for _, entry := range snapshot {
devices[entry.ID] = map[string]interface{}{
"info": entry.Device.DeviceInfo,
"status": entry.Device.Status(),
"lastSeen": entry.Device.LastSeen,
}
}
@@ -88,12 +90,13 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
}
// Send periodic status updates
for id, device := range app.Devices {
if device.Status.IsConnected {
for _, entry := range app.DeviceSnapshot() {
status := entry.Device.Status()
if status.IsConnected {
statusMessage := webtypes.WebSocketMessage{
Type: "status_update",
DeviceID: id,
Data: device.Status,
DeviceID: entry.ID,
Data: status,
}
if err := conn.WriteJSON(statusMessage); err != nil {
@@ -134,25 +137,35 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device
wsClient := conn.Client.NewWebSocketClient(nil)
// Setup event handlers
// Setup event handlers. Each handler funnels its change through
// UpdateStatus so concurrent events and the periodic poller
// (UpdateDeviceStatus) cannot lose each other's writes.
wsClient.OnNowPlaying(func(event *models.NowPlayingUpdatedEvent) {
conn.Status.NowPlaying = &event.NowPlaying
conn.Status.LastActivity = time.Now()
conn.UpdateStatus(func(s *webtypes.DeviceStatus) {
s.NowPlaying = &event.NowPlaying
s.LastActivity = time.Now()
})
})
wsClient.OnVolumeUpdated(func(event *models.VolumeUpdatedEvent) {
conn.Status.Volume = &event.Volume
conn.Status.LastActivity = time.Now()
conn.UpdateStatus(func(s *webtypes.DeviceStatus) {
s.Volume = &event.Volume
s.LastActivity = time.Now()
})
})
wsClient.OnConnectionState(func(event *models.ConnectionStateUpdatedEvent) {
conn.Status.IsConnected = event.ConnectionState.IsConnected()
conn.Status.LastActivity = time.Now()
conn.UpdateStatus(func(s *webtypes.DeviceStatus) {
s.IsConnected = event.ConnectionState.IsConnected()
s.LastActivity = time.Now()
})
})
wsClient.OnPresetUpdated(func(event *models.PresetUpdatedEvent) {
conn.Status.Presets = &event.Presets
conn.Status.LastActivity = time.Now()
conn.UpdateStatus(func(s *webtypes.DeviceStatus) {
s.Presets = &event.Presets
s.LastActivity = time.Now()
})
})
// Connect WebSocket
@@ -162,64 +175,81 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device
}
conn.WebSocket = wsClient
conn.Status.IsConnected = true
conn.UpdateStatus(func(s *webtypes.DeviceStatus) {
s.IsConnected = true
})
log.Printf("WebSocket connected for device %s", deviceID)
// Wait for disconnection
wsClient.Wait()
conn.Status.IsConnected = false
conn.UpdateStatus(func(s *webtypes.DeviceStatus) {
s.IsConnected = false
})
log.Printf("WebSocket disconnected for device %s", deviceID)
}
// UpdateDeviceStatus fetches current status from device
// UpdateDeviceStatus fetches current status from the device.
//
// Network calls run outside the atomic merge so the CAS loop in
// UpdateStatus stays fast and doesn't retry slow IO. WebSocket event
// handlers running concurrently are not lost: their UpdateStatus
// runs against whichever snapshot they observe, and the merge below
// sees their changes when it CAS-loops onto the latest status.
func (app *WebApp) UpdateDeviceStatus(_ string, conn *webtypes.DeviceConnection) {
// Skip status update if client is not available (e.g., in tests)
if conn.Client == nil {
return
}
statusUpdated := false
// Phase 1: slow network fetches. Local vars only, no shared state
// is touched yet. Errors are recorded so the merge below can tell
// "field N stayed unchanged" apart from "field N got refreshed".
nowPlaying, nowPlayingErr := conn.Client.GetNowPlaying()
volume, volumeErr := conn.Client.GetVolume()
presets, presetsErr := conn.Client.GetPresets()
sources, sourcesErr := conn.Client.GetSources()
bass, bassErr := conn.Client.GetBass()
// Get current now playing
if nowPlaying, err := conn.Client.GetNowPlaying(); err == nil {
conn.Status.NowPlaying = nowPlaying
statusUpdated = true
}
// Phase 2: fast merge. Only fields we successfully fetched
// overwrite; everything else keeps the value other goroutines may
// have just written.
conn.UpdateStatus(func(s *webtypes.DeviceStatus) {
statusUpdated := false
// Get current volume
if volume, err := conn.Client.GetVolume(); err == nil {
conn.Status.Volume = volume
statusUpdated = true
}
if nowPlayingErr == nil {
s.NowPlaying = nowPlaying
statusUpdated = true
}
// Get presets
if presets, err := conn.Client.GetPresets(); err == nil {
conn.Status.Presets = presets
statusUpdated = true
}
if volumeErr == nil {
s.Volume = volume
statusUpdated = true
}
// Update last activity if any status was updated
if statusUpdated {
conn.Status.LastActivity = time.Now()
}
// Get sources
if sources, err := conn.Client.GetSources(); err == nil {
conn.Status.Sources = sources
statusUpdated = true
}
if presetsErr == nil {
s.Presets = presets
statusUpdated = true
}
// Get bass (if available)
if bass, err := conn.Client.GetBass(); err == nil {
conn.Status.Bass = bass
statusUpdated = true
}
if sourcesErr == nil {
s.Sources = sources
statusUpdated = true
}
// Mark as connected if we successfully got at least one status
conn.Status.IsConnected = statusUpdated
conn.Status.LastActivity = time.Now()
if bassErr == nil {
s.Bass = bass
statusUpdated = true
}
// Mark as connected if we successfully got at least one
// status from this round. Mirrors prior behaviour.
s.IsConnected = statusUpdated
s.LastActivity = time.Now()
})
}
// HandleDeviceWebSocket handles individual device WebSocket connections for real-time device-specific updates
@@ -230,7 +260,7 @@ func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request)
return
}
device, exists := app.Devices[deviceID]
device, exists := app.GetDevice(deviceID)
if !exists {
http.Error(w, "Device not found", http.StatusNotFound)
return
@@ -251,7 +281,7 @@ func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request)
DeviceID: deviceID,
Data: map[string]interface{}{
"info": device.DeviceInfo,
"status": device.Status,
"status": device.Status(),
},
}
@@ -293,12 +323,13 @@ func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request)
}
// Send device status update
status := device.Status()
statusMessage := webtypes.WebSocketMessage{
Type: "device_status",
DeviceID: deviceID,
Data: map[string]interface{}{
"info": device.DeviceInfo,
"status": device.Status,
"status": status,
},
}
@@ -309,13 +340,13 @@ func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request)
// If device has active WebSocket connection to SoundTouch device,
// also send any real-time updates from that connection
if device.WebSocket != nil && device.Status.IsConnected {
if device.WebSocket != nil && status.IsConnected {
realtimeMessage := webtypes.WebSocketMessage{
Type: "device_realtime",
DeviceID: deviceID,
Data: map[string]interface{}{
"nowPlaying": device.Status.NowPlaying,
"volume": device.Status.Volume,
"nowPlaying": status.NowPlaying,
"volume": status.Volume,
"timestamp": time.Now(),
},
}
+53 -46
View File
@@ -46,6 +46,11 @@ func main() {
Usage: "Network interface name (e.g. eth0) for mDNS and UPnP device discovery. Defaults to the --bind interface name when one was given; leave empty otherwise to auto-pick",
EnvVars: []string{"DISCOVERY_INTERFACE"},
},
&cli.StringSliceFlag{
Name: "devices",
Usage: "SoundTouch device IP address(es) to add manually (can be specified multiple times)",
EnvVars: []string{"SOUNDTOUCH_DEVICES"},
},
},
Action: func(c *cli.Context) error {
port := c.String("port")
@@ -61,6 +66,7 @@ func main() {
}
rawIface := c.String("interface")
manualHosts := c.StringSlice("devices")
ifaceName := defaultDiscoveryInterface(rawIface, rawBind, bindAddr)
if rawIface == "" && ifaceName != "" {
@@ -97,11 +103,15 @@ func main() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
webApp.BroadcastDiscoveryStatus("starting", len(webApp.Devices))
webApp.BroadcastDiscoveryStatus("starting", webApp.DeviceCount())
for _, host := range manualHosts {
addDevice(webApp, host, 8090, "manual")
}
discoverDevices(ctx, webApp, discoveryService)
webApp.BroadcastDiscoveryStatus("completed", len(webApp.Devices))
webApp.BroadcastDiscoveryStatus("completed", webApp.DeviceCount())
webApp.BroadcastDeviceList()
}()
@@ -200,6 +210,43 @@ func resolveBindAddr(bindAddr string) (string, error) {
}
}
// addDevice registers a SoundTouch device with the WebApp by fetching
// its /info and creating a DeviceConnection. The source label
// ("manual" or "discovered") appears in log lines so the operator can
// tell apart entries that came from --devices from those found via
// mDNS/UPnP. If the host is already known, the existing entry's
// LastSeen is bumped and the function returns without re-fetching.
func addDevice(app *handlers.WebApp, host string, port int, source string) {
// Fast path: skip the network call if we already know this host.
if app.TouchDevice(host) {
return
}
c := client.NewClient(&client.Config{
Host: host,
Port: port,
Timeout: 10 * time.Second,
})
info, err := c.GetDeviceInfo()
if err != nil {
log.Printf("Failed to fetch device info from %s (%s): %v", host, source, err)
return
}
conn := webtypes.NewDeviceConnection(c, info)
if !app.AddDevice(host, conn) {
// Lost a race — another goroutine inserted the same host
// between TouchDevice and AddDevice. AddDevice bumped LastSeen
// on the existing entry; discard our conn.
return
}
go app.UpdateDeviceStatus(host, conn)
log.Printf("Added %s device %s (%s) at %s:%d", source, info.Name, info.Type, host, port)
}
func setupRoutes(app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) *chi.Mux {
r := chi.NewRouter()
@@ -230,12 +277,12 @@ func setupRoutes(app *handlers.WebApp, discoveryService *discovery.UnifiedDiscov
defer cancel()
// Broadcast discovery start
app.BroadcastDiscoveryStatus("starting", len(app.Devices))
app.BroadcastDiscoveryStatus("starting", app.DeviceCount())
discoverDevices(ctx, app, discoveryService)
// Broadcast discovery completion and updated device list
app.BroadcastDiscoveryStatus("completed", len(app.Devices))
app.BroadcastDiscoveryStatus("completed", app.DeviceCount())
app.BroadcastDeviceList()
}()
})
@@ -271,7 +318,7 @@ func discoverDevices(ctx context.Context, app *handlers.WebApp, discoveryService
devices, err := discoveryService.DiscoverDevices(ctx)
if err != nil {
log.Printf("Discovery failed: %v", err)
app.BroadcastDiscoveryStatus("failed", len(app.Devices))
app.BroadcastDiscoveryStatus("failed", app.DeviceCount())
return
}
@@ -279,46 +326,6 @@ func discoverDevices(ctx context.Context, app *handlers.WebApp, discoveryService
log.Printf("Found %d devices", len(devices))
for _, device := range devices {
deviceID := device.Host // Use host as unique ID for now
// Skip if we already have this device
if _, exists := app.Devices[deviceID]; exists {
app.Devices[deviceID].LastSeen = time.Now()
continue
}
// Create new device connection
clientConfig := &client.Config{
Host: device.Host,
Port: device.Port,
Timeout: 10 * time.Second,
}
soundTouchClient := client.NewClient(clientConfig)
// Get device info
deviceInfo, err := soundTouchClient.GetDeviceInfo()
if err != nil {
log.Printf("Failed to get device info for %s: %v", device.Host, err)
continue
}
// Create device connection
conn := &webtypes.DeviceConnection{
Client: soundTouchClient,
DeviceInfo: deviceInfo,
LastSeen: time.Now(),
Status: webtypes.DeviceStatus{
IsConnected: false,
LastActivity: time.Now(),
},
}
// Initial status fetch asynchronously to avoid blocking discovery
go app.UpdateDeviceStatus(deviceID, conn)
app.Devices[deviceID] = conn
log.Printf("Added device: %s (%s) at %s", deviceInfo.Name, deviceInfo.Type, device.Host)
addDevice(app, device.Host, device.Port, "discovered")
}
}
+3 -8
View File
@@ -7,7 +7,6 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/handlers"
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
@@ -250,13 +249,9 @@ func TestControlAPIValidation(t *testing.T) {
}
// Add a mock device for testing unknown action validation
mockDevice := &webtypes.DeviceConnection{
Client: nil,
DeviceInfo: &models.DeviceInfo{Name: "Test Device"},
LastSeen: time.Now(),
Status: webtypes.DeviceStatus{IsConnected: true},
}
app.Devices["testdevice"] = mockDevice
mockDevice := webtypes.NewDeviceConnection(nil, &models.DeviceInfo{Name: "Test Device"})
mockDevice.SetStatus(&webtypes.DeviceStatus{IsConnected: true})
app.AddDevice("testdevice", mockDevice)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
+197
View File
@@ -0,0 +1,197 @@
// Package webtypes tests for the atomic Status API on DeviceConnection
// (Status, SetStatus, UpdateStatus, NewDeviceConnection).
package webtypes
import (
"fmt"
"sync"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
func TestNewDeviceConnection_InitialStatus(t *testing.T) {
conn := NewDeviceConnection(nil, &models.DeviceInfo{Name: "test"})
status := conn.Status()
if status == nil {
t.Fatal("Status() returned nil from a NewDeviceConnection")
}
if status.IsConnected {
t.Error("IsConnected should default to false")
}
if status.LastActivity.IsZero() {
t.Error("LastActivity should be initialised, got zero time")
}
}
func TestSetStatus_ReplacesEntireStatus(t *testing.T) {
conn := NewDeviceConnection(nil, &models.DeviceInfo{Name: "test"})
conn.SetStatus(&DeviceStatus{
Volume: &models.Volume{ActualVolume: 42},
IsConnected: true,
})
got := conn.Status()
if got.Volume == nil || got.Volume.ActualVolume != 42 {
t.Errorf("Volume not stored: got %+v", got.Volume)
}
// Setting a sparser status should wipe previously-set fields.
conn.SetStatus(&DeviceStatus{IsConnected: false})
got = conn.Status()
if got.Volume != nil {
t.Error("SetStatus did not wipe previously-set Volume")
}
if got.IsConnected {
t.Error("SetStatus did not wipe IsConnected")
}
}
func TestUpdateStatus_AppliesMutator(t *testing.T) {
conn := NewDeviceConnection(nil, &models.DeviceInfo{Name: "test"})
conn.UpdateStatus(func(s *DeviceStatus) {
s.IsConnected = true
s.Volume = &models.Volume{ActualVolume: 30}
})
got := conn.Status()
if !got.IsConnected {
t.Error("UpdateStatus did not set IsConnected")
}
if got.Volume == nil || got.Volume.ActualVolume != 30 {
t.Errorf("UpdateStatus did not set Volume: %+v", got.Volume)
}
}
func TestUpdateStatus_PreservesUnchangedFields(t *testing.T) {
conn := NewDeviceConnection(nil, &models.DeviceInfo{Name: "test"})
conn.SetStatus(&DeviceStatus{
Volume: &models.Volume{ActualVolume: 10},
Bass: &models.Bass{ActualBass: 3},
IsConnected: true,
})
// Only touch Volume; Bass and IsConnected must survive.
conn.UpdateStatus(func(s *DeviceStatus) {
s.Volume = &models.Volume{ActualVolume: 99}
})
got := conn.Status()
if got.Volume.ActualVolume != 99 {
t.Errorf("Volume = %d, want 99", got.Volume.ActualVolume)
}
if got.Bass == nil || got.Bass.ActualBass != 3 {
t.Errorf("Bass not preserved: %+v", got.Bass)
}
if !got.IsConnected {
t.Error("IsConnected not preserved")
}
}
func TestStatusSnapshotIsolation(t *testing.T) {
// A snapshot returned by Status() must NOT change when a later
// UpdateStatus replaces a pointer field. This proves the atomic
// store gives readers a stable view (so long as the writer
// follows the docstring contract of replacing nested pointers).
conn := NewDeviceConnection(nil, &models.DeviceInfo{Name: "test"})
conn.SetStatus(&DeviceStatus{Volume: &models.Volume{ActualVolume: 1}})
first := conn.Status()
conn.UpdateStatus(func(s *DeviceStatus) {
s.Volume = &models.Volume{ActualVolume: 2}
})
if first.Volume.ActualVolume != 1 {
t.Errorf("Snapshot mutated after later UpdateStatus: got %d, want 1",
first.Volume.ActualVolume)
}
if conn.Status().Volume.ActualVolume != 2 {
t.Errorf("Current status not updated: got %d, want 2",
conn.Status().Volume.ActualVolume)
}
}
// TestStatusConcurrent runs many UpdateStatus writers alongside many
// Status() readers. Before atomic.Pointer[DeviceStatus] this pattern
// would be flagged by the race detector (writers mutate
// conn.Status.X while readers copy conn.Status). With the atomic
// pointer it must run clean under `go test -race`.
func TestStatusConcurrent(t *testing.T) {
conn := NewDeviceConnection(nil, &models.DeviceInfo{Name: "concurrent"})
const writers = 16
const readersPerKind = 16
const opsPerGoroutine = 200
var wg sync.WaitGroup
wg.Add(writers + 2*readersPerKind)
// Writers: each goroutine replaces NowPlaying with a fresh struct
// carrying its worker id. Replacement (not in-place mutation)
// is what the UpdateStatus contract requires for nested
// pointers.
for w := 0; w < writers; w++ {
go func(worker int) {
defer wg.Done()
for i := 0; i < opsPerGoroutine; i++ {
conn.UpdateStatus(func(s *DeviceStatus) {
s.NowPlaying = &models.NowPlaying{
Track: fmt.Sprintf("w%d-%d", worker, i),
}
s.IsConnected = true
})
}
}(w)
}
// Readers via Status() — full snapshot.
for r := 0; r < readersPerKind; r++ {
go func() {
defer wg.Done()
for i := 0; i < opsPerGoroutine; i++ {
_ = conn.Status()
}
}()
}
// Readers that deref a single field. Tests the common
// "device.Status().IsConnected" pattern.
for r := 0; r < readersPerKind; r++ {
go func() {
defer wg.Done()
for i := 0; i < opsPerGoroutine; i++ {
_ = conn.Status().IsConnected
}
}()
}
wg.Wait()
// After all writers finish, IsConnected should be true (every
// writer sets it). The exact NowPlaying value is whichever
// writer landed last, but it must be a valid non-nil pointer.
final := conn.Status()
if !final.IsConnected {
t.Error("IsConnected should be true after writers ran")
}
if final.NowPlaying == nil {
t.Error("NowPlaying should be non-nil after writers ran")
}
}
+69 -2
View File
@@ -2,6 +2,7 @@
package webtypes
import (
"sync/atomic"
"time"
"github.com/gesellix/bose-soundtouch/pkg/client"
@@ -29,13 +30,21 @@ type SoundTouchClient interface {
NewWebSocketClient(config interface{}) *client.WebSocketClient
}
// DeviceConnection wraps a SoundTouch client with WebSocket connection
// DeviceConnection wraps a SoundTouch client with WebSocket connection.
//
// The Status field is stored behind atomic.Pointer so concurrent
// readers (HTTP handlers, WebSocket broadcasters) never observe a
// torn struct while a writer (UpdateDeviceStatus, WebSocket event
// handlers) is mid-update. Access status through Status / SetStatus
// / UpdateStatus rather than the private field; construct connections
// via NewDeviceConnection to guarantee the status is initialised.
type DeviceConnection struct {
Client *client.Client
WebSocket *client.WebSocketClient
DeviceInfo *models.DeviceInfo
LastSeen time.Time
Status DeviceStatus
status atomic.Pointer[DeviceStatus]
}
// DeviceStatus represents the current device state
@@ -49,6 +58,64 @@ type DeviceStatus struct {
LastActivity time.Time `json:"lastActivity"`
}
// NewDeviceConnection creates a fully-initialised connection. The
// status starts with IsConnected=false and LastActivity set to now;
// real values arrive via UpdateStatus once the device responds.
func NewDeviceConnection(c *client.Client, info *models.DeviceInfo) *DeviceConnection {
conn := &DeviceConnection{
Client: c,
DeviceInfo: info,
LastSeen: time.Now(),
}
conn.status.Store(&DeviceStatus{
IsConnected: false,
LastActivity: time.Now(),
})
return conn
}
// Status returns a snapshot of the current device status. The returned
// pointer is read-only from the caller's perspective; mutating the
// pointed-to struct has no effect on the stored status. Use
// UpdateStatus or SetStatus to apply changes. Never returns nil for
// connections built via NewDeviceConnection.
func (c *DeviceConnection) Status() *DeviceStatus {
return c.status.Load()
}
// SetStatus atomically replaces the entire status. Use sparingly —
// UpdateStatus is the preferred entry point because it preserves
// concurrent changes from other goroutines.
func (c *DeviceConnection) SetStatus(s *DeviceStatus) {
c.status.Store(s)
}
// UpdateStatus atomically applies mut to a copy of the current status
// and stores the result. If another goroutine updates the status while
// mut runs, UpdateStatus retries with the newer status — so concurrent
// writers cannot silently lose each other's changes.
//
// The copy mut receives is a shallow value copy of the previous status.
// Nested pointer fields (NowPlaying, Volume, Presets, Sources, Bass)
// share their backing struct with the previous version: callers MUST
// REPLACE these pointers (s.Volume = &models.Volume{...}) rather than
// mutate through them (s.Volume.ActualVolume++ would race with any
// reader still holding the previous snapshot). Production callers
// receive these values fresh from the device API, so this is the
// natural shape.
func (c *DeviceConnection) UpdateStatus(mut func(*DeviceStatus)) {
for {
old := c.status.Load()
next := *old
mut(&next)
if c.status.CompareAndSwap(old, &next) {
return
}
}
}
// APIResponse is a standard JSON response wrapper
type APIResponse struct {
Success bool `json:"success"`
+14 -15
View File
@@ -145,31 +145,30 @@ func TestDeviceConnection(t *testing.T) {
MuteEnabled: false,
}
conn := &DeviceConnection{
DeviceInfo: deviceInfo,
LastSeen: time.Now(),
Status: DeviceStatus{
NowPlaying: nowPlaying,
Volume: volume,
IsConnected: true,
LastActivity: time.Now(),
},
}
conn := NewDeviceConnection(nil, deviceInfo)
conn.SetStatus(&DeviceStatus{
NowPlaying: nowPlaying,
Volume: volume,
IsConnected: true,
LastActivity: time.Now(),
})
t.Run("device connection fields", func(t *testing.T) {
if conn.DeviceInfo.Name != "Test Speaker" {
t.Errorf("Expected device name 'Test Speaker', got '%s'", conn.DeviceInfo.Name)
}
if conn.Status.NowPlaying.Track != "Test Track" {
t.Errorf("Expected track 'Test Track', got '%s'", conn.Status.NowPlaying.Track)
status := conn.Status()
if status.NowPlaying.Track != "Test Track" {
t.Errorf("Expected track 'Test Track', got '%s'", status.NowPlaying.Track)
}
if conn.Status.Volume.ActualVolume != 50 {
t.Errorf("Expected volume 50, got %d", conn.Status.Volume.ActualVolume)
if status.Volume.ActualVolume != 50 {
t.Errorf("Expected volume 50, got %d", status.Volume.ActualVolume)
}
if !conn.Status.IsConnected {
if !status.IsConnected {
t.Error("Expected device to be connected")
}
})
+5 -3
View File
@@ -355,9 +355,11 @@ func handlePreset(event *models.PresetUpdatedEvent, verbose bool) {
for _, preset := range presets.Preset {
fmt.Printf(" 📻 Preset %d:", preset.ID)
if preset.ContentItem != nil {
fmt.Printf(" %s", preset.ContentItem.ItemName)
fmt.Printf(" (%s)", preset.ContentItem.Source)
// IsEmpty catches both <preset/> and INVALID_SOURCE
// placeholders; using the nil-safe helpers below means the
// inner Printf never dereferences a nil ContentItem.
if !preset.IsEmpty() {
fmt.Printf(" %s (%s)", preset.GetDisplayName(), preset.GetSource())
}
fmt.Println()
+24
View File
@@ -0,0 +1,24 @@
{%- comment -%}
Render Mermaid diagrams in docs pages.
Markdown ```mermaid fenced blocks are emitted by Kramdown as
<pre><code class="language-mermaid"></code></pre>, but Mermaid only
auto-renders elements with class="mermaid". This snippet rewrites the
pre/code nodes into div.mermaid before initialising the library.
Loaded as an ES module from the jsDelivr CDN so we don't have to vendor
the library into the repo. Pinned to a major version for cache stability.
{%- endcomment -%}
<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
document.querySelectorAll('pre > code.language-mermaid').forEach((code) => {
const div = document.createElement('div');
div.className = 'mermaid';
div.textContent = code.textContent;
code.parentElement.replaceWith(div);
});
mermaid.initialize({ startOnLoad: false, securityLevel: 'strict' });
mermaid.run();
</script>
+11 -26
View File
@@ -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
+183
View File
@@ -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
+1
View File
@@ -86,6 +86,7 @@ A factory reset wipes Wi-Fi credentials, account pairing, and all presets, retur
| SoundTouch 10 | Power on; hold **Preset 1** + **Volume ** for 10 s | Wi-Fi indicator glows solid amber |
| SoundTouch 20 | Power on; hold **Preset 1** + **Volume ** for 10 s | Lights blink L→R, then solid amber |
| SoundTouch 20 Series III | Hold **Preset 1** + **Preset 6** simultaneously for ~10 s | White LED sweep |
| SoundTouch 30 | Power on; hold **Preset 1** + **Volume ** for 10 s (display counts down 101) | Display shows "Hold to restore factory settings", then restarts |
| SoundTouch 30 Series III | Hold **Preset 1** + **Preset 6** simultaneously for ~10 s | White LED sweep |
| SoundTouch 300 | Hold **Volume ** until light bar blinks rapidly (~15 s) | Rapid blink → off → on |
| SoundTouch 10 (alt) | Press and hold the back recessed **Reset** pinhole for 10 s | Status LED restarts |
+6
View File
@@ -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
+129
View File
@@ -105,6 +105,54 @@ iperf3 -c 192.168.1.1 # If iperf server available
## 🌐 **Connection Issues**
### ❌ Every cloud source shows `status="UNAVAILABLE"` / can't stream anything
**Symptoms:**
- The speaker's `/sources` (or the soundtouch-cli `source availability` output) lists every cloud-backed source — Spotify, TuneIn, Internet Radio, AirPlay, Amazon, Alexa — as `status="UNAVAILABLE"`.
- Often only AUX shows `status="READY"`.
- The speaker can be reached on the LAN (`:8090/info` works) but no Internet streaming source can be selected.
This is a different failure mode from the [`Curl 7` case below](#-speaker-logs-curl-7-http-0-and-aftertouch-sees-no-http-requests): the speaker can reach AfterTouch but doesn't have the account state to authenticate any cloud surface, so every cloud handler 401s itself out.
**Three-step diagnostic checklist** (in order — the cause is almost always one of these):
#### 1. Is `:443` reachable on AfterTouch?
The AfterTouch Settings tab now ships a preflight that flips ✅ / ❌ for whether the speaker can open a TLS handshake to AfterTouch's HTTPS listener. If `:443` is ❌, follow the steps in [HTTPS-SETUP.md → Binding to port 443](HTTPS-SETUP.md#binding-to-port-443).
A failing preflight at this layer typically presents as `Curl 7, http 0` in the speaker's syslog (see the [`Curl 7` entry below](#-speaker-logs-curl-7-http-0-and-aftertouch-sees-no-http-requests) for the focused walkthrough).
#### 2. Does the speaker have a `margeAccountUUID`?
```bash
curl -s http://<speaker-ip>:8090/info | xmllint --xpath '/info/margeAccountUUID/text()' -
```
If the element is empty (or you get no output), the speaker has no account token — every cloud surface that requires authentication will 401 itself out. The Migration tab in AfterTouch detects this and renders:
> **Current: ❌ Not paired (factory-reset or never paired) — set an ID to pair as part of Apply**
The Devices list also shows a `⚠ Not paired — re-pair` badge. To resolve, **open the Migration tab**, pick a previous account ID from the dropdown (or click **Generate**), and click **Apply** — same flow as the [factory-reset recovery](#-presets-flash-then-revert-to-select-a-preset-after-a-factory-reset) section below.
#### 3. What does `logread` say while you trigger a failing source?
SSH into the speaker (see [DEVICE-LOGGING.md](../DEVICE-LOGGING.md#1-accessing-system-logs-requires-root)) and capture:
```bash
logread -f | grep -v '127.0.0.1:'
```
…while you select a failing source in the SoundTouch app or via `soundtouch-cli`. The lines around the failed attempt usually name the failing host + protocol — TLS handshake error, token fetch 401, missing route, etc. — and that's enough to file an actionable issue.
**Common outcomes:**
-`:443` → fix HTTPS routing, sources transition to READY on the next refresh.
-`margeAccountUUID` empty → run Migration → Apply, sources reappear after `<sourcesUpdated/>` triggers a `/sources` re-sync.
- Everything looks right but sources still UNAVAILABLE → the `logread` snippet is the next signal; open an issue with it attached.
> **Note on the firmware-internal placeholder sources.** The `<sourceItem source="SPOTIFY" sourceAccount="SpotifyConnectUserName" ...>`, `SpotifyAlexaUserName`, `UPNP/UPnPUserName`, `STORED_MUSIC_MEDIA_RENDERER/StoredMusicUserName`, and `QPLAY/QPlay{1,2}UserName` entries that appear in `/sources` even on a broken or unpaired speaker are *firmware-synthesized*. They show up regardless of AfterTouch's source list — their `status="UNAVAILABLE"` does not indicate an AfterTouch problem. Use the three checks above to diagnose the actual cause.
### ❌ Speaker logs `Curl 7, http 0` and AfterTouch sees no HTTP requests
**Symptoms:**
@@ -335,6 +383,87 @@ client.SelectAux()
---
## 🎶 **Music Service & Preset Issues**
### ❌ Spotify preset fails with "Current content cannot be saved as preset"
**Symptoms:**
You push playback to the speaker via Spotify Connect from the Spotify mobile/desktop app. Audio plays fine. You try to store it as a preset and the CLI reports:
```
$ soundtouch-cli preset store-current --slot 2
Storing current content as preset 2 from 192.168.x.y:8090...
✗ Current content cannot be saved as preset
Content: <track name>
Source: SPOTIFY
2026/05/16 09:13:10 current content cannot be preset
```
…and `soundtouch-cli play now` shows `Source Account: SpotifyConnectUserName`.
**Cause:**
The speaker firmware marks Spotify-Connect-pushed content as **non-presetable** at the NowPlaying layer:
```xml
<ContentItem source="SPOTIFY" type="DO_NOT_RESUME" ...
sourceAccount="SpotifyConnectUserName" isPresetable="false">
```
That `isPresetable="false"` means the firmware can't independently re-fetch the stream later — it only knows about the session token your phone pushed via the Spotify Connect protocol, which is ephemeral. The speaker refuses the preset *locally*, before any storePreset request reaches AfterTouch's marge.
**Why an OAuth-linked Spotify account changes the answer:**
When AfterTouch has a Spotify OAuth account linked (see [MUSIC-SERVICES.md](MUSIC-SERVICES.md)), the speaker has a *persistent* Spotify source it can use to resolve the content URI later — typically an album/playlist container. With that source available, the firmware rewrites the content item from `DO_NOT_RESUME` to `tracklisturl` at save time, flips `isPresetable` to `true`, and the preset goes through. The recall path then routes through AfterTouch's `/oauth/.../cs3` token broker, which returns a Spotify access token for your linked account.
**Fix:**
1. Set up Spotify OAuth in AfterTouch following [MUSIC-SERVICES.md](MUSIC-SERVICES.md). The high-level model (Spotify Connect vs the OAuth-intercept path, the `streamingoauth.bose.com` DNS rewrite, the token lifecycle) is in [spotify-overview.md](../concepts/spotify-overview.md).
2. Make sure you're on **v0.84.0 or later** — earlier versions had a custom-OAuth-client bug that caused playback to hang at "Buffering".
3. Re-prime the speaker (Migration tab → **Prime Spotify**, or wait for the watchdog), then retry the preset save with Connect-pushed playback.
**What this won't fix:**
A Connect-only setup with no OAuth account linked in AfterTouch — that's a firmware-level constraint we can't route around from the server side. The speaker simply doesn't have credentials it can use to replay the content later, so it refuses to preset.
### ❌ TuneIn (or Internet Radio) missing from `/sources` after a factory reset
**Symptoms:**
- The speaker is happily migrated and reachable; most cloud sources work.
- `curl http://<speaker-ip>:8090/sources` lists AUX, Bluetooth, Spotify Connect placeholders, etc. — but **no `TUNEIN` entry**.
- `soundtouch-cli source content --source TUNEIN --type stationurl --location /v1/playback/station/<id> --name '<name>'` fails with `1005` (or playing a TuneIn preset silently does nothing).
- Other devices on the same setup have `TUNEIN` in `/sources` and work fine.
**Cause:**
TuneIn is **not a default source** on a freshly factory-reset SoundTouch. The speaker only adds `TUNEIN` to its `Sources.xml` after the source has been played at least once. Until then, source-selection requests for `TUNEIN` are rejected as invalid.
This is firmware behaviour — independent of AfterTouch — and is why one device can have `TUNEIN` and a sibling device (just reset) can be missing it. The same applies to `LOCAL_INTERNET_RADIO` if the speaker was reset before any LIR content was played.
**Fix:**
Play any TuneIn station once to register the source. Two equivalent paths:
1. **Via the SoundTouch app** — open the app, pick TuneIn, play any station. The source appears in `/sources` after a few seconds.
2. **Via `soundtouch-cli`** on a device that *does* still have TuneIn registered, or by first registering it with a known-working station:
```bash
soundtouch-cli --host <speaker-ip> source content \
--source TUNEIN --type stationurl \
--location /v1/playback/station/s166521 \
--name 'SMOOTH JAZZ'
```
(Station `s166521` is one that works for AfterTouch testing; any valid TuneIn station ID works.)
Once the source plays once, it gets persisted to `/mnt/nv/BoseApp-Persistence/1/Sources.xml` and subsequent TuneIn requests succeed without needing the app.
**For speakers without SSH:**
If `soundtouch-cli source content --source TUNEIN ...` returns `1005` on a reset device that has never had TuneIn, the speaker is refusing because the source isn't registered yet — chicken-and-egg. The SoundTouch app is then the only practical path to register it; we can't write `Sources.xml` directly over telnet on most models.
## 🔊 **Volume & Audio Issues**
### ❌ "Volume control not working"
+22 -6
View File
@@ -96,22 +96,38 @@ func showCurrentPresets(c *client.Client) error {
return err
}
if len(presets.Preset) == 0 {
// Filter out placeholder presets (issue #308): self-closing
// <preset/> entries from a factory-reset device and
// INVALID_SOURCE placeholders from healthy devices both panic if
// their fields are dereferenced directly.
configured := make([]models.Preset, 0, len(presets.Preset))
for _, p := range presets.Preset {
if !p.IsEmpty() {
configured = append(configured, p)
}
}
if len(configured) == 0 {
fmt.Println(" 📭 No presets configured")
return nil
}
fmt.Printf(" 📻 Found %d configured presets:\n", len(presets.Preset))
for _, preset := range presets.Preset {
fmt.Printf(" 📻 Found %d configured presets:\n", len(configured))
for _, preset := range configured {
fmt.Printf(" %d. %s\n", preset.ID, preset.GetDisplayName())
fmt.Printf(" Source: %s\n", preset.ContentItem.Source)
if preset.ContentItem.Location != "" {
fmt.Printf(" Location: %s\n", preset.ContentItem.Location)
fmt.Printf(" Source: %s\n", preset.GetSource())
if location := preset.GetLocation(); location != "" {
fmt.Printf(" Location: %s\n", location)
}
if preset.CreatedOn != nil && *preset.CreatedOn != 0 {
createdTime := time.Unix(*preset.CreatedOn, 0)
fmt.Printf(" Created: %s\n", createdTime.Format("2006-01-02 15:04:05"))
}
fmt.Println()
}
+18 -2
View File
@@ -71,9 +71,25 @@ func (p *Preset) IsSpotifyPreset() bool {
return p.ContentItem != nil && p.ContentItem.Source == "SPOTIFY"
}
// IsEmpty returns true if the preset has no content
// IsEmpty returns true if the preset has no playable content. Two
// placeholder shapes are observed in the wild and both count as empty:
//
// - <preset/> (or <preset id="0"/>) — no ContentItem child at all.
// Emitted by some firmware after a factory reset (issue #308).
// - <preset id="0"><ContentItem source="INVALID_SOURCE" isPresetable="true"/></preset>
// — a placeholder ContentItem the firmware uses for unconfigured
// slots, observed on FW 27.0.6 even on devices that were never
// reset.
//
// Treating both as empty keeps GetEmptyPresetSlots, GetUsedPresetSlots
// and HasPresets honest, and lets callers safely skip placeholders
// before formatting a preset for display.
func (p *Preset) IsEmpty() bool {
return p.ContentItem == nil
if p.ContentItem == nil {
return true
}
return p.ContentItem.Source == "" || p.ContentItem.Source == "INVALID_SOURCE"
}
// GetSource returns the source of the preset content
+188
View File
@@ -0,0 +1,188 @@
package models
import (
"encoding/xml"
"testing"
)
// reporterXML is the /presets response captured from the speaker that
// crashed the CLI in issue #308 (ST10 post factory reset, FW 27.0.6).
// Two configured presets followed by three self-closing <preset/>
// placeholders. The original crash happened on the first <preset/>:
// GetDisplayName() handled the nil ContentItem, but the very next
// line dereferenced ContentItem.Source unconditionally.
const reporterXML = `<presets>
<preset id="1" createdOn="1348058580" updatedOn="1348058580">
<ContentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s6634" sourceAccount="" isPresetable="true">
<itemName>MDR JUMP</itemName>
<containerArt/>
</ContentItem>
</preset>
<preset id="2" createdOn="1348058580" updatedOn="1348058580">
<ContentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s10637" sourceAccount="" isPresetable="true">
<itemName>SUNSHINE LIVE</itemName>
<containerArt>
http://cdn-profiles.tunein.com/s10637/images/logog.png?t=637791086340000000
</containerArt>
</ContentItem>
</preset>
<preset/>
<preset/>
<preset/>
</presets>`
// invalidSourceXML is the second placeholder shape observed in the
// wild (gesellix's ST10/ST20 on FW 27.0.6, never factory-reset). The
// firmware here populates ContentItem with source="INVALID_SOURCE"
// for unconfigured slots — non-nil but useless, so the old IsEmpty
// (== nil only) returned false and the placeholders polluted listings.
const invalidSourceXML = `<?xml version="1.0" encoding="UTF-8" ?><presets>` +
`<preset id="0"><ContentItem source="INVALID_SOURCE" isPresetable="true" /></preset>` +
`<preset id="0"><ContentItem source="INVALID_SOURCE" isPresetable="true" /></preset>` +
`<preset id="0"><ContentItem source="INVALID_SOURCE" isPresetable="true" /></preset>` +
`<preset id="1"><ContentItem source="SPOTIFY" type="tracklisturl" location="/playback/container/abc" sourceAccount="user" isPresetable="true"><itemName>Sand Castle Tapes</itemName><containerArt></containerArt></ContentItem></preset>` +
`<preset id="2" createdOn="1778965482" updatedOn="1778965482"><ContentItem source="SPOTIFY" type="tracklisturl" location="/playback/container/def" sourceAccount="user" isPresetable="true"><itemName>Unplugged</itemName><containerArt>https://example.com/art.jpg</containerArt></ContentItem></preset>` +
`<preset id="6"><ContentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s166521" sourceAccount="" isPresetable="true"><itemName>SMOOTH JAZZ</itemName><containerArt>https://example.com/logo.png</containerArt></ContentItem></preset>` +
`</presets>`
func TestIsEmpty_NoContentItem(t *testing.T) {
// Shape A: <preset/> — ContentItem == nil. This is the shape
// behind the issue #308 crash.
p := Preset{}
if !p.IsEmpty() {
t.Error("IsEmpty() should be true when ContentItem is nil")
}
}
func TestIsEmpty_InvalidSourcePlaceholder(t *testing.T) {
// Shape B: ContentItem present but Source == "INVALID_SOURCE".
// Observed on devices that never had a factory reset.
p := Preset{
ContentItem: &ContentItem{Source: "INVALID_SOURCE", IsPresetable: true},
}
if !p.IsEmpty() {
t.Error("IsEmpty() should be true when ContentItem has INVALID_SOURCE")
}
}
func TestIsEmpty_EmptySource(t *testing.T) {
// A ContentItem with no Source can't drive playback. Treat it
// as empty too — defensive, not tied to a single observed shape.
p := Preset{ContentItem: &ContentItem{}}
if !p.IsEmpty() {
t.Error("IsEmpty() should be true when ContentItem.Source is empty")
}
}
func TestIsEmpty_RealPreset(t *testing.T) {
p := Preset{
ContentItem: &ContentItem{
Source: "TUNEIN",
ItemName: "MDR JUMP",
},
}
if p.IsEmpty() {
t.Error("IsEmpty() should be false for a configured preset")
}
}
func TestReporterXML_DoesNotPanicAndFiltersEmpty(t *testing.T) {
// Reproducer for issue #308: simulate the loop that crashed the
// CLI. The fix is two-fold: IsEmpty now recognises <preset/>,
// and callers use the nil-safe Get* accessors. Walking every
// preset through the same paths the CLI uses must not panic on
// any entry.
var presets Presets
if err := xml.Unmarshal([]byte(reporterXML), &presets); err != nil {
t.Fatalf("Failed to unmarshal reporter XML: %v", err)
}
if got := len(presets.Preset); got != 5 {
t.Fatalf("Expected 5 preset entries (2 configured + 3 empty), got %d", got)
}
emptyCount := 0
configuredCount := 0
for _, p := range presets.Preset {
// The CLI now skips empty presets before dereferencing
// anything on ContentItem. The IsEmpty call must catch all
// three <preset/> entries.
if p.IsEmpty() {
emptyCount++
continue
}
configuredCount++
// These calls would have panicked pre-fix on the empty
// entries; here they exercise the still-printed paths for
// the real ones.
_ = p.GetDisplayName()
_ = p.GetSource()
_ = p.GetSourceAccount()
_ = p.GetLocation()
}
if emptyCount != 3 {
t.Errorf("Expected 3 empty presets, got %d", emptyCount)
}
if configuredCount != 2 {
t.Errorf("Expected 2 configured presets, got %d", configuredCount)
}
// HasPresets should reflect "there are real presets" — not
// confused by the placeholders.
if !presets.HasPresets() {
t.Error("HasPresets() should be true (2 real presets present)")
}
if got := presets.GetUsedPresetSlots(); len(got) != 2 {
t.Errorf("GetUsedPresetSlots() = %v; want 2 entries", got)
}
}
func TestInvalidSourceXML_PlaceholdersFilteredOut(t *testing.T) {
// Second-shape reproducer: three INVALID_SOURCE placeholders
// preceding three real presets. Before the IsEmpty extension,
// listings printed "0. Preset 0 / Source: INVALID_SOURCE" three
// times before the real entries — annoying, not crashing.
var presets Presets
if err := xml.Unmarshal([]byte(invalidSourceXML), &presets); err != nil {
t.Fatalf("Failed to unmarshal invalid-source XML: %v", err)
}
if got := len(presets.Preset); got != 6 {
t.Fatalf("Expected 6 preset entries, got %d", got)
}
configured := 0
for _, p := range presets.Preset {
if !p.IsEmpty() {
configured++
}
}
if configured != 3 {
t.Errorf("Expected 3 configured presets (after filtering INVALID_SOURCE placeholders), got %d",
configured)
}
// The three placeholders all carry id="0", so used-slot
// reporting should ignore them and show only the real ids.
used := presets.GetUsedPresetSlots()
if len(used) != 3 {
t.Fatalf("GetUsedPresetSlots() = %v; want 3 entries", used)
}
wantIDs := map[int]bool{1: true, 2: true, 6: true}
for _, id := range used {
if !wantIDs[id] {
t.Errorf("Unexpected used slot id %d; want one of %v", id, []int{1, 2, 6})
}
}
}
+4
View File
@@ -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).
+366
View File
@@ -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
}
+121 -2
View File
@@ -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)
}
+6 -1
View File
@@ -2588,7 +2588,12 @@ func (m *Manager) syncPresets(deviceIP, accountID, deviceID string) {
var servicePresets []models.ServicePreset
for _, p := range ps.Preset {
if p.ContentItem == nil {
// IsEmpty catches both placeholder shapes a SoundTouch device
// can emit: self-closing <preset/> (issue #308) and
// <ContentItem source="INVALID_SOURCE"/>. Neither carries
// real playable data and persisting them would surface as
// junk entries in the admin web UI.
if p.IsEmpty() {
continue
}
+5
View File
@@ -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)
+63
View File
@@ -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)
}
+110
View File
@@ -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)
}
})
}
}