convert_mitm_script.py was the last tracked file carrying a real Bose
account ID (9569497) and the maintainer's test-speaker MAC
(A81B6A536A98), hardcoded as the values to redact from MITM captures.
Replaced with mitmproxy `--set` options (`account_id`, `device_id`),
defaulting to empty strings (no-op) so the tracked source no longer
contains either real value. Callers configure their own at runtime:
mitmdump -s convert_mitm_script.py \
--set out_dir=_/mitm \
--set account_id=1234567 \
--set device_id=AABBCCDDEEFF
Added a module docstring documenting the flags so the usage isn't
folded only into the loader help text.
After this commit, the tree is clean for every personal-data pattern
the audit at _/RFC-5737-cleanup/assessment.md identified. The only
remaining 192.168.1.x references live in
docs/analysis/ANONYMIZATION-SUMMARY.md as intentional doc-context
discussion of why we moved off that range.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes the docs-tier RFC-5737 rollout by sweeping the remaining
192.168.1.x references that lived outside .md / .txt / test files:
- .env.example — active PREFERRED_DEVICES default + examples
- .github/ISSUE_TEMPLATE/*.yml + workflows — issue template + CI examples
- cmd/websocket-demo/main.go, doc.go — top-level docs
- examples/*/main.go (7 files) — example program comments
- pkg/client/client.go — godoc examples
- pkg/models/doc.go — package godoc
- pkg/service/{amazon,spotify,zeroconf}/zeroconf.go — godoc comments
- pkg/service/handlers/web/index.html — placeholder text in the UI
- scripts/prepare-release.sh — example invocations
- scripts/spotify/spotify-prime-speaker.sh — usage comment
- tests/integration/http-client/http-client.env.json — fixture IPs
Same mapping as the docs commit (136d24a): 192.168.1.X → 192.0.2.X
preserving the last octet.
One semantic carve-out: the three zeroconf `zcBaseURL` godoc comments
in pkg/service/{amazon,spotify,zeroconf}/zeroconf.go switched to
192.168.10.10 instead of the doc range, because validateZcBaseURL
only accepts RFC-1918 / loopback / link-local. The comment must show
a value the validator actually accepts — see the matching test fix
in 92f66a2 for the same reason.
go build ./... clean. go test ./... clean except the pre-existing
TestDocsConsistency (untracked DEVICE-LOCAL-INSTALL.md, unrelated).
golangci-lint run ./... — 0 issues after a gofmt fix on
examples/zone-slave-operations/main.go.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the .md/.txt sweep across all tracked _test.go, testdata XML,
and .http integration files. Test files are self-contained (producer
+ assertion in the same file), so the matched-pair swap stays green
under `go test ./...`.
Mapping applied:
192.168.178.[0-9]+ → 192.0.2.[same]
192.168.1.[0-9]+ → 192.0.2.[same]
Sound Machinechen → Living Room SoundTouch
A Sound Machine → Kitchen SoundTouch
A81B6A536A98 + case/separator variants → AABBCCDDEEFF (etc.)
A81B6A849D99 → AABBCCDDEE01
A81B6A849D88 → AABBCCDDEE03
A81B6A536A09 → AABBCCDDEE04
884AEAEEBD27 → AABBCCDDEE02
3230304 → 1000001
9569497 → 1000002
Two semantic fixes alongside the bulk swap:
- pkg/service/zeroconf/zeroconf_test.go: the "private 192" and
"strips query" cases pin acceptance of RFC-1918 192.168/16. They
must use a real 192.168 value; doc-range IPs would (correctly) be
rejected by validateZcBaseURL. Switched to 192.168.10.10 — generic
enough not to match any home LAN default, real enough for the
validator. Added a comment explaining why this single test still
carries a 192.168 literal.
- pkg/service/setup/setup_test.go: TestTestDNSRedirection mocks the
device's `od -An -tu1` byte output, which is space-separated
octets ("192 168 1 100"). My sed only matched the dot-separated
form, so the mock was returning the old IP while the test
assertions had moved to the doc range. Updated to " 192 0 2 100".
go build ./... clean. go test ./... clean (only TestDocsConsistency
remains failing, which is a pre-existing/untracked-file issue).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five `192.168.1.x` references in Makefile usage-error messages and
the `make help` example block. Same hygiene argument as the docs
sweep in 136d24a — replaced with `192.0.2.x` so the example output
clearly reads as a placeholder, not a real LAN.
Behaviour unchanged: these are echo-only strings printed when the
user forgets to set HOST=… or asks for `make help`. The
HOST=<your-IP> contract is unaffected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 4 of the docs portion of the rfc-5737-cleanup. Replaces all
192.168.1.x example IPs in tracked .md / .txt files with the
equivalent last-octet under 192.0.2.x.
192.168.1.x is RFC-1918 private space and routes on real networks,
which leaves readers guessing whether a documented IP is a placeholder
or a documented LAN. 192.0.2.0/24 is reserved by RFC 5737 exclusively
for documentation — readers know on sight that they're examples.
58 files touched, 551 line pairs. Includes .github issue/PR templates,
all docs/ references, example READMEs, and one script doc. No code
changes, no test changes; test files still carry the 192.168.1.x
placeholder pending Phase 2 in _/RFC-5737-cleanup/assessment.md.
Also fixed a small fallout in docs/analysis/ANONYMIZATION-SUMMARY.md
where the explanatory sentence "a reader can't tell whether
192.168.1.10 is a placeholder or a documented LAN address" had
itself been swept by the regex (inverting the point); restored the
literal example and noted the sweep progress inline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The old file documented a single anonymisation pass and embedded the
exact historical mappings (real LAN IPs, real MACs, real account IDs
on the "Original" side of each row). Those values are sensitive even
when presented as "what we replaced" — and they're already in git
history, so reprinting them in tracked content adds nothing.
Replaced with a concise reference that:
- lists the canonical placeholders to USE in new examples and tests
(RFC-5737 IPs, AA:BB:CC:DD:EE:FF MACs, generic device names,
1000001/1000002 account IDs)
- explains why RFC-5737 instead of 192.168.1.x
- gives detection regexes that catch *any* non-placeholder value,
rather than naming the specific leaked values
180 → 65 lines net, and the file no longer contains any of the
sensitive strings it used to track.
Completes the .md / .txt portion of the rfc-5737-cleanup branch.
Test files (.go / .xml / .http) + the convert_mitm_script.py and
the broader 192.168.1.* sweep remain — separate scope per
_/RFC-5737-cleanup/assessment.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two real Bose customer account IDs were embedded in documentation
examples: 3230304 (16 files repo-wide, 5 of them .md/.txt) and
9569497 (2 files, 1 .md). Account IDs look numeric and innocuous but
they're tied to a specific Bose customer — same exposure class as
MACs and home-LAN IPs.
Mapping:
3230304 → 1000001
9569497 → 1000002
6 .md files touched in this commit. Remaining occurrences live in
test files and one Python script (scripts/convert_mitm_script.py) —
those are out-of-scope for the docs sweep and will be handled in a
dedicated test-fixtures commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The maintainer's two test-speaker MACs (A81B6A536A98 / A81B6A849D99,
plus colon-separated forms) appeared throughout documentation, runbooks,
and example READMEs. Public repo — same hygiene argument as the LAN-IP
sweep in 787c4fa.
Mapping:
A81B6A536A98 → AABBCCDDEEFF
A81B6A849D99 → AABBCCDDEE01
A8:1B:6A:53:6A:98 → AA:BB:CC:DD:EE:FF
A8:1B:6A:84:9D:99 → AA:BB:CC:DD:EE:01
The placeholders use the IANA-reserved AA:BB:CC:DD:EE:FF address that's
clearly synthetic, matching the convention the earlier anonymisation
pass had already adopted. 13 .md files touched; no tests, no code.
ANONYMIZATION-SUMMARY.md left for a dedicated rewrite commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Public-repo hygiene: docs and READMEs carried the maintainer's home
LAN range (192.168.178.x) and personal speaker names ("Sound
Machinechen", "A Sound Machine"). Swapped to RFC-5737 documentation
IPs (192.0.2.x — reserved for examples, won't collide with anyone's
real network) and generic names ("Living Room SoundTouch",
"Kitchen SoundTouch").
12 files touched, all .md / .txt documentation. No code or tests
changed in this commit; subsequent commits will address the
docs/analysis/ANONYMIZATION-SUMMARY.md mapping log and the wider
real-MAC/real-account-ID footprint surfaced by the audit at
_/RFC-5737-cleanup/assessment.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The .env.example carried real device names ("Sound Machinechen", "A
Sound Machine") and the maintainer's home-LAN IPs (192.168.178.x).
This repo is public — see CLAUDE.md "What never goes into this repo".
Swapped in:
- generic device names ("Living Room SoundTouch", "Kitchen SoundTouch")
- RFC-5737 documentation IPs (192.0.2.10 / 192.0.2.11), which are
reserved exclusively for examples and won't collide with anyone's
real network
The default active line (PREFERRED_DEVICES=…192.168.1.100…) is left
alone for now — that's a different cleanup decision (broader sweep
of 192.168.1.* still pending; see _/RFC-5737-cleanup/assessment.md).
First step on rfc-5737-cleanup. Remaining Phase 1 docs follow in
separate commits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After the proxy/mirror removal there is no proxy left in the service,
but the parallel partial-update endpoint /setup/proxy-settings stuck
around with its legacy name. It serves a legitimate purpose distinct
from the bulk /setup/settings POST: the three checkboxes
(Redact / Log Bodies / Record) use onchange-triggered live save,
while /setup/settings drives a Save-button form for dozens of fields.
Folding the two endpoints together would either lose the live-toggle
UX or send half-edited draft form data on every toggle, so the
partial-update endpoint earns its keep — it just needed the right
name.
Renamed symbols (no behaviour change):
Go handler funcs:
HandleGetProxySettings → HandleGetLoggingSettings
HandleUpdateProxySettings → HandleUpdateLoggingSettings
GetProxySettings → GetLoggingSettings
Route:
/setup/proxy-settings → /setup/logging-settings
JS:
fetchProxySettings() → fetchLoggingSettings()
updateProxySettings() → updateLoggingSettings()
HTML element IDs (cosmetic, kept consistent):
proxy-redact / proxy-log-body / proxy-record
→ logging-redact / logging-log-body / logging-record
HTML heading:
"Proxy Logging:" → "Logging:"
JSON payload shapes (request + response keys) are UNCHANGED: the
endpoint still emits / accepts {"redact", "log_body", "record"}.
Persisted Settings on disk are UNCHANGED. CLI flags are UNCHANGED.
Server struct fields redactLogs / logBodies / recordEnabled
(renamed earlier this session) are UNCHANGED.
testdata/router_routes.txt regenerated. go build clean. go test
./... clean except pre-existing TestDocsConsistency (untracked-file
issue, unrelated). golangci-lint 0 issues.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After the proxy/mirror removal, two internal Server fields kept their
historical "proxy" prefix even though no proxy code exists anymore:
- s.proxyRedact still controls recorder.Redact for sensitive-header
scrubbing (server.go:393)
- s.proxyLogBody still controls the [UNHANDLED] body preview in the
catch-all (handlers_catchall.go:14)
Both names misled — they read as proxy-related. Renamed to match the
public-facing names that have been used all along: the CLI flags are
--redact-logs / --log-bodies, the persisted Settings fields are
RedactLogs / LogBodies, and the JSON keys are redact_logs / log_bodies.
proxyRedact → redactLogs
proxyLogBody → logBodies
Also renamed the file that now contains only HandleNotFound:
pkg/service/handlers/handlers_proxy.go → handlers_catchall.go
pkg/service/handlers/handlers_proxy_test.go → handlers_catchall_test.go
git mv preserves history. NewServer's positional parameter list is
unchanged at the call site (cmd/soundtouch-service/main.go:391).
go build ./... clean. go test ./... clean except the pre-existing
TestDocsConsistency (unrelated). golangci-lint run ./... 0 issues.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The jsdiff library at pkg/service/handlers/web/js/diff.min.js (29 KB)
was loaded by the management UI to render rich diffs on the parity-
mismatch detail view. The previous two commits removed both the tab
and the JS consumer; the asset, its <script> tag, and the served-
asset test stanza were left behind.
Removes:
- pkg/service/handlers/web/js/diff.min.js (the asset itself)
- web/index.html: <script src="/web/js/diff.min.js"></script>
- handlers_media_test.go: the // 3. Test diff.min.js stanza in
TestStaticWeb, and renumbers the trailing "// 4. Test Favicon"
comment to "// 3."
No remaining Diff./jsdiff/diffChars/diffLines references in any
tracked JS or HTML. go build + TestStaticMedia + TestStaticWeb stay
green. The //go:embed pattern in handlers_media.go is web/js/*
(wildcard), so the embed bundle regenerates without the asset on
the next build with no directive edit needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Communication principles + project conventions now live in CLAUDE.md
(committed in 4c3fedd). The .junie/ dir becomes per-machine tool
config — matches how .claude/ is handled. Any .junie/guidelines.md
present locally should just point at CLAUDE.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two reasons:
1. Survives a laptop switch. The principles previously lived only in
.junie/guidelines.md; that file is per-machine tool config.
Centralising in CLAUDE.md (which IS tracked) means the rules
travel with the repo instead of with the workstation.
2. Single source of truth. Other AI assistants pointed at this repo
should defer to CLAUDE.md, not maintain their own copies that drift.
The .junie/ dir becomes a per-machine breadcrumb that points back at
CLAUDE.md, and is .gitignore'd in a separate commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings the file into version control so it survives a laptop switch.
Aim: a self-contained briefing that doesn't rely on per-machine
auto-memory or local scratch files.
Notable content:
- "How a new session should start" — concrete read order
- "Load-bearing gotchas" — the ETag header literal must stay
capitalised; rewriting to Go's canonical "Etag" breaks real speakers
(encoded in handlers_etag_test.go as caseSensitiveETag/normalizedEtag)
- "What never goes into this repo" — explicit list of data classes
that must never be committed (real IPs, MACs, account IDs, Bose
binaries, captures), since the repo is public
- Pre-push quality gate codified: golangci-lint clean before git push
- Trademark disclaimer for "SoundTouch" / "Bose"
Drops the stale ".impeccable.md" reference (no such file in the tree)
and trims the destructive-ops safety prose to the rules that actually
apply during a session.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extract a buildVersionInfo helper from HandleHealth so both endpoints
emit identical version + VCS metadata. JSON callers hitting / now get
the same release context they get from /health; under go run/test
where debug.ReadBuildInfo lacks VCS settings, version falls back to
"0.0.1" and the vcs_* keys are omitted (instead of empty strings).
The HTML branch of / is unchanged — the embedded index.html keeps its
own version-display story.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
settings.local.json carries per-user permission overrides; report.html
is a session-local artifact. Both belong outside version control,
matching how .vscode/ and .idea/ are already handled.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both files are session-local pickup-here / archive notes that have
always lived untracked in the working tree; codify the intent so they
don't keep cluttering git status.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a `soundtouch-cli source tunein` subcommand that takes a TuneIn
guide ID and routes it through the right SelectContentItem shape —
`--station`, `--episode`, `--program`, or `--id` with prefix
auto-detect. The flag picks the ContentItem Type (`stationurl` for
stations/episodes, `tracklisturl` for programs) and the location
template, then enriches the now-playing metadata from TuneIn's describe
endpoint unless `--no-lookup` is set.
Program IDs (`p<N>`) are containers, not streams. The legacy OPML
`Tune.ashx?id=p<N>` returns `#STATUS: 400`, which pre-filter went out
to the speaker verbatim. Fix in three layers:
1. `parseTuneInStreamBody` filters `#`-prefixed comment lines out of
Tune.ashx responses and errors when nothing playable remains, so
a broken TuneIn reply surfaces as a real 500 instead of corrupting
the playback response.
2. `TuneInPlaybackPodcast` expands `p<N>` to its newest episode via
`api.radiotime.com/profiles/{id}/contents` (same JSON shape as
api.tunein.com; uses the radiotime mirror so all program traffic
stays on the host already in `allowedTuneInHosts`).
3. `tuneInSearchProfile` (Program search items) and
`TuneInNavigateProfile` (program detail hero) now emit
`BmxPlayback` links, so soundtouch-web renders play buttons on
program cards and on the profile hero — clicking either plays the
latest episode via the same backend expansion.
Tests pin the parser contracts (`#STATUS: 400` filter, program-contents
episode pick) and the navigate Program-only playback emission. CLI
resolver has table-driven coverage for kind selection, prefix
auto-detect, and conflicting-flag errors.
Endpoint contract + raw probe responses captured under
`_/i226/tunein-api-findings.md` and `_/i226/tunein-probe/` for future
reference.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two of the eight CodeQL alerts on PR #313 had clean, low-cost fixes:
- go/clear-text-logging (#141, #142): the SiriusXM stub logged the
raw Authorization header value at INFO. The header carries a
long-lived bearer token (margeAuthToken) — capturing service logs
would yield replayable credentials. Switch to logging only the
boolean presence (`authPresent=%t`).
- go/bad-redirect-check (#138): the Stockholm handler's bare-path
redirect uses cfg.BasePath verbatim. basePath is operator-provided
(CLI flag / STOCKHOLM_BASE_PATH env), not request input — but a
value like "//evil.com" would still produce a scheme-relative
redirect to an external host. Reject any leading-double-slash or
embedded backslash at construction time so the redirect target
can only ever be an absolute local path.
The remaining CodeQL alerts are out of scope here:
- go/request-forgery on proxy.go (#139, #140): the /api/http-proxy
endpoint takes a user-provided url= parameter and fetches it by
design — that's the whole point of the proxy. Mitigations
already in place: isProxyLoop rejects self-references; the proxy
is only reachable under a LAN trust model.
- go/path-injection on static.go (#143, #144, #145): the
path-traversal guard in resolveStaticFile (string-prefix check
on absolute paths) is sound, but CodeQL doesn't trace it across
the function boundary. A clearer refactor to filepath.Rel might
silence the alert; deferred.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the auth-gate relaxation in a213b68. The first request in
tunein_playback_station.http (no Authorization header) previously
asserted 401 + the "Unauthorized" body markup; the gate now logs
instead of 401, so the request returns 200 with the same audio
payload the second (authorized) request gets.
Comment above the request points back to handlers_bmx.go so a future
contributor restoring the gate sees what to flip back. The
test-http-client target is what catches drift here — without this
update, CI's http-client step would fail on the first assertion.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The new HandleSiriusXMLiveAdapter and HandleSiriusXMLiveAdapterSubpath
routes were registered via r.HandleFunc (every HTTP method) at the top
level in main.go. The router-shape golden file gets one entry per
(method, path) pair, so SiriusXM adds 14 lines across CONNECT / DELETE
/ GET / HEAD / OPTIONS / PATCH / POST / PUT / TRACE.
Pure regeneration — no behaviour change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Stockholm bridge persists its native-bridge state into
`data/backend/state/native-state.json` (per pkg/service/stockholm/handler.go,
which mkdir-p's `<workspaceRoot>/backend/state/`). The directory accumulates
per-session state — auth tokens, guids, device caches — that's not
meant to be tracked alongside the source.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bmx_services.json advertises SIRIUSXM_EVEREST at
`{BMX_SERVER}/core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter`
and bmx_services_availability.json lists it as available, so speakers
that try SiriusXM hit that path. Without a route we 404'd silently
and the call was invisible in our logs.
- HandleSiriusXMLiveAdapter at the bare base URL returns the
SIRIUSXM_EVEREST service descriptor (selected by id.name from
bmx_services.json, with {BMX_SERVER}/{MEDIA_SERVER} substitution).
Mirrors deborahgu/soundcork main.py:805 in shape.
- HandleSiriusXMLiveAdapterSubpath catches every sub-path advertised
by the descriptor's _links (/availability, /token, /navigate,
/logout) plus the playback URLs the speaker discovers via navigate.
Logs the request with method+path+UA+Authorization+RawQuery, then
404s — giving the next implementation pass concrete data about
what the speaker actually asks for.
Two helpers added to handlers_bmx.go (shared with any future
BMX-segment stub):
- extractBMXService(json, name) — find a service entry by id.name.
- (*Server).applyBMXTemplate(content) — {BMX_SERVER}/{MEDIA_SERVER}
substitution, identical to what HandleBMXRegistry does inline.
Routes registered next to Orion at the top level — same convention
(no /bmx/ prefix) because bmx_services.json advertises baseUrl without
that prefix and speakers reach the path verbatim under either
migration mode.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Seven BMX adapter handlers required a non-empty `Authorization` header
and returned 401 from writeBMXUnauthorized when missing:
TuneIn: Playback, PodcastInfo, PlaybackPodcast, Report, Navigate, Search
Orion: Playback
Speakers calling these endpoints directly carry their margeAuthToken in
the header, so the gate works for them. But the Stockholm browser
proxy (pkg/service/stockholm/proxy.go injectBackendHeaders) only injects
Authorization for hosts ending in .bose.com or .apigee.net with a marge
path — when Stockholm calls back into our own service for TuneIn
browsing/playback/search/etc., no header is added and every request
401s.
Disable the gate at all seven sites; log the missing-header case so the
absence remains visible. Keep writeBMXUnauthorized as the future-restore
point (//nolint:unused) — when the gate comes back (e.g. behind a
BMX_STRICT_AUTH env-var or once the Stockholm proxy learns to inject
Authorization for our own host), callers will use this helper again.
Tests that assert 401 for missing Authorization (TestBMXUnauthorized,
TestHandleTuneInReport/Unauthorized, TestHandleTuneInNavigate/Unauthorized,
TestHandleTuneInSearch/Unauthorized) are `t.Skip`'d with a pointer back
to handlers_bmx_tunein.go — they stay in the file to come back to life
the day the gate does.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
handlers_bmx.go had grown to ~426 lines covering registry + availability +
shared helpers + TuneIn (9 handlers) + Orion (2 handlers) + our own
custom-playback adapter. The test files were already split per service
(handlers_bmx_test.go, handlers_bmx_tunein_test.go,
handlers_bmx_report_test.go) — the production code now matches that
shape.
Pure move, no logic change:
- handlers_bmx.go → BMX registry + availability + shared
helpers (writeBMXUnauthorized,
bmxServicesJSON file-level vars)
- handlers_bmx_tunein.go → all TuneIn handlers (Playback,
PodcastInfo, PlaybackPodcast, Token,
Report, Navigate, Search, Favorite,
DeleteFavorite) plus tuneInStreamFormats
helper and parseTuneInNavigatePath
- handlers_bmx_orion.go → Orion (LOCAL_INTERNET_RADIO) Token +
Playback
- handlers_bmx_custom.go → our own /custom/v1/playback adapter
(not a Bose-official BMX service —
kept distinct from Orion for clarity)
Imports are tightened per file. No public API change; tests pass the
same as before this commit.
A future iteration may extract a common BMX-service interface once 3-4
services are fully implemented. Until then, file-per-service is the
shape — see memory project_bmx_service_interface.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The /favicon.ico route was redirecting r.URL.Path to
"/media/favicon-braille.svg" and calling HandleMedia. HandleMedia
strips "/media" and serves from the embedded static/media/ subtree —
which does not contain a favicon. The actual asset lives under the
embedded web/img/ subtree (see the `web/img/favicon-braille*` embed
directive in handlers_media.go).
Repoint to "/web/img/favicon-braille.svg" + HandleWeb. http.FileServer
inside HandleWeb finds the file at its native embed path and serves
it with the right Content-Type.
Pre-existing bug exposed by Stockholm because that frontend triggers
a /favicon.ico request from every loaded page; without this fix the
browser fills the console with a 404 on every Stockholm view.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two patching gaps caused every Stockholm HTTP-proxy call from a
/stockholm/* page to hit /api/http-proxy (404) instead of the
basePath-prefixed /stockholm/api/http-proxy:
1. The proxy URL constant in browser_http_proxy.js is declared as
`var PROXY_PATH` (uppercase). Our patch script only knew about the
lowercase `var proxyPath` form used in app_comm.js, so it never
matched the upstream file.
2. Even if the constant had matched, browser_http_proxy.js's IIFE
evaluates the URL at script-load time — but the injected bootstrap
that defines window.__stockholmBase is placed just before </head>,
i.e. after the <script src=…> tags. The captured value would
always fall back to the unprefixed "/api/http-proxy".
3. The Makefile never passed browser_http_proxy.js to the patch script
at all.
Fix:
- Add an uppercase `PROXY_PATH` replacement entry in
patch-stockholm-bridge.py (keeps the lowercase one for
app_comm.js).
- Add a second replacement that rewrites the **use site** in
browser_http_proxy.js to inline `(window.__stockholmBase||"") +
"/api/http-proxy?url=" + ...`. Reading __stockholmBase at
call-time bypasses the load-order trap; the patched
`var PROXY_PATH = …` declaration above becomes dead code but
stays harmless.
- Pass `$(STOCKHOLM_DIR)/js/browser_http_proxy.js` to the patch
script in the prepare-stockholm target so it actually gets
rewritten.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "Enabling the Stockholm UI" section listed the binary/env-var/Docker
forms but not the new dev-service-stockholm make target — which is the
shortest path through the local roundtrip and the one most contributors
will want.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Compresses the local roundtrip to a single command:
make build-stockholm-image # one-time
make prepare-stockholm # once per zip update
make dev-service-stockholm # iterative loop
The target only checks that prepare-stockholm has produced
stockholm/index.html (a fast file stat) — it deliberately does NOT
re-run the Docker preparation step on every launch, since that takes
tens of seconds and produces identical output most of the time. Fails
loudly with a hint if Stockholm isn't prepared.
Listed in `make help` under the existing dev-* group. Not added to
.PHONY because the surrounding dev-service / dev-service-proxy targets
aren't either — matching local convention rather than gold-plating.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two user-facing additions modelled on the streborn project's README:
- **Disclaimer section in README.** Stronger Bose-trademark clause,
explicit "not affiliated, endorsed, sponsored, or connected"
statement, and the EU 2009/24/EC Art. 6 interoperability clause
with a stable EUR-Lex hyperlink. Adds a Stockholm-specific
sentence: users supply the Stockholm web-app sources themselves,
no Bose code is redistributed in this repo.
- **Ways to Contribute / Support the project in README and
CONTRIBUTING.** Itemises the contribution categories users
actually have (code, docs, bug reports, donations) and adds the
GitHub Sponsors badge for gesellix. Sponsorship is explicitly
optional and licensing-neutral.
The thin "Not affiliated" line at the top of the README now points at
the full Disclaimer section rather than carrying the whole statement.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The port guide was written when only v1 and v2 existed; today the
upstream krahl/soundcork-stockholm-app ships v1..v4. The Go code path
already scans dynamically (no hardcoded version list), so future
versions get picked up without code changes — only the documentation
was stale.
Update three spots:
- The patch-application section now notes the dynamic scan and lists
the four current versions with one-line summaries.
- The shell instructions for a plain-process install use a for-loop
over stockholm-changes_v*.patch instead of hardcoding v1 and v2.
- The "Patches summary" appendix gains v3 (now_play.js guard) and
v4 (app_comm.js clientId polish).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Stockholm "kilo" constant (a7928d7b43dcd49f0af31e5aeed26458) was
duplicated as a string literal in bridge.go and state.go. To a future
reader the hex blob can read like a leaked secret, which it is not —
it's a published default carried over from the upstream
krahl/soundcork-stockholm-app project (BackendApplication.java). The
Stockholm JS expects exactly this value via getConstant("kilo") when
nothing else has stored a different one.
Promote to a named const in util.go with the explanation, and reference
it from both call sites. Tests keep the literal so they continue to
catch any accidental change to the wire value.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
setupRouter gained a *stockholm.Handler parameter on this branch, but
the test left over from the previous signature still called it with
one argument, breaking `go vet ./...`. Pass nil — Stockholm is opt-in
and not exercised in this test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements pkg/service/stockholm with bridge (appSend/runQueue), HTTP
proxy, static serving, config URL rewriting, native state persistence,
and device discovery. Mounts under a configurable base path (/stockholm
by default) with correct http.StripPrefix routing and apiBase-prefixed
bridge API routes matching the patched JS window.__stockholmBase calls.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Dockerfile.stockholm clones github.com/krahl/soundcork-stockholm-app at build
time and installs the required tools (prettier, patch, unzip, jq). No pre-built
image is published upstream, so users must run `make build-stockholm-image` once
before `make prepare-stockholm`.
`make prepare-stockholm` runs the upstream entrypoint logic (extract zip,
run prettier, apply patches) via a volume-mounted docker run, stopping before
`exec java` so we only collect the processed stockholm/ output. The Go service
then serves that directory directly with no patching required at runtime.
Prerequisites: Docker with internet access, and stockholm_zip/stockholm.zip
(Stockholm source zip placed manually — tracked directory, zip gitignored).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The ST10's /presets response after a factory reset emits self-closing
<preset/> entries with no ContentItem child. cmd/soundtouch-cli's
getPresets() handled the missing ContentItem in GetDisplayName() but
then dereferenced preset.ContentItem.Source on the next line, panicking
with "invalid memory address or nil pointer dereference" the moment the
loop reached the first empty entry.
A second placeholder shape was observed on healthy devices that were
never reset: <preset id="0"><ContentItem source="INVALID_SOURCE"
isPresetable="true"/></preset>. ContentItem is non-nil here, so the
previous "ContentItem != nil" guard at other call sites still let
these placeholders through into listings and into the AfterTouch
datastore.
Fix shape:
pkg/models/presets.go - extend Preset.IsEmpty() to recognise both
shapes (ContentItem == nil, OR Source == "" / "INVALID_SOURCE").
HasPresets, GetEmptyPresetSlots and GetUsedPresetSlots become honest
about which slots actually carry playable content.
cmd/soundtouch-cli/cmd_info.go (the crash site) - filter the slice
via IsEmpty before the print loop, and switch the still-printed
fields to the existing nil-safe Get* helpers.
pkg/service/setup/setup.go - upgrade syncPresets's "ContentItem ==
nil" continue-guard to IsEmpty so Shape B placeholders don't get
persisted in the AfterTouch datastore and then surface as junk
rows in the admin web UI.
cmd/soundtouch-cli/cmd_events.go, cmd/websocket-demo/main.go - same
nil-guard upgrade. These already nil-checked so were crash-safe;
the change is for consistency and to stop printing
"Preset 0: (INVALID_SOURCE)" demo lines.
examples/preset-management/main.go - had the same latent crash as
cmd_info.go; same fix shape.
Regression tests in pkg/models/presets_test.go cover both shapes using
the exact XML observed in the wild: the reporter's three <preset/>
placeholders plus the three INVALID_SOURCE entries from a live device.
The reporter XML test walks every preset through the same accessor
path the CLI used and asserts no panic.
The soundtouch-web Go code does not deref preset.ContentItem.X
anywhere - presets flow through as JSON - so no separate crash trap
exists there. The web frontend will pick up the cleaner data once
syncPresets stops persisting placeholders.
Closes#308
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Status was a value-typed DeviceStatus field on DeviceConnection,
written from the periodic poller (UpdateDeviceStatus) and from four
WebSocket event handlers (OnNowPlaying, OnVolumeUpdated,
OnConnectionState, OnPresetUpdated) while being read from every HTTP
handler and the WebSocket broadcaster. The struct was 8+ words wide
with time.Time and string members, so concurrent readers could
observe torn fields or mixed-update snapshots. The map-level race was
fixed in the previous commit; this one closes the per-connection
struct race.
Hide the field behind atomic.Pointer[DeviceStatus]:
Status() // returns current snapshot
SetStatus(*DeviceStatus) // wholesale replace
UpdateStatus(func(*DeviceStatus)) // CAS retry loop
NewDeviceConnection constructs a connection with the atomic pointer
pre-initialised, so Status() never returns nil for callers that go
through the constructor (the old struct-literal pattern is no longer
possible because the status field is now private).
UpdateDeviceStatus runs network fetches into local vars first, then
batches them into a single UpdateStatus call so the CAS loop only
retries the merge — not the slow IO. WebSocket event handlers and
the connect/disconnect transitions each use UpdateStatus, so any
ordering of poller + event delivery converges to a consistent
status.
The UpdateStatus docstring is explicit about the shallow-copy
contract: nested pointer fields (NowPlaying, Volume, Bass, Presets,
Sources) MUST be replaced, not mutated through, because the copy
mut receives shares those pointers with the prior snapshot. All
production callers already follow this pattern (every value comes
fresh from the device API).
Tests:
- types_test.go: migrated literal struct to NewDeviceConnection +
SetStatus, switched reads to Status().
- status_test.go (new): six tests covering constructor init,
SetStatus replacement semantics, UpdateStatus mutator
application, field preservation across UpdateStatus, snapshot
isolation (old snapshot stable under later writes), and a
concurrent stress test (16 writers + 32 readers x 200 ops) that
runs under -race.
- handlers_test.go, registry_test.go, spa_test.go: migrated to
constructor.
Not addressed by this commit:
- DeviceConnection.WebSocket (set once in ConnectDeviceWebSocket,
read elsewhere). Word-sized pointer, atomic at the hardware
level on amd64/arm64; race detector may still flag.
- DeviceConnection.LastSeen (written under devicesMu by the
registry, read outside that lock via DeviceSnapshot consumers).
time.Time is non-atomic but the read is cosmetic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Devices map on WebApp was written from the startup goroutine, the
/api/discover POST handler, and addDevice, while being read from every
HTTP handler and the WebSocket periodic-update loop — all without any
mutex. The Go runtime panics with "fatal error: concurrent map writes"
or "concurrent map read and map write" on any actual collision, so this
was a latent crash, not a tearing issue.
Hide the map behind a sync.RWMutex and a small API:
GetDevice(id) (*DeviceConnection, bool)
DeviceSnapshot() []DeviceEntry
DeviceCount() int
AddDevice(id, conn) bool // atomic insert-or-touch
TouchDevice(id) bool // fast-path LastSeen bump
Update every caller — handlers, websocket, main, tests — to go through
the API. addDevice's existing-host fast path uses TouchDevice; the
final insert uses AddDevice so a race with another writer is rejected
cleanly instead of silently overwriting.
Add a TestRegistryConcurrent stress test that runs 64 goroutines doing
12,800 operations across writers, touchers, and two reader patterns.
It exists to give `-race` (already on in CI) a concrete shape to catch
if the encapsulation ever leaks back out.
Struct-field races on conn.Status.* are not addressed by this change;
they need their own follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`addManualDevice` and the per-device branch of `discoverDevices` were
~40 lines of near-identical client setup, info fetch, connection
build, and map write — differing only in log wording. Extract a
shared `addDevice(app, host, port, source)` helper used by both
paths.
Side effects of consolidating:
- Duplicate-host guard (LastSeen bump) now applies to both paths, so
passing `--devices 1.2.3.4` twice is idempotent and matches how
discovery treats repeat sightings.
- Map write happens before the UpdateDeviceStatus goroutine launch,
so a concurrent GET /api/devices sees the device with
`IsConnected: false` instead of racing the status update.
- Log wording is consistent: "Failed to fetch device info from <host>
(<source>): <err>" and "Added <source> device <name> (<type>) at
<host>:<port>".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add three new entries to docs/guides/TROUBLESHOOTING.md so the next
reporter who hits these symptoms finds the answer without needing the
issue thread.
- "Every cloud source shows status=UNAVAILABLE / can't stream anything"
(Connection Issues). Three-step diagnostic checklist: :443
reachability preflight, margeAccountUUID check, filtered
`logread -f`. Distilled from the diagnostic ping on #224 plus
Thatboioofy's resolution (missing margeAccountUUID was the cause).
Sidebar clarifies that the firmware-internal placeholder sources
(SpotifyConnectUserName, SpotifyAlexaUserName, UPnPUserName,
StoredMusicUserName, QPlay{1,2}UserName, AirPlay2DefaultUserName)
are speaker-synthesized and their UNAVAILABLE status is never an
AfterTouch problem on its own.
- New section "Music Service & Preset Issues" with "Spotify preset
fails with 'Current content cannot be saved as preset'". Explains
the firmware-side isPresetable="false" gate on Connect-pushed
playback (foob61451's NowPlaying capture in #235), why an
OAuth-linked account flips it to true, and cross-links to
MUSIC-SERVICES.md and the new spotify-overview.md.
- "TuneIn (or Internet Radio) missing from /sources after a factory
reset". TuneIn is not a default source; the speaker only registers
it after first play. Captured from the #253 side-thread with both
app and `soundtouch-cli source content` recipes plus the
no-SSH caveat for newer hardware (SA-5).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
spotify-oauth.md (and any future docs) embed mermaid sequence/flow
diagrams as fenced code blocks. Kramdown emits those as
<pre><code class="language-mermaid">, which is not what Mermaid's
auto-renderer looks for, so on the rendered site they show up as raw
code instead of diagrams.
Add docs/_includes/head-custom.html (a hook the pages-themes/minimal
remote theme already exposes) to load Mermaid 11 as an ES module from
jsDelivr, rewrite pre/code.language-mermaid nodes into div.mermaid, and
call mermaid.run() once.
No Jekyll plugin or _config.yml change needed — the include slot is
honoured by the remote theme as-is.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>