NewTestServer registers its own t.Cleanup(Close) instead of needing a
manual defer, and fails the test on a handler panic instead of just
logging it. It defaults to an in-memory transport reachable only via
Server.Client(), which wouldn't work here since our production
client.NewClient dials a real address rather than using that client --
calling Start() instead of Client() opts back into a real loopback
listener, identical to the old NewServer, confirmed by reading the
actual go1.27.0 source (server.go's Start implementation).
This is a proactive adoption of a new stdlib idiom, not one of the
review findings from the previous commit; it doesn't change the
goroutine-drain fix from that commit, which is a separate concern
Close()'s "wait for outstanding requests" guarantee doesn't fully
cover (a goroutine that hasn't started its request yet at Close() time
isn't "outstanding").
Verified: 10x -count re-run under -race, full suite + lint clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Five findings from reviewing gesellix/Bose-SoundTouch#644
(Retry configured player devices during discovery):
1. Source-labeling used exact string equality
(device.DiscoveryMethod == "Configuration"), which breaks once a
configured host is also found via mDNS/UPnP in the same sweep:
mergeDeviceData concatenates methods into e.g.
"Configuration+mDNS/Bonjour", so the check silently failed and the
device got labeled "discovered" instead of "manual". Extracted the
decision into classifySource() and switched to a substring match.
Added TestClassifySource, which fails against the old exact-equality
logic on exactly the composite-string cases (verified) and would
have caught this before merge -- the PR's own test disables
mDNS/UPnP, so it never exercised this path.
2. Manually configured devices no longer registered immediately at
startup -- they now wait for the full mDNS/UPnP sweep (up to the
10s discovery timeout) to complete, since the PR removed the
synchronous registration loop and relies entirely on
PreferredDevices. Restored the immediate loop alongside (not
instead of) folding manualHosts into PreferredDevices, so a
currently-online configured device registers immediately as
before, while an offline one still gets retried on every
subsequent discovery pass -- the actual value this PR adds.
3. The new PreferredDevices-seeding loop didn't dedupe against hosts
already loaded from PREFERRED_DEVICES, so setting both for the same
host produced duplicate entries. Currently harmless (absorbed by
AddDeviceByHost's fast path) but fragile. Added dedup by host.
4. NewDiscoveryService's doc comment didn't mention the new
configuredHosts parameter or its retry-on-every-sweep behavior.
Documented.
5. The new test's second DiscoverDevices call spawns a one-shot
status-update goroutine and a 30s-ticker poll loop with no
guaranteed drain before the deferred server.Close(), risking
benign but real -race/CI flakiness. Added a bounded settle delay
after RemoveDevice.
Verified: full build/vet/race test suite/lint clean; the new
TestClassifySource fails against the pre-fix logic and passes with
it; TestDiscoverDevicesRetriesConfiguredHosts re-run 20x under -race
with no flakiness.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The nine env vars install.sh reads were only ever mentioned inline,
scattered across the file, or not documented at all
(AFTERTOUCH_FORCE_NO_BACKUP, GH_REPO, BINARY_URL, INIT_SCRIPT_URL,
FALLBACK_VERSION, AFTERTOUCH_LAN_PORT). Collect them into one table.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Space Limitation section still described update safety as an open
problem ("we are currently working on this"). Replace it with what the
installer now actually does: gzip-compressed backups, a preflight
disk-space check with an interactive confirm-or-abort before skipping
the backup, and a hard abort before downloading anything if there
isn't even room for the update itself.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The gzip fix in the previous commit only helps once a backup is being
made; it doesn't address the actual moment that broke on real hardware:
the cross-device mv/copy of the new binary into place ran out of space
mid-write, leaving a truncated, non-executable binary as the live one.
UBIFS is a log-structured flash filesystem, so space "freed" by
overwriting the old binary isn't guaranteed reusable in time for the new
one to land -- this happened on a device with 15.7MB available against
a ~14.8MB binary.
Add a preflight check before downloading anything: fetch the new
binary's real size via a HEAD request (adapts automatically as binaries
grow, instead of a threshold that goes stale every release) and compare
against available space plus a flat 5MB safety margin.
- Comfortably enough room for old + new + a compressed backup: proceed
exactly as before, silently.
- Enough for old + new but not enough extra for a backup: warn
interactively and require explicit confirmation before proceeding
without one. Reads from /dev/tty since the script is normally piped
via `curl | sh` (stdin is consumed by the script itself). Defaults to
the safe choice (abort) on empty input, matching the [y/N] prompt.
AFTERTOUCH_FORCE_NO_BACKUP=yes overrides for non-interactive/scripted
use.
- Not enough room even for the replace itself: abort before starting
the download, rather than attempting a doomed download/replace that
could leave a truncated live binary.
- No TTY available and the operator didn't set the override: abort
rather than silently guessing.
- HEAD request fails for any reason: skip the check with a warning
rather than blocking the install on it.
Verified: all five decision branches (plenty of room, warn+decline,
warn+confirm, warn+forced-override, hard abort) produce the correct
result under both dash and a real BusyBox v1.38.0 container, including
the gzip/gunzip streaming backup and glob-based pruning from the
previous commit. The HEAD-request size lookup was separately verified
against a live GitHub release URL with real curl -- catching and fixing
a bug where naively taking the first "content-length:" header grabbed
the 302 redirect's (0), not the actual asset's, size. Not yet re-tested
end-to-end on real hardware.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Binaries are tens of MB and only growing (Go 1.27 alone added ~655KB
to soundtouch-service via its own new stdlib defaults, unrelated to
this project's code), while the on-device install target (/mnt/nv) is
only tens of MB total. A user already hit "no space left on device"
attempting an update on real hardware.
Stream the pre-update backup straight through gzip instead of cp-then-
gzip: at that point in the script the old binary is still live and the
newly-downloaded one is already sitting in the temp dir, so writing an
intermediate uncompressed backup copy would briefly need three full
binary-sized copies on disk at once. Streaming avoids ever creating
that intermediate copy. Falls back to a plain uncompressed backup if
gzip is unavailable or the stream fails partway, matching prior
behavior exactly.
Both GC loops (pre- and post-install) now also prune stale
*.backup.gz artefacts, and the README's documented rollback command
covers both the compressed and (fallback) uncompressed cases.
Verified locally (not yet on real hardware): streaming path produces
no uncompressed intermediate, the gzip-unavailable fallback still
produces a plain backup, and the documented gunzip+chmod rollback
restores a byte-identical, executable binary.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
silently resolves to the latest v1.x release (v1.64.8) -- Go's semantic
import versioning treats v2+ as a completely separate module path
(.../v2/cmd/golangci-lint), so the unsuffixed path's @latest can never
see v2 releases. That mismatched v1 binary can't even load this
repo's v2-format .golangci.yml, and separately doesn't understand the
go1.27.0 toolchain declared in go.mod.
Fix the install hint in `make lint`'s not-found message to use the /v2
path, and refresh the now-current version noted in .golangci.yml's
header comment (installed locally as v2.13.1, built with go1.27.0).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Go 1.27 changed how runtime.FuncForPC reports the symbol for
HandleWeb()'s returned closure: it now correctly attributes it to its
defining function (handlers.(*Server).HandleWeb) instead of leaking the
inlining call site's enclosing function name (setupRouter) the way
older Go versions did. The registered route itself is unchanged -- this
is purely a difference in the introspected debug name.
The test's cleanup logic had a dedicated special case for stripping a
leading "setupRouter" prefix, added to work around exactly that
inlining artifact. Verified empirically (temporarily instrumented with
the raw runtime.FuncForPC output, then diffed the full 299-route table
with the special case removed) that the general prefix-stripping loop
already produces an identical result for the remaining legitimate
cases (closures actually defined inline in setupRouter, e.g.
/favicon.ico) -- so the dedicated case was already redundant before
this Go bump and can be dropped.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
syncSources never reported how many sources it actually saved, so the
Admin UI's success message always said the meaningless "sources:
synced" regardless of outcome. syncSources now returns the count saved
(-1 if the fetch failed), threaded through SyncResult.SourcesCount.
Also replaces the single run-on results string (which visually mashed
presets/recents/sources together with no separator) with a real <ul>
list, one <li> per resource, matching the presets/recents diff lines.
Built via DOM APIs rather than innerHTML string concatenation, since
preset/recent names ultimately come from user-editable station names
on the speaker.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
startSync() used to POST once and, on any 2xx, render a hardcoded
"Presets: OK / Recents: OK / Sources: OK" regardless of what the
response actually said -- exactly why a silent partial data loss (see
the previous commit) would have looked like success to the user.
Now: on a 409 (destructive) response, build a specific confirm message
from the diff (e.g. "presets: 6 -> 5: Ici Roussillon") and gate via
window.confirm(), matching the existing QuickFix confirm UX; on
confirm, retry with ?confirmed=true. On success, render the actual
per-resource counts from the response body instead of a canned string.
Adds an HTTP-level regression test
(TestHandleInitialSync_DestructiveSyncReturns409ThenAppliesWhenConfirmed)
covering the same refuse-then-confirm flow through the real handler and
router, complementing the lower-level setup package test.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
SyncDeviceData's syncPresets/syncRecents unconditionally overwrote the
datastore with whatever the speaker's live :8090 API returned at that
instant, with no check against what's already stored. If the speaker's
own local cache was stale or incomplete at that moment (e.g. right
after a burst of preset writes, or shortly after a reboot before the
speaker resyncs with Marge), Sync would silently persist that bad
snapshot over good data. A reporter's fresh #614 repro showed the
account's /full response dropping from 6 to 5 presets right after a
Sync click, consistent with this mechanism.
SyncDeviceData now diffs a fresh live fetch against what's stored
before writing anything; if applying would shrink either list, it
returns the diff (via the new SyncResourceDiff/SyncResult types)
without writing unless the caller passes confirmed=true.
HandleInitialSync surfaces this as a 409 with the diff JSON; every call
(confirmed or not) re-fetches live from the speaker, so a confirmed
retry re-checks reality rather than replaying a stale snapshot. Sources
sync is left unconditional, as before -- lower risk in practice and
out of scope for this fix.
fetchLivePresets/fetchLiveRecents are extracted pure-fetch helpers;
syncPresets/syncRecents keep their unconditional-apply behavior (used
directly by existing tests) since the button-driven path now goes
through the diff/confirm guard instead.
Adds TestSyncDeviceData_DestructiveSyncRequiresConfirmation covering
both the refusal and the confirmed-retry path.
Frontend wiring (script.js's startSync + real per-resource result
rendering, replacing the current hardcoded "OK" text) is a follow-up
commit on this branch.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Converts the remaining GetX-then-SaveX call sites (UpdatePreset,
RemovePreset, AddRecent's recent + learned-source persistence, AddSource)
to the new datastore.Mutate{Presets,Recents,ConfiguredSources} helpers,
closing the lost-update race for good on the actual write path the
speaker hits on every preset/recent store.
Adds a regression test that fires 6 concurrent UpdatePreset calls (same
shape as #614's rapid-fire repro) and asserts none are lost. Verified it
reliably fails against the pre-fix code (consistently drops presets
across repeated runs) and passes reliably with the fix, including under
-race.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GetX-then-SaveX call sites did an unguarded read-modify-write: two
concurrent callers could each read the same starting list, mutate
different entries, and the second writer's Save clobber the first's
update. This is exactly what dropped a preset during #614's rapid-fire
preset-programming repro (overlapping PUT .../preset/N requests).
Add MutatePresets/MutateRecents/MutateConfiguredSources, each holding a
single write lock across the whole read-mutate-write cycle, and switch
resolvePresetSource's auto-add-canonical-source path (the same race,
for sources) to use the new MutateConfiguredSources.
Part of the #614 follow-up; more call sites (UpdatePreset's own preset
write, recents, other sources writers) still need converting.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Bumps golang from 1.26.6-alpine to 1.27.0-alpine.
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
<details>
<summary>Dependabot commands and options</summary>
<br />
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
</details>
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Speakers paired via non-AfterTouch tooling (e.g. the USB-stick SSH-enable
method) can report a margeAccountUUID that isn't Bose's own 7-digit
numeric format, such as "stick@local". Discovery persisted this value
unvalidated, and the datastore's identifier check rejected it outright,
so the device was silently never saved.
Widens datastore.IsSafeIdentifier to accept any identifier that's safe
as a path component, XML value, and telnet-command token (still
excluding whitespace, control characters, and HTML/XML/shell
metacharacters), and makes it the single account-ID validator,
replacing setup's separate, stricter 7-digit-only IsValidAccountID.
Also closes related gaps found while widening the validator:
- postSetMargeAccount now XML-escapes the account ID instead of raw
string interpolation.
- SaveAccountInfo/HandleMargeCreateAccount now validate the account ID
the same way SaveDeviceInfo already did.
- handlers_export.go URL-escapes account/device IDs before building
outbound diagnostic-fetch URLs.
- pkg/service/health gained the sanitizeLog helper every other package
already has, applied to log lines carrying speaker-reported values.
- The admin web UI (script.js) renders account/device IDs via DOM APIs
instead of innerHTML/inline event-handler string interpolation,
closing a stored-XSS path, and a duplicate escape helper was
consolidated into one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
readPresetsLocked's os.IsNotExist branch and GetRecents' equivalent
branch silently returned an empty result with no log line at all,
unlike their sibling 0-byte/malformed-XML branches which already log.
When a reporter's speaker got served an empty preset list at reboot
despite an intact on-disk Presets.xml, there was no durable record of
it anywhere except a live capture at the exact moment.
Also log the per-device preset count going into every /full response
in CreateAccountDevice, distinguishing a disk read that came back
empty from one where source-mapping silently dropped presets
afterward.
Diagnostic only, no behavior change - the actual trigger for the
empty response is still open.
Release notes previously pointed at the flat, alphabetical Assets
list, forcing readers to hunt for their platform's soundtouch-service
or soundtouch-cli build. Generate direct per-platform links (with
inline checksum links, one row per OS/arch) from the deterministic
asset naming convention, and wire it into both release paths: the
auto-generated notes (create_release) and the hand-authored notes a
maintainer publishes via the GitHub web UI (update_release, which now
replaces the Downloads footer line in place). The footer-replace logic
always goes through the same strip-then-append path so re-running the
job for the same tag stays byte-for-byte idempotent.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A speaker can be reachable, named, and already account-paired yet still
report SOUNDTOUCH_NOT_CONFIGURED, leaving the "install the Bose app"
prompt on screen (reported for ST30 Series II/III in #615). Only a full
pass through the WebSocket setup state machine clears it, but running
that unconditionally risks re-running the bracket on speakers that
don't need or support it.
Add Manager.PreflightInitPlan: checks /supportedURLs for
/setMargeAccount, then requires /soundTouchConfigurationStatus to read
exactly SOUNDTOUCH_NOT_CONFIGURED before ExecuteInitPlan runs.
Already-configured devices are a no-op; an unsupported route or an
unrecognised status value aborts instead of guessing.
The default Suggested Plan (both XML-over-SSH and Telnet) migrates the
speaker over plain HTTP and never touches CA/TLS at all, but the CA/TLS
precondition always showed a red not-installed marker and the HTTPS
Connection Test panel was always rendered, regardless of whether the
current Target URL actually needs HTTPS. Both read as mandatory steps
even when nothing needed doing.
CA/TLS and HTTPS only matter when the Target URL is https:// or the
Customize form's DNS-interception method is chosen (that one always
targets https://*.bose.com).
- caVerdict() now takes whether the Target URL is HTTPS: shows a
neutral marker with a "not needed" note for HTTP targets, keeps the
red marker with a sharper "required" note for HTTPS targets.
- The HTTPS Connection Test panel gets a small note under its heading
("Optional for your current plan (HTTP)" / "Required ... (HTTPS)"),
computed from the same check. Stays visible either way so someone can
still run it if they want.
Frontend-only — showSummary already had the Target URL in scope.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Follow-up on #621: the Reboot Speaker button (plus Revert to Defaults,
Enable SSH, Disable SSH) was reachable only after expanding the
collapsed "Customize this migration" section and scrolling past three
fieldsets and the XML/telnet diff panes. Meanwhile every other real
action elsewhere in the admin UI (Save Settings, Apply Suggested Plan,
Start Sync, ...) is visible by default.
- Move Revert to Defaults and Reboot Speaker into an always-visible
"Speaker controls" row directly under the Migration State card.
- Move Enable/Disable SSH into the Preconditions table, inline with the
SSH (remote_services) status row, sized like the existing "Trust CA
Now" button next to the CA/TLS row. script.js now only rewrites the
inner status span on re-render (matching the CA/TLS pattern) so the
buttons survive summary refreshes.
- Add shared .btn-primary/.btn-danger CSS classes so button color
consistently means the same thing everywhere (primary = confirm,
danger = destructive) instead of ad-hoc inline colors; applied to
Save Settings, Apply Suggested/Custom Plan, Enable/Disable SSH,
Revert to Defaults, and Trust CA Now. Removed decorative gray from
Reboot Speaker and the connection/DNS test buttons.
- Replace the "Cancel" button (which only hid the whole summary panel,
not any of the actions it sat beside) with a "✕ Hide" control next
to the "Migration Summary for <device>" heading, alongside a new
"↻ Reload" shortcut for refreshSummary().
- Remove the now-unneeded force-open-the-details hack in migrate()
since Reboot no longer lives inside any collapsed container.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two doc notes from dunha's #621 follow-up: the factory-reset button
combo is confirmed identical on the SoundTouch 30 Series III, and
checking the reported version right after an on-device update can
still show stale info until the speaker (or an open Admin UI tab) is
rebooted, even though the new binary is already running.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- setup: resync all four boseurls (not just marge/swUpdate) over telnet
after an SSH-XML migration. `envswitch boseurls set` persists whatever
is currently in the runtime layer, so leaving stats/bmx untouched froze
their stale pre-migration values into the persistence layer permanently
-- surviving reboot and previously requiring a factory reset to clear.
- admin-ui: Migrate tab's Target Domain edits now propagate into the four
service URL fields (tracked via a dataset.autofilled flag so real manual
edits still aren't clobbered), closing the gap where changing Target
Domain to a new value left the four fields pointed at a stale default.
- install.sh: prune stale binary backups before the download too, not
only after a successful install, so a backup left by a previously
aborted (out-of-space) run gets cleaned up instead of compounding.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
validateURL() unconditionally rejected the hostnames "localhost" and
"127.0.0.1" for the four Migrate-tab plan URL fields, with no awareness
of deployment mode. Since the Suggested Plan's URLs are derived from the
page's own configured Target URL, a fresh on-device install (whose
server_url is now correctly http://localhost:8000, since #546) loaded
the Migrate tab with "Apply Suggested Plan" and "Pre-flight" disabled
by default, before the user touched anything -- directly contradicting
the on-device docs' "Migrate -> accept the suggested plan -> apply"
instructions.
Found while investigating why a #614 reporter used the non-standard
"localhost.localdomain" as a workaround, and why a #621 reporter got
stuck with "Migration Status: Migrated (URL mismatch)" trying to follow
the (correct) on-device localhost guidance.
Fix: a loopback URL is only flagged when it doesn't match the plan's
own Target URL origin. A field that's exactly what the service itself
is already configured to answer as (the on-device case) is accepted;
a stray "localhost" typed into one field while Target URL is a real LAN
address (the external-host mistake the check exists to catch) is still
flagged, since the origins differ.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Confirmed via code review: the Settings-save handler only updates the
service's own serverURL/settings.json, never contacts a device, and
neither migration method (telnet or XML/SSH) leaves anything behind that
would make a speaker later re-fetch a new address on its own. Both write
once, at migrate time.
Adds a Troubleshooting entry for this, and a cross-reference from the
Migration Guide's Step 2 (Target Domain) pointing at it, plus a step-
number fix (Migrate is Step 5, not Step 4).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
On an on-device install, soundtouch-service defaulted its server URL to
os.Hostname() when --server-url wasn't set. Since the service runs on the
speaker's own Linux, that returns the speaker's internal variant codename
(e.g. "spotty", "mojo") -- never resolvable, not even by the speaker itself
-- breaking TuneIn/BMX playback with CURL ErrorCode 6 (issue #546).
Add a --deployment-mode/DEPLOYMENT_MODE flag (on-device, private-network,
public-network) so the fallback is chosen deliberately instead of guessed:
on-device defaults to localhost, public-network refuses to start rather
than guess a public address, and the previous hostname-guessing behavior
is kept for private-network/unset installs, now with a startup warning.
The on-device init script sets DEPLOYMENT_MODE=on-device automatically and
now auto-exports aftertouch.conf into the daemon's environment generally,
which also unblocks discussion #610 (setting MGMT_USERNAME/MGMT_PASSWORD
on-device) without any further code change.
Verified end-to-end on real ST20 hardware: service now resolves
http://localhost:8000, a re-migrate updates the speaker's own runtime
config to match, and TuneIn playback works again.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The site sets disablePathToLower, so published URLs keep the source
filename's case, but the README link was lowercased and 404'd.
The walkthrough link was wrong in a second way: it used ../../reference/
with no extension, while cross-document links from guides/ resolve as
../reference/NAME.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
On-device installs were only reachable through an SSH tunnel, and the
docs blamed it on the service binding loopback-only. That was wrong.
Some SoundTouch chassis carry a BCO ("SMSC") Wi-Fi/Bluetooth
co-processor, and inbound LAN traffic reaches the main Linux SoC only
for a fixed set of Bose's own service ports, a list that appears to be
compiled into the co-processor firmware. AfterTouch's :8000 was never
part of that design, so connections never arrive at the SoC at all.
Confirmed on an ST20: a port sweep from a LAN client showed Bose's
:82/:8080/:8090/:8091/:8200/:17000 all answering while :8000 failed,
and tcpdump on the speaker's own eth0 recorded zero packets for it.
Ruled out along the way: iptables (empty), nft/ebtables (absent), the
router, Wi-Fi isolation, and the binding itself (0.0.0.0 is correct).
The init script now redirects one of the relayed ports to AfterTouch,
so http://<speaker-ip>:17008 works with no tunnel. 17008 is Bose's
software-update listener, whose cloud no longer exists. Only external
traffic is matched, so anything on the speaker still reaches :8000 as
before. Auto-enabled only where has-bco reports the co-processor, and
configurable via AFTERTOUCH_LAN_PORT (auto/none/port) in
aftertouch.conf. The rule is re-applied on every start and removed on
stop and uninstall, so it needs no watchdog; unlike prior art it is not
pinned to the LAN IP, so it also survives DHCP changes.
Credit for the REDIRECT technique goes to the STR / SoundTouch Reborn
project, which documented and shipped it first.
Also de-hardcodes the service port, which was baked independently into
the daemon args, the readiness poll and status, and makes install.sh
print the speaker's real address instead of a <your-device-ip>
placeholder it never filled in.
Adds a model support matrix, since the repo had no per-model
compatibility record and this behaviour is entirely chassis-dependent.
Only the verified ST20 row is filled in; everything else is marked
unknown rather than inferred.
Verified on hardware: auto-detection, idempotency across restarts,
teardown and restore, persistence across a full reboot, and LAN access
returning the service's health JSON.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The SSH connection-reuse fix (a4c0539) landed after this entry was
originally written describing the bug as open. Update the entry to
reflect the fix, confirmed on the same real hardware that surfaced
the original failure.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Three stacked bugs, found and confirmed on real hardware while
downgrading a speaker: the new binary landed on disk correctly, but
the running service kept reporting the old version indefinitely.
- install.sh called `/etc/init.d/aftertouch start`, not `restart`,
after installing. start-stop-daemon silently refuses to launch a
second instance when one is already running, and the init script
never checked its exit status, so the old process was never
replaced.
- The init script started the daemon through a `sh -c "exec ... |
logger"` pipeline, on the assumption that `exec` lets --make-pidfile
record the daemon's own PID. POSIX forks each side of a pipe into
its own process, so the wrapper shell (not the daemon) was the one
actually tracked. `stop` killed the wrapper, which doesn't forward
SIGTERM to its children, orphaning the real daemon to keep running
and keep holding :8000 forever.
- Once the wrapper correctly tracked the daemon's own PID, a further
race surfaced: start-stop-daemon's own "already running?" check
matched on generic `/bin/sh` identity, so a `restart`'s `start`
phase could catch the previous wrapper still mid-teardown and
silently refuse to launch a new one (masked by --quiet, looking
like a 120s hang).
Fixed by calling `restart` instead of `start` in install.sh, and by
having the wrapper shell record the daemon's real PID itself (via $!)
while keying start-stop-daemon's own check on that same pidfile
instead of process identity.
Verified on hardware: three consecutive restart cycles, each fast,
each with the pidfile matching the live daemon PID and daemon output
flowing through syslog again via logread.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Confirmed on real hardware: `VERSION=0.123.0 curl -sSL .../install.sh | sh`
silently does NOT pin the version, despite the docs claiming it "works
with pipe-to-sh". Shell variable-assignment prefixes only apply to the one
command they're attached to; in a pipe each command is its own process, so
the env var was set for `curl` (which never reads it) and never reached
`sh` (which does). A real attempt to pin v0.123.0 for a #614 pure
reproduction silently installed "latest" instead.
Verified the fix with a minimal repro (VERSION=X cat file | sh vs.
cat file | VERSION=X sh) before changing anything.
Moves the VERSION= prefix onto sh -- the last command in the pipe, the one
that actually reads it -- in ON-DEVICE-INSTALL-WALKTHROUGH.md (both
occurrences), scripts/on-device-install/README.md, and
scripts/on-device-install/install.sh's own header comment.
RASPBERRY-PI.md/EXTERNAL-HOST-WALKTHROUGH.md's `sudo VERSION=x ... bash
install.sh` pattern is unaffected -- that's a direct invocation, not a
pipe, so the env var already reaches the right process there.
Confirmed on real hardware (192.168.178.28): RevertMigration's full call
graph (revertXMLConfig/revertHosts/revertResolvConf/revertAftertouchHook/
removeRcLocalHooks/revertCACert) makes 17 separate client.Run() calls, and
pkg/ssh.Client.Run/UploadContent each dialed a brand-new SSH connection
per call with no reuse. Hitting a resource-constrained speaker with 17
rapid reconnects overwhelmed it -- confirmed via a follow-up plain SSH
command timing out at the TCP level, and the speaker going visibly
unresponsive.
Gives pkg/ssh.Client an opt-in persistent connection: Connect() dials
once and caches it, Close() releases it, and a shared dial() helper makes
Run/UploadContent reuse the cached connection when one's open, falling
back to today's per-call dial otherwise. RevertMigration now calls
Connect() once and defer Close(), collapsing 17 connections into 1. The
other ~21 m.NewSSH() call sites in pkg/service/setup never call Connect,
so their behavior is completely unchanged -- this only touches the one
function that was actually causing real-world problems.
SSHClient interface gained Connect()/Close(); both test mocks
(pkg/service/setup/setup_test.go, pkg/service/handlers/handlers_setup_test.go)
got no-op stubs. Added TestClose_NoOpWithoutConnect and
TestConnect_DialFailureLeavesConnNil in pkg/ssh/ssh_test.go -- these don't
prove connection reuse against a real server (Client.Run hardcodes :22,
no configurable port for a test listener), so that specific behavior is
verified by code review (a single `if c.conn != nil` branch) plus the
real-hardware confirmation above, not an automated integration test.
Also fixes the web UI's "Revert to Defaults" button, which calls the same
RevertMigration code path.
Prompted by recovering a real speaker (192.168.178.28) that had gone
stressed/unresponsive: it had previously been through `setup enable-ssh`
without --service-url (leaving margeServerUrl persisted as the
aftertouch.invalid placeholder, firmware retry-looping a failing DNS/curl
lookup against it, #546-shaped), compounded by a burst of SSH connections
from a `setup revert` attempt (see the dial-storm finding, tracked
separately, not fixed here).
Added --marge-url/--stats-url/--sw-update-url/--bmx-url override flags to
`setup migrate`. No new backend logic: telnetURLsFromOptions
(pkg/service/setup/telnet_migration.go) already supported per-field
overrides end-to-end, shared with the XML method's applyURLOverrides --
the CLI just never exposed it. Lets a speaker's URLs be pointed anywhere
(back to AfterTouch, or back to the genuine original Bose cloud) via a
single telnet connection, no SSH and no .original backup required --
confirmed recovering the real speaker above (migration committed, a
previously-timing-out plain SSH command returned instantly afterward).
Documented in TROUBLESHOOTING.md: the enable-ssh placeholder-persistence
gotcha (now with the escape hatch above) and the setup revert dial-storm
risk as a known, not-yet-fixed issue with a workaround (prefer this
lighter telnet-only migrate over repeated revert attempts). Documented the
new flags in CLI-REFERENCE.md's setup migrate section.
Prompted by writing a #614 self-test guide (on-device install walkthrough)
and by helping fully revert a real speaker a factory reset didn't fully
clean up.
New soundtouch-cli commands (cmd/soundtouch-cli/cmd_setup.go):
- `setup sync` — wraps POST /api/setup/sync/{deviceId}, the same operation
as the web UI's Devices -> Sync Data button. Read-only towards the
speaker (presets/recents/sources into the datastore); never writes back.
- `setup revert` — wraps setup.Manager.RevertMigration, the same operation
as the web UI's "Revert to Defaults" button. Restores
SoundTouchSdkPrivateCfg.xml/hosts/resolv.conf from their .original
backups and strips the AfterTouch CA cert from the trust bundle. No
--service-url needed; pure SSH against the speaker. Deliberately leaves
SSH persistence and account pairing untouched, matching the web UI
button (use `setup remote-services --remove` / `account unpair` for
those).
Both are thin wrappers with no new business logic, matching the existing
migrate/pair/reboot pattern. Tests added for setup sync's HTTP plumbing
(auth-retry, device-scoped URL, error propagation); no CLI-level test for
setup revert, consistent with reboot/migrate/pair also having none --
RevertMigration itself is already tested in pkg/service/setup/setup_test.go.
Documentation gaps closed:
- ON-DEVICE-INSTALL-WALKTHROUGH.md never showed the Migrate step at all --
jumped from install/reboot straight to the pairing QuickFix as if the
speaker were already pointed at itself. Added an explicit Migrate step
(web-UI and CLI paths), a CLI alternative for the pairing QuickFix, a
no-USB-stick `enable-ssh` (#471) alternative to the physical stick
procedure, and a "testing a pre-release build" section for cross-
compiling and manually swapping an unreleased binary (soundtouch-cli
deploy step included, mirroring the already-covered soundtouch-service
swap).
- MIGRATION-GUIDE.md's "never use localhost" Target Domain warning had no
on-device exception, even though loopback is exactly correct there since
the speaker and the service are the same machine. Added the callout, and
the same enable-ssh alternative to its SSH-enablement step.
- DEVICE-INITIAL-SETUP.md's AP-mode Wi-Fi provisioning commands were
macOS-only (networksetup, dns-sd) with no Linux/Windows equivalents,
unlike the rest of the docs. Added nmcli/netsh wlan alongside.
- CLI-REFERENCE.md's entire `setup <subcommand>` group was undocumented
(--help was the only reference) -- wrote a full "Setup & Migration"
section covering all 16 subcommands, and added the also-undocumented
`account unpair` to the existing Music Service Account Management
section.
Repo-wide sweep of example version strings still pinned around 0.111.2/
0.111.3 (four releases behind) across install-script comments, README
walkthroughs, the FALLBACK_VERSION defaults in on-device-install and
raspberry-pi install scripts, and the bug-report issue template's version
placeholder. Bumped to 0.123.0, the current release.
Left untouched: RFC-5737 example IPs and Go test fixtures that happened to
match the same version-number pattern, dated blog posts, and the Hugo
theme's own unrelated version pin.
Adds a second QuickFix to the speaker_presets_count warning, reusing
the existing postSourcesUpdated fix (checks_refresh_sources.go). It
nudges the speaker to re-fetch /full, which is confirmed (both from
marge.AccountFullToXML and a genuine captured Bose-cloud response) to
carry presets alongside sources.
Whether firmware actually re-applies /full's preset section back onto
its own local table is unconfirmed — issue253_regression_test.go
already flags that exact link as untested. So this is offered as a
free, non-destructive thing to try first, with the guaranteed
restore_presets_to_speaker push as the fallback. Gives both directions
(pull-style nudge, direct push) rather than only the one.
Refs #614
When the speaker shows 0 preset slots while the service's Presets.xml
has entries (the #614 pattern), replays each stored preset onto the
speaker via :8090/storePreset (client.StorePreset), one slot at a
time. Doesn't require a reboot and doesn't need the content playing
first, unlike a physical preset-button save.
Sync only ever reads from the speaker; this is the missing write
direction, and lets a reporter try recovering presets without
re-entering all 6 by hand.
Refs #614
The speaker_presets_count check told users a power-cycle "usually
re-syncs" missing presets. #614 shows a power-cycle is itself one of
the two reported triggers for the speaker wiping its own presets, so
that advice was actively harmful for this failure mode.
Also fixes the comment's citation: it claimed this was a known pattern
from discussion #295 and #235, but neither actually discusses preset
loss (#295 is a cloud-hosting question, #235 a closed Spotify
preset-save bug). That reference was wrong from the original commit
(7d46ae2); #614 is the first confirmed instance.
Refs #614
Bumps the go directive to 1.26.6 across the main module and both
standalone example modules (preset-management, navigation-station-demo),
plus the builder image in Dockerfile and the three mock-service images
in docker-compose.ci.yml.
Also refreshes the examples' require github.com/gesellix/bose-soundtouch
pin from the stale v0.118.0 to the current v0.123.0 release tag (the
replace directive means they build against local source regardless,
but the pin should still track reality). go mod tidy run in all three
modules; no other dependency changes.
Answers #591's open question 2: CLI-only users get no update notice
from soundtouch-service's periodic background check. Both binaries
gain a soundtouch-cli/soundtouch-backup update-check command that
does a single, on-demand GitHub Releases check via the existing
pkg/service/updatecheck package. Running the command is itself the
opt-in, so unlike the service there's no config flag or persisted
state.
pkg/service/updatecheck.Checker was already designed decoupled from
handlers.Server/main.go specifically so other binaries could import
it directly; this is that follow-through.
The update-check interval field just got an info-toggle explaining Go
duration syntax; Discovery Interval takes the exact same syntax and
had no such help, which would read as inconsistent on the same
Settings page. Pre-existing gap, unrelated to #591 itself, but small
enough to fix alongside it while the pattern is fresh.
Reuses the existing info-toggle/info-details pattern (already used for
the HTTPS override, TLS extra hosts, and DNS upstream fields) rather
than inventing a new affordance, so users aren't left guessing at Go's
duration syntax when typing a custom interval.
Follow-up to #591: UpdateCheckEnabled/UpdateCheckInterval are now
persisted, live-reloaded Settings fields (mirroring the discovery
enabled/interval pattern), editable from the admin Settings page
without a restart. The env var/CLI flag remains the seed value for a
fresh install with no settings.json yet.
The background goroutine now always runs and polls the live settings
every minute (updateCheckPollTick), instead of being started only if
enabled at process launch, so flipping the toggle takes effect within
a minute rather than requiring a restart.
htm/Preact template literals insert text as a DOM text node rather than
parsing it as HTML, so the × entity was never decoded and showed
up literally in the player UI's announcement banner. The admin UI's
equivalent button is unaffected because it's built as an HTML string
inserted via innerHTML, where the browser does decode entities.
Fixes the player-UI regression noted in #591.
Comparing against JRpersonal/streborn#587 surfaced two gaps: no test
pinned that a newly added source type renders the same element shape
as a known-good default (the firmware rejects the whole account
document if one source entry omits an expected element), and our DLNA
discovery only swept SSDP from the service host, missing servers only
visible from a paired speaker's own LAN segment.
Adds TestSourceXMLShapeConsistencyAcrossTypes in pkg/service/marge,
and has HandleDiscoverLibraryServers merge results from each paired
speaker's own /listMediaServers alongside the existing SSDP sweep,
deduped by UDN, with unreachable speakers skipped silently.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Addresses recommendations 4 and 7 from #515 comment 5231931569: a
green-looking getpdo readback only confirms the sys configuration
writes were accepted, not that they'll survive a reboot (that's what
the envswitch-persisted layer decides). Labels the getpdo line in both
migrateViaTelnet and runTelnetInjection's CLI/log output accordingly,
softens migrateViaTelnet's "succeeded" wording to "accepted", and adds
the same one-line caveat to TELNET-MIGRATION-METHOD.md #2.3 (previously
only in TELNET-COMMAND-REFERENCE.md).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Confirmed on hardware (#471 comments 5231997551, 5232046477): after a
reboot, HTTP :8090 and the diagnostic telnet :17000 shell become ready
at very different times, up to ~92s. Our own enable-ssh retry guidance
tells users to power-cycle and re-run immediately, which can hit the
device mid-boot and surface as a raw connection-refused error. Adds a
troubleshooting entry plus the underlying measurement in
TELNET-COMMAND-REFERENCE.md; no code change.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Community hardware testing (bitranox, JRpersonal) on 2026-08-09 retracted
the earlier "inter-command delay is necessary" theory and established that
envswitch boseurls set commits the whole runtime layer (not just its two
arguments), has no read form, and doesn't ack with "OK". Corrects
TELNET-MIGRATION-METHOD.md and TELNET-COMMAND-REFERENCE.md accordingly,
retracts the stale "confirmed necessary" command-delay claim in
enable_ssh.go/cmd_setup.go, and lowers DefaultTelnetCommandDelay 5s -> 3s
as a smaller hedge now that the delay itself is known not to be the
mechanism.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ErrorsResponse.Error() only returned the <error> element's text body,
dropping the name attribute entirely. Some speaker error responses
have a Message that just restates Value as text (e.g. a bare "1047"
for SOURCE_ALREADY_REMOVED), so callers only ever saw the useless
numeric string. Found while live-debugging a Deezer account
add/remove cycle on real hardware, where the raw XML
(<error value="1047" name="SOURCE_ALREADY_REMOVED">1047</error>)
carried real information only in the name attribute.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Guards against whitespace-only stationID/podcastID/encodedName path
segments and tightens the existing empty-string checks on the search
q/cursor query params. Spotted while reviewing stalkerquatre-oss's
fork diff for TuneIn handling improvements; their s0/Radio fallback
defaults were skipped as unprecedented invented values that would
mask malformed requests instead of erroring.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Follow-up to the 3s default from earlier in #515: the reporter agreed
5s is a better trade-off (issue comment 5230881285) — more headroom
than the original guess, still comfortably under the ~7s gap their
manual A/B test used.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
#515 comment 5230833551: on a genuinely unpaired (factory-reset) device,
margeServerUrl is reportedly never polled at all, so the boseurls
SSH-enable injection has no read cycle to fire on regardless of any
command delay. enable-ssh now checks /info first and, if
margeAccountUUID is empty, pairs the device via the existing
PairAccount helper (HTTP /setMargeAccount, telnet fallback) before
running the injection.
Adds setup.Manager.EnsureMargeAccountPaired plus --no-auto-pair (skip
entirely) and --account (use a specific 7-digit ID instead of a
generated one, e.g. to match one already in the datastore) flags on
enable-ssh. Pairing failure is a warning, not fatal, since the claim
is unconfirmed on this specific hardware and existing working flows
must not regress.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
markdown-link-check has no concept of "warning" vs "error" — it's a
binary alive/dead per link, so a transient 429 from a rate-limiting site
we don't control (recently: blogspot.com, izndgroup.com) fails the whole
CI job exactly like a genuine dead link, with no way to tell them apart
from the job's exit code.
New scripts/check-doc-links.sh wraps the tool per file, parses its
"[✖] <url> → Status: <code>" output, and re-decides pass/fail per link:
404 still fails the build (a real dead link, worth fixing), other 4xx
(429, 401, 403, ...) become a GitHub Actions ::warning:: annotation
instead, and anything else (5xx, timeouts, DNS failures) still fails the
build same as before. De-dupes markdown-link-check's own doubled -v
output. Written for bash 3.2 (macOS's default /bin/bash) so it's testable
locally, not just on the ubuntu-latest runner.
Existing retry config in .github/markdown-link-check.json (retryOn429,
3 retries, 30s backoff) is untouched; this only changes what happens once
retries are exhausted.
Verified locally: 6 synthetic scenarios (404/429/500/mixed/clean/
duplicate-line) via a stubbed markdown-link-check, plus a real run
against the docs tree with the actual tool.
Per #515 comment 5228449448: on a real Lifestyle console, the same 6
commands (5 sys configuration/envswitch + reboot) sent back-to-back left
sshd down after reboot, but succeeded sent one at a time with ~7s gaps —
same commands, same order, same device, minutes apart. Sending fast may
not let the device fully process one command before the next arrives.
Adds --command-delay (setup.DefaultTelnetCommandDelay, 3s), threaded
through EnableSSHViaTelnetFullConfig/runTelnetInjection (pause after each
of the 5 commands) and runEnableSSHInjection (one more pause before the
reboot). 0 restores the old back-to-back behavior. The reporter didn't
try to find the true minimum, just confirmed ~7s works and speculated
"a second or two may well be enough" — 3s is a middle ground, tunable via
the flag if a specific device needs more.
Also prints an approximate total for the injection phase up front (6
steps x delay, ~18s at the default) so the command doesn't look hung —
separate from the existing --wait message for sshd coming up after
reboot, which can take much longer.
Refs #515
setup ssh-check's failure message claimed "those commands were removed"
on FW 27.x and jumped straight to the USB-stick fallback — flatly
contradicted by setup enable-ssh, which exists specifically to bootstrap
SSH over telnet via the port-17000 envswitch trick (#471), and by
TELNET-COMMAND-REFERENCE.md's own notes that the injection is
field-confirmed on several FW 27.x models. Success is model/build-
dependent, not universally impossible — some devices (ST Portable, some
CineMate 520 units) need --full-config instead of the default injection.
Reordered the message to point at `setup enable-ssh` (with the
--full-config caveat) first, USB stick as the fallback if that doesn't
work on a given device — this was the exact point where a user hitting a
closed port 22 would previously be told to go find a USB stick without
ever learning the telnet route exists.
Refs #598
On Bose Lifestyle/CineMate consoles, the SoundTouch module is one input
among several, and #160 already established the input can't be switched
from the SoundTouch side. This adds a troubleshooting section covering:
- source="LOCAL" in /now_playing with LOCAL absent from /sources means
the console is on a different input, not that the content is invalid.
- POST /key POWER is not a harmless "stop playback" on these devices like
it is on a plain speaker — it puts the console into standby, and on
waking it returns to the console's OWN input, not back to SoundTouch.
A test loop that uses POWER between trials silently drops off
SoundTouch after the first trial, so every later station reports
INVALID_SOURCE regardless of whether it would actually play fine.
Also adds a cross-reference caveat to the `key power` row in
TELNET-COMMAND-REFERENCE.md, whose existing "no observable effect on FW
27.x" note is speaker-only phrasing that doesn't hold for these consoles.
Refs #597
Follow-up to #591, prompted by the update-check notice showing a raw
https:// URL as plain text instead of a clickable link. Made it general
rather than a one-off fix, since future announcements may also want to
link to docs.
Added Announcement.LinkText/LinkURL (+ LinkURLFunc, the dynamic
counterpart, for the update-check entry's per-release URL) alongside the
existing Message/MessageFunc pair. Both frontends render it as a real
<a> element now: the admin UI (innerHTML) escapes Message/LinkText/LinkURL
via the existing escapeHtml() before composing the markup — previously
Message went into innerHTML unescaped, which this incidentally hardens;
the player (Preact/htm) templates an actual <a> rather than interpolating
a string, since Preact escapes string children by default and a raw
<a href=...> string would otherwise render as literal text, not a link.
Rephrased the #419 admin-gate announcement to use the new field too (was
a plain "See issue #419 for details." text mention).
Bug found while wiring this up: UpdateCheckState never persisted the
release URL, only the version — so after a restart, the announcement
would show a correct message but a broken/empty link until the next live
check completed (which can be up to a full interval away, since a fresh
check is skipped when the persisted last-check is still recent). Fixed by
adding UpdateCheckState.LastReleaseURL and threading it through
Checker.persist/NewChecker's seeding path, with a test
(TestNewChecker_SeedsFromPersistedState) that would have caught it.
Also fixed two gocritic rangeValCopy findings in
handlers_announcements.go (switched to index-based iteration) surfaced by
the Announcement struct growing with the new fields.
Refs #591
Addresses the Semgrep finding on PR #599
(go.lang.security.audit.crypto.math_random.math-random-used) on the
update-check jitter delay, and applies the same treatment to the #419
activity-log filename suffix, which has the same non-security shape but
predates this PR's diff so it wasn't flagged.
Neither value is ever compared, kept secret, or otherwise security-
sensitive (a sleep duration and a filename-uniqueness suffix), so
crypto/rand would only add error-handling overhead for no real benefit.
Suppressed with the same // nosemgrep: <rule-id> pattern already used in
the mock-amazon/mock-spotify/mock-tunein servers, mirroring the existing
//nolint:gosec on the same lines.
Refs #591
Fifth and final piece of #591's initial implementation. Extends
/api/setup/version with update_available/latest_version/
latest_release_url (nil-safe via Server.UpdateCheckResult, defaults to
Available: false when the check was never enabled). Response switched from
map[string]string to map[string]interface{} to carry the new bool field;
updated the one existing test that decoded into the old stricter type.
Documents UPDATE_CHECK_ENABLED/UPDATE_CHECK_INTERVAL in the Configuration
Options reference table, explicit that this is the only network call
AfterTouch makes beyond speaker/provider traffic when enabled, and that it
defaults off.
This closes out the initial #591 implementation per the design doc
(_/i591/design-update-check.md): UpdateCheckState persistence, the
updatecheck.Checker package, background goroutine wiring with jitter/
backoff, reusing #419's Announcements mechanism instead of a second notice
UI, and this version-info exposure. `make check` passes end to end
(including the Docker HTTP integration suite).
Refs #591
Fourth piece of #591 — the "minimal and future-proof at once" move from
the design doc: no new notice UI, just one new entry in the #419
announcements list, which is already rendered in both the admin UI and the
player and already has per-ID dismissal.
Added Announcement.MessageFunc/DismissKeyFunc (nil = use the static
Message/ID, as before, so the existing #419 entry is unaffected) since
this entry's text names a specific version and its dismissal must be
per-version — dismissing the notice for v1.2.0 must not suppress a later
notice for v1.3.0. HandleListAnnouncements/HandleDismissAnnouncement now
compute the effective key through Announcement.dismissKey(s) rather than
reading the static ID field directly.
Refs #591
Third piece of #591. --update-check-enabled/--update-check-interval
(UPDATE_CHECK_ENABLED/UPDATE_CHECK_INTERVAL), default off/24h, following
the same local main.go flag pattern as discovery-enabled — not pkg/config,
which soundtouch-service doesn't import at all (correction to the issue's
proposed location, see the design doc).
Background goroutine modeled on startDeviceDiscovery: startup jitter
(0-5min), skips the immediate check if the persisted last-check is still
fresh, backs off retries to no sooner than 1h after a failure, logs once
per newly-detected version. The decision logic (shouldCheckImmediately,
shouldSkipDueToBackoff, logUpdateIfNewlyAvailable) is split into pure,
directly-testable functions rather than living inline in the goroutine.
Server gets a SetUpdateChecker/UpdateCheckResult pair (nil-safe) so the
next two pieces (announcement, /api/setup/version) can read the current
state without importing updatecheck's construction details.
Manually verified against a running instance: enabled via flags, no panic,
service stays responsive (jitter means the actual first check can take up
to 5 minutes to fire, so this only confirms the wiring, not a live
GitHub response — that's covered by the previous commit's httptest-backed
unit tests).
Refs #591
Second piece of #591. Standalone package (pkg/service/updatecheck):
GitHub releases API client, golang.org/x/mod/semver comparison (promoted
from indirect to direct dependency), persisted state via datastore's
UpdateCheckState. Dev/(devel)/dirty current versions skip the comparison
entirely rather than guessing; prereleases are excluded even though
GitHub's /releases/latest endpoint shouldn't return one anyway (defensive).
Deliberately decoupled from handlers.Server/main.go: repo and current
version are constructor arguments, not hardcoded, so a future CLI-side
check could reuse this as a plain import rather than a rewrite (open
question 2 in the design doc).
Not wired into the service yet — nothing calls NewChecker/CheckNow outside
tests.
Refs #591
First piece of #591 (opt-in periodic update check). A small persisted
state (last_checked_at, last_seen_version) under update-check.json,
mirroring Settings' Get/Save shape — separate from Settings itself since
this is runtime state, not operator-editable config.
Not wired to anything yet.
Refs #591
Prompted by not being able to tell where a locally-run instance's data dir
actually was without inspecting the running process (ps/lsof). Adds
data_dir to /api/setup/version's response, resolved to an absolute path so
it's unambiguous regardless of whether --data-dir/DATA_DIR was relative or
left at the default.
Shown as a read-only line at the top of the Settings tab, not the always-
visible footer — the footer is prime real estate seen on every tab/every
page load, and this is a rarely-needed piece of diagnostic info that
belongs alongside the rest of System Settings instead.
Also filled in a test-helper gap: the pkg/service/handlers package's
internal test router (main_test.go) never registered /setup/version at
all, unlike the real production router — added it so the new test (and any
future one exercising this endpoint) can actually run.
Unrelated to #419, but found while verifying that work against a running
instance.
The backend (field, validation, guard rail, live-reload middleware) has
worked since the first #419 commit, and the announcement banner correctly
told people "you can opt in now in Settings" — but there was never
actually a control in Settings to do that with. Caught by manual testing
against a running instance: the banner rendered fine, proving chunks 1-6
worked, but Settings had nothing to act on.
Adds a select (mirroring default_landing's pattern) with the tri-state
choices spelled out in plain language, wired into updateSettings()/
fetchSettings() alongside the other fields.
Verified end-to-end against a running instance: setting it to "enabled"
(with non-default credentials) persists, and immediately gates /admin
(401 without credentials, 200 with) without a restart.
Refs #419
The design's three-area target model (chooser/app/admin) and the backend
(HandleListAnnouncements' target=app filtering) already supported this, but
nothing in soundtouch-player's frontend called it — chunk 5 only wired the
admin UI. New Announcements Preact component (static/js/components/), mounted
in App() above the main content so it's visible across every page, styled
with the app's existing CSS variables (dark-mode-aware, unlike the admin
UI's hardcoded inline colors). Currently renders nothing, since no
announcement in the list targets "app" yet (only the admin-gate notice,
targeting "admin") — this is just closing the parity gap so a future
app-targeted announcement has somewhere to show up.
Verified end-to-end against a running instance: the component is served
under /app/static/js/components/, app.js references it, and
/api/announcements?target=app responds correctly (empty today).
Refs #419
Seventh and final piece of #419's initial rollout. Adds addActivityLog to
buildDiagnosticArchive, walking stats/activity/ and bundling every event
file verbatim (same idea as the per-device XML bundling, mirroring
addSettingsJSON's placement). Without this, the privacy guarantee discussed
during design ("local-only, but included in an explicit diagnostic export")
would have been aspirational rather than true — caught before documenting
it as fact.
Documents the activity log in DIAGNOSTIC-EXPORT.md, anchored to the
existing "all data stays on your network" language in
SOUNDTOUCH-SERVICE-ANNOUNCEMENT.md.
This closes out the initial #419 implementation: AdminAreaAuth setting +
guard rail, BasicAuthAdmin gate, activity log + dismissal cache,
announcements + dismiss endpoint, admin UI banner, health check nudge, and
now diagnostic-export coverage + docs. Still opt-in only (AdminAreaAuth
defaults to unset) — flipping the default is a separate, later change per
the design doc's rollout plan.
Refs #419
Sixth piece of #419. Visibility-only nudge, same spirit as
mgmt_default_credentials: surfaces on the Health tab that the admin-area
gate exists and is unset, for operators who dismissed the announcement
banner or never saw it on an older release. Does not gate anything.
Refs #419
Fifth piece of #419: wires up the announcements endpoint added in the
previous commit. A banner container sits outside the tab-content divs
(index.html) so it stays visible across all tabs, not just one. Fetched
once on page load alongside settings/version/devices; dismissing calls the
server-side dismiss endpoint (not a client-only localStorage flag, so it
stays dismissed across sessions/devices) and removes it from the DOM
immediately.
Manually verified end-to-end against a running instance: the banner
container renders, the admin-gate notice appears by default, dismissing it
removes it from subsequent /api/announcements responses. No Go test
coverage — this is frontend-only wiring of already-tested endpoints.
Refs #419
Fourth piece of #419: a small in-code (not admin-authored) announcement
list, target-scoped ("app"/"admin", "chooser" reserved but not wired since
the landing page has no JS yet) and filterable by live server state via
ShowWhile. First entry: the admin-area-gate heads-up, shown on the admin
target while AdminAreaAuth is unset.
GET /api/announcements?target=... and POST /api/announcements/{id}/dismiss
are deliberately NOT behind BasicAuthAdmin — the whole point of the gate
notice is to reach operators who haven't set up credentials yet, the exact
audience an admin-only endpoint would exclude. The dismiss endpoint
validates id against the known announcement list before it reaches
RecordActivity, since this is the one call site where an id comes from an
HTTP request rather than a compile-time constant.
Updated the router snapshot (testdata/router_routes.txt) for the two new
routes.
Not wired into any UI yet — nothing calls these endpoints.
Refs #419
Third piece of #419. BasicAuthAdmin() mirrors BasicAuthMgmt but reads the
live AdminAreaAuth mode and credentials on every request instead of
capturing them once at router-setup time, so toggling the Settings-UI
switch takes effect immediately.
Split mountSetupAPI into mountSetupAPIShared (ca.crt, tts/speak, tts/config
— used directly by soundtouch-cli and soundtouch-player, must stay reachable
regardless of the gate) and mountSetupAPIAdmin (everything else). Wired the
gate around /admin and both mountSetupAPIAdmin mounts (/setup, /api/setup).
Stockholm's optional legacy setup wizard is intentionally left out of scope.
Also fixes two lint issues introduced in the prior commit (unchecked
json.Marshal in tests, HandleUpdateSettings over the cyclomatic complexity
threshold) since `make lint` wasn't run before that commit landed.
Refs #419
Second piece of #419: a generic, local-only, append-only activity log
(datastore.RecordActivity/GetActivityRecords, one file per event under
stats/activity/<kind>/, same shape as SaveUsageStats) meant to back the
upcoming announcement-banner dismissals and be reusable for other admin-UI
action kinds later.
The read path never touches disk: a scoped startup scan folds prior
dismissals into an in-memory map once, RecordDismissal updates it
write-through. Same id can recur with a new timestamp (re-shown, dismissed
again) — it's a log, not a keyed store.
Not wired to anything user-facing yet — no announcements exist to dismiss.
Refs #419
First piece of the #419 admin-area gate: a persisted, live-reloadable
tri-state setting ("" unset / "enabled" / "disabled") so a later release
can flip the default from open to gated without breaking an explicit
opt-out. Rejects enabling while MGMT_USERNAME/MGMT_PASSWORD are still the
published default, since that would give a false sense of security.
No behavior change yet — nothing reads this field to actually gate
anything. That's the next chunk.
Refs #419
The placeholder state is expected and harmless (every code path already
treats it safely, and it self-heals once language/provider settings are
saved), but the old wording read like a file-corruption error and leaked
the internal account.json filename to users. Reword it to explain the
actual (benign) state instead.
Refs #360
HandleUpdateSettings and HandleUpdateLoggingSettings each built a fresh
datastore.Settings{} from scratch before saving, so any field not covered
by that handler's own DTO (e.g. hand-edited trust_forwarded_headers /
trusted_proxy_cidrs) was silently reset to its zero value on every save.
Load the persisted settings first and overlay only the fields each
handler actually owns, matching the pattern already used elsewhere
(addMargeHostToTLSFix).
Fixes#589
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
## Summary
- Documents the default Management API credentials (`admin` /
`change_me!`) right where the Local Account tab is introduced
(`MUSIC-SERVICES.md`) — surfaced by #269, where the reporter was stuck
at an unexplained login prompt.
- Consolidates: the defaults now live in one place
(`SOUNDTOUCH-SERVICE.md`'s existing Configuration Options table), with
the other five mentions across the docs linking to it instead of
restating the value independently.
- Fixes two factual errors found while consolidating
(`SELF-HOSTING.md`): it claimed there's no login by default (wrong —
Basic Auth is always on with a published default) and that it protects
the Settings tab (wrong — `/api/setup/*` isn't behind Basic Auth at
all).
Refs #269.
## Test plan
- [x] Docs-only change; links verified against existing cross-file
anchor conventions used elsewhere in the repo
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
## What
Adds a new `runtime_bmx_url_stale` health check to `soundtouch-service`.
For each reachable speaker it reads the **runtime** `bmxRegistryUrl`
(from the on-device `SoundTouchSdkPrivateCfg.xml` via SSH, or `getpdo
CurrentSystemConfiguration` over telnet) and warns when it still points
at the shut-down Bose cloud, offering a copy-paste re-migrate command.
## Why
Radio source types (TUNEIN / RADIO_BROWSER / LOCAL_INTERNET_RADIO) are
delivered to the speaker through the BMX registry. A speaker whose
runtime `bmxRegistryUrl` still names the Bose cloud can never mount
them, even though the service's own `/sources` listing is correct. The
existing `sources_xml_diff` check reports the *symptom* ("missing 3
source types"); this check reports the *per-device cause*, so an
operator sees exactly which speakers still need re-migrating.
This is the recurring "radio missing after migration" theme (relates to
#549, #547, #546, #493). In the #549 diagnostic, 6 of 9 speakers had
never actually been migrated (all four runtime URLs still on
`content.api.bose.io` / `streaming.bose.com`) while the service itself
looked healthy; this check would have surfaced that per device
immediately.
## False-positive guard
Under a DNS-based migration (AfterTouch acting as the speaker's DNS
server) a cloud URL is legitimate: the redirect happens at the DNS
layer, not by rewriting the on-device URL. So the check stays silent
while the service's own DNS interception is running (`GetDNSRunning`).
The router-DNS variant (the LAN's DNS points at AfterTouch without our
DNS server running) cannot be detected here, so it is called out as a
known exception in the finding text rather than suppressed.
## Changes
- `pkg/service/health/checks_runtime_bmx_url.go` (+ unit test): the
check, following the injected-closure pattern of `checks_marge_url.go`.
`assessRuntimeBmxURL` is the pure, testable core; `isBoseCloudHost` does
a domain-suffix match on the Bose cloud domains.
- `pkg/service/handlers/handlers_export.go`: a lightweight
`readSpeakerBmxRegistryURL(ip)` reader (SSH then telnet), reusing the
existing export imports. The diagnostic export path itself is unchanged.
- `pkg/service/handlers/server.go`: registers the check, wiring
`GetDNSRunning` as the guard.
The health package stays free of the SSH / `setup` imports (the reader
lives in the handlers layer), matching the existing dependency boundary.
## Testing
- `go test ./pkg/service/health/` green (new tests: cloud URL warns;
AfterTouch URL and empty URL do not; `isBoseCloudHost` matrix).
- `go vet` and `golangci-lint` clean on both packages; `go build
./cmd/soundtouch-service/` succeeds.
Not tied to a single issue to close; it complements the #549 / #547 /
#546 / #493 cluster as a diagnostic aid.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Summary
- #269's stuck Spotify presets traced to the account link never
completing, invisible because the admin UI silently swallowed 401s from
`/api/mgmt/*` instead of prompting for a retry. The browser's own Basic
Auth caching already works correctly here (verified live); the bug was
purely missing feedback.
- Adds two Health-tab checks: Spotify configured but no account linked,
and Management API credentials still at the published default.
Refs #269, #419.
## Test plan
- [x] `make check` (fmt, vet, unit tests) clean
- [x] `make lint` clean
- [x] Live-tested end to end with a headless Chrome (chromedp) against a
local build: confirmed the new failure-path messages render correctly
for `fetchSpotifyStatus`, `fetchAccountList`, and `linkSpotify`
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Relates to #571.
## What
Adds a **Name / IP** sort toggle to the Player device list. The choice
is persisted in `localStorage` (`aftertouch_device_sort`), following the
same preference pattern as the service-URL field in `PlayURL.js`.
## Why
The device list was previously ordered only by IP: the service datastore
keys devices by IP address and Go marshals map keys lexicographically,
so the frontend received an already-IP-ordered object and rendered it
as-is. BirdyBA (#571) asked to be able to sort by name instead.
## Changes
- `DeviceList.js`: a `sortEntries()` helper plus a `useState`-backed
toggle seeded from `localStorage`. Name mode sorts by `device.info.name`
(falling back to the IP key when a device has no name yet); IP mode
sorts the IP key **numerically** (`.2` before `.10`), which also tidies
the old lexicographic ordering.
- `css/app.css`: additive `.device-sort` / `.sort-btn` styling, reusing
the existing accent / `.active` look. No existing rules touched.
No backend change: the device name and IP are already in the payload.
## Testing
- `node --check` on `DeviceList.js` passes.
- `make build-player` succeeds (the static tree is `//go:embed`ed into
the binary).
- Manual: open the Player, toggle Name / IP, confirm the order changes
and the choice survives a page reload.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
github/codeql-action/init, /analyze and /upload-sarif are separate
Dependabot dependencies but must run on the same version. Without a
group they update in independent PRs that merge at different times,
producing a version mismatch that fails CodeQL. Group them so all
sub-actions bump together in one PR.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dependabot bumped only github/codeql-action/analyze to v4.37.0 while
init and upload-sarif stayed on v4.36.2. CodeQL requires all of its
action steps on the same version; the mismatch failed every Analyze job
with 'Loaded a configuration file for version 4.36.2, but running
version 4.37.0'. Bump init and upload-sarif to the same v4.37.0 commit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chi 5.3.1 recognizes the HTTP QUERY method, so chi.Walk now expands the
all-methods HandleFunc registrations for the SiriusXM live-adapter routes
to include QUERY. The routes are functionally unchanged; only the walk
output grew two lines.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- HTTPS-SETUP.md: the HTTPS endpoint is only needed for certain features
and its URL now derives from the Target Domain by default; note the
https-Target-Domain shortcut and the Settings override.
- SOUNDTOUCH-SERVICE.md: HTTPS_SERVER_URL is an override that derives from
SERVER_URL when empty, and is viewable/overridable in Settings.
- TROUBLESHOOTING.md: new entry for the "HTTPS endpoint TLS configuration"
health warning (wrong port / not reachable), how to fix via the Settings
HTTPS URL, and when it's an expected reverse-proxy case.
refs #355
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up polish to the HTTPS-URL settings work:
- Group Target Domain and the derived HTTPS URL under a single bold
"Service URLs" section header, matching the existing section-header
pattern (Landing page, TLS extra hosts, Device Discovery) rather than a
one-off fieldset box — consistent across the whole Settings tab.
- Tighten the spacing so the HTTPS URL sits with Target Domain (drop the
empty :443-status reserved line, reduce the intra-group gap) instead of
floating toward the next section.
- Make "Landing page" a bold header for the same consistency.
- Expand the HTTPS URL override hint: HTTPS is only needed for certain
features (DNS redirect, Spotify/Amazon login, cert trust); it derives
from the Target Domain; and if you don't need plain HTTP you can set the
Target Domain itself to an https:// URL, no override required.
refs #355
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two follow-ups to the derive/show/override settings work:
- The "advanced override" affordance reused the .info-toggle style with a
text label, which is an 18px circular icon badge — the label rendered as
a broken blue circle. Use the icon-toggle pattern like TLS extra hosts:
a small ⓘ that reveals a details block containing the explanation and the
override input.
- Existing installs persist their old effective HTTPS URL in the (now
override) https_server_url field, so the UI showed "(override)" even when
the value equals what we would derive. On load, treat an override that
exactly matches the derived URL as "derive" (clear it), so default
installs show "(derived from Target Domain)"; genuinely custom values are
kept as overrides.
Verified live: an existing settings.json with https_server_url equal to the
derived value now reports an empty override, and the served admin HTML uses
the ⓘ toggle.
refs #355
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The diagnostic export wrote the raw persisted https_server_url, which is
now the override (empty when the URL is derived). Report the effective
HTTPS URL actually in use plus the override as a separate field, so a
diagnostic makes an advertised-URL/listener mismatch legible instead of
showing an empty field.
refs #355
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The HTTPS URL AfterTouch advertises (and points speakers at for the
DNS-redirect, OAuth, install-ca and cert-trust flows) was a separate,
internally-tracked value: sourced only from --https-server-url /
HTTPS_SERVER_URL / the settings file, defaulting to the machine hostname,
and never shown or editable in the web UI. So it could silently diverge
from the Target Domain (e.g. a different host, or a port-less value that
fell back to 443 while the listener was on 8443 — the root of #355), with
no way to see or fix it in the UI.
Make it derive + show + override:
- DeriveHTTPSURL resolves the effective HTTPS URL: an explicit override
wins; otherwise it follows the Target Domain (same host, https, on the
configured HTTPS port); an already-https Target Domain is honoured
verbatim (its port is not second-guessed); empty falls back to the
hostname default. So changing the Target Domain updates the HTTPS URL
automatically for the common single-host case.
- The persisted https_server_url is now the *override* (empty = derive).
Existing installs carry their old value here, so it is preserved as an
override — no silent change on upgrade; clearing it opts into derive.
- The server keeps httpsServerURL as the effective value, so all
consumers (cert SANs, migration, export, health) are unchanged; it is
recomputed whenever the Target Domain or override changes.
- Settings API returns https_server_url (effective) plus
https_server_url_override; the Settings page shows the effective URL
with a derived/override note and an "advanced" override field.
Verified live on a clean data dir: derive from an http Target Domain,
auto-follow when the Target Domain changes, explicit override, an https
Target Domain kept verbatim, and override persistence across restart.
Unit tests cover DeriveHTTPSURL including the already-https cases.
refs #355
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the previous commit. The cert-chain check dialed the
advertised HTTPS URL, whose port defaults to 443 when the URL omits it
(splitHTTPSHostPort). The advertised URL comes from
--https-server-url / HTTPS_SERVER_URL / the settings file and is not
editable in the web UI, so when it lost its port it silently pointed the
check (and speakers) at 443 while the real listener was on 8443 — the
exact "port 443" complaint in issue #355.
Thread the actual HTTPS listener port into the check (new
Server.SetHTTPSListenAddr, wired from config.httpsAddr). When the dial
fails and the advertised port differs from the listener port, emit a
mismatch-specific warning that names both ports and offers the corrected
HTTPS_SERVER_URL, while still deferring to reverse-proxy setups. A
reachable endpoint never reaches this branch.
Reproduced locally on a clean data dir: seeding a port-less
https_server_url with the listener on 8443 previously errored on
:443; it now warns with both ports and the fix. Regression tests added.
refs #355
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `service_cert_chain` check ("HTTPS endpoint TLS configuration") dials
the service's own configured HTTPS URL. When that dial fails before any
certificate is presented (connection refused, timeout, handshake reset),
it reported a hard red error.
But from inside the service we can't distinguish "the endpoint is down"
from "the advertised HTTPS URL simply isn't reachable from here" — and
the latter is a normal, healthy deployment: TLS terminated by a reverse
proxy in front of AfterTouch, or a Docker-published port / LAN-only
hostname that the container itself can't dial. In those setups the red
error is a false alarm (issue #355: reporter runs HTTP 8080 / HTTPS 8443
and noted "in my configuration that is expected").
Downgrade that specific case (no cert presented) to a warning, reword it
to name the expected reverse-proxy / unreachable-advertised-URL case, and
add an `openssl s_client` command to verify the endpoint from a client
that actually reaches the advertised URL. Cert-classification outcomes
(own-CA info, foreign-chain warning) are unchanged.
Reproduced locally on a clean data dir before/after: custom ports and
localhost/127.0.0.1 already returned INFO; only the unreachable-URL case
produced the error, which now returns a warning.
refs #355
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address the two docs follow-ups from #493 (radio sources not mounting
after an in-place migration).
Troubleshooting: the "Radio sources never activate after an in-place
migration" entry now leads with the confirmed non-destructive fix,
re-running migration via the telnet method so all four service URLs
(incl. bmxRegistryUrl / statsServerUrl) land on the speaker's runtime:
soundtouch-cli --host <ip> setup migrate --method telnet --service-url http://<host>:8000
Factory reset is kept as the fallback for models without a reachable
telnet port. The cause text is updated to the diagnosed BMX-registry
explanation, and notes why pointing the service at http://bose:8000 with
a server-side /etc/hosts entry does not help.
Downloads: new top-level docs section (docs/content/docs/downloads/)
structured by tool (service / player / cli / backup) x OS/arch, using
the real release asset naming (soundtouch-<tool>-v<ver>-<os>-<arch>),
plus install-script, Docker, and go-install routes. Sibling section
weights bumped so Downloads leads the sidebar. README, the release-notes
template, and the key install guides now point here. Also fixes the
stale, never-produced .tar.gz/.zip filenames in SELF-HOSTING.md.
refs #493
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Close every post with the established footer convention (## Current
release + dated release line + subscribe note), and never restyle
already-published posts to fit a new convention.
- Prefer a narrative over a bare release-note aggregation.
- Forbid em dashes (with a grep check).
- Allow an explicit tag/date argument to override lookback detection.
- Stop the skill from pushing or opening the PR itself: commit to a
branch and hand the maintainer the push + PR commands (maintainer
always pushes over SSH).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tells the story since v0.93.1 (rescue to platform): local music + TTS,
robustness/security hardening, health diagnostics. Emphasises that the
roadmap is community-driven, shouts out Sander ten Brinke's
soundtouch-maui companion app, and sets out what v1.0.0 signals
(stability, clean-slate re-migration, beyond-Bose value: #495, #508, #188).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Spell out that AfterTouch reads X-Forwarded-For only when the connecting
socket's source IP is in trusted_proxy_cidrs (the socket address, which a
header can't forge), reword the table rows in those terms, and note that a
proxy in a separate Docker container is usually seen as the Docker bridge
subnet rather than its published address.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reverse-proxy client-IP resolution (trust_forwarded_headers /
trusted_proxy_cidrs) was documented under HTTPS-SETUP because proxies are
commonly used for TLS termination, but it's really a deployment concern.
Relocate it to CLOUD-DEPLOY-WALKTHROUGH as a "Client IP behind a proxy or
load balancer" section with a behavior table (no-proxy default, trusted-proxy
XFF resolution, and the untrusted-peer spoofing gate). HTTPS-SETUP keeps the
TLS-termination example and now cross-links to it; the deployment section
links back for the cert details.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Startup treated an empty server_url as "first run" and wrote a fresh
default settings.json via createDefaultSettings, which builds the struct
from CLI flags and does not merge the existing file. A hand-authored
settings.json that sets, say, trust_forwarded_headers but leaves
server_url to the --server-url flag has no server_url, so it was
silently clobbered on first start (losing the operator's keys).
Gate the default-seed (and the lost-volume "first run" notice) on the
ABSENCE of settings.json instead. An existing file is now always
respected; a genuinely empty data dir still gets defaults and the
notice. This also fixes a latent loop where a never-set server_url made
every start look like a first run.
Adds regression tests: settingsFileExists, plus first-run seed both
preserving a hand-authored file and writing defaults when absent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Update the HTTPS reverse-proxy guide and the trust_forwarded_headers /
trusted_proxy_cidrs settings comments to reflect that the client IP is now
resolved from X-Forwarded-For only (no longer X-Real-IP / True-Client-IP),
read via the request context rather than by rewriting r.RemoteAddr. The
nginx example now sets X-Forwarded-For.
(Release note staged locally at _/releases/v0_117_0.md, which is gitignored
like prior release notes.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chi v5.3.0 deprecates middleware.RealIP (IP-spoofing advisories), which
failed the Lint and Static Security Analysis CI jobs (SA1019). Replace the
RealIP wrapper with chi's middleware.ClientIP: ClientIPFromRemoteAddr is
always applied so middleware.GetClientIP is populated, and when
trust_forwarded_headers is set and the immediate peer is a trusted-proxy
CIDR, ClientIPFromXFF resolves the real client from X-Forwarded-For
(rightmost entry outside the trusted CIDRs). The immediate-peer trust gate
is preserved, so a non-trusted peer's XFF is ignored. CIDR strings are
validated with netip.ParsePrefix first to avoid ClientIPFromXFF's panic.
Behavior change: only X-Forwarded-For is honored now (RealIP also read
X-Real-IP / True-Client-IP). Docs and a release note follow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Route every HTTP read of the client IP through a single clientHost(r)
helper backed by chi's new middleware.GetClientIP, falling back to the
socket peer from r.RemoteAddr. AddDeviceToAccount now takes a bare client
host instead of a "host:port" RemoteAddr. Behavior is unchanged in this
commit (no ClientIP middleware is wired yet, so the fallback is always
taken); a follow-up wires middleware.ClientIP and removes the deprecated
middleware.RealIP.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The check title asserted "Speaker /sources matches service Sources.xml",
but the row renders as a warning when they differ, so "matches" plus a
warning read as a contradiction (reported in #493). Reword to "should
match" so the title states the expectation; the per-finding messages and
severities already convey whether it holds and what the differences are.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per-device health findings previously labelled the device by account
and device IDs only (e.g. "account 3230304 · device 08DF1F0BA325"),
which is hard to place at a glance. Add display-only Name and IP fields
to health.Target and fill them centrally via EnrichTargets after the
checks run, so individual checks don't each have to look up the device
record. Both the live health endpoint and the diagnostic export go
through the new Server.runHealthChecks helper, and the Health tab renders
the friendly name first, then account/device IDs, then IP.
Fixes match on Account+Device only, so the new fields don't affect
quick-fix dispatch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document the setup where AfterTouch sits behind a local resolver
(AdGuard Home / Pi-hole / FRITZ!Box) and is addressed by a short
hostname like `bose` instead of a raw IP. Captures the symptom cluster
(INVALID_SOURCE, missing source types, URL-mismatch pre-flight) and the
fix: short-hostname DNS rewrites, TLS_EXTRA_HOST coverage, switching the
service URLs to the hostname, and re-migration. Based on a real
user-contributed setup; IPs sanitised to RFC 5737.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Acknowledge a small Discord for direct, real-time conversation when an
email exchange or an issue/discussion thread isn't enough. No public
invite link: Issues and Discussions stay the first stop, and the invite
is shared in-thread only when a conversation genuinely needs it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The help text still listed only tts/url/beep/notify. Add the UPnP
AVTransport option (no app key, no DNS; http:// only, replaces source).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a third way to push a clip to a speaker, surfaced by @dagrider in
#517: POST SetAVTransportURI + Play to the speaker's UPnP MediaRenderer
control endpoint (port 8091). Unlike /speaker play_info it needs no
app_key and no DNS interception, so it works on a plain LAN; the
trade-off is it switches the speaker to the UPNP source and replaces the
current playback (no duck-and-resume).
- pkg/client: SetAVTransportURI, AVTransportPlay, PlayURLViaUPnP (+ the
:8091 control-URL derivation and SOAP plumbing), with tests.
- cmd/soundtouch-cli: `speaker url-upnp --url <url>`.
- docs: document the UPnP/AVTransport option under POST /speaker.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The on-device installer's curl fails with `curl: (60) ... certificate is
not yet valid` when the speaker's clock has fallen into the past (no NTP
since the cloud shutdown), since TLS then rejects the recently-issued
server cert. Document the symptom and the fix (set the date over SSH,
then re-run), and note the speaker_clock health check keeps it corrected
afterwards.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Delete RemoveMember, ClearMembers and HasMember (deprecated in the
previous commit) plus their tests. They had no production callers after
the zone remove paths moved to /removeZoneSlave.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RemoveMember, ClearMembers and HasMember have no production callers: the
zone remove paths now use /removeZoneSlave instead of a /setZone rebuild,
and standalone is done by dissolving the zone. Mark them Deprecated ahead
of removal in the next commit (keeps history legible).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Removing one member from a multi-member zone did nothing. The remove
paths rebuilt the zone with /setZone and the remaining members, but
/setZone is additive: it never drops a member that is simply absent from
the list. It only "removed" when the resulting set was empty (equivalent
to dissolve), which is why removing the last member worked but removing
one of several did not.
Switch all three remove paths to the dedicated /removeZoneSlave endpoint
(already implemented as client.RemoveZoneSlave):
- HandleZoneRemove (web UI "remove member")
- HandleZoneLeave (web UI slave "leave zone")
- RemoveFromZone (client lib, used by CLI `zone remove`)
DissolveZone (setZone master-only) and HandleZoneAdd (additive setZone)
are correct and unchanged. Adds handler regression tests for remove/leave
and rewrites TestClient_RemoveFromZone to assert /removeZoneSlave (the old
test removed one of two members but only checked that setZone was called,
never that the member was dropped).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The walkthrough mounted the volume at /data, but the image's DATA_DIR is
/app/data, so the documented docker run never actually persisted the
datastore, settings or CA; a recreated container silently lost all state.
Correct the mount path, document what lives under /app/data and the cost
of losing it, and add a Windows/macOS Docker Desktop note (host
networking is Linux-only; publish ports; DNS interception needs :53/:443).
The service also logs a clear notice on startup when the data dir looks
empty.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
play_info notifications make the speaker validate the app_key via
GET /v1/auth against a hardcoded Bose host; without DNS interception that
call can't resolve and /speaker times out with ALLEGROWEBSERVER_TIMEOUT
(1046). Document the requirement on POST /speaker (plus the no-DNS
LOCAL_INTERNET_RADIO alternative), and have the "Test DNS path" health
check mention that TTS/play_info depends on the same DNS path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
checkCACertTrusted matched only the static "# AfterTouch" label in the
device's trust bundle. After the service CA was regenerated (e.g. a
recreated container with a fresh/empty data dir), the stale label was
still present, so the migration wrongly reported the speaker as already
trusting the new CA and skipped re-installing it, leaving the speaker
unable to validate TLS to the service.
When the service CA is available, compare the actual cert payload and
re-install on mismatch; fall back to the label only when the CA can't be
read (CLI callers without Crypto). Adds regression tests for the
stale-label and no-Crypto cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- radio-browser.md: correct the relative path to the troubleshooting
section (../guides/TROUBLESHOOTING.md#..., not ../../guides/.../).
- TROUBLESHOOTING.md: drop a same-page fragment link to the emoji
"Getting More Help" heading (github-slugger anchor was unstable);
reference the section in prose instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Use the repo's own scripts/decrypt-diagnostic.go (not the generic age
CLI), unpack per-file next to the .age, and note the archive layout.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
After an in-place migration the firmware sometimes does not activate the
radio source types (LOCAL_INTERNET_RADIO, TUNEIN, RADIO_BROWSER) even
though the entries are present in the device's own Sources.xml; a reboot
and a sourcesUpdated notification do not help. The root cause is not yet
understood, so this documents the user-confirmed workaround (factory
reset + re-migrate) rather than changing migration behaviour:
- New troubleshooting section with a stable anchor, linked from the
sources_xml_diff health check and the Radio Browser reference.
- Capture the speaker's on-device /mnt/nv/BoseApp-Persistence/1/Sources.xml
in the diagnostic export (when SSH is available), so a future report
taken before a factory reset carries the evidence to pin down the cause.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The on-device and Raspberry Pi installers hardcoded the release version, which
had to be bumped on every release. Default VERSION to empty and resolve the
newest tag by following GitHub's documented stable redirect
(https://github.com/<repo>/releases/latest -> .../releases/tag/vX.Y.Z), reading
the effective URL. This avoids the GitHub API rate limit and needs no jq.
An explicit version (positional arg / VERSION= / --version) still pins a
release. If the lookup fails (offline, rate-limited, or a curl without -w
support), each script falls back to a pinned FALLBACK_VERSION so installs still
work. The Pi self_update path runs after resolution, so it fetches the resolved
tag's installer.
Docs updated to state the default installs the latest release; the pinned-version
examples remain as illustrations.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The installer docs and the on-device + Raspberry Pi installer scripts all
defaulted to and showed v0.107.0. Update every install example and the
VERSION defaults to the current release v0.111.3 across the on-device and
Pi guides and scripts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
soundtouch-web was renamed to soundtouch-player and the web UI merged into
soundtouch-service, so the player is now optional. The Raspberry Pi / host
installers had no matching uninstaller (removal was only documented as manual
commands), and users who installed the old soundtouch-web have a leftover
service with no scripted way to remove it.
Add three uninstallers under scripts/raspberry-pi/, each mirroring its
installer's conventions and tolerant of already-missing pieces:
- uninstall.sh — soundtouch-service; preserves the data directory by
default, --purge / PURGE_DATA=true to delete it.
- uninstall-player.sh — soundtouch-player (stateless).
- uninstall-web.sh — leftover soundtouch-web; points users at install-player.sh.
The shared soundtouch:soundtouch user/group is removed only once no other
soundtouch-{service,player,web} install remains on the host.
Docs: the README and guides still told users to fetch install-web.sh to install
the player. Switch those to install-player.sh, keep but improve the manual
removal commands (note the service datastore is preserved unless explicitly
deleted), document the new uninstallers, and add a "Migrating from soundtouch-web"
section.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The migration pre-flight "Reachability check (passive observer)" reported a
timed-out no-inbound as a hard failure, so the panel showed "N of M checks
failed" and forced a Proceed Anyway. That outcome is usually just timing: the
speaker's swUpdate daemon dials out on its own slow schedule and a reboot
after Apply validates the fan-out. One reporter wrongly suspected custom
service ports were to blame (#471, Leeto001).
Introduce a proper non-blocking `warn` status (amber, no fail count) and
downgrade the no-inbound case to it, with a message that explains the timing
and states it is not a port or config problem and is safe to proceed. The
pre-flight summaries now surface a warning count alongside the passed/skipped
counts and keep auto-proceeding; genuine probe errors still fail.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v0.114.0 binaries reported version 0.0.0 in the web UI. Two root causes,
both fixed here.
1. The release build relied solely on Go's VCS stamping of
info.Main.Version and never injected a version. When v0.114.0 was
re-released via workflow_dispatch from `main` (one commit past the
tag) with a shallow checkout, no tag was reachable, so Go stamped a
v0.0.0-<ts>-<sha> pseudo-version. The asset filenames used the
validated input version, so the files were named v0.114.0 but
reported 0.0.0 at runtime.
2. The `release` and `workflow_dispatch` triggers followed two distinct
patterns. On `release` every job's checkout landed on the tagged
commit (GITHUB_SHA == tag); on `workflow_dispatch` they all built
whatever branch the run started from. So a manual dispatch built the
wrong source entirely (binaries and Docker images alike).
Changes:
- Unify both triggers on the git tag. `validate` resolves the tag once
(inputs.tag on dispatch, release.tag_name on a release event), verifies
it exists in git, and exposes it as an output. Every other job checks
out `ref: needs.validate.outputs.tag`, so the build is always the
tagged commit regardless of trigger. The dispatch path now re-releases
an existing tag (push the tag first) instead of creating one from a
branch; it fails fast if the tag is missing.
- Inject -X main.version/commit/date into the release binaries, mirroring
the Dockerfile (which has done this since #422). version/commit no
longer depend on git stamping; commit is read from the checked-out HEAD
(not github.sha, which on dispatch is the branch HEAD). Both binaries
and Docker images take the v-prefixed tag (needs.validate.outputs.tag)
so the displayed version stays "v0.114.0", matching prior releases.
- Guard updateBuildInfo() in all four cmd/*/main.go so an injected
version (version != "dev") is never clobbered by a VCS pseudo-version.
`go install …@vX.Y.Z` still resolves the tag via build info as before.
- Collapse the duplicated `if event_name == workflow_dispatch` tag
derivations and route tag/version through needs.validate.outputs.*.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
soundtouch-web was a transitional alias of soundtouch-player. Stop
building and publishing it everywhere, and refresh the release notes
while at it:
- release.yml: remove the soundtouch-web binary, its individual and
combined checksums, and its release assets (EXPECTED_COUNT 35 -> 28);
drop the ghcr.io/...-web Docker image steps. Also slim the
workflow_dispatch release notes to an accurate AfterTouch header plus
GitHub's auto-generated changelog, with the bare tag as the title.
- Dockerfile: drop the soundtouch-web image stage.
- Makefile: remove WEB_NAME and the build-web target (and its use in
build/install).
- Delete scripts/raspberry-pi/install-web.sh (it fetched a release asset
that is no longer published) and point the docs at install-player.sh.
- Correct README, CLAUDE.md, and main.go wording that claimed the alias
was still published.
The runtime notice for a binary still run under the soundtouch-web name
is kept, so anyone who renamed the binary is nudged to soundtouch-player.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The default `setup enable-ssh` injects the remote_services/sshd payload
only via `envswitch boseurls set` and relies on the speaker re-reading its
boseurls (~60s) without a reboot. On the SoundTouch Portable (Series I,
FW 27.0.6.46330.5043500) and some CineMate 520 units the device accepts and
persists that injection (getpdo confirms) but sshd never comes up, so :22
stays "Connection refused".
@Henri-be got root on the ST Portable by typing a different sequence by hand
over telnet :17000: the injection rides `sys configuration margeServerUrl`
(the runtime layer) as well as `envswitch`, all four URL keys are written,
and the device is rebooted so it re-parses the config at boot.
Add an opt-in `--full-config` flag that replicates that exact sequence
(EnableSSHViaTelnetFullConfig + telnet reboot via the existing
RebootMethodTelnet). The default single-envswitch path is unchanged, so the
field-confirmed flow on the Wireless Link Adapter and CineMate 520 `lisa`
variant does not regress. Docs (TELNET-COMMAND-REFERENCE, DEVICE-LOGGING)
document both paths and which device models/firmware need `--full-config`.
The flag automation is candidate behaviour awaiting reporter confirmation:
the manual sequence is confirmed on the ST Portable, the flag is not yet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#500: the absolutely-centered page title and the right-aligned icon bar
shared the same space in the fixed-height navbar and overlapped on phones
(portrait). On <=600px the navbar now wraps into two rows: row 1 keeps the
logo with the title beside it (the title fills the remaining width and
ellipsizes), and the icon bar drops onto its own centered, full-width row
below. CSS-only.
#498: the zone/grouping UI showed raw IP addresses instead of device names.
Root cause was a field-name casing bug: Zone.js read info.Name (uppercase),
but the device info field is info.name (lowercase) everywhere else in the UI
(app.js, DeviceList, Library, TTS, ...). So the lookup always missed and fell
back to the IP. Fixed the casing in the deviceName() helper, and made the
"Add to zone" picker show the device name with the IP as a smaller secondary
line (reusing the .picker-device-info/name/ip pattern the other pickers
already use). Member/master rows resolve names via deviceName().
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaying a STORED_MUSIC item from Recents sent the speaker a ContentItem with
an empty type (recents carry no contentItemType for STORED_MUSIC), and the
speaker rejects an empty-type STORED_MUSIC select with INVALID_SOURCE. The
library play paths work because they pass type "track"/"dir".
HandleDevicePlay now derives the type from the speaker-native location, which
ends with the item kind (e.g. "1$4$2 TRACK" -> "track"), when the caller didn't
supply one. (The recents account itself is already correct via the #503 fix.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DLNA STORED_MUSIC playback stopped after one track and next/previous did nothing:
the Library UI only offered a play button on individual tracks and always
selected with type "track", so the speaker had no queue to advance through
(next/prev send the NEXT_TRACK/PREV_TRACK key, which needs a queue).
- playEntry now passes the entry's own type, so selecting a folder uses the
container type ("dir") instead of "track" — letting the speaker queue the
folder for next/previous + auto-advance.
- show the play button on folders too (title "Play folder"), in addition to
navigating into them.
Server-side needs no change: HandlePlayLibrary already forwards the type to the
speaker's /select. Whether a given firmware queues a container select is to be
confirmed on hardware (testable with cmd/example-dlna-server).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the Copilot Autofix commit for the reflected-XSS finding.
- fakespeaker buildAddGroupResponse: the autofix modelled only
name/master/slave, dropping the posted masterDeviceId, roles, id, and
senderIPAddress that the client (pkg/models.Group) actually sends and
TestFakeSpeakerAddGroupEchoesWithGroupOK expects to survive the echo.
Parse into the canonical models.Group and re-marshal it, so values stay
XML-escaped (CodeQL-clean) and the fake can't drift from the real
request schema. Updates the now-stale doc comment.
- marge ProviderSettingsToXML / fakespeaker: satisfy golangci-lint
(wsl_v5 cuddled type decls, gofmt trailing blank lines) the autofix
left behind.
make lint clean; marge, handlers, and fakespeaker suites pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two lint fixes on the DLNA test server, no behaviour change:
- nilerr: the "skip unreadable file, keep walking" branch in the
--media-dir WalkDir callback returns nil after a non-nil read error
by design; annotate it with //nolint:nilerr, matching the existing
skip-entry branch above it.
- revive (redefines-builtin-id): rename between()'s `close` parameter
(and `open` for symmetry) to closeTag/openTag so it no longer shadows
the builtin `close`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
--media-dir flattened all tracks into a single container named after --name, so
browsing showed one "<--name>" dir (and a doubled breadcrumb) instead of the
real album folder, and every track's artist was the hardcoded "Test Artist".
- Group tracks by their containing directory; each becomes its own browsable +
playable container titled after that directory (e.g. "Sunday at Devil Dirt").
- Derive the artist from the directory above the album
(<root>/<artist>/<album>/track), falling back to "Unknown Artist"; album stays
the track's own folder name.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tooling to reproduce "album cover broken in the player" (disc #499) and to debug
STORED_MUSIC playback against a controllable DLNA source.
- dlnatest.Item gains ArtPayload/ArtMime; the Browse DIDL emits
<upnp:albumArtURI> and the art bytes are served at /AlbumArt/<id>.<ext>. The
built-in tracks carry a tiny PNG cover so the repro works with zero setup.
- audio + art are served via http.ServeContent (adds the byte-range support real
speakers use when streaming).
- example-dlna-server gains --media-dir: serve real .mp3/.wav/.flac/.m4a/.ogg
files, searched recursively so an artist/album tree works. Art per track: a
sibling <name>.jpg/.png, else cover.jpg/cover.png/folder.jpg in the album
folder. Files are read into memory (point it at an album, not a whole library).
- BrowseMetadata: serveContentDir now honours BrowseFlag and returns single-object
metadata (with the track's <res>). Speakers issue Browse(BrowseMetadata) to
resolve a track before playing; returning empty caused INVALID_SOURCE.
- fix SSDP discoverability on multi-interface hosts: join the multicast group on
the interface that owns the LAN IP (macOS lists lo0 first, so the old "first
multicast interface" join landed on loopback and never heard the LAN M-SEARCH).
- add an HTTP access log (method/path/status/bytes/peer, plus ObjectID+BrowseFlag
for Browse) so the speaker's request sequence is visible while debugging.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A speaker<->marge recents sync could re-store the same recent (same ID) multiple
times — observed live as one STORED_MUSIC track appearing 4x in the speaker's
/recents, the service's stored Recents.xml, and /full. The duplicates crowd the
capped (10) recents list and evict other sources (e.g. a freshly played Spotify
track never appears). SaveConfiguredSources already dedups by ID; SaveRecents did
not, so dupes introduced by any path (AddRecent move-to-front, syncRecents from
the speaker's /full, setup/health) persisted and fed back through the sync loop.
SaveRecents now dedups by ID (first occurrence wins) at the single chokepoint all
callers share, so the list self-heals on the next write. Regression test added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-playing an existing recent could make it (and its list neighbour)
vanish from the speaker's recents, even with the list well under the
10-item cap. Root cause is a slice-aliasing bug in updateOrCreateRecent's
move-to-front branch:
recentObj = &recents[i]
recents = append([]ServiceRecent{*recentObj}, append(recents[:i], recents[i+1:]...)...)
return recentObj, recents
The inner append(recents[:i], recents[i+1:]...) shifts elements left in
place in the shared backing array, overwriting slot i. The returned
recentObj still points at &recents[i], so it leaks the neighbouring
recent back to the speaker. Worse, Go does not specify evaluation order
between the *recentObj dereference and the inner append call, so the
front element written into the saved list can also read the overwritten
slot, dropping the matched recent and duplicating its neighbour. The
SaveRecents dedup-by-ID guard then collapses that duplicate into a clean
loss.
Verified against recorded interactions (a "White Water" replay returned
the "Sand Castle" recent; both Spotify albums vanished from a 9-item
list) and a live diagnostic export (6 persisted recents, no duplicates,
both albums gone).
Fix: copy the matched recent out first, rebuild into a fresh backing
array, and return a pointer into the new slice. Adds a regression test
that fails on the old code and passes now.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaying a STORED_MUSIC media-server item from Recents failed with
INVALID_SOURCE: the served recent's <source> had an empty <username>, so the
speaker fell back to the provider id ("7") as the account and could not resolve
which media server to use.
Root cause: a media server's account ("<UDN>/0") is persisted in
SourceKey.Account, but Username is NOT persisted (SaveConfiguredSources writes
sourceKey.account, not username). prepareRecentItemParitySource and
formatRecentResponse emitted <username> straight from the now-empty Username
field. The /full path (mapToFullResponseSource) already falls back to
SourceKeyAccount; the recents builders did not.
Fix: add recentSourceUsername(src) that falls back to SourceKeyAccount when
Username is empty (TuneIn / Internet Radio / Local Internet Radio keep an empty
username for parity), used by both recent <source> builders. Regression test
drives the captured Bose_Lisa flow (sourceid-only recent POST) and asserts the
served <source><username> is the real UDN, never empty or the bare provider id.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A speaker registers each DLNA media server as a STORED_MUSIC source whose
account is "<UDN>/0", and reconciles its source list against marge (/full +
/sources). AddSource deduped STORED_MUSIC by provider ID alone, so registering
a second media server overwrote the first in the datastore; the first then
disappeared from /full + /sources and the speaker dropped it. Only one media
server could ever stay registered.
- STORED_MUSIC now replaces only when the account (SourceKey.Account) matches,
so distinct servers coexist and re-adding the same server updates in place.
Other (singleton) providers keep replace-by-provider.
- Generate source IDs from crypto/rand instead of a per-second timestamp.
SaveConfiguredSources dedups by ID, so two sources created in the same instant
would otherwise collide and one would be silently dropped; a timestamp (even
nanosecond) is fragile on coarse clocks, so use 64 bits of randomness with a
timestamp fallback only if the RNG fails.
- Add a regression test for two coexisting media servers + same-account update.
Diagnosed from speaker + service logs: setMusicServiceAccount succeeds locally,
the speaker pushes AddSource to marge (streaming.bose.com, DNS-intercepted to
AfterTouch), then re-fetches /full + /sources; that list returned only the
latest STORED_MUSIC source, so the speaker pruned the previously-added one.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adding a DLNA media server (setMusicServiceAccount) can leave the new
STORED_MUSIC source not fully registered on the speaker, so playing a track
fails with INVALID_SOURCE until a power-cycle. AfterTouch's health
diagnostic already recommends the no-reboot fix: a sourcesUpdated
notification makes the speaker re-fetch its account /full and re-register
its source list.
Fire that nudge automatically right after a successful registration, in
both the player (HandleAddLibraryServer) and the CLI (account add-nas), via
the existing client.NotifySourcesUpdated. It is best-effort: registration
already succeeded, so a failed nudge never fails the request (the handler
returns {account, refreshed}, the CLI prints a warning that a power-cycle
may still be needed). The handler resolves the Bose device ID from the
cached DeviceConnection.DeviceInfo, falling back to GetDeviceInfo.
Note: per the diagnostic, a power-cycle is still occasionally required, so
the nudge is an improvement, not a guarantee.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TestPrintRoutes is a golden snapshot of the full chi route tree. The new
DLNA Music Library routes (device-scoped /library/{servers,browse,play} and
the global /providers/library/servers, plus the /app/library SPA deep link)
legitimately extend the tree, so refresh the snapshot. Diff is exactly the
seven new library routes mapping to the new handlers; no other routes change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New guide docs/content/docs/guides/dlna-music-library.md: how to browse a
DLNA/UPnP media server and play it on a SoundTouch via native STORED_MUSIC
(discover -> register -> browse -> play), with CLI examples (RFC-5737 IPs +
placeholder UDNs) and the player Library tab (BETA), plus gotchas and format
limits. Flips GET /listMediaServers from unimplemented to implemented in
UNIMPLEMENTED-ENDPOINTS.md (client.ListMediaServers + models.ListMediaServersResponse).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The DLNA UDN from discovery carries a "uuid:" prefix (e.g.
uuid:fa095ecc-...), but a SoundTouch STORED_MUSIC account is the bare UUID
plus /0 (the speaker's /sources reports the bare form). The mismatch made
the player Library tab show an "Add" button for an already-registered
server, and an Add via the UI would have registered a wrong "uuid:.../0"
account. Normalize (strip "uuid:") when mapping discovery results to the DTO
and when building the account in HandleAddLibraryServer, so the LAN list and
the registered list agree and Add builds the correct account. Verified live:
discover now returns the bare UDN, matching /sources.
Also populate the previously-unset MediaServer.Address from the
ContentDirectory control URL host.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the soundtouch-player "Library" tab (Preact + htm), implementing the
discover -> add server -> browse -> play flow over the device-scoped
library API. Device is picked up front (browsing is speaker-native), then:
find LAN servers (SSDP) and add one to the speaker, open a registered
server, navigate folders via a breadcrumb, and play a track via native
STORED_MUSIC. Mirrors the TuneIn/RadioBrowser components and reuses their
CSS classes; marked BETA. api.js gains libraryDiscover/Servers/AddServer/
RemoveServer/Browse/Play; app.js gets the nav entry, title, and route.
Validated end to end through the running player against real hardware
(FRITZ!Box media server -> ST10): now_playing source=STORED_MUSIC
status=PLAY_STATE.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wires the soundtouch-player control API for browsing and playing DLNA
media-server content on a speaker, using the validated native STORED_MUSIC
path (the speaker is the DLNA control point; no AfterTouch proxy).
Routes (under /api/control):
- GET /providers/library/servers LAN-wide SSDP sweep (discovery.DiscoverMediaServers)
- GET /devices/{id}/library/servers STORED_MUSIC sources registered on this speaker (+ ready)
- POST /devices/{id}/library/servers register a server (setMusicServiceAccount; 1024 = already present)
- DELETE /devices/{id}/library/servers/{account} unregister
- GET /devices/{id}/library/browse speaker /navigate (root or container) -> location tokens
- POST /devices/{id}/library/play select a STORED_MUSIC ContentItem (type=track)
- GET /app/library SPA deep link
Browsing goes through the speaker's own /navigate so the returned location
tokens are the ones /select accepts; the raw DLNA ContentDirectory IDs are
not playable, so pkg/dlna is intentionally not on this path. All handlers
reuse existing client methods (Navigate, NavigateContainer, SelectContentItem,
GetSources, AddStoredMusicAccount, RemoveStoredMusicAccount) and the existing
APIResponse envelope. Unit tests cover play XML shape, browse mapping,
source filtering, idempotent register, and validation/404s.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hardware testing (ST10 FW27.0.6 + FRITZ!Box 6490) showed the previous
raw-URL play modes do not play DLNA content: a raw stream URL sent as a
LOCAL_INTERNET_RADIO location is rejected by the speaker (APServer
"REJECT: TransportControl: Wrong Client", nothing plays). The native
mechanism is STORED_MUSIC: register the server, then select a ContentItem
carrying the media server's object ID as location.
`library play` now takes --source-account (<UDN>/0) and --location
(object ID from a browse), plus optional --name/--type/--art, and selects
a STORED_MUSIC ContentItem with type="track" via SelectContentItem (so the
type is set, which SelectStoredMusic does not do). It first checks /sources
for a READY STORED_MUSIC entry with that account and, if absent, prints a
ready-to-copy `account add-nas` hint and stops instead of failing opaquely.
The old --url/--mode raw-URL flags are removed.
Validated end to end: browse -> play -> now_playing source=STORED_MUSIC
status=PLAY_STATE. The speaker streams from the media server directly; no
AfterTouch proxy involved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the CLI-first surface for the DLNA feature
(https://github.com/gesellix/Bose-SoundTouch/discussions/213), so the
discovery/browse/play plumbing can be exercised against a real media server
and speaker without the web build loop.
- soundtouch-cli library servers: app-side SSDP sweep
(discovery.DiscoverMediaServers); --via-speaker queries the speaker's own
/listMediaServers instead, for an A/B of the two views.
- soundtouch-cli library browse --udn <id> [--object --start --count]:
dlna.Browse of a discovered server's ContentDirectory.
- soundtouch-cli library play --url <streamURL> --mode <...>: plays a track
URL on a speaker; --mode selects the playback path (local-internet-radio,
local-music, stored-music, content-item) so the best one can be found
empirically on hardware.
- pkg/client.ListMediaServers() + models.ListMediaServersResponse for the
speaker-native (Option 2) path; an empty <ListMediaServersResponse />
parses to an empty slice.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Foundation for browsing DLNA media servers and playing tracks on a
SoundTouch speaker (https://github.com/gesellix/Bose-SoundTouch/discussions/213).
- pkg/discovery/ssdp.go: a target-agnostic UPnP SSDP core. SearchSSDP sweeps
multiple targets (a typed device URN plus ssdp:all, since some servers only
answer one), fans out across all routable IPv4 interfaces, and sends each
batch in two rounds spaced 80ms apart so slower NAS/router boxes that drop
back-to-back bursts still answer. FetchDescription parses a UPnP device
description into a generic device tree with FindService/FirstIcon that
recurse through sub-devices. The XML parse is a pure function for offline
unit testing.
- pkg/discovery/mediaserver.go: DiscoverMediaServers rides the core, keeps
only devices exposing a ContentDirectory service, and dedupes by UDN. The
description->MediaServer mapping is a pure, tested function.
- pkg/dlna: a ContentDirectory browse client (Browse + DIDL-Lite parse +
IsAudioItem), consuming discovery.MediaServer. Kept separate from discovery,
mirroring how pkg/client is separate from pkg/discovery. Track metadata maps
upnp:artist / upnp:album; the audio filter accepts audio/* MIME or an
audioItem/musicTrack class.
Existing SoundTouch speaker discovery (pkg/discovery/upnp.go) is untouched;
migrating it onto the shared core is a later, de-risked step. Tests cover the
description/DIDL parsers and run the browse client against an in-process
ContentDirectory server; the parse was checked against real minidlna and
FRITZ!Box output.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a dependency-free Go DLNA/UPnP MediaServer used to develop and test
the upcoming "browse a DLNA server and play on a SoundTouch" feature
(https://github.com/gesellix/Bose-SoundTouch/discussions/213).
Two faces over one content core:
- pkg/dlna/dlnatest: an in-process server (httptest) serving rootDesc.xml
and ContentDirectory Browse for an injectable content tree, for fast
cross-platform unit tests with no Docker and no multicast.
- cmd/example-dlna-server: the same handlers behind a real http.Server
plus an SSDP responder (answers M-SEARCH, periodic NOTIFY), so a real
speaker on the LAN can discover it and fetch real (silent WAV) audio.
A Docker minidlna was unusable here: on macOS the container IP in the
DIDL <res> URL is unreachable from the LAN, and its SSDP never reaches the
speakers. A native Go server embeds the host LAN IP and is discoverable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two follow-ups from the #471 field reports on the BETA `setup enable-ssh`:
1. enable-ssh: when sshd (:22) does not come up within the wait window, this is
no longer treated as a hard error. On some devices (e.g. the Wireless Link
Adapter) the envswitch injection is accepted but sshd only starts after the
speaker restarts. The command now prints a warning with power-cycle + retry
guidance (and the exact ssh command), deliberately leaves the injected
boseurls in place so a restart re-triggers the unlock, and exits cleanly
instead of failing.
2. XML migration: re-apply the boseurls over telnet at the end of migrateViaXML
so the runtime layer reported by `getpdo CurrentSystemConfiguration` matches
the persisted SoundTouchSdkPrivateCfg.xml. After enable-ssh bootstraps SSH,
that runtime layer still points at the placeholder (https://aftertouch.invalid),
so the preflight cross-check keeps warning that margeServerUrl/swUpdateUrl
differ between transports until a reboot. The re-apply reconciles it now.
Best-effort: if telnet is unavailable (e.g. port 17000 was closed via
--close-17000), a reboot still reconciles the layers, so it only logs a note
and never fails the migration.
Tests cover the re-apply command and its best-effort (non-fatal) behavior.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The templates were written during the Go-library era and no longer match the
project: they asked reporters (mostly speaker owners) for Go versions, library
versions, pkg/client pickers, and minimal repro code, while pointing at dead doc
links. #478 reported one of those dead links (the troubleshooting guide).
Issue templates:
- Fix the dead troubleshooting + API-cookbook links (now the published docs site).
- Delete the legacy .md duplicates of bug_report/feature_request/device_compatibility
(GitHub was showing them alongside the .yml forms).
- Rewrite bug_report.yml and feature_request.yml around how people actually run
AfterTouch (service/CLI/player/backup); make them short and easy to file, with
the encrypted diagnostic export as the headline ask.
- Add device_compatibility.yml (slim) and a config.yml chooser that links
Discussions, the Survival Guide, and the Troubleshooting Guide. Blank issues stay
enabled.
Diagnostic-export transparency: instead of claiming the report "contains no
readable secrets", state honestly that the raw datastore XML (e.g. Sources.xml) is
included as-is and can carry access tokens for linked services (Spotify/Amazon),
that there is no datastore-redaction setting, and that users can unlink first or
send privately. Point at the same support email the Health tab shows
(aftertouch-support@gesellix.net) and note GitHub blocks .age uploads (rename to
.age.txt or zip).
PR template: cut the library-era ceremony down to summary/issue/type/testing/
checklist, add an "AI-assisted contributions" note (agent code welcome, unreviewed
slop rejected), a no-personal-data reminder, and an MIT + Code of Conduct footer.
CONTRIBUTING.md: reframe from "Bose SoundTouch API Client / Go library" to the
AfterTouch toolkit; fix build paths (./build/) and make targets; drop broken
references; point at CLAUDE.md; add the AI stance and the no-personal-data rule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Selecting a Radio Browser station from the player UI returned HTTP 500
and the speaker dropped to INVALID_SOURCE. The play path sent the speaker
a ContentItem with source="URL" and an absolute location
(https://all.api.radio-browser.info/soundtouch/stations/byuuid/<uuid>).
source="URL" makes the speaker fetch that location as a raw audio stream,
but the URL returns station JSON, not audio, so the speaker rejects it.
"URL" was never a real source: it is not in the speaker's sourceprovider
registry and never persisted in any datastore. The rest of the stack is
already built for the native RADIO_BROWSER source (BMX registry provider
39 with base URL .../soundtouch, a seeded RADIO_BROWSER source, marge
classification, and the documented relative location form). Working
RADIO_BROWSER items use source="RADIO_BROWSER" with a relative
location="/stations/byuuid/<uuid>", which the speaker resolves against
the registry base URL and plays directly.
- stations.ResolveContentItem: emit source=RADIO_BROWSER for the provider
- RadioBrowserSearch: emit the relative /stations/byuuid/<uuid> playback
href so the speaker prepends the registry base URL
- marge classifier: match the relative /stations/byuuid/ segment (covers
both the relative and legacy absolute forms)
- tests updated to assert the native source + relative location
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the #471 "secure" steps as opt-in flags on `setup enable-ssh`, off by
default (per the decision that closing 17000 must be opt-in):
- --close-17000: blocks port 17000 from the LAN. Manager.Close17000 remounts /
read-write, persists an idempotent iptables rule in
/etc/init.d/Firewalls/update_iptables (keyed on a marker), and applies it
immediately; loopback access is kept.
- --authorized-key <pubkey>: Manager.InstallAuthorizedKey writes the key to
/home/root/.ssh/authorized_keys so root SSH no longer relies on the
empty-password login.
Both run over the SSH the enable step just opened. Default output reminds the
user that 17000 is left open and how to close it. Unit tests cover the
firewall command sequence and the key upload path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds `soundtouch-cli setup enable-ssh`, the first iteration of foob61451's #471:
turn on SSH on a speaker that has no prior SSH access and without a USB
recovery stick, then fall into the migration / CA-install flow we already have.
Mechanism (new Manager methods, reusing the existing telnet :17000 client):
- EnableSSHViaTelnet sends `envswitch boseurls set "<url>;touch
/tmp/remote_services;/etc/init.d/sshd start" "<url>/update"`. The injected
shell commands run when the speaker next parses its boseurls (~60s), starting
sshd. The URL is only the vehicle for the injection — it does NOT need a live
server, so this works before any AfterTouch service exists.
- WaitForSSHPort polls :22 until sshd is up.
- ResetBoseURLs restores a clean marge URL afterwards.
- Persistence reuses the existing EnsureRemoteServices (writes the marker over
the now-open SSH so it survives reboot).
CLI flow: inject → wait for :22 → reset clean URLs → persist. `--service-url`
is optional (placeholder used otherwise; set real URLs later via migration).
Securing/closing port 17000 is deliberately OPT-IN and not done here. Unit
tests pin the exact injected/reset command strings and the double-quote guard.
This lands in -cli first (cheapest to iterate); the future soundtouch-app can
reuse the same Manager methods.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The embedded player's TTS proxy made a server-side call back to the
service over the public ServiceURL. When that URL is HTTPS with the
service's self-signed CA, the call failed with "x509: certificate
signed by unknown authority" — the service didn't trust its own CA.
Route the player's own server-side self-calls to the service's loopback
HTTP listener instead (new WebApp.InternalServiceURL, used via
proxyServiceURL()). Loopback is plain HTTP, so it needs no CA and works
on HTTP and HTTPS deployments alike, including before the CA is
generated, and it doesn't depend on the public URL being routable from
inside the service. ServiceURL stays public: Play URL bakes it into the
stream URLs the speaker fetches, and the UI displays it.
config.port is always the plain-HTTP listener (http.Serve); TLS lives
on a separate httpsAddr, so the loopback URL can never hit a TLS-only
socket.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The set_clock quick-fix pushed the time via POST /clockTime and reported
success unconditionally. On real hardware (ST10, observed live) the firmware
dispatches POST /clockTime to its read handler (HandleClockGetTime) and
ignores the value: it returns 200 but the clock never moves, so the fix was a
silent no-op that still claimed success.
Now setSpeakerClock:
- tries HTTP POST /clockTime (works on firmware that honours it), then
- verifies by re-reading /clockTime; if the clock did not move, it
- sets the clock over SSH (`date -u -s …`, with a BusyBox positional
fallback) on an SSH-reachable speaker (root, empty password), and
- verifies again. It only reports success when the clock actually changed;
otherwise it returns an honest error pointing at the real root cause
(the speaker can't resolve/reach NTP, so the clock is stuck — restore
DNS/NTP reachability; a wrong clock breaks HTTPS/TLS).
The HTTP request format itself was already correct (the device's own GET uses
`utcTime`); the problem was never the payload, only that some firmware has no
HTTP setter at all. Durable NTP-side fix (AfterTouch resolving/serving NTP) is
tracked separately.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The soundtouch-player image (and its transitional soundtouch-web alias) ran
as root for no reason: the player is stateless, binds an unprivileged port
(8080), and its mDNS/SSDP discovery uses unprivileged multicast. Drop to
USER nobody. Verified the image starts, binds 8080, and discovers as uid
65534.
The soundtouch-service image is left as root for now: it persists to
/app/data (commonly a host-mounted volume whose ownership we can't assume)
and its optional built-in DNS server binds the privileged :53. Making it
non-root needs a chowned data dir plus NET_BIND_SERVICE (or moving DNS off
:53), so it's handled separately.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lands the groundwork to run the service container as non-root, but keeps it
running as root by default so this is NOT a breaking change yet. Enabling it
(BREAKING) is planned for v1.0.0 and reduced to a one-line flip.
Image prep (all harmless while running as root):
- A fixed non-root user, uid/gid 65532 (aftertouch), with /app chowned to it.
- A cap_net_bind_service file capability on the binary so the optional DNS
server can still bind :53 as non-root (NET_BIND_SERVICE is in Docker's
default cap set; no --cap-add needed). Applied after chown so it survives.
- USER ${APP_USER} with ARG APP_USER=root: still root by default. To enable
non-root, flip the default to "aftertouch" (one line) or build with
--build-arg APP_USER=aftertouch.
Startup safety net (active now, no-op while writable):
- warnIfDataDirNotWritable probes DATA_DIR and, if it can't write, logs the
exact `chown -R 65532:65532 <dir>` fix (with the process uid) instead of
failing later with a cryptic permission error. This is the common snag when
a non-root container meets a bind-mounted host dir owned by someone else.
Verified: default build runs as root; --build-arg APP_USER=aftertouch runs as
65532, serves /health, writes the data dir; a read-only data dir triggers the
warning + chown hint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The community Markdown conversion of the official SoundTouch Web API PDF moved
from jaas666/bose-soundtouch-player-api to jaas666/bose-soundtouch-web-api.
Update the links in the community-tools comparison and related-resources list
so the docs link check passes. (Supersedes an earlier mistaken removal; the
repo was renamed, not deleted.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The web player is intrinsically LAN-resident: it reaches speakers directly
and only delegates cloud-only features (e.g. TTS) to a possibly-remote
AfterTouch service via --service-url. That is exactly what a cloud-hosted
soundtouch-service cannot do, so the standalone player binary stays useful
and is not being deprecated. Rename it to state its purpose, with a
transition window so existing downloads keep working.
- cmd/soundtouch-web -> cmd/soundtouch-player; CLI name is now
soundtouch-player. When the binary is invoked under its old name it prints
a one-line rename notice (filepath.Base(os.Args[0])).
- Build/release both names from the same source: Makefile (build-player +
build-web alias, dev-player* targets), Dockerfile (soundtouch-player image
+ transitional soundtouch-web image), release.yml and ci.yml (player +
web artifacts, checksums, Docker images; release notes announce the
rename). The soundtouch-web binary, image, and install script remain a
transitional alias to be dropped in a future release (which will break
stale fetch scripts and nudge users to the release notes).
- scripts/raspberry-pi/install-player.sh is canonical; install-web.sh keeps
working but warns.
- Sweep docs, code comments, user-facing strings, and assets
(soundtouch-web-ui.png, soundtouch-web-tunein.png, soundtouch-web-roadmap.md)
to soundtouch-player; README documents the rename and why the player
remains separate from the embedded /app.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The header bars are intentionally monochrome, but the logo was recoloured
along with them (admin forced it white; the player and chooser whitened it
in light mode). Drop the filter on the brand mark only so it stays in its
blue/yellow brand colours as the single accent, while the mono nav icons
still recolour via --nav-icon-filter. Removes the now-unused --logo-filter
var from the chooser.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Unify the three surfaces' footers and rework the documentation link:
- All footers now show the same version-only line (AfterTouch <version>
(<commit>) • <date>), centered and full-width. The chooser footer no
longer caps its width or carries a docs link; the admin footer uses the
same "•" separator as the player and chooser instead of "-".
- The chooser gets a prominent in-body Documentation link with a book
icon, distinct from the two destination rows (and removed from the top
bar, which is now brand-only).
- That same book icon becomes a small docs button in the player navbar
and the admin header bar, so documentation is one click away from every
surface.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
With default_landing set to app or admin, "/" redirects straight there,
which made the chooser (and through it the other surface) unreachable
from the "home" link. Add a "?chooser" override: "/" always serves the
chooser when that query is present, regardless of the configured default.
Point the "home" brand links on the player, the admin console, and the
chooser itself at /?chooser, so "home" always lands on the hub instead of
bouncing back through the default redirect. The bare "/" still honours the
default for direct hits and bookmarks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause of the "navigating away from /admin waits ~30s" report: the
device list refreshed every device's live /info at once. Over HTTP/1.1 a
browser opens only ~6 connections per origin, and it keeps the current
document's in-flight requests (and their sockets) alive until a new
navigation's response begins. With several offline speakers each holding
an /info socket until timeout, all ~6 connections were occupied, so the
next navigation (GET /) could not get a socket until a probe freed one.
The page genuinely waited the full timeout before painting.
Cap the live-info probes at LIVE_INFO_CONCURRENCY (3) via a small mapLimit
helper, leaving sockets free for navigation and other requests. Combined
with the 5s GET timeout, an offline-heavy datastore no longer stalls the
UI. The device table still renders immediately from the datastore; only
the live enrichment is throttled.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Manager.HTTPGet defaulted to http.Get, which uses http.DefaultClient with
no timeout. An offline speaker therefore hung the caller for the OS-level
TCP timeout (~30 s). The admin device list refreshes every device's live
/info on each load (updateDeviceInfo per row), so a handful of offline
speakers each held a request for 30 s. Server-side those run concurrently
and never blocked other routes, but the browser's ~6-connections-per-origin
limit got saturated by the long-held /info requests, which made the whole
admin page (and navigating away from it) feel stuck.
Give HTTPGet a 5 s timeout (liveDeviceHTTPTimeout): ample for a healthy
speaker on the LAN, quick to fail a dead one. Applies to the /info,
/presets, /recents, /sources, inspect, and peer-probe GETs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The previous commit gated the on-load discovery sweep to a cold start,
but a second, redundant DOMContentLoaded handler still called
triggerDiscovery() ungated on every admin load, so /admin kept kicking
off a full sweep (and its reseed) even with devices already known. The
second handler only duplicated fetchDevices + fetchSettings + the
ungated trigger, all of which the first (gated) handler already does, so
remove it outright. That also drops the duplicate per-device live /info
refresh the second handler caused.
Also drop two em dashes (a code comment and the landing meta description).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The admin console ran a full discovery sweep on every page load whenever
discovery was enabled (DOMContentLoaded -> triggerDiscovery). With devices
already in the datastore, that re-probed every host (including offline
ones) on each visit, which felt slow and surprising.
Gate the on-load sweep on a cold start only: fetch the cached device list
first, and trigger discovery just when it is empty. With devices known,
rely on the cached list, the periodic sweep, and the explicit Discover
button. fetchDevices now returns the device count for that check.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SeedExtraDevices probed each datastore host serially via AddDeviceByHost,
whose /info call blocks up to its 10 s timeout for an unknown host. With
offline speakers in the datastore, a re-sync (e.g. the admin page's
discovery sweep on load, or the periodic discovery) stalled for 10 s per
offline device, one after another.
Fan the per-host probes out across goroutines and wait for all of them,
so the seed costs roughly a single timeout regardless of how many devices
are offline. AddDeviceByHost is already registry-safe under concurrency
(covered by TestRegistryConcurrent).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Post-merge, "/" was the admin console with a small text link to the
player. This makes "/" a neutral chooser and unifies the chrome across
all three surfaces (landing, player, admin).
- "/" now serves a lean chooser page (web/landing.html): a calm, self-
contained page (no framework, inline CSS) that routes to the Player
(/app) or the Admin & Setup console (/admin), with the console framed
as the privileged surface. API/speaker clients (non-HTML Accept) still
get the version JSON from "/" unchanged.
- The admin console moved to /admin (HandleAdmin); its assets and APIs
are absolute, so it works unchanged at the new path.
- New persisted setting default_landing (chooser|app|admin): when set to
app or admin, "/" 302-redirects straight there. Exposed in the admin
Settings tab; defaults to the chooser.
- Shared header: all three carry the same accent bar (braille mark +
"AfterTouch" + "Bose SoundTouch Toolkit"); the mark is the home link
back to "/". Shared footer: all three show the same version line
(the landing fetches /api/setup/version with a tiny vanilla script).
Light/dark and mobile refinements are deliberately left for a later
pass; the admin keeps its existing light-only styling for now.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The merge of soundtouch-web into soundtouch-service was asymmetric:
manual device *adds* propagated to the player UI (HandleAddManualDevice
notifies, the hook re-seeds + broadcasts), but *removals* did not. The
datastore-removal handler never notified, and the web registry's sync
only ever added entries — its map was append-only, so a removed device
lingered in the player UI until restart.
This adds the missing removal path:
- DELETE /api/control/devices/{id} (HandleDeleteDevice). The registry is
keyed by host/IP; the datastore by device ID (MAC), so the handler
resolves one to the other via the connection's DeviceInfo, cascades to
the datastore through a new RemoveDeviceHook (embedded build only),
prunes the in-memory entry, and broadcasts the updated list.
- WebApp.RemoveDevice prunes the registry and stops the per-device
goroutines (status poller + WebSocket reconnect loop) via a new
done-channel + Close() on DeviceConnection — previously both ran for
the life of the process.
- Server.RemoveDeviceByID extracts the cross-account lookup + remove from
HandleRemoveDevice and now fires notifyDevicesChanged, so the admin
Devices tab removal also propagates to the player UI.
- Player UI: a quiet per-card Remove control (visible on hover), a
confirm dialog, optimistic prune, and a note that a still-online
device may reappear after the next discovery scan (honest v1 — no
ignore-list).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the datastore the single source of truth for the embedded web UI and
stop running a second mDNS/UPnP stack inside the same process.
- The embedded web app no longer creates its own discovery service. Its
"discover" action (POST /api/control/discover) now triggers the service's
own sweep via a new WebApp.TriggerDiscovery hook (wired to
server.DiscoverDevices), which writes results to the shared datastore.
- DiscoverDevices: when TriggerDiscovery is set it runs the external sweep
and re-syncs from ExtraDeviceHosts (the datastore) without any own mDNS;
it only runs its own sweep when given a non-nil discovery service
(standalone soundtouch-web, unchanged).
- Liveness: server.SetDevicesChangedHook fires after a discovery sweep
(server.DiscoverDevices) and after a manual add (HandleAddManualDevice);
the embedded build re-seeds the web registry and broadcasts the updated
device list, so speakers found by the service's periodic discovery or
added via /setup appear in the UI without a manual refresh.
- setupRouter no longer takes a web discovery service (it was always nil
for the service); MountWeb is mounted with a nil discovery service.
Removing devices live still needs a web-registry delete path (the registry
only adds today); that is a separate follow-up. Routes are unchanged, so
the router golden file is untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fold soundtouch-web into soundtouch-service as an additive mount, so a
single process serves both the speaker/cloud-replacement API and the LAN
control UI. No new auth and no opt-in flag: the web surface sits at the
same LAN-trust tier as /setup (which -web already calls without
credentials), and -web is LAN-only by nature.
- newEmbeddedWebApp builds the web app with release metadata, a loopback
ServiceURL (plain HTTP, no CA needed) for the TTS / Play URL proxy, and
an initial discovery sweep. setupRouter gains the web app + discovery
service and mounts the portable surface (MountWeb) additively:
/api/control/* and /app/* (+ /app/static/*). The service keeps its own
/, /health and /static; nothing collides. webApp is optional so the
router unit tests that only exercise the service surface pass nil.
- Manual devices with discovery off: the web app's ExtraDeviceHosts hook
is pointed at the service datastore (ListAllDevices), and
SeedExtraDevices (run from DiscoverDevices, i.e. at startup and on each
/api/control/discover) registers them via the existing AddDeviceByHost.
So speakers added via /setup show up in the UI even when periodic
discovery is disabled.
- The admin page at / now links to the player UI at /app; the speaker /
JSON contract is unchanged.
- Router golden file regenerated: the diff is purely the additive
/api/control + /app routes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Prepare soundtouch-web to be folded into soundtouch-service as an additive
mount. Two changes, no behaviour change for the standalone binary:
- Move the embedded assets from /static/* to /app/static/*, so the whole
web UI lives under /api/control + /app and nothing contends with a host
router's own /static (e.g. the optional Stockholm bridge's root catch-all).
index.html and app.js asset references are updated in lockstep.
- Split Mount into a portable core and a standalone wrapper. MountWeb
registers only the portable surface (/app/static/*, /api/control/*,
/app/*) and nothing outside those subtrees (no /, no /health), so it can
be mounted into another router additively. Mount (used by cmd/soundtouch-web)
now calls MountWeb and adds the standalone-only /health and /->/app redirect.
mount_test.go exercises MountWeb (asserts the portable surface owns nothing
outside /api/control + /app) and Mount (asserts it adds / and /health).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the web UI's app-wide event stream (device list, discovery status,
per-device status updates) from top-level /ws to /api/control/ws. It is
the read/event half of the control surface, so it belongs under the same
namespace as the rest of the web API (the per-device socket already sits
at /api/control/devices/{id}/ws). The bundled app.js WebSocket URL is
updated in lockstep.
This brings soundtouch-web's entire HTTP surface under two clean subtrees
(/api/control/* for the API, /app/* for the SPA), so folding -web into
-service becomes a near-additive mount.
mount_test.go now asserts /api/control/ws is registered and top-level /ws
is gone.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Model tunein, radiobrowser, playurl and tts as content "providers" and
give them a uniform /providers namespace, so the surface is consistent
and extensible (Spotify/Amazon slot in later as new providers).
Two kinds of provider operation fall out naturally:
- Browsable providers (a catalog you search/navigate) expose global
browse routes:
GET /api/control/providers/tunein/{search,search/next,navigate,navigate/*}
GET /api/control/providers/radiobrowser/search
- Every provider plays on a device via a uniform `play` verb:
POST /api/control/devices/{id}/providers/tunein/play
POST /api/control/devices/{id}/providers/radiobrowser/play
POST /api/control/devices/{id}/providers/url/play (was play-url)
POST /api/control/devices/{id}/providers/tts/play (was speak)
Input providers (url, tts) have no catalog, so they appear only as a
device play. The generic POST /devices/{id}/play (raw ContentItem) stays
the low-level primitive, not a provider. /providers stays a literal
namespace with literal provider children (no {provider} param), so there
is still zero static-vs-param ambiguity.
The bundled api.js is updated in lockstep. mount_test.go now asserts the
provider routes exist and the pre-infix flat paths are gone.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the soundtouch-web single-page app from top-level page paths
(/devices, /tunein, ...) under one /app subtree, so the whole web UI
lives under /app/* and folding -web into -service stays an additive
mount. The client navigates via component state rather than the URL and
all assets are referenced absolutely (/static/...), so this is a pure
routing change: no frontend edits needed.
The bare root / now redirects into the app (standalone convenience).
When -web is folded into -service, / instead serves a landing page
(admin vs app) and this redirect is replaced.
Extend mount_test.go with TestMountSPARoutes: the SPA resolves under
/app, the old top-level page paths are gone, and / remains only as the
redirect.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Restructure soundtouch-web's control API to the post-merge canonical
shape so folding -web into -service later is a near-additive mount.
Device-scoped actions now nest under /api/control/devices/{id}/...,
making every direct child of /api/control a literal namespace (devices,
tunein, radiobrowser, version, discover) with no static-vs-param sibling
ambiguity. Browse/search endpoints (tunein, radiobrowser) stay global.
This is a direct migration (no dual-mount, no deprecation middleware):
-web's only client is its own bundled frontend, so a reload picks up the
new paths. The bundled api.js/app.js are updated in lockstep.
Add mount_test.go: the first test that exercises Mount() itself. It
walks the registered routes to assert (a) registration never panics and
(b) the invariant that every web /api/* route lives under /api/control/*
so no flat route is left behind. Handler unit tests call handlers
directly with injected params, so their request-path literals were
cosmetic; updated to the new nested shape for accurate documentation.
SPA routes and the main /ws socket are unchanged here; they move in
follow-up steps.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Verified the actual overlap between the service and soundtouch-web routers; the
doc's "/, /health, /ws are all collisions" was too broad:
- `/` is the only true collision -> resolve with a landing page (Admin/Setup vs App).
- `/health` is a merge (both define it; standardise on the service's richer body,
and check nothing depends on the web's {"status":"ok","version"} shape).
- `/ws` and `/static/*` are additive -- the service registers neither.
Sequence the merge to mirror the proven service approach but adapted to -web:
- Migrate `-web` in place to the target shape (`/api/control/*`, `/app/*`) FIRST,
as a direct restructure -- no dual-mount, no deprecation signal -- because its
only client is its own bundled frontend (reload-to-fix). The careful
add-alias-then-deprecate dance stays reserved for the central `-service`.
- The subsequent fold-in is then a near-additive mount plus the `/` landing page
and `/health` standardisation.
Also: resolve overlaps structurally before merging (a flag that conditionally
registers routes hides a collision, it does not fix it; do not rely on chi to
warn); ship the merged variant behind an opt-in flag whose purpose is optional
testing/feedback (default-off also keeps the surface unexposed until auth lands),
not a collision guard. Note the deprecation signal is already implemented for
/setup and /mgmt.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
So the eventual 1.x removal of the legacy admin paths can be data-driven (cut a
route only once it has gone quiet across real deployments), record usage of the
pre-/api paths without changing their behavior.
- New DeprecatedRouteMiddleware: after serving, counts the hit keyed by
"METHOD <route-pattern>" and logs a one-time warning per route pointing at the
/api equivalent. Wired onto the legacy /setup and /mgmt mounts only — NOT the
/api/* twins, NOT the externally-pinned OAuth callbacks, NOT the Stockholm
setup-wizard catch-all.
- Counts are exposed in the diagnostic export (deprecated_route_hits), so the
shared bundles show whether the old paths are still in use.
Legacy paths keep working unchanged. make test-http-client: 95 requests, 0
failed (the suite still exercises /mgmt directly and now emits the one-time
warnings). go test + golangci-lint clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Point soundtouch-web's TTS proxy at the new canonical /api/setup/tts/speak path
(request URL and doc comment). No behavior change; the legacy path still works.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Point the CLI's service calls at the new canonical paths: tts speak
(/api/setup/tts/speak) and the CA bundle fetch (/api/setup/ca.crt), plus the
user-facing message and doc comment. No behavior change; legacy paths still work.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the internal self-reachability probe onto the new /api/setup/version path
(updating the doc comment and the unit test accordingly). No behavior change
(the legacy path still works); keeps our own code off the soon-to-be-legacy
/setup/* surface.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Switch the bundled admin SPA's requests from the legacy /setup/* and /mgmt/*
paths to the new canonical /api/setup/* and /api/mgmt/* aliases. Behaviour is
unchanged (the aliases serve the same handlers; TestDualRouteEquivalence pins
that), and the legacy paths stay live, so this is a no-break move. The OAuth
callback URLs are not referenced by the SPA and stay at /mgmt regardless.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Route-transition step 1: add /api/setup/* and /api/mgmt/* as purely additive
aliases of the existing /setup/* and /mgmt/* admin-tier routes, registered from
one shared closure so the legacy and new paths stay byte-identical. The old
paths remain live (no-break upgrade); the admin-SPA repoint and the
old-route deprecation signal are deliberate follow-ups.
- /api/mgmt carries the same Basic Auth as /mgmt. The browser OAuth callbacks
(/mgmt/{spotify,amazon}/callback) are externally-pinned (provider redirect
URIs) and stay at /mgmt only — not aliased.
- /api/setup serves data only; the Stockholm setup-wizard static catch-all
(/setup/*) stays under /setup.
- peer-probe is now part of the shared setup registration, so it is served at
both /setup/peer-probe and /api/setup/peer-probe (previously a one-off
top-level /setup/peer-probe route).
- New TestDualRouteEquivalence fires the same request at the old and new path
and asserts identical status + body — the harness that guards each
dual-routing step.
Frozen speaker contract untouched. Router golden updated.
make test-http-client: 95 requests, 0 failed. go test + golangci-lint clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Close the speaker/service-contract gaps found comparing against a reference
implementation — three real Bose routes we did not serve:
- DELETE /streaming/account/{account}/source/{sourceID} — removes a configured
source from every device of the account (HandleMargeDeleteSource +
marge.RemoveSourceFromAccount), mirroring the account-level POST add-source.
Bare 200, empty body. Previously source removal was only reachable via the
admin /setup surface.
- GET /bmx/tunein — bare TuneIn service descriptor (the registry's `self` link),
HandleTuneInService. chi routes both /bmx/tunein and /bmx/tunein/.
- GET /core02/svc-bmx-adapter-orion/prod/orion — bare Orion (LOCAL_INTERNET_RADIO)
adapter descriptor, HandleOrionService.
The two descriptors reuse the existing extractBMXService + applyBMXTemplate
helpers (same {BMX_SERVER}/{MEDIA_SERVER} substitution the registry applies).
Contract tests added (delete_source.http, get_bmx_service_descriptors.http);
router + frozen-coverage goldens updated.
make test-http-client: 95 requests, 0 failed. go test + golangci-lint clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Shrink the route surface the #451 refactor must preserve by retiring the
/accounts/{account}/* compatibility mirror. Across the full recording corpus
(all _/backup/*, _/mitm, _/i195, _/issue-94, captures + data/ + tests/, 139k+
.http files) no speaker or app uses the /accounts prefix, and every operation it
offered is served by the /streaming/account/* paths real clients actually use.
- New HandleUnsupported: returns 501 and logs the full request + client IP + a
"please report this" message, so any real-world use surfaces instead of being
silently dropped, and the prefix becomes a clean removal candidate.
- Re-point every /accounts/* route to it. The frozen /streaming/* contract is
left entirely on its real handlers (those stay even where our corpus didn't
exercise them — absence of capture is not proof of disuse).
- Migrate the integration tests off the /accounts mirror onto their recorded
/streaming/account/* equivalents (register/unregister/spotify_full_flow), then
pin the mirror's 501 contract in unsupported_routes.http.
- Router + frozen-route-coverage golden files updated accordingly.
make test-http-client: 91 requests, 0 failed. go test + golangci-lint clean.
Note for release time: call out the intentional /accounts/* 501 breakage in the
release notes' Noteworthy section (use /streaming/account/* instead).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Revert an over-eager sanitization: this paragraph explains *why* RFC-1918 ranges
make poor placeholders, and deliberately uses 192.168.1.10 as the
non-conformant counter-example. Rewriting it to an RFC-5737 address defeated the
point (192.0.2.10 is obviously a documentation placeholder). Restore the
illustrative bad example.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The integration suite flaked in CI: with three `go run` mocks now compiling
concurrently, the spotify/amazon mocks weren't listening within the fixed
`sleep 10`, so the registration requests at the start of the suite hit a
connection-refused and the "Account exists" assertions (and the cascading amazon
oauth token test) failed. Locally it passed because the mock builds were warm.
Replace the fixed sleep with real readiness gating:
- Add a /healthz endpoint to the spotify, amazon and tunein mocks.
- Give all four CI services (the three mocks + soundtouch-service) a compose
healthcheck (busybox wget; all images are alpine-based), and make the service
depend_on the mocks being service_healthy.
- `docker compose up -d --build --wait` blocks until everything is healthy, so
the JetBrains client only runs against a fully-ready stack.
Also clear the two semgrep advisories on the new TuneIn mock:
- cmd/mock-*: annotate the intentional plaintext ListenAndServe with nosemgrep
(throwaway loopback/CI test servers, never production).
- pkg/testutils/tunein: sanitize the query-supplied guide id to a safe charset
before interpolating it into the JSON/XML response (raw-html-format).
make test-http-client: 73 requests, 0 failed (clean testdata, healthcheck-gated).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TestFrozenRouteContractCoverage walks the service router for frozen speaker/app
contract routes (the /streaming, /accounts, /customer, /bmx, /core02, /oauth,
/custom, /media, /updates, /v1, /alexa, /ced prefixes) and checks each is hit by
at least one .http integration test. The set of uncovered frozen routes is
golden-filed (testdata/frozen_routes_uncovered.txt), mirroring the existing
router_routes.txt pattern: adding a frozen route without a test, or a test that
newly covers one, changes the set and fails the guard, forcing a conscious
update. This makes COVERAGE.md a machine-checked invariant rather than a doc
that can silently drift.
Restricted to GET/POST/PUT/DELETE (chi HandleFunc-registered routes otherwise
add CONNECT/TRACE/... noise). golangci-lint clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per the repo's no-real-data rule (CLAUDE.md), scrub committed files only (the
gitignored _/ local captures are left as-is):
- Real Bose-OUI device ID 08DF1F0BA325 -> placeholder AABBCCDDEE0A across 4 docs
and 8 Go test files (consistent 1:1 rename; affected packages tested green).
- Personal/topology LAN IPs -> RFC-5737: the lab runbook's AP subnet
192.168.10.x -> 198.51.100.x (192.0.2.x is already used contrastively there)
and 192.168.100.1 -> 203.0.113.1; illustrative example IPs in
ANONYMIZATION-SUMMARY / spotify-overview / TROUBLESHOOTING -> 192.0.2.x.
- Kept factual RFC-1918 range citations (10.0.0.0/8 trusted-proxy example,
192.168.0.0/16 "all private subnets") since they name the ranges themselves.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The device-API coverage docs had drifted from the code. Verified each claim
against pkg/client and corrected:
- UNIMPLEMENTED-ENDPOINTS.md: re-marked endpoints now implemented but still
listed as candidates — setMusicServiceAccount / removeMusicServiceAccount and
the stereo-pair group set (getGroup/addGroup/removeGroup/updateGroup); added a
reconciliation note and clarified this tracks the speaker :8090 API, not the
service router.
- SUPPORTED-URLS.md: fixed the "Not Yet Implemented" lists (music services,
presets, stations, navigate, speaker, requestToken/notification/playNotification
are all implemented), the contradictory storePreset double-listing, the native
group section, and the System Info over-claim (trackInfo non-functional,
bluetoothInfo not implemented).
- API-COVERAGE.md: fixed the exec-summary count (18/19 -> 20/21) to match its own
table and refreshed the date.
Also sanitised a real device ID (08DF1F0BA325 -> placeholder) found in
SUPPORTED-URLS.md, per the repo's no-real-MACs rule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two deliberately-unimplemented routes, pinned as "currently ignored" so a future
change to them is conscious:
- GET /v1/blacklist/{deviceId} -> 405 (inline stub)
- POST /alexa/certificate -> 501 (no AWS IoT integration)
App / provisioning surface (app-called, not the speaker data-plane). Shapes come
from the _/mitm capture where one exists, otherwise from the handler (canned /
stub responses):
- GET /streaming/account/{a}/emailaddress -> 200 (<emailAddress>, _/mitm)
- GET /customer/account/{a} -> 200 (<customer> profile, canned)
- POST /customer/account/{a} -> 200 (profile update, stub)
- POST /customer/account/{a}/password -> 200 (password change, stub)
COVERAGE.md gains an app/provisioning section and records the source (mitm vs
handler) for each. make test-http-client: 73 requests, 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two more frozen GET routes that return a static 200:
- GET /bmx/registry/v1/servicesAvailability (embedded availability registry JSON)
- GET /ced/soundtouch/mr4_22097fe2/index.xml (CED firmware-update config; a
present static file is 200, absent paths 404)
COVERAGE.md: correct the rows that were already covered by the first batch but
left marked as gaps (/v1/auth, /v1/scmudc, orion station, custom playback,
ding, bmx-icons), and record the two new routes. Remaining gaps are the ones
that need an upstream fixture (tunein episode), prior TTS state (media/tts), or
are quirky-status edges.
make test-http-client: 67 requests, 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The speaker re-polls /full and the device presets with the ETag it last saw and
expects 304 Not Modified when nothing changed. Two self-contained flows capture
the current ETag and replay it via If-None-Match, asserting 304. This pins the
conditional-GET behaviour and the case-sensitive ETag header path (CLAUDE.md).
make test-http-client: 65 requests, 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the BMX TuneIn integration tests independent of the live TuneIn
(radiotime.com) service, the same way Spotify/Amazon are already mocked.
- pkg/service/bmx: the TuneIn upstream base URLs become configurable vars with
a SetTuneInEndpoints(opmlBase, apiBase) setter that also registers the host in
the outbound allowlist. Defaults are unchanged (real radiotime hosts), so
production behaviour is identical; tests can redirect to a mock.
- cmd/soundtouch-service: new --tunein-opml-url / --tunein-api-url flags
(TUNEIN_OPML_URL / TUNEIN_API_URL) wired through to SetTuneInEndpoints.
- cmd/mock-tunein + pkg/testutils/tunein: a mock TuneIn server serving Tune.ashx
(stream URLs) and describe.ashx (name/logo) with RFC-5737 values; unmocked
endpoints 404 so a test needing them fails loudly.
- docker-compose.ci.yml: add the tunein-mock service and point the service at it.
- tunein_playback_station.http now asserts the mock-served stream URL + name,
proving the path is offline. tunein_favorite.http covers the local-only
favorite add/remove (202).
- TUNEIN-MOCK-MISSING.md lists the upstream captures still needed (episode /
navigate / search) before those routes can be mocked + tested.
make test-http-client: 61 requests, 0 failed. golangci-lint clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
create_group.http now captures the new group id from the Location header into
{{groupId}}; delete_group.http then completes the lifecycle by removing that
group (DELETE /group/{groupId} -> 200 with <status>) and exercises the no-id,
account-level teardown form a speaker sends on factory reset
(DELETE /group/ -> 200). Inserted after get_group.http, before device teardown.
make test-http-client: 59 requests, 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Build the regression net the API route layout note calls for, before any
route refactoring: mine real recorded speaker traffic (Bose_Lisa UA) into a
coverage checklist and fill the high-priority, dependency-free gaps.
- COVERAGE.md: inventory of frozen speaker routes (method + status) mapped to
covering .http files, with the remaining gaps classified by priority.
- New flows, all asserting status/content-type/structure with the firmware UA:
- GET /v1/auth (app-key probe)
- POST /v1/scmudc/{deviceId} (telemetry upload)
- GET /core02/.../orion/station (Orion custom-stream adapter)
- GET /custom/v1/playback/{encodedURL} (LOCAL_INTERNET_RADIO / ding)
- POST /bmx/tunein/v1/report (STOP -> {}, START -> nextReportIn)
- GET /media/aftertouch-ding.wav (binary: status + content-type)
- GET /media/bmx-icons/{provider}/{file} (binary: status + content-type)
All request/response values use placeholder / RFC-5737 data; no recorded
bodies are committed. make test-http-client: 57 requests, 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- architecture/_index.md: list the section's docs with links.
- reference/CLOUD-API.md: "See also" pointer (service cloud-emulation routes).
- reference/API-ENDPOINTS.md: note distinguishing the speaker device API from
the service route layout, with a link.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Architectural reference for the staged API refactoring that precedes the
soundtouch-web / soundtouch-service merge:
- Route classification by client audience and what pins each path (frozen
firmware contract vs externally-pinned OAuth callbacks vs our movable
admin/control surface), with service + web route tables.
- Actors model (speaker / app / cloud) and deployment topologies; speaker-direct
vs data-plane reachability.
- deployment-mode parameter (private/shared/public), trust tiers, auth posture
(opt-none -> opt-in -> opt-out?), and auth mechanisms (Marge as one auth
provider like EntraID; native/headless clients via RFC 8252 loopback or a
headless token; identity in logs).
- /app/* single role-gated app with code-splitting for on-device size.
- Versioning policy: no path versioning; semver with 0.x dual-routing and a 1.x
cutover that removes obsolete routes.
- Staged migration (add+alias, fold in web, deprecate the binary, observable
old-route warnings) with a "before 1.x" definition of done.
- Regression safety: contract tests from the frozen recordings, building on the
existing tests/integration/http-client suite.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A server_url configured with a trailing slash (e.g. http://host:8000/)
flowed verbatim into the BMX registry base ("{BMX_SERVER}/bmx/tunein"),
so speakers were handed "http://host:8000//bmx/tunein" and requested
"//bmx/tunein/v1/playback/station/{id}". The chi router does not match
the doubled-slash path, so TuneIn playback returned 404 and the speaker
reported INVALID_SOURCE. Confirmed from a reporter's diagnostic export.
- Add NormalizeServerURL (trim whitespace + trailing slashes); apply in
NewServer so the BMX base is always clean.
- Normalize server_url at ingestion in main (flag + persisted) so the
margeServerUrl/bmxRegistryUrl pushed to speakers stays clean too.
- Normalize in the live settings-update path so a UI-saved trailing slash
is trimmed before validate/persist.
- Mount chi middleware.CleanPath as a defensive net: any "//" path
collapses to "/" before routing, regardless of source.
- Regression tests: NormalizeServerURL table + BMX registry must not emit
"//bmx"/"//media" for a trailing-slash server_url.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The #458 empty/0-byte resilience logging logged the raw xml.Unmarshal error with
%v. A parse error can echo attacker-controlled file content, so a newline-bearing
error string reached the log unsanitized (CodeQL go/log-injection, medium). Wrap
the error with sanitizeErr (strips \n/\r), the barrier logutil.go documents.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
atomicWriteFile wrote a temp file and renamed it, but never fsync'd — so an
unclean power-cut on a journaling NAND filesystem (UBIFS on the speaker's
/mnt/nv) could leave the renamed datastore file present but 0 bytes (the rename
was journalled, the data blocks were not flushed). Now fsync the temp file
before the rename and the parent directory after, via os.Root.OpenFile/Open;
directory fsync is best-effort (unsupported on some filesystems).
Pairs with the read-side resilience fix (#459): durability prevents the 0-byte
files; resilience tolerates any that already exist.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A power-cut on the speaker's NAND can leave a datastore file present but 0-byte
(a not-yet-flushed atomicWriteFile write). The read paths now treat empty/0-byte/
unparseable Presets/Recents/Sources the same as missing: GetConfiguredSources
serves the managed defaults (so /full self-heals instead of wiping the speaker),
GetPresets/GetRecents return an empty list (no more HTTP 500 on the device-level
endpoints), and HasConfiguredSources reports a 0-byte file as absent (so the
create_default_sources health quick fix is offered again).
Read-side resilience only; the write-side durability fix (fsync in
atomicWriteFile) follows in a separate PR.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A speaker with a wrong clock fails TLS to any HTTPS host because the
certificate appears not-yet-valid or expired (the CURL ErrorCode 60 seen
in #345, where the failing speaker had a wrong clock, the only one of
several speakers that was off, with a failing NTP sync; these speakers
default to the year 2000 at boot until NTP succeeds). Nothing surfaced
this before.
The check reads each speaker's /clockTime and compares its UTC epoch to
the service's epoch. Using the epoch (ClockTime.GetUTC, not GetTime) keeps
the comparison timezone-independent. Tiers: under 60s no finding; 60s-5m
info; 5m-24h warning; 24h-or-more, or a time outside the year 2000..2100
plausibility window, error. Findings note a stale or missing NTP sync.
A set_clock quick-fix on the warning and error findings pushes the current
time to the speaker via POST /clockTime (client.SetClockTime). That call is
plain HTTP on :8090, so it works regardless of the speaker's wrong clock or
TLS state. It is a band-aid: if NTP is still failing the clock drifts again
and resets on reboot, so the confirm dialog and success message point at
restoring time sync as the durable fix. An SSH set-clock fallback is left
for later since the HTTP path is confirmed on firmware 27.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a speaker resolves the firmware-hardcoded content.api.bose.io through
the operator's own DNS instead of AfterTouch, TuneIn/BMX content requests
escape AfterTouch and fail (CURL 60, or a dead-cloud 404), so the speaker
reports INVALID_SOURCE. The existing dns_sanity check only probes AfterTouch's
own answering side over loopback, so it passes even when no speaker uses
AfterTouch as its resolver. This adds a speaker-side, on-demand check.
dns_speaker_usage:
- pkg/discovery/dns.go tracks distinct non-loopback clients that query an
intercepted Bose hostname (interceptClients set, populated in recordQuery,
exposed via InterceptClientIPs()). Loopback is excluded so dns_sanity's own
probes don't register.
- The check lists each unconfirmed speaker as an info finding with a "Test DNS
path" quick-fix. It never emits a standing warning, so it does not
false-positive after a restart (the querier set is in-memory and starts empty).
Active probe (the "Test DNS path" quick-fix; also POST /setup/health/dns-path-probe):
- Sends a /speaker notification carrying a per-probe nonce as the app_key. To
accept it the speaker must resolve audionotification.api.bosecm.com
(intercepted) and call back GET /v1/auth with that nonce; the callback
arriving is direct proof the speaker resolves Bose hosts through AfterTouch.
- HandleSpeakerAuth returns 403 for a matching nonce so the speaker refuses the
notification (silent, no audio, confirmed on hardware); any other key still
gets 200 so real TTS is untouched. Reuses resolveTTSHost for SSRF-safe
targeting; the nonce is never logged. Registered without refresh so the probe
result stays visible in the Health tab.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An <updates> frame whose only child is an element the WebSocketEvent
struct doesn't model (e.g. nowSelectionUpdated, sent by SoundTouch 10
firmware around a play action) produced no known event types, so
handleEvent logged "Received unknown event types: []" repeatedly. The
empty list carried no information and flooded soundtouch-web's logs and
the CLI events subscribe output we point people at for debugging.
Capture unmodeled <updates> children by name via an xml:",any" catch-all
on WebSocketEvent and log the actual element names ("[nowSelectionUpdated]"),
skipping frames that carry no child events entirely. A regression test
confirms a modeled event is not also captured as unknown.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
soundtouch-web had no logging on its play/select paths, which made
issues like #345 (a source rejected by the speaker) hard to diagnose:
a SoundTouch /select returns HTTP 200 even when the source is then
rejected, so the failure only surfaces asynchronously as a now_playing
transition to an error source, and nothing recorded it.
Add two log points:
- logPlaybackRequest: one line per play/select with the resolved
source, sourceAccount, location and itemName, from all five handlers
(source-select, device-play, play-url, radiobrowser, tunein). This is
often the only record of what was actually requested. sourceAccount
here is an account identifier, not a bearer credential.
- logNowPlayingError: logs when a device's now_playing enters an error
source (INVALID_SOURCE or any *_ERROR), deduped per transition, which
is the real signal that a selection failed on the speaker.
The two TuneIn/RadioBrowser handlers now resolve the ContentItem via
stations.ResolveContentItem and select it directly so the log shows the
authoritative outgoing source; the now-unused stations.Play wrapper is
removed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The /api/control/{host}/source handler hardcoded an empty sourceAccount,
so devices that share source="AUX" across multiple jacks (e.g. the ST-5
CD/Aux inputs, disambiguated by AUX/AUX1/AUX2) always received
sourceAccount="AUX" and rejected the wrong jack with internal error 1005.
Read the account query parameter and forward it to SelectSource, matching
what the frontend already sends and what the CLI already does.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Draft a "News & Updates" blog post for AfterTouch covering recent git activity, then open a draft PR for review.
Draft a "News & Updates" blog post for AfterTouch covering recent git activity, commit it to a branch, and hand the maintainer the commands to push and open a draft PR (the maintainer pushes, not you).
## Step 1 — Determine lookback window
Run:
If the invocation arguments name an explicit starting point (a tag like `v0.93.1` or a
date), use that as SINCE. For a tag, resolve its date:
`git log -1 --format=%ad --date=short <tag>`. An explicit argument always overrides the
auto-detection below.
Otherwise, auto-detect from the last published post:
1. Opening paragraph (3–5 sentences) explaining what happened and why it matters to someone running AfterTouch.
2.One `##` section per non-empty category. Use bullet points written for an operator audience — no raw git subjects, no internal Go package paths.
3.End with: `**Current release:** vX.Y.Z`
2.The body. Prefer a narrative that ties the changes into a story (what shifted, why it matters), not a bare aggregation of the release notes. Group related work under `##` sections (the commit categories are raw material, not the final headings). Write for an operator audience: no raw git subjects, no internal Go package paths. A short bullet list inside a section is fine, but the post should read like prose, not a changelog dump.
3.Close with the standard footer convention used by the existing posts, so every post ends the same way:
Target length: 300–600 words. Never include real IPs, MAC addresses, account IDs, or device names.
```markdown
## Current release
## Step 6 — Create a branch and open a draft PR
**vX.Y.Z**, released MONTH D, YYYY
This blog will be updated monthly, or whenever something significant ships.
Subscribe to the [GitHub releases](https://github.com/gesellix/Bose-SoundTouch/releases)
for individual version notes.
```
Get the release date with `git log -1 --format=%ad --date=format:'%B %-d, %Y' vX.Y.Z`.
When in doubt about any recurring element (footer, release line, tags), match the most
recent existing post under `docs/content/blog/` rather than inventing a new convention.
Never retrofit or restyle already-published posts to fit a new convention — they are
dated records; a new convention applies going forward only.
Target length: 300–600 words (longer is fine when the story warrants it). Never include
real IPs, MAC addresses, account IDs, or device names.
**No em dashes.** Do not use the em dash character (`—`) anywhere in the post; use commas,
parentheses, colons, or separate sentences. (En dashes in a period label like
`April – May 2026` are fine.) Verify with `grep -c '—' <file>` before committing.
## Step 6 — Create a branch and commit (do NOT push)
```bash
git checkout -b blog/YYYY-MM-update
git add docs/content/blog/YYYY-MM-slug.md
git commit -m "docs(blog): add PERIOD update post"
```
**Do not push and do not open the PR yourself.** The maintainer always pushes over SSH
(see the global and project instructions). Pushing on their behalf, including over HTTPS
with a token or by switching the remote, is not allowed.
## Step 7 — Done
Hand the maintainer the ready-to-run commands to push and open the draft PR, then stop:
```bash
git push -u origin blog/YYYY-MM-update
gh pr create --draft \
--title "Blog: PERIOD update post" \
--body "Automated draft from /blog-update skill. Review content before merging — deployment is automatic on merge to main."
--body "Update post covering recent changes. Review content before merging — deployment is automatic on merge to main."
```
If the `documentation` label exists on the repo, add `--label documentation`.
## Step 7 — Done
Report the PR URL. Do not merge, approve, or request review.
- **Excellent Documentation**: 4000+ lines including Getting Started, Cookbook, Troubleshooting, and Deployment guides
- **CLI Tool**: Full-featured command-line interface with all endpoints
## What's included
## 🚀 Quick Start
Pre-built binaries for Linux (amd64, arm64, armv7), macOS (Intel & Apple Silicon), Windows (amd64), and FreeBSD (amd64):
- **soundtouch-service** (see above)
- **soundtouch-cli** (see above)
- **soundtouch-player**: standalone LAN web UI for device control: play/pause, volume, presets, live status. (Formerly \`soundtouch-web\`.)
- **soundtouch-backup**: back up your Bose cloud account and each speaker's local state. \`soundtouch-backup all\` captures everything in one step.
Not sure which file to grab? The [Downloads page](https://gesellix.github.io/Bose-SoundTouch/docs/downloads/) explains which tool you need and which \`<os>-<arch>\` build matches your computer.
## Documentation
Full guides, setup walkthroughs, and troubleshooting: https://gesellix.github.io/Bose-SoundTouch/
## Use as a Go library
The core client is also importable:
\`\`\`bash
go get github.com/gesellix/bose-soundtouch@$TAG_NAME
\`\`\`
\`\`\`go
package main
## Verifying downloads
import (
"fmt"
"log"
"github.com/gesellix/bose-soundtouch/pkg/client"
)
func main() {
// Create client
c := client.New("192.0.2.100", 8090)
// Get device info
info, err := c.GetInfo()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Device: %s\\n", info.Name)
}
\`\`\`
## 📚 Documentation
- [Getting Started Guide](docs/GETTING-STARTED.md) - 10-minute tutorial from discovery to WebSocket monitoring
- [API Cookbook](docs/API-COOKBOOK.md) - 1000+ lines of real-world patterns and examples
Thank you for your interest in contributing to the Bose SoundTouch API Client! This project aims to provide a comprehensive, reliable, and well-tested Go library for controlling Bose SoundTouch devices.
Thank you for your interest in contributing to **AfterTouch**!
## Ways to Contribute
AfterTouch is a community-built toolkit that keeps Bose SoundTouch speakers
usable after Bose shut down the SoundTouch cloud. It is a Go codebase that ships
several tools plus a reusable library:
All contributions are welcome — large or small:
- **soundtouch-service** the local cloud replacement (emulates `streaming.bose.com` and the `bmx` services)
- **soundtouch-cli** command-line control of one or more speakers
- **soundtouch-player** the web UI for radio browsing and device control
- **soundtouch-backup** on-device backup and restore helper
- **pkg/** the underlying Go library (HTTP + WebSocket client, models, discovery, ...)
- **Bug fixes** — even just a clear reproducer in an issue is a real contribution.
- **Donations** — if the project kept a speaker (or several) of yours alive past the Bose cloud shutdown and you want to give back, [GitHub Sponsors](https://github.com/sponsors/gesellix) is open. No expectation; everything in this repo stays MIT regardless.
We are an open community: we both provide and ask for support. Contributions of
every size are welcome, and you do not need to be a Go developer to help.
By submitting a code or documentation contribution you agree to license it under MIT. The detailed guides below cover the mechanics.
## Ways to contribute
## Table of Contents
- **Bug reports** even a clear reproducer is a real contribution. An attached
diagnostic report (see [Reporting issues](#reporting-issues)) helps enormously.
- **Device compatibility reports** tell us how AfterTouch behaves with your speaker model.
By submitting a code or documentation contribution you agree to license it under
the project's [MIT License](LICENSE).
## Code of Conduct
This project adheres to our [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to the project maintainers.
This project follows a [Code of Conduct](CODE_OF_CONDUCT.md). By participating,
you agree to uphold it. Please report unacceptable behavior to the maintainer.
## Getting Started
## Getting started
### Prerequisites
- **Go 1.25.6 or later**: [Download Go](https://golang.org/dl/)
- **Git**: For version control
- **Make**: For build automation (optional but recommended)
- **SoundTouch Device**: For testing (optional but valuable)
- **Go** (version per [`go.mod`](go.mod), currently the 1.26.x series)
- **Git**
- **Make** (recommended; drives builds and the quality gate)
- **Docker** (only needed for the HTTP-client integration tests)
- A **SoundTouch device** is optional but valuable for testing
Bose shut down SoundTouch cloud services on **May 6, 2026**. Presets, music service browsing, and stereo pairing no longer work through Bose's infrastructure. AfterTouch restores all of these — no Bose infrastructure required.
See the [Survival Guide](https://gesellix.github.io/Bose-SoundTouch/docs/guides/SURVIVAL-GUIDE/) for the full picture.
See the [Survival Guide](https://gesellix.github.io/Bose-SoundTouch/docs/guides/SURVIVAL-GUIDE/) for the full picture, or jump straight to [Downloads](https://gesellix.github.io/Bose-SoundTouch/docs/downloads/) to get the tools.
@@ -66,15 +65,17 @@ See the [soundtouch-backup README](cmd/soundtouch-backup/README.md) for usage.
Command-line control of any SoundTouch device: play/pause/volume, presets, source selection, multiroom zones, device discovery, and more. Works entirely over the local network — no cloud dependency. Well-suited for scripting and home automation.
See the [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/docs/guides/CLI-REFERENCE/) for full usage.
See the [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/docs/guides/CLI-REFERENCE/) for full usage, and the [Downloads page](https://gesellix.github.io/Bose-SoundTouch/docs/downloads/) to get the `soundtouch-cli` build for your OS.
---
### soundtouch-web
### soundtouch-player
A standalone web UI for device control — play, pause, volume, preset selection, real-time status — served from a local Go binary. Complements `soundtouch-service` when you want a dedicated device-control interface separate from the setup/admin UI.
> Formerly `soundtouch-web`. The `soundtouch-web` binary, Docker image, and install script are no longer published; please use `soundtouch-player`. (If you still run the binary under its old name, it prints a rename notice and works as before.)
See the [soundtouch-web README](cmd/soundtouch-web/README.md) for usage.
A standalone, LAN-resident web UI for device control — play, pause, volume, preset selection, real-time status — served from a local Go binary. Because it reaches speakers directly on your network and can delegate cloud-only features (e.g. TTS) to a remote AfterTouch service via `--service-url`, it stays useful when `soundtouch-service` runs off-LAN (for example in the cloud), where the embedded `/app` player cannot reach your speakers.
See the [soundtouch-player README](cmd/soundtouch-player/README.md) for usage.
---
@@ -111,6 +112,7 @@ See the [API Reference](https://gesellix.github.io/Bose-SoundTouch/docs/referenc
- **[SoundTouch Plus](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus)** (Todd Lucas) — Home Assistant integration; extensive undocumented API documentation
- **[ÜberBöse API](https://github.com/julius-d/ueberboese-api)** (Julius) — API research and advanced endpoint discovery
- **[STR, SoundTouch Reborn](https://github.com/JRpersonal/streborn)** ([st-reborn.de](https://st-reborn.de)) — on-device agent plus desktop app; its published `iptables` REDIRECT technique is what makes AfterTouch's on-device install reachable over the LAN on co-processor chassis (see [Model Support Matrix](https://gesellix.github.io/Bose-SoundTouch/docs/reference/MODEL-SUPPORT-MATRIX/))
fmt.Printf("Device was unpaired — paired it with generated account %s so margeServerUrl gets polled (#515).\n",accountID)
}
}
funcsetupEnableSSHCmd()*cli.Command{
return&cli.Command{
Name:"enable-ssh",
Usage:"Bootstrap SSH on a speaker with no prior access via the port-17000 envswitch trick (#471), "+
"then restore clean URLs and persist it",
Before:RequireHost,
Flags:[]cli.Flag{
&cli.StringFlag{
Name:"service-url",
Usage:"AfterTouch service base URL to point the speaker at (e.g. https://192.0.2.10:8443). "+
"Optional: enabling SSH does not need a live server (the injection fires when the speaker "+
"parses its boseurls), so you can omit this now and set the real URLs later via migration",
},
&cli.DurationFlag{
Name:"wait",
Value:90*time.Second,
Usage:"How long to wait for sshd (:22) after the envswitch injection (it runs on the speaker's next boseurls check, ~60s)",
},
&cli.BoolFlag{
Name:"full-config",
Usage:"For stubborn devices (ST Portable, CineMate 520) where the default single-envswitch injection is accepted but sshd never starts: "+
"replicate the #515 manual sequence — write all four sys configuration URL keys with the SSH-enable injection on margeServerUrl (not just envswitch), then reboot",
},
&cli.DurationFlag{
Name:"command-delay",
Value:setup.DefaultTelnetCommandDelay,
Usage:"Only affects --full-config: pause between each of its 6 steps (5 commands + reboot). "+
"Raise this if the default doesn't work on your device; 0 sends everything back-to-back",
},
&cli.BoolFlag{
Name:"no-auto-pair",
Usage:"Skip the automatic pairing check: by default, enable-ssh reads /info first and pairs an unpaired "+
"(factory-reset) device with an account ID, since an unpaired device reportedly never "+
"polls margeServerUrl at all (#515) — the injection would have nothing to fire on otherwise",
},
&cli.StringFlag{
Name:"account",
Usage:"Only used when the device is unpaired and --no-auto-pair is not set: account ID to pair with "+
"(empty = generate a fresh 7-digit one). Use this if you already know which account this device "+
"should end up on (e.g. to match one already in the datastore) rather than getting a random one now",
},
&cli.BoolFlag{
Name:"no-reset-urls",
Usage:"Skip restoring clean boseurls after SSH is up (leaves the injected marge URL in place)",
},
&cli.BoolFlag{
Name:"no-persist",
Usage:"Skip persisting the remote_services marker (SSH would not survive a reboot)",
},
&cli.StringFlag{
Name:"authorized-key",
Usage:"Opt-in hardening: install this SSH public key for root (key auth instead of the empty-password login). Pass the key text, e.g. --authorized-key \"$(cat id_ed25519.pub)\"",
},
&cli.BoolFlag{
Name:"close-17000",
Usage:"Opt-in hardening: block port 17000 from the LAN (firewall rule applied now + persisted); loopback access is kept",
},
},
Action:func(c*cli.Context)error{
cfg:=GetClientConfig(c)
m:=setup.NewManager("",nil,nil)
// The URL is only the vehicle for the command injection; the
// SSH-enable fires when the speaker parses its boseurls, whether
// or not anything answers there. When the user has no service URL
// yet, use a clearly-placeholder value and tell them to set the
&cli.BoolFlag{Name:"skip-preflight",Usage:"Skip the AfterTouch settings preflight (use when AfterTouch's settings endpoint is unreachable)"},
&cli.StringFlag{Name:"marge-url",Usage:"Override margeServerUrl instead of deriving it from --service-url (e.g. to restore the original Bose cloud URL). Applies to --method=telnet and --method=xml"},
Usage:"Pair the speaker with an account via WebSocket SETUP state machine",
Before:RequireHost,
Flags:[]cli.Flag{
&cli.StringFlag{Name:"account",Usage:"7-digit account ID (empty = generate)"},
&cli.StringFlag{Name:"account",Usage:"Account ID to pair with (empty = generate a fresh 7-digit one)"},
&cli.StringFlag{Name:"mode",Value:"full",Usage:"full (state machine) or bare (setMargeAccount only — experimental)"},
&cli.StringFlag{Name:"service-url",Value:"http://aftertouch.local:8000",Usage:"AfterTouch base URL (also populates <boseServer>/<updateServer> in setMargeAccount)"},
&cli.StringFlag{Name:"name",Usage:"Speaker name to set during pairing (empty = keep current)"},
The `soundtouch-web` tool provides a modern single-page application (SPA) for controlling Bose SoundTouch devices. Built with a JSON API backend and client-side JavaScript rendering, it offers superior performance and eliminates template rendering issues.
The `soundtouch-player` tool provides a modern single-page application (SPA) for controlling Bose SoundTouch devices. Built with a JSON API backend and client-side JavaScript rendering, it offers superior performance and eliminates template rendering issues.
## Architecture
@@ -141,10 +141,10 @@ GET /api/control/{id}/source?name=X # Select source
### Build Commands
```bash
# Build the web application
cd cmd/soundtouch-web
go build -o soundtouch-web
cd cmd/soundtouch-player
go build -o soundtouch-player
# Build all project components (includes soundtouch-web)
# Build all project components (includes soundtouch-player)
title: "AfterTouch: From Rescue to Something Better, and the Road to 1.0"
date: 2026-06-28
description: "Since v0.93.1, AfterTouch grew from a cloud-shutdown rescue into a platform of its own: local music, voice prompts, sturdier internals, a growing community, and a 1.0 on the horizon."
tags:
- discovery
- health
- migration
- fixes
sidebar:
exclude: true
---
The launch post went out under the wire. Bose pulled the plug on the SoundTouch cloud on
May 6, and **v0.93.1** was very much a rescue: get accounts migrated, keep radio and
presets alive, stop perfectly good speakers from turning into bricks. The weeks since,
up through **v0.117.0**, have been about a quieter shift: turning that rescue into
something that stands on its own, and in a few places, something better than what Bose
offered. And almost none of that direction came from me. I use my own speakers with a
pretty narrow set of features; nearly everything below exists because someone in the
community described a use case I'd never have thought to build.
## Local music, back under your control, and a speaker that talks
The clearest sign of that shift is local music. Your speakers always had a native
local-music source for playing your own library off the network, but browsing it used to
run through the Bose app. AfterTouch brings that back on its own terms: it discovers
DLNA / UPnP media servers on your network and drives the speaker's native source
directly. Browse folders in the **Library** tab or from the command line, queue a whole
folder, and next/previous and auto-advance behave like a real playlist.
Then there's something genuinely new: speakers can now *talk*. A text-to-speech feature
announces arbitrary text out loud, with Google Cloud TTS as a pluggable provider you
configure from the settings UI. It's built on the speaker's notification capability, but
turning that into spoken prompts is the kind of thing that happens when the platform is
open and nobody has to wait for a vendor to approve it.
There is more in the same spirit, smaller but useful: service-side search across TuneIn
and Radio Browser, a "Play URL" view for arbitrary streams, save-as-preset straight from
Now Playing, and a step toward needing no extra hardware at all, an on-device SSH unlock
flow that opens the door to running AfterTouch directly on the speaker.
## The unglamorous half: earning trust
Features are the easy part to write about. The work that actually mattered most was
making AfterTouch dependable enough that you stop thinking about it. Speaker data is now
written to disk durably, so a power cut mid-write no longer wipes your presets and
accounts, and corrupt or empty files fall back to sane defaults instead of failing.
Recent tracks stopped vanishing and duplicating. Internet radio got steadier: Radio
Browser plays through its proper native source, TuneIn fails over across stream
candidates, and a stray trailing slash in a server URL no longer breaks playback.
Multi-room grouping handles member removal correctly.
Under the surface, a sustained pass closed several request-forgery paths, swept the code
for log-injection, validated identifiers on management endpoints, and removed a
credential-logging shortcut. And the health checks grew teeth: server-URL reachability,
CA-bundle integrity, a speaker-clock check with a one-click fix, and a DNS-path probe for
the internet-radio escape problem, all now labelled with the device name and IP so you
know exactly which speaker a warning is about.
## A community, not a product
The best thing to happen since launch isn't in the changelog. It's the people.
It's worth saying plainly: this project is driven by its users. I personally use
SoundTouch in a fairly simple way, and most of what shipped over these weeks (features
and bug fixes alike) is the result of friendly, constructive feedback from people who use
their speakers very differently than I do. The DLNA library, the voice prompts, the radio
and grouping fixes, the migration edge cases: each one started as someone taking the time
to explain a real-world setup and point at what was missing. That feedback is the
roadmap. Keep it coming.
A standout is **[Sander ten Brinke](https://x.com/sandertenbrinke)**, who is building
**[soundtouch-maui](https://github.com/sander1095/soundtouch-maui)**, a cross-platform
SoundTouch app designed to work hand in hand with AfterTouch. That's exactly the shape
this project should take: not one tool trying to do everything, but independent pieces
that fit together because they share an open, community-owned foundation. Go build a
player, a remote, a home-automation bridge, whatever you need, and have it talk to a
service you control.
An honest admission: there has been more activity in issues and discussions than one
maintainer can keep up with, and not every thread got the reply it deserved. But the
encouraging part is that it increasingly doesn't have to. People are answering each
other, sharing setups (the FRITZ!Box and AdGuard DNS notes came straight from a user's
own working configuration), and debugging together. That's the project moving in the
right direction. AfterTouch works best as a community, not a support desk.
And a heartfelt thank you to everyone who sponsors AfterTouch. The project is free and
maintained in spare time, so every contribution, recurring or one-off, directly funds the
hosting, the test hardware, and the hours that keep these speakers alive. It genuinely
makes a difference, and it's deeply appreciated. If you'd like to chip in, the
[sponsor page](../sponsor.md) has the details.
## The road to 1.0
So what does **v1.0.0** mean? Mostly: stability. A version number that signals a proper,
dependable base you can build on, with a management API that won't shift under you and a
service that runs unprivileged and installs cleanly by default.
A few things are on the list to get there. The admin and account-management UI works,
but it feels rough at the edges, and that's the part you actually touch, so it deserves
some polish. I also want to keep a publicly deployed, cloud-hosted service in mind:
the moment AfterTouch is reachable from the open internet, it needs proper authentication
and authorization, so a passing script kiddie can't read your recently played songs (or
worse). And the docs need some love and a clearer structure. One feature is likely to land
in this stretch too: making
[presets propagate cleanly across the speakers in one account](https://github.com/gesellix/Bose-SoundTouch/issues/495),
without the manual "refresh sources" dance. There's probably more before it's truly
"1.0", but none of it is blocking: there's nothing preventing us from getting there *now*.
It's also a natural moment for a clean slate. If your migration has accumulated quirks,
1.0 is a good excuse to reset and re-migrate your speakers onto a known-good footing.
And then the interesting part begins. With the rescue done and a stable base in place, the
focus shifts to delivering value the old Bose cloud never could. Some of that is already
taking shape in the issue tracker: an
[audiobook mode](https://github.com/gesellix/Bose-SoundTouch/issues/508), and deeper
integration with external music providers such as
[Amazon Music](https://github.com/gesellix/Bose-SoundTouch/issues/188). A service under
community control is a rare chance to actually solve the things people ask for, instead of
waiting on a roadmap that was discontinued. If there's something you wish your speakers
did, the [issue tracker](https://github.com/gesellix/Bose-SoundTouch/issues) and
[Discussions](https://github.com/gesellix/Bose-SoundTouch/discussions) are where it starts.
## Current release
**v0.117.0**, released June 28, 2026
This blog will be updated monthly, or whenever something significant ships.
Subscribe to the [GitHub releases](https://github.com/gesellix/Bose-SoundTouch/releases)
- **Documentation**: Check troubleshooting guides first
- **Community**: Share experiences and help others
- **Direct chat (last resort)**: There's a small Discord for the rare case where an email exchange or an issue/discussion thread needs real-time back-and-forth. It's not a primary support channel: please start with Issues or Discussions. If a conversation genuinely needs it, ask in your thread and I'll share an invite.
For a complete list of all documents, browse the sections in the sidebar.
**Last Updated:** June 2026 (reconciled against `pkg/client`)
**API Version:** Official Bose SoundTouch Web API v1.0
**Implementation Status:** 100% Official Coverage + Extended Features
**Implementation Status:** Official coverage 20/21 + extended features
## Executive Summary
This Go implementation provides **complete coverage** of the Bose SoundTouch Web API with **100% of official endpoints implemented** (18/19) plus **5 additional extended features** not documented in the official API v1.0 but working with real hardware.
This Go implementation provides near-complete coverage of the Bose SoundTouch Web API with **20 of 21 official endpoints implemented** (the one exception, `/trackInfo`, is documented but non-functional on real hardware) plus **5 additional extended features** not documented in the official API v1.0 but working with real hardware.
### Key Findings
- ✅ **All essential user functionality implemented**
# DNS Upstream: custom server on localhost (adjust port if necessary)
server=127.0.0.1#5353 # Example: custom server on port 5353
@@ -237,7 +237,7 @@ If you cannot see the `Bose-Lab` SSID on your phone:
```bash
sudo nmcli device set wlan0 managed no
```
7. **Ghost IP Conflict:** If `ip addr show wlan0` shows both `192.168.10.1` and another IP (like `192.0.2.x`), `hostapd` will fail. This is usually caused by NetworkManager managing the interface. Ensure you've run:
7. **Ghost IP Conflict:** If `ip addr show wlan0` shows both `198.51.100.1` and another IP (like `192.0.2.x`), `hostapd` will fail. This is usually caused by NetworkManager managing the interface. Ensure you've run:
```bash
sudo nmcli device set wlan0 managed no
# If the ghost IP is still there, remove it manually:
@@ -259,13 +259,13 @@ If you haven't created a CA yet, follow **Appendix A** first.
# Temporarily make reachable via HTTP for easy download:
cd /etc/my-dns-ca/
python3 -m http.server 8080
# → Reachable at http://192.168.10.1:8080/ca.crt
# → Reachable at http://198.51.100.1:8080/ca.crt
```
### Install on Android
1. Connect phone to `Bose-Lab`
2. Open browser → `http://192.168.10.1:8080/ca.crt`
2. Open browser → `http://198.51.100.1:8080/ca.crt`
3. Download certificate
4. **Settings → Security → Credentials → Install CA Certificate**
# Traffic of a specific host only (filter by phone IP)
# Read phone IP from dnsmasq.leases beforehand (see below)
sudo tcpdump -i wlan0 -n host 192.168.10.101
sudo tcpdump -i wlan0 -n host 198.51.100.101
```
### Read SNI from TLS Traffic (without decryption)
@@ -351,7 +351,7 @@ Transfer `.pcap` files from the Pi to the PC:
```bash
# From the PC (scp)
scp pi@192.168.10.1:/tmp/bose-*.pcap ~/Desktop/
scp pi@198.51.100.1:/tmp/bose-*.pcap ~/Desktop/
```
**Important Wireshark Filters:**
@@ -607,7 +607,7 @@ You can either configure the macOS system proxy manually or use `mitmproxy`'s au
**Method 1: System Proxy (Manual)**
1. Go to **System Settings → Network → Wi-Fi → Details... → Proxies**.
2. Enable **HTTP Proxy** and **HTTPS Proxy**.
3. Set Server to your Pi's IP (`192.168.10.1`) and Port to `8080`.
3. Set Server to your Pi's IP (`198.51.100.1`) and Port to `8080`.
4. Click **OK** and **Apply**.
**Method 2: mitmproxy Local Redirect (Automatic)**
@@ -667,7 +667,7 @@ If the app uses **Certificate Pinning** (hardcoded hashes), even moving the CA t
If the **Transparent AP** setup (Steps 1–6) is too complex or you are experiencing routing issues, you can use `mitmproxy` as a **Regular HTTP Proxy**.
### 1. How it works
In this mode, the Pi acts as a simple server on port 8080. You tell your phone's Wi-Fi settings to send all traffic to `192.168.10.1:8080`.
In this mode, the Pi acts as a simple server on port 8080. You tell your phone's Wi-Fi settings to send all traffic to `198.51.100.1:8080`.
* **Pros:** No complex `nftables` or NAT rules required.
* **Cons:** Many Android apps (and background processes) ignore system-wide proxy settings. **HTTPS still requires a trusted CA for decryption.**
@@ -683,7 +683,7 @@ mitmproxy --listen-port 8080
1. Go to **Settings → Wi-Fi → Bose-Lab**.
2. Select **Modify Network** (or the "i" icon).
3. Set **Proxy** to **Manual**.
4. **Proxy hostname:**`192.168.10.1`
4. **Proxy hostname:**`198.51.100.1`
5. **Proxy port:**`8080`
6. Save and try to browse a site.
@@ -706,7 +706,7 @@ go get github.com/google/gopacket
go run scripts/extract-ws.go your_capture.pcap [filter_ip]
# Example: Filter for a specific speaker's IP in WebSocket messages
go run scripts/extract-ws.go capture.pcap 192.168.100.1
go run scripts/extract-ws.go capture.pcap 203.0.113.1
```
### 2. Manual Extraction with tshark
@@ -889,5 +889,5 @@ pgrep -a tcpdump
dig @127.0.0.1 -p 5353 global.api.bose.io
# Check network connectivity from the phone (from the Pi)
ping 192.168.10.101 # Phone IP from dnsmasq.leases
ping 198.51.100.101 # Phone IP from dnsmasq.leases
This document provides a comprehensive analysis of the `/supportedURLs` endpoint response from real Bose SoundTouch devices and compares it with our current implementation.
> **Reconciliation note (June 2026).** The categorised lists below had drifted
> from `pkg/client`. Verified against the code, these are **implemented** and have
> been re-marked (some were wrongly under "Not Yet Implemented", and a few were
> listed twice): the music-service set (`setMusicServiceAccount`,
| `key prev` | Restart current song / previous track. | S5 |
| `key next` | Next track. | S5 |
| `key aux` | Toggle Bluetooth / AUX input. | S5 |
| `key power` | Echoes "OK" but no observable effect on FW 27.x — possibly handled at a higher layer. On Lifestyle/CineMate console devices this is **not** a no-op: it puts the console into standby and, on waking, returns it to the console's own input rather than SoundTouch — see [Lifestyle / Console Device Behavior](../guides/TROUBLESHOOTING.md#lifestyle-console-devices) and #597. | S5 |
The S4 `bose` script's `sys presetkey N p` form still works, but `key prefix_N` is shorter and matches what the remote already does on FW 27.x.
@@ -150,11 +150,15 @@ Each `sys configuration` setter is reported by users to return `OK` on success.
`envswitch` writes to a separate, lower-level persistence store that **wins on next reboot** if the corresponding `sys configuration` value differs. So our migration writes both — see TELNET-MIGRATION-METHOD.md §2.1.
| `envswitch boseurls set <margeUrl> <swUpdateUrl>` | Persist the marge and update URLs. **Two arguments**, in that order. | S6 |
| `envswitch accountid set <numeric-id>` | Equivalent to the HTTP `/setMargeAccount` POST. Used as fallback in our `PairAccount` helper. | S6 |
| `envswitch accountid get` | Plausible by symmetry but **not yet confirmed** across firmwares; we probe it best-effort. | (probe) |
**It's a commit point, not just a two-field setter.** `envswitch boseurls set` persists whatever is currently in the runtime layer at the moment it runs — not only its own two arguments. Confirmed on five variants (`lisa`, `mojo`, `spotty`, `ginger`, `taigan`; [#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)): a `sys configuration` write survives a reboot **if and only if** an `envswitch boseurls set` runs after it. The same command sequence in reverse order silently loses the later `sys configuration` values on reboot — every command still answers, nothing looks wrong until the reboot. This is why our migration and SSH-enable sequences always issue all four `sys configuration` writes first and `envswitch boseurls set` last (see `telnetURLs.Commands()` / `EnableSSHViaTelnetFullConfig`).
**It does not acknowledge with `OK`.** Unlike `sys configuration` (which does), `envswitch boseurls set` responds with a different string (observed: `Setting Bose Server URLs to <a> and <b> ->`, no `OK` substring). An implementation that waits for the literal token `OK` will hit its own timeout on this exact command. Our `pkg/telnet.Client.SendCommand` doesn't string-match at all — it reads until the connection goes idle — so this only matters if you're hand-typing the sequence or reimplementing the client elsewhere.
| `envswitch boseurls set <margeUrl> <swUpdateUrl>` | Persist the marge and update URLs, committing the runtime layer as it stands (see above). **Two arguments**, in that order. | S6 |
| `envswitch accountid set <numeric-id>` | Equivalent to the HTTP `/setMargeAccount` POST. Used as fallback in our `PairAccount` helper. | S6 |
| `envswitch accountid get`, bare `envswitch`, `envswitch boseurls` | **Confirmed unsupported** — all answer `Invalid Command Option` on `lisa`/`mojo`/`spotty` ([#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)). `envswitch` has no read form on any variant tested; the persisted layer can only be written, then observed indirectly after a reboot (e.g. via `getpdo`, which then reflects the *new* value). | (probe) |
---
@@ -166,6 +170,8 @@ Each `sys configuration` setter is reported by users to return `OK` on success.
| `getpdo CurrentSystemConfiguration` | Echoes the resolved URL set, including margeServerUrl/bmxRegistryUrl/statsServerUrl/swUpdateUrl. We grep our targetURL out of this to confirm a successful migration. | S6 |
**The two layers are inverted in `getpdo` visibility around a reboot** ([#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)): *before* a reboot, `getpdo` shows the runtime (`sys configuration`) values immediately, while an `envswitch`-written value isn't visible yet; *after* a reboot, the `sys configuration` values are gone and the `envswitch`-persisted values are what's now applied. So a `getpdo` check run before rebooting confirms the writes were accepted, but it is **not** evidence the configuration will survive the reboot — only the `envswitch` write (in the right order, see above) determines that. This is why our own migration verification (`migrateViaTelnet`) checks `getpdo` before reboot only to confirm the runtime layer accepted the values, and never claims persistence from it.
---
## The `scm` family — service control
@@ -225,7 +231,7 @@ These show up in `getpdo`, `network status`, and SSH-side hostnames. Useful for
- **Firmware 1.x–7.x** (S1 era): everything — `help`, `remote_services on`, full `scm`, and an in-shell login prompt. `flarn2006` documents the original Linux insides.
- **Firmware 8.x–14.x** (S2 era): `remote_services on` removed; `network`, `sys`, `envswitch`, `getpdo` still present. `local_services on` works on some Wave/SA-5 models.
- **Firmware 27.x** (S5/S6 era — the long-lived "frozen" build that survived through EOS): `help`, `remote_services on`, and `sys ver` removed in some builds; `sys configuration …` and `envswitch …` confirmed working on ST 10, ST 20, ST 300, Wave III, Wave IV. **This is the firmware our migration targets**. The Portable on more recent firmware drops further commands and is the hardest target.
- **Firmware 27.x** (S5/S6 era — the long-lived "frozen" build that survived through EOS): `help`, `remote_services on`, and `sys ver` removed in some builds; `sys configuration …` and `envswitch …` confirmed working on ST 10, ST 20, ST 300, Wave III, Wave IV. **This is the firmware our migration targets**. The Portable on more recent firmware drops further commands and is the hardest target; on the ST Portable (Series I, FW `27.0.6.46330.5043500`) and some CineMate 520 units the SSH-enable injection persists but `sshd` does not start via the default path, which is what `setup enable-ssh --full-config` addresses (see "What we use to enable SSH" above).
S5 enumerated the **top-level command roots** that don't return "Command not found" on a vanilla ST 10 (`rhino`) running `27.0.6.46330.5043500`:
@@ -265,6 +271,46 @@ Reboot is **not** part of these sequences — it stays a user-initiated action v
---
## What we use to enable SSH (`setup enable-ssh`, #471)
To open SSH on a speaker that has never had it (no USB recovery), the CLI abuses the boseurls value as a command-injection vehicle: when the device next parses it, the appended shell snippet touches the `remote_services` marker and starts `sshd`. The injected suffix is:
**Default path** (`soundtouch-cli setup enable-ssh`) writes that injection only via the persistence layer, then waits for `:22`:
```
envswitch boseurls set "<serverURL>;touch /tmp/remote_services;/etc/init.d/sshd start" "<serverURL>/update"
```
This is field-confirmed on the Wireless Link Adapter and on the CineMate 520 `lisa` variant (FW 27.0.6).
**`--full-config` path** (`soundtouch-cli setup enable-ssh --full-config`) is for devices where the default injection is *accepted and persisted* (`getpdo` confirms the value) but `sshd` never comes up, so `:22` stays "Connection refused". It mirrors the manual telnet sequence @Henri-be confirmed by hand on issue #515: it puts the injection on the runtime `sys configuration margeServerUrl` key as well as `envswitch`, writes all four URL keys, then reboots so the device re-parses the config at boot:
envswitch boseurls set "<serverURL>;touch /tmp/remote_services;/etc/init.d/sshd start" "<serverURL>/updates/soundtouch"
getpdo CurrentSystemConfiguration
sys reboot
```
**Which devices need `--full-config`:** observed on the **SoundTouch Portable (Series I, model 412540, FW `27.0.6.46330.5043500`)** (#515) and on some **CineMate 520** units where the default path leaves `sshd` down. The structural differences from the default path that appear to matter are (1) the injection riding `sys configuration margeServerUrl`, not just `envswitch`, and (2) the explicit `sys reboot`. The `--full-config` automation is **candidate behaviour awaiting reporter confirmation** — the manual sequence is confirmed working on the ST Portable, but the flag that automates it has not yet been re-confirmed on hardware. Not every device responds even to the manual sequence (some ST10 and CineMate 520 units never start `sshd` over telnet at all and need the serial / U-Boot route).
**On the `--command-delay` between steps:** originally added because a reporter's back-to-back run left `sshd` down while a ~7s-gapped run succeeded ([#515 comment 5228449448](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5228449448)). That theory was **retracted** by the same reporter after a controlled A/B across three variants showed identical outcomes at 0s and 5s gaps ([comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)) — the delay itself doesn't appear to matter. The default is kept small and non-zero (`setup.DefaultTelnetCommandDelay`) as a low-cost hedge for untested variants, not because the delay is known to help.
**The account-pairing precondition** (raised by `Henri-be`, [#515 comment 5230785528](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5230785528), tracing back to [#471 comment 4903016740](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-4903016740); confirmed empirically by `bitranox`, [#515 comment 5232241580](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5232241580)): a genuinely unpaired (factory-reset, empty `margeAccountUUID`) device does not poll `margeServerUrl`**at all** — confirmed by pointing a reset device's marge URL at a listener and observing zero requests over 10+ minutes. The SSH-enable injection has no read cycle to fire on until the device is paired. `enable-ssh` handles this automatically by default (`EnsureMargeAccountPaired`, `--no-auto-pair` to skip).
**Factory reset does not remove root access, if it was ever persisted.** Confirmed on a genuinely factory-reset `spotty` ([#471 comment 5232232575](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-5232232575)): after the reset, `margeAccountUUID` was empty, all four service URLs were back to `streaming.bose.com`, and presets were gone — but `/etc/remote_services` and `/mnt/nv/remote_services`**survived**, and SSH (:22) and telnet (:17000) stayed open. So once a device has been through `setup enable-ssh` with persistence (`EnsureRemoteServices`, the default), a later factory reset only wipes configuration, not root access — recovery is re-migrate + re-pair + rename + restore presets, with **no USB stick and no re-running the injection**.
**Readiness after a reboot is per-port, not a single moment.** `JRpersonal` first measured that the firmware needs roughly 60s after a cold boot before `:8090`'s `/info` answers and marge state is ready — a booting device answers a bare `HTTP 400` with an empty body before its services are up, which is easy to misread as a rejection rather than "too early" ([#471 comment 5231997551](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-5231997551)). `bitranox` refined this across three variants: `:8090` and the diagnostic `:17000` shell (and the config subsystem behind it that `getpdo` reads) do **not** become ready at the same time — waiting for `:8090` and then immediately reading over `:17000` returned an empty response even though the box was otherwise up. Ten observed reboots: down in 2.3–5.3s, ready (able to answer `getpdo` correctly) in 55.1–91.8s, median ~69.8s ([#471 comment 5232046477](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-5232046477)). Anything automated should wait for the specific interface it's about to use, not for a different port to answer first — see the troubleshooting guide's [power-cycle retry note](../guides/TROUBLESHOOTING.md) for the user-facing version of this.
---
## Out of scope here, but worth recording
- **Setup-mode WiFi onboarding via 192.0.2.1.** The community uses this to add a fresh device to a network without the Bose app. Our `soundtouch-service` does not currently automate this, but `network wifi profiles add` is the entry point if we ever do.
@@ -82,6 +82,16 @@ Three important details from the discussion:
silently restored on reboot — i.e. there is a parallel "envswitch" persistence
layer that wins on next boot if you don't also write to it. **We must always
issue both.**
A later, more precise measurement ([#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569), confirmed on
five variants: `lisa`/`mojo`/`spotty`/`ginger`/`taigan`) explains *why*
order matters: `envswitch boseurls set` is not just a two-field setter, it
**commits whatever is currently in the runtime layer at the moment it
runs**. A `sys configuration` write only survives a reboot if `envswitch
boseurls set` runs **after** it; the same commands in reverse order lose
the `sys configuration` values silently on reboot, with every individual
command still answering normally. This is why the sequence above is
ordered all-four-`sys-configuration`-then-`envswitch`, never the reverse.
2. **margeServerUrl path is bare for `soundtouch-service`.** We mount the marge
endpoints at the **root** of port 8000, matching what the existing XML
migration writes (`Manager.migrateViaXML` in `pkg/service/setup/setup.go`
@@ -91,8 +101,15 @@ Three important details from the discussion:
routes marge under that sub-path. **For our service: bare URL. For users
redirecting to soundcork: append `/marge`** to both `margeServerUrl` and
the first argument of `envswitch boseurls set`.
3. **Each command must be sent one at a time, waiting for the device's`OK`
response** before sending the next one (`foob61451`'s explicit warning).
3. **Each command must be sent one at a time, waiting for the device's
response** before sending the next one (`foob61451`'s original warning).
Note the exception: `sys configuration` commands ack with `OK`, but
`envswitch boseurls set` does **not** — it acks with a different string
entirely (observed: `Setting Bose Server URLs to <a> and <b> ->`, no `OK`
substring; [#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)). An implementation that waits for the
literal token `OK` will time out on exactly that command. Wait for the
shell's prompt (or, as our own `pkg/telnet.Client` does, for the
connection to go idle) rather than string-matching `OK`.
### 2.2 Account pairing fallback
@@ -106,7 +123,11 @@ in-band equivalent to the HTTP `/setMargeAccount` call, useful when the
@@ -198,7 +198,7 @@ The web UI is already fully responsive — it has Bootstrap grid columns, `@medi
### Priority 2 — RadioBrowser as a first-class provider
AfterTouch can proxy and play any stream URL, but there is no built-in station search. OpenCloudTouch's RadioBrowser integration is the reference. Tasks:
- Wire the [RadioBrowser API](https://www.radio-browser.info/) into the `soundtouch-web` web UI as a browsable/searchable source.
- Wire the [RadioBrowser API](https://www.radio-browser.info/) into the `soundtouch-player` web UI as a browsable/searchable source.
- Make discovered stations directly presetable to hardware buttons.
- This is the most common replacement for TuneIn for users who listened to internet radio via presets.
@@ -242,7 +242,7 @@ These exist in soundcork but are deliberate architectural choices in AfterTouch,
@@ -21,13 +21,17 @@ Most SoundTouch devices run a modified Linux distribution. Accessing these logs
Community research (SoundCork Issue #112) has identified a "backdoor" to enable developer services:
1. **USB Method**:
1. **CLI Method (recommended, no USB needed)**:
- `soundtouch-cli --host <device-ip> setup enable-ssh` drives the port-17000 diagnostic shell to inject the `remote_services` marker and start `sshd`, then waits for `:22`. This is the #471 bootstrap; it needs no prior SSH and no USB stick.
- If the command is accepted (the device persists it, confirmed by `getpdo`) but `sshd` never comes up and `:22` stays "Connection refused", retry with `--full-config`. That variant mirrors the manual telnet sequence confirmed on issue #515: it puts the injection on `sys configuration margeServerUrl` as well as `envswitch`, writes all four URL keys, and reboots.
- **`--full-config` is meant for:** the **SoundTouch Portable (Series I, model 412540, FW `27.0.6.46330.5043500`)** and some **CineMate 520** units, where the default single-`envswitch` path leaves `sshd` down. The default path is sufficient on the Wireless Link Adapter and the CineMate 520 `lisa` variant. Some units (e.g. certain ST10 and CineMate 520 firmwares) do not respond to either path and need the serial / U-Boot console route instead. See [TELNET-COMMAND-REFERENCE.md](../analysis/TELNET-COMMAND-REFERENCE.md#what-we-use-to-enable-ssh-setup-enable-ssh-471) for the exact commands and current confirmation status.
2. **USB Method**:
- Format a USB stick to **FAT32**.
- Create an empty file named `remote_services` (no extension) in the root of the USB stick.
- Insert the stick into the SoundTouch device.
- Reboot the device (power cycle).
- On some models, you may need to hold **4** and **Volume -** on the device while powering on to force a USB check.
2. **TAP Command (Legacy)**:
3. **TAP Command (Legacy)**:
- On older firmware versions, you can connect to port 17000 via Telnet and issue the command: `remote_services on`.
SoundTouch devices support 6 preset slots that can store your favorite content for instant access. This guide shows you how to manage presets using the soundtouch-web UI, the CLI, or the Go library.
SoundTouch devices support 6 preset slots that can store your favorite content for instant access. This guide shows you how to manage presets using the soundtouch-player UI, the CLI, or the Go library.
## Via soundtouch-web (browser UI)
## Via soundtouch-player (browser UI)
**soundtouch-web** (default port **8080**) is the easiest way to manage presets without the command line. Two save paths are available whenever content is playing:
**soundtouch-player** (default port **8080**) is the easiest way to manage presets without the command line. Two save paths are available whenever content is playing:
@@ -3,13 +3,31 @@ title: "Unimplemented SoundTouch API Endpoints"
sidebar:
exclude: true
---
**Last Updated:** January 2026
**Last Updated:** June 2026 (reconciled against `pkg/client`)
**Source:** [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)
**Current Implementation:** 35 endpoints (including preset & navigation management discovered via SoundTouch Plus Wiki)
**Current Implementation:** ~41 endpoints in`pkg/client` (see reconciliation note)
**Wiki Documentation:** 87 endpoints
**Implementation Gap:** 52 endpoints
**Implementation Gap:** ~46 endpoints
This document provides comprehensive information about SoundTouch API endpoints documented in the community wiki but not yet implemented in this Go library. All examples are based on real device responses and extensive community testing.
This document covers SoundTouch **device** WebServices API endpoints (the
speaker's local `:8090` API consumed by `pkg/client`) documented in the community
wiki but not yet implemented. It is **not** about the cloud-service router
(`cmd/soundtouch-service`); for that surface see the contract checklist
`tests/integration/http-client/COVERAGE.md`. Examples are based on real device
responses and community testing.
> **Reconciliation note (June 2026).** Verified against `pkg/client`. Since the
> last update these are **now implemented** and have been re-marked below:
@@ -272,8 +295,10 @@ Rates currently playing media (Pandora only).
#### GET /listMediaServers 🔥 **CRITICAL**
Returns detected UPnP/DLNA media servers.
#### ~~GET /listMediaServers~~ ✅ **IMPLEMENTED**
~~Returns detected UPnP/DLNA media servers.~~
**Implementation Status:** ✅ Complete - Available in `pkg/client/client.go` as `ListMediaServers()`; response model in `pkg/models/mediaservers.go` as `ListMediaServersResponse`. The CLI exposes this via `soundtouch-cli library servers --via-speaker`. App-side SSDP discovery (without `--via-speaker`) is in `pkg/discovery`.
**Response Example:**
```xml
@@ -630,9 +655,12 @@ Selects LOCAL source (only way to select LOCAL on some devices).
1. **Implement account-device MAC mapping** for automatic association
2. **Add IP geolocation** for regional settings inference
3. **Create device registration UI** optimized for `/power_on` discovered devices
@@ -372,7 +372,7 @@ type Migration struct {
### Long-term Enhancements (Phase 3)
1. **Request firmware enhancement** to include missing data in `/power_on`
2. **Implement real-time device monitoring** via `/power_on` events
2. **Implement real-time device monitoring** via `/power_on` events
3. **Create centralized device management** independent of network topology
4. **Add predictive migration** based on device status patterns
@@ -387,8 +387,8 @@ type Migration struct {
The `/power_on` endpoint provides a significant opportunity to reduce network dependencies while enhancing device management capabilities. By implementing a hybrid approach that leverages `/power_on` data for primary device identification and status updates while maintaining existing registration workflows for user-controlled metadata, the system can achieve:
- **Network independence** for core device management
- **Enhanced real-time capabilities** through device-initiated communication
- **Enhanced real-time capabilities** through device-initiated communication
- **Improved scalability** across diverse network topologies
- **Better user experience** with automatic device discovery and status updates
The proposed implementation strategy provides a clear path to achieve these benefits while maintaining system reliability and user workflow compatibility.
The proposed implementation strategy provides a clear path to achieve these benefits while maintaining system reliability and user workflow compatibility.
| `/health`<br>`/static/*`<br>`/ws` | (Infra) | `/health` is a merge (standardise on the service's body); `/static/*` and `/ws` are additive (the service registers neither) |
| `/`<br>`/device/*`<br>`/devices`<br>`/playurl`<br>`/radiobrowser`<br>`/tts`<br>`/tunein` | (4) frontend | `/` is the one true collision (-> landing page); the rest move under `/app/*`. The anti-pattern: each SPA route enumerated in the backend, all serving `index.html` |
## Deployment scenarios, reachability, and trust boundaries
The client-audience axis tells you *who* calls a route. The deployment tells you
whether that caller can actually reach it and whether the surrounding network
can be trusted. AfterTouch runs in materially different places, and that decides
which routes are even *meaningful* and what the trust boundary is.
### Actors (the original Bose model)
The original Bose architecture had three actors, and our route surface still
| Speaker | Local (the device) | Calls the cloud for its data-plane (`/full`, presets, sources, software update, tokens) and is provisioned by the app. |
| App | Local (phone / desktop), **in-between** | Creates the account, adds a speaker to an account, and teaches the speaker its cloud/marge credentials. **Authenticates itself** to the cloud. |
| Cloud | External / public (what AfterTouch replaces) | Serves the speaker data-plane and the app's account/provisioning calls. |
Two things matter for our design:
- **The app is deployment-agnostic.** It does not care whether the cloud (our
service) runs locally or in a datacenter; it talks to whatever cloud endpoint
it is pointed at. So the **deployment modes below are about where the *cloud*
role runs**, orthogonal to the app actor.
- **AfterTouch's own tooling currently plays the app's role.** Account creation
and "teach the speaker its marge account" are done by our migration tooling
(today via the speaker's local WebSocket `setMargeAccount`), i.e. we are the
provisioning agent. But the app-facing *cloud* endpoints still exist in the
surface (account create/login, add device, profile, password, groups), and a
real app pointed at us would use them. They are part of the frozen contract,
but their caller and trust story differ from the speaker's data-plane (see
below).
### Deployment topologies (where the cloud role runs)
This is descriptive (where it runs), distinct from the `deployment-mode`
*parameter* below (the security posture). They correlate but are kept separate so
an operator is not locked into one because of the other.
| Speaker contract | frozen (1a), speaker-polled | None (no user login; the app_key is validated but is not user auth) | None, but network-segmentable; in cloud these are necessarily public so the speaker can reach them |
| App / provisioning | account create/login, add/update/remove device, profile, password, groups (`/streaming/account*`, `/customer/account*`) | None enforced (we accept; the app's self-auth from the original model is not required) | Authenticated in cloud: an open provisioning surface lets anyone create accounts or attach devices |
| Admin / setup | `/mgmt/*` (non-callback)<br>`/setup/*`<br>`/web/*` | `/mgmt/*` has single-credential HTTP Basic Auth; **`/setup/*` has none** (explicit "LAN-trust" premise); the Basic Auth even leaks behind a proxy (#419) | Authenticated always; mandatory in cloud |
| Control / player | `/api/control/*` (post-merge) | None | Optional auth; low blast radius |
The "LAN-trust" premise is defensible on a home LAN but **invalid in the cloud**:
@@ -156,7 +156,7 @@ Where today's surfaces fall short for this user:
**Goal.** Music plays. Pressing preset 3 gives them what preset 3 should give them. Skipping a station, adjusting volume, browsing for a new station — all fast, no friction.
**Surfaces.** Physical preset buttons (always there), `soundtouch-web` (today), mobile app (Journey 2 admin app's daily-use mode), WASM-served browser UI (planned), Bose app while it still functions, voice assistants where wired up.
**Surfaces.** Physical preset buttons (always there), `soundtouch-player` (today), mobile app (Journey 2 admin app's daily-use mode), WASM-served browser UI (planned), Bose app while it still functions, voice assistants where wired up.
### What this layer needs to be good at
@@ -168,14 +168,14 @@ Where today's surfaces fall short for this user:
### How surfaces map
- `soundtouch-web`: primary daily UI for desktop browsers and (responsively) for tablets. This is already shipped.
- `soundtouch-player`: primary daily UI for desktop browsers and (responsively) for tablets. This is already shipped.
- Mobile app: daily-use mode of the same Gio app that handles admin. Capability split — admin features only show up when the user is in admin mode.
- WASM: same Gio app, served from `soundtouch-service` to anyone on the LAN. The "I forgot which device my login is on, just open a browser" fallback.
- Physical preset buttons: handled at the agent level (the Bose firmware fires them; AfterTouch or the on-device agent reacts).
### Open decisions for this journey
- Do we keep `soundtouch-web` as a separate codebase (HTML/JS), or does it become a Gio WASM build sharing code with the admin app?
- Do we keep `soundtouch-player` as a separate codebase (HTML/JS), or does it become a Gio WASM build sharing code with the admin app?
- Mobile app store distribution: TestFlight for iOS (gated, slow), Play Store for Android (faster, AAB only), F-Droid as an open-source-friendly side path.
- Multi-user state: presets per-user vs per-household. Out of scope here, but the daily surface is where it gets felt.
@@ -199,7 +199,7 @@ Where today's surfaces fall short for this user:
### How surfaces map
- `soundtouch-cli`: the canonical surface for scripted control. Already covers most of the API.
- `soundtouch-service` REST endpoints: same surface, network-accessible. Used by `soundtouch-web` and by third-party automation.
- `soundtouch-service` REST endpoints: same surface, network-accessible. Used by `soundtouch-player` and by third-party automation.
- Home Assistant: external integration; track but do not own.
- Webhooks / MQTT: not present today; would let speakers participate in event-driven flows. Out of scope for a first pass; worth a separate design doc when demand surfaces.
@@ -217,7 +217,7 @@ Where today's surfaces fall short for this user:
| GUI admin app (Gio, planned) | primary | primary | mobile mode | no |
| Pre-flashed stick (hypothetical) | primary | recovery | no | no |
| Physical preset buttons | no | no | primary | no |
@@ -229,7 +229,7 @@ The diagonal isn't full because some journeys lack a polished surface today (Jou
The Gio admin app, if built, can target Windows / macOS / Linux / iOS / Android / WASM from one codebase. Each target has hard constraints:
- **WASM (browser).** Post-install REST control, device list and status, preset editing, station search. No mDNS (browsers cannot do raw multicast — fall back to manual IP entry or a backend bridge); no raw TCP, so no SSH and no install; no block-device access, so no stick writing. This is the "I just want to use my speakers" surface, equivalent to today's `soundtouch-web`.
- **WASM (browser).** Post-install REST control, device list and status, preset editing, station search. No mDNS (browsers cannot do raw multicast — fall back to manual IP entry or a backend bridge); no raw TCP, so no SSH and no install; no block-device access, so no stick writing. This is the "I just want to use my speakers" surface, equivalent to today's `soundtouch-player`.
- **Mobile iOS.** Everything WASM does, plus Bonjour-based mDNS, plus full SSH client (so app-driven install and recovery work). No FAT32 stick writing — iOS has no filesystem-level block device access for third-party apps. Best paired with a pre-flashed stick or a friend's desktop install for the bootstrap.
- **Mobile Android.** Same as iOS, plus FAT32 stick writing *if* the user grants USB-OTG host permission. UX caveat: most users will not know what USB host mode is.
- **Desktop (Gio).** Full capability set. mDNS, SSH-driven install, FAT32 stick writing via standard block-device APIs, post-install control, recovery. The primary onboarding surface.
- `/mgmt/spotify/callback` is intentionally outside Basic Auth to allow direct redirects from Spotify's authorization server.
- All other `/mgmt/*` endpoints require Basic Auth as configured by `--mgmt-username` and `--mgmt-password`.
- All other `/mgmt/*` endpoints require Basic Auth as configured by `--mgmt-username` and `--mgmt-password` (defaults documented in [Configuration Options](../guides/SOUNDTOUCH-SERVICE.md#configuration-options)).
- Tokens are persisted to disk as JSON with restricted file permissions (`0600`).
- The `GetAccounts` endpoint strips sensitive tokens from the response.
| `soundtouch-service` | The local cloud replacement ("AfterTouch"). Runs always-on and takes over from the Bose cloud. | You are migrating speakers off the Bose cloud. |
| `soundtouch-cli` | Command-line control and setup (status, play, presets, groups, **migration**, …). | You want to script things, or run a migration by hand. |
| `soundtouch-player` | A browser control panel (radio browsing, device control). | You want a web UI to browse radio and control speakers. |
| `soundtouch-backup` | Backs up your Bose cloud account and each speaker's local state. | You are preparing before a shutdown / factory reset. |
Most people only need **`soundtouch-service`** and **`soundtouch-cli`** — the
release notes on each [GitHub release](https://github.com/gesellix/Bose-SoundTouch/releases/latest)
link those two directly, one row per platform, so you don't have to hunt
through the flat Assets list below.
> Running a migration from the command line (for example the telnet
| Direct LAN / on-device (no proxy) | `false` (default) | the connecting socket's IP; `X-Forwarded-For` is ignored |
| Behind a proxy whose socket IP is in `trusted_proxy_cidrs` | `true` | the rightmost `X-Forwarded-For` entry outside `trusted_proxy_cidrs` (the real speaker) |
| A direct connection whose socket IP is not in `trusted_proxy_cidrs` | `true` | the socket IP; `X-Forwarded-For` is ignored (spoofing protection) |
> **Do not enable `trust_forwarded_headers` on a flat LAN with no proxy.** A
> malicious speaker could then send `X-Forwarded-For` itself and spoof its
> source IP. A missing or unparseable header always falls back to the socket IP.
For terminating TLS at the proxy (serving the certificate on `:443`), see the
[reverse proxy section of the HTTPS guide](HTTPS-SETUP.md#reverse-proxy-optional).
---
## Step 1 — Deploy AfterTouch on your server
### Docker / Docker Compose (any VPS)
@@ -118,7 +163,7 @@ migration must be driven from `soundtouch-cli` **running on your own machine
@@ -99,18 +99,28 @@ After factory restore the speaker enters setup mode automatically; no power-cycl
## 6. AP Mode Wi-Fi Provisioning via Console
When BLE is unavailable (e.g. when using an Android emulator), use AP mode to push Wi-Fi credentials from the Mac command line.
When BLE is unavailable (e.g. when using an Android emulator), use AP mode to push Wi-Fi credentials from the command line. The HTTP steps below (6.2, 6.3) are OS-agnostic; only the Wi-Fi-network-switching commands (6.1, 6.4) are platform-specific — macOS is shown inline, with Linux and Windows equivalents alongside.
### 6.1 Connect Mac to Speaker AP
### 6.1 Connect your machine to the Speaker AP
After factory reset the speaker broadcasts an SSID like `Bose SoundTouch XXXX`. Connect the Mac to it:
After factory reset the speaker broadcasts an SSID like `Bose SoundTouch XXXX`. Connect to it:
```bash
# List nearby SSIDs — use System Settings → Wi-Fi (the airport command was removed in macOS Sequoia+)
# Connect (replace with actual SSID)
# macOS — list nearby SSIDs via System Settings → Wi-Fi (the `airport`
# command was removed in macOS Sequoia+); connect (replace with actual SSID):
# Linux (NetworkManager) — assumes the connection profile already exists
# (e.g. from a prior manual connect); use `nmcli device wifi connect
# "MyHomeNetwork" password "MyPassword"` instead for a first-time connect.
nmcli connection up "MyHomeNetwork"
```
```powershell
# Windows
netsh wlan connect name="MyHomeNetwork"
```
Wait ~15 s for the speaker to join the home network, then verify:
```bash
# Discover the speaker's new IP via mDNS
dns-sd -B _soundtouch._tcp local &
sleep 5 ; kill %1
# macOS/Linux — discover the speaker's new IP via mDNS.
# macOS: dns-sd ships with the OS. Linux: use avahi-browse (avahi-utils package).
dns-sd -B _soundtouch._tcp local & # macOS
avahi-browse -r _soundtouch._tcp # Linux — Ctrl-C to stop
sleep 5 ; kill %1 2>/dev/null # only needed for the dns-sd form
```
Windows has no equivalent built-in mDNS browser; use `soundtouch-cli discover devices` (this repo's own mDNS/UPnP discovery, cross-platform) or check your router's DHCP client list instead.
@@ -21,7 +21,7 @@ The service includes a built-in HTTPS listener (default port `8443`) that presen
- Wildcard: `*.api.bose.io`, `*.api.bosecm.com`
- Specific: `streaming.bose.com`, `bmx.bose.com`, `stats.bose.com`, `updates.bose.com`, `worldwide.bose.com`, `bose-prod.apigee.net`, `media.bose.io`, `downloads.bose.com`, `voice.api.bose.io`, and more
> **Note**: The hostname you configure as `HTTPS_SERVER_URL` (e.g. `https://soundtouch.fritz.box:8443`) is also added as a Subject Alternative Name, ensuring valid TLS for direct browser or API access.
> **Note**: The HTTPS endpoint is only needed for certain features (the DNS-based redirect, Spotify/Amazon login, and certificate trust). Its URL is added as a Subject Alternative Name, ensuring valid TLS for direct browser or API access. By default this URL is **derived from the Target Domain** (same host, `https`, on the HTTPS port), so you usually don't configure it separately. If you don't need plain HTTP at all, you can set the Target Domain itself to an `https://` URL — it is then used as the HTTPS endpoint as-is, with no separate override. Settings → **HTTPS URL** shows the effective value; set an override (`HTTPS_SERVER_URL` / `--https-server-url`, or the "advanced" field in Settings) only when a reverse proxy serves HTTPS on a different host or port.
Download the `soundtouch-service` build for your platform from the
[Downloads page](../downloads/_index.md) (it explains which file to pick).
Make it executable and run:
```bash
# Linux / macOS example
@@ -107,6 +107,10 @@ Open `http://<server>:8000` and go to the **Settings** tab.
Set the **Target Domain** to the address your speakers can reach — for example `https://soundtouch.fritz.box` or `http://192.0.2.100:8000`. This must be the host's address on your local network, not `localhost`.
> **Changing this later?** Saving Settings only updates AfterTouch's own record of its address — it does **not** reach out to any already-migrated speaker. Each speaker only learns a new address when you (re-)run Migrate for it (Step 5 below), regardless of migration method. If you change Target Domain after some speakers are already migrated, re-migrate each of them too, or they'll keep using whatever address they were originally migrated with. See [Troubleshooting: Changing Target Domain doesn't change what a speaker actually uses](TROUBLESHOOTING.md#settings-vs-migrate).
> **On-device install:** this "not `localhost`" rule is for the local-network-host and cloud/VPS scenarios above, where the service runs on a *different* machine than the speaker. If you're running AfterTouch directly on the speaker itself (see the [On-Device Install Walkthrough](ON-DEVICE-INSTALL-WALKTHROUGH.md)), the speaker and the service are the same machine — `http://localhost:8000` is exactly right there, and is the recommended value: it needs no DNS/mDNS to resolve and survives DHCP address changes since it never depends on the LAN address at all. Installs built after issue #546's fix set this automatically (via `DEPLOYMENT_MODE=on-device`); on older installs, or if the field still shows the speaker's own unresolvable Linux hostname (e.g. `http://spotty:8000`), set it here by hand.
If you plan to use DNS/DHCP redirect, enable the **DNS Discovery Server** and set the **DNS Bind Address** to `:53`. The upstream DNS should be your router's IP, not the service's own address.
> **Tip**: If you change settings and they don't seem to take effect, check `data/settings.json` — settings saved in the UI take precedence over environment variables.
@@ -127,6 +131,14 @@ The XML migration writes updated configuration to the speaker's filesystem, whic
4. Power-cycle the speaker (unplug the power cable, wait 10 seconds, reconnect).
5. After boot, root SSH is available with no password: `ssh -oHostKeyAlgorithms=+ssh-rsa root@<SPEAKER-IP>`
**Or, without a USB stick:** `soundtouch-cli setup enable-ssh` (#471) bootstraps SSH purely over the network, using the speaker's telnet:17000 diagnostic shell (open by default on most firmware) to inject the SSH-enable command:
It waits for `:22` to come up and persists the change (survives a reboot) by default. Falls back to the USB-stick method above if telnet:17000 is closed or the injection doesn't take on your model.
You only need to do this once per speaker. SSH can remain enabled for future maintenance or be disabled after migration — your choice.
@@ -21,18 +21,18 @@ Good choices: a Raspberry Pi, a NAS (like Synology or QNAP), an always-on PC or
## Step 1: Get the software
Go to the [AfterTouch releases page](https://github.com/gesellix/Bose-SoundTouch/releases) and download the latest release for your operating system:
See the **[Downloads page](../downloads/_index.md)** for the full list of builds and how to pick the right one for your system. You want the `soundtouch-service` tool; download the build whose suffix matches your computer:
| Windows | `soundtouch-service-vX.Y.Z-windows-amd64.exe` |
Extract the archive. You will find a single file called `soundtouch-service` (or `soundtouch-service.exe` on Windows).
(`X.Y.Z` is the current release version.) The download is a single ready-to-run executable called `soundtouch-service` (or `soundtouch-service.exe` on Windows) — no archive to extract.
### Alternative: Docker
@@ -122,12 +122,12 @@ The easiest solution is to assign a **static (fixed) IP address** to the compute
## Security note
AfterTouch's web interface and management API have no login by default. On a typical home network this is fine, since only devices on your local network can reach it.
The main web interface has no login by default — on a typical home network this is fine, since only devices on your local network can reach it.
If you want to restrict access — for example, on a shared network — start the service with a username and password:
The Management API (Spotify/Amazon account linking, the Local Accounts page) is a separate area that's *always* protected by HTTP Basic Auth, but ships with a published default (`admin` / `change_me!`) — anyone who has read the docs can use it. If you want real protection — for example, on a shared network — set your own:
This protects the Settings tab (where your Spotify and Amazon credentials are stored) from being read or changed by others on the network.
See [Configuration Options](SOUNDTOUCH-SERVICE.md#configuration-options) for the full list of settings and env-var equivalents. Note that this does *not* cover the Settings tab, where your Spotify/Amazon Client ID and Secret are stored — that tab has no separate protection today.
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.