Commit Graph
607 Commits
Author SHA1 Message Date
Tobias GesellchenandClaude Sonnet 4.6 6fb999435a feat(stockholm): add Go backend integration for Stockholm frontend
Implements pkg/service/stockholm with bridge (appSend/runQueue), HTTP
proxy, static serving, config URL rewriting, native state persistence,
and device discovery. Mounts under a configurable base path (/stockholm
by default) with correct http.StripPrefix routing and apiBase-prefixed
bridge API routes matching the patched JS window.__stockholmBase calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 15:05:39 +02:00
Tobias GesellchenandClaude Sonnet 4.6 c64e601df6 feat(stockholm): add Dockerfile.stockholm and Makefile targets for frontend prep
Dockerfile.stockholm clones github.com/krahl/soundcork-stockholm-app at build
time and installs the required tools (prettier, patch, unzip, jq). No pre-built
image is published upstream, so users must run `make build-stockholm-image` once
before `make prepare-stockholm`.

`make prepare-stockholm` runs the upstream entrypoint logic (extract zip,
run prettier, apply patches) via a volume-mounted docker run, stopping before
`exec java` so we only collect the processed stockholm/ output. The Go service
then serves that directory directly with no patching required at runtime.

Prerequisites: Docker with internet access, and stockholm_zip/stockholm.zip
(Stockholm source zip placed manually — tracked directory, zip gitignored).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 15:05:39 +02:00
Tim VahlbrockandTobias Gesellchen 55ae4d06ba Add missing "don't" in README.md regarding On-Device Installer 2026-05-17 13:02:23 +02:00
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>
v0.85.0
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
chrizgandTobias Gesellchen 712801259e feat(soundtouch-web): rename --host to --devices, support multiple devices via StringSliceFlag 2026-05-17 10:30:25 +02:00
chrizgandTobias Gesellchen 46546f5494 feat(soundtouch-web): add --host flag for manual device IP 2026-05-17 10:30:25 +02:00
chrisandGitHub 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
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>
v0.84.0
2026-05-16 22:47:57 +02:00
Tobias GesellchenandClaude Opus 4.7 e64481f008 docs(setup): record ST10 ≡ ST20 bundle equivalence + curl reproducer
Two doc-only additions to TestValidateRealSpeakerBundle's header
comment:

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

No code change; test still passes.

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

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

Changes:

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

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

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

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

Restart soundtouch-service after editing.

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

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

Changes:

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

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

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

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

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

The test asserts:

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

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

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

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

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

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

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

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

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

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

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

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

Adds:

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:14:18 +02:00
Tobias GesellchenandClaude Opus 4.7 332c7b87d0 fix(marge): drop AUX from cloud /full and /sources to unblock dispatch
Closes #195 and #269. Both issues reported the same symptom on
freshly-paired speakers: AUX selection and preset playback failed
post-pair, while /sources at :8090 still reported the sources as
READY. The bug was upstream in AfterTouch's cloud-side responses.

Real Bose's /streaming/account/{a}/full never emitted AUX as a
cloud <source>. Verified across 61 captured upstream /full bodies
covering 4669 source elements: zero match sourceproviderid=9 (AUX),
zero match the literal string "AUX". Captures sample at
scripts/android/captures/var/lib/soundtouch-service/parity_mismatches/.
The captured speakers are SoundTouch 20s which do have physical AUX
inputs — Bose deliberately kept AUX out of /full and let the speaker
enumerate it locally via isLocal=true.

AfterTouch's getAccountSources unconditionally included AUX
(id=10001) with the wrong shape: a displayName="AUX IN" attribute
(real Bose: never), <name>AUX</name> (real Bose: empty), an empty
<credential> (real Bose: empty for INTERNET_RADIO providerid=2 only,
never present for AUX since AUX wasn't there). The speaker's source-
reconciliation logic treated AfterTouch's malformed AUX entry as a
cloud-side inconsistency and refused dispatch to AUX — even though
the local availability check kept reporting it READY.

This was the actual cause behind a long red-herring trail (TPDA
:30034 storm, IoT.xml/AVS bootstrap, userAuthToken shape, SETUP
state machine bracket). All of those are universal across the
firmware family; spotty has the same TPDA storm in logread and AUX
still works there. Only the cloud-source-list shape diverged
between working and broken speakers.

The filter applies in getAccountSources because both AccountFullToXML
and AccountSourcesToXML go through it. AUX stays in
GetDefaultSources for non-cloud consumers (web UI source picker,
default-sources init). Three handler tests updated to assert AUX is
intentionally excluded from cloud responses.

Verified by gesellix on rhino 2026-05-16 via full factory-reset →
wifi-push → setup pair → AUX press → audio plays.

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:36:43 +02:00
Tobias Gesellchen 9dcde21f39 Bump release version to v0.80.1 in installer scripts 2026-05-15 19:25:01 +02:00
Tobias GesellchenandClaude Opus 4.7 979374b501 test(integration): pin #285 rename PUT behaviour at the HTTP layer
Adds rename_device.http between get_group.http and unregister_device.http
in the make test-http-client sequence. The new test fires the PUT
the speaker emits after a rename and asserts:

  - 200 OK, content type vnd.bose.streaming-v1.2+xml
  - the response carries the renamed value
  - createdOn matches the value captured during register_device.http
    (cross-request global), locking in the "first-paired" semantics
  - ipaddress is preserved from the prior power_on, not reset by the
    rename body's empty IP field
  - a mismatched body deviceid is rejected with 400

register_device.http captures the initial createdOn into a global so
the rename test can assert equality rather than a flakier
updatedOn != createdOn heuristic. The variant POST's stale
updatedOn === createdOn assertion is replaced with an upsert-aware
equality against the same captured global.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v0.80.1
2026-05-15 19:04:41 +02:00
Tobias GesellchenandClaude Opus 4.7 6bc4ee1e73 fix(marge): reject rename PUT mismatch before persisting
HandleMargeUpdateDevice used to call AddDeviceToAccount (an upsert)
and only check body-vs-URL deviceID after the row was already
written. A speaker sending a malformed PUT with the wrong deviceid
attribute would still leave a spurious record before getting 400.

Now we parse just the deviceid attribute, compare against the URL
segment, and only call into the upsert when they match. The
existing regression test gains two GetDeviceInfo assertions to lock
the no-spurious-row guarantee in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:04:41 +02:00
Tobias GesellchenandClaude Opus 4.7 49904635f2 fix(marge): preserve CreatedOn + IPAddress across the device rename PUT
The PUT handler shipped in 5f31616 + the routing fix in 66b83b6 made
the rename PUT reach AfterTouch and return 200. But the response and
the on-disk record both drifted away from real Bose's parity on every
rename: CreatedOn was rewritten to now() (so the "first paired in
2017" semantics evaporated on the second rename) and IPAddress
landed empty (because the speaker's PUT body doesn't carry it and
the marge handler had no preservation path).

Pre-shutdown capture at
data/parity_mismatches/1771797308__streaming_account_3230304_device_A81B6A536A98.json
shows real Bose's 200 OK shape: createdOn pinned to the original
pairing timestamp (2017-02-07), ipaddress populated, only updatedOn
and name change across renames. Aligning with that.

Three small persistence additions:

  - models.ServiceDeviceInfo grows CreatedOn + UpdatedOn (ISO8601
    strings, omitempty so existing JSON consumers don't break).
  - datastore.SaveDeviceInfo persists them inside the DeviceInfo.xml
    payload as <createdOn> / <updatedOn> alongside the other fields.
  - mergeWithExistingDeviceInfo preserves CreatedOn unconditionally
    (it's the "first-paired" timestamp and never re-derived from
    inbound data) and preserves UpdatedOn only if the caller didn't
    set a fresh one.

marge.AddDeviceToAccount becomes precedence-aware:

  - Reads the existing record once at the top.
  - CreatedOn: preserved from existing if present, else now() for
    first registration.
  - IPAddress: preserves what's in the existing record; falls back
    to r.RemoteAddr's host portion only when no prior IP exists.
    Lets first-time PUTs seed an IP from the inbound connection
    without later renames clobbering a known-good value.
  - UpdatedOn: always now().
  - Response XML now re-reads the persisted record so the
    response body matches what's on disk — no parallel hand-built
    XML drifting from the merge result.

Function signature gained a remoteAddr parameter. Both callers
(HandleMargeAddDevice and HandleMargeUpdateDevice) pass r.RemoteAddr.

Test coverage:

  - TestIssue285_RenamePutAcceptedAndPersisted seeds the datastore
    with a 2017 CreatedOn and a known IP, then PUTs the rename;
    asserts both survive on disk AND in the response body, and
    that UpdatedOn refreshes. The same pre-shutdown capture cited
    above is the parity reference.

  - TestIssue285_NewDeviceGetsRemoteAddrAndFreshTimestamps (new)
    covers the no-prior-record path: first-time PUT against an
    unknown device produces CreatedOn = now() and IPAddress
    pulled from the inbound TCP connection. Pins the fallback
    behaviour so it can't quietly stop seeding new devices.

Authorization is still not enforced — the speaker has no Bose token
to send post-shutdown, and we don't (yet) have a token-authority
story of our own. Adding a warn-only auth check is a deferred
follow-up (see NEXT.md). Real Bose returned 401 for this PUT in the
2026-05-15 capture; we knowingly accept anything.

Refs #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:04:41 +02:00
Tobias GesellchenandClaude Opus 4.7 ff96430f53 fix(router): consolidate /device subrouter so PUT and DELETE actually resolve
Issue #285's first fix (5f31616) registered the rename PUT inside a
chi subrouter at `/streaming/account/{account}/device`, alongside the
existing POST handlers. A *second* subrouter was already declared at
`/streaming/account/{account}/device/{device}` for the per-device
sub-resources (presets, recent, group, …). chi's radix tree treats
those two registrations as overlapping prefixes and at request time
prefers the more-specific `/device/{device}` subrouter — which had
no root-level method handlers. A PUT to /device/X fell through to
the [UNHANDLED] catch-all, got proxied to streaming.bose.com, came
back as 401 from CloudFront. Speakers retried in a loop.

The handlers-package regression test passed because the test router
in `pkg/service/handlers/main_test.go` is flatter (one subrouter for
device, no `/device/{device}` nested block). The route snapshot
test passed because `chi.Walk` enumerates each subrouter's
registrations independently — it doesn't simulate how the radix tree
will resolve a runtime request when subrouters overlap.

Reproduced against the actual production setupRouter in
TestPUTRenameRoutesToLocalHandler (new in router_test.go). Before
this commit: 404 / [UNHANDLED] / 401 proxy. After: 200 from
HandleMargeUpdateDevice.

Fix: collapse the two subrouters into one. All `/device` routes —
the POST/PUT/DELETE on the device resource itself plus the GET/POST
sub-resources — share a single `r.Route("/device", ...)` block with
explicit `/{device}/...` paths inside. No radix-tree ambiguity.

Knock-on: the `r.Delete("/device/{device}", server.HandleMargeRemoveDevice)`
that lived at the outer `/account/{account}` level moves into the
unified `/device` subrouter for symmetry. Its prior placement was
also being shadowed by the radix overlap, which is why the route
snapshot's first regeneration after this fix grew by exactly one
DELETE line — that route was never resolvable at runtime under the
old structure either.

Refs #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:08:52 +02:00
Tobias GesellchenandClaude Opus 4.7 596e24595d docs(on-device-install): debugging recipe for the SSH-tunnel + listener trap
Lifts the back-and-forth in issue #250 into the README so the next
user doesn't repeat the same three traps Gustour hit:

  1. The `ssh -L 8000:localhost:8000` command must run on the user's
     own machine, NOT inside the speaker's SSH session. Gustour
     pasted it at the speaker's `root@mojo:~#` prompt; the tunnel
     ended up speaker → speaker (loopback) and did nothing.

  2. SoundTouch firmware offers only ssh-rsa/ssh-dss host-key
     algorithms; modern OpenSSH refuses them by default with
     `Unable to negotiate with <ip> port 22: no matching host key
     type found`. The README's *initial* ssh command already
     uses `-oHostKeyAlgorithms=+ssh-rsa`, but the port-forward
     example didn't — adding it.

  3. If the tunnel is correct and the browser still gets
     ERR_CONNECTION_RESET, the daemon isn't listening. The previous
     README left the user stranded here. Adds the diagnostic ladder
     (`netstat`, `ps`, `logread | grep aftertouch`) that matches
     the syslog-tag pattern shipped in the prior commit, plus the
     `/etc/init.d/aftertouch start` + `status` retry — the new
     status case can now distinguish "PID alive, listener up" from
     "PID alive, listener silently died".

No script changes; pure docs lift.

Refs #250.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v0.80.0
2026-05-15 17:11:02 +02:00
Tobias GesellchenandClaude Opus 4.7 064fe80e18 fix(on-device-install): persistent install path + syslog-based logging
Bundles the install-time hygiene work for issues #268 and #250.

# Install location — #268

Stock SoundTouch rootfs has only a few MB free (~4 MB on the ST20
the reporter captured); the AfterTouch binary is ~12 MB. The previous
flow downloaded into tmpfs (/media/aftertouch) and then `mv`'d the
binary into /opt/aftertouch on rootfs — which fails with
"No space left on device" on any speaker with the standard layout.

install.sh now installs to /mnt/nv/aftertouch by default (the
persistent partition, ~30 MB free on the same captures) and points
/opt/aftertouch at it via a symlink so the init script's hardcoded
DAEMON path keeps working unchanged. Power users can override with
INSTALL_DIR=/some/other/path. The interactive prompt from the
community patch in #268's thread is dropped — STDIN is the curl
pipe under the documented `curl | sh` invocation, so a read prompt
would hang or read garbage.

uninstall.sh is updated to resolve the symlink and remove the
target before unlinking, so the 12 MB binary doesn't get orphaned
on /mnt/nv when users uninstall.

# Logging — #250

Issue #250 surfaced a "running but unreachable" state: the install
script reported AfterTouch as running, the init script's status
agreed, but `curl :8000` returned connection-refused. start-stop-
daemon's --background detaches stdout/stderr, so any panic the
daemon emitted before dying went to /dev/null with no diagnostic
trail.

The fix is to route the daemon's stdout/stderr through `logger -t
aftertouch` so output lands in BusyBox syslog — a bounded in-memory
ring buffer that never grows on disk (writing to a file in /mnt/nv
would have eaten the volume over months). Diagnostic flow is now:

    logread        | grep aftertouch | tail -20
    logread -f     | grep aftertouch     # live tail

Matches the recipe already documented in TROUBLESHOOTING.md for the
speaker's own logs (Curl 7 section).

Tightening on top of the syslog change:

  - The init script's `status` case now also curls localhost:8000
    when the PID is alive — distinguishes "PID alive, listener up"
    from "PID alive, listener silently died" (which is what fooled
    everyone on #250). A bare PID-liveness check returned "running"
    in both cases.

  - install.sh's post-install verification now does its own 10s
    curl probe after the init script returns; on failure it tails
    the aftertouch syslog so the user sees the actual error rather
    than the install script claiming success.

  - `exec` is added inside the start-stop-daemon's shell wrapper so
    --make-pidfile records the daemon's own PID (not the shell's),
    which keeps `stop` semantics correct.

README updated to document the install location, INSTALL_DIR
override, and the syslog tag.

No automated tests — these are shell scripts the install pipeline
runs once on the device. All three scripts pass `bash -n` /
`sh -n` syntax checks. Real validation is end-user retest, gated on
the next release.

Refs #268, refs #250.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:11:02 +02:00
Tobias GesellchenandClaude Opus 4.7 554fa78c0b fix(marge): handle the rename PUT speakers fire at /streaming/account/.../device/{id}
Closes issue #285. When the user renames an ST10 via the Bose App or
via `soundtouch-cli name set`, the speaker fires:

  PUT http://<aftertouch>:8000/streaming/account/{accountID}/device/{deviceID}
  Content-Type: application/xml
  <device deviceid="…"><name>NEW</name><macaddress>…</macaddress></device>

The router only had POST registered for that path; PUT fell through
to chi's default handling and the speaker observed HTTP 502 (captured
verbatim in _/i285/Rename.log:38: "SimpleURLFetcher: retry needed,
Curl 0, http 502, retries remaining 0"). The speaker's SimpleURLFetcher
retried the PUT on a 15-second timer, the Bose App showed the rename
spinning indefinitely, and the device's display name never updated on
the AfterTouch side.

Implementation reuses marge.AddDeviceToAccount, which is already an
upsert via ds.SaveDeviceInfo — there's no semantic difference between
"add" and "update" at the persistence layer. The new handler
HandleMargeUpdateDevice differs from HandleMargeAddDevice only in the
HTTP envelope:

  - 200 OK (not 201 Created — this is an update, not a fresh resource)
  - no Location header (the resource already lives at the URL the
    speaker is PUT-ing to)
  - deviceID in the body must match the URL's {device} segment;
    mismatch is a 400 rather than a silent re-key

Registered as `r.Put("/{device}", server.HandleMargeUpdateDevice)`
inside the existing `/streaming/account/{account}/device/` route
group in both cmd/soundtouch-service/main.go and the handlers-package
test router. Router-routes snapshot regenerated.

Test coverage in pkg/service/handlers/issue285_regression_test.go:

  - TestIssue285_RenamePutAcceptedAndPersisted seeds the datastore
    with a device under its original name, replays the literal log
    payload from _/i285/Rename.log:36 against the real router, and
    asserts 200 OK + new name in response body + new name persisted
    on disk. testdata/issue285/rename_request.xml is the captured
    payload byte-for-byte (accountID 3981561, deviceID 884AEAEEBD27,
    rename to "Wohnzimmer SB" — same as the reporter).

  - TestIssue285_RenamePutRejectsMismatchedDeviceID pins the safety
    check: body deviceid != URL {device} → 400.

Closes #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:48:06 +02:00
Tobias GesellchenandClaude Opus 4.7 6e6e4838e6 fix(setup): fire <sourcesUpdated/> after data sync to recover post-factory-reset sources
Closes the AfterTouch-side half of issue #234. After a factory reset
the speaker's /sources only lists the always-on local entries (AUX,
BLUETOOTH, AIRPLAY, NOTIFICATION, QPLAY, plus a SpotifyConnectUserName
placeholder); TUNEIN, LOCAL_INTERNET_RADIO, DEEZER, and linked
Spotify accounts are absent until the device receives the
<sourcesUpdated/> notification the reporter ran by hand. SyncDeviceData
now POSTs that notification as the final step, so users get the
visible-source-list recovery for free when they click Data Sync.

The other half — re-creating Marge.xml so playback resumes — is
already handled by the wizard's pair-account flow: it detects an
empty <margeAccountUUID/> in /info and prompts the user to pick a
known account or generate a new one. The wizard's pairing UI is
deliberately user-driven (the user picks the ID); the notification
nudge is purely automatic because there's no choice to make.

Implementation routes through the existing client surface rather
than reinventing it. setup.notifySpeakerSourcesUpdated delegates to
pkg/client.Client.NotifySourcesUpdated — the same path
handlers_mgmt.go already uses after music-service account changes
(handlers_mgmt.go:304, :637). The wire shape lives in one place
(pkg/models.NewSourcesUpdatedNotification). Fire-and-forget: a
notification failure logs but doesn't fail the sync.

Adjacent UX changes:

  - docs/guides/TROUBLESHOOTING.md: new section "Presets flash then
    revert to 'Select a preset' after a factory reset". Names the
    symptom, the Marge.xml + reduced-/sources cause, and walks the
    user through re-opening the Migration tab + Data Sync.

  - pkg/service/handlers/web/js/script.js: devices list now renders
    a "⚠ Not paired — re-pair" badge in the account-ID column for
    speakers whose live /info reports an empty margeAccountUUID.
    Clicking it opens the Migration tab pre-filled with that device,
    surfacing the wizard's existing "Not paired (factory-reset or
    never paired)" flow without making users discover it cold.

  - pkg/service/testing/fakespeaker/testdata/info.xml: demo speaker
    now reports margeAccountUUID=1234567 instead of the misleading
    0000000 (which AfterTouch happens to accept as syntactically
    valid but is not a documented sentinel anywhere — the convention
    is empty for factory-reset, a real 7-digit number otherwise,
    matching pkg/client/testdata/info_response_st{10,20}.xml).
    Screenshots regenerated accordingly.

Test scaffolding:

  - fakespeaker grows a POST /notification recorder that captures
    body + Content-Type; tests assert on s.Notifications().
  - TestIssue234_FactoryResetSpeakerSyncsReducedSources now drives
    SyncDeviceData end-to-end (exercises the wiring) and asserts
    the notification fires with the right deviceID and shape.
  - TestFakeSpeakerNotificationRecorder pins the recorder contract
    and the POST-only method gate.

Refs #234.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:25:07 +02:00
Tobias GesellchenandClaude Opus 4.7 61c33d527c fix(setup): atomic CA-bundle install with PEM-frame verification
Hardens TrustCACertFromBytes against the failure mode behind issue
#262 (corrupted /etc/pki/tls/certs/ca-bundle.crt on a SoundTouch 20)
and against silent transport-time corruption of our own writes.
Three-part change.

1. Atomic write path. The previous flow piped bytes straight into the
   live bundle via `cat > <path>`; a dropped SSH session or partial
   write left the device with a half-written trust store and no way
   to roll back. The new path:

     - uploads to <bundlePath>.aftertouch.tmp (sibling on the same
       filesystem, same rw remount),
     - reads the tmp back over SSH,
     - validates the readback at the PEM-frame layer + the AfterTouch
       sentinel bracketing,
     - atomically `mv`s the tmp into place,
     - on any verification failure: `rm -f` the tmp; the live bundle
       is never touched, so there is no rollback semantics to reason
       about.

   The .original backup written on first install stays as
   defense-in-depth (manual recovery for corruption from outside this
   code path), but it is no longer the primary safety net.

2. New validators in pkg/service/setup/ca_validation.go.

     - validateCABundleBytes: BEGIN/END marker counts match, every
       decoded block is a CERTIFICATE with a non-empty body, decoded
       block count equals BEGIN-marker count (catches a block with
       unparseable base64 body), trailing non-PEM/non-comment content
       rejected.
     - validateAfterTouchLabelBracketing: CALabel appears exactly
       twice and brackets exactly one CERTIFICATE block.
     - stripAfterTouchEntries: collapses any number of stale
       AfterTouch entries from the existing bundle. Older releases
       reported to have appended without stripping, so long-lived
       devices can carry several copies; we strip them all and log
       the cleanup count rather than failing validation. Unpaired
       sentinels (truncated prior install) surface as a structured
       anomaly the caller logs and warns about.

   The validators stay at the PEM-frame layer on purpose — an
   earlier iteration called x509.ParseCertificate per block and
   rejected the real ST20 bundle on block 29 (Go 1.23+ disallows
   negative serial numbers, but Mozilla CCADB still ships ancient
   CA roots that have them). Shipping that version would have made
   every legitimate speaker install fail. The corruption mode #262
   surfaces at the PEM-framing layer; x509-level checks aren't what
   we needed.

3. testdata/ca_bundle_st20_pristine.crt is the pristine
   /etc/pki/tls/certs/ca-bundle.crt captured off a real SoundTouch 20
   (firmware 27.0.6.46330.5043500, snapshot 2022-08-04). Mozilla
   CCADB public dataset, 165 certs, ~251 KB. TestValidateRealSpeakerBundle
   locks in the cert count and asserts the strip pass is a no-op
   against a bundle that has never been touched by AfterTouch.

Test infrastructure. mockSSH (both the setup-package and the
handlers-package copies) now mirrors UploadContent into a private
map so a subsequent `cat <path>` on the same path returns what was
written there. Lets the tmp-readback step in TrustCACertFromBytes
work against tests that only scripted the live-bundle path, without
per-test wiring. Two new behavioural tests in setup_test.go:
TestTrustCACert_StripsMultipleStaleEntriesSilently (pins the
multi-entry cleanup contract) and
TestTrustCACert_PostUploadVerificationFailureCleansUpTmp (pins the
rollback-free recovery: live bundle untouched, tmp removed).

Refs #262.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:05:41 +02:00
Tobias Gesellchen 7d3359dfb4 chore(lint) make the linter happy 2026-05-15 14:13:24 +02:00
Tobias GesellchenandClaude Opus 4.7 673be16f4f fix(bmx): restore the Authorization gate on /core02/.../orion/station
f3a4658 dropped the auth check on HandleOrionPlayback while moving
the orion routes to their registry-advertised paths. The rationale at
the time was "data is the speaker's own input, nothing privileged"
and parity with soundcork's reference impl.

On reflection, requiring the Authorization header is the right
default here for two reasons:

  1. Parity with the rest of our BMX playback surface (TuneIn
     variants — see TestBMXUnauthorized's table — all gate on a
     non-empty Authorization header). Orion being the lone unguarded
     exception was a footgun, not a feature.
  2. Real speakers obtain a Bearer token via the orion
     /token endpoint before they follow a LOCAL_INTERNET_RADIO
     preset, so the gate doesn't cost any legitimate caller. A
     callerless GET (curl, scraper, casual probe) gets a clean 401
     instead of a working playback resolver.

The check itself is the same shape as the other BMX handlers:
empty Authorization header → s.writeBMXUnauthorized → 401. Token
contents are not validated, only presence — sufficient for the
parity contract.

Test side:

  - TestOrionPlayback regains its Bearer header (it had one before
    the GET-method switch in f3a4658).
  - TestBMXUnauthorized's table regains a sibling row for the orion
    station endpoint with the GET + query-string shape.
  - TestIssue218_OrionStationResolvesPresetStreamURL sends a Bearer
    header on the loop-closing GET — added with a doc comment
    naming the orion /token bootstrap a real speaker would do.

No route-table changes; the registry advertisement and route paths
from f3a4658 stay as they are.

Refs #218.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:13:24 +02:00
Tobias GesellchenandClaude Opus 4.7 0e10bfcb14 test(setup): wire issue #235 — Spotify Connect /now_playing reports IsPresetable=false
Two-part iteration. First, the fakespeaker grows a `/now_playing`
route with a default STANDBY fixture — issue #235 is the first one in
this series that needs to override /now_playing, and adding the route
on its own would be infrastructure noise; bundled here it has an
immediate consumer.

The regression test then locks in the device-side signal at the heart
of #235: when a SoundTouch is targeted by Spotify Connect (Spotify
app sends audio to the speaker), the speaker's /now_playing reports

  - source = SPOTIFY
  - sourceAccount = SpotifyConnectUserName (the marker)
  - ContentItem.location = /playback/container/<base64 spotify:...>
    — a perfectly resolvable URI
  - **ContentItem.isPresetable = false**

The contradiction (resolvable location + isPresetable=false) is the
reason the CLI's storeCurrentPreset at
cmd/soundtouch-cli/cmd_preset.go:41 refuses to act and emits "current
content cannot be preset" — exactly the reporter's symptom.

The test base64-decodes the location to surface the contradiction
explicitly: it should yield a `spotify:` URI. When AfterTouch grows a
fallback path (CLI --force, or service-side resolution to the
device's own Spotify integration via the SoundTouch Spotify source
provider), the assertion here stays sound — it tests what the device
emits, not what the CLI decides — but a sibling test should assert
the new fallback path produces a successful preset.

Fixture pattern matches the rest of the issue series:
testdata/issue235/ next to the test, fakespeaker driven via
FixtureOverrides, doc-comment naming what would have to change for
the assertion to flip.

Refs #235.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:13:24 +02:00
Tobias GesellchenandClaude Opus 4.7 65a9873545 test(marge): pin the disk→marge half of issue #253 (preset edit propagation)
Issue #253 ("Edits to local Presets.xml don't propagate to
:8090/presets") has a three-hop propagation chain — disk → marge,
marge → device (via notification or power_on), device → :8090. Only
the first hop is in our reach; if it's broken, neither of the others
can recover.

This test writes presets_v1.xml directly to the datastore
(mimicking the reporter's hand-edit), calls PresetsToXML, asserts the
v1 markers (itemName "Initial Station", location s..INITIAL) land in
the rendered bytes. It then overwrites with presets_v2.xml and calls
PresetsToXML again, asserting:

  - v2 markers ("Edited Station", s..EDITED) land,
  - v1 markers are gone.

Current AfterTouch passes both assertions — disk→marge is sound, so
the reporter's symptom must originate downstream (notification
trigger missing, device-side firmware behaviour, or both). That
narrows the investigation surface for whoever picks up #253 next.

If this test ever flips (a caching layer is added without proper
invalidation, an in-memory presets handle is held across edits), the
fix is to invalidate the cache on disk write rather than weaken the
test — that contract is what the reporter relies on.

Pattern mirrors recents_sourceproviderid_regression_test.go: write
XML directly into the temp datastore filesystem and exercise the
marge function the handler calls (PresetsToXML at marge.go:370).
Fakespeaker isn't involved here — the failure surface is server-side,
not in what the device emits.

Refs #253.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:13:24 +02:00
Tobias GesellchenandClaude Opus 4.7 dd535cdb52 test(setup): pin factory-reset behaviour from issue #234
Wires the device-side state the reporter described in
https://github.com/gesellix/Bose-SoundTouch/issues/234 into the
fakespeaker via FixtureOverrides, and exercises GetLiveDeviceInfo +
syncSources against it.

The factory-reset state has two observable signals:

  - `/info` returns an empty `<margeAccountUUID/>` because Marge.xml
    is missing from the persistence partition. AfterTouch's
    "is the device paired?" check at setup.go:632 keys on AccountID,
    so this is the canonical "needs re-pairing" signal.
  - `/sources` lists only AUX, BLUETOOTH, AIRPLAY, the
    SpotifyConnectUserName placeholder, NOTIFICATION, and QPLAY —
    TUNEIN, LOCAL_INTERNET_RADIO, and any post-pairing Spotify
    accounts are gone until the speaker is nudged with a
    `<sourcesUpdated/>` notification or re-pairs.

Today AfterTouch has no auto-recovery for either signal — it just
passes the state through. The test locks in that contract by
asserting:

  - GetLiveDeviceInfo reports an empty MargeAccountUUID,
  - persisted Sources.xml contains AUX/BLUETOOTH/AIRPLAY sourceKeys,
  - persisted Sources.xml does NOT contain TUNEIN/LOCAL_INTERNET_RADIO.

When auto-recovery lands (e.g. an automatic POST of the
sourcesUpdated notification during sync, or marge-side source
replenishment), the absence assertions will flip — at which point
update them to assert the survivors are *present*, and adjust the
doc-comment so the contract stays in sync with the code.

Pattern mirrors pkg/service/setup/issue218_regression_test.go: a
testdata fixture next to the test, fakespeaker driven via
Config.FixtureOverrides, doc-comment naming what would have to
change for the assertion to flip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:13:24 +02:00
Tobias GesellchenandClaude Opus 4.7 13e82bbf85 test(bmx): close the loop on issue #218 — preset URL resolves end-to-end
Pairs with the existing pkg/service/setup/issue218_regression_test.go
"survives sync" assertion. This one takes the exact `location`
attribute the reporter pasted in issue #218 — the cloud URL embedded
in their LOCAL_INTERNET_RADIO preset — parses out the base64 `data`
query payload, sanity-checks it really does encode the documented
http://ais-sa3.cdnstream1.com/2440_128.aac stream URL, then hits the
preset's path-and-query on the real router and asserts the
BmxPlaybackResponse the speaker would receive: audio.streamUrl, name,
streamType, and the streams[] mirror.

Before f3a4658 this test would have 404'd because orion was nested
under the wrong `/bmx/` prefix. With the routing fix in place, the
two issue #218 regressions now bracket the failure end-to-end:

  - setup test (sync side):  the URL is preserved on the way in
  - handlers test (this one): the URL works on the way out

No fix-side code changes; this is purely a regression-protection
addition that documents the contract resolved by f3a4658.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:13:24 +02:00
Tobias GesellchenandClaude Opus 4.7 098b4f59dd fix(bmx): serve orion at the registry-advertised path, drop the /bmx/ prefix
The BMX registry advertises orion at
`{BMX_SERVER}/core02/svc-bmx-adapter-orion/prod/orion` — no `/bmx/`
prefix. That matches the upstream Bose capture in
pkg/service/handlers/static/bmx_services_ustream.json. But our router
nested both orion routes inside the `/bmx/` chi group, so the speaker
asked `/core02/.../prod/orion/token` and our service routed
`/bmx/core02/.../prod/orion/token` — pure path mismatch. The legacy
preset URLs in issue #218 (LOCAL_INTERNET_RADIO presets pointing at
`https://content.api.bose.io/core02/svc-bmx-adapter-orion/prod/orion/station?data=...`)
also dead-ended for the same reason.

Three changes:

- Move `POST /core02/svc-bmx-adapter-orion/prod/orion/token` from the
  `/bmx/` group to top level so it matches what the registry hands the
  speaker.
- Add the missing `GET /core02/svc-bmx-adapter-orion/prod/orion/station`
  that takes `data` as a query string. The handler reuses
  bmx.PlayCustomStream — base64-decode the JSON blob (streamUrl/
  imageUrl/name) and rewrap it into the standard BmxPlaybackResponse
  shape, exactly the way soundcork's reference impl handles it
  (soundcork main.py:786, bmx.py:720). No auth check on this endpoint:
  `data` is the speaker's own preset payload, there's nothing
  privileged to gate, and the upstream behaviour treats it the same way.
- Drop the local-invention `POST /bmx/orion/v1/playback/station/{data}`
  route. Nothing advertised it, nothing real-world called it, and
  keeping it as a "convenience alias" would have left a misleading
  duplicate next to the canonical path.

TuneIn's `/bmx/tunein/...` routes stay where they are — TuneIn's
upstream baseUrl genuinely is `{BMX_SERVER}/bmx/tunein`, so the chi
group prefix is correct for that one.

Router snapshot regenerated; TestOrionPlayback flipped from
POST `/bmx/orion/v1/playback/station/{data}` to GET
`/core02/...station?data=...` (no auth header); the orion entry in
TestBMXUnauthorized's table is removed (the endpoint isn't authed
anymore, by design).

Refs #218.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:13:24 +02:00
Tobias GesellchenandClaude Opus 4.7 2fabdece64 test(fakespeaker): wire issue-specific payloads via Config.FixtureOverrides
Introduces a per-route fixture-override hook on fakespeaker.Config so
open issues with concrete device-side payloads can become repeatable
regression tests, then demonstrates the pattern by wiring issue #218.

Foundation. Config grows a single optional field:

  FixtureOverrides map[string][]byte

Routes named in the map (e.g. "/presets", "/sources", "/info") return
the supplied bytes; routes not in the map fall through to the embedded
testdata defaults the screenshot pipeline relies on. Stateful handlers
(/getGroup, /addGroup, /updateGroup, /removeGroup) are unaffected
because they're code-driven, not fixture-driven. The override slice is
snapshotted at construction so later mutations of the caller's slice
don't change the served body. Zero-value Config keeps the existing
behaviour, so cmd/dummy-speaker + scripts/screenshots are untouched.

Iteration zero — issue #218.
pkg/service/setup/issue218_regression_test.go starts a fakespeaker
serving the reporter's LOCAL_INTERNET_RADIO preset XML verbatim (URL:
content.api.bose.io/core02/svc-bmx-adapter-orion/prod/orion/station?…),
runs Manager.syncPresets against it, then asserts the persisted
Presets.xml retains the Bose cloud URL prefix. This locks in the
"location preserved through sync" contract; when AfterTouch starts
rewriting the URL to its own base (the eventual fix for #218), the
assertion flips and the fixture stays unchanged — the test is the
carrier for the decision.

Pattern reference for future issue regression tests: this exemplar
mirrors pkg/service/marge/recents_sourceproviderid_regression_test.go's
style (issue link, trigger chain in the doc-comment, locked-in
assertion) but is the first one to drive the device side via fakespeaker
rather than an inline httptest.NewServer. Subsequent issues with
device-side payloads (#234 factory-reset state, #235 Spotify-as-preset,
…) can reuse the FixtureOverrides hook without further infrastructure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:13:24 +02:00
TonyandTobias Gesellchen 6196a802e2 add new format in tunein query 2026-05-15 14:04:32 +02:00
Frank WandTobias Gesellchen 996faa0578 API uses "playback", not "playbook" 2026-05-15 13:45:54 +02:00
Tobias GesellchenandClaude Opus 4.7 5ba0776787 Bump install scripts to v0.79.0
on-device-install and raspberry-pi installers default to the new
v0.79.0 release binary. Also refreshes two stale comment examples in
the raspberry-pi install script (v0.17.0 → v0.78.0, v0.18.1 → v0.79.0)
so the in-file usage hints reflect the same era as the default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 12:56:43 +02:00
Tobias GesellchenandClaude Opus 4.7 abae685a85 fix(screenshots): widen fakespeaker coverage and stabilize the pipeline
make screenshots was producing artifacts: a ghost Spotify pill on
ui-devices, empty Plan-card URL inputs on ui-migration with cascading
"localhost" warnings, and "Checking configuration…" placeholder text
instead of " Not configured" on ui-settings. Two root causes, fixed
together so the run is deterministic again.

1. Fakespeaker too thin for the post-wizard inspect pipeline. The new
   migration wizard probes /supportedURLs and reads /networkInfo and
   /sources alongside the existing /info, /presets, /recents. Those
   routes now exist with sanitized fixtures (deviceID DEADBEEFCAFE,
   loopback IPs, no real MACs or account IDs). The full group endpoint
   set is also wired: /getGroup and /removeGroup return the empty
   <group/> shape a real un-paired device emits; /addGroup and
   /updateGroup echo the posted body with <status>GROUP_OK</status>
   inserted before </group>, matching the success path documented in
   issue #252. /supportedURLs lists everything the fake now serves so
   any caller that probes capabilities first (e.g. marge_pairing.go)
   sees a coherent picture. Tests cover the GET routes' XML roots, the
   POST echo + GROUP_OK insertion contract, and /removeGroup's
   GET-only contract (405 with Allow: GET on other methods).

2. run.sh seed hit a DNS cliff. The :443 preflight shipped in 3727ae6
   resolves server_url on every /setup/settings call, and the
   populatePlannedNetworkConfig step does it again. With the previous
   seed of http://aftertouch.local:8000 each lookup burned ~5s on DNS
   timeout, which compounded across the wizard calls and pushed
   ui-migration past chromedp's 30s per-shot budget. Switched the seed
   to http://aftertouch.localhost:8000 — RFC 6761 means *.localhost
   resolves to loopback via the system resolver in milliseconds
   (verified ~8ms on macOS / glibc / systemd-resolved) — so the brand-
   friendly hostname survives in the captured PNGs without the
   timeout. Manifest settle times bumped (ui-settings 300→2000ms,
   ui-devices 500→2500ms, ui-sync 300→1000ms) to give fetchSettings +
   fetchSpotifyStatus time to complete in headless Chrome.

While here, softened validateURL's loopback message to acknowledge the
on-device-install case (AfterTouch running on the speaker itself, where
loopback works) instead of unconditionally telling users they're
wrong. The validation still flags 127.0.0.1 / localhost since it's the
wrong answer 99% of the time, but the message now frames the
constraint rather than scolding.

docs/images/ui-*.png regenerated against the new pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 12:56:43 +02:00