Commit Graph
223 Commits
Author SHA1 Message Date
Tobias GesellchenandClaude Opus 4.7 29d611f9c1 feat(health): aggregate device-summary panel on Devices tab
Audit item #1 (11+ recurrences in issues / discussions): pull
speaker /info + /sources + /presets, plus service-side state
and pairing inference, into one view per device.

Backend: GET /setup/device-summary/{deviceId} probes the three
speaker endpoints concurrently (sync.WaitGroup, 3 s per probe)
and merges the result with what the datastore knows for the
same device. Partial failures don't break the response — each
sub-section carries its own reachability + error + curl_command
so the UI can render copy-paste fallbacks when the service host
can't reach the speaker.

JSON shape covers four panels:
  - device      identity + firmware
  - speaker     {info, sources, presets} with raw outcomes
  - service     server URL, expected hosts, Sources.xml /
                Presets.xml presence and counts
  - pairing     paired flag, marge host, host match

UI: new "Inspect" button per row on the Devices tab. Clicking
expands a sibling row with five summary cards (info / sources /
presets / service / pairing). Each unreachable card renders the
matching curl command with a Copy button — same dual-mode
pattern as Health findings. Closes the gap operators were
filling by manually concatenating curl output across the three
speaker endpoints when filing bug reports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 147a69d1c3 refactor(ding): synthesise on demand instead of vendoring the WAV
Move the ding renderer into pkg/service/ding so it can run both
at request time (from the new HandleDing handler) and offline
(from the existing scripts/gen-aftertouch-ding CLI, now a thin
wrapper around the same package).

- GET /media/aftertouch-ding.wav synthesises on first call,
  caches the default-options bytes via sync.Once, and accepts
  query-string overrides for every knob (pitch-{high,mid,low},
  chirp-ms, gap-ms, attack-ms, release-ms, sample-rate, peak).
  Invalid / out-of-range values silently fall back to defaults.
- Embedded WAV is gone from VCS — no 52 KB binary in the
  repo, and tweaking the sound is now a query-param away rather
  than a regenerate-and-commit cycle.
- Health-tab playback_test check is unchanged: the URL it
  references (/media/aftertouch-ding.wav) keeps the same shape,
  the handler just produces the bytes dynamically now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 dee5a0146c feat(health): check speaker <margeURL> against configured hosts
For each device, probe /info and extract the <margeURL> the
speaker is configured to talk to. Compare the hostname against
the service's expected-hosts list (serverURL host +
httpsServerURL host + --tls-extra-host values).

When the speaker is pointed at a host AfterTouch doesn't claim,
emit a warning with two pieces of context:
  - the actual <margeURL>, so the operator sees the drift
  - a copyable `soundtouch-service --tls-extra-host=<host>`
    suggestion, which is the right fix when the speaker should
    keep talking to AfterTouch via the unexpected hostname (the
    other fix is re-migration, which is mentioned in the details).

Reachability / parse failures are intentionally silent here —
speaker_info_reachable already covers those, no need to double-warn.

Required plumbing: Server.SetExpectedHosts so main.go can pass
config.domains in, plus an ExpectedHosts() getter the closure-form
registration reads at run time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 c3723dc0e6 feat(service): add Logs tab streaming the live stderr trace
Cloud-deploy operators on Discussion #295 needed to leave the
admin UI for docker logs / journalctl to see what the service was
doing. Mirror log.Default() output into an in-memory ring buffer
and expose it under /setup/logs so the admin UI can show a live
trace alongside the existing tabs.

The buffer is a second sink under log.SetOutput(io.MultiWriter(
os.Stderr, buf)) — stderr keeps receiving every line verbatim,
so docker logs / journalctl are unaffected. Default capacity
2000 lines (~400 KB), tunable via SOUNDTOUCH_LOG_BUFFER_LINES.

- pkg/service/logbuf: io.Writer ring with \n splitting,
  partial-line buffering, monotonic Seq, Since(since, limit)
  reporting dropped count when the caller falls behind.
- New /setup/logs (GET) returns {entries, nextSince, dropped,
  capacity}. Polls at 1.5s while the tab is active; paused on
  document.hidden.
- "8. Logs" tab with substring filter, tail-follow toggle
  (auto-disables when the user scrolls up), monospace dark view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:12:25 +02:00
Tobias GesellchenandClaude Opus 4.7 f791145976 feat(service): add Health tab with datastore checks and quick fixes
Discussion #295 surfaced that a paired device without Sources.xml
silently breaks playback — /full omits TUNEIN and selection fails
with 1005. initializeDefaultSources only runs at startup over
existing devices, so a device that checks in later is never
seeded.

Add a Health tab to the admin UI that runs registered checks
against the datastore and offers one-click remediations. The
first check flags missing Sources.xml per device; its quick fix
writes the canonical defaults via SaveConfiguredSources. The
check/fix registry is designed so adding Presets.xml,
Recents.xml, or future reachability probes is a one-file diff.

- New /setup/health (GET) and /setup/health/fix (POST) routes
- pkg/service/health: Registry, Check, Finding, QuickFix types
- Sources.xml-present check + create_default_sources fix
- "7. Health" tab in pkg/service/handlers/web/

Inspired by issue #327's MAINTENANCE tab proposal; curl/URL
helper content from that issue can slot into the same tab in
a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:00:16 +02:00
Tobias Gesellchen 68760a8977 feat(service): add --tls-extra-host for additional TLS cert SAN entries
The leaf cert generator already routes IP-shaped entries into the
IPAddresses SAN, and getDomains already feeds it the hostnames parsed
from --server-url and --https-server-url. Add an explicit
--tls-extra-host flag (repeatable, env TLS_EXTRA_HOST) for the
remaining cases: multi-homed hosts, reverse-proxy frontends, or
browsing the admin UI via a LAN IP that isn't part of the configured
server URLs.

Resolves the ERR_CERT_COMMON_NAME_INVALID Chrome refuses when the URL
bar hostname (e.g. the host's LAN IP) isn't in any cert SAN, even
when the local CA is trusted.
2026-05-18 23:09:28 +02:00
Tobias Gesellchen b95bdae751 feat: Add RadioBrowser integration alongside TuneIn support
- Refactors BMX service to support multiple radio providers
- Adds RadioBrowser.com API integration with search and browse
- Splits TuneIn logic into separate module for better organization
- Adds new web UI components for radio station discovery
- Includes new SVG icons for RadioBrowser branding
2026-05-18 22:34:26 +02:00
Tobias GesellchenandClaude Opus 4.7 7e696ea000 refactor(soundtouch-web): package owns discovery + route registration
Replays app's commit-1 architectural restructure onto current main —
mechanical move only, behaviour preserved verbatim. main.go shrinks
from 333 to ~190 lines; the binary now orchestrates lifecycle and
flag handling, the package owns the WebApp's responsibilities.

Moves (no logic change vs the previous main.go bodies):

  main.go addDevice         → (*WebApp).AddDeviceByHost in discovery.go
  main.go discoverDevices   → (*WebApp).DiscoverDevices in discovery.go
  main.go setupRoutes       → (*WebApp).Mount(r, ds) in mount.go
  inline serveIndex closure → (*WebApp).serveIndex in mount.go

New helper:

  soundtouchweb.NewDiscoveryService(interfaceName) wraps
  config.LoadFromEnv + cfg adjustments + NewUnifiedDiscoveryService.
  Single source of truth for the web UI's discovery settings;
  identical to the inline wiring main.go used to do.

main.go still owns (kept verbatim, post-base on main):

- --port / --bind / --interface / --devices flags
- resolveBindAddr (NIC-name → IP resolution for --bind)
- defaultDiscoveryInterface (--bind ↔ --interface defaulting)
- Startup goroutine sequence: broadcast start → preseed loop
  (AddDeviceByHost for each --devices entry) → DiscoverDevices →
  broadcast complete + device list
- http.ListenAndServe

Behaviour parity checklist:

- Routes registered: identical set (see Mount). /api/discover still
  reuses the startup discoveryService instance, same as before.
- Preseeded --devices still added BEFORE the mDNS/UPnP sweep, so the
  UI doesn't briefly show empty for hosts that come from --devices.
- Discovery interface still pinned via --interface (or inherited from
  --bind), threaded through NewDiscoveryService.
- Static FS still served at /static/*, SPA fallback at / /devices
  /device/* still hits the same index.html.

go build ./... clean. go test ./... clean (only pre-existing
TestDocsConsistency fails, untracked-file issue, unrelated).
golangci-lint run ./... 0 issues.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:34:26 +02:00
Tobias GesellchenandClaude Opus 4.7 9c5ba43fb3 feat(soundtouch-web): swap vanilla Bootstrap UI for Preact+htm SPA
Brings forward the frontend rewrite from the `app` branch
(6723515 + later refinements) onto the relocated package layout.
The Go side untouched — main.go's orchestration, discovery, routes,
and handlers all remain. Only the static-asset layer changes.

Frontend (lives in pkg/service/soundtouchweb/static/):

- index.html (importmap-driven, ES modules, no build step)
- css/app.css (CSS-custom-property design system, dark by default)
- js/api.js (typed-ish fetch wrappers)
- js/app.js (Preact App shell: routing, toast, websocket reconnect)
- js/components/{DeviceList,NowPlaying,Controls,Presets,Sources,
                 Recents,Zone,TuneInBrowser}.js
- img/favicon.{ico,svg}
- lib/{preact,preact-hooks,htm}.module.js (vendored ES modules)

Backend wiring:

- New pkg/service/soundtouchweb/embed.go exports `StaticFS embed.FS`
  via `//go:embed static`. main.go drops its own `//go:embed` and
  consumes `soundtouchweb.StaticFS` instead, so the static tree
  lives alongside the handlers it serves.
- cmd/soundtouch-web/static/{index.html,css/app.css,js/app.js} are
  deleted; the old `cmd/soundtouch-web/static/` directory is empty
  now and removed entirely.

Path rename vs. app branch:

- app's importmap pointed at `/static/vendor/preact*.js` and the
  vendor files were never committed because `.gitignore:44 vendor/`
  silently masked them. Renamed to `/static/lib/` to escape the
  global rule and `git add`-ed the three modules.

Known regressions vs. main's vanilla UI (acceptable for this commit;
flag in review or follow-up if any matter):

- Per-card power toggle on the device list — Preact only exposes
  power inside the device-detail view, not on the list card.
- WebSocket reconnect uses `location.reload()` after 5s; main had
  exponential backoff. Functional, simpler, less elegant.
- Theme icon control absent (Preact UI is dark-only via CSS vars;
  no light-mode toggle).

Features carried over and confirmed at the route-shape level:
device list / device detail / nowPlaying / volume+key+power controls
/ presets / sources / TuneIn search + browse + play / discovery /
toasts / WebSocket status updates.

go build ./... clean. go test ./... clean (only pre-existing
TestDocsConsistency fails). golangci-lint run ./... 0 issues.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:34:26 +02:00
Tobias GesellchenandClaude Opus 4.7 42257aeebc refactor(soundtouch-web): relocate handlers/webtypes to pkg/service/soundtouchweb
Mechanical relocation only — zero semantic change. Sets up the package
layout that the future Preact-UI rewrite (branch `app`) wants, while
preserving every line of main's current logic. Subsequent commits will
land the additive parts (frontend rewrite, recents, zones, bass control)
on top of this clean base.

Moves (`git mv`, content unchanged except package decl):

  cmd/soundtouch-web/handlers/handlers.go      → pkg/service/soundtouchweb/handler.go
  cmd/soundtouch-web/handlers/handlers_test.go → pkg/service/soundtouchweb/handler_test.go
  cmd/soundtouch-web/handlers/websocket.go     → pkg/service/soundtouchweb/websocket.go
  cmd/soundtouch-web/handlers/registry_test.go → pkg/service/soundtouchweb/registry_test.go
  cmd/soundtouch-web/webtypes/types.go         → pkg/service/soundtouchweb/webtypes/types.go
  cmd/soundtouch-web/webtypes/types_test.go    → pkg/service/soundtouchweb/webtypes/types_test.go
  cmd/soundtouch-web/webtypes/status_test.go   → pkg/service/soundtouchweb/webtypes/status_test.go
  cmd/soundtouch-web/static/img/tunein-{dark,mono}.svg → pkg/service/soundtouchweb/static/img/

Adjustments:

- `package handlers` → `package soundtouchweb` in the 4 moved handler-tier
  files (plus their package-doc comments).
- Import paths rewritten in cmd/soundtouch-web/{main.go,spa_test.go} and
  in the moved files themselves: cmd/soundtouch-web/{handlers,webtypes}
  → pkg/service/soundtouchweb/{,webtypes}.
- `handlers.` selector renamed to `soundtouchweb.` in the callers.
- `.golangci.yml` errcheck waiver extended from `cmd/.*\.go` to also
  cover `pkg/service/soundtouchweb/.*\.go`. Same code that the
  cmd-tier waiver applied to; same waiver follows it. Documented as
  a carry-over with the intent to tighten in a follow-up review.

Not changed:

- `cmd/soundtouch-web/main.go` keeps the `//go:embed static` pointing at
  the still-vanilla `cmd/soundtouch-web/static/`. The frontend rewrite
  (Preact UI) lands in a later commit; this one is mechanical.
- `cmd/soundtouch-web/resolve_bind_addr_test.go` stays put — it tests
  main.go-local flag plumbing.

go build ./... clean. go test ./... clean (only pre-existing
TestDocsConsistency fails, untracked-file issue, unrelated).
golangci-lint run ./... 0 issues.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:34:26 +02:00
Tobias GesellchenandClaude Opus 4.7 702092d772 chore: sweep example LAN IPs to RFC-5737 in source and config files
Completes the docs-tier RFC-5737 rollout by sweeping the remaining
192.168.1.x references that lived outside .md / .txt / test files:

  - .env.example                                — active PREFERRED_DEVICES default + examples
  - .github/ISSUE_TEMPLATE/*.yml + workflows    — issue template + CI examples
  - cmd/websocket-demo/main.go, doc.go          — top-level docs
  - examples/*/main.go (7 files)                — example program comments
  - pkg/client/client.go                        — godoc examples
  - pkg/models/doc.go                           — package godoc
  - pkg/service/{amazon,spotify,zeroconf}/zeroconf.go — godoc comments
  - pkg/service/handlers/web/index.html         — placeholder text in the UI
  - scripts/prepare-release.sh                  — example invocations
  - scripts/spotify/spotify-prime-speaker.sh    — usage comment
  - tests/integration/http-client/http-client.env.json — fixture IPs

Same mapping as the docs commit (136d24a): 192.168.1.X → 192.0.2.X
preserving the last octet.

One semantic carve-out: the three zeroconf `zcBaseURL` godoc comments
in pkg/service/{amazon,spotify,zeroconf}/zeroconf.go switched to
192.168.10.10 instead of the doc range, because validateZcBaseURL
only accepts RFC-1918 / loopback / link-local. The comment must show
a value the validator actually accepts — see the matching test fix
in 92f66a2 for the same reason.

go build ./... clean. go test ./... clean except the pre-existing
TestDocsConsistency (untracked DEVICE-LOCAL-INSTALL.md, unrelated).
golangci-lint run ./... — 0 issues after a gofmt fix on
examples/zone-slave-operations/main.go.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:05:13 +02:00
Tobias GesellchenandClaude Opus 4.7 feadc478d5 test: sweep example data in test files to RFC-5737 + placeholders
Mirrors the .md/.txt sweep across all tracked _test.go, testdata XML,
and .http integration files. Test files are self-contained (producer
+ assertion in the same file), so the matched-pair swap stays green
under `go test ./...`.

Mapping applied:
  192.168.178.[0-9]+   → 192.0.2.[same]
  192.168.1.[0-9]+     → 192.0.2.[same]
  Sound Machinechen    → Living Room SoundTouch
  A Sound Machine      → Kitchen SoundTouch
  A81B6A536A98 + case/separator variants → AABBCCDDEEFF (etc.)
  A81B6A849D99         → AABBCCDDEE01
  A81B6A849D88         → AABBCCDDEE03
  A81B6A536A09         → AABBCCDDEE04
  884AEAEEBD27         → AABBCCDDEE02
  3230304              → 1000001
  9569497              → 1000002

Two semantic fixes alongside the bulk swap:

- pkg/service/zeroconf/zeroconf_test.go: the "private 192" and
  "strips query" cases pin acceptance of RFC-1918 192.168/16. They
  must use a real 192.168 value; doc-range IPs would (correctly) be
  rejected by validateZcBaseURL. Switched to 192.168.10.10 — generic
  enough not to match any home LAN default, real enough for the
  validator. Added a comment explaining why this single test still
  carries a 192.168 literal.

- pkg/service/setup/setup_test.go: TestTestDNSRedirection mocks the
  device's `od -An -tu1` byte output, which is space-separated
  octets ("192 168 1 100"). My sed only matched the dot-separated
  form, so the mock was returning the old IP while the test
  assertions had moved to the doc range. Updated to " 192 0 2 100".

go build ./... clean. go test ./... clean (only TestDocsConsistency
remains failing, which is a pre-existing/untracked-file issue).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:05:13 +02:00
Tobias GesellchenandClaude Opus 4.7 1b21e0eaa8 docs: sweep example LAN IPs to RFC-5737 documentation range
Phase 4 of the docs portion of the rfc-5737-cleanup. Replaces all
192.168.1.x example IPs in tracked .md / .txt files with the
equivalent last-octet under 192.0.2.x.

192.168.1.x is RFC-1918 private space and routes on real networks,
which leaves readers guessing whether a documented IP is a placeholder
or a documented LAN. 192.0.2.0/24 is reserved by RFC 5737 exclusively
for documentation — readers know on sight that they're examples.

58 files touched, 551 line pairs. Includes .github issue/PR templates,
all docs/ references, example READMEs, and one script doc. No code
changes, no test changes; test files still carry the 192.168.1.x
placeholder pending Phase 2 in _/RFC-5737-cleanup/assessment.md.

Also fixed a small fallout in docs/analysis/ANONYMIZATION-SUMMARY.md
where the explanatory sentence "a reader can't tell whether
192.168.1.10 is a placeholder or a documented LAN address" had
itself been swept by the regex (inverting the point); restored the
literal example and noted the sweep progress inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:05:13 +02:00
Tobias GesellchenandClaude Opus 4.7 51d196dd03 docs: replace personal LAN IPs and device names with placeholders
Public-repo hygiene: docs and READMEs carried the maintainer's home
LAN range (192.168.178.x) and personal speaker names ("Sound
Machinechen", "A Sound Machine"). Swapped to RFC-5737 documentation
IPs (192.0.2.x — reserved for examples, won't collide with anyone's
real network) and generic names ("Living Room SoundTouch",
"Kitchen SoundTouch").

12 files touched, all .md / .txt documentation. No code or tests
changed in this commit; subsequent commits will address the
docs/analysis/ANONYMIZATION-SUMMARY.md mapping log and the wider
real-MAC/real-account-ID footprint surfaced by the audit at
_/RFC-5737-cleanup/assessment.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:05:13 +02:00
Tobias GesellchenandClaude Opus 4.7 f8108b0dd9 refactor: rename /setup/proxy-settings → /setup/logging-settings
After the proxy/mirror removal there is no proxy left in the service,
but the parallel partial-update endpoint /setup/proxy-settings stuck
around with its legacy name. It serves a legitimate purpose distinct
from the bulk /setup/settings POST: the three checkboxes
(Redact / Log Bodies / Record) use onchange-triggered live save,
while /setup/settings drives a Save-button form for dozens of fields.
Folding the two endpoints together would either lose the live-toggle
UX or send half-edited draft form data on every toggle, so the
partial-update endpoint earns its keep — it just needed the right
name.

Renamed symbols (no behaviour change):

  Go handler funcs:
    HandleGetProxySettings      → HandleGetLoggingSettings
    HandleUpdateProxySettings   → HandleUpdateLoggingSettings
    GetProxySettings            → GetLoggingSettings

  Route:
    /setup/proxy-settings       → /setup/logging-settings

  JS:
    fetchProxySettings()        → fetchLoggingSettings()
    updateProxySettings()       → updateLoggingSettings()

  HTML element IDs (cosmetic, kept consistent):
    proxy-redact / proxy-log-body / proxy-record
                                → logging-redact / logging-log-body / logging-record

  HTML heading:
    "Proxy Logging:"            → "Logging:"

JSON payload shapes (request + response keys) are UNCHANGED: the
endpoint still emits / accepts {"redact", "log_body", "record"}.
Persisted Settings on disk are UNCHANGED. CLI flags are UNCHANGED.
Server struct fields redactLogs / logBodies / recordEnabled
(renamed earlier this session) are UNCHANGED.

testdata/router_routes.txt regenerated. go build clean. go test
./... clean except pre-existing TestDocsConsistency (untracked-file
issue, unrelated). golangci-lint 0 issues.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:53:33 +02:00
Marcin MennemannandTobias Gesellchen 2747d95a8f remove: proxy forwarding to Bose upstream 2026-05-17 21:53:33 +02:00
Marcin MennemannandTobias Gesellchen 0f0a96c0ce remove: mirror middleware and parity comparison with Bose cloud 2026-05-17 21:53:33 +02:00
Tobias GesellchenandClaude Opus 4.7 cc7675a07c feat(tunein): play stations/episodes/programs via cli source tunein (#226)
Add a `soundtouch-cli source tunein` subcommand that takes a TuneIn
guide ID and routes it through the right SelectContentItem shape —
`--station`, `--episode`, `--program`, or `--id` with prefix
auto-detect. The flag picks the ContentItem Type (`stationurl` for
stations/episodes, `tracklisturl` for programs) and the location
template, then enriches the now-playing metadata from TuneIn's describe
endpoint unless `--no-lookup` is set.

Program IDs (`p<N>`) are containers, not streams. The legacy OPML
`Tune.ashx?id=p<N>` returns `#STATUS: 400`, which pre-filter went out
to the speaker verbatim. Fix in three layers:

  1. `parseTuneInStreamBody` filters `#`-prefixed comment lines out of
     Tune.ashx responses and errors when nothing playable remains, so
     a broken TuneIn reply surfaces as a real 500 instead of corrupting
     the playback response.
  2. `TuneInPlaybackPodcast` expands `p<N>` to its newest episode via
     `api.radiotime.com/profiles/{id}/contents` (same JSON shape as
     api.tunein.com; uses the radiotime mirror so all program traffic
     stays on the host already in `allowedTuneInHosts`).
  3. `tuneInSearchProfile` (Program search items) and
     `TuneInNavigateProfile` (program detail hero) now emit
     `BmxPlayback` links, so soundtouch-web renders play buttons on
     program cards and on the profile hero — clicking either plays the
     latest episode via the same backend expansion.

Tests pin the parser contracts (`#STATUS: 400` filter, program-contents
episode pick) and the navigate Program-only playback emission. CLI
resolver has table-driven coverage for kind selection, prefix
auto-detect, and conflicting-flag errors.

Endpoint contract + raw probe responses captured under
`_/i226/tunein-api-findings.md` and `_/i226/tunein-probe/` for future
reference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:29:48 +02:00
Tobias GesellchenandClaude Opus 4.7 0038db35d3 test(service): regenerate router_routes golden after SiriusXM routes
The new HandleSiriusXMLiveAdapter and HandleSiriusXMLiveAdapterSubpath
routes were registered via r.HandleFunc (every HTTP method) at the top
level in main.go. The router-shape golden file gets one entry per
(method, path) pair, so SiriusXM adds 14 lines across CONNECT / DELETE
/ GET / HEAD / OPTIONS / PATCH / POST / PUT / TRACE.

Pure regeneration — no behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:05:39 +02:00
Tobias GesellchenandClaude Opus 4.7 2df0adf4e3 feat(bmx): SiriusXM live-adapter logging stub
bmx_services.json advertises SIRIUSXM_EVEREST at
`{BMX_SERVER}/core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter`
and bmx_services_availability.json lists it as available, so speakers
that try SiriusXM hit that path. Without a route we 404'd silently
and the call was invisible in our logs.

  - HandleSiriusXMLiveAdapter at the bare base URL returns the
    SIRIUSXM_EVEREST service descriptor (selected by id.name from
    bmx_services.json, with {BMX_SERVER}/{MEDIA_SERVER} substitution).
    Mirrors deborahgu/soundcork main.py:805 in shape.

  - HandleSiriusXMLiveAdapterSubpath catches every sub-path advertised
    by the descriptor's _links (/availability, /token, /navigate,
    /logout) plus the playback URLs the speaker discovers via navigate.
    Logs the request with method+path+UA+Authorization+RawQuery, then
    404s — giving the next implementation pass concrete data about
    what the speaker actually asks for.

Two helpers added to handlers_bmx.go (shared with any future
BMX-segment stub):

  - extractBMXService(json, name) — find a service entry by id.name.
  - (*Server).applyBMXTemplate(content) — {BMX_SERVER}/{MEDIA_SERVER}
    substitution, identical to what HandleBMXRegistry does inline.

Routes registered next to Orion at the top level — same convention
(no /bmx/ prefix) because bmx_services.json advertises baseUrl without
that prefix and speakers reach the path verbatim under either
migration mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:05:39 +02:00
Tobias GesellchenandClaude Opus 4.7 9f260a60ea fix(service): /favicon.ico now serves from the embedded web bundle
The /favicon.ico route was redirecting r.URL.Path to
"/media/favicon-braille.svg" and calling HandleMedia. HandleMedia
strips "/media" and serves from the embedded static/media/ subtree —
which does not contain a favicon. The actual asset lives under the
embedded web/img/ subtree (see the `web/img/favicon-braille*` embed
directive in handlers_media.go).

Repoint to "/web/img/favicon-braille.svg" + HandleWeb. http.FileServer
inside HandleWeb finds the file at its native embed path and serves
it with the right Content-Type.

Pre-existing bug exposed by Stockholm because that frontend triggers
a /favicon.ico request from every loaded page; without this fix the
browser fills the console with a 404 on every Stockholm view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:05:39 +02:00
Tobias GesellchenandClaude Opus 4.7 c9eefc7e84 fix(stockholm): match setupRouter signature in router_test
setupRouter gained a *stockholm.Handler parameter on this branch, but
the test left over from the previous signature still called it with
one argument, breaking `go vet ./...`. Pass nil — Stockholm is opt-in
and not exercised in this test.

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

Changes:

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:16:53 +02:00
Tobias GesellchenandClaude Opus 4.7 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 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 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 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 Gesellchen 7d3359dfb4 chore(lint) make the linter happy 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 3727ae6f0f feat(service): pre-flight :443 reachability check with UI surfacing
Speakers connect to Bose hostnames over implicit HTTPS (:443) while
AfterTouch's listener defaults to :8443. Without iptables / setcap /
reverse-proxy in front, the speaker side sees Curl 7 / connection
refused and AfterTouch's HTTP log stays silent — a recurring source
of confusion (see #214, #269).

Add a server-side probe (Check443Reachability) that dials both
localhost:443 and the DNS-resolved LAN IP on :443. Run it once at
service startup with a 2s timeout and emit a [WARN] log with the
exact iptables/setcap commands keyed to the configured listener port.
Expose the result via GET /setup/settings (with a shorter inline
timeout) so the web UI renders a / line next to Target Domain
and a complementary browser-side fetch probe — the browser sits on
the LAN exactly where speakers do, and timing-to-error distinguishes
TCP refused from TLS handshake started even with an untrusted CA.

Both the startup WARN and the UI row are gated on dns_enabled,
since :443 only matters for the DNS migration path; SDK-override
migration uses the port from the configured URL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:03:12 +02:00
Tobias GesellchenandClaude Opus 4.7 c3422ed0d5 fix(marge): accept trailing slash on POST /streaming/account/{id}/group/
SoundTouch 10 firmware 27.x posts the addGroup payload to the Marge
URL with a trailing slash ("/streaming/account/<id>/group/") when the
master is forming a stereo pair. AfterTouch only registered the no-
slash form, so chi returned 404, the master's MargeClient retried
every 15 s, the slave kept connecting to the master's audio transport
but was rejected with "Group STP NOT FOUND" because the master never
finished AddingMaster, and the group eventually reverted -- the symptom
reported in #252.

Register POST /group/ alongside POST /group in both Marge route trees
(the /marge/streaming/... mount and the bare /streaming/... mount that
serves direct device traffic). The GET device-group routes already had
both forms; this brings the POST in line.

Add TestMargeAddGroup_FromSpeakerCapture, which replays the exact
request captured live from BirdyBA's master log: URL with trailing
slash, Authorization Bearer header, vendor Content-Type, and the
minimal XML body (no <senderIPAddress>, no per-role <ipAddress>, no
<status>, no numeric group id). The test failed with 404 before this
change and now returns 201 Created with the proper Location header,
pinning the exact wire contract so future refactors fail loudly.

Refs #252

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:05:14 +02:00
Tobias GesellchenandClaude Opus 4.7 cf62057a26 fix(cli): omit senderIPAddress on master's /addGroup payload
The speaker's GroupService state machine uses the presence of
<senderIPAddress> in the addGroup payload to decide whether it should
form the group as master or join as slave: "SenderIp is provided, I am
the slave". Sending the same XML to both speakers (with senderIP set to
the master's IP) made the master also conclude it was the slave, enter
AddingSlave, time out after 5 s waiting for a master that never
confirmed, and revert. The slave briefly showed GROUP_OK before
following the master back to NoGroup -- the "stereo pair appears for a
few seconds, then disappears" symptom reported in #252.

Send two distinct payloads from propagateAddGroup: the master receives
the base request with no senderIPAddress, the slave receives a copy
with senderIPAddress set to the master's IP. The base request built by
createGroup no longer carries senderIPAddress; the per-role injection
is contained inside propagateAddGroup where the master/slave roles are
unambiguous.

Update TestPropagateAddGroup_BothSucceed to assert the master's body
has no <senderIPAddress> while the slave's body does, so any future
regression on either side fails the test.

Refs #252

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:05:14 +02:00
Tobias GesellchenandClaude Opus 4.7 f89b2243c2 fix(cli): POST /addGroup to both speakers in parallel for stereo pair
createGroup used to POST only to the LEFT (master) speaker and rely on
the master to propagate the group to the slave via marge. That round-
trip is the source of the "context deadline exceeded" failures reported
in #252 — the master blocks waiting for marge while the CLI times out
client-side. SoundCork's working ST10 implementation addresses each
speaker directly, which avoids the inter-device coordination entirely.

Changes:
  * Build the group request with senderIPAddress = master IP (the fhem
    wiki documents this field; SoundCork sets it; we previously omitted
    it).
  * propagateAddGroup() POSTs the same payload to both speakers
    concurrently via a sync.WaitGroup and returns per-side outcomes.
  * postAddGroup() flags a non-GROUP_OK response Status as an error so
    the caller doesn't have to re-parse the body.
  * On partial failure (one side succeeded), surface a remove command
    the user can run to clean up.

Tests cover the happy path (both succeed, payload shape correct), the
right-side-fails path, the non-GROUP_OK response, and an empty-status
response (some firmware omits Status entirely on a successful echo).

Refs #252. Optimistic fix — still pending feedback from BirdyBA's
two-curl test on real ST10s before we're confident.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:05:14 +02:00
Tobias GesellchenandClaude Opus 4.7 e496974f0d feat(web): default --interface to --bind's interface name
When the user passes --bind <iface> and doesn't set --interface,
discovery now reuses the same interface name instead of auto-picking.
Common single-interface setups stop needing to repeat the flag, while
the two flags remain independent for the cases that legitimately want
HTTP and discovery on different interfaces.

Update the --interface help text to document the default. The --bind
text is unchanged: it still describes the HTTP listener address.

Refs #264

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:37:56 +02:00
Tobias GesellchenandClaude Opus 4.7 206ef1f665 fix(web): harden --bind interface resolution and add tests
The previous implementation silently returned the literal interface
name when the interface existed but had no IPv4 address (or when
listing addresses failed). That reproduces the exact error from #264
("listen tcp: lookup eth103 on ...: no such host") for users on
IPv6-only or admin-down interfaces, so the fix only worked for the
happy path.

Return an explicit error for those cases and fatal in main with a
message that identifies the offending --bind value. Add an IPv6
fallback (single non-link-local address, bracketed) and treat any
ambiguity -- multiple IPv4 or multiple IPv6 addresses on the same
interface -- as an error rather than picking one silently. Log when an
interface name was resolved to an IP so the indirection is visible.
Update the --bind flag help text to reflect the supported inputs.

Add a test covering the pass-through cases (host, IP, empty, unknown
name) and a portable loopback-interface test that skips cleanly when
the loopback isn't in a single-IPv4 configuration.

Refs #264

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:37:56 +02:00
mehmet turacandTobias Gesellchen 413ae74315 fix: resolve interface names for web bind address
Fixes #264

Signed-off-by: mehmet turac <mehmetturac@gmail.com>
2026-05-14 15:37:56 +02:00
Tobias GesellchenandClaude Opus 4.7 cb071c9b1b feat(discovery): allow pinning mDNS and UPnP to a specific interface
On a multi-homed host the discovery layer used to walk net.Interfaces()
and pick the first non-loopback IPv4 NIC, while UPnP/SSDP bound a
wildcard UDP socket and let the kernel route the multicast send. That
meant --bind on soundtouch-web only moved the HTTP listener; the
discovery still went out whatever interface the kernel preferred (often
the wrong one on hosts where the speakers sit behind a secondary NIC).

Introduce a separate DiscoveryInterface knob:

  * pkg/config: DiscoveryInterface field + DISCOVERY_INTERFACE env var.
  * pkg/discovery/mdns: NewMDNSDiscoveryServiceWithInterface; the
    interface resolver now honours an explicit name and validates it
    has a usable IPv4 address before handing it to hashicorp/mdns.
  * pkg/discovery/upnp: when an interface is configured, bind the UDP
    socket's source IP to the NIC's IPv4 and call
    ipv4.PacketConn.SetMulticastInterface so M-SEARCH leaves the right
    NIC. Without an interface, behaviour is unchanged.
  * cmd/soundtouch-web: new --interface flag (DISCOVERY_INTERFACE env)
    plumbed into the config before the discovery service is built.

go.mod/go.sum reflect promoting golang.org/x/net from indirect to a
direct dependency (now imported for ipv4.PacketConn).

Refs #264.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 19:36:06 +02:00
Tobias GesellchenandClaude Opus 4.7 67111b6f5e docs(web): clarify that --bind takes a host or IP, not an interface name
The flag's value is concatenated with ":PORT" and passed to
http.ListenAndServe, so it has always been a host/IP. The previous help
text invited users to pass an interface name like "eth0", which then
failed with a confusing DNS lookup error.

Refs #264.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 19:36:06 +02:00
Tobias GesellchenandClaude Opus 4.7 8b0a41744d fix(setup): cast syscall.Stdin to int for Windows cross-compile
term.ReadPassword takes an int, but syscall.Stdin is syscall.Handle
(uintptr) on Windows. The explicit cast keeps the call building on
Windows while a //nolint:unconvert silences the false positive on Unix
where syscall.Stdin is already int.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:43:36 +02:00
Tobias GesellchenandClaude Opus 4.7 e3450ffd00 refactor(setup): split high-complexity functions into per-axis helpers
Brings the five remaining gocyclo > 20 warnings to zero by extracting
cohesive sub-functions; same observable behaviour, smaller surface to
read at each call site. Bonus: the new helpers are individually testable.

- pkg/models/clockdisplay.go: split ClockDisplay.UnmarshalXML attr
  handling into applyClockDisplayOuterAttrs (legacy flat shape) and
  applyClockConfigAttrs (current nested shape).
- pkg/service/setup/ssh_probe_apply.go: split applyProbeToSummary into
  applyProbeCurrentConfig / applyProbeResolvConf /
  applyProbeRemoteServices / applyProbeCACert — one helper per
  MigrationSummary axis the probe populates.
- pkg/service/setup/init_plan.go: split ExecuteInitPlan into
  applyInitPlanDefaults, runURLRewrite, resolveAccountID, and
  verifyPairing. Cleans up several shadowed err variables in the
  process.
- cmd/soundtouch-cli/cmd_setup.go: split renderInspectReport into
  renderInspectIdentityAndPairing / renderInspectNetwork /
  renderInspectSources / renderInspectPresets / renderInspectRuntimeURLs,
  and buildPlanSteps into resetSteps + migrationSteps helpers.

golangci-lint run ./pkg/service/setup/... ./pkg/models/...
./cmd/soundtouch-cli/... now reports zero findings. Tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:43:36 +02:00
Tobias GesellchenandClaude Opus 4.7 9e384840ba style(setup): un-stutter exported type names and tighten range loops
- Rename SetupStateMachine → setup.StateMachine, SetupSessionConfig →
  setup.SessionConfig, SetupSession → setup.Session, and
  DialSetupSession → setup.DialSession. The Setup* prefix only stutters
  in package context (`setup.SetupSession`); the renamed forms read
  cleaner at every call site (revive: exported).
- Iterate r.Network.Interfaces.Interfaces by index in cmd_setup.go
  rather than by value — each NetworkInterface is 168 bytes and the
  per-iteration copy was unnecessary (gocritic: rangeValCopy).

Test fixtures (fakeSetupSession → fakeSession, TestSetupSession_* →
TestSession_*) renamed by the same substring replacement to keep
naming consistent inside the package.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:43:36 +02:00
Tobias GesellchenandClaude Opus 4.7 a1ae10650f style(setup): address actionable golangci-lint findings
Fixes the lint hits that pointed at real bugs or dead code; leaves the
remaining style-only suggestions (rangeValCopy micro-copies, gocyclo
informational, intentional name choices like SetupStateMachine) alone.

- pkg/models/clockdisplay.go: restore <clockDisplay> XMLName tag on both
  ClockDisplay and ClockDisplayRequest. The earlier `xml:"-"` clashed
  with ClockDisplayUpdatedEvent.ClockDisplay's `xml:"clockDisplay"` tag
  (SA5008). Custom MarshalXML/UnmarshalXML still own the wire format.
- pkg/service/setup/setup.go: drop the now-unused checkRemoteServices
  helper (replaced by applyProbeToSummary) and rename the unused
  deviceIP parameter of populatePlannedNetworkConfig to _.
- pkg/service/setup/setup_session.go: collapse sendStep's (string, error)
  return to plain error — every caller already discarded the string.
- pkg/service/setup/init_plan.go: rename shadowed err variables to
  rwErr / genErr / invalidErr / nilErr / stepErr.
- cmd/soundtouch-cli/cmd_setup.go: drop redundant int(syscall.Stdin)
  conversion (already int) and rename a shadowed err to pairErr.

go build ./..., go vet ./..., and tests for the touched packages all
green.

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