- Add scripts/raspberry-pi/install-web.sh: mirrors install.sh but for
the stateless soundtouch-web binary (no privileged ports, no data dir,
no HTTPS). Default port 8080; override via HTTP_PORT at install time.
- Add GET /health to soundtouch-web (handler + mount); returns
{"status":"ok","version":"…"} — used by the installer's health check
and by monitoring.
- Update scripts/raspberry-pi/README.md to document both installers side
by side (installation, config, service management, updates, removal).
- Bump default VERSION to v0.97.0 in all three installer scripts
(install.sh, install-web.sh, on-device-install/install.sh).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two bugs prevented clean stereo-pair teardown:
1. removeGroup (CLI) only contacted the --host speaker (master). The
slave never received /removeGroup and stayed stuck in GroupSlave state
indefinitely, blocking direct playback. Fix: fetch the current group
first, then send /removeGroup to every member in parallel — mirrors
the same symmetry as createGroup (issue #252).
2. Speakers send DELETE /streaming/account/{id}/group/ (trailing slash,
no group ID) during teardown. Master and slave live in different
accounts, so each deletes its own copy independently. AfterTouch had
no route for this form → 405. Fix: add DeleteAllGroupsForAccount to
the datastore (scans Group_*.xml, idempotent if none found) and wire
DELETE /group and DELETE /group/ to a new HandleMargeDeleteAccountGroups
handler in both routing blocks.
Confirmed: after the fix both DELETE calls return 200 and the slave
exits GroupSlave state cleanly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add a refresh policy to the fix registry so the UI can avoid the
unnecessary "Loading…" flash when a quick fix does not change any
check state.
- Registry stores fixEntry{fn, refresh} instead of bare FixFunc.
- RegisterFix (existing callers) keeps refresh=true: resolved
findings disappear from the list after the fix runs.
- New RegisterFixNoRefresh sets refresh=false: used for persistent
operator affordances whose success leaves the finding unchanged.
- RunFix now returns (string, bool, error); the bool propagates to
the healthFixResponse JSON as "refresh".
- play_ding registered via RegisterFixNoRefresh — pressing it never
resolves the finding, so no re-fetch is needed.
- runQuickFix in script.js gates setTimeout(fetchHealth, 400) on
data.refresh !== false; absent or true keeps the existing behaviour.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Speakers need a moment to start buffering after receiving a ContentItem;
the first ~2 s of audio is often missed. Repeating the ding 3 times with
0.4 s gaps between each ensures at least one repetition is audible.
- Add Repeat (default 3) and RepeatGapDuration (default 0.40 s) to Options
- Render() appends silence + base audio for each extra repetition
- WithDefaults() fills zero values for the new fields
- Handler exposes ?repeat= (1–10) and ?repeat-gap-ms= query knobs
- Update TestRender_DefaultSizeApproximately52KB → ~229 KB (2.6 s)
- Add TestRender_RepeatProducesLongerAudio
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two per-device checks run against each speaker's CA bundle via a
single SSH probe round-trip:
(1) Every PEM block from ca-bundle.crt.original (the factory backup
written by TrustCACertFromBytes on first CA injection) must be
present in the live ca-bundle.crt. A missing block means the
original trust store was truncated, which would break external
HTTPS (Spotify, Amazon, firmware updates).
(2) The AfterTouch CA sentinel (# AfterTouch) must be present in
the live bundle. Without it the speaker rejects AfterTouch's
TLS cert and migration is effectively inactive.
Both findings carry a QuickFix:
- FixIDRestoreAndInjectCA: cp .original → live bundle over SSH,
then TrustCACert to re-inject the AfterTouch CA.
- FixIDInjectCACert: TrustCACert only (original certs intact).
Graceful degradation:
- SSH unavailable → SeverityInfo, no fix offered.
- .original absent (device never had install-ca run) → SeverityWarning,
suggest install-ca; check (2) still runs.
Infrastructure changes:
- ssh_probe.go: add ca-bundle.crt.original to probeFilePaths (free
in the existing single-round-trip batch).
- setup.go: export ProbeCABundles and RestoreCABundleFromOriginal so
the handlers package can use them without exposing speakerProbe.
- Fix executors live in handlers (need setup.Manager) per the
established boundary used by completeSpeakerPairingFix.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ci.yml's Docker job was missing the build-args introduced alongside
the Dockerfile ARG/ldflags changes. COMMIT and DATE are now injected
into both soundtouch-service and soundtouch-web CI builds; VERSION
stays 'dev' (the Dockerfile default) since CI builds aren't tagged
releases.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Docker build excluded .git via .dockerignore, so Go's debug.ReadBuildInfo()
found no vcs.revision / vcs.time settings and the binaries reported
version=dev, commit=unknown, date=unknown in the web UI.
Two fixes:
1. Dockerfile — declare ARG VERSION/COMMIT/DATE (default to dev/unknown/unknown
so local docker build still works) and pass them to both go build commands
via -X main.version/commit/date ldflags. Also add the -trimpath and -s -w
flags that the Makefile's BUILDFLAGS already uses but the Dockerfile was
missing.
2. release.yml — add a 'Set build date' step, then pass build-args with
VERSION, COMMIT (full SHA), and DATE to both docker/build-push-action
steps. The .git exclusion in .dockerignore stays correct; version info
is now supplied explicitly instead of being read from VCS at build time.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The 'listening on' message now shows both the configured address
(config.addr, e.g. ':8000') and the true effective address returned
by the listener (e.g. '0.0.0.0:8000'), making it immediately clear
which port was requested and which was actually bound:
Go service listening on 0.0.0.0:8000 (configured: :8000, server URL: http://192.0.2.1)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The most common misconfiguration on install-on-speaker setups is an
HTTP server URL that omits the port (e.g. http://192.0.2.1 instead of
http://192.0.2.1:8000). Port 80 is occupied by the Bose firmware's
PtsServer, so AfterTouch binds its default port 8000 — but the
margeURL pushed to speakers still resolves to port 80 and hits
PtsServer instead of AfterTouch. Marge calls are silently dropped,
sources are never registered, and TuneIn playback fails with error
1005 (UNKNOWN_SOURCE_ERROR). See issue #319.
Changes:
- pkg/service/health/checks_server_url.go — new health check
(server_url_reachable) that probes GET {serverURL}/setup/version from
inside the service; emits SeverityWarning with remediation steps when
the endpoint is not reachable or returns non-200.
- pkg/service/handlers/server.go — register the new check in NewServer.
- cmd/soundtouch-service/main.go — replace http.ListenAndServe with an
explicit net.Listen so the true effective port is logged before TLS
starts. Both HTTP and HTTPS log lines now show the listener's actual
bound address alongside the configured server URL:
Go service listening on 0.0.0.0:8000 (server URL: http://192.0.2.1)
Previously only the server URL was logged, creating the false
impression that AfterTouch had bound that URL's implicit port.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Hextra's production build bundles assets/css/custom.css into
css/compiled/main.css. The original '../fonts/' relative path resolved
correctly from css/custom.css (dev) but landed at css/fonts/ in
production — one directory too deep.
Fix: use '../../fonts/' so the URL resolves correctly from every
output location browsers may encounter:
dev: /css/custom.css → ../../fonts/ → /fonts/
production: /css/compiled/main.css → ../../fonts/ → /fonts/
GH Pages: /Bose-SoundTouch/css/compiled/main.css
→ ../../fonts/ → /Bose-SoundTouch/fonts/
Browsers clamp traversal at the origin root, so going two levels up
from /css/custom.css still reaches /fonts/ — safe in dev, correct in
production.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
initializeDefaultSources() called GetDefaultSources(), which includes
the legacy INTERNET_RADIO stub (ID 10002). On every service start it
would re-add that entry to any device whose Sources.xml had it removed
— including devices where the stale_internet_radio health-check quick
fix was applied — silently undoing the clean-up.
getAccountSources() in marge.go had the same issue: it passed the full
default list into the /full cloud response, causing a phantom
"sources_xml_diff" Info finding after a clean-up.
Fix: export the existing private getInitialSources() as
GetInitialSources() (excludes INTERNET_RADIO) and use it in both call
sites instead of GetDefaultSources().
Existing devices that still have INTERNET_RADIO in their Sources.xml
are unaffected: the merge loop only appends entries that are missing,
so a present entry is preserved (the token is refreshed as before).
Update unit and integration test expectations accordingly: the no-device
fallback now returns 3 cloud sources (LOCAL_INTERNET_RADIO, TUNEIN,
RADIO_BROWSER) instead of 4 (dropping INTERNET_RADIO / ID 10002).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
EXTERNAL-HOST-WALKTHROUGH.md — Step 7 "Via soundtouch-web":
Replaced the single save path with two labelled options:
- ★ Star button: appears in the Now Playing card's top-right corner,
opens a slot picker (1–6), turns gold once mapped.
- + button: appears on each preset tile on hover, saves directly to
that slot without a picker.
Added a one-liner on when to use each.
PRESET-QUICKSTART.md:
New "Via soundtouch-web (browser UI)" section added above the CLI
section, covering both the ★ star and + paths with step-by-step
instructions.
soundtouch-web-roadmap.md:
- Added a "Shipped" callout noting that preset-slot saving is done.
- Retitled the Favorites section to "Favorites (device-native, distinct
from presets)" and added a note clarifying it refers to the speaker's
/favorites API (different from the 6 preset slots) which is still
pending.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two complementary ways to save what's currently playing to a preset slot
without leaving the web UI:
★ Star button (Now Playing card)
A semi-transparent star appears in the top-right corner of the Now
Playing card whenever a device is selected and something is playing.
Clicking it opens a slot picker (1–6); selecting a slot calls
POST /api/control/{id}/storepreset?id={slot}. The star turns gold
when the current ContentItem is already mapped to at least one preset,
matching the preset list by Source + Location. An outside-click
closes the picker without saving.
+ button (preset tiles)
While content is playing each of the six preset tiles shows a small +
button on hover. Clicking it saves directly to that slot — no picker
needed. The button cycles through + → ✓ → (reset) states with
a 1.5 s success flash and shows ✗ briefly on error.
Backend (handler.go):
New "storepreset" case in handleControlAction dispatches to
handleStorePreset, which validates the ?id= query param (1-6) and
calls device.Client.StoreCurrentAsPreset(presetID).
Frontend (api.js):
storePreset(deviceId, slotId) helper added.
CSS (app.css):
.preset-slot-wrap wrapper + .preset-save-btn styles for the + button,
source-specific --slot-color custom properties for border accents,
.now-playing-fav-wrap / .now-playing-fav-btn / .now-playing-fav-overlay
for the star button and its popover (right-aligned, z-index: 50).
position: relative added to .now-playing so the star can be absolutely
positioned without being clipped by .track-info overflow: hidden.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
StoreCurrentAsPreset only used ContentItem.ContainerArt for the stored
artwork URL. For Spotify (and some other streaming sources) the speaker
populates the top-level NowPlaying.Art.URL field instead, leaving
ContainerArt empty, which caused preset tiles to show as text-only.
When ContainerArt is empty and Art.URL is present with artImageStatus
IMAGE_PRESENT, copy the URL into a shallow-copy of the ContentItem
before storing it. Devices where ContainerArt is already set are
unaffected.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The hardcoded default 'ueberboese-login://' scheme was a leftover from
an earlier Spotify callback flow that no longer applies. An empty default
is correct — the value is set by the user during installation if they want
Spotify support.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Stripping the .md extension alone is not enough under Hugo pretty URLs.
A page rendered at /guides/DEPLOYMENT-OVERVIEW/ treats a bare relative
href like 'CLOUD-DEPLOY-WALKTHROUGH' as relative to that directory,
producing /guides/DEPLOYMENT-OVERVIEW/CLOUD-DEPLOY-WALKTHROUGH (404).
Switch to site.GetPage to look up the target page by its content path
(resolved relative to the current file's directory) and write its
RelPermalink into the href. This gives an absolute path that is correct
in both the dev server and the GitHub Pages build (where --baseURL
injects the /Bose-SoundTouch/ prefix via RelPermalink automatically).
Also handles anchored links (OTHER.md#section) and falls back to
bare-stripped path when GetPage finds no match.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When isXMLMigrated and isTelnetMigrated both return false, the UI fell
through to the ❌ "Original (Bose cloud)" catch-all even if the speaker's
on-device URLs clearly point to a non-Bose host. This happened when the
service's Settings Target Domain and the URL written to the speaker had
drifted — e.g. migrated with http://spotify:8000 but Settings URL is an
IP address, or vice versa.
Add isMigratedToOtherTarget() that checks parsed_current_config: if at
least one URL field is set and none contain a known Bose cloud hostname,
the speaker has been migrated, just not to the *current* Settings Target
Domain.
- urlConfigVerdict now returns ⚠️ "Migrated (URL mismatch)" in this case,
showing the actual margeServerUrl and noting that the speaker must be
able to reach the service there
- The top-level migration status badge shows ⚠️ orange instead of ❌ red
- The apply plan path is unchanged: it will re-point the speaker to the
current Settings Target Domain, which is one valid resolution path
Related to #408
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The docs framework renders frontmatter title: as the page heading.
Every file that also had a matching # Heading as the first content
line displayed the title twice. Removed the redundant H1 and its
following blank line from all 91 affected files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Migration guide: expand the one-liner after SSH setup into a concrete
'To disable SSH' section covering both the USB-stick and persistent-file
cases, with the button name and CLI command.
Admin UI:
- Preconditions label: 'remote_services' → 'SSH (remote_services)'
with a tooltip explaining the connection
- Buttons: 'Enable/Remove Persistent Remote Services' →
'Enable SSH (Persist remote_services)' /
'Disable SSH (Remove remote_services)'
- Confirm dialog: mentions SSH and reboot requirement explicitly
- Verdict text: all three states now lead with 'SSH ...' so users
recognise what the check controls
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
testing.Short() would silently suppress the test even with
RADIOBROWSER_INTEGRATION=1 set, contradicting the skip message.
The env var opt-in is sufficient on its own.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The test dials all.api.radio-browser.info directly. When the upstream
TLS certificate expires the test fails and blocks the build — the local
codebase has no control over third-party certificate health.
Guard with testing.Short() and an opt-in env var so CI stays green and
the live-network test can still be run explicitly when needed.
Closes#412
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add validateZcPort alongside validateZcHost: the strconv.Atoi→Itoa
round-trip produces a sanitised integer string that CodeQL no longer
considers tainted, closing the remaining go/request-forgery findings
at zeroconf.go:263, :336, :413.
Also rejects clearly invalid inputs (non-numeric, out-of-range) that
would previously have produced a silently broken URL.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Establishes the constraint in godoc so future authors have a visible
signal before passing user-supplied values to session.CombinedOutput.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Trailing inline // lgtm[...] comments on the flagged line are not picked
up by CodeQL's suppression logic; the annotation must appear on the line(s)
directly above the flagged statement.
Closes CodeQL alert 294 (go/clear-text-logging).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace validateZcBaseURL(zcBaseURL string) with:
- validateZcHost(host string) (net.IP, error) — validates literal IP
- buildZcBase(ip net.IP, port string) *url.URL — builds URL with literal /zc path
The key change: the URL path is now the string literal "/zc" everywhere,
never derived from user input. CodeQL's go/request-forgery model traces
taint through the Path field of a rebuilt URL; removing that field from
the taint chain closes alerts 134, 135, 136.
Public API changes:
zeroconf.GetInfo(host, port string)
zeroconf.PushCredentials(host, port, username, accessToken string)
spotify.ZeroConfGetInfo(host, port string)
spotify.PushSpotifyCredentials(host, port, username, accessToken string)
amazon.PushAmazonCredentials(host, port, username, accessToken string)
Callers in handlers/server.go already held host+port separately via
net.SplitHostPort; the zcURL construction is removed.
Tests updated throughout; TestValidateZcBaseURL renamed to
TestValidateZcHost and TestBuildZcBase added for the new helpers.
Closes CodeQL alerts 134, 135, 136 (go/request-forgery).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The log.Printf at this line uses formatHeaders, which unconditionally
redacts alwaysSensitiveHeaders (Authorization, Cookie, …) and applies
sanitizeLog to strip newlines from other values. CodeQL cannot model the
custom redaction inside formatHeaders and flags the call.
The lgtm annotation suppresses the false positive. The struct comment
explains the reviewed rationale in full.
Closes CodeQL alert 294 (go/clear-text-logging).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The middleware is a transparent passthrough for XML API responses
(Content-Type: application/vnd.bose.streaming-v1.2+xml). Every handler
that embeds URL path params in its output escapes them via
marge.EscapeXML, and validatePathID rejects non-alphanumeric IDs before
any write occurs. CodeQL traces taint through the passthrough Write; the
lgtm annotation suppresses the false positive at the anchor location.
Closes CodeQL alert 75 (go/reflected-xss).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extend the "destructive git actions" guideline to cover force-flags
(git add -f, git push --force, git push --force-with-lease, …).
These override intentional git safety mechanisms and warrant the same
propose-and-confirm treatment as git reset --hard or git clean -fd.
Prompted by: git add -f on a gitignored file during sec6/sec7 work.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
e6bfcd1 removed the credential-log debug flag entirely to close
go/clear-text-logging (alert 294). Restore it with a design that
satisfies CodeQL while keeping the feature:
- log.Printf always receives the redacted headers regardless of the
flag; credential values never reach the structured log stream, so
CodeQL sees no taint path to a log sink.
- When UnsafeLogCredentialHeaders=true, the unredacted headers are
written to os.Stderr via fmt.Fprintf(os.Stderr, …). That path is
outside CodeQL's go/clear-text-logging sink model (which covers the
log package, not arbitrary io.Writer writes).
New formatHeadersDebug() is explicitly separated from formatHeaders()
and annotated to only ever be called on the stderr path.
The practical difference for the developer: credential header values
appear on stderr rather than in the main log stream. LOG_PROXY_CREDENTIALS=true
still activates it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- stockholm/static.go: wrap deferred root.Close() in func(){}() to
silence errcheck; change 'rel = rel + ...' to 'rel += ...' (gocritic).
- Remove sanitizeErr from four logutil files where no call site exists
(cmd/soundtouch-cli, cmd/websocket-demo, pkg/discovery, pkg/service/setup).
The log-injection fixes in those packages used sanitizeLog on string
arguments rather than sanitizeErr on error values.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two alerts at proxy.go:87:
- go/clear-text-logging (alert 294): the UnsafeLogCredentialHeaders escape
hatch allowed credential-bearing headers (Authorization, Cookie, …) to
reach log.Printf in plaintext when LOG_PROXY_CREDENTIALS=true. CodeQL
traces the taint regardless of the conditional.
Remove UnsafeLogCredentialHeaders entirely. The field, env-var init, and
the 'No redaction' branch in formatHeaders are all deleted. Credentials
are now always redacted unconditionally. Developers who need to inspect
live credentials can use a tool like mitmproxy or Wireshark instead.
- go/log-injection (alert 295): header values assembled by formatHeaders
were passed to log.Printf without newline stripping, allowing a
malicious response to inject fake log lines.
Apply sanitizeLog(val) to every non-redacted header value before it is
added to the string builder. Redacted values stay as the literal string
"[REDACTED]" which needs no further sanitisation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the filepath.Abs + string-prefix path-traversal check followed
by os.Stat / os.ReadFile calls with an os.Root anchored at stockholmDir.
CodeQL (go/path-injection, alerts 143–145) did not recognise the
string-based validation as a sanitiser boundary; os.Root is the same
OS-level barrier used in the sec3 datastore and recorder refactors.
Changes:
- Open os.OpenRoot(stockholmDir) in ServeStatic; all file ops go
through root.Stat / root.Open instead of os.Stat / os.ReadFile.
- Replace resolveStaticFile (returned absolute + relative paths) with
resolveStaticRel (URL path → relative path only; no filesystem
access, no traversal logic — the Root handles containment).
- Directory → index.html fallback moved into ServeStatic via root.Stat.
- Drop path/filepath import from static.go (no longer needed).
- Update tests: resolveStaticFile unit tests become resolveStaticRel
unit tests; directory and traversal cases become ServeStatic
integration tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The handler used chi.URLParam("account") directly without the
validatePathID guard present on every other account-parameter handler
in the file. CodeQL traced the raw URL param through
marge.ProviderSettingsToXML into the response body (go/reflected-xss,
alert 75).
Add the standard two-line guard identical to HandleMargeAddDevice,
HandleMargeUpdateDevice, and the rest of the family.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Use relURL (no leading slash) for the sponsor link so it respects
the /Bose-SoundTouch/ base path on GitHub Pages; absURL and relURL
both ignore the base path when the input starts with /
- Inject HUGO_PARAMS_GITHASH (github.sha) via the docs workflow and
forward it into the Hugo container via docker-compose.docs.yml +
make dev-docs, so the deployed footer shows a clickable short hash
linking to the exact commit
- Use site.Params.githash (global) instead of .Site.Params.githash
because Hextra calls custom/footer.html with a dict context, not a
page; .Site is nil in that scope
- Use substr not slice to trim the hash to 7 chars
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
$logoLink is sourced from site config (never user input) and Hugo
auto-escapes template values. Pipe through safeURL to make the intent
explicit and satisfy the generic.html-templates.security.var-in-href rule.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The existing pattern ^/images/[^/]+\.png$ only matched single-level
image paths. Blog post images live under /images/blog/ — broaden the
pattern to ^/images/ to cover all static image paths regardless of depth.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- /sponsor landing page lists both options with feature cards
- Navbar heart icon and footer sponsor link both point to /sponsor
instead of directly to GitHub Sponsors, so PayPal is equally reachable
- No GitHub account required for PayPal path
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Migration Guide step 1:
- Add 'Download pre-built binary' as the first option (no Go required)
- Add install-script option for Raspberry Pi / on-device deployments
- Move 'go install' to last (developer option)
- Add data/ directory callout: single directory to back up for a full restore
SoundTouch Service guide:
- Mention RadioBrowser alongside TuneIn in the BMX section
- Add soundtouch-web TuneIn search screenshot
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous link aimed at a Go-developer getting-started page. Most
users are not Go developers — they want to migrate their speakers.
MIGRATION-GUIDE is the right first destination.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Covers what AfterTouch delivers today: migration (existing account and
factory-reset paths), marge+bmx replacement, TuneIn+RadioBrowser, Spotify,
presets, ST-10 stereo pairing, soundtouch-cli automation, soundtouch-web
browser UI, and the three installation options (on-device, local host /
Raspberry Pi Zero 2W, cloud/VPS).
Includes screenshot of the soundtouch-web UI (Spotify playback, presets,
sources, zone management).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- blog/_index.md: add introductory sentence to the News & Updates index
- .claude/commands/blog-update.md: project skill that drafts a monthly
update post from git history and opens a draft PR for review
- .gitignore: .claude/* + !.claude/commands/ so the skill is tracked
while session state (settings.local.json, worktrees/) stays ignored
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Enable navbar logo (favicon-braille.svg, 24×24)
- Override navbar-title partial to add 'Bose SoundTouch Toolkit' subtitle
- Add favicon.svg to static root (picked up by Hextra head automatically)
- Custom footer: sponsor link (left) + copyright (right) in a single row
- i18n/en.yaml: copyright text with link to github.com/gesellix
- hugo.toml: blog list sorted by date desc, tags enabled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three links in pkg/service/handlers/web/index.html still pointed to
the old Jekyll URL structure (/guides/FOO.html). The docs site moved
to Hugo+Hextra; correct URLs now include /docs/ and drop the .html
extension in favour of a trailing slash.
MIGRATION-SAFETY.html → docs/guides/MIGRATION-SAFETY/
SURVIVAL-GUIDE.html → docs/guides/SURVIVAL-GUIDE/
CLI-REFERENCE.html → docs/guides/CLI-REFERENCE/
The GitHub blob links in script.js and the hostname-resolution warning
in index.html point to source Markdown files and remain valid.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixes CodeQL go/log-injection alerts in the final batch of packages.
New logutil.go helpers: pkg/client, pkg/testutils/amazon,
pkg/testutils/spotify, cmd/soundtouch-service, cmd/soundtouch-web,
cmd/dummy-speaker, cmd/mdns-scanner.
pkg/discovery/logger.go: added sanitizeLog and a nil-safe
remoteAddrString helper to the existing file (alongside logVerbose).
Call sites wrapped across 11 files — device IDs, source types,
hostnames, IPs, interface names, URLs, service names, HTTP method/form
values, WebSocket URLs and payloads, TLS SNI names, remote addresses.
No behaviour change. golangci-lint and make check pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
autobuild is a black box — if it fails for any reason (CGO/libpcap
timing, module cache, etc.) no SARIF gets uploaded and GitHub reports
'1 configuration not found: /language:go' on the PR.
Switching to build-mode: manual with an explicit 'go build ./...'
step placed after CodeQL init (so the build is traced) gives us a
deterministic, visible build step. libpcap-dev is still installed
before init so the CGO dependency is satisfied.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixes CodeQL go/log-injection alerts in the handlers package.
Adds pkg/service/handlers/logutil.go with a package-private
sanitizeLog helper that strips \n and \r from strings before they
reach log call sites. Values from speakers, HTTP requests, and
external APIs (device IDs, account IDs, IP addresses, speaker names,
OAuth user IDs/emails, station IDs, URL paths, user-agent strings)
may contain attacker-controlled newlines.
Wraps all external-data string arguments across 12 files:
handlers_account_mgmt.go, handlers_alexa.go, handlers_bmx_orion.go,
handlers_bmx_siriusxm.go, handlers_bmx_tunein.go, handlers_catchall.go,
handlers_export.go, handlers_marge.go, handlers_mgmt.go,
handlers_oauth.go, origin_middleware.go, server.go.
No behaviour change — purely a logging concern. make check passes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two Python scripts are tracked in the repo (scripts/convert_mitm_script.py,
scripts/patch-stockholm-bridge.py). The original GitHub-generated codeql.yml
included language:python; our adapted version dropped it unintentionally.
Restores parity with what GitHub auto-detected.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes CodeQL alerts 280 and 281 (go/unhandled-writable-file-close).
scripts/extract-ws/main.go: change bare 'defer f.Close()' to
'defer func() { _ = f.Close() }()' — function returns void, silent
discard is the correct pattern (matches existing '_, _ = w.Write()'
usage elsewhere).
pkg/service/certmanager/certmanager.go: sequence encode + close for
both the cert file and the key file, checking both errors. This also
fixes resource leaks on the pem.Encode error path (file was previously
left open when encode failed). Matches the established pattern in
handlers_export.go (tw.Close / gz.Close).
.gitignore: exclude CODE-SCANNING-NOTES.md (local working notes;
will be added to VCS once the scanning sweep is complete and the
notes are stable).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
codeql.yml (GitHub's Advanced Setup template) adapted for this repo:
- Pin action SHAs (checkout v6.0.2, codeql-action v4.36.0)
- Drop python from the language matrix (no Python in this repo)
- Add conditional libpcap install for the Go matrix entry
(gopacket requires libpcap-dev; autobuild fails without it)
- Wire in .github/codeql-config.yml for Go (path filters, query
selection); other languages get an empty config-file value
- Remove boilerplate template comments and the unused manual-build step
- Fix runner expression (no swift, so the macos-latest conditional
is unnecessary; always ubuntu-latest)
security.yml:
- Remove codeql-analysis job (now handled by codeql.yml)
- Drop codeql-analysis from security-summary needs, summary echo,
and fail condition
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
nancy was installed from github.com/sonatypecommunity/nancy
which is a non-existent package (correct org is
sonatype-nexus-community). nancy v2.0.0 also has replace-
directive issues that break go install.
govulncheck already covers Go CVE scanning via the official
Go vulnerability database, making nancy redundant here.
The nancy-report.json artifact referenced in the upload step
was never actually produced by the pipeline anyway.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The static-analysis CI job runs 'staticcheck ./...' directly.
Standalone staticcheck uses //lint:ignore directives, not the
//nolint comments that golangci-lint reads.
SA1008 (non-canonical header key) on three ETag lines:
handlers_etag_test.go:228, :270
mac_mapping_integration_test.go:226
ETag must stay non-canonical — Bose speakers reject 'Etag'.
Existing //nolint:canonicalheader / //nolint:staticcheck comments
remain for golangci-lint; //lint:ignore SA1008 is added for the
standalone staticcheck invocation.
U1000 (unused function) on writeBMXUnauthorized in handlers_bmx.go:
The auth gate is temporarily disabled; the helper is kept as a
restore point. //lint:ignore U1000 replaces //nolint:unused because
golangci-lint's staticcheck runner also honours //lint:ignore,
making //nolint:unused redundant (nolintlint would complain).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The links used /guides/ and /reference/ directly, missing the
/docs/ sub-path that Hugo places all content under. They also
had a .html suffix which Hugo's clean URL mode does not produce.
Fix: /Bose-SoundTouch/guides/FOO.html → /Bose-SoundTouch/docs/guides/FOO/
/Bose-SoundTouch/reference/FOO.html → /Bose-SoundTouch/docs/reference/FOO/
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The hextra/hero-button and hextra/feature-card shortcodes call
Hugo's relURL on any link starting with '/'. relURL prepends the
baseURL sub-path — but the deployed site was producing /docs/...
instead of /Bose-SoundTouch/docs/..., meaning relURL was seeing
a baseURL with no sub-path (likely just the domain).
Rather than depend on relURL working correctly at build time,
remove the leading slash from all four internal links. Bare paths
are emitted verbatim by the shortcode and are resolved by the
browser relative to the page's own URL (/Bose-SoundTouch/ on
GitHub Pages, / on local dev) — correct in both environments.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The config failed with:
MismatchedInputException "Cannot deserialize value of type
java.lang.String from Array value"
Root causes removed:
- 'uses' in a queries entry must be a string, not an array.
The 'go-security-extra' block used uses: [list] which is invalid.
All the listed queries are already covered by security-extended
and security-and-quality, so the block is simply removed.
- 'reason' is not a valid key under query-filters entries.
Removed from both exclude blocks (one entry had no other
valid keys so the whole exclude was dropped too).
- 'query-config' is not a CodeQL config section at all. Removed.
- 'packs' duplicated codeql/go-queries with an invalid semver
range (@~0.0.0). Removed the section entirely; the queries
package is already loaded transitively by the suites above.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
actions/configure-pages v5+ exports HUGO_BASEURL automatically,
which overrides hugo.toml. By adding id: pages to the step and
passing --baseURL explicitly, we get the correct sub-path
(https://gesellix.github.io/Bose-SoundTouch/) on GitHub Pages
while local dev (docker-compose.docs.yml already passes --baseURL /)
continues to work unchanged.
Also change hugo.toml baseURL to '/' as the neutral local default.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous SHA 75d2a84... did not correspond to any real commit in
peaceiris/actions-hugo (there is no v3.0.0 release). Update to the
correct v3.2.1 SHA.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add entries for docs/, examples/navigation-station-demo/, and
examples/preset-management/ alongside the existing root entry.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Extend image ignorePattern in markdown-link-check.json to cover all
/images/*.png (covers ui-settings, ui-devices, ui-sync, ui-migration,
speaker-ap-wifi-setup that live under docs/static/images/ but are
referenced as absolute /images/ paths in Markdown)
- Fix appendix cross-section links: add ../ prefix to guides/, reference/,
and analysis/ paths in PRESET-QUICKSTART, SOUNDTOUCH-SERVICE-ANNOUNCEMENT,
CONTENT-SELECTION-IMPLEMENTATION, DEVICE-LOGGING, NAVIGATION-GUIDE,
PARITY-SOUNDCORK, and CLAUDE.md
- Convert ../examples/* relative links in appendix to GitHub URLs (the
examples/ dir is at repo root, not under docs/content/)
- Fix CLAUDE.md in appendix: archive/PLAN.md → ../../../archive/PLAN.md;
remove dead PDF link
- Fix TROUBLESHOOTING.md: ../DEVICE-LOGGING.md → ../appendix/DEVICE-LOGGING.md
- Fix CAPTURE-DEVICE-PAIRING.md: ../DEVICE-SETUP.md → ../appendix/DEVICE-SETUP.md
- Fix RASPBERRY-PI.md: remove accidental ../ prefix from GitHub URL
- Fix CONTRIBUTING.md: update docs/reference/ and docs/PROJECT-PATTERNS.md
to their new paths under docs/content/docs/
- Fix README.md: update deployment overview link to new path
- Fix BASS-CONTROLS.md and SOURCE-SELECTION.md: convert ../../pkg/models/
relative links to GitHub URLs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Files in cmd/ examples/ scripts/ referenced docs/guides/ and docs/reference/
which moved to docs/content/docs/guides/ and docs/content/docs/reference/.
A few links to loose files at the docs/ root were updated to their new
location under docs/content/docs/appendix/.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add docs/go.sum (Hextra v0.12.3 checksums) produced by hugo mod tidy.
Update docs/go.mod with the resolved module version.
Ignore docs/.hugo_build.lock, docs/public/, and docs/resources/ —
all are generated by Hugo locally and not needed in the repo.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace docs/_config.yml + docs/SUMMARY.md with Hugo + Hextra theme.
Move all content into docs/content/, images into docs/static/images/.
Update docs_consistency_test.go to check Hugo front matter instead of
SUMMARY.md inclusion. Update CI workflow and screenshot script paths.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bose shut down SoundTouch cloud services on 2026-05-06. Update the three
main user-facing docs to reflect that the shutdown has happened:
- README.md: rename section, rewrite opening paragraph, reframe the two
getting-started scenarios as 'already migrated' vs 'starting fresh'.
- SURVIVAL-GUIDE.md: past-tense title and opening; remove duplicate
Scenario B heading (copy-paste leftover from earlier edit); remove the
table of redirect methods and TLS note that belonged to the deleted
pre-shutdown Scenario B stub.
- MIGRATION-GUIDE.md: remove the 'cloud is still running' note from the
Sync step; fix the post-migration backup blurb to reference
soundtouch-backup rather than a non-existent Step 4 tar.gz.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- DEVICE-LOCAL-INSTALL.md: old path at docs/ root → docs/architecture/
- Add Deployment Overview + three walkthrough pages under User Guides
- Add Architecture section for the planning doc
Data Sync requires AfterTouch to reach the speaker outbound, which
fails when AfterTouch is running in the cloud. Replace with the
correct three-step workaround from wimdeblauwe (discussion #295):
1. Manually create Sources.xml in the server's data volume with the
default source set (AUX, LOCAL_INTERNET_RADIO, TUNEIN, RADIO_BROWSER)
2. Send a sourcesUpdated notification to the speaker from a local machine
3. Power-cycle the speaker (CLI reboot is insufficient; firmware only
activates new source types at boot)
Add a clear note that Data Sync is not available from cloud deployments.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
'Each speaker needs its own install' is only true when the firmware
binds port 8000 to loopback (older devices, issue #196). Devices that
expose the port on the LAN can run one on-device AfterTouch and point
other LAN speakers at it — same as a Raspberry Pi. Qualify the cell
accordingly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- docs/guides/CLOUD-DEPLOY-WALKTHROUGH.md (new)
Step-by-step for deploying AfterTouch on a remote VPS:
Docker Compose + DISCOVERY_ENABLED=false, Coolify config from
wimdeblauwe's field report (discussion #295), CLI-driven speaker
migration (soundtouch-cli setup migrate/reboot from the local
machine), TuneIn source registration gotcha and fix, preset setup,
security warning about the unauthenticated Marge API, and the
'what breaks if the server goes offline' answer.
- docs/guides/DEPLOYMENT-OVERVIEW.md: expand from 2 to 3 options
(Local external host / Cloud VPS / On-device); update the
comparison table with the cloud-specific columns (HTTPS needed,
CLI migration, discovery disabled); link to the new walkthrough
and to discussion #295 as the community field report.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Problem: the existing docs gave no clear path for non-technical users.
- GETTING-STARTED.md is a Go library developer guide
- RASPBERRY-PI.md stops after the service is running (no migration or preset steps)
- DEVICE-LOCAL-INSTALL.md is an architectural analysis that confused installation intent
- No single page helped a user choose between external-host vs on-device
Changes:
- docs/DEVICE-LOCAL-INSTALL.md → docs/architecture/DEVICE-LOCAL-INSTALL.md
Move the planning/architecture doc out of the user-visible guides root;
add a redirect banner pointing to the user guides
- docs/guides/DEPLOYMENT-OVERVIEW.md (new)
Navigation landing page: comparison table (external host vs on-device),
links to user-friendly walkthrough + technical reference for each scenario
- docs/guides/EXTERNAL-HOST-WALKTHROUGH.md (new)
Step-by-step for Raspberry Pi / any always-on host: install, discover
speaker, run migration wizard, Health QuickFix, verify pairing, set
presets via UI or CLI — the post-install steps that RASPBERRY-PI.md
did not cover
- docs/guides/RASPBERRY-PI.md: cross-link to full walkthrough and overview
- README.md: replace the one-liner "see On-Device Installer" with a
pointer to the Deployment Overview so both paths are equally visible
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- aftertouch init script: stop) now waits up to 15 s for SIGTERM
to take effect, then escalates to SIGKILL; prevents stale daemon
processes after '/etc/init.d/aftertouch stop' returns (weissigera's
workaround was manual 'killall aftertouch-service')
- install.sh: add --version / -v CLI flag so the version to install
can be passed as a command-line argument in addition to the VERSION
env var; document the trade-off of the hard-coded default in a
comment; update scripts/on-device-install/README.md with concrete
usage examples for env-override, CLI flag, and rollback tip
- docs/guides/ON-DEVICE-INSTALL-WALKTHROUGH.md: 10-step runbook
derived from weissigera's field-tested procedure (issue #329
comment #4521280831): SSH connection, storage cleanup, install via
install.sh, reboot, SSH tunnel, Health QuickFix, pairing
verification, soundtouch-cli download, custom-radio preset setup,
and final verification; troubleshooting table at the end
Closes#329 (remaining two tasks)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Before overwriting the binary, read its version via --version and save
a copy as aftertouch-service.<version>.backup (falls back to a timestamp
if the flag is absent or the build is a dev build).
After the new binary is in place, delete every older *.backup, *.old,
and *.new artefact in INSTALL_DIR. /mnt/nv on SoundTouch SCM modules
has only tens of MB free; accumulating one ~12 MB backup per upgrade
quickly causes 'no space left on device' on the next download.
Only the backup created in this run (the <current-release>-1 binary) is
kept, giving a single one-step rollback point without wasting disk.
Relates to #329 (on-device install friction reported by weissigera).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When handleDiscoveredDevice calls MoveDevice and the target device
directory already exists (pre-existing duplicate state), os.Rename
fails with ENOTEMPTY/EEXIST leaving the stale source account entry
on disk. Because SaveDeviceInfo has just written fresh data under
accountID, it is safe to unconditionally remove the stale source
entry afterward — RemoveDevice returns nil when the path is already
gone (successful rename), so this is a no-op in the happy path and
a cleanup in the failure path.
Adds TestHandleDiscoveredDevice_CrossAccountMigration_TargetExists
which seeds a device under two real accounts (old sorts alphabetically
first so findExistingDeviceInfoByDeviceID picks it as storedAccount),
triggers discovery with the new account as MargeAccountUUID, and
asserts that after the cycle only the new account entry exists.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Exercises the branch in handleDiscoveredDevice where a device's live
MargeAccountUUID differs from its stored account. The test:
- seeds a device + presets under 'default'
- mocks /info to report a different margeAccountUUID ('8637922')
- calls handleDiscoveredDevice
- asserts the device is now stored under the new account with the live name
- asserts the old 'default' entry is gone
- asserts presets survived the MoveDevice rename
- asserts ListAllDevices returns exactly one entry (no duplicates)
Closes the server-level gap noted during PR #348 review.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Health check (checks_stale_internet_radio.go): detects stub INTERNET_RADIO
sources (empty credentials) left on devices initialised before the stub was
removed from the default source list. Quick-fix removes by ID; skips any
INTERNET_RADIO source that has real credentials.
Datastore: DeleteSourceByID and DeleteSourceByType (uniqueness-guarded).
API: DELETE /setup/sources/{account}/{device}/{sourceID}
CLI — two new commands:
soundtouch-cli cloud source remove --service-url ... --account ... --device ... [--id 10002 | --type INTERNET_RADIO]
Talks to AfterTouch (service side). --type resolves to canonical ID
locally; fails for unknown types.
soundtouch-cli source notify-updated --host <speaker-ip>
Talks to the speaker directly. Fetches device ID from /info, then
POSTs sourcesUpdated to :8090/notification so the speaker re-fetches
its source list immediately.
CloudCommonFlags (--service-url / AFTERTOUCH_URL) mirrors CommonFlags
(--host) for AfterTouch-facing command groups.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add getInitialSources() that excludes the legacy INTERNET_RADIO (10002)
provider from newly-created device Sources.xml files. GetDefaultSources()
retains the entry for backward-compatible canonicalisation of existing
devices and cloud-level account responses.
Fix mergeDefaultSources() to rebuild the merged list in canonical ID
order (defaults first, using stored credentials when present, then
custom sources such as Spotify). This prevents INTERNET_RADIO from
landing at the end of the cloud /sources response when a device's
Sources.xml was created without it.
Drop the two verbose search-loop log lines from resolvePresetSource.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The guard was accidentally placed in HandlePlayRadioBrowser instead of
HandleDevicePlay in the initial fix commit, then removed from there by
the build-fix commit — leaving HandleDevicePlay with no guard at all.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous edit accidentally inserted the TUNEIN placeholder guard
into HandlePlayRadioBrowser, which uses a different req struct without
SourceAccount/Source fields, breaking the build.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Speakers echo back the source name as SourceAccount when no real
credential is set (e.g. SourceAccount="TUNEIN" for a TUNEIN source).
HandleDevicePlay was forwarding this verbatim, causing the speaker to
try authenticating with the source name as a TuneIn account and
returning INVALID_SOURCE.
Clear SourceAccount when it equals Source; preserve it when it differs
(real credentials such as Spotify or STORED_MUSIC UUIDs).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TuneIn's profiles API caps initial results at ~10 per container (Stations,
Shows, etc.) and exposes a Pivots.More.Url cursor for the remainder. This
change wires that cursor through the stack so users can load additional
results without leaving the search view.
- tuneInSearchSection now extracts Pivots.More.Url as bmx_next when
itemToken is present; absent for containers already at their limit
- TuneInSearchNext fetches the cursor URL, which returns a flat Items[]
(not nested containers), and maps Station/Program/Topic items using
the existing play/profile builders
- New GET /v1/search/next and /api/tunein/search/next endpoints with
matching handlers in both service paths
- TuneInBrowser: flat items state replaced with per-section sections
state; each section shows a header label and a Load more button when
a cursor is available; browse/navigate mode is unaffected
Relates to #336.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When `discovery_enabled` is false the page no longer fires a discovery
scan on load. Both DOMContentLoaded handlers now await fetchSettings()
and gate triggerDiscovery() on the returned flag — default true keeps
existing behaviour for installations that never touched the setting.
Also renames the UI label from "Enable Automated Discovery" to
"Enable Periodic Discovery" to make clear the checkbox controls the
background timer, not the manual trigger button or IP-entry form.
Relates to #269
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
encoding/xml is case-sensitive, so Presets.xml files written by older
AfterTouch versions using <ContentItem> (capital C) had all source,
location, and type attributes silently dropped on read. Every preset for
such a device had empty fields, causing mapPresetsToFullResponse to skip
them all — the speaker received /full with zero presets and stored nothing.
Fix: normalise <ContentItem> → <contentItem> before unmarshaling in the
new readPresetsLocked helper. If normalisation was needed, GetPresets
rewrites the file in canonical form after releasing the read lock, so the
issue self-heals on first service start with no manual intervention.
Diagnosed via the i218 encrypted diagnostic export (device 304511B46CBC,
ST30 Master Bedroom): health check speaker_presets_count reported
"Speaker shows 0 preset slot(s); service Presets.xml has 6", and the
service log showed six [Marge] /full: skipping preset N — source ""
messages per /full call.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
#337's first commit added the OAuth-derivation to the DNS interceptor
but missed the served TLS certificate. With a serverURL of
`http://mac.fritz.box:8000` the cert SAN list covered `mac.fritz.box`
but not `macoauth.fritz.box`, so the speaker would resolve the OAuth
host correctly (via the new DNS hijack) and then immediately fail the
TLS handshake — Spotify / Amazon Music token refresh dies before
reaching AfterTouch.
getDomains now calls discovery.DeriveOAuthHostnames(serverURL) and
discovery.DeriveOAuthHostnames(httpsServerURL), feeding the derived
names into the SAN map alongside the existing entries. IP-based
serverURLs continue to produce no derivation (the OAuth construction
is unrecoverable for them — see the existing oauth_target_reachable
health check).
Tests in cmd/soundtouch-service/main_test.go lock in:
- Hostname serverURL → derived OAuth variant present in SAN list.
- IP serverURL → no malformed `192oauth.…` entry leaks in.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The markdown-link-check CI step caught five stale links in
docs/README.md's Concept Documentation section pointing at files
the previous commit moved into docs/archive/. Replaced with a
pointer to SUMMARY.md's Concepts section + a short curated list
of the currently-relevant docs (Spotify Overview, Spotify OAuth,
Amazon Music OAuth, Encrypted Export, Request Recording). The
archived planning artefacts get a single line acknowledging
their existence under docs/archive/.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The speaker firmware constructs the OAuth host by appending "oauth" to
the first label of the configured streaming hostname (aftertouch.lan
→ aftertouchoauth.lan, used by both Spotify and Amazon Music token
refresh). AfterTouch's DNS server previously only hijacked the
hardcoded list of Bose hostnames, so operators self-hosting at a
custom hostname had to add the OAuth alias themselves — and the
amazon-music-oauth.md / spotify-overview.md docs incorrectly
claimed the DNS server handled it automatically.
ofthesun9 (#337) caught this via the worst variant: IP-based
serverURL (192.168.0.30 → 192oauth.168.0.30), which is a malformed
hostname no DNS resolver can answer for. There is no clean DNS
workaround for the IP case — the operator must use a hostname.
Three changes:
- pkg/discovery/dns.go DeriveOAuthHostnames parses the configured
serverURL, derives <first-label>oauth.<rest> when the host is a
hostname (not IP), and adds it to the DNSDiscovery hijack list. IP
serverURLs deliberately yield no derivation — the malformed name
isn't worth handling and the new health check surfaces the trap.
- New checks_oauth_target health check fires a Warning when serverURL
is an IP literal, with a concrete example of the malformed name
(`192oauth.168.0.30`) and a ManualCommand pointing at the switch.
- amazon-music-oauth.md and spotify-overview.md rewritten: drop the
false "automatic" claim, document the three resolution paths
(AfterTouch DNS + speaker resolves via it / external LAN DNS /
per-speaker /etc/hosts), and explicitly flag IP-based --server-url
as incompatible with OAuth on either provider.
Tests cover the derivation matrix (hostname / IPv4 / IPv6 / single
label / empty / garbage URL), shouldIntercept's new behaviour
(derived host hit, base host not auto-hijacked, case-insensitive),
the health check's four states, and the malformed-host helper.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The TestDocsConsistency walk only iterated [".", "guides", "reference",
"analysis"] — concepts/ was silently invisible, which is why
amazon-music-oauth.md slipped into the tree without a SUMMARY entry.
Refactored to walk the entire docs/ tree, with a small dirsToSkip
allow-list (_includes, archive, diagrams, images) for asset trees.
New top-level narrative directories are picked up automatically;
only asset dirs need an explicit entry.
The wider walk surfaced six previously-hidden concepts/* files. Five
older planning artefacts ("Enhanced State Management System",
"Upstream Bose Service Simulation") moved into docs/archive/ where
the dirsToSkip already excludes them; concepts/README.md renamed to
upstream-service-simulation-overview.md since "README.md" inside
archive/ would be misleading. Spotify Overview and Amazon Music
OAuth are user-facing narrative docs and are now linked under
Concepts in SUMMARY.md.
Note: concepts/streborn-patterns.md is internal review notes (its
own opening line says so) and is currently unlinked from SUMMARY.md;
will be handled separately by the maintainer.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Discovery cycles emit one line per UPnP M-SEARCH header, one per
parsed response, and one per enrichment step — by default. A typical
service-binary cycle prints ~50–80 lines for a 3-speaker LAN. Most
operators want a startup-and-summary view; the per-packet trace is
only useful for debugging.
- New SetVerbose/IsVerbose/logVerbose helpers in pkg/discovery (atomic
bool, zero-value off).
- Chatty log.Printf calls in upnp.go and mdns.go demoted to logVerbose:
per-header dumps, per-response dumps, per-device enrichment steps,
M-SEARCH details, read-deadline / cancel-context noise.
- Kept at default level: discovery start ("Starting SSDP discovery
for…"), end ("Discovery completed. Processed N responses, found N
unique devices" + per-device summary), warnings ("Configured
interface not found", "Failed to fetch device description", …), and
the new "Rejecting non-Bose device" classifier.
- cmd/soundtouch-cli/discover devices grew a --verbose / -v flag that
flips the package toggle on; the service binary leaves it at the
zero value.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Four small, independent improvements bundled into one cut:
1. Restrict device discovery to SoundTouch-family services (#269/#359).
- mDNS now queries all three SoundTouch service-type variants in
parallel (_soundtouch._tcp, _bose-soundtouch._tcp, _soundtouchstick._tcp)
and deduplicates results by host:port. mDNS has no native wildcard
for service types, so we fan out one query per variant.
- UPnP/SSDP M-SEARCH receives a manufacturer/modelName check after
fetching the device description: devices whose manufacturer doesn't
contain "bose" AND whose model doesn't contain "soundtouch" are
rejected. Closes the loop on NorbertBauer's diagnostic bundle that
showed a Dreambox dm920 and Onkyo HT-R695 living under the default
account because they answered our generic MediaRenderer:1 probe.
2. New health check: default-account-contains-non-Bose-devices (#269).
Walks devices keyed under data/accounts/default/devices/, flags any
whose ProductCode/Name doesn't look SoundTouch, and offers an Evict
QuickFix. Bose devices still in default (legitimate pre-pair) are
intentionally ignored — that's the consistency check's domain.
3. Clipboard fallback for Copy buttons (#355). The two health-tab Copy
buttons used navigator.clipboard.writeText, which requires a secure
context. Over plain HTTP at a LAN IP the browser blocks it silently
and the button shows "Copy failed". New copyTextToClipboard helper
tries the modern API first, falls back to document.execCommand("copy")
via an off-screen textarea.
4. Web UI static-asset cache-busting (#345). dekiesel needed Ctrl+F5 to
see the v0.89 Download button after upgrade. The root HTML now
carries a ?v=<hash> query string on /web/js/script.js and
/web/css/style.css references. Hash is sha256 over the embedded asset
bodies, truncated to 12 hex chars — stable per binary, changes when
the assets change.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the findings list grows the diagnostic-export subsection got
pushed below the visible viewport. Moving it above the findings list
(but below the Refresh header and description) keeps the Download
button in reach regardless of how many checks fire.
Wrapped in a subtle gray box to visually distinguish it from the
checks themselves.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously the Download button sat at the top-right with the Refresh
button, while the "What does the report contain?" details block lived
below the health-checks description paragraph — visually separated by
the description and an entire section's worth of layout.
Now the diagnostic export lives in its own subsection at the bottom of
the Health tab, with the button, a one-line tagline, the details
block, and the post-download status indicator all adjacent. The
header keeps just Refresh, which controls the health-checks view it
sits next to.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previous text explained how the merge works but didn't give operators a
clear signal for when to act. New structure leads with:
- "When you need this": rarely; symptoms a user actually sees (presets
reset, BoseApp offline) instead of a syslog string most users won't
consult.
- "How to tell": open the Health tab, look for speaker_marge_url; if
clean, leave this empty.
- "Manual path": only after the user has decided they need it.
Adds a small, always-visible hint below the label that points to the
Health tab — most operators won't expand the ⓘ panel.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Operators who deploy AfterTouch on an IP-only host (no DNS hostname) and
who get a speaker_marge_url health warning previously had to SSH in, edit
their systemd unit or docker-compose, add --tls-extra-host, and restart.
The fix is now reachable from the UI:
- datastore.Settings gains TLSExtraHosts []string. At startup
applyPersistedSettings merges CLI/env values (still authoritative)
with persisted ones, deduplicating while preserving order.
- /setup/settings (GET) exposes tls_extra_hosts (editable list) and
tls_san_hosts (the full effective SAN list, read-only).
- /setup/settings (POST) accepts tls_extra_hosts (*[]string so callers
can distinguish "field omitted" from "explicitly empty").
- Settings tab grows a "TLS extra hosts" textarea + an info panel
explaining the restart-required dance.
- speaker_marge_url emits a QuickFix labelled "Add <host> to TLS hosts"
alongside the existing CLI manual command. The fix re-probes the
device's /info, extracts the margeURL host, and appends it to the
persisted list — race-safe against stale findings.
- HTTPS-SETUP.md documents both paths.
Tests cover: merge dedup + ordering + whitespace, the new QuickFix
emission shape, and the margeURL host extraction across HTTPS/HTTP/bare
input forms.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The :443 reachability preflight was emitting a WARN on every deployment
where AfterTouch's configured --server-url is HTTP (not HTTPS), even
when speakers were migrated to that HTTP URL and never connect to :443.
Operators reproduced this on #218 (CTonyPeterson) and #344
(california444) — both saw the warning even though their setups had no
need for iptables port forwarding, and CTonyPeterson followed the
recommended iptables OUTPUT rule which then caught his host's own
outbound HTTPS traffic and broke `go install` and his browser.
Two changes:
- Probe443Result gains NotApplicable + Reason. Check443Reachability
returns the NotApplicable verdict when the parsed serverURL scheme is
http. The settings UI renders an ℹ️ info badge with the reason instead
of a red ✗.
- FormatPreflightGuidance grows a one-line caveat about the iptables
OUTPUT chain: it catches all outbound :443 on the host, including
browsers / go install / apt-get, which is rarely what the operator
wants.
HTTPS-SETUP.md gains the same caveat plus a section documenting the
new not-applicable verdict.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- setup remote-services subcommand: enables (default) or removes
(--remove) the remote_services SSH-enablement marker via SSH, targeting
persistent locations (/etc or /mnt/nv) before the volatile /tmp fallback
- setup plan now includes a "persist remote_services" step when the marker
is only in /tmp (would be lost on next reboot, breaking SSH mid-migration)
- setup plan state header shows a [⚠] line when remote_services is
enabled but not persistent
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the single-sided requireAfterTouchDNSReachable with runDNSPreflight
that probes both the CLI machine and the speaker (via SSH nslookup) in
parallel, then renders a two-row table when results differ.
The speaker's perspective is authoritative: a CLI-only failure no longer
blocks the migration (the speaker may reach the DNS listener via a network
path the CLI host cannot). Migration is only aborted when the speaker itself
definitively cannot reach AfterTouch's DNS listener.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- isXMLMigrated and isResolvConfMigrated now guard against empty hostname
(Go's strings.Contains(s, "") is always true, causing any speaker to
appear migrated when --service-url has a malformed single-slash scheme)
- renderPlanSteps message no longer claims "and paired" when --include-pair=false
- validateServiceURL rejects malformed service URLs early with a hint
(e.g. "did you mean https://soundtouch.fritz.box?")
- Generated plan-step commands move --host before the subcommand name
(urfave/cli/v2 requires global flags before the first subcommand token)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add a <details> block listing what the encrypted archive contains so
reporters know what they're sharing before clicking. After a successful
download, show two submission options with email preferred:
aftertouch-support@gesellix.net (mailto link with pre-filled subject and
filename) or a GitHub issue with the file renamed to <filename>.txt (GitHub
blocks .age uploads).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a "Download diagnostic report" button on the Health tab that
produces an age-encrypted .age file the user can attach to a GitHub
issue without exposing sensitive data.
Archive contents (tar.gz, then age-encrypted with the maintainer's
SSH ed25519 public key):
- diagnostic.json structured health/device summary (no secrets)
- datastore/…/*.xml raw on-disk XML verbatim for diff vs HTTP
- http/service/… live service HTTP responses per account/device
- http/speaker/… live speaker API responses (port 8090)
- ssh/speaker/… CA bundles + logread (last 20 min, 127.0.0.1
filtered) + dmesg fetched via SSH
- system/ca.pem service CA cert
- system/resolv.conf host DNS resolver config
- settings.json service settings (OAuth secrets redacted)
- env.txt filtered process environment
- logs/service.txt in-memory service log buffer
Supporting tooling:
- scripts/setup-diagnostic-key.sh one-time SSH key-pair generation
- scripts/decrypt-diagnostic.go go run helper for maintainer decryption
- keys/public/diagnostic.pub committed public key (matches github.com/gesellix.keys)
- docs/DIAGNOSTIC-EXPORT.md maintainer setup + user workflow guide
- docs/concepts/ENCRYPTED-EXPORT.md research notes and architecture rationale
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes the loop on the empty-<margeAccountUUID> finding from
RegisterSpeakerInfoReachable. Operators in #329 quoted that finding
verbatim and asked "What is the recommended way to complete pairing?"
— the framework detected the condition but offered no in-UI recourse.
The QuickFix completes pairing in-place by dispatching through
setup.Manager.PairAccount, which tries HTTP /setMargeAccount first
and falls back to telnet `envswitch accountid set` — same code path
the existing POST /setup/pair-account/{deviceId} handler uses.
Account ID is picked at finding-time when a real (7-digit) account
directory already contains this device on disk (typical scenario:
AfterTouch remembers a previous pairing the speaker forgot). When
no such account exists, the executor generates a fresh 7-digit ID
via setup.GenerateAccountID at click time. Either way, the chosen
ID is named in the Confirm dialog and the CLI ManualCommand
fallback so the operator can see what's about to happen.
Architecturally: the FixID constant lives in the health package
alongside the check that emits the finding, but the executor is
registered from handlers/server.go where setup.Manager is
available. This keeps the health package's transitive dep surface
small (the boundary comment near speakerInfoXML deliberately
forbids importing setup, which would pull SSH/telnet/certmgr).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the same device appears under `default/` in two separate data dirs
(e.g. primary DataDir and the legacy st-go/data path), the first-seen entry
was kept unconditionally even when it had an empty name. A subsequent
default entry carrying a real name was silently dropped, causing name loss
in SyncFromAccountFull.
Addresses TestReproduceMissingName regression introduced by the
dedup-default-last change.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The orphan-account QuickFix used to rely solely on the operator's
manual log inspection ("Before deleting, verify the speaker isn't
currently PUTting to account X") plus the Confirm dialog. Adds a
defensive layer: the speaker itself answers "which account do I
belong to?" via :8090/info's <margeAccountUUID> element. Wire that
into both ends of the flow.
Detection (consistency check): on each scan we probe /info for each
device with a known IP. When the speaker answers, its
margeAccountUUID overrides the on-disk ListAllDevices guess, and the
finding's Details/Confirm copy quotes the speaker verbatim — "Speaker
/info reports margeAccountUUID=1111111; this directory (account
9569497) is stale because the speaker has stopped targeting it." If
the probe fails the wording falls back to the manual-verify hint.
Executor (deleteOrphanAccountEntry): re-probes /info before deleting
and refuses when the speaker reports target.Account as live. That
closes the race where the operator re-paired between scan and click.
Logs every successful probe + decision for auditability.
fetchSpeakerMargeAccount split into a URL-injectable variant so the
httptest-driven tests can verify the probe end-to-end without
hard-coding :8090 onto an unreachable address.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
og-gh's #343 reproducer is built-in radio sources sitting on
non-canonical IDs (the 2000001+i fallback that GetConfiguredSources
hands out when on-disk sources lack canonical IDs). After re-pair
churn, presets binding by <sourceid> end up rebound to whichever
source happened to get the colliding numeric ID — silently rewriting
e.g. a TUNEIN preset to RADIOPLAYER on the next /full fetch.
The strict-match commit (aa449fb) keeps that drift from corrupting
emission downstream, but the underlying Sources.xml is still wrong
and the operator has to either pull-from-speaker (online) or
hand-edit XML (tedious). This commit adds an offline QuickFix that
rewrites the source IDs in Sources.xml back to canonical
(TUNEIN→10004, INTERNET_RADIO→10002, LOCAL_INTERNET_RADIO→10003,
RADIO_BROWSER→10005) and updates every <sourceid> reference in
Presets.xml/Recents.xml in lockstep.
Skipped when the canonical ID is already in use by another source
(e.g. duplicate TUNEIN entries from manual XML editing) — collisions
need operator review. Idempotent: a second click is a no-op when
everything is already canonical.
The fix is reachable from the consistency check finding, gated by
the framework's standard Confirm dialog which enumerates the exact
ID rewrites before executing. No speaker contact required; the
speaker re-fetches /full on its own and picks up the new IDs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The orphan-account-entry finding (introduced in 0ac140f) currently just
points the operator at a copy-pasteable rm -rf command. Adds a
QuickFix button that does the same delete in-process after the
operator confirms via the standard health-framework Confirm dialog.
Findings are now one-per-(stale_account, device) pair so each delete
button targets exactly one directory. The Confirm copy spells out the
full path being removed and reminds the operator that the active
account isn't touched. The companion ManualCommands entry keeps the
shell-side rm available for operators who prefer to run it themselves.
deleteOrphanAccountEntry refuses on missing account/device, errors
explicitly when the directory was already cleaned up by hand, and
logs every successful removal so the action is auditable from the
service log.
The framework gates the click on Confirm — destructive operations
need operator consent per CLAUDE.md.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
User reported "we might have another issue with the account mapping"
after the prior commit only handled the default-vs-real case. The
backup at /backup/var_20260520_01 showed device A81B6A536A98 living
under four directories — accounts/9569497, accounts/default,
accounts/1111111, and the top-level default/ — only the third of
which currently receives the speaker's PUTs.
The authoritative "which account does this device belong to" signal
is the URL of the speaker's incoming PUT (per "speaker decides"),
which only the live handler observes. mtime is a proxy and can be
fooled by backup tools, manual touches, etc., so this commit drops
the mtime tiebreaker the previous attempt added.
Instead:
- ListAllDevices' dedup keeps default-deprioritisation (clear
placeholder semantics) but otherwise picks the first real account
encountered in stable alphabetical order. No heuristic guessing
among real accounts.
- New AllAccountsForDevice(deviceID) enumerates every on-disk
account directory containing the deviceID.
- The consistency check's orphan finding now lists every stale
account dir for each device, with the path the operator needs to
inspect and a pointer to the service log so they can verify which
account the speaker is actually targeting before deleting
anything.
We don't delete automatically — destructive filesystem actions need
explicit operator consent (CLAUDE.md "destructive actions" rule).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ListAllDevices used to let an entry under accounts/default/devices/<id>
replace the real-account entry for the same physical device whenever
the default-side DeviceInfo.xml had a non-empty <name>. The consistency
check then reported the device under "account default" even while the
speaker was happily POST/PUT'ing to its actually-paired account — the
operator saw "preset slot 1 present on speaker but missing from
service" for slots that very obviously did exist, just under the real
account they couldn't see.
The dedup now treats "default" as a fallback placeholder: sorts it to
the back of the iteration, and never lets it replace a real-account
entry. A default-only device (fresh discovery, never paired) is still
returned exactly as before.
Also adds an orphan-detection finding in the consistency check that
walks accounts/default/devices/ directly and flags entries whose
deviceID is also paired under a real account, with a copy-pasteable
rm -rf hint. We don't delete automatically — destructive filesystem
actions need explicit operator consent (CLAUDE.md).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The speaker's preset PUT carries only <sourceid> — no symbolic source
name — so we can't strict-match at write time the way we do on /full
emission. Adds a diagnostic-only inference from the preset's location
URL pattern (/v1/playback/station/sNNN -> TUNEIN, /playback/container/
-> SPOTIFY, /custom/v1/playback/ -> LOCAL_INTERNET_RADIO) and logs
when the inference disagrees with the bound source's SourceKeyType.
This is visibility, not enforcement: the binding still proceeds as
the speaker requested (per "speaker wins"). The log gives the operator
a concrete pointer — "the URL looks like TUNEIN but I bound to
RADIOPLAYER, your Sources.xml may be stale, try setup.syncSources" —
instead of leaving them to discover the drift via the consistency
check days later.
URL inference is deliberately fuzzy and one-way: it only triggers a
log when confident, returns "" otherwise, and never feeds the
binding decision. That keeps it from re-introducing the guesswork
the user pushed back on for the actual GH-343 fix.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The pre-fix marge.syncPresets / syncRecents path persisted the upstream
cloud's <source type="Audio"> attribute into ServicePreset.Source /
ServiceRecent.Source. That value doesn't match what the speaker writes
via its own /presets endpoint (which is the source of truth), and one
operator's consistency-check scan surfaced ~50 recent_mismatch findings
all tracing back to this single leak.
GetPresets / GetRecents now repair the leak on load: when persisted
Source is "Audio" (or empty) AND SourceID resolves in the current
Sources.xml, substitute the speaker-perspective SourceKeyType. The
repair fires only on the *leak signature* — when persisted Source
carries a non-leak symbolic value like "TUNEIN", we never touch it.
That asymmetry is load-bearing for GH-343: a TUNEIN preset whose
SourceID has been re-classified to RADIOPLAYER in Sources.xml stays
TUNEIN here. The speaker's previously-stored intent wins over a stale
current source-list entry — soundcork's blind matching_src.source_key_type
substitution is the silent rewrite we're protecting against.
Also:
- sourceKeyTypeFromFullSource now logs when the providerid isn't
canonical and we fall back to upstream Type, so future leak
signatures are visible instead of silent.
- Removes the loadServiceView workaround that resolved Source via
SourceID at consistency-check time — datastore now repairs at
the layer where every consumer benefits.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marge.syncPresets / syncRecents were writing the upstream cloud's
<source type="Audio"> attribute into ServicePreset.Source /
ServiceRecent.Source on disk. That's a protocol-level classification,
not the symbolic name the speaker itself uses (TUNEIN, INTERNET_RADIO,
…). The on-disk shape ended up disagreeing with what the speaker writes
via its own /presets endpoint, which IS the source of truth — and the
disagreement surfaced as cross-side mismatches in the new consistency
check (one user saw 30+ recent_mismatch findings, all "speaker source=X
vs service source=Audio").
Project the upstream FullResponseSource back to the speaker's
perspective at persist time via SourceProviderID lookup against
StaticProviders (the inverse of canonicalProviderIDByID). Falls back
to the upstream Type for unknown providerids so non-canonical sources
stay no-worse-than-before.
The consistency-check workaround in loadServiceView (which resolves
Source via SourceID lookup on read) stays in place to cover legacy
on-disk data written by the previous behaviour — that data only gets
cleaned up when the operator re-runs setup.syncPresets from the
speaker directly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
First operator run of the new consistency check surfaced both real bugs
and a lot of noise. This commit refines the report so the remaining
findings are actionable.
Real bugs fixed:
- loadServiceView now resolves preset/recent Source via SourceID lookup
against Sources.xml, instead of trusting the persisted Source field.
syncPresets / syncRecents in sync.go currently writes the upstream
FullResponseSource.Type ("Audio") into ServicePreset.Source, which
made every cross-side mismatch finding read "service source='Audio'".
Underlying syncPresets/Recents misfeature is a separate fix; the
consistency check stops being fooled by it.
- Duplicate-source dedup keyed by type+account, not just type.
SpotifyConnectUserName + SpotifyAlexaUserName, QPlay1UserName +
QPlay2UserName are legitimate sub-accounts of the same source type
and used to falsely trip duplicate_source warnings.
Noise removed:
- Cross-side source_mismatch comparison dropped. Speaker /sources
enumerates local I/O sources (AUX, BLUETOOTH, AIRPLAY, QPLAY, …),
service Sources.xml tracks credentialed streaming sources (TUNEIN,
INTERNET_RADIO, …). They legitimately don't overlap on most types,
so the asymmetry was pure noise.
- Internal-consistency check restricted to the service side. Streaming
sources are never in the speaker's /sources by design (they're
proxied through BMX), so a TUNEIN preset on the speaker always
looked "dangling" against speaker /sources.
- Service-only / speaker-only recent cascade collapsed into one
summary line when 5+ speaker recents are missing from service.
- New short-circuit: when the service has nothing (presets, recents,
sources all empty) for a device the speaker clearly has state for,
emit one "this device looks unsynced, click Sync" warning instead
of dozens of per-slot mismatches.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a new health check that surfaces preset / recent / sources
inconsistencies operators previously had to dig out by hand. For every
paired device, the check runs three analyses:
1. Service-side internal consistency. Verifies every Presets.xml and
Recents.xml entry's <sourceid> resolves to a Sources.xml entry, and
flags duplicate source-type entries (mapPresetsToFullResponse picks
the first match, so duplicates can mask GH-343-style cross-type
binds).
2. Speaker-side internal consistency. Same analysis applied to the
speaker's :8090 XML — catches the case where the speaker locally
knows a TUNEIN preset but the speaker's /sources list doesn't
advertise TuneIn (a #253-class trigger).
3. Cross-side comparison. Speaker vs service per slot / per recent /
per source type. A preset whose source attribute disagrees between
sides is flagged with both values in the detail — that's the
GH-343 footprint after a reboot, and now it shows up as a Finding
instead of a forum thread.
Speaker probes fail gracefully with a copy-pasteable curl block; the
service-side internal check still runs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GH-343: a TUNEIN preset surviving a reboot used to come back from /full
re-attributed to RADIOPLAYER because mapPresetsToFullResponse's step-1
exact-ID match accepted any source with the matching numeric ID,
regardless of what the preset originally claimed for its Source. The
speaker trusts /full as ground truth, so the local preset got its
source attribute silently rewritten.
Tighten step-1: refuse the bind when the preset's claimed Source and
the configured source's SourceKeyType disagree (both populated). The
existing step-2 type/account fallback then finds the right source, or
synthesise/skip handles the no-match case. The refusal is logged so
the cross-type collision is visible in service logs.
Same fix applied to findMatchingSourceForRecent.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SavePresets hard-coded isPresetable="true" on every persisted preset,
overwriting the speaker firmware's verdict. The speaker sets
isPresetable="false" for content it can't independently recall later
(notably Spotify Connect pushes from a phone — see GH-235); masking
that flag made the on-disk XML look valid while pressing the preset
on the speaker still did nothing, leaving users debugging a phantom
"stored but won't play" state.
Now preserve the caller's value and default to "true" only when it's
empty. A non-recallable preset is logged at info level so users can
tell from the service log why a stored preset isn't playing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Recents had the same protobuf-required-field hazard as presets — an
empty <source/> block inside <recent> would also abort the speaker's
/full sync (the recents poisoned-sourceproviderid regression
documented this once for a related sub-symptom). Apply the same
skip-or-synthesise filter so an orphaned recent can never take the
whole account sync down.
The synthesise/skip code paths log at info level; same visibility
posture as the preset side.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
UpdatePreset returned "invalid account/source" with a 500 when the
speaker's preset PUT referenced a source that wasn't in AfterTouch's
per-device configured-sources list. After a factory reset the speaker
locally knows the built-in radio sources but AfterTouch's Sources.xml
may not, so a long-press appeared to succeed on the speaker but the
preset was never persisted — and the next /full sync wiped the local
copy. Closes GH-314 (and the underlying trigger described in GH-253).
For the canonical built-in IDs (10001..10005) AfterTouch now auto-adds
the source from the same template post-pair would have used, then lets
the preset land. Non-canonical / account-bound IDs (Spotify "100004",
Amazon, custom) are still rejected — we can't fabricate per-account
credentials. The rejection now logs the diagnostic context so users
don't have to grep source to understand why their long-press didn't
stick.
Also accepts the Stockholm mobile app's <username> field as the preset
name when <name> is empty (soundcork documents the same divergence).
Every code path that silently repairs preset data now logs at info
level: synthesised /full source blocks, skipped presets, auto-added
canonical sources, and the Stockholm name fallback. This makes user
diagnostic dumps actionable without source-spelunking.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a preset on disk referenced a source no longer in the configured-
sources list, mapPresetsToFullResponse appended it with an empty
<source/> block. The speaker decodes /full as protobuf and treats the
inner source fields (id, type, sourceproviderid, credential) as
required, so the malformed block aborted the whole account sync and
wiped the speaker's locally stored presets — the GH-269 symptom of
"/presets empty within seconds of AfterTouch coming online".
For well-known radio providers (TuneIn, InternetRadio,
LocalInternetRadio, RadioBrowser) the preset now gets a synthesised
source block built from canonical defaults; account-bound providers
(Spotify, Amazon) are skipped with a log line so other presets in the
response survive the sync.
Also folds RADIO_BROWSER into resolveSourceName's fallback switch.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CodeQL alert 147 flagged the Phase-2 re-dial with
InsecureSkipVerify=true, used to read the served leaf after
Phase 1's strict verification failed.
The leaf is already reachable without a second connection:
tls.CertificateVerificationError carries
UnverifiedCertificates, and the three x509.* verification-
error types each carry the offending Cert. errors.As over
those covers darwin (Security.framework) and linux
(crypto/x509) consistently.
Same three classifier outcomes
(leafFromOwnCA/leafSubjectEqualsIssuer/leafForeign), same
chainContext rendering — the classifier reads only the leaf,
which is byte-identical to the Phase-2 peers[0]. Removes the
only InsecureSkipVerify literal in the tree.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CodeQL flagged the writeWAV cast of strconv.Atoi's result to
uint32 (alert 148). Two-layer defence: the handler rejects
sample-rate query params outside [8000, 192000] before parsing
ever reaches Render, and WithDefaults snaps any out-of-range
caller-supplied SampleRate back to the default before
renderChirp allocates buffers sized by it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous classifier always returned SeverityWarning when the
served leaf didn't validate against the service host's system
trust store. For AfterTouch's *default* deployment shape (its
own self-signed CA), that's the expected, healthy state — the
service host's trust store deliberately doesn't include our CA;
speakers establish trust via `setup install-ca`, not via system
roots. Reporting it as a warning misled non-technical operators
into thinking something was broken.
Rework the severity matrix:
- leafFromOwnCA (signature-verified): INFO. Message says
"AfterTouch is serving its own self-signed CA chain
(expected)". Details explain the service-host trust-store
state is by design. Manual command becomes a reminder
rather than a fix.
- leafSubjectEqualsIssuer (heuristic): INFO. Explains the
heuristic and offers both install-ca (if it is AfterTouch)
and openssl (if it isn't) as paths.
- leafForeign (genuinely unexpected): WARN. Unchanged
semantics; this is the case that actually wants attention.
- connection failure: ERROR. Unchanged.
Title renamed from "HTTPS endpoint certificate validates" (which
read as a binary assertion the finding contradicted) to
"HTTPS endpoint TLS configuration".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Separate check from service_cert_chain: that one inspects what's
served right now, this one watches when the trust anchor itself
will stop being usable. Even when the served leaf validates,
the CA's NotAfter will eventually expire every leaf it has ever
issued — and every paired speaker would then need
`setup install-ca` again with a freshly generated CA.
Three thresholds against the loaded CA's NotAfter:
> 90 days remaining → no finding (rolls up to OK)
31..90 days → INFO, surfaces the renewal date so it
isn't a surprise
1..30 days → WARNING with regeneration guidance
expired → ERROR — speakers will reject leaves
ManualCommand renders the actual cert path from
certmanager.GetCACertPath() so operators don't have to guess
where to delete. Sibling .key path inferred from the cert path
basename — close enough for a copy-paste hint; operators verify
before running.
Rounded day arithmetic via (d + 12h) / 24h to avoid the
"expires in 59 days" surprise caused by ASN.1 GeneralizedTime
truncating sub-second precision on the CreateCertificate /
ParseCertificate round-trip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit item #1 (11+ recurrences in issues / discussions): pull
speaker /info + /sources + /presets, plus service-side state
and pairing inference, into one view per device.
Backend: GET /setup/device-summary/{deviceId} probes the three
speaker endpoints concurrently (sync.WaitGroup, 3 s per probe)
and merges the result with what the datastore knows for the
same device. Partial failures don't break the response — each
sub-section carries its own reachability + error + curl_command
so the UI can render copy-paste fallbacks when the service host
can't reach the speaker.
JSON shape covers four panels:
- device identity + firmware
- speaker {info, sources, presets} with raw outcomes
- service server URL, expected hosts, Sources.xml /
Presets.xml presence and counts
- pairing paired flag, marge host, host match
UI: new "Inspect" button per row on the Devices tab. Clicking
expands a sibling row with five summary cards (info / sources /
presets / service / pairing). Each unreachable card renders the
matching curl command with a Copy button — same dual-mode
pattern as Health findings. Closes the gap operators were
filling by manually concatenating curl output across the three
speaker endpoints when filing bug reports.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The DNS sanity check passed bindAddr directly to dns.Client.Exchange.
Wildcard binds like "0.0.0.0:53", "[::]:53", or the empty
string (which the dns lib treats as default port 53 on all
interfaces) aren't actually dialable from inside the same
host — net would refuse the empty string outright, and our
finding rendered "Queried ." in the operator's UI.
resolveDNSQueryTarget now translates:
"" → 127.0.0.1:53
":53" → 127.0.0.1:53
"0.0.0.0:53" → 127.0.0.1:53
"[::]:53" → 127.0.0.1:53
"192.0.2.10:53" → unchanged
"53" → 127.0.0.1:53
"example.com" → example.com:53
The finding's Details now exposes both the configured bind and
the effective query target separately, so when queries still
fail the operator can tell whether the server simply isn't
listening on a dialable address vs. responding with the wrong IP.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors what I had in the conversation summary: parameter list
with types and defaults, the sync.Once cache behaviour for the
default-options request shape, a copy-paste curl example, and a
pointer to the renderer package + offline CLI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Subject==Issuer heuristic for "this is AfterTouch's
self-signed cert" misses the common case: AfterTouch's internal
CA has CN="SoundTouch Local Root CA" while leaves it issues have
CN="soundtouch" — different Subject and Issuer strings, so the
classifier was falling through to "foreign chain" and suggesting
openssl s_client when install-ca was actually the right fix.
Replace the heuristic with a definitive check: load AfterTouch's
own CA leaf via setup.Manager.Crypto.GetCACertPath() and call
x509.Certificate.CheckSignatureFrom(ca). When that succeeds we
*know* the leaf came from our own CA. The Subject==Issuer
heuristic stays as a fallback for environments where the CA
isn't loadable (with a clarifying note in the hint).
Server.loadOwnCACert caches the parsed CA via sync.Once so
repeated Health polls don't re-read the PEM.
Fixes the case shown in soundtouch.fritz.box deployments where
Subject=CN=soundtouch,O=AfterTouch and Issuer=CN=SoundTouch
Local Root CA,O=SoundTouch Local Service confused the
classifier.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move the ding renderer into pkg/service/ding so it can run both
at request time (from the new HandleDing handler) and offline
(from the existing scripts/gen-aftertouch-ding CLI, now a thin
wrapper around the same package).
- GET /media/aftertouch-ding.wav synthesises on first call,
caches the default-options bytes via sync.Once, and accepts
query-string overrides for every knob (pitch-{high,mid,low},
chirp-ms, gap-ms, attack-ms, release-ms, sample-rate, peak).
Invalid / out-of-range values silently fall back to defaults.
- Embedded WAV is gone from VCS — no 52 KB binary in the
repo, and tweaking the sound is now a query-param away rather
than a regenerate-and-commit cycle.
- Health-tab playback_test check is unchanged: the URL it
references (/media/aftertouch-ding.wav) keeps the same shape,
the handler just produces the bytes dynamically now.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Companion working-tree note for the Health-tab debug-utility
programme. Same status as NEXT.md and DONE.md — session-local
plan/tracking artifact, not a project document.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Standalone version of the sources-refresh trigger the
sources_xml_diff check emits opportunistically — exposed per
device regardless of whether drift was detected, since operators
also use it after manual Sources.xml edits or after running the
sources_xml_present quick fix.
Quick fix POSTs `<updates><sourcesUpdated/></updates>` to the
speaker's /notification endpoint. Manual command of equivalent
shape provided for cloud-deployed setups where the service
can't reach the speaker.
Recurring debug pattern from #175, disc #223, implied in #314.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Queries this service's own DNS server for every intercepted
Bose hostname (api.bose.com, content.api.bose.io, etc.) and
verifies the answer is the configured service IP. Catches:
- DNS subsystem disabled or unbound (speakers using us as
their resolver get NXDOMAIN).
- DNS running but answers point at a stale IP (operator
changed the LAN address without restarting).
- Subset of intercepts silently failing — emits the failing
hostname list explicitly so it's obvious which patterns are
falling through shouldIntercept.
For the mismatch case the finding includes a copyable
`nslookup … <our-dns-bind>` so operators can verify the same
behaviour from the speaker's network.
To avoid duplicating the intercept list, exports it as
`discovery.InterceptedBoseHosts` instead — same string slice
that DNSDiscovery.shouldIntercept walks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Probes http://<ip>:8090/presets for each device and counts the
returned <preset id=…> entries against the service-side
Presets.xml count. Three outcomes:
- Match: no finding.
- Speaker has 0 while service has entries: WARNING — the
post-migration / post-reset preset-loss pattern from
discussion #295 and #235.
- Counts differ otherwise: INFO with both numbers in the
message, so the operator can decide whether to sync.
Reachability / parse failures degrade to info-level findings
with a copyable curl command, matching the dual-mode pattern
the rest of the slice uses.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Recurring failure mode in issues #218 and #224: presets saved
before the May 2026 cloud shutdown still carry
content.api.bose.io/.../orion URLs in their <location>, which
the speaker fetches directly post-migration. Result: playback
silently fails because the dead host can't serve the request
and the speaker has no fallback path.
Passive filesystem scan over every device's service-side
Presets.xml; emits a warning per device listing the affected
preset slot IDs and a copyable sed snippet that strips the dead
host prefix, leaving the BMX-relative /v1/playback/... path
that this service can resolve.
No probe, no LAN access needed — purely a service-side data
check, so it's also safe to run on cloud-deployed AfterTouch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
For each known device, surface an info-level finding with a
"Play ding" quick fix and an equivalent curl command. The fix
POSTs an INTERNET_RADIO ContentItem to the speaker's /select
endpoint pointing at <serverURL>/media/aftertouch-ding.wav — the
asset committed earlier in this branch.
No external dependency (unlike TuneIn-based playback tests from
issues #94, #175, #188, #214, #218, #224, #235, #253, #262,
#272), so it works for cloud-deployed AfterTouch as long as the
speaker can reach the service URL.
Dual-mode by construction: the curl command in ManualCommands
is the same shape the server-side fix uses, so operators on
LAN-isolated setups can paste it and trigger the same playback
from a reachable host. Skipped (with an explanatory finding)
when SERVER_URL isn't configured — the speaker would have
nowhere to fetch the audio from.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cloud-deploy reports (discussion #295 et al.) repeatedly came
down to "does the speaker trust AfterTouch's cert?". Add a check
that dials the configured HTTPS endpoint, attempts validation
against the system trust store, and:
- Says nothing when the chain validates — typical for a public
CA chain (Let's Encrypt, etc.) the speaker firmware trusts
natively. No action needed.
- Warns when validation fails and surfaces the chain context:
subject, issuer, SANs, expiry, and the underlying error so
operators can copy a diagnosis into a bug report. Includes a
copyable suggestion — install-ca when the leaf looks
self-signed (Subject == Issuer heuristic), or an
`openssl s_client` invocation for unknown/foreign chains.
Reads the HTTPS URL via a closure on Server.GetSettings(), so
later restarts pick up new URLs without re-registration.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
For each device, probe /info and extract the <margeURL> the
speaker is configured to talk to. Compare the hostname against
the service's expected-hosts list (serverURL host +
httpsServerURL host + --tls-extra-host values).
When the speaker is pointed at a host AfterTouch doesn't claim,
emit a warning with two pieces of context:
- the actual <margeURL>, so the operator sees the drift
- a copyable `soundtouch-service --tls-extra-host=<host>`
suggestion, which is the right fix when the speaker should
keep talking to AfterTouch via the unexpected hostname (the
other fix is re-migration, which is mentioned in the details).
Reachability / parse failures are intentionally silent here —
speaker_info_reachable already covers those, no need to double-warn.
Required plumbing: Server.SetExpectedHosts so main.go can pass
config.domains in, plus an ExpectedHosts() getter the closure-form
registration reads at run time.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
For each device, probe http://<ip>:8090/sources and compare the
set of source types against the service-side Sources.xml. The two
documents have *different* schemas (sourceItem attributes vs.
source elements with sourceKey children), so we compare the
extracted type sets rather than diffing XML directly.
Two finding shapes:
- WARN: service advertises types the speaker doesn't have
(e.g. TUNEIN, RADIO_BROWSER missing after a factory reset).
Includes a copyable POST /notification command that triggers
a sourcesUpdated refresh without a reboot.
- INFO: speaker has types the service doesn't know about
(mostly harmless — usually AUX or BLUETOOTH-style local-only
sources). Surfaces it so operators notice managed sources
that drifted out of the service config.
Recurring debug pattern from issues #175, #195, #214, #218, #236,
disc #315.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
For every known device, probe http://<ip>:8090/info from the
service and emit findings for:
- Unreachable speakers — surfaces a copyable curl command the
operator can run from a host on the speaker's LAN.
- Speakers replying 200 but with empty <margeAccountUUID> —
the TPDA pairing-state failure mode documented in
discussion #223 ("Account ID = (empty)" in logread).
- Non-200 HTTP responses and malformed /info bodies, both as
warnings with the underlying detail in the finding.
Uses the ProbeGet helper from the previous commit; the dual-mode
fallback is the curl command emitted via ManualCommands when
server-side reach fails — appropriate when AfterTouch is hosted
off the speaker's LAN.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diagnostic checks coming next need to talk to speakers on the
LAN, which the service can't always reach — e.g. AfterTouch
hosted publicly while the operator's browser sits on the speaker
subnet. Establish the dual-mode primitive first so subsequent
checks can use it consistently:
- ProbeGet(ctx, url, timeout) issues a short-timeout GET and
always returns a CurlCommand the operator can run from a host
that can reach the target, regardless of whether the
server-side fetch succeeded.
- Finding gains an optional ManualCommands field; the admin UI
renders each as a labelled, copyable code block with a Copy
button and an optional hint line.
No new checks yet — that's the next commit. This one only adds
the primitive and the rendering path so each subsequent check is
a one-file diff.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A 600 ms two-chirp sound derived from the braille S+T pair that
makes up the AfterTouch logo. Used as the test-playback target so
operators can confirm a freshly migrated speaker actually emits
audio without depending on TuneIn or any external service.
Mapping: dot rows → pitches (A5/E5/A4), dot columns → stereo
channels. S (dots 2,3,4) renders first, then T (dots 2,3,4,5) —
audibly "S plus one more voice".
Generator under scripts/gen-aftertouch-ding regenerates the file
on demand:
go run ./scripts/gen-aftertouch-ding \
-o pkg/service/handlers/static/media/aftertouch-ding.wav
22050 Hz stereo 16-bit PCM, ~52 KB. Picked up by the existing
static/media/* embed in handlers_media.go, so it's served at
GET /media/aftertouch-ding.wav once handlers can play it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cloud-deploy operators on Discussion #295 needed to leave the
admin UI for docker logs / journalctl to see what the service was
doing. Mirror log.Default() output into an in-memory ring buffer
and expose it under /setup/logs so the admin UI can show a live
trace alongside the existing tabs.
The buffer is a second sink under log.SetOutput(io.MultiWriter(
os.Stderr, buf)) — stderr keeps receiving every line verbatim,
so docker logs / journalctl are unaffected. Default capacity
2000 lines (~400 KB), tunable via SOUNDTOUCH_LOG_BUFFER_LINES.
- pkg/service/logbuf: io.Writer ring with \n splitting,
partial-line buffering, monotonic Seq, Since(since, limit)
reporting dropped count when the caller falls behind.
- New /setup/logs (GET) returns {entries, nextSince, dropped,
capacity}. Polls at 1.5s while the tab is active; paused on
document.hidden.
- "8. Logs" tab with substring filter, tail-follow toggle
(auto-disables when the user scrolls up), monospace dark view.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Discussion #295 surfaced that a paired device without Sources.xml
silently breaks playback — /full omits TUNEIN and selection fails
with 1005. initializeDefaultSources only runs at startup over
existing devices, so a device that checks in later is never
seeded.
Add a Health tab to the admin UI that runs registered checks
against the datastore and offers one-click remediations. The
first check flags missing Sources.xml per device; its quick fix
writes the canonical defaults via SaveConfiguredSources. The
check/fix registry is designed so adding Presets.xml,
Recents.xml, or future reachability probes is a one-file diff.
- New /setup/health (GET) and /setup/health/fix (POST) routes
- pkg/service/health: Registry, Check, Finding, QuickFix types
- Sources.xml-present check + create_default_sources fix
- "7. Health" tab in pkg/service/handlers/web/
Inspired by issue #327's MAINTENANCE tab proposal; curl/URL
helper content from that issue can slot into the same tab in
a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The b95bdae split changed BmxPlayback.Href to raw `Tune.ashx?id=…` URLs,
which the speaker's BMX module fetches directly — failing `IsItBose`,
sending no auth, and getting 401 from radiotime. Restore the v0.85.0
shape (`/v1/playback/{station|episodes}/{id}`) so playback flows back
through HandleTuneInPlayback. Also restore play-link emission for Topic
search results (single podcast episodes); `Tune.ashx?id=t<N>` accepts
them like station IDs, so the same path works.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The leaf cert generator already routes IP-shaped entries into the
IPAddresses SAN, and getDomains already feeds it the hostnames parsed
from --server-url and --https-server-url. Add an explicit
--tls-extra-host flag (repeatable, env TLS_EXTRA_HOST) for the
remaining cases: multi-homed hosts, reverse-proxy frontends, or
browsing the admin UI via a LAN IP that isn't part of the configured
server URLs.
Resolves the ERR_CERT_COMMON_NAME_INVALID Chrome refuses when the URL
bar hostname (e.g. the host's LAN IP) isn't in any cert SAN, even
when the local CA is trusted.
> by, or otherwise connected to Bose Corporation.** See
> [Disclaimer](#disclaimer) for the full statement.
## Context: Cloud Shutdown
## The Bose Cloud Has Shut Down
Bose is shutting down SoundTouch cloud services on **May 6, 2026**. After that, music service browsing, preset sync, and the official SoundTouch app stop working. This toolkit lets you keep your speakers fully functional.
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/guides/SURVIVAL-GUIDE.html) for the full picture.
See the [Survival Guide](https://gesellix.github.io/Bose-SoundTouch/docs/guides/SURVIVAL-GUIDE/) for the full picture.
@@ -23,15 +25,13 @@ See the [Survival Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SURVI
A local server that replaces the Bose cloud ("AfterTouch"). Once your speaker is redirected to it, you have full control without any Bose cloud dependency. The built-in web UI at `http://localhost:8000` handles all setup — no config files needed to get started.
If you don't want to run a server for this - no problem. The service is small enough to run on the SoundTouch itself. See the [On-Device Installer](./scripts/on-device-install/README.md) for instructions.
Not sure which approach fits your situation? See the [Deployment Overview](./docs/content/docs/guides/DEPLOYMENT-OVERVIEW.md) — it compares running AfterTouch on a Raspberry Pi or other always-on host against running it directly on the SoundTouch speaker, with links to step-by-step walkthroughs for each path.
**Two scenarios:**
**Getting started:**
**Before shutdown — migrate your existing setup**
While the Bose cloud is still running, use `soundtouch-backup` to save your account data. The local service web UI then helps with the migration so your speaker keeps its presets and credentials.
**Already migrated before May 6** — your presets and credentials are preserved. AfterTouch picks up where the Bose cloud left off.
**After shutdown or factory reset — start fresh**
Create a local account, configure your speakers, and start using them immediately. No Bose infrastructure required.
**Starting fresh (or after a factory reset)** — create a local account, configure your speakers, and start using them immediately.
**Redirecting your speaker**
@@ -50,7 +50,7 @@ The web UI walks you through each method. DNS redirect requires HTTPS — the se
Some setup steps require SSH access to the speaker. Enable it once per device: create a file named `remote_services` on a FAT-formatted USB drive (the drive may need its bootable flag set — see [SoundCork issue #172](https://github.com/deborahgu/soundcork/issues/172)), and insert it while the speaker is powered on. After reboot, root SSH is available with no password.
See [Device Initial Setup](https://gesellix.github.io/Bose-SoundTouch/guides/DEVICE-INITIAL-SETUP.html) and [Migration Guide](https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-GUIDE.html) for step-by-step instructions.
See [Device Initial Setup](https://gesellix.github.io/Bose-SoundTouch/docs/guides/DEVICE-INITIAL-SETUP/) and [Migration Guide](https://gesellix.github.io/Bose-SoundTouch/docs/guides/MIGRATION-GUIDE/) for step-by-step instructions.
---
@@ -66,7 +66,7 @@ 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/guides/CLI-REFERENCE.html) for full usage.
See the [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/docs/guides/CLI-REFERENCE/) for full usage.
---
@@ -86,21 +86,21 @@ See the [soundtouch-web README](cmd/soundtouch-web/README.md) for usage.
go get github.com/gesellix/bose-soundtouch
```
See the [API Reference](https://gesellix.github.io/Bose-SoundTouch/reference/API-ENDPOINTS.html) and [pkg.go.dev](https://pkg.go.dev/github.com/gesellix/bose-soundtouch) for documentation.
See the [API Reference](https://gesellix.github.io/Bose-SoundTouch/docs/reference/API-ENDPOINTS/) and [pkg.go.dev](https://pkg.go.dev/github.com/gesellix/bose-soundtouch) for documentation.
"preflight: DNS query to %s:53 failed: %w. AfterTouch's DNS listener is unreachable or not bound to port 53. Use --skip-preflight to bypass once you've verified DNS some other way",
host,err,
)
returndnsCheckResult{detail:err.Error()}
}
iflen(ips)==0{
returnfmt.Errorf("preflight: %s:53 returned no answers for streaming.bose.com — listener may be misconfigured",host)
returndnsCheckResult{detail:"no answers for streaming.bose.com — listener may be misconfigured"}
}
returndnsCheckResult{ok:true,detail:"works"}
}
// speakerDNSCheck SSHes into the speaker and runs nslookup streaming.bose.com
// against the AfterTouch DNS server to verify reachability from the device.
Bose shut down SoundTouch cloud services on May 6, 2026. <br class="sm:hx-block hx-hidden" />AfterTouch replaces the cloud — presets, music browsing, stereo pairing, all restored.
title:"Welcome to AfterTouch: Your SoundTouch Speakers, Still Alive"
date:2026-05-24
description:"Bose shut down SoundTouch cloud services in May 2026. AfterTouch replaces everything your speakers relied on — migration, radio, Spotify, presets, and more."
tags:
- migration
- web
- spotify
- cli
sidebar:
exclude:true
---
On May 6, 2026, Bose shut down the SoundTouch cloud services that millions of speakers
depended on for account sync, presets, internet radio, and streaming. Speakers kept
working locally, but remote features stopped and first-time setup became impossible.
AfterTouch was built to change that. It is a self-hosted replacement for the Bose
cloud infrastructure — a drop-in local service that your speakers talk to instead of
`streaming.bose.com`. This post covers what works today and how to get started.
## What works right now
### Migration and first-time setup
If your speaker was registered with Bose before the shutdown, AfterTouch can **migrate
your existing account and presets** in a single step — no reconfiguration on the
speaker side. If you are setting up a factory-reset or brand-new speaker, AfterTouch
handles that path too, guiding you through Wi-Fi pairing and account creation locally.
See the [Migration Guide](../docs/guides/MIGRATION-GUIDE.md) for step-by-step instructions.
### Internet radio — TuneIn and RadioBrowser
Both **TuneIn** and **RadioBrowser** are fully supported for browsing and playback.
Navigate categories and search for stations exactly as you did with the original Bose
app. TuneIn delivers the same station catalogue; RadioBrowser provides an open,
community-maintained alternative.
### Spotify
**Spotify** works via both OAuth (account linking) and Spotify Connect (the ZeroConf
"connect to device" flow from the Spotify app). Once linked, playback and device
selection behave the same as before.
### Presets
Your six preset buttons work. AfterTouch stores preset bindings locally and serves them
back to the speaker on request. You can also **save new presets** — via the API,
via `soundtouch-cli`, or through the soundtouch-web UI.
### ST-10 stereo pairing
**SoundTouch 10 stereo pairs** (and other ST pairing configurations) are supported
end-to-end: creation, management, and playback routing all go through AfterTouch.
### soundtouch-web — browser UI
**soundtouch-web** is an early-stage but functional browser UI bundled with AfterTouch.
It gives you:
- TuneIn and RadioBrowser browsing and playback
- Speaker management and device discovery
- Recent tracks panel
- Multi-room zone management
It runs as part of the AfterTouch service — no separate install needed.

### Automation with soundtouch-cli
The **`soundtouch-cli`** command-line tool covers every speaker control: play, pause,
volume, source selection, preset recall, group management, migration, and more.
It is well-suited for home-automation scripts, cron jobs, and shell one-liners.
## Three ways to install
AfterTouch runs on any machine your speakers can reach:
1.**On the speaker itself** — install directly on supported SoundTouch hardware via
the on-device installer. The speaker hosts its own replacement cloud, with no
additional hardware required.
2.**On a local network host** — run AfterTouch on any machine on your LAN. A
**Raspberry Pi Zero 2W** handles the load without breaking a sweat, making this
path remarkably low-cost and low-power.
3.**On a cloud or VPS host** — deploy to a remote server for access outside your
home network. AfterTouch handles TLS certificate generation and DNS configuration
for this scenario.
All three paths are documented in the [Deployment Overview](../docs/guides/DEPLOYMENT-OVERVIEW.md).
## Current release
**v0.93.1** — released May 24, 2026
## Community
AfterTouch would not be where it is without the people who opened issues, tested
pre-release builds, reported edge cases, and contributed code. A significant share of
the fixes and features shipped in the lead-up to the cloud shutdown were driven by
real-world feedback from the community — from migration quirks to stereo-pair
specifics to Spotify Connect timing issues. Thank you to everyone who helped.
If you run into something or have an idea, the
[GitHub issue tracker](https://github.com/gesellix/Bose-SoundTouch/issues) and
[Discussions](https://github.com/gesellix/Bose-SoundTouch/discussions) are the
right places to start.
## What's next
The soundtouch-web UI will gain richer preset management — browsing, editing, and
reordering presets directly from the browser. Longer term, merging
`soundtouch-service` and `soundtouch-web` into a single binary is on the table,
which would simplify deployment to a single process with no extra flags.
This blog will be updated monthly — or whenever something significant ships.
Subscribe to the [GitHub releases](https://github.com/gesellix/Bose-SoundTouch/releases)
Welcome to the documentation for the Bose SoundTouch Toolkit. This comprehensive toolkit helps you keep your Bose SoundTouch speakers functional even after the Bose Cloud shutdown in May 2026, with enhanced local management and monitoring capabilities.
@@ -10,7 +16,7 @@ Welcome to the documentation for the Bose SoundTouch Toolkit. This comprehensive
### For Existing Users
- **[Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)** - Prepare for the May 2026 shutdown
- **[Backup Tool](../cmd/soundtouch-backup/README.md)** - Back up your cloud account and speaker data before shutdown
- **[Backup Tool](https://github.com/gesellix/Bose-SoundTouch/blob/main/cmd/soundtouch-backup/README.md)** - Back up your cloud account and speaker data before shutdown
- **[SoundTouch Service Guide](guides/SOUNDTOUCH-SERVICE.md)** - Advanced service configuration
## 📋 Essential Documentation
@@ -41,7 +47,7 @@ The documentation is organized into three main categories:
- [Request Recording](appendix/REQUEST_RECORDING_CONCEPT.md) — how the proxy captures live device traffic for parity testing
### Development Planning
- [Implementation Roadmap](concepts/implementation-roadmap.md) - Project phases and milestones
Older planning artefacts ("Enhanced State Management System", "Upstream Service Simulation") live under [docs/archive/](../../archive/) — kept for the record, no longer current.
## 💡 Quick Reference
@@ -89,4 +94,4 @@ The documentation is organized into three main categories:
- **Documentation**: Check troubleshooting guides first
- **Community**: Share experiences and help others
For a complete list of all documents, see the [Summary](SUMMARY.md).
For a complete list of all documents, browse the sections in the sidebar.
Intercept HTTPS/WebSocket traffic from the Bose SoundTouch Android app using an Android emulator, mitmproxy, and Frida. Tested on Apple Silicon (ARM64) Mac.
> **Goal:** Set up a Raspberry Pi as a transparent access point to fully observe the traffic of the Bose SoundTouch app – specifically the pairing flow with the Bose Cloud. This serves as a basis for later reverse engineering / simulation of the cloud endpoints.
title: "Device Redirect Methods & Custom Service Setup"
---
To enable offline operation or use custom services like **SoundCork** or **ÜberBöse API**, SoundTouch devices must be redirected from Bose's official cloud endpoints to a local or custom server. This document outlines the three known methods to achieve this, gathered from community reverse-engineering efforts in the **SoundCork** and **ÜberBöse API** projects.
> A fourth, **SSH-free** path — driving the device's diagnostic shell on TCP port 17000 — is being added as a peer to the XML and DNS methods. See **[TELNET-MIGRATION-METHOD.md](TELNET-MIGRATION-METHOD.md)** for the use cases, community findings, and feasibility analysis. The `/etc/hosts` method documented below is now deprecated and will not be exposed in the web UI.
# What a SoundTouch speaker does during factory reset
---
title: "What a SoundTouch speaker does during factory reset"
---
Observed live on ST10 firmware `27.0.6.46330.5043500` (build `epdbuild.trunk.hepdswbld04.2022-08-04`) on 2026-05-12, by running `soundtouch-cli setup factory-reset` and tailing the speaker's `logread` over SSH. The trace is preserved at `_/logs/factory-reset.txt` for reference.
This document provides a detailed analysis of the AWS IoT configuration system used by Bose SoundTouch devices, based on firmware backup analysis from ST10 and ST20 models.
To fully replace Bose cloud services for the Spotify account addition flow in the "Stockholm" SoundTouch application, the following routes have been implemented in the `soundtouch-service`:
# Experiment: Does bare `setMargeAccount` work outside the SETUP bracket?
---
title: "Experiment: Does bare `setMargeAccount` work outside the SETUP bracket?"
---
## Why we are doing this
Our captured pairing flow (`docs/reference/DEVICE-PAIRING-FLOW.md`) shows the official Bose app always sends `setMargeAccount`*inside* a `SETUP_START` → `SETUP_ENTER` → `SETUP_LEAVE` state-machine bracket over WebSocket. The question this experiment answers:
This document provides a comprehensive analysis of the `/supportedURLs` endpoint response from real Bose SoundTouch devices and compares it with our current implementation.
This document provides a comprehensive overview of the upstream Bose cloud services and domains that SoundTouch devices communicate with. These details were gathered from firmware analysis of ST10/ST20 devices, binary string extraction, and community research from the **SoundCork** project (Issue #128).
# Bose SoundTouch — Community Tools for Post-EOL Preservation
---
title: "Bose SoundTouch — Community Tools for Post-EOL Preservation"
---
> **Context:** Bose announced the shutdown of SoundTouch cloud services, extended to **May 6, 2026**. On that date the official SoundTouch app will update to a local-only version. Bose has released the [SoundTouch Web API documentation](https://assets.bosecreative.com/m/496577402d128874/original/SoundTouch-Web-API.pdf) as open-source to enable community-driven development. This document surveys the active community projects, their feature coverage, and open development opportunities.
---
@@ -233,7 +234,7 @@ A design document exists (`docs/guides/MQTT-INTEGRATION-DESIGN.md`) but no code
## soundcork ↔ AfterTouch
soundcork and AfterTouch share the most functional overlap of any two projects in the ecosystem. For the implementation-level parity analysis and remaining tasks see [docs/PARITY-SOUNDCORK.md](../PARITY-SOUNDCORK.md).
soundcork and AfterTouch share the most functional overlap of any two projects in the ecosystem. For the implementation-level parity analysis and remaining tasks see [docs/PARITY-SOUNDCORK.md](../appendix/PARITY-SOUNDCORK.md).
This document provides a complete API reference for the Bose SoundTouch navigation and station management functionality. For usage examples and workflows, see [NAVIGATION-GUIDE.md](NAVIGATION-GUIDE.md).
This document summarizes the implementation of advanced content selection features for the Bose SoundTouch Go client, including full support for the LOCAL_INTERNET_RADIO streamUrl format and LOCAL_MUSIC/STORED_MUSIC content selection.
Accessing logs from SoundTouch devices is critical for debugging custom service integrations and understanding internal device behavior. This document outlines the methods for collecting logs, as discovered by the **SoundCork** and **ÜberBöse API** communities.
## Log Types
@@ -80,7 +83,7 @@ If you have a managed switch or a router capable of port mirroring, you can use
### "IsItBose" Validation Failures
If the device fails to connect to your custom service despite correct configuration, it may be failing the internal `IsItBose` regex check.
- **Evidence**: Look for SSL handshake failures or "Unauthorized" errors in your service logs.
- **Solution**: See the [Binary Patching section in DEVICE-REDIRECT-METHODS.md](analysis/DEVICE-REDIRECT-METHODS.md#method-3-binary-patching).
- **Solution**: See the [Binary Patching section in DEVICE-REDIRECT-METHODS.md](../analysis/DEVICE-REDIRECT-METHODS.md#method-3-binary-patching).
### Disappearing Sources (TuneIn/Local Radio)
If `TUNEIN` or `LOCAL_INTERNET_RADIO` sources disappear after a reboot in an offline environment.
This document details the multi-step process required to fully set up a Bose SoundTouch device, as derived from the Stockholm firmware (`setup/js/`) analysis.
A complete setup flow involves a sequence of local (WebSocket) and cloud (HTTP) actions that move the device from a factory-reset state to a fully registered, functional system.
This document describes the automatic host:port parsing functionality added to the SoundTouch CLI, which allows users to specify both host and port in a single `-host` flag.
This document provides comprehensive guidance for manually discovering network services and devices using built-in macOS tools and command-line utilities. This is particularly useful for troubleshooting network discovery issues or understanding what services are available on your local network.
The Bose SoundTouch Go client provides comprehensive navigation and station management functionality that allows you to:
@@ -895,4 +898,4 @@ For additional help:
---
*This guide covers the complete navigation and station management functionality. For preset management, see [PRESET-MANAGEMENT.md](reference/PRESET-MANAGEMENT.md).*
*This guide covers the complete navigation and station management functionality. For preset management, see [PRESET-MANAGEMENT.md](../reference/PRESET-MANAGEMENT.md).*
### Overview of Recent Improvements and Next Steps
This document summarizes the improvements made to the **Marge service** to improve parity with the upstream Bose SoundTouch service, along with open issues and proposed next steps.
# Parity Analysis: Bose-SoundTouch (Go) vs. OpenCloudTouch (Python)
---
title: "Parity Analysis: Bose-SoundTouch (Go) vs. OpenCloudTouch (Python)"
sidebar:
exclude: true
---
This document provides a comparative analysis of the current Go implementation and the `scheilch/opencloudtouch` project, identifying functional gaps and potential improvements.
# Parity Analysis: Bose-SoundTouch (Go) vs. SoundCork (Python)
---
title: "Parity Analysis: Bose-SoundTouch (Go) vs. SoundCork (Python)"
sidebar:
exclude: true
---
This document provides a comparative analysis of the current Go implementation and the `deborahgu/soundcork` project, identifying functional gaps and potential improvements.
## 1. Core Architecture and Language
@@ -54,4 +57,4 @@ Group support and ZeroConf Spotify priming are now feature-complete in AfterTouc
- **BMX service extensibility**: the `bmx_services.json` registry makes it trivial to add or mock new streaming providers without code changes (step C above).
- **Group pairing logic**: master/slave relationship management for SoundTouch 10 stereo pairs goes beyond the CRUD AfterTouch implements.
For the broader ecosystem context (feature matrix across all community projects, AfterTouch open tasks, and cross-project observations) see [docs/analysis/bose-soundtouch-community-tools.md](analysis/bose-soundtouch-community-tools.md).
For the broader ecosystem context (feature matrix across all community projects, AfterTouch open tasks, and cross-project observations) see [docs/analysis/bose-soundtouch-community-tools.md](../analysis/bose-soundtouch-community-tools.md).
**Save your favorite music, radio stations, and playlists as 1-6 presets for instant access.**
## Overview
SoundTouch devices support 6 preset slots that can store your favorite content for instant access. This guide shows you how to manage presets using both the CLI and 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-web UI, the CLI, or the Go library.
## Via soundtouch-web (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:
### ★ Star button — save from Now Playing
1. Navigate to your speaker's detail page.
2. Play any station or track (via Radio Browser, TuneIn, or the speaker's own sources).
3. A semi-transparent **★** appears in the top-right corner of the **Now Playing** card.
4. Click the star — a slot picker **1 · 2 · 3 · 4 · 5 · 6** opens.
5. Click the target slot number. The star turns gold once the current content is saved to at least one slot.
### + button — save directly to a preset tile
While something is playing, hover over any of the six **Preset** tiles. A small **+** button appears in the tile's corner; clicking it saves the current content to that slot immediately (no picker needed).
Use the **+** when you already know which slot you want; use the **★** when you want to pick the slot after you've decided to save.
---
## Quick CLI Usage
@@ -332,11 +355,11 @@ soundtouch-cli --host 192.0.2.100 info
# Project Structure Patterns: Bose SoundTouch API Client
---
title: "Project Structure Patterns: Bose SoundTouch API Client"
sidebar:
exclude: true
---
## Summary for Reuse in API Client Projects
This document describes the most important patterns for the Bose SoundTouch API client, especially for XML-based API clients with Web UI, CLI tool, and WASM support.
The current request recording system has fundamental issues when dealing with request cloning, body consumption, and multiple response scenarios. Specifically:
This document summarizes the implementation of SCMUDC (Sound Control Management Usage Data Collection) event enrichment in the AfterTouch toolkit. The enhancement provides human-readable analysis of device telemetry data to improve usability and debugging capabilities.
This document summarizes the implementation of the `/serviceAvailability` endpoint support in the Bose SoundTouch Go client library. This feature enables applications to query which music services and input sources are available on a SoundTouch device, providing better user feedback about supported stations and sources.
# Undocumented Community Features & API Discoveries
---
title: "Undocumented Community Features & API Discoveries"
sidebar:
exclude: true
---
This document captures advanced API endpoints and device behaviors discovered by the SoundTouch community through reverse engineering projects like **SoundCork** and **ÜberBöse API**. These features are not documented in the official Bose SoundTouch Web API v1.0 but are crucial for full device emulation and offline operation.
## Cloud Emulation (Marge/BMX) Discoveries
While the local `/8090` API is well-documented, the cloud-side service emulation reveals deeper device integration points.
title: "Device Lifecycle and /power_on Enhancement"
sidebar:
exclude: true
---
## Overview
This document provides a comprehensive analysis of the current SoundTouch device registration and lifecycle management implementation, and proposes enhancements using the `/power_on` endpoint to reduce dependency on local network connectivity.
This guide provides detailed technical specifications for implementing `/power_on` endpoint enhancements to reduce network dependency and improve device lifecycle management in the SoundTouch service.
This document analyzes the feasibility and implementation approach for adding `/storePreset` functionality to the Bose SoundTouch API client, based on [GitHub Issue #14](https://github.com/gesellix/Bose-SoundTouch/issues/14) and endpoints discovered through the comprehensive [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API).
SCMUDC (Sound Control Management Usage Data Collection) events are telemetry data sent from SoundTouch devices to `events.api.bosecm.com` via `/v1/scmudc/{deviceId}` endpoints. These events track user interactions and device behaviors for analytics and monitoring.
functionality into the Go service. It is written as a reference; nothing here
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.