Commit Graph
562 Commits
Author SHA1 Message Date
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
Tobias GesellchenandClaude Opus 4.7 9cb8549c79 docs(troubleshooting): add filtered logread recipe + cross-link from Curl 7
Add the loopback-filtered command `logread -f | grep -v '127.0.0.1'` to
DEVICE-LOGGING.md's Pro-Tip section with a one-line rationale (strips
the speaker's in-device localhost chatter so cloud/AfterTouch attempts
are readable). Cross-link from the new Curl 7 entry in TROUBLESHOOTING
so users hitting that symptom find the SSH/logread how-to.

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 33c1db5b97 style(preflight): replace if-else chain with switch (gocritic)
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 8cc8f28bdd style: gofmt alignment and blank-line tidy
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 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 ef2b775ce0 test(integration): add http-client test for stereo-pair Marge POST
Add an end-to-end IntelliJ HTTP Client test that replays the exact
request shape a SoundTouch 10 master sends to its configured Marge
server during stereo-pair formation (captured live in issue #252):

  POST /streaming/account/{accountId}/group/
  Authorization: Bearer <token>
  Content-Type:  application/vnd.bose.streaming-v1.2+xml

  <group>
    <masterDeviceId>...</masterDeviceId>
    <name>TEST</name>
    <roles>
      <groupRole><deviceId>...</deviceId><role>LEFT</role></groupRole>
      <groupRole><deviceId>...</deviceId><role>RIGHT</role></groupRole>
    </roles>
  </group>

Assertions cover the wire contract that fails loudly if regressed:
trailing-slash URL is matched, response is 201 Created with the vendor
media type, Location header references the new group under the
account, and the body echoes masterDeviceId, name, and both groupRole
entries.

Wired into the make test-http-client target, sequenced before
get_group.http so the GET runs against the post-create state.
get_group.http's assertion only checks for the presence of a <group>
element, so adding a populated group beforehand is compatible.

Refs #252

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v0.79.0
2026-05-15 09:05:14 +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 Gesellchen 5fd7e8c0ba Bump service version to v0.78.0 2026-05-14 23:39:21 +02:00
Tobias Gesellchen b6a207e33d Bump default service version to v0.78.0 2026-05-14 23:36:59 +02:00
Tobias GesellchenandClaude Opus 4.7 43578059dd docs(web): add soundtouch-web parity roadmap
Document the remaining feature gap between soundtouch-web and the
Stockholm app's local-control functionality (seek/scrub, queue view,
per-device settings) and the explicit non-goals (anything cloud-bound
that is either shut down or already handled by soundtouch-service).
Acts as both a contributor checklist and a public statement of what
the web UI will and won't try to cover.

Link the page under the Concepts section in SUMMARY.md so it shows up
in the published docs and satisfies the docs-consistency test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:15:53 +02:00
Tobias Gesellchen 36013cf005 docs(archive) add SoundTouch End-of-service Guidance
See https://www.bose.com/soundtouch-end-of-life
2026-05-14 22:15:53 +02:00
Tobias GesellchenandClaude Opus 4.7 a8d499cfe9 docs(telnet): document Docker fallback when telnet is not installed
Users on systems without a local telnet binary (modern macOS, Windows
without OptionalFeatures, minimal Linux distros) need a workable
recipe to reach the speaker's port-17000 shell. Add a one-line docker
run snippet that uses busybox-extras telnet inside an alpine
container, parameterised by the target speaker IP.

Placed at the top of the reference page so a reader who lands there
asking "how do I run telnet?" sees the fallback before the command
listings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:15:53 +02:00
Tobias GesellchenandClaude Opus 4.7 0556492fe4 ci: pin GitHub Actions to commit SHAs
Replace floating major-tag references (uses: foo/bar@vN) with the
specific commit SHAs they currently resolve to, annotated with the
fully-versioned tag (# vX.Y.Z) for human readability. Pinning to a SHA
makes the action behaviour reproducible across runs and removes the
supply-chain risk of a maintainer (or attacker) moving a tag to a new
commit.

One documented exception: semgrep/semgrep-action does not publish
v1.x.y semver tags — v1 is their only canonical release name on that
line — so it keeps a "# v1" annotation with an inline explanation.

actions/dependency-review-action's previous "@v5" reference would have
failed at run time: that repo only ships fully-versioned tags
(v5.0.0), no moving v5 alias. Pinned to v5.0.0 explicitly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v0.78.0
2026-05-14 16:47:50 +02:00
Tobias GesellchenandClaude Opus 4.7 6078309724 test(datastore): compare MAC lookup to update by ratio, not wall clock
The lookup branch of AccountDeviceDir does up to two Stat() syscalls,
so its wall-clock cost is dominated by filesystem latency. On shared
CI runners that latency varies enough that the existing 70 ms absolute
threshold has been tripped repeatedly -- the previous bump from 50 ms
to 70 ms in d97cd45 was the same story. Incrementally relaxing an
absolute bound to track CI noise is a treadmill.

Replace the lookup-time wall-clock check with a ratio against the
in-memory update cost (currently ~8x on dev machines, ~12x on CI).
The 30x threshold leaves comfortable headroom for noise while still
catching an algorithmic regression in the lookup path, where the ratio
would explode well past 30 (an O(n^2) walk over 1000 entries would
push it into the hundreds).

The update path's absolute cap stays in place as a backstop against
catastrophic regressions in that hot in-memory path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:30:56 +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 8ee15bb034 test(handlers): use deterministic IP in BMX registry test
soundtouch.local relied on mDNS resolution, which works on developer
macOS but not in CI/Linux. With the new server_url validation, an
unresolvable hostname now correctly causes DNS to refuse to start --
which flips dnsEnabled to false and made the test fail honestly instead
of passing while DNS was silently broken. Switch the fixture to
127.0.0.1 so the test exercises the DNS-enabled path everywhere.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:49:23 +02:00
Tobias GesellchenandClaude Opus 4.7 ab65dceb9a feat(service): validate server_url and surface resolved DNS intercept IP
Refuse to start the DNS server and reject Settings updates whose
server_url does not resolve to a routable IP. Without this, a
misconfigured hostname caused the DNS server to answer every intercepted
Bose hostname with `CNAME .`, leaving speakers unable to reach the
service while everything looked healthy. The Settings page now displays
the resolved intercept IP (or the resolve error) next to "Target
Domain", so misconfigurations are visible up front instead of buried in
the DNS log.

Refs #269

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:49:23 +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
Tobias GesellchenandClaude Opus 4.7 29a462da2b feat(setup): add CLI setup command group for end-to-end speaker provisioning
Add `soundtouch-cli setup` subcommand group covering the full reset →
re-provision → pair lifecycle as a scriptable alternative to the web UI:

  inspect, verify, plan, factory-reset, wait-ap, wifi-push, wait-online,
  ssh-check, install-ca, migrate, reboot, pair (bare | full state machine)

Supporting library code lives in pkg/service/setup: factory_reset.go,
wifi_provision.go, inspect.go, init_plan.go, setup_session.go.

Confirmed against ST10 firmware 27.0.6 that bare setMargeAccount over
WebSocket — no SETUP_START/SETUP_ENTER/SETUP_LEAVE bracket — is
sufficient to pair a factory-reset speaker; the firmware materializes
SystemConfigurationDB.xml and Sources.xml itself and the pairing
survives reboot. Result and field-by-field SystemConfigurationDB
comparison documented in docs/analysis/SETUP-WEBSOCKET-EXPERIMENT.md.
Captures the device's pre-reset DELETE-to-marge plus its LAN peer
notification flow in docs/analysis/FACTORY-RESET-PROTOCOL.md.

Perf: batch GetMigrationSummary's SSH probes into one Run() call via
ssh_probe.go / ssh_probe_apply.go — was ~8 sequential dials at
500-1000 ms each on FW 27 crypto, now one round-trip. Same data shape,
same MigrationSummary fields populated.

Fixes /clockTime and /clockDisplay wire formats — firmware 27 rejects
the legacy flat XML ("Error parsing request"). ClockTimeRequest now
uses utcTime attribute; ClockDisplayRequest emits the nested
<clockConfig> envelope with timezoneInfo/timeFormat/brightnessLevel.

Removes cmd/example-init-speaker (superseded by setup pair).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:43:36 +02:00
dependabot[bot]andTobias Gesellchen 1ab4295653 ci(deps): Bump actions/dependency-review-action
Bumps the actions-core group with 1 update: [actions/dependency-review-action](https://github.com/actions/dependency-review-action).


Updates `actions/dependency-review-action` from 4 to 5
- [Release notes](https://github.com/actions/dependency-review-action/releases)
- [Commits](https://github.com/actions/dependency-review-action/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/dependency-review-action
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions-core
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-12 20:22:01 +02:00
Tobias GesellchenandClaude Opus 4.7 cbbbaa9707 feat(group): add ST-10 stereo-pair support end-to-end
Implements the speaker-side group API surface (path 1 of the two
approaches gmuth outlined in issue #252): clients form, rename, and
dissolve stereo pairs directly on the device, and the resulting
GroupService.xml persists on disk in the same shape the device emits
over /getGroup.

What landed:

- pkg/models/group.go: Status field + IsEmpty() helper, matching the
  GET /getGroup response shape (id-attr, masterDeviceId, roles,
  senderIPAddress).
- pkg/client/client.go: GetGroup, AddGroup, UpdateGroup, RemoveGroup.
  The endpoint name is /getGroup (not /group, despite some wiki docs)
  — confirmed against a real ST-10's /supportedURLs. RemoveGroup uses
  GET per the wire spec.
- cmd/soundtouch-cli/cmd_group.go + main.go: new `group` subcommand
  with status / create --left --right [--name] / rename / remove,
  mirroring gmuth's group.sh recipe.

WebSocket notifications:

- pkg/models/websocket.go: EventTypeGroupUpdated +
  GroupUpdatedEvent + dispatch helpers. The device fans this out to
  both LEFT and RIGHT speakers on every group mutation, including
  empty-group teardowns; the parse test covers both shapes.
- pkg/client/websocket.go: OnGroupUpdated registration and dispatch.
- cmd/soundtouch-cli/cmd_events.go: `group` filter +
  handleGroupEvent formatter.

WebSocket observability (came up while validating the above against
a real device):

- New RawMessageHandler type + OnRawMessage hook that fires for every
  incoming frame before parsing, with the parse error alongside.
- New --debug flag on `events subscribe` with modes all / unknown /
  errors. Raw output goes to stderr so it composes cleanly with
  shell redirects.

The pkg/client refactor in this commit also adopts speaker.HTTPPort
(introduced in the previous refactor) — the unexported
defaultSoundTouchPort and three hard-coded 8090 literals are gone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v0.77.0
2026-05-11 23:18:08 +02:00
Tobias GesellchenandClaude Opus 4.7 c8c38b78e6 refactor(speaker): introduce pkg/speaker leaf for shared protocol constants
The HTTP port and on-device paths for the SoundTouch speaker were
duplicated across pkg/client (unexported) and pkg/service/constants
(under a service-layer prefix). Both spots needed the same values, and
the next round of work (group/persistence handling in the CLI) would
have created a third — or worse, dragged pkg/service into the CLI's
dependency graph just for a port number.

pkg/speaker is a no-deps leaf that holds the speaker-protocol
constants: HTTPPort, the request paths, and the on-device persistence
file locations (now including GroupServiceFileLocation, for the
upcoming stereo-pair sync work). The client library, the service, the
CLI, and tests can all import it without introducing a layering edge.

This commit moves nothing into pkg/speaker that doesn't belong there —
the service-specific constants (provider IDs, file names, date stub,
etc.) stay in pkg/service/constants. Only the genuinely
protocol-level values move.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:18:08 +02:00
Tobias GesellchenandClaude Opus 4.7 bb71253690 feat(screenshots): add headless-Chrome capture pipeline with fake speaker
Refreshes docs/images/ui-{settings,devices,sync,migration}.png by
driving the web UI in chromedp against a synthetic speaker, so
documentation can be regenerated without real hardware and without
leaking personal data from the local network.

Three independent pieces:

- pkg/service/testing/fakespeaker — embeddable library serving the
  HTTP and telnet surface the migration wizard probes (/info,
  /presets, /recents and a getpdo CurrentSystemConfiguration reply
  that places the device on the unmigrated happy path).
- cmd/dummy-speaker — thin CLI wrapping the library; self-registers
  with a running service via POST /setup/devices.
- scripts/screenshots — chromedp runner driven by a JSON manifest;
  decoupled from speaker/service setup so it can target any backend
  URL. run.sh orchestrates a one-shot end-to-end capture and seeds
  settings.json with a generic hostname plus discovery disabled to
  keep real-network state out of the captures.

Captures are at DPR=2 for retina-sharp text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v0.76.0
2026-05-11 20:37:23 +02:00
Tobias GesellchenandClaude Opus 4.7 0e8ab1cd89 test(service): update routes snapshot after round-trip probe removal
TestPrintRoutes compares the live router against
testdata/router_routes.txt; the deletion commit (ba69fc0) changed the
route set but didn't regenerate the golden file. Drops
/probe/{token}[/*] and /setup/telnet-probe/{deviceId}; adds
/setup/peer-probe/{deviceId}.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 20:31:24 +02:00
Tobias GesellchenandClaude Opus 4.7 952200ee26 docs: align migration guide and analysis with simplified pre-flight
MIGRATION-GUIDE.md step 5 — replaces the "Telnet round-trip probe"
bullet with two honest variants: the new passive observer for
already-migrated speakers, and a skip-row explainer for not-yet-
migrated speakers pointing at the Apply + reboot cycle. The rollback
section drops the obsolete tangent about the probe step leaving
persisted URLs untouched (the probe no longer exists, and the wizard
already writes both layers).

TELNET-MIGRATION-METHOD.md — §9.4's pre-flight table swaps the
deprecated `POST /setup/telnet-probe` row for the new
`POST /setup/peer-probe` row plus a skip-explainer row for the
not-yet-migrated case. §9.5 gains a "REMOVED — see §9.8" header
pointer (the section is kept as historical record of what was
tried). §9.6's backend-additions table replaces the deleted
`probeRegistry` + `RunTelnetRoundTripProbe` + `/setup/telnet-probe`
row with the `peerObserver` + `RunPeerReachabilityProbe` +
`/setup/peer-probe` row that supersedes it.

NEXT.md is local-working-tree only (deliberately untracked) and
gains a  Resolved header pointing at §9.8; not part of this
commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 20:31:24 +02:00
Tobias GesellchenandClaude Opus 4.7 62dd53777d remove(service): delete deprecated telnet round-trip probe
Hard-deletes everything marked DEPRECATED in the previous commit:

  Files:
    - pkg/service/setup/telnet_probe.go
    - pkg/service/setup/telnet_probe_test.go
    - pkg/service/handlers/handlers_telnet_probe.go
    - pkg/service/handlers/probe_registry.go
    - pkg/service/handlers/probe_registry_test.go

  Edits:
    - Server.probes field + initialization (server.go).
    - Routes /probe/{token}, /probe/{token}/*, and
      /setup/telnet-probe/{deviceId} (main.go).
    - checkTelnetRoundTrip() in script.js.

The passive observer (peer_probe.go + handlers_peer_probe.go) is now
the only reachability check for migrated speakers; unmigrated/partial
states surface a skip row pointing at the Apply + reboot cycle, as
documented in TELNET-MIGRATION-METHOD.md §9.8.

isCommandNotFound and parseGetpdoConfig remain — they are used by
telnet_migration, telnet_preflight, marge_pairing, and
preflight_crosscheck.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 20:31:24 +02:00
Tobias GesellchenandClaude Opus 4.7 f0de4864b6 deprecate(service): mark active telnet round-trip probe for removal
The swUpdate daemon caches its target URL at boot and ignores live
`sys configuration` writes, so the active flip in
RunTelnetRoundTripProbe never reaches the running daemon — confirmed
empirically on a fully-migrated speaker (FW 27.0.6) where both the
runtime and persistence layers were flipped and the device still
dialed the previously-cached `/updates/soundtouch` URL plus
DNS-intercepted `/streaming/software/update/account/*`. The probe URL
was never observed.

Marks DEPRECATED:
  - pkg/service/setup/telnet_probe.go: ProbeRegistrar,
    TelnetProbeResult, generateProbeToken, RunTelnetRoundTripProbe.
  - pkg/service/handlers/handlers_telnet_probe.go: HandleTelnetProbe,
    HandleProbeInbound, telnetProbeTimeout, telnetProbeResponse.
  - pkg/service/handlers/probe_registry.go: probeRegistry.
  - Server.probes field.
  - /probe/{token}[/*] and /setup/telnet-probe/{deviceId} routes.

Adds §9.8 to docs/analysis/TELNET-MIGRATION-METHOD.md documenting the
daemon-cache finding, the diagnostic that confirmed it, the passive
observer replacement, the pre-flight branch on migration state, and
the canonical telnet flow (Apply config → reboot → passive
validation). All code symbols remain in place this commit; the
follow-up commit performs the hard delete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 20:31:24 +02:00
Tobias GesellchenandClaude Opus 4.7 9a7646bf58 feat(web): branch pre-flight on migration state
The pre-flight panel's reachability check now picks one of two paths
based on summary.is_migrated:

  - Migrated → run the new passive peer-reachability probe
    (POST /setup/peer-probe/{deviceId}) and label the row
    "Reachability check (passive observer)".
  - Not migrated (incl. partial) → render a skip row
    "Round-trip validation runs after Apply + reboot" with the
    rationale "daemon caches swUpdateUrl at boot". Per-axis state
    remains visible in the State card so the user sees which parts
    are already in place.

Adds checkPeerReachability() alongside checkTelnetRoundTrip(). The
latter is marked DEPRECATED inline — no longer called by the
orchestrator, scheduled for removal in a follow-up commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 20:31:24 +02:00
Tobias GesellchenandClaude Opus 4.7 d74bb9b5ca feat(service): add passive peer-reachability probe handler
RunPeerReachabilityProbe is the post-migration replacement for the
active swUpdateUrl round-trip: register the device IP with the
in-process observer, nudge :8090/swUpdateCheck, and wait for any
inbound from that IP. No device-state mutation. Any inbound counts
as proof — on a migrated speaker, DNS interception routes the
daemon's outbounds through this service regardless of which URL it
resolved internally, so reachability reduces to "did the device
dial us at all."

PeerHit and the abstract observer interface live in setup alongside
the probe logic; handlers.peerObserver implements the interface and
the existing observer files now import from setup.

Route: POST /setup/peer-probe/{deviceId}. Timeout: 30s, surfaced as
result.ElapsedMs so the budget can be tuned from real data. The
pre-flight orchestrator gains the branch in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 20:31:24 +02:00
Tobias GesellchenandClaude Opus 4.7 dc924e351c feat(service): add peer observer registry and middleware
Adds an in-process observer that records device->service requests by
source IP. PeerObserverMiddleware fires on every inbound after RealIP
trust and Recoverer; the registry exposes Register/Signal/Forget keyed
on the device IP with a buffered one-shot delivery.

No callers yet — this is the substrate for the passive reachability
probe that replaces the broken active swUpdateUrl round-trip on
migrated speakers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 20:31:24 +02:00
dependabot[bot]andTobias Gesellchen fa2883f66b deps(deps): Bump the golang group with 2 updates
Bumps the golang group with 2 updates: [golang.org/x/net](https://github.com/golang/net) and [golang.org/x/tools](https://github.com/golang/tools).


Updates `golang.org/x/net` from 0.53.0 to 0.54.0
- [Commits](https://github.com/golang/net/compare/v0.53.0...v0.54.0)

Updates `golang.org/x/tools` from 0.44.0 to 0.45.0
- [Release notes](https://github.com/golang/tools/releases)
- [Commits](https://github.com/golang/tools/compare/v0.44.0...v0.45.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.54.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/tools
  dependency-version: 0.45.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-11 15:52:45 +02:00
Tobias GesellchenandClaude Opus 4.7 10c9edbb25 fix(marge): keep <sourceproviderid> in recents to satisfy speaker's protobuf
The speaker decodes /streaming/account/.../full into a protobuf message where
recents>recent>source>sourceproviderid is a required field. A laut.fm recent
(location "/custom/v1/playback/...") POSTed against an account with no
Sources.xml fell into classifyLearnedSource's default branch, which wrote
sourceKey type="INVALID" with no providerid. That entry then re-appeared
in /full with an empty <sourceproviderid> element, which the post-marshal
strip-empty step deleted entirely — aborting the speaker's account sync
with "MargePB.account.devices.device[N].recents.recent[K].source.sourceproviderid"
missing and forcing a 60-second retry loop.

Three changes, each defended by the new regression test:

* classifyLearnedSource recognises LocalInternetRadio via sourceProviderID
  == 11 and via the /custom/v1/playback/ URL pattern, and stops writing the
  "INVALID" sentinel that locked sources out of every read-side repair path.

* mapToFullResponseSource falls back to the canonical SourceProviderID
  keyed by source ID (10002/10003/10004/10005) so already-poisoned data
  on disk still renders a non-empty providerid at /full time, with no
  manual data scrub required.

* AccountFullToXML no longer strips empty <sourceproviderid> elements.
  The strip-empty was added for parity with upstream's standalone <sources>
  block, but it's wrong inside recents/preset source blocks where the field
  is protobuf-required.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v0.75.0
2026-05-11 09:14:54 +02:00
Tobias Gesellchen 93c3f68443 Bump the default version in install scripts to v0.74.0 2026-05-11 00:49:16 +02:00
Tobias Gesellchen 41a0f32296 chore v0.74.0 2026-05-11 00:39:28 +02:00
Tobias Gesellchen ae04ac3128 fix/update routes test 2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 a08c2c3072 feat(web): standalone Pre-flight button beside each Apply
"Test first, decide later" affordance: the same check sequence Apply
runs is now reachable without committing to the migration. Useful
for spot-checking a speaker after editing URLs, or for verifying a
fresh device is reachable before the user commits to writing
anything.

Two buttons, one per Apply path:

  - #plan-preflight-btn  (Suggested Plan side) — reads the chosen
    method from plan-apply-btn.dataset.method, same source the
    real Apply uses, so what's tested matches what would be
    applied.
  - #customize-preflight-btn (Custom Plan side) — walks the same
    radio choices applyCustomPlan reads and builds the same
    methods array, then runs the checks against it.

Both share the existing pre-flight panel and runApplyPreflight
orchestrator. New renderPreflightPreviewSummary terminates the
panel with a single Close button instead of Proceed Anyway /
Cancel — there's nothing to proceed to in preview mode.

Both Pre-flight buttons share the disabled-state gate of their
Apply counterparts (no plan / invalid URLs disables both) so users
can't accidentally pre-flight a plan that wouldn't apply.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 9ad159d41d feat(web): run telnet round-trip probe on SSH-capable speakers too
Previously the SSH-capable branch and the telnet-only branch were
mutually exclusive — speakers with both transports reachable only
got the curl-from-device HTTPS check, never the round-trip probe.
That left a class of bugs invisible to pre-flight: an asymmetric
network path where the speaker's userspace can reach our service
(curl works) but the swUpdateUrl fan-out can't (or vice versa).

Each transport now gets its own check; both run when both are
reachable. The two exercise meaningfully different code paths in
the speaker:

  - SSH curl-from-device: speaker's normal userspace HTTP stack
    over an arbitrary inbound TCP to our HTTP/HTTPS port.
  - Telnet round-trip: speaker's firmware-internal swUpdateCheck
    fan-out, which writes to its own DNS resolver and outbound
    HTTP code path that the curl test doesn't go near.

A speaker that passes one and fails the other reveals a real
connectivity asymmetry worth surfacing before the migration
writes its target URLs.

Cost: ~1s extra on the success path (probe is fast on healthy FW
27.0.6), up to ~6s extra on the timeout path. The probe restores
the runtime swUpdateUrl unconditionally so there's no lingering
state regardless of outcome.

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